Skip to main content

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:

quick-start earth.png

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/.

transparent disks overview
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.
earth dayside, SPICE-driven
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
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
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.

texture gallery — every body's default

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)
Mercury 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)
Venus variants
Cloud-top UV (sss_atmosphere, default) vs. the Magellan SAR surface mosaic (sss_surface).
Earth (6 variants)
Earth variants
Blue Marble at two resolutions, the Solar System Scope cloud / day / night composites, and the vivid Natural Earth III.
Moon (4 variants)
Moon variants
Clementine UVVIS (default), two LROC color composites, and the Solar System Scope re-processing.
Mars (2 auto-fetchable variants)
Mars 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)
Jupiter 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:

Earth sun Earth terminator Earth antisun Earth north pole Earth south pole

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:

flatmap + terminator example

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 of MESSENGER M1
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
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, description
  • format, resolution, size_bytes_estimated
  • asset_url (auto-downloadable) and/or portal_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

implanet-0.2.1.tar.gz (93.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

implanet-0.2.1-py3-none-any.whl (62.0 kB view details)

Uploaded Python 3

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

Hashes for implanet-0.2.1.tar.gz
Algorithm Hash digest
SHA256 a2621dfe74d523908a14243576a923ec1b5e6ffacaef695eba6dde1475d9012c
MD5 5f22c7440f9127cee71df31a24095309
BLAKE2b-256 c02c3485dd3e88a17641409451bbe2b0633b724c9b43f0a2829efaa6f8e5fb28

See more details on using hashes here.

Provenance

The following attestation bundles were made for implanet-0.2.1.tar.gz:

Publisher: python-publish.yml on jiutongzhao/implanet

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for implanet-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 243271ca692feef2819d71b17f0c24b262daf415f26178fb25bcc551fa7c9a46
MD5 8d7be65443d2fea1cab8312094f8932d
BLAKE2b-256 d1eb348a0ee5e5af14aada6edc9ee1429bbd306bc10d0acf22e67371351b6985

See more details on using hashes here.

Provenance

The following attestation bundles were made for implanet-0.2.1-py3-none-any.whl:

Publisher: python-publish.yml on jiutongzhao/implanet

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page