raw · 6727 bytes
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 | """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") |