1
0
mirror of git://f0xx.org/ac/ac-be-hub synced 2026-07-29 07:38:50 +03:00
Files
ac-be-hub/assets/extract_red_polygons.py
Anton Afanasyeu 6013b848c5 initial
2026-06-23 12:29:37 +02:00

307 lines
9.7 KiB
Python

#!/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'<path id="continent-{c["id"]}" data-area="{c["area"]}" d="{c["flat"]}"/>'
for c in components
)
globe_paths = "\n ".join(
f'<path id="continent-{c["id"]}" data-area="{c["area"]}" d="{c["globe"]}"/>'
for c in components
)
OUT_FLAT.write_text(
f"""<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}" role="img" aria-label="Extracted continents (flat Mercator plate)">
<rect width="100%" height="100%" fill="#b8d4e8"/>
<g id="continents-inner-layer" fill="#9ab3cc" fill-opacity="0.82" stroke="#dce8f6" stroke-opacity="0.78" stroke-width="1.1">
{flat_paths}
</g>
</svg>
""",
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"""<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid slice" role="img" aria-label="{label}">
<defs>
<clipPath id="globeClip"><circle cx="{GLOBE_CX:.0f}" cy="{GLOBE_CY:.0f}" r="{GLOBE_R:.0f}"/></clipPath>
<radialGradient id="continentRelief" cx="34%" cy="22%" r="78%">
<stop offset="0%" stop-color="#dbe7f6" stop-opacity="0.45"/>
<stop offset="58%" stop-color="#9ab3cc" stop-opacity="0.22"/>
<stop offset="100%" stop-color="#6a7f97" stop-opacity="0.18"/>
</radialGradient>
<filter id="continentDepth" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="-4" dy="-6" stdDeviation="3" flood-color="#f4fbff" flood-opacity="0.2"/>
<feDropShadow dx="4" dy="6" stdDeviation="4" flood-color="#0a1220" flood-opacity="0.28"/>
</filter>
</defs>
<g id="continents-outer-layer" clip-path="url(#globeClip)" transform="translate({GLOBE_CX:.0f} {GLOBE_CY:.0f}) scale({outer_sx_val:.3f} {outer_sy_val:.3f}) translate(-{GLOBE_CX:.0f} -{GLOBE_CY:.0f})">
<g id="continents-inner-layer" transform="translate({inner_tx_val:.2f} {inner_ty_val:.2f}) scale({inner_sx_val:.4f} {inner_sy_val:.4f})">
<g id="continents-flat" fill="url(#continentRelief)" fill-opacity="0.9" stroke="#dce8f6" stroke-opacity="0.78" stroke-width="1.2" filter="url(#continentDepth)">
{flat_paths}
</g>
</g>
</g>
</svg>
"""
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()