worldgen

git clone https://git.godosa.eu/worldgen

master

raw · 6727 bytes

"""Map projections of the equirectangular relief: Equal Earth, Mollweide (equal-area) and orthographic globes."""
from __future__ import annotations

import numpy as np
from PIL import Image, ImageDraw

from .sketch import sample_equirect
from .sphere import east_north, latlon_to_xyz, xyz_to_latlon

BACKGROUND = (16, 18, 24)
_A1, _A2, _A3, _A4 = 1.340264, -0.081106, 0.000893, 0.003796
_M = np.sqrt(3.0) / 2.0
EE_XMAX = 2.0 * np.sqrt(3.0) * np.pi / (3.0 * _A1)
EE_YMAX = _A1 * np.pi / 3 + _A2 * (np.pi / 3) ** 3 + _A3 * (np.pi / 3) ** 7 + _A4 * (np.pi / 3) ** 9
MW_XMAX, MW_YMAX = 2.0 * np.sqrt(2.0), np.sqrt(2.0)


def equal_earth_forward(lat, lon):
    t = np.arcsin(_M * np.sin(np.radians(lat)))
    d = _A1 + 3 * _A2 * t**2 + 7 * _A3 * t**6 + 9 * _A4 * t**8
    x = 2 * np.sqrt(3.0) * np.radians(lon) * np.cos(t) / (3 * d)
    y = _A1 * t + _A2 * t**3 + _A3 * t**7 + _A4 * t**9
    return x, y


def equal_earth_inverse(x, y):
    t = np.asarray(y, dtype=np.float64) / _A1
    for _ in range(12):
        f = _A1 * t + _A2 * t**3 + _A3 * t**7 + _A4 * t**9 - y
        t = t - f / (_A1 + 3 * _A2 * t**2 + 7 * _A3 * t**6 + 9 * _A4 * t**8)
    d = _A1 + 3 * _A2 * t**2 + 7 * _A3 * t**6 + 9 * _A4 * t**8
    lat = np.degrees(np.arcsin(np.clip(np.sin(t) / _M, -1, 1)))
    lon = np.degrees(3 * np.asarray(x) * d / (2 * np.sqrt(3.0) * np.cos(t)))
    return lat, lon


def mollweide_forward(lat, lon):
    phi = np.radians(lat)
    t = phi.copy() if isinstance(phi, np.ndarray) else np.array(phi, dtype=np.float64)
    for _ in range(30):
        f = 2 * t + np.sin(2 * t) - np.pi * np.sin(phi)
        t = t - f / np.maximum(2 + 2 * np.cos(2 * t), 1e-12)
    return MW_XMAX / np.pi * np.radians(lon) * np.cos(t), MW_YMAX * np.sin(t)


def mollweide_inverse(x, y):
    t = np.arcsin(np.clip(np.asarray(y) / MW_YMAX, -1, 1))
    lat = np.degrees(np.arcsin(np.clip((2 * t + np.sin(2 * t)) / np.pi, -1, 1)))
    lon = np.degrees(np.pi * np.asarray(x) / (MW_XMAX * np.maximum(np.cos(t), 1e-12)))
    return lat, lon


def orthographic_inverse(x, y, lat0, lon0):
    """Unit-disc view coords → lat/lon on the visible hemisphere centred on (lat0, lon0); ok = inside the disc."""
    x, y = np.asarray(x, dtype=np.float64), np.asarray(y, dtype=np.float64)
    rho2 = x**2 + y**2
    ok = rho2 <= 1.0
    z = np.sqrt(np.clip(1.0 - rho2, 0.0, 1.0))
    c = latlon_to_xyz(np.array([lat0]), np.array([lon0]))
    e, n = east_north(c)
    p = x[..., None] * e[0] + y[..., None] * n[0] + z[..., None] * c[0]
    lat, lon = xyz_to_latlon(p)
    return lat, lon, ok


def _sample_rgb(img, lat, lon):
    """Bilinear RGB samples, read straight from the uint8 channels (each sampled value promotes to float64 exactly,
    so no full-size float copy of a big image is needed)."""
    return np.stack([sample_equirect(img[..., k], lat, lon) for k in range(3)], axis=-1)


def reproject(img, proj, width):
    """Equirectangular RGB → equal-area world map ('equal_earth' | 'mollweide')."""
    xmax, ymax, inv = {"equal_earth": (EE_XMAX, EE_YMAX, equal_earth_inverse),
                       "mollweide": (MW_XMAX, MW_YMAX, mollweide_inverse)}[proj]
    height = int(round(width * ymax / xmax))
    xs = ((np.arange(width) + 0.5) / width * 2 - 1) * xmax
    ys = (1 - (np.arange(height) + 0.5) / height * 2) * ymax
    X, Y = np.meshgrid(xs, ys)
    lat, lon = inv(X, Y)
    ok = np.isfinite(lat) & np.isfinite(lon) & (np.abs(lon) <= 180.0)
    rgb = _sample_rgb(img, np.where(ok, lat, 0.0), np.where(ok, lon, 0.0))
    out = np.where(ok[..., None], rgb, np.array(BACKGROUND, dtype=np.float64))
    return np.clip(np.round(out), 0, 255).astype(np.uint8)


def globe(img, lat0, lon0, size):
    """Orthographic view centred on (lat0, lon0), gentle limb darkening."""
    v = (np.arange(size) + 0.5) / size * 2 - 1
    X, Y = np.meshgrid(v, -v)
    lat, lon, ok = orthographic_inverse(X, Y, lat0, lon0)
    rgb = _sample_rgb(img, lat, lon) * (0.72 + 0.28 * np.sqrt(np.clip(1 - X**2 - Y**2, 0, 1)))[..., None]
    out = np.where(ok[..., None], rgb, np.array(BACKGROUND, dtype=np.float64))
    return np.clip(np.round(out), 0, 255).astype(np.uint8)


def graticule(arr, proj, step=30):
    """Draw a light lat/lon grid (every `step`°) onto an equal-area world map produced by `reproject`."""
    fwd, xmax, ymax = {"equal_earth": (equal_earth_forward, EE_XMAX, EE_YMAX),
                       "mollweide": (mollweide_forward, MW_XMAX, MW_YMAX)}[proj]
    h, w = arr.shape[:2]
    im = Image.fromarray(arr)
    draw = ImageDraw.Draw(im)
    to_px = lambda x, y: list(zip(((x / xmax + 1) / 2 * w).tolist(), ((1 - y / ymax) / 2 * h).tolist()))
    t = np.linspace(-90, 90, 181)
    for lon in range(-180, 181, step):
        draw.line(to_px(*fwd(t, np.full_like(t, float(lon)))), fill=(200, 210, 225), width=1)
    s = np.linspace(-180, 180, 361)
    for lat in range(-90 + step, 90, step):
        draw.line(to_px(*fwd(np.full_like(s, float(lat)), s)), fill=(200, 210, 225), width=1)
    return np.array(im)


def continent_centres(g, ocean, min_share=0.03):
    """Centroids (lat, lon) of land bodies holding ≥ min_share of all land, largest first."""
    from .graph import components
    land = ~np.asarray(ocean)
    lab = components(g, land)
    area = np.bincount(lab[land], weights=g.area_km2[land])
    out = []
    for k in np.argsort(-area):
        if area[k] < min_share * area.sum():
            break
        m = lab == k
        c = (g.xyz[m] * g.area_km2[m, None]).sum(axis=0)
        la, lo = xyz_to_latlon(c[None])
        out.append((float(la[0]), float(lo[0])))
    return out


def write_all(relief, pdir, g, ocean, width, globe_size=1024):
    """Write proj_equal_earth.png, proj_mollweide.png, globe_*.png and globes_sheet.png into pdir."""
    for proj in ("equal_earth", "mollweide"):
        Image.fromarray(graticule(reproject(relief, proj, width), proj)).save(pdir / f"proj_{proj}.png")
    views = [("north_pole", 90.0, 0.0), ("south_pole", -90.0, 0.0)]
    views = [(f"continent_{i + 1}", la, lo) for i, (la, lo) in enumerate(continent_centres(g, ocean))] + views
    tiles = []
    for name, la, lo in views:
        im = Image.fromarray(globe(relief, la, lo, globe_size))
        im.save(pdir / f"globe_{name}.png")
        tiles.append((f"{name} ({la:.0f}°, {lo:.0f}°)", im))
    cols, t = 4, globe_size // 2
    rows = (len(tiles) + cols - 1) // cols
    sheet = Image.new("RGB", (cols * t, rows * (t + 16)), BACKGROUND)
    draw = ImageDraw.Draw(sheet)
    for k, (label, im) in enumerate(tiles):
        x, y = (k % cols) * t, (k // cols) * (t + 16)
        sheet.paste(im.resize((t, t)), (x, y + 16))
        draw.text((x + 4, y + 2), label, fill=(230, 230, 230))
    sheet.save(pdir / "globes_sheet.png")