mirror of
git://f0xx.org/ac/ac-be-hub
synced 2026-07-29 03:38:44 +03:00
initial
This commit is contained in:
306
assets/extract_red_polygons.py
Normal file
306
assets/extract_red_polygons.py
Normal file
@@ -0,0 +1,306 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user