Files
settled-reach/tooling/planet-gen/render_heightmap.py
T
jpmschweitzerandClaude Opus 4.6 a8379871ce fix(assets): address PR #119 review round 2 — biomes.toml, legend, profile table
1. Add 5 extended atmosphere colors to biomes.toml (globe renderer reads
   from toml, not body_def) — cold_arid, hot_arid, tropical, boreal,
   temperate_terminator. Remove dead martian entry. Slightly differentiate
   colors from base classes.
2. Fix profile table raw identifiers — apply .replace('_', ' ') to class
   field in wiki body pages.
3. Fix legend text overflow — truncate labels with ellipsis when step
   size is too narrow for full text.
4. Fix title panel underscores — use .replace('_', ' ') instead of
   .replace('_ringed', '').

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:32:39 +02:00

495 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
render_heightmap.py
-------------------
Renders an annotated equirectangular heightmap PNG from a terrain dict.
This is the PRIMARY output of the planet generator pipeline.
The globe render is a separate downstream step that reads the same terrain dict.
Equirectangular projection:
X axis: longitude 0°→360° (left to right)
Y axis: latitude +90°→-90° (top to bottom, north pole at row 0)
Each terrain grid cell maps to a block of output pixels via bicubic upscale.
All rendering is in float32; final conversion to uint8 at save time.
Output layers (composited in order):
1. Biome colour — smooth-blended from Whittaker grid, not hard-snapped
2. Elevation shading — subtle darkening in valleys, lightening on peaks
3. Hillshade — surface normal lighting pass (makes terrain 3D-readable)
4. Coastline — 1px dark border at sea level threshold
5. Rivers — anti-aliased polylines from river list
6. Lat/lon grid — every 30°, semi-transparent
7. Title panel — body metadata strip at top
8. Legend — biome colour swatches at bottom
Geographic only. No settlements, roads, or cultural data.
Those live in a separate JSON sidecar and are overlaid by the atlas app.
Usage:
from render_heightmap import render_heightmap
from planet_simulation import simulate
from body_definition_parser import parse_system
defs = parse_system("index.md")
terrain = simulate(defs[0])
img = render_heightmap(defs[0], terrain)
img.save("GJ144d_heightmap.png")
"""
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from scipy.ndimage import binary_dilation
from biome_config import (
BIOME_PALETTE as _BIOME_PALETTE_CFG,
RIVER_RGB as _RIVER_RGB_CFG,
COAST_RGB as _COAST_RGB_CFG,
build_biome_rgb,
)
# ---------------------------------------------------------------------------
# Output resolution
# ---------------------------------------------------------------------------
OUT_W = 1024
OUT_H = 512
UI_SCALE = OUT_W / 1024 # 1.0 at default 1024 — all pixel sizes scale with this
# ---------------------------------------------------------------------------
# Biome colour palette
# Indices match planet_simulation.WHITTAKER_TABLE class IDs.
# Extended exotic classes appended at end.
# ---------------------------------------------------------------------------
# Biome palette loaded from biomes.toml via biome_config.
# Per-planet overrides can patch _BIOME_PALETTE_CFG before rendering.
BIOME_PALETTE = _BIOME_PALETTE_CFG
def _build_biome_rgb(mode: str = "cartographic") -> dict:
return build_biome_rgb(mode)
RENDER_MODE = "cartographic"
BIOME_RGB = _build_biome_rgb(RENDER_MODE)
def _ocean_arrays(mode: str = "cartographic"):
return (
np.array(BIOME_PALETTE[0][mode], dtype=np.float32),
np.array(BIOME_PALETTE[1][mode], dtype=np.float32),
np.array(BIOME_PALETTE[2][mode], dtype=np.float32),
)
OCEAN_DEEP, OCEAN_MID, OCEAN_SHALLOW = _ocean_arrays(RENDER_MODE)
RIVER_RGB = _RIVER_RGB_CFG
COAST_RGB = _COAST_RGB_CFG
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _upscale(grid: np.ndarray, order: int = 1) -> np.ndarray:
"""
Upscale a (GRID_H, GRID_W) float32 grid to (OUT_H, OUT_W).
order=1 → bilinear (smooth, good for continuous fields)
order=0 → nearest (sharp, good for integer class grids)
"""
from scipy.ndimage import zoom
zy = OUT_H / grid.shape[0]
zx = OUT_W / grid.shape[1]
return zoom(grid.astype(np.float32), (zy, zx), order=order).astype(np.float32)
def _upscale_int(grid: np.ndarray) -> np.ndarray:
"""Nearest-neighbour upscale for integer class grids (biome, etc)."""
from scipy.ndimage import zoom
zy = OUT_H / grid.shape[0]
zx = OUT_W / grid.shape[1]
return zoom(grid.astype(np.int32), (zy, zx), order=0).astype(np.int8)
# ---------------------------------------------------------------------------
# Layer 1 + 2 + 3: Biome colour + elevation shading + hillshade
# ---------------------------------------------------------------------------
def _render_surface(terrain: dict) -> np.ndarray:
"""
Returns (OUT_H, OUT_W, 3) float32 RGB in [0, 1].
Compositing order:
biome_colour × elevation_shade × hillshade_factor
"""
elevation = _upscale(terrain["elevation"], order=1)
hillshade = _upscale(terrain["hillshade"], order=1)
biome_up = _upscale_int(terrain["biome"])
surf_water = _upscale(terrain["surface_water"].astype(np.float32),
order=0) > 0.5
sea_level = terrain["sea_level"]
H, W = elevation.shape
# ── Biome base colour ─────────────────────────────────────────────────
# Clamp biome index, look up palette
# Build lookup array from active BIOME_RGB dict for vectorised indexing
max_id = max(BIOME_RGB.keys())
pal_arr = np.zeros((max_id + 1, 3), dtype=np.float32)
for k, v in BIOME_RGB.items():
pal_arr[k] = v
biome_clamped = np.clip(biome_up, 0, max_id)
rgb = pal_arr[biome_clamped].astype(np.float32) / 255.0
# ── Ocean depth blending ───────────────────────────────────────────────
# Override flat ocean biome with smooth depth gradient
if surf_water.any():
depth = np.clip((sea_level - elevation) / (sea_level + 1e-9), 0, 1)
deep_col = OCEAN_DEEP / 255.0
mid_col = OCEAN_MID / 255.0
shallow_col = OCEAN_SHALLOW / 255.0
# Three-stop blend: 0=shallow, 0.5=mid, 1=deep
t1 = np.clip(depth * 2.0, 0, 1) # 0→0.5 depth: shallow→mid
t2 = np.clip((depth - 0.5) * 2.0, 0, 1) # 0.5→1 depth: mid→deep
ocean_rgb = (shallow_col * (1 - t1)[..., None]
+ mid_col * (t1 * (1 - t2))[..., None]
+ deep_col * t2[..., None])
rgb = np.where(surf_water[..., None], ocean_rgb, rgb)
# ── Elevation shading on land ──────────────────────────────────────────
# Slight darkening in lowlands, brightening on ridges
elev_norm = np.where(
~surf_water,
np.clip((elevation - sea_level) / (1.0 - sea_level + 1e-9), 0, 1),
0.0)
elev_shade = 0.88 + 0.18 * elev_norm # [0.88, 1.06] — clamp below
rgb = np.where(~surf_water[..., None],
np.clip(rgb * elev_shade[..., None], 0, 1),
rgb)
# ── Hillshade ──────────────────────────────────────────────────────────
# Apply only on land — ocean gets its own depth shading
# Blend factor: 0.55 hillshade + 0.45 flat (keeps colours readable)
hs_blend = 0.55 * hillshade + 0.45
rgb = np.where(~surf_water[..., None],
np.clip(rgb * hs_blend[..., None], 0, 1),
rgb)
return rgb.astype(np.float32)
# ---------------------------------------------------------------------------
# Layer 4: Coastline
# ---------------------------------------------------------------------------
def _render_coastline(terrain: dict,
rgb: np.ndarray) -> np.ndarray:
"""Draw a 12px dark border at the sea level threshold."""
surf_water = _upscale(terrain["surface_water"].astype(np.float32),
order=0) > 0.5
# Dilate water mask by 1px, XOR with original → coastline ring
dilated = binary_dilation(surf_water, iterations=2)
coastline = dilated & ~surf_water
coast_col = np.array(COAST_RGB, dtype=np.float32) / 255.0
out = rgb.copy()
out[coastline] = coast_col
return out
# ---------------------------------------------------------------------------
# Layer 5: Rivers
# ---------------------------------------------------------------------------
def _render_rivers(terrain: dict,
rgb: np.ndarray) -> np.ndarray:
"""
Draw rivers as anti-aliased polylines.
River list is in simulation grid coords (row, col) at GRID_H×GRID_W.
Scale to output pixels, draw with PIL.
"""
rivers = terrain.get("rivers", [])
if not rivers:
return rgb
GRID_H, GRID_W = terrain["_grid_h"], terrain["_grid_w"]
scale_y = OUT_H / GRID_H
scale_x = OUT_W / GRID_W
# Work on a PIL image for anti-aliased line drawing
img = Image.fromarray((rgb * 255).clip(0, 255).astype(np.uint8), mode="RGB")
draw = ImageDraw.Draw(img)
river_col = RIVER_RGB
for path in rivers:
if len(path) < 2:
continue
# Scale grid coords to output pixels
pts = [(int(c * scale_x), int(r * scale_y)) for r, c in path]
# Line width scales with path length — longer rivers are wider.
# Base width doubled for readability at high output resolutions.
width = max(2, min(6, len(path) // 40))
draw.line(pts, fill=river_col, width=width, joint="curve")
return np.array(img).astype(np.float32) / 255.0
# ---------------------------------------------------------------------------
# Layer 6: Lat/lon grid
# ---------------------------------------------------------------------------
def _render_grid(rgb: np.ndarray) -> np.ndarray:
"""Draw lat/lon lines every 30° as semi-transparent overlays."""
out = rgb.copy()
col = np.array([255, 255, 255], dtype=np.float32) / 255.0
alpha = 0.12 # very subtle
# Latitude lines (horizontal) every 30°: at 1/6, 2/6, 3/6, 4/6, 5/6 of height
for frac in [1/6, 2/6, 3/6, 4/6, 5/6]:
y = int(frac * OUT_H)
y0 = max(0, y - 1); y1 = min(OUT_H - 1, y + 1)
out[y0:y1, :] = out[y0:y1, :] * (1 - alpha) + col * alpha
# Longitude lines (vertical) every 30°
for frac in [1/6, 2/6, 3/6, 4/6, 5/6]:
x = int(frac * OUT_W)
x0 = max(0, x - 1); x1 = min(OUT_W - 1, x + 1)
out[:, x0:x1] = out[:, x0:x1] * (1 - alpha) + col * alpha
return out
# ---------------------------------------------------------------------------
# Layer 7: Title panel
# ---------------------------------------------------------------------------
def _load_font(size: int):
try:
return ImageFont.load_default(size=size)
except TypeError:
return ImageFont.load_default()
def _render_title(img: Image.Image, body_def: dict) -> Image.Image:
"""Draw metadata strip at top of image."""
panel_h = int(52 * UI_SCALE)
panel = Image.new("RGBA", (OUT_W, panel_h), (12, 15, 22, 210))
img_rgba = img.convert("RGBA")
img_rgba.paste(panel, (0, 0), panel)
img_out = img_rgba.convert("RGB")
draw = ImageDraw.Draw(img_out)
name = body_def.get("name") or body_def.get("id", "Unknown")
bid = body_def.get("id", "")
pclass = body_def.get("planet_class", "").replace("_", " ")
star = body_def.get("star", {})
orbit = body_def.get("orbit", {})
phys = body_def.get("physical", {})
env = body_def.get("environment", {})
star_str = f"{star.get('type','?')}-type"
dist_str = f"{orbit.get('distance_au', 0):.2f} AU"
grav_str = f"{phys.get('gravity_g', '?')}g"
atmo_str = phys.get("atmosphere", "?")
hydro_str = env.get("hydrosphere", "?")
px = int(14 * UI_SCALE)
py = int(7 * UI_SCALE)
lh = int(17 * UI_SCALE)
title_col = (200, 210, 228)
sub_col = (130, 145, 168)
dim_col = (75, 88, 110)
line1 = f"{name.upper()} · {bid} · {pclass}"
line2 = f"{star_str} · {dist_str} · {grav_str} · atmo: {atmo_str} · hydro: {hydro_str}"
line3 = "HEIGHTMAP · Settled Reach"
draw.text((px, py), line1, fill=title_col, font=_load_font(int(14 * UI_SCALE)))
draw.text((px, py + lh), line2, fill=sub_col, font=_load_font(int(12 * UI_SCALE)))
draw.text((px, py + lh*2), line3, fill=dim_col, font=_load_font(int(11 * UI_SCALE)))
return img_out
# ---------------------------------------------------------------------------
# Layer 8: Legend
# ---------------------------------------------------------------------------
def _biome_legend_items(terrain: dict) -> list:
"""
Return list of (label, RGB) for biome classes actually present
in this terrain — no phantom legend entries.
"""
biome = terrain["biome"]
present = set(np.unique(biome).tolist())
LABELS = {
0: "ocean deep", 1: "ocean", 2: "coastal water",
3: "coast", 5: "trop. rainforest", 6: "trop. forest",
7: "savanna", 8: "grassland", 9: "forest",
10: "temp. rainforest", 11: "boreal", 12: "shrubland",
13: "temperate desert", 14: "desert", 15: "hot desert",
16: "tundra", 17: "ice / snow", 18: "mountain rock",
19: "lava field", 20: "chemosyn. mat", 21: "thermophilic",
22: "sulfuric scrub", 23: "crypto. crust", 25: "ash field",
}
items = []
# Fixed display order — most common first, exotic last
order = [0, 1, 2, 3, 7, 8, 5, 6, 9, 10, 11, 12, 13, 14, 15, 16, 17,
18, 19, 20, 21, 22, 23, 25]
for cls_id in order:
if cls_id in present and cls_id in LABELS:
rgb = BIOME_RGB.get(cls_id, (128, 128, 128))
items.append((LABELS[cls_id], rgb))
# Always include river swatch if rivers exist
if terrain.get("rivers"):
items.append(("river", RIVER_RGB))
return items
def _render_legend(img: Image.Image, terrain: dict) -> Image.Image:
"""Draw biome legend strip at bottom of image."""
items = _biome_legend_items(terrain)
if not items:
return img
draw = ImageDraw.Draw(img)
sw = int(14 * UI_SCALE) # swatch width
sh = int(12 * UI_SCALE) # swatch height
pad_x = int(14 * UI_SCALE)
leg_y = OUT_H - int(34 * UI_SCALE)
font = _load_font(int(10 * UI_SCALE))
gap = int(6 * UI_SCALE)
# Scale step to fit all items within image width
max_items = len(items)
max_step = (OUT_W - 2 * pad_x) // max(max_items, 1)
step = min(int(108 * UI_SCALE), max_step)
# Max label width = step minus swatch minus gaps
max_label_px = step - sw - gap - int(4 * UI_SCALE)
lx = pad_x
for label, rgb in items:
draw.rectangle([(lx, leg_y), (lx + sw, leg_y + sh)], fill=rgb)
# Truncate label to fit allocated space
display = label
while display and draw.textlength(display, font=font) > max_label_px:
display = display[:-1]
if display != label and display:
display = display[:-1] + "…"
draw.text((lx + sw + gap, leg_y), display,
fill=(185, 192, 205), font=font)
lx += step
return img
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def render_heightmap(body_def: dict,
terrain: dict,
out_w: int = OUT_W,
out_h: int = OUT_H,
render_mode: str = "cartographic",
chrome: bool = True) -> Image.Image:
"""
Render an annotated equirectangular heightmap PNG.
Parameters
----------
body_def : dict — body definition from body_definition_parser
terrain : dict — terrain dict from planet_simulation.simulate()
out_w, out_h — output resolution (default 1024×512)
Returns
-------
PIL.Image.Image RGB
"""
global OUT_W, OUT_H, UI_SCALE
OUT_W = out_w
OUT_H = out_h
UI_SCALE = out_w / 1024
# Set active colour mode for this render
global BIOME_RGB, OCEAN_DEEP, OCEAN_MID, OCEAN_SHALLOW, RENDER_MODE
RENDER_MODE = render_mode
BIOME_RGB = _build_biome_rgb(render_mode)
OCEAN_DEEP, OCEAN_MID, OCEAN_SHALLOW = _ocean_arrays(render_mode)
# Guard: require simulation data
required = ("elevation", "biome", "surface_water", "hillshade", "sea_level")
missing = [k for k in required if k not in terrain]
if missing:
raise ValueError(f"terrain dict missing keys: {missing}")
# 1+2+3: surface colour with elevation shading and hillshade
rgb = _render_surface(terrain)
# 4: coastline
rgb = _render_coastline(terrain, rgb)
# 5: rivers
rgb = _render_rivers(terrain, rgb)
# 6: lat/lon grid
rgb = _render_grid(rgb)
# Convert to PIL for text rendering
img = Image.fromarray(
(rgb * 255).clip(0, 255).astype(np.uint8), mode="RGB")
if chrome:
# 7: title panel
img = _render_title(img, body_def)
# 8: legend
img = _render_legend(img, terrain)
return img
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import sys, json, time
if len(sys.argv) < 2:
print("Usage: python3 render_heightmap.py body_def.json [--large]")
sys.exit(1)
with open(sys.argv[1]) as f:
bd = json.load(f)
# --large flag renders at 4096×2048 for high-res review
large = "--large" in sys.argv
w, h = (4096, 2048) if large else (OUT_W, OUT_H)
from planet_simulation import simulate
print(f"Simulating: {bd['id']} ({bd['planet_class']})")
t0 = time.time()
terrain = simulate(bd)
sim_t = time.time() - t0
if not terrain:
print("Gas giant — no heightmap.")
sys.exit(0)
print(f"Rendering heightmap {w}×{h}…")
t1 = time.time()
img = render_heightmap(bd, terrain, out_w=w, out_h=h)
ren_t = time.time() - t1
out = f"/mnt/user-data/outputs/{bd['id']}_heightmap.png"
img.save(out, format="PNG")
print(f"Saved: {out}")
print(f" simulate={sim_t:.1f}s render={ren_t:.1f}s total={sim_t+ren_t:.1f}s")