docs(pbf): archive Full production provenance and engineering history
This commit is contained in:
372
navsea_generate_sprite_semantic_unified_map.py
Normal file
372
navsea_generate_sprite_semantic_unified_map.py
Normal file
@@ -0,0 +1,372 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user