Render an equirectangular planet map into a 2D orthographic view with optional SPICE-driven sunlit geometry.
Project description
implanet
Render a planet's equirectangular map into a 2D orthographic view from any viewing direction, with optional SPICE-driven sun lighting and a publication-style matplotlib figure layer.
+---------- matplotlib figure -----------+
| white bg · dashed graticule · ticks |
| |
equirectangular → | rendered disk on axes |
texture (2:1) | [-1,+1] planet radii |
| |
| sub-obs (lat, lon) · sub-solar |
+----------------------------------------+
↑
SPICE (spiceypy) → sun_direction
Install
pip install implanet # everything — one command, no extras
That single install pulls in numpy, Pillow, spiceypy and matplotlib,
so rendering, SPICE ephemerides, and the figure layer all work out of
the box. (Developing the package: pip install -e .[test] adds pytest.)
Python ≥ 3.8. One console script is installed:
implanet-fetch [--list|--cite|--body …] # bulk-download / inspect maps
Everything else is a Python API — import implanet and call the
functions directly.
Assets are not bundled — textures and SPICE kernels download on first
use. By default they live with the package
(site-packages/implanet/_data/{maps,kernels}); in a dev checkout the
repo's maps/data/ and kernels/ are reused instead. Override with
IMPLANET_CACHE=/some/dir (or IMPLANET_MAPS / IMPLANET_KERNELS for
fine control). The first ensure_kernels() pulls ~32 MB of generic NAIF
kernels; individual textures download as you request them.
Quick start
from PIL import Image
from implanet import render_disk
# Bare body name → routed through get_texture() (downloads on first
# use). String view preset → camera on the named axis; "yz" is the
# classic prime-meridian equator view.
img = render_disk(
"Earth", # body name | path | PIL.Image | ndarray
view_direction="yz", # or a 3-vector like (-1, 0, 0)
sun_direction=(1, 0.5, 0.4), # planet → Sun
size=600,
)
Image.fromarray(img).save("earth.png")
# Or plot it yourself — the disk occupies [-1, +1] in planet radii, with
# a small `margin` cushion around it (default 1.05):
# ax.imshow(img, extent=(-1.05, 1.05, -1.05, 1.05))
# ax.set_aspect("equal")
For an even shorter path to a finished plot, plot_disk composes
render_disk with the package overlays (limb, graticule, terminator,
sub-observer marker) directly into a matplotlib axes:
from implanet import plot_disk
fig, ax = plot_disk("Mars", view_direction="yz",
sun_direction=(1, 0.4, 0.2))
fig.savefig("mars.png", dpi=150, bbox_inches="tight")
Result — a 600×600 RGB PNG, half-lit Earth with the terminator running through the middle:
get_texture(body, variant=None) picks the body's default map; pass a
variant for a specific one, e.g. get_texture("Earth", "natural_earth3").
Need a figure caption with the texture credit and the camera/sun
geometry? render_info mirrors render_disk's signature and returns
a structured dict plus a one-line caption:
from implanet import render_info
info = render_info(get_texture("Mars"),
view_direction=(-1, 0, 0),
sun_direction=(1, 0, 0.3))
print(info["caption"])
Result:
Mars / sss · sub-obs 0°N 0°E · sun (1.00, 0.00, 0.30) · Solar System Scope (CC BY 4.0). Underlying data: NASA MGS MOLA team; Viking Orbiter; USGS Astrogeology.
The dict also carries texture (body / variant / mission / citation /
license — only populated when the texture path is in the manifest),
camera (sub-observer lat/lon), sun (sub-solar lat/lon, ambient),
and output (size / margin / lon0). For this call:
info["texture"]["body"] # 'Mars'
info["texture"]["variant"] # 'sss'
info["camera"]["sub_observer_lat_deg"], info["camera"]["sub_observer_lon_deg"] # (0.0, 0.0)
info["sun"]["sub_solar_lat_deg"], info["sun"]["sub_solar_lon_deg"] # (16.7, 0.0)
With real ephemerides — SPICE drives the sun direction and the
sub-solar point, you compose a view_direction around it, and
render_disk does the rest:
import math
from PIL import Image
from implanet import render_disk, sun_direction, sub_solar_point, get_texture
utc = "2026-05-14T12:00:00"
sun = sun_direction("Mars", utc)
lat, lon = sub_solar_point("Mars", utc)
# camera 30° west of the sub-solar point → day side with a visible terminator
lon_cam = math.radians(lon - 30)
lat_cam = math.radians(lat)
view = (-math.cos(lat_cam)*math.cos(lon_cam),
-math.cos(lat_cam)*math.sin(lon_cam),
-math.sin(lat_cam))
img = render_disk(get_texture("Mars"),
view_direction=view, sun_direction=sun, size=600)
Image.fromarray(img).save("mars.png")
Result — Mars at 2026-05-14T12:00:00 UTC. SPICE puts the sub-solar
point at (−24.6°, −160.2°) on that date; the camera is 30° west of
that, so the terminator slices across the right side of the disk.
Examples
A small curated showcase of implanet output. Each figure is
regenerable from a script in examples/; the full output trees there
are git-ignored, only this hand-picked set is committed under
figures/.
|
Every body, one view — every body's default texture as a transparent RGBA disk on an exact [-1,1] grid (equator view). Built by examples/transparent_disks.py.
|
SPICE-driven illumination — fully sunlit Earth at 2026-04-03T00:27:39 UTC (Pacific facing the Sun);
sub-solar = sub-observer.
Built by examples/earth_dayside.py.
|
|
Natural Earth III variant — the more vivid Earth texture option ( get_texture("Earth", "natural_earth3")).
Built by examples/earth_dayside.py.
|
Synthetic day/night reference — built locally (no download) to verify viewing geometry / lighting / sub-solar lookups. Built by examples/daynight_reference.py.
|
A full index (with regenerate commands) lives at
figures/README.md.
Texture catalog
What you actually get from get_texture(body) — one shaded
orthographic disk per body's default variant, same lighting for all.
Regenerate with python examples/texture_gallery.py.
Per-body variant comparisons
Several bodies have multiple catalogued textures — different missions,
different processing, day vs. night. Each comparison below renders
every auto-fetchable variant under identical lighting and a fixed
camera. Regenerate any of these with
python examples/variant_comparison.py (which writes one PNG per
multi-variant body to examples/figures_gallery/).
|
Mercury (3 variants) Default sss (Solar System Scope colour),
plus the B&W messenger_bdr_mono BDR basemap and the
pseudo-color messenger_enhanced_color.
|
Venus (2 variants) Cloud-top UV ( sss_atmosphere, default) vs. the
Magellan SAR surface mosaic (sss_surface).
|
|
Earth (6 variants) Blue Marble at two resolutions, the Solar System Scope cloud / day / night composites, and the vivid Natural Earth III. |
Moon (4 variants) Clementine UVVIS (default), two LROC color composites, and the Solar System Scope re-processing. |
|
Mars (2 auto-fetchable variants) Solar System Scope (default) vs. the Viking MDIM 2.1 1-km mosaic. HRSC / Tianwen-1 are manual-only; the 12-GB Viking full-res is skipped by the gallery script. |
Jupiter (2 variants) Cassini ISS PIA07782 (default) vs. the Solar System Scope composite. |
Transparent disk views
Ready-to-grab transparent RGBA disks — every body from five
illumination FOVs (sun, antisun, terminator, north_pole,
south_pole), on an exact [-1, 1] grid with alpha=0 off-disk. The
full set (20 bodies × 5 views = 100 PNGs) lives in
figures/disk_views/ — see its
gallery README. Regenerate with
python examples/disk_views.py. Earth, all five views:
Conventions
All vectors live in the body-fixed IAU frame of the rendered body:
- +Z — rotation axis (north pole)
- +X — prime meridian at the equator
- +Y — 90° east longitude
- right-handed
Two vector inputs flip readers up most:
| Argument | Convention |
|---|---|
view_direction |
camera → planet center |
sun_direction |
planet → Sun |
The texture is equirectangular (2:1 aspect, lon spans 2π, lat spans π,
row 0 = north pole). lon0 shifts the texture's left edge in radians:
lon0=-π(default) — texture column 0 sits at lon = −180°lon0=0— texture column 0 sits at the prime meridian
view_direction accepts string presets (case-insensitive, leading +
optional) in addition to 3-vectors. Each preset positions the camera on
the named axis with a sensible in-plane up, so polar views just work:
| Preset | Camera on | Sub-observer | Same as |
|---|---|---|---|
"x", "yz" |
+X | lon = 0° (prime meridian) | view=(-1, 0, 0) |
"-x", "-yz" |
-X | lon = 180° | view=(1, 0, 0) |
"y", "-xz" |
+Y | lon = +90°E | view=(0, -1, 0) |
"-y", "xz" |
-Y | lon = -90°E | view=(0, 1, 0) |
"z", "xy" |
+Z | north pole | view=(0, 0, -1), up=(0, 1, 0) |
"-z", "-xy" |
-Z | south pole | view=(0, 0, 1), up=(0, 1, 0) |
Use implanet.resolve_view(name) if you want the explicit
(view_direction, up) pair the preset would produce.
How rendering works
render_disk does orthographic projection of a textured unit sphere
and (optionally) Lambertian shading. All steps are fully vectorized in
NumPy — there are no per-pixel Python loops.
equirectangular camera basis visible hemisphere
texture T(λ, φ) ─→ (right, up, ─→ of unit sphere
(H, W, C) forward) S² ∩ {P·forward ≤ 0}
│
▼
image pixel (u, v)
─→ 3D point P
─→ (lat, lon)
─→ bilinear sample T
─→ optional shade × albedo
1. Camera basis
camera_basis(view_direction, up=(0,0,1)) builds an orthonormal
triplet (right, up, forward) in three lines:
forward = normalize(view_direction) # camera → planet center
right = normalize(cross(forward, up_hint)) # in-image horizontal
up_axis = cross(right, forward) # in-image vertical
| axis | direction | fixed by |
|---|---|---|
forward |
camera optical axis | view_direction |
right |
image-plane horizontal (points right on screen) | plane containing forward + up_hint |
up_axis |
image-plane vertical (points up on screen) | enforced perpendicular to both |
The camera sits at −∞·forward (orthographic limit), so what reaches
the image plane is a parallel projection of the visible hemisphere
onto the (right, up_axis) plane.
How roll is determined. A camera has three orientation DOFs: yaw,
pitch, roll. view_direction fixes the first two (the direction the
camera looks). The third — rotation about the optical axis — is the
roll, and it's not an explicit parameter. Instead, the construction
above does a Gram-Schmidt on the up hint against forward and uses
the result as image-up; that's the "look-at" convention used by
gluLookAt and most game/graphics libraries.
With the default up=(0, 0, 1) (the body's rotation axis), up_axis
is the projection of the north pole onto the image plane, so every
render is "north-up" by construction. That's the implicit roll choice.
If up_hint is parallel to forward (looking straight down a pole)
the cross product collapses and camera_basis raises ValueError —
for polar views you must pass a non-vertical up, e.g.
up=(1, 0, 0) to send the prime meridian to image-up.
To pick an explicit roll θ about the optical axis, pre-rotate up
about forward by θ before passing it in (Rodrigues):
import math, numpy as np
f = np.asarray(view_direction, float); f /= np.linalg.norm(f)
k = np.array([0.0, 0.0, 1.0])
up = (k*math.cos(θ) + np.cross(f, k)*math.sin(θ)
+ f*np.dot(f, k)*(1 - math.cos(θ)))
camera_basis(view_direction, up=up)
In practice the default is what almost every scientific figure wants — sub-Earth views of planets are conventionally north-up.
Code: camera_basis() in projection.py.
2. Pixel → 3D point on the visible hemisphere
Output image pixels (px, py) map to normalized image-plane coordinates
u = (px − cx) / R v = −(py − cy) / R
where R is the disk radius in pixels (min(H, W) / 2 / margin).
Pixels with u² + v² > 1 fall outside the disk and are filled with
background. For the rest, the point on the near hemisphere of the
unit sphere is
z = √(1 − u² − v²)
P = u·right + v·up − z·forward (world coords, body-fixed)
The near hemisphere is the one with P·forward ≤ 0 — the side facing
the camera. Code: orthographic_rays().
3. Sphere → texture coordinates
For each surface point P = (Px, Py, Pz) on the unit sphere,
lat = arcsin(Pz) ∈ [−π/2, π/2]
lon = atan2(Py, Px) ∈ [−π, π]
mapped to the texture's normalized coordinates
u_tex = ((lon − lon0) / 2π) mod 1
v_tex = ½ − lat / π
The mod 1 in u handles longitude wrap-around at the seam; v clamps at
the poles. lon0 lets you shift textures whose column 0 sits at the
prime meridian instead of −180°. Code: sphere_to_uv().
4. Bilinear sampling with seam-correct wrap
The four neighboring texels around (u_tex·W − ½, v_tex·H − ½) are
fetched with wrap-around in u and clamp in v, then mixed with
fractional weights. Wrapping is what keeps a meridian line continuous
at the texture's left/right seam; clamping prevents bogus reads above
the north pole / below the south. Code: _sample_bilinear() in
render.py.
5. Optional Lambertian shading
If sun_direction s (a unit vector, planet → Sun) is given, the
local surface normal on a unit sphere is just P itself, so the cosine
of the solar incidence angle is
cos i = max(0, P · s)
The pixel color is then multiplied by
shade = ambient + (1 − ambient) · cos i
with ambient ∈ [0, 1] setting the floor on the night side. Setting
ambient = 1.0 disables shading (used for the Sun and for SAR mosaics
like Venus, whose pixel values already encode reflectance).
6. Compositing
Off-disk pixels are replaced with background, the array is clipped
to [0, 255] and cast to uint8. Grayscale, RGB, and RGBA all flow
through the same path; the output mode is inferred from the input
channel count.
Cost
The work is dominated by the H × W bilinear gather, which is a
constant 4 lookups per pixel. A 720×720 render of an 8K texture takes
~50 ms on this machine; SPICE-driven calls (sun_direction,
sub_solar_point) add a few ms each after kernels are cached.
API
Layer 1 — Rendering
image = render_disk(
texture, # body name | str/Path | PIL.Image | ndarray
# "Mars" → routes through get_texture()
view_direction=(1, 0, 0), # 3-vector OR a preset like "yz", "xy", "-z"
up=None, # None → preset's own up, else (0, 0, 1)
size=512, # int or (h, w)
margin=1.05, # 1.0 = disk touches the shorter edge
lon0=-math.pi,
sun_direction=None, # None → flat albedo (no shading)
ambient=0.15, # [0, 1]; floor on Lambertian shading
background=(255, 255, 255), # RGB 0-255 *or* a matplotlib color
# string: "white", "#1f77b4", "0.25"
)
# image: uint8 ndarray (H, W) or (H, W, C); row 0 = top.
# The disk occupies [-1, +1] in planet radii on both axes.
# → Save: Image.fromarray(image).save(...)
# → Plot: ax.imshow(image, extent=(-margin, margin, -margin, margin))
# ax.set_aspect("equal")
output = render_flatmap(
texture,
rotation_lon_deg=0.0, # rolls the body's spin phase
sun_direction=None, # None → no shading
ambient=0.15,
lon0=-math.pi,
output_size=None, # (h, w) or None (= matches texture)
return_array=False, # True → ndarray, False → PIL.Image
)
# Produces a full 2:1 equirectangular re-render with optional spin +
# Lambertian shading. Pair with `flatmap_terminator()` to overlay the
# day-night line in lon/lat space.
info = render_info(
texture, view_direction=(1, 0, 0), up=None,
size=512, margin=1.05, lon0=-math.pi,
sun_direction=None, ambient=0.15,
)
# Same signature as render_disk (minus background). Body-name strings
# and view-direction presets work the same way. Returns a dict:
# texture → {body, variant, mission, citation, license, …}
# (populated when texture is a body name, a path, or a PIL
# image whose filename is catalogued in manifest.json)
# camera → {view_direction, up, sub_observer_lat_deg, …_lon_deg}
# sun → {sun_direction, sub_solar_lat_deg, …, ambient} or None
# output → {size, margin, lon0}
# caption → one-line string ready for a figure caption / title
fig, ax = plot_disk(
texture, # body name, path, PIL.Image, or ndarray
view_direction="yz", # any 3-vector or preset; defaults to "yz"
up=None, # preset's up, or (0, 0, 1)
sun_direction=None, ambient=0.15,
size=512, margin=1.05, lon0=-math.pi,
background="white",
ax=None, figsize=(5.5, 5.5), dpi=120, title=None,
show_graticule=True, graticule_step_deg=30,
show_limb=True, show_terminator=True, show_subobserver=True,
show_axes=False, # True → planet-radii ticks like a paper plate
# *_kwargs dicts customise each overlay's matplotlib style.
)
# Composes render_disk + the overlay drawers (limb, graticule,
# terminator if sun_direction is given, sub-observer cross) into a
# matplotlib axes. Returns (fig, ax) — equivalent to writing the
# imshow + plot calls by hand, but six lines shorter.
Layer 2 — Geometry primitives
Used internally by render_disk, exposed if you need to build your own
pipeline.
resolve_view(view, up=None) # preset name or 3-vec
# → (view_direction, up)
camera_basis(view_direction, up=(0,0,1)) # → (right, up, forward)
orthographic_rays(size, right, up, forward, margin=1.0)
# → (HxWx3 points, HxW mask)
sphere_to_uv(points, lon0=0.0) # → (u, v) in [0, 1]
Layer 3 — Overlays (matplotlib-friendly)
Every overlay returns plain x/y arrays in unit-disk coordinates (the
visible hemisphere, u²+v² ≤ 1) so you can ax.plot(x, y) them directly
onto a rendered disk — no Nx2 unpacking, no matplotlib dependency in the
overlay layer itself.
graticule_segments(view_direction, up=(0,0,1),
lat_step_deg=30, lon_step_deg=30,
include_poles=True, samples_per_line=361)
# → {"parallels": (xs, ys), "meridians": (xs, ys)}
# xs, ys are parallel LISTS of 1-D arrays — one polyline per line:
# for x, y in zip(*g["parallels"]): ax.plot(x, y)
limb_circle(samples=360) # → (x, y) two 1-D arrays
subobserver_point(view_direction, up=(0,0,1)) # → (lat_deg, lon_deg) floats
disk_terminator(view_direction, sun_direction, up=(0,0,1), samples=361)
# → (xs, ys) parallel lists of 1-D arrays: the projected great
# circle {P : P · sun_unit = 0}, clipped at the limb.
flatmap_terminator(sun_direction, rotation_lon_deg=0.0, samples=721)
# → (xs, ys) lon/lat-space terminator for render_flatmap output
Layer 4 — Ephemeris (optional)
from implanet import (
ensure_kernels, sun_direction, sub_solar_point,
view_direction_from_earth, known_ephemeris_bodies,
)
ensure_kernels() # one-time ~32 MB download
sun_direction(body, utc, abcorr="LT") # → unit 3-vec in IAU_<body>
sub_solar_point(body, utc, abcorr="LT") # → (lat_deg, lon_deg)
view_direction_from_earth(body, utc, abcorr="LT") # → unit 3-vec in IAU_<body>
known_ephemeris_bodies() # list[str]
body is a name like "Mars". utc is any SPICE-parseable string
("2026-05-14T12:00:00", "2026 May 14 12:00:00", …). abcorr is the
NAIF aberration-correction code: "NONE", "LT" (default — light-time),
or "LT+S" (light-time + stellar aberration).
Supported bodies (22): Sun's neighbors plus moons in DE440s' direct coverage or close enough that the parent barycenter is a sufficient proxy.
Mercury Venus Earth Moon Mars Phobos Deimos
Jupiter Io Europa Ganymede Callisto
Saturn Rhea Iapetus Titan Enceladus
Uranus Neptune Triton Pluto Charon
The Sun is intentionally absent — sun_direction("Sun", ...) would be
meaningless; render the Sun's texture flat with ambient=1.0.
Plotting with matplotlib
render_disk returns a raw ndarray, so plotting is a one-liner with
ax.imshow and the natural extent (the disk lives in [-1, +1]
planet radii on both axes, with a margin cushion). The Layer-3
overlays return plain (xs, ys) arrays so they drop straight onto the
same axes — no extra glue, no matplotlib dependency in the rendering
path:
import matplotlib.pyplot as plt
from implanet import (
render_disk, render_info, get_texture,
graticule_segments, limb_circle, disk_terminator,
subobserver_point,
)
view = (-1, -0.2, -0.3)
sun = (1, 0.5, 0.4)
margin = 1.05
img = render_disk(get_texture("Earth"),
view_direction=view, sun_direction=sun,
size=600, margin=margin,
background="white") # mpl color string also works
fig, ax = plt.subplots(figsize=(6, 6))
ax.imshow(img, extent=(-margin, margin, -margin, margin))
ax.set_aspect("equal")
ax.set_xlim(-margin, margin); ax.set_ylim(-margin, margin)
ax.set_xlabel("x [planet radii]"); ax.set_ylabel("y [planet radii]")
# Overlays — every helper returns parallel lists of polylines you can
# loop into ax.plot(...).
g = graticule_segments(view_direction=view, lat_step_deg=30, lon_step_deg=30)
for xs, ys in zip(*g["parallels"]): ax.plot(xs, ys, ":", color="0.25", lw=0.7)
for xs, ys in zip(*g["meridians"]): ax.plot(xs, ys, ":", color="0.25", lw=0.7)
lx, ly = limb_circle()
ax.plot(lx, ly, "-", color="black", lw=1.0)
for xs, ys in zip(*disk_terminator(view_direction=view, sun_direction=sun)):
ax.plot(xs, ys, "--", color="white", lw=1.2)
sub_lat, sub_lon = subobserver_point(view_direction=view)
ax.set_title(render_info(get_texture("Earth"), view_direction=view,
sun_direction=sun)["caption"], fontsize=8)
fig.savefig("earth_scientific.png", dpi=140, bbox_inches="tight")
Flatmap with day-night terminator
render_flatmap returns a shaded equirectangular re-render (lon on x,
lat on y, both linear) instead of an orthographic disk.
flatmap_terminator(sun_direction=…) is its overlay companion — the
day-night great circle expressed in lon/lat rather than disk
coordinates, so the same ax.plot(xs, ys) pattern works:
import matplotlib.pyplot as plt
from implanet import (
render_flatmap, flatmap_terminator,
sun_direction, get_texture,
)
utc = "2026-05-14T12:00:00"
sun = sun_direction("Earth", utc)
# Shade the full equirectangular map for this UTC.
flat = render_flatmap(get_texture("Earth"), sun_direction=sun,
ambient=0.05, return_array=True)
fig, ax = plt.subplots(figsize=(8, 4))
ax.imshow(flat, extent=(-180, 180, -90, 90), aspect="auto")
# Overlay the terminator (one or two polyline pieces, depending on the
# sub-solar latitude — at solstice it's a single sinusoid; at equinox
# it wraps around the seam).
for xs, ys in zip(*flatmap_terminator(sun_direction=sun)):
ax.plot(xs, ys, "--", color="white", lw=1.4)
ax.set_xlabel("longitude (°)"); ax.set_ylabel("latitude (°)")
ax.set_title(f"Earth flatmap with day-night terminator · {utc}")
fig.savefig("earth_flatmap.png", dpi=140, bbox_inches="tight")
Result:
Pass rotation_lon_deg=θ to render_flatmap to spin the body under a
fixed sun — handy for stitching rotation-period animations frame by
frame.
Spacecraft flyby geometry (e.g. MESSENGER M1)
sun_direction / view_direction_from_earth cover Sun- and
Earth-based vantages. For a spacecraft vantage, implanet doesn't add
any helper — you grab the kernels yourself and use spiceypy directly
to get the view and sun directions, then hand them to render_disk.
Download the generic kernels plus the mission trajectory SPK straight
from NAIF (any tool works; wget shown):
mkdir -p kernels && cd kernels
wget https://naif.jpl.nasa.gov/pub/naif/generic_kernels/lsk/naif0012.tls
wget https://naif.jpl.nasa.gov/pub/naif/generic_kernels/pck/pck00011.tpc
wget https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/de440s.bsp
wget https://naif.jpl.nasa.gov/pub/naif/pds/data/mess-e_v_h-spice-6-v1.0/messsp_1000/data/spk/msgr_040803_080216_120401.bsp
cd ..
Then it's pure spiceypy for the geometry — the MESSENGER Mercury
flyby 1 (M1) departing crescent at 2008-01-14T20:24:00 UTC
(~80 min after closest approach):
import numpy as np
import spiceypy
from PIL import Image
from implanet import render_disk, get_texture
for k in ("naif0012.tls", "pck00011.tpc", "de440s.bsp",
"msgr_040803_080216_120401.bsp"):
spiceypy.furnsh(f"kernels/{k}")
utc = "2008-01-14T20:24:00"
et = spiceypy.str2et(utc)
# MESSENGER (NAIF -236) position relative to Mercury (199), in J2000,
# then rotate into the body-fixed IAU_MERCURY frame. (de440s has no
# Mercury-centre IAU chain, so rotate explicitly via pxform.)
pos_j2000, lt = spiceypy.spkpos("-236", et, "J2000", "LT", "199")
R = spiceypy.pxform("J2000", "IAU_MERCURY", et)
sc = R @ np.array(pos_j2000)
view = -sc / np.linalg.norm(sc) # camera → planet centre
# Mercury → Sun, same frame.
sun_j2000, _ = spiceypy.spkpos("SUN", et, "J2000", "LT", "199")
sun = R @ np.array(sun_j2000)
sun = sun / np.linalg.norm(sun)
img = render_disk(get_texture("Mercury", "sss"),
view_direction=view, sun_direction=sun,
size=1024, ambient=0.0, background="black")
# range ≈ 29 000 km; phase angle ≈ 52°, so MESSENGER saw an ~80%-lit
# departing gibbous (the terminator clips the lower-left limb).
Image.fromarray(img).save("messenger_m1.png")
Side-by-side with the real flyby — implanet's render (left) vs. NASA's published MESSENGER M1 departure mosaic (right):
|
implanet render — Mercury sss colour mosaic,
raw render (no contrast filter), ambient 0 so the shadow side goes
fully dark. Geometry at 2008-01-14T20:24 UTC, north-up — so its
terminator falls on the left.
|
NASA MESSENGER M1 departure mosaic — contrast-enhanced and rolled to NASA's own display frame, so its terminator is on the right. NASA SVS 30340; credit NASA/JHU-APL/Carnegie Institution of Washington. |
Both show the same ~80%-lit departing gibbous. They are not
pixel-identical: NASA's mosaic is contrast-stretched and presented in
its own orientation, so its terminator is on the right while the
north-up raw render's is on the left — implanet reproduces the
geometry (which
hemisphere, the phase, the terminator), not NASA's exact framing. render_disk only ever needs the two body-fixed
3-vectors; where they come from is up to you. Swap the SPK URL + body +
NAIF codes for other flybys (Voyager, New Horizons, Galileo, …); browse
the NAIF PDS archive at https://naif.jpl.nasa.gov/pub/naif/pds/.
Map sources
maps/manifest.json catalogs equirectangular maps from NASA, ESA, JAXA,
and CNSA, plus a few community redistributions. Each entry has:
body+variant(composite key — same body can appear multiple times)agency,mission,instrument,descriptionformat,resolution,size_bytes_estimatedasset_url(auto-downloadable) and/orportal_url(manual)provenance,license,citation
See what's available (don't rely on a hand-maintained list here — it goes stale; ask the package):
import implanet
implanet.show_maps() # pretty table: body, variant, size, status
implanet.show_maps(body="Earth") # filter to one body
implanet.list_maps(downloadable_only=True) # → list[dict], for scripting
implanet-fetch --list # same catalogue from the shell
implanet-fetch --where # print the resolved maps directory
Get one map (lazy, on demand) — usually all you need:
from implanet import get_texture
path = get_texture("Mars") # default variant
path = get_texture("Earth", "natural_earth3") # specific, vivid variant
Bulk-download the auto-fetchable subset — from the shell:
implanet-fetch # ~250 MB total
implanet-fetch --body Mars # filter by body
implanet-fetch --agency NASA # filter by agency
implanet-fetch --include-large # also the multi-GB USGS mosaics
…or the same thing from Python, which returns the local paths:
from implanet import download_maps
download_maps() # everything auto-fetchable
download_maps(body="Mars") # one body's variants
download_maps(agency="NASA") # filter by agency
paths = download_maps(include_large=True) # → list[Path]; allow the multi-GB mosaics
download_maps(body="Moon", quiet=True) # silence progress + cite hints
download_maps reuses get_texture under the hood, so manual-only
entries are skipped and files over ~200 MB are skipped unless
include_large=True. Use get_texture(body, variant) when you just
want one map back as a Path.
Status column meaning: cached (on disk) · download (auto-fetchable)
· generate (synthetic, built locally) · manual (portal-only — e.g.
Titan's default, ESA HRSC Mars, JAXA Kaguya, CNSA mosaics, full-res USGS;
get_texture raises with the portal URL for these).
Reproducing the demo figures
Scripts under examples/:
# Quick PIL-only rotation/terminator/pole grids
python examples/figures.py # → examples/figures/*.png
# Animated rotation / sub-solar drift GIFs
python examples/animations.py # → examples/animations/*.gif
# Per-body equirectangular flatmap re-renders with shading
python examples/flatmap_figures.py # → examples/figures_flatmap/*.png
# A transparent RGBA disk per body, on an exact [-1,1] grid
python examples/transparent_disks.py # → examples/figures_transparent/*.png
# Synthetic day/night reference grid (no download)
python examples/daynight_reference.py
# A single sunlit-Earth render driven by SPICE
python examples/earth_dayside.py
# Three quick views: front / side / pole-down
python examples/demo.py
All examples write into examples/<some-output-dir>/ which is
git-ignored; the committed showcase set lives under
figures/.
Attribution & citation
implanet itself is MIT-licensed. The maps and SPICE kernels are
not our work — they're redistributions of public-domain or
Creative-Commons assets from NASA, ESA, JAXA, CNSA, USGS, and a few
community texture providers. The terms of those upstream sources apply.
If you use any rendered figure in a paper or talk, credit both the
mission/instrument and the texture provider. The manifest's citation
field gives the right phrasing per map; e.g.:
Solar System Scope (CC BY 4.0). Underlying data: NASA MGS MOLA team; Viking Orbiter; USGS Astrogeology.
You can read the citation block at runtime so it stays in sync with the catalogue:
import implanet
implanet.show_attribution("Mars") # one body, pretty-printed
implanet.show_attribution() # all 42 entries
implanet.attribution("Earth", "natural_earth3") # → dict
implanet-fetch --cite # citation block from the CLI
implanet-fetch --cite --body Mars # filtered
The first time get_texture(body) downloads a map, a one-line license
- cite hint is printed to stderr so the requirement is hard to miss.
For the full block of every catalogued texture and SPICE kernel see
ATTRIBUTION.md at the repo root (regenerable via
python scripts/build_attribution.py).
References
If you use implanet in published work, please cite the package and
the upstream tooling it builds on. The maps and SPICE kernels each
carry their own citation — see the
Attribution & citation section above and the
per-entry citation field in maps/manifest.json (also exposed via
implanet.show_attribution(...) and as a one-liner in
render_info(...)["caption"]).
Citing implanet
@software{implanet,
title = {implanet: orthographic planet renderer with SPICE-driven sun lighting},
author = {Zhao, Jiutong},
year = {2026},
url = {https://github.com/jiutongzhao/implanet},
note = {MIT-licensed Python package}
}
SPICE / NAIF. The ephemeris layer (implanet.ephemeris) wraps the
NAIF SPICE toolkit through spiceypy. If you publish a result that
relies on sun_direction, sub_solar_point, or any mission SPK in
this catalogue, cite:
Acton, C. H. (1996). Ancillary data services of NASA's Navigation and Ancillary Information Facility. Planetary and Space Science, 44(1), 65–70. https://doi.org/10.1016/0032-0633(95)00107-7
Acton, C., Bachman, N., Semenov, B., & Wright, E. (2018). A look towards the future in the handling of space science mission geometry. Planetary and Space Science, 150, 9–12. https://doi.org/10.1016/j.pss.2017.02.013
Rendering pipeline. The orthographic projection + Lambertian shading + bilinear sampling here is textbook computer-graphics machinery; consult any introductory CG text (e.g. Foley et al., Computer Graphics: Principles and Practice) for derivations.
Data sources. Catalogued in
ATTRIBUTION.md — one entry per texture + SPICE
kernel, with provenance, license, and citation text. Always cite the
mission/instrument and the texture provider in figure captions.
Tests
pip install -e .[test]
pytest tests/ # 49 tests, ~1 s
tests/test_render.py covers basis orthonormality, disk geometry,
sphere→uv mapping, hemisphere correctness, terminator shading,
path/PIL/array texture inputs, and the render_info metadata helper.
tests/test_assets.py covers the registry/cache layer (resolution
order, packaged-registry sync, the synthetic texture, the
attribution() API, and an ATTRIBUTION.md drift check).
tests/test_ephemeris.py sanity-checks SPICE-derived geometry against
physical reality (Mercury obliquity ≈ 0, Uranus obliquity dominates,
sun near Greenwich at equinox noon UTC) and auto-skips if spiceypy
or the kernels are absent.
File layout
implanet/
├── __init__.py # public API + lazy ephemeris import
├── projection.py # camera_basis, orthographic_rays, sphere_to_uv
├── render.py # render_disk, render_flatmap, render_info
├── overlays.py # graticule/limb/terminator/subobserver
├── ephemeris.py # SPICE wrappers (spiceypy)
├── fetch.py # `implanet-fetch` console script
└── assets/ # registry + lazy download/cache
├── __init__.py # get_texture, get_kernel, list_maps,
│ # show_maps, attribution, show_attribution
├── _registry.py _cache.py _synthetic.py
└── data/ # packaged copies of the registry JSON
maps/
├── manifest.json # 42 entries, 23 bodies (textures)
├── kernels.json # 15 entries (SPICE kernels)
└── data/ # texture cache (dev checkout)
kernels/ # SPICE cache (dev checkout); pip → implanet/_data
scripts/ # fetch_maps.py / sync_registry.py /
# build_attribution.py (dev helpers)
ATTRIBUTION.md # human-browseable license/citation index,
# regenerated from manifest.json + kernels.json
examples/ # demo.py, figures.py, animations.py,
# flatmap_figures.py, transparent_disks.py,
# earth_dayside.py, daynight_reference.py
tests/ # test_render.py, test_assets.py,
# test_ephemeris.py, _cli_tool.py (dev-only
# ad-hoc render CLI; not shipped)
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file implanet-0.2.1.tar.gz.
File metadata
- Download URL: implanet-0.2.1.tar.gz
- Upload date:
- Size: 93.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a2621dfe74d523908a14243576a923ec1b5e6ffacaef695eba6dde1475d9012c
|
|
| MD5 |
5f22c7440f9127cee71df31a24095309
|
|
| BLAKE2b-256 |
c02c3485dd3e88a17641409451bbe2b0633b724c9b43f0a2829efaa6f8e5fb28
|
Provenance
The following attestation bundles were made for implanet-0.2.1.tar.gz:
Publisher:
python-publish.yml on jiutongzhao/implanet
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
implanet-0.2.1.tar.gz -
Subject digest:
a2621dfe74d523908a14243576a923ec1b5e6ffacaef695eba6dde1475d9012c - Sigstore transparency entry: 1713506369
- Sigstore integration time:
-
Permalink:
jiutongzhao/implanet@58b0850836a07ea9f209e44a1031c05b838c9d24 -
Branch / Tag:
refs/tags/v0.2.1 - Owner: https://github.com/jiutongzhao
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@58b0850836a07ea9f209e44a1031c05b838c9d24 -
Trigger Event:
release
-
Statement type:
File details
Details for the file implanet-0.2.1-py3-none-any.whl.
File metadata
- Download URL: implanet-0.2.1-py3-none-any.whl
- Upload date:
- Size: 62.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
243271ca692feef2819d71b17f0c24b262daf415f26178fb25bcc551fa7c9a46
|
|
| MD5 |
8d7be65443d2fea1cab8312094f8932d
|
|
| BLAKE2b-256 |
d1eb348a0ee5e5af14aada6edc9ee1429bbd306bc10d0acf22e67371351b6985
|
Provenance
The following attestation bundles were made for implanet-0.2.1-py3-none-any.whl:
Publisher:
python-publish.yml on jiutongzhao/implanet
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
implanet-0.2.1-py3-none-any.whl -
Subject digest:
243271ca692feef2819d71b17f0c24b262daf415f26178fb25bcc551fa7c9a46 - Sigstore transparency entry: 1713506438
- Sigstore integration time:
-
Permalink:
jiutongzhao/implanet@58b0850836a07ea9f209e44a1031c05b838c9d24 -
Branch / Tag:
refs/tags/v0.2.1 - Owner: https://github.com/jiutongzhao
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@58b0850836a07ea9f209e44a1031c05b838c9d24 -
Trigger Event:
release
-
Statement type: