mirror of
git://f0xx.org/ac/ac-be-hub
synced 2026-07-29 04:38:17 +03:00
307 lines
9.7 KiB
Python
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()
|