from __future__ import annotations import json import shutil from pathlib import Path from PIL import Image ROOT = Path(__file__).resolve().parent MAP_JSON = ROOT / "src/pbf/semantic_sprite_key_map_2026-08-08.json" SOURCE_SPRITE_JSON = Path("/mnt/sda1/www/newpec/sprite/sprite@2x.json") SOURCE_SPRITE_PNG = Path("/mnt/sda1/www/newpec/sprite/sprite@2x.png") OUTPUT_SPRITE_DIR = Path("/mnt/sda1/www/newpec/sprite-semantic-unified-20260808") SOURCE_STYLE = ROOT / "src/pbf/style.navsea-delivery-full-semantic-extentfix.json" OUTPUT_STYLE = ROOT / "src/pbf/style.navsea-delivery-full-semantic-unified-20260808.json" DEPLOYED_STYLE = Path("/mnt/sda1/www/newpec/domain/style.navsea-delivery-full-semantic-unified-20260808.json") SPRITE_URL = "http://192.168.200.184/newpec/sprite-semantic-unified-20260808/sprite" PBF_URL = "http://192.168.200.184/pbf-delivery-full-semantic-unified-20260808/{z}/{x}/{y}.pbf" def pack_sprite(mapping: dict[str, str], source_meta: dict[str, dict], scale: float, max_width: int): source = Image.open(SOURCE_SPRITE_PNG).convert("RGBA") positions: dict[str, dict] = {} packed: list[tuple[str, tuple[int, int, int, int]]] = [] cursor_x = cursor_y = row_height = 0 for old_key, new_key in mapping.items(): meta = source_meta[old_key] width = max(1, round(meta["width"] * scale)) height = max(1, round(meta["height"] * scale)) if cursor_x and cursor_x + width > max_width: cursor_x = 0 cursor_y += row_height row_height = 0 packed.append((old_key, (cursor_x, cursor_y, width, height))) positions[new_key] = { "x": cursor_x, "y": cursor_y, "width": width, "height": height, "pixelRatio": 1 if scale == 0.5 else 2, } cursor_x += width row_height = max(row_height, height) image = Image.new("RGBA", (max_width, cursor_y + row_height), (0, 0, 0, 0)) for old_key, (x, y, width, height) in packed: meta = source_meta[old_key] crop = source.crop((meta["x"], meta["y"], meta["x"] + meta["width"], meta["y"] + meta["height"])) if scale != 1.0: crop = crop.resize((width, height), Image.Resampling.LANCZOS) image.paste(crop, (x, y)) return image, positions def replace_literals(value, mapping: dict[str, str]): if isinstance(value, dict): return {key: replace_literals(item, mapping) for key, item in value.items()} if isinstance(value, list): return [replace_literals(item, mapping) for item in value] if isinstance(value, str): return mapping.get(value, value) return value def build_style(mapping: dict[str, str]) -> dict: style = json.loads(SOURCE_STYLE.read_text(encoding="utf-8")) style = replace_literals(style, mapping) # The source style contains a second, older semantic vocabulary such as # ``buoy_can`` and ``coastal_lighthouse``. Those names are not guaranteed # to exist in the actual PNG atlas. The delivery PBF already carries the # resolved, auditable image key in chart_icon_image/chart_fill_pattern, so # use those properties as the single style/sprite contract. This also # makes an unavailable source image render as empty instead of silently # pointing at a non-existent sprite key. direct_icon = ["coalesce", ["get", "chart_icon_image"], ""] direct_fill_pattern = ["coalesce", ["get", "chart_fill_pattern"], ""] direct_line_pattern = ["coalesce", ["get", "chart_line_pattern"], ""] for layer in style.get("layers", []): layer_id = layer.get("id") if layer_id in { "hazard-points", "anchorage-symbols", "anchor-hazard-points", "anchor-hazard-points-428", "nav-marks-harbor-lighthouses", "nav-marks-breakwater-lighthouses", "nav-marks-small-lights", "nav-marks-light-beacons", "nav-marks", "facility-points", "pilot-station-points", "landmark-points", }: layer.setdefault("layout", {})["icon-image"] = direct_icon if layer.get("id") == "nav-light-arc": layer.setdefault("layout", {})["icon-image"] = [ "case", ["has", "arc_id"], ["get", "arc_id"], [ "match", ["get", "light_color_code"], "green", mapping["arc-daytime-m1"], "red", mapping["arc-daytime-m2"], mapping["arc-daytime-m3"], ], ] if layer_id == "nav-light-flare": layer.setdefault("layout", {})["icon-image"] = [ "match", ["get", "light_color_code"], "green", mapping["light-daytime-1"], "red", mapping["light-daytime-2"], "yellow", mapping["light-daytime-3"], "white", mapping["light-daytime-3"], "amber", mapping["light-daytime-3"], "orange", mapping["light-daytime-3"], mapping["light-daytime-3"], ] if layer_id == "fishery-areas": layer.setdefault("paint", {})["fill-pattern"] = direct_fill_pattern if layer_id == "special-pattern-line-754": layer.setdefault("paint", {})["line-pattern"] = direct_line_pattern style["name"] = "NavSea Delivery Full Semantic Unified 20260808" style["sprite"] = SPRITE_URL source = style.get("sources", {}).get("navsea_delivery") if source: source["tiles"] = [PBF_URL] return style def main() -> None: mapping = json.loads(MAP_JSON.read_text(encoding="utf-8")) source_meta = json.loads(SOURCE_SPRITE_JSON.read_text(encoding="utf-8")) if OUTPUT_SPRITE_DIR.exists(): shutil.rmtree(OUTPUT_SPRITE_DIR) OUTPUT_SPRITE_DIR.mkdir(parents=True, exist_ok=True) for scale, json_name, png_name, width in ( (1.0, "sprite@2x.json", "sprite@2x.png", 2048), (0.5, "sprite.json", "sprite.png", 1024), ): image, metadata = pack_sprite(mapping, source_meta, scale, width) image.save(OUTPUT_SPRITE_DIR / png_name) (OUTPUT_SPRITE_DIR / json_name).write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") style = build_style(mapping) OUTPUT_STYLE.write_text(json.dumps(style, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") DEPLOYED_STYLE.parent.mkdir(parents=True, exist_ok=True) DEPLOYED_STYLE.write_text(json.dumps(style, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps({"sprite_dir": str(OUTPUT_SPRITE_DIR), "sprite_keys": len(mapping), "style": str(OUTPUT_STYLE), "deployed_style": str(DEPLOYED_STYLE)}, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()