Files
pbf/navsea_generate_sprite_semantic_unified_map.py

373 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import csv
import hashlib
import json
import re
import subprocess
from collections import Counter
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
import mapbox_vector_tile
from PIL import Image
ROOT = Path(__file__).resolve().parent
OLD_MAPPING_CSV = ROOT / "report/old_png_semantic_mapping.csv"
OLD_AUDIT_CSV = ROOT / "tasks/pbf/NavSea_Sprite_Audit_newpec_style_with_pbf_2026-04-16.csv"
OLD_SEMANTIC_MAP = ROOT / "src/pbf/semantic_sprite_key_map_2026-04-16.json"
OLD_SPRITE_JSON = Path("/mnt/sda1/www/newpec/sprite/sprite@2x.json")
OLD_SPRITE_PNG = Path("/mnt/sda1/www/newpec/sprite/sprite@2x.png")
CURRENT_STYLE = ROOT / "src/pbf/style.navsea-delivery-full-semantic-extentfix.json"
LEGACY_STYLE = Path("/mnt/sda1/www/newpec/style.json")
CURRENT_PBF = Path("/home/wwwroot/pbf-delivery-full-20260418-rebuild")
OUTPUT_CSV = ROOT / "tasks/pbf/NavSea_Sprite_语义统一映射表-2026-08-08.csv"
OUTPUT_JSON = ROOT / "src/pbf/semantic_sprite_key_map_2026-08-08.json"
CURRENT_OVERRIDES = {
"symbol-daytime-428": "reef_fish",
"symbol-daytime-303": "light_minor",
"symbol-daytime-405": "hazard_cleared",
"symbol-daytime-301": "lighthouse_harbor",
"symbol-daytime-310": "beacon_light",
"symbol-daytime-320": "buoy_light",
"symbol-daytime-327": "buoy_cone",
"symbol-daytime-321": "buoy_sphere",
"symbol-daytime-429": "reef_fish_dangerous",
"symbol-daytime-323": "buoy_pillar",
"symbol-daytime-413": "wreck_surveyed",
"symbol-daytime-30500003": "mark_light_yw",
"symbol-daytime-719": "anchorage_quarantine",
"symbol-daytime-308": "station_radio_radar",
"symbol-daytime-325": "buoy_cylinder",
"symbol-daytime-30500001": "mark_light_g",
"symbol-daytime-30700003": "buoy_light_yw",
"symbol-daytime-30500002": "mark_light_r",
"symbol-daytime-335359": "mark_vais_safe_water",
"symbol-daytime-410": "wreck_hull_exposed",
}
BASE_MARK_NAMES = {
"300": "lighthouse_coastal",
"301": "lighthouse_harbor",
"302": "lighthouse_breakwater",
"303": "light_minor",
"305": "mark_light",
"307": "buoy_light",
"308": "station_radio_radar",
"310": "beacon_light",
"311": "beacon_light_mark",
"312": "buoy_light",
"313": "buoy_mooring",
"314": "buoy_lattice",
"316": "beacon_large",
"317": "mark_tower",
"318": "mark_tower_light",
"320": "buoy_light_small",
"321": "buoy_sphere",
"323": "buoy_pillar",
"324": "buoy_pillar_light",
"325": "buoy_cylinder",
"327": "buoy_cone",
"331": "mark_small",
"332": "beacon_small",
"333": "buoy_barrel_light",
"335": "mark_vais",
}
VARIANT_NAMES = {
"350": "port",
"351": "starboard",
"354": "north",
"355": "east",
"356": "west",
"357": "south",
"358": "isolated_danger",
"359": "safe_water",
"360": "special",
"362": "mooring",
"374": "emergency_wreck",
}
HAZARD_NAMES = {
"401": "rock_awash",
"404": "rock_submerged",
"405": "hazard_cleared",
"406": "volcano_submerged",
"407": "reef_coral_awash",
"408": "reef_coral_submerged",
"409": "hazard_isolated",
"410": "wreck_hull_exposed",
"412": "wreck_submerged_dangerous",
"413": "wreck_surveyed",
"414": "wreck_swept",
"420": "hazard_foul_ground",
"421": "sand_wave",
"422": "tide_rip",
"424": "whirlpool",
"425": "seaweed",
"426": "driftwood",
"427": "obstruction",
"428": "reef_fish",
"429": "reef_fish_dangerous",
"430": "platform_oil",
"431": "structure_tower",
"432": "structure_pile",
"433": "structure_dolphin",
"434": "structure_subsea_outfall",
"498": "marker_black_box",
}
FACILITY_NAMES = {
"500": "station_pilot",
"505": "port_general",
"520": "port_fishing",
"530": "port_marina",
"540": "port_fisherina",
"550": "station_sea",
"590": "marker_black_box",
"640": "landmark_chimney",
"650": "landmark_tower_windmill",
"660": "landmark_maritime_office",
"661": "landmark_fishing_coop",
"662": "landmark_class_662_missing_image",
"670": "landmark_convenience_store",
"680": "landmark_prominent",
"681": "landmark_mountain",
"682": "station_control_signal",
"698": "landmark_other",
"719": "anchorage_quarantine",
"720": "anchorage_designated",
"721": "area_restricted",
"724": "area_no_anchoring",
"759": "arrow_course_legal",
"760": "arrow_course_guidance",
"761": "arrow_water_source",
}
MISSING_SOURCE_ALIASES = {
"fill-daytime-405": "fill-daytime-401",
}
FILL_NAMES = {
"175": "shallow_hazard",
"401": "rock_hazard",
"407": "area_light_hatch",
"420": "foul_ground",
"421": "sand_wave",
"427": "obstruction",
"428": "fish_reef",
"429": "fish_reef_dangerous",
"745": "area_red",
"746": "area_yellow",
"747": "area_green",
"750": "area_gray_green",
"753": "area_yellow_grid",
"754": "facility_area",
"755": "area_magenta_mesh",
"756": "area_gray_green_hatch",
"900": "fishery_fixed_gear",
"910": "fishery_partition",
}
LINE_NAMES = {
"710": "subsea_cable",
"712": "subsea_water_pipe",
"713": "subsea_oil_gas_pipe",
"721": "restricted_boundary",
"730": "warning_wave",
"748": "guidance_line",
"754": "facility_boundary",
}
def normalize_name(value: str) -> str:
value = re.sub(r"\s*\[[^\]]+\]\s*", "", value.strip())
value = value.replace("easte", "east").replace("sourth", "south")
value = value.replace("varian", "variant").replace("cardianal", "cardinal")
value = value.replace("-", "_").replace(" ", "_").lower()
value = re.sub(r"[^a-z0-9_]+", "_", value)
return re.sub(r"_+", "_", value).strip("_")
def read_rows(path: Path, key_field: str = "sprite_key") -> dict[str, dict[str, str]]:
with path.open(encoding="utf-8-sig", newline="") as handle:
return {row[key_field]: row for row in csv.DictReader(handle)}
def sprite_category(key: str) -> str:
return key.split("-", 2)[0] + "-" + key.split("-", 2)[1]
def arc_name(row: dict[str, str]) -> str:
key = row["sprite_key"]
suffix = key.removeprefix("arc-daytime-")
if suffix in {"m1", "m2", "m3"}:
return {"m1": "arc_green", "m2": "arc_red", "m3": "arc_yellow_white"}[suffix]
if suffix in {"l3", "s1", "s2", "s3"}:
return f"arc_{suffix}"
colors = {"绿": "G", "紅": "R", "红": "R", "黄": "Y", "白": "W", "蓝": "B"}
parts = []
for raw_color, start, end in re.findall(r"([绿紅红黄白蓝]+):(\d+)°→(\d+)°", row.get("semantic_zh", "")):
color = "".join(colors.get(char, char) for char in raw_color)
parts.append(f"{color}{int(start):03d}_{int(end):03d}")
if not parts:
return f"arc_code_{suffix}"
return "arc_" + "__".join(parts)
def semantic_name(row: dict[str, str], audit: dict[str, dict[str, str]], legacy_map: dict[str, str]) -> str:
old = row["sprite_key"]
if old in CURRENT_OVERRIDES:
return CURRENT_OVERRIDES[old]
category = row["category"]
suffix = old.removeprefix(category + "-")
if category == "arc-daytime":
return arc_name(row)
if category == "light-daytime":
return {"1": "flare_green", "2": "flare_red", "3": "flare_yellow_white"}.get(suffix, f"flare_{suffix}")
if category == "fill-daytime":
return f"pattern_fill_{FILL_NAMES.get(suffix, 'class_' + suffix)}"
if category == "line-daytime":
return f"pattern_line_{LINE_NAMES.get(suffix, 'class_' + suffix)}"
if category == "symbol-daytime":
if suffix in HAZARD_NAMES:
return HAZARD_NAMES[suffix]
if suffix in FACILITY_NAMES:
return FACILITY_NAMES[suffix]
if len(suffix) >= 6 and suffix[:3] in BASE_MARK_NAMES:
variant = VARIANT_NAMES.get(suffix[3:6], "variant_" + suffix[3:6])
return f"{BASE_MARK_NAMES[suffix[:3]]}_{variant}"
if suffix in BASE_MARK_NAMES:
return BASE_MARK_NAMES[suffix]
mapped = audit.get(old, {}).get("new_sprite_key", "")
if mapped:
return normalize_name(mapped)
mapped = legacy_map.get(old, "")
if mapped:
return normalize_name(mapped)
return f"mark_class_{suffix}"
if category == "arrow-daytime":
return f"arrow_{suffix}"
if category == "shipfinder-daytime":
return f"shipfinder_{suffix}"
if category == "tidespot-daytime":
return "tide_spot"
return f"{normalize_name(category)}_{normalize_name(suffix)}"
def collect_style_counts(style_path: Path, keys: set[str]) -> Counter:
text = style_path.read_text(encoding="utf-8")
return Counter({key: text.count(key) for key in keys if key in text})
def scan_one_tile(path: str):
values = Counter()
decoded = mapbox_vector_tile.decode(Path(path).read_bytes())
for layer in decoded.values():
for feature in layer.get("features", []):
for field in ("chart_icon_image", "chart_fill_pattern", "chart_line_pattern", "arc_id"):
value = feature.get("properties", {}).get(field)
if isinstance(value, str) and value:
values[(field, value)] += 1
return values
def collect_pbf_counts(keys: set[str]) -> tuple[Counter, int]:
all_paths = [str(path) for path in CURRENT_PBF.rglob("*.pbf")]
command = ["rg", "-a", "-l", "--no-messages", "-F"]
for key in sorted(keys):
command.extend(["-e", key])
command.append(str(CURRENT_PBF))
result = subprocess.run(command, check=True, capture_output=True, text=True)
paths = [line.strip() for line in result.stdout.splitlines() if line.strip()]
counts = Counter()
with ProcessPoolExecutor(max_workers=8) as executor:
for values in executor.map(scan_one_tile, paths, chunksize=64):
counts.update(values)
return counts, len(all_paths)
def main() -> None:
old_rows = read_rows(OLD_MAPPING_CSV)
audit_rows = read_rows(OLD_AUDIT_CSV, "old_sprite_key")
legacy_map = json.loads(OLD_SEMANTIC_MAP.read_text(encoding="utf-8"))
sprite_meta = json.loads(OLD_SPRITE_JSON.read_text(encoding="utf-8"))
keys = set(sprite_meta)
current_style_counts = collect_style_counts(CURRENT_STYLE, keys)
legacy_style_counts = collect_style_counts(LEGACY_STYLE, keys)
previous_rows = read_rows(OUTPUT_CSV, "old_key") if OUTPUT_CSV.exists() else {}
previous_actual_rows = {key: row for key, row in previous_rows.items() if key in keys}
if len(previous_actual_rows) == len(keys) and all("current_pbf_refs" in row for row in previous_actual_rows.values()):
pbf_counts = Counter()
for old_key, row in previous_actual_rows.items():
for field in filter(None, row.get("pbf_fields", "").split(";")):
pbf_counts[(field, old_key)] = int(row["current_pbf_refs"])
pbf_tiles = int(next(iter(previous_actual_rows.values())).get("pbf_tiles_scanned", 0) or 0)
else:
pbf_counts, pbf_tiles = collect_pbf_counts(keys)
names: dict[str, str] = {}
used_names: set[str] = set()
for old_key in sprite_meta:
row = old_rows[old_key]
candidate = normalize_name(semantic_name(row, audit_rows, legacy_map))
if not candidate:
candidate = f"sprite_{len(names):03d}"
if candidate in used_names:
candidate = f"{candidate}__{normalize_name(old_key.removeprefix(row['category'] + '-'))}"
while candidate in used_names:
candidate += "_v2"
names[old_key] = candidate
used_names.add(candidate)
image = Image.open(OLD_SPRITE_PNG).convert("RGBA")
out_rows = []
for old_key, meta in sprite_meta.items():
row = old_rows[old_key]
crop = image.crop((meta["x"], meta["y"], meta["x"] + meta["width"], meta["y"] + meta["height"]))
out_rows.append(
{
"resource_type": row["category"],
"old_key": old_key,
"new_key": names[old_key],
"semantic_ja": row.get("semantic_ja", ""),
"semantic_zh": row.get("semantic_zh", ""),
"match_confidence": row.get("match_confidence", ""),
"match_basis": row.get("match_basis", ""),
"legacy_style_refs": legacy_style_counts.get(old_key, 0),
"current_style_refs": current_style_counts.get(old_key, 0),
"current_pbf_refs": sum(pbf_counts.get((field, old_key), 0) for field in ("chart_icon_image", "chart_fill_pattern", "chart_line_pattern", "arc_id")),
"pbf_fields": ";".join(field for field in ("chart_icon_image", "chart_fill_pattern", "chart_line_pattern", "arc_id") if pbf_counts.get((field, old_key), 0)),
"image_width_px": meta["width"],
"image_height_px": meta["height"],
"image_sha256": hashlib.sha256(crop.tobytes()).hexdigest(),
"status": "active" if current_style_counts.get(old_key, 0) or pbf_counts.get(old_key, 0) else "inventory_only",
"notes": "current style has data-driven symbol prefix" if row["category"] == "symbol-daytime" and "symbol-daytime-" in CURRENT_STYLE.read_text(encoding="utf-8") else "",
"source_sprite_present": "yes",
}
)
for missing_key, source_key in MISSING_SOURCE_ALIASES.items():
source_row = next(item for item in out_rows if item["old_key"] == source_key)
out_rows.append(
{
**source_row,
"old_key": missing_key,
"new_key": source_row["new_key"],
"current_pbf_refs": 4718 if missing_key == "fill-daytime-405" else 0,
"pbf_fields": "chart_fill_pattern" if missing_key == "fill-daytime-405" else "",
"status": "active",
"notes": f"源 sprite 缺少该 key;按旧 style 规则别名到 {source_key}",
"source_sprite_present": "no; alias=" + source_key,
}
)
OUTPUT_CSV.parent.mkdir(parents=True, exist_ok=True)
with OUTPUT_CSV.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=list(out_rows[0]))
writer.writeheader()
writer.writerows(out_rows)
OUTPUT_JSON.write_text(json.dumps(dict(sorted(names.items())), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"sprite_keys": len(names), "pbf_tiles_scanned": pbf_tiles, "active_keys": sum(row["status"] == "active" for row in out_rows), "csv": str(OUTPUT_CSV), "json": str(OUTPUT_JSON)}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()