feat: add Kyushu semantic icon test delivery
This commit is contained in:
425
navsea_build_kyushu_semantic_icon_test.py
Normal file
425
navsea_build_kyushu_semantic_icon_test.py
Normal file
@@ -0,0 +1,425 @@
|
||||
#!/usr/bin/env python3
|
||||
"""从当前全国 Full 基线生成九州语义图标替换测试包。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from collections import Counter
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import mercantile
|
||||
|
||||
from navsea_tile_reencode_guard import (
|
||||
assert_reencoded_tile_safe,
|
||||
decode_tile_with_extents,
|
||||
encode_layers_preserving_extents,
|
||||
)
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent
|
||||
CONFIG_PATH = REPO_ROOT / "tasks/pbf/navsea_semantic_icon_map_v1.json"
|
||||
SOURCE_ROOT = Path("/home/wwwroot/pbf-delivery-full-20260418-rebuild")
|
||||
TARGET_ROOT = Path("/home/wwwroot/pbf-delivery-kyushu-semantic-icon-v1-20260806")
|
||||
TARGET_TILE_URL = "http://192.168.200.184/pbf-delivery-kyushu-semantic-icon-v1-20260806/{z}/{x}/{y}.pbf"
|
||||
KYUSHU_BBOX = (128.0, 30.0, 132.5, 34.9)
|
||||
ZOOMS = tuple(range(5, 13))
|
||||
|
||||
SPRITE_SOURCE = Path("/mnt/sda1/www/newpec/sprite-semantic")
|
||||
SPRITE_TARGET = Path("/mnt/sda1/www/newpec/sprite-kyushu-semantic-icon-v1-20260806")
|
||||
SPRITE_URL = "http://192.168.200.184/newpec/sprite-kyushu-semantic-icon-v1-20260806/sprite"
|
||||
|
||||
STYLE_SOURCE = REPO_ROOT / "src/pbf/style.navsea-delivery-full-semantic-extentfix.json"
|
||||
STYLE_OUTPUT = REPO_ROOT / "src/pbf/style.navsea-delivery-kyushu-semantic-icon-v1.json"
|
||||
STYLE_DEPLOY = Path("/mnt/sda1/www/newpec/domain/style.navsea-delivery-kyushu-semantic-icon-v1.json")
|
||||
|
||||
NEWPEC_STYLE_SOURCE = REPO_ROOT / "src/pbf/style.json"
|
||||
NEWPEC_STYLE_OUTPUT = REPO_ROOT / "src/pbf/style.navsea-newpec-kyushu-icon-test-v1.json"
|
||||
NEWPEC_STYLE_DEPLOY = Path("/mnt/sda1/www/newpec/domain/style.navsea-newpec-kyushu-icon-test-v1.json")
|
||||
|
||||
REPORT_DIR = REPO_ROOT / "report/kyushu_semantic_icon_v1_2026-08-06"
|
||||
|
||||
SIMPLE_ICON_LAYERS = {
|
||||
"hazard-points",
|
||||
"anchorage-symbols",
|
||||
"anchor-hazard-points",
|
||||
"anchor-hazard-points-428",
|
||||
"facility-points",
|
||||
"landmark-points",
|
||||
}
|
||||
|
||||
STYLE_EXTRA_MAP = {
|
||||
"light_flare_green": "fl_G",
|
||||
"light_flare_red": "fl_R",
|
||||
"light_flare_yellow_white": "fl_YW",
|
||||
}
|
||||
|
||||
CANONICAL_ICON_MAP = {
|
||||
"coastal_lighthouse_over_15m": "lt_cst",
|
||||
"harbor_lighthouse": "lt_hbr",
|
||||
"breakwater_lighthouse": "lt_bkw",
|
||||
"minor_light": "lt_min",
|
||||
"light_beacon": "lt_bcn",
|
||||
"light_buoy": "bu_lit",
|
||||
"lattice_buoy": "bu_lat",
|
||||
"pillar_buoy": "bu_pil",
|
||||
"can_buoy": "bu_can",
|
||||
"buoy_generic": "bu_gen",
|
||||
"nav_mark_vais": "mk_vais",
|
||||
}
|
||||
|
||||
|
||||
def load_config() -> dict[str, Any]:
|
||||
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def collect_tiles() -> list[tuple[int, int, int, Path]]:
|
||||
tiles: list[tuple[int, int, int, Path]] = []
|
||||
west, south, east, north = KYUSHU_BBOX
|
||||
for z in ZOOMS:
|
||||
for tile in mercantile.tiles(west, south, east, north, [z]):
|
||||
rel = Path(str(tile.z)) / str(tile.x) / f"{tile.y}.pbf"
|
||||
source = SOURCE_ROOT / rel
|
||||
if source.exists():
|
||||
tiles.append((tile.z, tile.x, tile.y, rel))
|
||||
return sorted(tiles)
|
||||
|
||||
|
||||
def copy_link(source: Path, target: Path) -> None:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
try:
|
||||
target.hardlink_to(source)
|
||||
except OSError:
|
||||
shutil.copy2(source, target)
|
||||
|
||||
|
||||
def class_icon(layer_name: str, class_code: object, config: dict[str, Any]) -> str | None:
|
||||
code = str(class_code)
|
||||
if layer_name in {"navigation_hazard_point", "anchor_caution_hazard_point"}:
|
||||
return config["hazard_class_map"].get(code)
|
||||
if layer_name == "onshore_structure_point":
|
||||
return config["landmark_class_map"].get(code)
|
||||
if layer_name == "anchorage_point":
|
||||
return config["anchorage_class_map"].get(code)
|
||||
if layer_name == "facility_boundary_point":
|
||||
return config["facility_class_map"].get(code)
|
||||
return None
|
||||
|
||||
|
||||
def navigation_icon(properties: dict[str, Any], config: dict[str, Any]) -> str | None:
|
||||
legacy = properties.get("chart_icon_image")
|
||||
if isinstance(legacy, str):
|
||||
mapped = config["sprite_key_map"].get(legacy)
|
||||
if mapped:
|
||||
return mapped
|
||||
display_code = properties.get("display_code")
|
||||
if display_code is not None:
|
||||
mapped = config["display_code_map"].get(str(display_code))
|
||||
if mapped:
|
||||
return mapped
|
||||
canonical = properties.get("canonical_object_type")
|
||||
if isinstance(canonical, str):
|
||||
return CANONICAL_ICON_MAP.get(canonical)
|
||||
return None
|
||||
|
||||
|
||||
def arc_icon(properties: dict[str, Any], config: dict[str, Any]) -> str | None:
|
||||
if properties.get("chart_symbol_code") != "lighthouse":
|
||||
return None
|
||||
if not properties.get("light_color_code"):
|
||||
return None
|
||||
if (
|
||||
properties.get("canonical_object_type") == "coastal_lighthouse_over_15m"
|
||||
and properties.get("light_sector_mode") == "sector"
|
||||
):
|
||||
return config["arc_rules"]["sector"]
|
||||
color = str(properties.get("light_color_code"))
|
||||
return config["arc_rules"].get(color, config["arc_rules"]["other"])
|
||||
|
||||
|
||||
def rewrite_tile(
|
||||
source: Path,
|
||||
target: Path,
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
verify_geometry: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
decoded, extents = decode_tile_with_extents(source.read_bytes())
|
||||
changed_features = 0
|
||||
icon_counts: Counter[str] = Counter()
|
||||
arc_counts: Counter[str] = Counter()
|
||||
old_values: Counter[str] = Counter()
|
||||
unknown_old_values: Counter[str] = Counter()
|
||||
output_layers: list[dict[str, Any]] = []
|
||||
|
||||
for layer_name, layer in decoded.items():
|
||||
output_features = []
|
||||
for feature in layer["features"]:
|
||||
properties = dict(feature.get("properties") or {})
|
||||
before = dict(properties)
|
||||
legacy = properties.get("chart_icon_image")
|
||||
if isinstance(legacy, str) and (
|
||||
legacy.startswith("symbol-daytime-") or legacy.startswith("arc-daytime-")
|
||||
):
|
||||
old_values[legacy] += 1
|
||||
|
||||
icon_id = class_icon(layer_name, properties.get("class_code"), config)
|
||||
if layer_name == "navigation_marks":
|
||||
icon_id = icon_id or navigation_icon(properties, config)
|
||||
if icon_id is None and isinstance(legacy, str):
|
||||
icon_id = config["sprite_key_map"].get(legacy)
|
||||
|
||||
if icon_id:
|
||||
properties["icon_id"] = icon_id
|
||||
icon_counts[icon_id] += 1
|
||||
properties.pop("chart_icon_image", None)
|
||||
|
||||
if layer_name == "navigation_marks":
|
||||
arc_id = arc_icon(properties, config)
|
||||
if arc_id:
|
||||
properties["arc_id"] = arc_id
|
||||
arc_counts[arc_id] += 1
|
||||
|
||||
for value in properties.values():
|
||||
if isinstance(value, str) and (
|
||||
value.startswith("symbol-daytime-") or value.startswith("arc-daytime-")
|
||||
):
|
||||
unknown_old_values[value] += 1
|
||||
|
||||
if properties != before:
|
||||
changed_features += 1
|
||||
output_feature = {
|
||||
"geometry": feature["geometry"],
|
||||
"properties": properties,
|
||||
}
|
||||
if feature.get("id") is not None:
|
||||
output_feature["id"] = feature["id"]
|
||||
output_features.append(output_feature)
|
||||
output_layers.append({"name": layer_name, "features": output_features})
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(encode_layers_preserving_extents(output_layers, extents))
|
||||
if verify_geometry:
|
||||
assert_reencoded_tile_safe(source, target)
|
||||
return {
|
||||
"changed_features": changed_features,
|
||||
"icon_counts": dict(icon_counts),
|
||||
"arc_counts": dict(arc_counts),
|
||||
"old_values": dict(old_values),
|
||||
"unknown_old_values": dict(unknown_old_values),
|
||||
}
|
||||
|
||||
|
||||
def rewrite_tile_job(args: tuple[str, str, dict[str, Any], bool]) -> dict[str, Any]:
|
||||
source, target, config, verify_geometry = args
|
||||
result = rewrite_tile(
|
||||
Path(source), Path(target), config, verify_geometry=verify_geometry
|
||||
)
|
||||
result["source"] = source
|
||||
return result
|
||||
|
||||
|
||||
def transform_style(config: dict[str, Any]) -> dict[str, Any]:
|
||||
style = json.loads(STYLE_SOURCE.read_text(encoding="utf-8"))
|
||||
key_map = dict(config["sprite_key_map"])
|
||||
key_map.update(STYLE_EXTRA_MAP)
|
||||
|
||||
def transform(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: transform(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [transform(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
if value == "chart_icon_image":
|
||||
return "icon_id"
|
||||
return key_map.get(value, value)
|
||||
return value
|
||||
|
||||
style = transform(style)
|
||||
style["name"] = "NavSea Kyushu Semantic Icon Test v1"
|
||||
style["sprite"] = SPRITE_URL
|
||||
style["metadata"] = {
|
||||
"navsea_test_version": "kyushu-semantic-icon-v1-20260806",
|
||||
"source_baseline": str(SOURCE_ROOT),
|
||||
"icon_map": str(CONFIG_PATH),
|
||||
}
|
||||
style["sources"]["navsea_delivery"]["tiles"] = [TARGET_TILE_URL]
|
||||
|
||||
for layer in style["layers"]:
|
||||
layer_id = layer.get("id")
|
||||
layout = layer.get("layout") or {}
|
||||
if layer_id in SIMPLE_ICON_LAYERS:
|
||||
layout["icon-image"] = ["coalesce", ["get", "icon_id"], ""]
|
||||
layer["layout"] = layout
|
||||
elif layer_id == "nav-light-arc":
|
||||
layout["icon-image"] = ["coalesce", ["get", "arc_id"], ""]
|
||||
layer["layout"] = layout
|
||||
return style
|
||||
|
||||
|
||||
def write_style(style: dict[str, Any], path: Path, deploy_path: Path) -> None:
|
||||
payload = json.dumps(style, ensure_ascii=False, indent=2) + "\n"
|
||||
path.write_text(payload, encoding="utf-8")
|
||||
deploy_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
deploy_path.write_text(payload, encoding="utf-8")
|
||||
|
||||
|
||||
def write_newpec_style() -> None:
|
||||
style = json.loads(NEWPEC_STYLE_SOURCE.read_text(encoding="utf-8"))
|
||||
style["name"] = "NavSea Newpec Kyushu Icon Test v1"
|
||||
style["metadata"] = {
|
||||
"navsea_test_version": "kyushu-semantic-icon-v1-20260806",
|
||||
"role": "left_original_newpec_reference",
|
||||
}
|
||||
write_style(style, NEWPEC_STYLE_OUTPUT, NEWPEC_STYLE_DEPLOY)
|
||||
|
||||
|
||||
def build_sprite(config: dict[str, Any]) -> dict[str, Any]:
|
||||
if SPRITE_TARGET.exists():
|
||||
raise RuntimeError(f"refusing to overwrite existing sprite target: {SPRITE_TARGET}")
|
||||
SPRITE_TARGET.mkdir(parents=True)
|
||||
source_meta: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
for filename in ("sprite.json", "sprite@2x.json"):
|
||||
source_meta[filename] = json.loads((SPRITE_SOURCE / filename).read_text(encoding="utf-8"))
|
||||
|
||||
key_map = dict(config["sprite_key_map"])
|
||||
key_map.update(STYLE_EXTRA_MAP)
|
||||
fallback_sources = {"lm_cus": "symbol-daytime-660"}
|
||||
added: dict[str, str] = {}
|
||||
removed = set(key_map)
|
||||
for filename, meta in source_meta.items():
|
||||
# 测试包只保留新的短语键,避免把未使用的旧 symbol/arc 键继续带入 sprite。
|
||||
output: dict[str, dict[str, Any]] = {}
|
||||
for old_key, new_key in key_map.items():
|
||||
source_key = old_key if old_key in meta else None
|
||||
if source_key is None:
|
||||
for candidate, candidate_new in key_map.items():
|
||||
if candidate_new == new_key and candidate in meta:
|
||||
source_key = candidate
|
||||
break
|
||||
if source_key is None:
|
||||
source_key = fallback_sources.get(new_key)
|
||||
if source_key and source_key in meta:
|
||||
output[new_key] = dict(meta[source_key])
|
||||
added[new_key] = source_key
|
||||
(SPRITE_TARGET / filename).write_text(
|
||||
json.dumps(output, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
for filename in ("sprite.png", "sprite@2x.png"):
|
||||
shutil.copy2(SPRITE_SOURCE / filename, SPRITE_TARGET / filename)
|
||||
return {"added": added, "fallbacks": {"lm_cus": fallback_sources["lm_cus"]}}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = load_config()
|
||||
tiles = collect_tiles()
|
||||
if TARGET_ROOT.exists():
|
||||
raise RuntimeError(f"refusing to overwrite existing PBF target: {TARGET_ROOT}")
|
||||
TARGET_ROOT.mkdir(parents=True)
|
||||
|
||||
totals: Counter[str] = Counter()
|
||||
old_values: Counter[str] = Counter()
|
||||
unknown_old_values: Counter[str] = Counter()
|
||||
icon_counts: Counter[str] = Counter()
|
||||
arc_counts: Counter[str] = Counter()
|
||||
rewritten = 0
|
||||
candidate_needles = [
|
||||
key.encode("utf-8") for key in config["sprite_key_map"]
|
||||
] + [
|
||||
b"navigation_marks",
|
||||
b"navigation_hazard_point",
|
||||
b"anchor_caution_hazard_point",
|
||||
b"anchorage_point",
|
||||
b"facility_boundary_point",
|
||||
b"onshore_structure_point",
|
||||
]
|
||||
rewrite_jobs: list[tuple[str, str, dict[str, Any], bool]] = []
|
||||
candidate_count = 0
|
||||
for z, x, y, rel in tiles:
|
||||
source = SOURCE_ROOT / rel
|
||||
target = TARGET_ROOT / rel
|
||||
raw = source.read_bytes()
|
||||
if not any(needle in raw for needle in candidate_needles):
|
||||
copy_link(source, target)
|
||||
continue
|
||||
rewrite_jobs.append(
|
||||
(str(source), str(target), config, candidate_count < 3)
|
||||
)
|
||||
candidate_count += 1
|
||||
|
||||
worker_count = min(os.cpu_count() or 4, 12)
|
||||
with ProcessPoolExecutor(max_workers=worker_count) as executor:
|
||||
for result in executor.map(rewrite_tile_job, rewrite_jobs, chunksize=8):
|
||||
if result["changed_features"]:
|
||||
rewritten += 1
|
||||
totals["features_changed"] += result["changed_features"]
|
||||
old_values.update(result["old_values"])
|
||||
unknown_old_values.update(result["unknown_old_values"])
|
||||
icon_counts.update(result["icon_counts"])
|
||||
arc_counts.update(result["arc_counts"])
|
||||
|
||||
sprite = build_sprite(config)
|
||||
style = transform_style(config)
|
||||
write_style(style, STYLE_OUTPUT, STYLE_DEPLOY)
|
||||
write_newpec_style()
|
||||
|
||||
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
summary = {
|
||||
"version": "kyushu-semantic-icon-v1-20260806",
|
||||
"source_root": str(SOURCE_ROOT),
|
||||
"target_root": str(TARGET_ROOT),
|
||||
"bbox": KYUSHU_BBOX,
|
||||
"zooms": list(ZOOMS),
|
||||
"tiles": len(tiles),
|
||||
"tiles_rewritten": rewritten,
|
||||
"candidate_tiles": candidate_count,
|
||||
**totals,
|
||||
"old_values": dict(old_values),
|
||||
"unknown_old_values_after_rewrite": dict(unknown_old_values),
|
||||
"icon_counts": dict(icon_counts),
|
||||
"arc_counts": dict(arc_counts),
|
||||
"sprite": sprite,
|
||||
"style": str(STYLE_OUTPUT),
|
||||
"newpec_style": str(NEWPEC_STYLE_OUTPUT),
|
||||
}
|
||||
(REPORT_DIR / "kyushu_semantic_icon_v1.json").write_text(
|
||||
json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
lines = [
|
||||
"# 九州语义图标替换测试版 v1",
|
||||
"",
|
||||
f"- PBF 来源:`{SOURCE_ROOT}`",
|
||||
f"- PBF 输出:`{TARGET_ROOT}`",
|
||||
f"- 范围:`{KYUSHU_BBOX}`",
|
||||
f"- zoom:`{ZOOMS[0]}-{ZOOMS[-1]}`",
|
||||
f"- 瓦片数:`{len(tiles)}`",
|
||||
f"- 重写瓦片:`{rewritten}`",
|
||||
f"- 改动 feature:`{totals['features_changed']}`",
|
||||
f"- sprite:`{SPRITE_TARGET}`",
|
||||
f"- delivery style:`{STYLE_OUTPUT}`",
|
||||
f"- newpec style:`{NEWPEC_STYLE_OUTPUT}`",
|
||||
"",
|
||||
"## 旧图标值",
|
||||
"",
|
||||
"```json",
|
||||
json.dumps(dict(old_values), ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
"",
|
||||
"## 审计关注",
|
||||
"",
|
||||
"- `unknown_old_values_after_rewrite` 必须为空。",
|
||||
"- `lm_cus` 当前使用 `symbol-daytime-660` 的临时视觉回退,需人工确认。",
|
||||
"- 几何使用原 tile extent 重编码,并执行 extent 安全检查。",
|
||||
]
|
||||
(REPORT_DIR / "kyushu_semantic_icon_v1.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user