#!/usr/bin/env python3 """Extract red land polygons from Mercator reference image -> layered SVG paths.""" from __future__ import annotations import json import math from collections import deque from pathlib import Path import numpy as np from PIL import Image ROOT = Path(__file__).resolve().parent SRC = ROOT / "mercator-source.jpg" OUT_FLAT = ROOT / "hub-logo-continents-mercator-flat.svg" OUT_GLOBE = ROOT / "hub-logo-continents-mercator-globe.svg" OUT_GLOBE_SNAP_VERTICAL = ROOT / "hub-logo-continents-mercator-globe.snapshot-vertical.svg" OUT_META = ROOT / "continents-meta.json" # Globe framing in 1920x1080 logo scene. GLOBE_CX = 720.0 GLOBE_CY = 560.0 GLOBE_R = 322.0 # Orthographic center (visible hemisphere). CENTER_LAT = 18.0 CENTER_LON = 12.0 def red_mask(rgb: np.ndarray) -> np.ndarray: r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2] return (r.astype(np.int16) > 150) & (g.astype(np.int16) < 120) & (b.astype(np.int16) < 120) def dilate(mask: np.ndarray, radius: int = 3) -> np.ndarray: h, w = mask.shape out = mask.copy() ys, xs = np.where(mask) for y, x in zip(ys, xs): y0 = max(0, y - radius) y1 = min(h, y + radius + 1) x0 = max(0, x - radius) x1 = min(w, x + radius + 1) if mask[y0:y1, x0:x1].any(): out[y0:y1, x0:x1] = True return out def label_components(mask: np.ndarray) -> tuple[np.ndarray, int]: h, w = mask.shape labels = np.zeros((h, w), dtype=np.int32) current = 0 for y in range(h): for x in range(w): if not mask[y, x] or labels[y, x]: continue current += 1 q = deque([(x, y)]) labels[y, x] = current while q: cx, cy = q.popleft() for nx, ny in ((cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)): if 0 <= nx < w and 0 <= ny < h and mask[ny, nx] and labels[ny, nx] == 0: labels[ny, nx] = current q.append((nx, ny)) return labels, current def trace_boundary(labels: np.ndarray, label_id: int) -> list[tuple[int, int]]: h, w = labels.shape comp = labels == label_id if not comp.any(): return [] ys, xs = np.where(comp) start = (int(xs[0]), int(ys[0])) x, y = start path = [start] dirs = [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)] dir_idx = 0 def is_land(px: int, py: int) -> bool: return 0 <= px < w and 0 <= py < h and labels[py, px] == label_id for _ in range(w * h * 4): found = False for i in range(8): nd = (dir_idx + i) % 8 dx, dy = dirs[nd] nx, ny = x + dx, y + dy if is_land(nx, ny): x, y = nx, ny dir_idx = (nd + 6) % 8 path.append((x, y)) found = True break if not found: break if (x, y) == start and len(path) > 8: break return path def simplify(points: list[tuple[int, int]], min_dist: float = 2.0) -> list[tuple[int, int]]: if len(points) < 3: return points out = [points[0]] for p in points[1:]: ox, oy = out[-1] if math.hypot(p[0] - ox, p[1] - oy) >= min_dist: out.append(p) if out[0] != out[-1]: out.append(out[0]) return out def path_d(points: list[tuple[float, float]]) -> str: if len(points) < 3: return "" x0, y0 = points[0] parts = [f"M {x0:.1f} {y0:.1f}"] for x, y in points[1:]: parts.append(f"L {x:.1f} {y:.1f}") parts.append("Z") return " ".join(parts) def mercator_y_to_lat(y: float, h: float) -> float: t = 1.0 - (y / h) t = min(max(t, 0.02), 0.98) return math.degrees(math.atan(math.sinh(math.pi * (2 * t - 1)))) def lat_lon_to_globe(lat: float, lon: float) -> tuple[float, float]: lat_r = math.radians(lat) lon_r = math.radians(lon) lon0 = math.radians(CENTER_LON) dlon = lon_r - lon0 x = GLOBE_CX + GLOBE_R * math.cos(lat_r) * math.sin(dlon) y = GLOBE_CY - GLOBE_R * math.sin(lat_r) return x, y def map_to_globe(points: list[tuple[int, int]], w: int, h: int) -> str: mapped: list[tuple[float, float]] = [] for x, y in points: lon = (x / w) * 360.0 - 180.0 lat = mercator_y_to_lat(float(y), float(h)) mapped.append(lat_lon_to_globe(lat, lon)) if len(mapped) < 3: return "" return path_d(mapped) def main() -> None: img = Image.open(SRC).convert("RGB") rgb = np.array(img) w, h = rgb.shape[1], rgb.shape[0] mask = dilate(red_mask(rgb), radius=3) labels, n = label_components(mask) components: list[dict] = [] for label_id in range(1, n + 1): area = int((labels == label_id).sum()) if area < 500: continue boundary = trace_boundary(labels, label_id) if len(boundary) < 20: continue boundary = simplify(boundary, min_dist=3.5) flat = path_d([(float(x), float(y)) for x, y in boundary]) globe = map_to_globe(boundary, w, h) if not flat: continue ys, xs = np.where(labels == label_id) components.append( { "id": label_id, "area": area, "bbox": [int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())], "flat": flat, "globe": globe, } ) components.sort(key=lambda c: c["area"], reverse=True) flat_paths = "\n ".join( f'' for c in components ) globe_paths = "\n ".join( f'' for c in components ) OUT_FLAT.write_text( f""" {flat_paths} """, encoding="utf-8", ) # Two-step alignment: # 1) Vertical fit: Mercator north/south poles match globe top/bottom. # 2) Horizontal fit: layer centers/pivots match at globe center. globe_diameter = GLOBE_R * 2 inner_sy = globe_diameter / h inner_sx = inner_sy inner_ty = GLOBE_CY - GLOBE_R inner_tx_vertical = 0.0 inner_tx = GLOBE_CX - (w * inner_sx) / 2 outer_sx = 1.0 outer_sy = 1.0 def globe_svg( inner_tx_val: float, inner_ty_val: float, inner_sx_val: float, inner_sy_val: float, outer_sx_val: float, outer_sy_val: float, label: str, ) -> str: return f""" {flat_paths} """ OUT_GLOBE_SNAP_VERTICAL.write_text( globe_svg( inner_tx_vertical, inner_ty, inner_sx, inner_sy, outer_sx, outer_sy, "Continents vertical-fit snapshot", ), encoding="utf-8", ) OUT_GLOBE.write_text( globe_svg( inner_tx, inner_ty, inner_sx, inner_sy, outer_sx, outer_sy, "Continents layered Mercator overlay", ), encoding="utf-8", ) OUT_META.write_text( json.dumps( { "source": str(SRC.name), "size": [w, h], "components": len(components), "layers": { "vertical_snapshot": { "translate": [inner_tx_vertical, inner_ty], "scale": [inner_sx, inner_sy], }, "inner": {"translate": [inner_tx, inner_ty], "scale": [inner_sx, inner_sy]}, "outer": {"scale": [outer_sx, outer_sy]}, "pivot": [GLOBE_CX, GLOBE_CY], }, "areas_top10": [c["area"] for c in components[:10]], }, indent=2, ), encoding="utf-8", ) print(f"components={len(components)}") print(f"wrote {OUT_FLAT}") print(f"wrote {OUT_GLOBE}") if __name__ == "__main__": main()