#!/usr/bin/env python3 """ NavSea render audit for original vs engineering tiles/styles. This script compares one original style/tile set against one engineering style/tile set. Audit goal: - use legacy fid as the primary object identity - fall back to geometry + stable legacy properties when fid is missing - evaluate each style layer against decoded tile features - extract per-object render observations (icon/text/line/fill) - compare original and engineering render observations at tile-instance scope - emit Markdown and JSON audit reports for review - persist the latest audit result into MySQL for fid-level trace-back Important scope note: - render comparison is performed per tile feature instance - identity is "fid first", but zoom/tile instance is preserved because rendering is zoom-sensitive """ from __future__ import annotations import argparse import hashlib import json from collections import Counter, defaultdict from dataclasses import dataclass from pathlib import Path from typing import Any import mapbox_vector_tile import pymysql from navsea_tile_builder import NavSeaFidCodec DEFAULT_ORIGINAL_STYLE_PATH = Path("/mnt/sda1/www/newpec/style.patched.local.json") DEFAULT_ENGINEERING_STYLE_PATH = Path("/mnt/sda1/www/newpec/navsea-engineering.json") DEFAULT_ORIGINAL_TILE_ROOT = Path( "/home/wwwroot/newpec/exported_auto/" "tile.mapple-on.jp__newpec-mvt-20260106__z___x___y_.pbf/tiles" ) DEFAULT_ENGINEERING_TILE_ROOT = Path("/home/wwwroot/pbf-engineering-karatsu-10nm") DEFAULT_REPORT_MD_PATH = Path("/root/weather/NavSea_Original_vs_Engineering_Render_Audit_Karatsu_10nm.md") DEFAULT_REPORT_JSON_PATH = Path("/root/weather/NavSea_Original_vs_Engineering_Render_Audit_Karatsu_10nm.json") DEFAULT_AUDIT_NAME = "navsea_original_vs_engineering_karatsu_10nm" DB_BATCH_SIZE = 1000 MAX_EXAMPLES = 30 ORIGINAL_STYLE_PATH = DEFAULT_ORIGINAL_STYLE_PATH ENGINEERING_STYLE_PATH = DEFAULT_ENGINEERING_STYLE_PATH ORIGINAL_TILE_ROOT = DEFAULT_ORIGINAL_TILE_ROOT ENGINEERING_TILE_ROOT = DEFAULT_ENGINEERING_TILE_ROOT REPORT_MD_PATH = DEFAULT_REPORT_MD_PATH REPORT_JSON_PATH = DEFAULT_REPORT_JSON_PATH AUDIT_NAME = DEFAULT_AUDIT_NAME SKIP_DB_PERSIST = False COMPARISON_LABEL = "工程版" FID_CODEC: NavSeaFidCodec | None = None MATCH_ON_FID_ONLY = False DERIVED_PROPERTY_PREFIXES = ( "canonical_", "semantic_", "detection_", "render_", "chart_", "light_", "hazard_", "area_", "source_layer_", ) DERIVED_PROPERTY_KEYS = { "fid_algo_id", "fid_key_id", "fid_navsea_int", "fid_legacy_raw", "normalization_bundle_id", "source_layer_rule_id", "trace_status", "feature_id", "depth_value_m", "clearance_height_m", "least_depth_m", "bearing_deg", } STABLE_FALLBACK_KEYS = ( "分類番号", "形状分類番号", "表示用番号", "灯色", "灯略記", "明弧/分孤", "表示位置", "名称", "名称補助", "日本語地名", "英文字地名", "水深値(m)", "高さ(m)", "高さ/深度(m)", "角度", ) EXPRESSION_OPS = { "get", "match", "coalesce", "concat", "number", "literal", "rgba", "case", "has", "any", "all", "==", "!=", ">=", "<=", ">", "<", "/", "*", "interpolate", "step", "zoom", } DEPTH_NUMERIC_SOURCE_LAYERS = { "L海底地形", "L概略等深線", "L等深線", } CLEARANCE_NUMERIC_SOURCE_LAYERS = { "p高さ制限", } SAFETY_ICON_SOURCE_LAYERS = { "p航路標識群", "p航路標識", "P航路標識", "p灯台", "p灯浮標", "p灯立標", "p灯標", } SUPPORT_FILL_SOURCE_LAYERS = { "P穴", } @dataclass(frozen=True) class DbConfig: host: str = "localhost" port: int = 3306 user: str = "root" password: str = "2chi9ks2" database: str = "pbf_analysis" unix_socket: str | None = "/tmp/mysql.sock" @dataclass(frozen=True) class AuditFeature: dataset: str tile_z: int tile_x: int tile_y: int layer_name: str geom_type: str object_id: str fid_legacy: str | None native_feature_id: int | None properties: dict[str, Any] @dataclass(frozen=True) class RenderObservation: component_type: str style_layer_id: str signature: str payload: dict[str, Any] def canonical_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) def hash_text(text: str) -> str: return hashlib.sha1(text.encode("utf-8")).hexdigest()[:16] def text_or_none(value: Any) -> str | None: if value is None: return None text = str(value).strip() return text or None def numeric_or_text(value: Any) -> float | str | None: if value is None: return None if isinstance(value, (int, float)): return float(value) text = text_or_none(value) if text is None: return None try: return float(text) except ValueError: return text def normalize_color(value: Any) -> str | None: if value is None: return None if isinstance(value, str): return value if isinstance(value, list): return canonical_json(value) return str(value) def compare_numbers(lhs: Any, rhs: Any, op: str) -> bool: lhs_num = numeric_or_text(lhs) rhs_num = numeric_or_text(rhs) if isinstance(lhs_num, float) and isinstance(rhs_num, float): if op == ">=": return lhs_num >= rhs_num if op == "<=": return lhs_num <= rhs_num if op == ">": return lhs_num > rhs_num if op == "<": return lhs_num < rhs_num lhs_text = "" if lhs is None else str(lhs) rhs_text = "" if rhs is None else str(rhs) if op == ">=": return lhs_text >= rhs_text if op == "<=": return lhs_text <= rhs_text if op == ">": return lhs_text > rhs_text return lhs_text < rhs_text def evaluate_expression(expr: Any, feature: AuditFeature, zoom: int) -> Any: if not isinstance(expr, list): return expr if not expr: return expr head = expr[0] if not isinstance(head, str) or head not in EXPRESSION_OPS: return [evaluate_expression(item, feature, zoom) for item in expr] if head == "get": key = expr[1] return feature.properties.get(key) if head == "literal": return expr[1] if head == "coalesce": for item in expr[1:]: value = evaluate_expression(item, feature, zoom) if value not in (None, ""): return value return None if head == "concat": return "".join("" if (value := evaluate_expression(item, feature, zoom)) is None else str(value) for item in expr[1:]) if head == "number": value = evaluate_expression(expr[1], feature, zoom) parsed = numeric_or_text(value) if isinstance(parsed, float): return parsed fallback = evaluate_expression(expr[2], feature, zoom) if len(expr) > 2 else None return fallback if head == "rgba": values = [evaluate_expression(item, feature, zoom) for item in expr[1:5]] return f"rgba({values[0]},{values[1]},{values[2]},{values[3]})" if head == "has": key = expr[1] return key in feature.properties and feature.properties.get(key) not in (None, "") if head == "zoom": return zoom if head in {"==", "!="}: lhs = evaluate_expression(expr[1], feature, zoom) rhs = evaluate_expression(expr[2], feature, zoom) result = lhs == rhs return result if head == "==" else not result if head in {">=", "<=", ">", "<"}: lhs = evaluate_expression(expr[1], feature, zoom) rhs = evaluate_expression(expr[2], feature, zoom) return compare_numbers(lhs, rhs, head) if head == "any": return any(bool(evaluate_expression(item, feature, zoom)) for item in expr[1:]) if head == "all": return all(bool(evaluate_expression(item, feature, zoom)) for item in expr[1:]) if head == "case": clauses = expr[1:] for idx in range(0, len(clauses) - 1, 2): if bool(evaluate_expression(clauses[idx], feature, zoom)): return evaluate_expression(clauses[idx + 1], feature, zoom) return evaluate_expression(clauses[-1], feature, zoom) if clauses else None if head == "match": value = evaluate_expression(expr[1], feature, zoom) arms = expr[2:] fallback = arms[-1] if arms else None for idx in range(0, len(arms) - 1, 2): label = arms[idx] result = arms[idx + 1] if isinstance(label, list): if value in [evaluate_expression(item, feature, zoom) for item in label]: return evaluate_expression(result, feature, zoom) else: if value == evaluate_expression(label, feature, zoom): return evaluate_expression(result, feature, zoom) return evaluate_expression(fallback, feature, zoom) if head == "/": lhs = evaluate_expression(expr[1], feature, zoom) rhs = evaluate_expression(expr[2], feature, zoom) lhs_num = numeric_or_text(lhs) rhs_num = numeric_or_text(rhs) if isinstance(lhs_num, float) and isinstance(rhs_num, float) and rhs_num != 0: return lhs_num / rhs_num return None if head == "*": lhs = evaluate_expression(expr[1], feature, zoom) rhs = evaluate_expression(expr[2], feature, zoom) lhs_num = numeric_or_text(lhs) rhs_num = numeric_or_text(rhs) if isinstance(lhs_num, float) and isinstance(rhs_num, float): return lhs_num * rhs_num return None if head == "step": input_value = evaluate_expression(expr[1], feature, zoom) input_num = numeric_or_text(input_value) if not isinstance(input_num, float): return evaluate_expression(expr[2], feature, zoom) result = evaluate_expression(expr[2], feature, zoom) stops = expr[3:] for idx in range(0, len(stops), 2): stop = evaluate_expression(stops[idx], feature, zoom) stop_num = numeric_or_text(stop) if not isinstance(stop_num, float): continue if idx + 1 >= len(stops): break if input_num >= stop_num: result = evaluate_expression(stops[idx + 1], feature, zoom) else: break return result if head == "interpolate": input_value = evaluate_expression(expr[2], feature, zoom) input_num = numeric_or_text(input_value) if not isinstance(input_num, float): return None stops = expr[3:] prev_stop = None prev_value = None for idx in range(0, len(stops), 2): stop_num = numeric_or_text(evaluate_expression(stops[idx], feature, zoom)) stop_value = evaluate_expression(stops[idx + 1], feature, zoom) if idx + 1 < len(stops) else None if not isinstance(stop_num, float): continue if input_num == stop_num: return stop_value if input_num < stop_num: return prev_value if prev_value is not None else stop_value prev_stop = stop_num prev_value = stop_value return prev_value return None def layer_visible(layer: dict[str, Any], zoom: int) -> bool: if zoom < int(layer.get("minzoom", 0)): return False maxzoom = layer.get("maxzoom") if maxzoom is not None and zoom >= int(maxzoom): return False layout = layer.get("layout") or {} if layout.get("visibility") == "none": return False return True def layer_matches_feature(layer: dict[str, Any], feature: AuditFeature) -> bool: if layer.get("source-layer") != feature.layer_name: return False if not layer_visible(layer, feature.tile_z): return False filter_expr = layer.get("filter") if filter_expr is None: return True return bool(evaluate_expression(filter_expr, feature, feature.tile_z)) def evaluate_style_value(layer: dict[str, Any], section: str, key: str, feature: AuditFeature) -> Any: payload = layer.get(section) or {} if key not in payload: return None return evaluate_expression(payload[key], feature, feature.tile_z) def build_signature(component_type: str, payload: dict[str, Any]) -> str: normalized = { k: payload[k] for k in sorted(payload) if k != "layer_id" and payload[k] not in (None, "", [], {}) } return f"{component_type}:{canonical_json(normalized)}" def collect_layer_observations(layer: dict[str, Any], feature: AuditFeature) -> list[RenderObservation]: observations: list[RenderObservation] = [] layer_id = str(layer["id"]) layer_type = str(layer.get("type", "")) if layer_type == "symbol": icon_image = evaluate_style_value(layer, "layout", "icon-image", feature) text_value = evaluate_style_value(layer, "layout", "text-field", feature) text_color = evaluate_style_value(layer, "paint", "text-color", feature) text_anchor = evaluate_style_value(layer, "layout", "text-anchor", feature) text_size = evaluate_style_value(layer, "layout", "text-size", feature) icon_size = evaluate_style_value(layer, "layout", "icon-size", feature) if icon_image not in (None, ""): payload = { "layer_id": layer_id, "icon_image": icon_image, "icon_size": icon_size, } observations.append( RenderObservation( component_type="icon", style_layer_id=layer_id, signature=build_signature("icon", payload), payload=payload, ) ) if text_value not in (None, ""): payload = { "layer_id": layer_id, "text_value": str(text_value), "text_color": normalize_color(text_color), "text_anchor": text_anchor, "text_size": text_size, } observations.append( RenderObservation( component_type="text", style_layer_id=layer_id, signature=build_signature("text", payload), payload=payload, ) ) return observations if layer_type == "line": payload = { "layer_id": layer_id, "line_color": normalize_color(evaluate_style_value(layer, "paint", "line-color", feature)), "line_width": evaluate_style_value(layer, "paint", "line-width", feature), "line_dasharray": evaluate_style_value(layer, "paint", "line-dasharray", feature), "line_pattern": evaluate_style_value(layer, "paint", "line-pattern", feature), } observations.append( RenderObservation( component_type="line", style_layer_id=layer_id, signature=build_signature("line", payload), payload=payload, ) ) return observations if layer_type == "fill": payload = { "layer_id": layer_id, "fill_color": normalize_color(evaluate_style_value(layer, "paint", "fill-color", feature)), "fill_pattern": evaluate_style_value(layer, "paint", "fill-pattern", feature), "fill_outline_color": normalize_color(evaluate_style_value(layer, "paint", "fill-outline-color", feature)), "fill_opacity": evaluate_style_value(layer, "paint", "fill-opacity", feature), } observations.append( RenderObservation( component_type="fill", style_layer_id=layer_id, signature=build_signature("fill", payload), payload=payload, ) ) return observations return observations def collect_render_observations(style: dict[str, Any], feature: AuditFeature) -> list[RenderObservation]: observations: list[RenderObservation] = [] for layer in style.get("layers", []): if "source-layer" not in layer: continue if not layer_matches_feature(layer, feature): continue observations.extend(collect_layer_observations(layer, feature)) return observations def load_style(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def iter_tile_paths(root: Path) -> list[Path]: return sorted(root.glob("*/*/*.pbf")) def stable_fallback_properties(properties: dict[str, Any]) -> dict[str, Any]: return { key: properties[key] for key in STABLE_FALLBACK_KEYS if key in properties and properties[key] not in (None, "") } def geometry_hash(geometry: dict[str, Any]) -> str: return hash_text(canonical_json(geometry)) def extract_legacy_fid(properties: dict[str, Any], native_feature_id: int | None = None) -> str | None: legacy = properties.get("fid_legacy_raw") if legacy not in (None, ""): try: return str(int(str(legacy))) except ValueError: return str(legacy) fid = properties.get("fid") if fid not in (None, ""): fid_text = str(fid).strip() # Original tiles store legacy fid as decimal integers. Only treat # an 8-char property fid as encrypted hex when it is not plain digits. if fid_text.isdigit(): return str(int(fid_text)) if FID_CODEC is not None and len(fid_text) == 8: try: decoded = FID_CODEC.decrypt_number(int(fid_text, 16)) return str(decoded) except ValueError: pass try: return str(int(fid_text)) except ValueError: return fid_text if native_feature_id is not None: if FID_CODEC is not None: try: decoded = FID_CODEC.decrypt_number(int(native_feature_id)) return str(decoded) except ValueError: pass try: feature_id_int = int(native_feature_id) if feature_id_int > 2147483647: feature_id_int -= 4294967296 return str(feature_id_int) except ValueError: return str(native_feature_id) return None def object_identity( layer_name: str, geometry: dict[str, Any], properties: dict[str, Any], native_feature_id: int | None = None, ) -> tuple[str, str | None]: geom_type = str(geometry.get("type", "")) legacy_source_layer = text_or_none(properties.get("source_layer_jp")) or layer_name legacy_fid = extract_legacy_fid(properties, native_feature_id) if legacy_fid: if MATCH_ON_FID_ONLY: return f"fid:{legacy_fid}|geom:{geom_type}", legacy_fid return f"fid:{legacy_fid}|src:{legacy_source_layer}|geom:{geom_type}", legacy_fid fallback_payload = { "source_layer": legacy_source_layer, "geom_type": geom_type, "geometry_hash": geometry_hash(geometry), "stable_props": stable_fallback_properties(properties), } return f"fallback:{hash_text(canonical_json(fallback_payload))}", None def feature_instance_id(feature: AuditFeature) -> str: if MATCH_ON_FID_ONLY: return f"{feature.object_id}|z:{feature.tile_z}|x:{feature.tile_x}|y:{feature.tile_y}" return ( f"{feature.object_id}|z:{feature.tile_z}|x:{feature.tile_x}|" f"y:{feature.tile_y}|layer:{feature.layer_name}" ) def decode_tile_instances(dataset: str, tile_root: Path, style: dict[str, Any], tile_path: Path) -> dict[str, dict[str, Any]]: instances: dict[str, dict[str, Any]] = {} rel = tile_path.relative_to(tile_root) z = int(rel.parts[0]) x = int(rel.parts[1]) y = int(tile_path.stem) decoded = mapbox_vector_tile.decode(tile_path.read_bytes()) for layer_name, payload in decoded.items(): for feature in payload.get("features", []): geometry = feature.get("geometry") or {} properties = dict(feature.get("properties") or {}) native_feature_id = feature.get("id") native_feature_id = int(native_feature_id) if isinstance(native_feature_id, int) else None object_id, legacy_fid = object_identity(layer_name, geometry, properties, native_feature_id) audit_feature = AuditFeature( dataset=dataset, tile_z=z, tile_x=x, tile_y=y, layer_name=layer_name, geom_type=str(geometry.get("type", "")), object_id=object_id, fid_legacy=legacy_fid, native_feature_id=native_feature_id, properties=properties, ) instance_id = feature_instance_id(audit_feature) instances[instance_id] = { "feature": audit_feature, "observations": collect_render_observations(style, audit_feature), } return instances def summarize_observations(observations: list[RenderObservation]) -> dict[str, list[str]]: grouped: dict[str, set[str]] = defaultdict(set) for observation in observations: grouped[observation.component_type].add(observation.signature) return {key: sorted(values) for key, values in grouped.items()} def summarize_observation_payloads(observations: list[RenderObservation]) -> dict[str, list[dict[str, Any]]]: grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) for observation in observations: grouped[observation.component_type].append(dict(observation.payload)) return { key: sorted(values, key=canonical_json) for key, values in grouped.items() } def normalize_component_map(component_map: dict[str, list[str]]) -> dict[str, tuple[str, ...]]: return {key: tuple(values) for key, values in sorted(component_map.items())} def normalize_payload_map(payload_map: dict[str, list[dict[str, Any]]]) -> dict[str, tuple[dict[str, Any], ...]]: return { key: tuple(sorted(values, key=canonical_json)) for key, values in sorted(payload_map.items()) } def compare_instance( original: dict[str, Any] | None, engineering: dict[str, Any] | None, ) -> dict[str, Any]: if original is None: eng_feature = engineering["feature"] engineering_components = normalize_component_map( summarize_observations(engineering["observations"]) ) engineering_payloads = normalize_payload_map( summarize_observation_payloads(engineering["observations"]) ) return { "status": "extra_in_engineering", "object_instance_id": feature_instance_id(eng_feature), "object_id": eng_feature.object_id, "fid_legacy": eng_feature.fid_legacy, "tile": f"{eng_feature.tile_z}/{eng_feature.tile_x}/{eng_feature.tile_y}", "tile_z": eng_feature.tile_z, "tile_x": eng_feature.tile_x, "tile_y": eng_feature.tile_y, "source_layer": eng_feature.layer_name, "canonical_object_type": text_or_none(eng_feature.properties.get("canonical_object_type")), "original_components": {}, "engineering_components": engineering_components, "original_payloads": {}, "engineering_payloads": engineering_payloads, } if engineering is None: orig_feature = original["feature"] original_components = normalize_component_map( summarize_observations(original["observations"]) ) original_payloads = normalize_payload_map( summarize_observation_payloads(original["observations"]) ) return { "status": "missing_in_engineering", "object_instance_id": feature_instance_id(orig_feature), "object_id": orig_feature.object_id, "fid_legacy": orig_feature.fid_legacy, "tile": f"{orig_feature.tile_z}/{orig_feature.tile_x}/{orig_feature.tile_y}", "tile_z": orig_feature.tile_z, "tile_x": orig_feature.tile_x, "tile_y": orig_feature.tile_y, "source_layer": orig_feature.layer_name, "canonical_object_type": None, "original_components": original_components, "engineering_components": {}, "original_payloads": original_payloads, "engineering_payloads": {}, } orig_feature = original["feature"] eng_feature = engineering["feature"] original_components = normalize_component_map(summarize_observations(original["observations"])) engineering_components = normalize_component_map(summarize_observations(engineering["observations"])) original_payloads = normalize_payload_map(summarize_observation_payloads(original["observations"])) engineering_payloads = normalize_payload_map(summarize_observation_payloads(engineering["observations"])) if original_components == engineering_components: status = "exact_match" elif not original_components and engineering_components: status = "extra_in_engineering" elif original_components and not engineering_components: status = "missing_in_engineering" else: status = "mismatch" return { "status": status, "object_instance_id": feature_instance_id(orig_feature), "object_id": orig_feature.object_id, "fid_legacy": orig_feature.fid_legacy or eng_feature.fid_legacy, "tile": f"{orig_feature.tile_z}/{orig_feature.tile_x}/{orig_feature.tile_y}", "tile_z": orig_feature.tile_z, "tile_x": orig_feature.tile_x, "tile_y": orig_feature.tile_y, "source_layer": text_or_none(eng_feature.properties.get("source_layer_jp")) or orig_feature.layer_name, "canonical_object_type": text_or_none(eng_feature.properties.get("canonical_object_type")), "original_components": original_components, "engineering_components": engineering_components, "original_payloads": original_payloads, "engineering_payloads": engineering_payloads, } def format_component_map(component_map: dict[str, tuple[str, ...]] | dict[str, list[str]]) -> str: if not component_map: return "none" parts = [] for key in sorted(component_map): values = component_map[key] parts.append(f"{key}={list(values)}") return "; ".join(parts) def has_component(component_map: dict[str, tuple[str, ...]] | dict[str, list[str]], component_type: str) -> bool: values = component_map.get(component_type) return bool(values) def get_component_payloads( payload_map: dict[str, tuple[dict[str, Any], ...]] | dict[str, list[dict[str, Any]]], component_type: str, ) -> list[dict[str, Any]]: values = payload_map.get(component_type) or () return [dict(value) for value in values] def payload_has_nontransparent_fill(payloads: list[dict[str, Any]]) -> bool: for payload in payloads: color = text_or_none(payload.get("fill_color")) opacity = payload.get("fill_opacity") if opacity == 0: continue if color in (None, "rgba(0,0,0,0)", "rgba(0, 0, 0, 0)"): continue return True return False def payload_has_transparent_fill(payloads: list[dict[str, Any]]) -> bool: for payload in payloads: color = text_or_none(payload.get("fill_color")) opacity = payload.get("fill_opacity") if opacity == 0: return True if color in ("rgba(0,0,0,0)", "rgba(0, 0, 0, 0)"): return True return False def text_payloads_with_numeric_value(payloads: list[dict[str, Any]]) -> list[dict[str, Any]]: numeric_payloads: list[dict[str, Any]] = [] for payload in payloads: text_value = text_or_none(payload.get("text_value")) if text_value is None: continue if any(char.isdigit() for char in text_value): numeric_payloads.append(payload) return numeric_payloads def classify_annotation_issue(item: dict[str, Any]) -> str | None: original_components = item["original_components"] engineering_components = item["engineering_components"] original_has_text = has_component(original_components, "text") engineering_has_text = has_component(engineering_components, "text") original_has_icon = has_component(original_components, "icon") engineering_has_icon = has_component(engineering_components, "icon") if original_has_text and not engineering_has_text: return "text_missing_in_engineering" if not original_has_text and engineering_has_text: return "extra_text_in_engineering" if original_has_icon and not engineering_has_icon: return "icon_missing_in_engineering" if not original_has_icon and engineering_has_icon: return "extra_icon_in_engineering" return None def classify_style_semantic_issue(item: dict[str, Any]) -> str | None: source_layer = item["source_layer"] or "unknown" original_payloads = item.get("original_payloads", {}) engineering_payloads = item.get("engineering_payloads", {}) original_fill_payloads = get_component_payloads(original_payloads, "fill") engineering_fill_payloads = get_component_payloads(engineering_payloads, "fill") original_text_payloads = get_component_payloads(original_payloads, "text") engineering_text_payloads = get_component_payloads(engineering_payloads, "text") original_icon_payloads = get_component_payloads(original_payloads, "icon") engineering_icon_payloads = get_component_payloads(engineering_payloads, "icon") if source_layer in SUPPORT_FILL_SOURCE_LAYERS: if payload_has_transparent_fill(original_fill_payloads) and payload_has_nontransparent_fill(engineering_fill_payloads): return "support_layer_strengthened_fill" original_numeric_texts = text_payloads_with_numeric_value(original_text_payloads) engineering_numeric_texts = text_payloads_with_numeric_value(engineering_text_payloads) if source_layer in DEPTH_NUMERIC_SOURCE_LAYERS and original_numeric_texts and not engineering_numeric_texts: return "depth_numeric_missing" if source_layer in CLEARANCE_NUMERIC_SOURCE_LAYERS and original_numeric_texts and not engineering_numeric_texts: return "clearance_numeric_missing" if source_layer in SAFETY_ICON_SOURCE_LAYERS and original_icon_payloads and not engineering_icon_payloads: return "safety_icon_missing" return None def write_reports( *, original_count: int, engineering_count: int, result_count: int, status_counter: Counter[str], source_layer_counter: Counter[tuple[str, str]], mismatch_examples: list[dict[str, Any]], annotation_issue_counter: Counter[tuple[str, str]], annotation_issue_examples: list[dict[str, Any]], style_semantic_issue_counter: Counter[tuple[str, str]], style_semantic_issue_examples: list[dict[str, Any]], ) -> None: payload = { "original_feature_instances": original_count, "engineering_feature_instances": engineering_count, "result_count": result_count, "status_counts": dict(status_counter), "source_layer_issue_counts": [ {"status": status, "source_layer": source_layer, "count": count} for (status, source_layer), count in source_layer_counter.most_common(30) ], "annotation_issue_counts": [ {"issue": issue, "source_layer": source_layer, "count": count} for (issue, source_layer), count in annotation_issue_counter.most_common(30) ], "style_semantic_issue_counts": [ {"issue": issue, "source_layer": source_layer, "count": count} for (issue, source_layer), count in style_semantic_issue_counter.most_common(30) ], "mismatch_examples": mismatch_examples, "annotation_issue_examples": annotation_issue_examples, "style_semantic_issue_examples": style_semantic_issue_examples, } REPORT_JSON_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") lines = [ f"# NavSea 原始版 vs {COMPARISON_LABEL}渲染审计报告", "", "## 范围", "", f"- 原始样式: `{ORIGINAL_STYLE_PATH}`", f"- {COMPARISON_LABEL}样式: `{ENGINEERING_STYLE_PATH}`", f"- 原始瓦片根目录: `{ORIGINAL_TILE_ROOT}`", f"- {COMPARISON_LABEL}瓦片根目录: `{ENGINEERING_TILE_ROOT}`", f"- 原始 feature 实例数: `{original_count}`", f"- {COMPARISON_LABEL} feature 实例数: `{engineering_count}`", f"- 审计结果数: `{result_count}`", "", "## 匹配口径", "", "- 主键优先使用 legacy `fid`。", "- 没有 `fid` 的对象,回退到 `geometry + 稳定旧属性`。", "- 审计粒度保留 tile 实例,因为渲染具有 zoom 敏感性。", "", "## 结果统计", "", ] for status, count in status_counter.most_common(): lines.append(f"- `{status}`: `{count}`") lines.extend([ "", "## 主要问题层", "", ]) if not source_layer_counter: lines.append("- 没有发现差异。") else: for (status, source_layer), count in source_layer_counter.most_common(20): lines.append(f"- `{status}` | `{source_layer}` | `{count}`") lines.extend([ "", "## 标注专项问题", "", ]) if not annotation_issue_counter: lines.append("- 没有发现标注专项问题。") else: for (issue, source_layer), count in annotation_issue_counter.most_common(20): lines.append(f"- `{issue}` | `{source_layer}` | `{count}`") lines.extend([ "", "## Style 语义专项问题", "", ]) if not style_semantic_issue_counter: lines.append("- 没有发现 style 语义专项问题。") else: for (issue, source_layer), count in style_semantic_issue_counter.most_common(20): lines.append(f"- `{issue}` | `{source_layer}` | `{count}`") lines.extend([ "", "## 差异样例", "", ]) if not mismatch_examples: lines.append("- 没有差异样例。") else: for item in mismatch_examples: lines.append( f"- `{item['status']}` | tile=`{item['tile']}` | source_layer=`{item['source_layer']}` | " f"fid=`{item['fid_legacy']}` | object=`{item['canonical_object_type'] or 'n/a'}`" ) lines.append(f" original: {format_component_map(item['original_components'])}") lines.append(f" engineering: {format_component_map(item['engineering_components'])}") lines.extend([ "", "## 标注问题样例", "", ]) if not annotation_issue_examples: lines.append("- 没有标注问题样例。") else: for item in annotation_issue_examples: lines.append( f"- `{item['annotation_issue']}` | tile=`{item['tile']}` | source_layer=`{item['source_layer']}` | " f"fid=`{item['fid_legacy']}` | object=`{item['canonical_object_type'] or 'n/a'}`" ) lines.append(f" original: {format_component_map(item['original_components'])}") lines.append(f" engineering: {format_component_map(item['engineering_components'])}") lines.extend([ "", "## Style 语义问题样例", "", ]) if not style_semantic_issue_examples: lines.append("- 没有 style 语义问题样例。") else: for item in style_semantic_issue_examples: lines.append( f"- `{item['style_semantic_issue']}` | tile=`{item['tile']}` | source_layer=`{item['source_layer']}` | " f"fid=`{item['fid_legacy']}` | object=`{item['canonical_object_type'] or 'n/a'}`" ) lines.append(f" original: {format_component_map(item['original_components'])}") lines.append(f" engineering: {format_component_map(item['engineering_components'])}") REPORT_MD_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8") def db_connect() -> pymysql.Connection: cfg = DbConfig() kwargs: dict[str, Any] = { "host": cfg.host, "port": cfg.port, "user": cfg.user, "password": cfg.password, "database": cfg.database, "charset": "utf8mb4", "autocommit": False, } if cfg.unix_socket: kwargs["unix_socket"] = cfg.unix_socket return pymysql.connect(**kwargs) def ensure_audit_tables(cur: pymysql.cursors.Cursor) -> None: cur.execute( """ CREATE TABLE IF NOT EXISTS navsea_render_audit_run ( audit_name VARCHAR(128) NOT NULL, original_style_path VARCHAR(512) NOT NULL, engineering_style_path VARCHAR(512) NOT NULL, original_tile_root VARCHAR(512) NOT NULL, engineering_tile_root VARCHAR(512) NOT NULL, original_feature_instances INT NOT NULL, engineering_feature_instances INT NOT NULL, result_count INT NOT NULL, status_counts_json LONGTEXT NOT NULL, source_layer_issue_counts_json LONGTEXT NOT NULL, report_md_path VARCHAR(512) NOT NULL, report_json_path VARCHAR(512) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (audit_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """ ) cur.execute( """ CREATE TABLE IF NOT EXISTS navsea_render_audit_result ( audit_name VARCHAR(64) NOT NULL, object_instance_key VARCHAR(64) NOT NULL, object_instance_id TEXT NOT NULL, object_id VARCHAR(255) NOT NULL, fid_legacy VARCHAR(64) DEFAULT NULL, tile_z INT NOT NULL, tile_x INT NOT NULL, tile_y INT NOT NULL, source_layer VARCHAR(100) DEFAULT NULL, canonical_object_type VARCHAR(191) DEFAULT NULL, status VARCHAR(32) NOT NULL, original_components_json LONGTEXT NOT NULL, engineering_components_json LONGTEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (audit_name, object_instance_key), KEY idx_render_audit_fid (fid_legacy), KEY idx_render_audit_status (status), KEY idx_render_audit_layer (source_layer) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """ ) def insert_result_batch(cur: pymysql.cursors.Cursor, rows: list[tuple[Any, ...]]) -> None: if not rows: return cur.executemany( """ INSERT INTO navsea_render_audit_result ( audit_name, object_instance_key, object_instance_id, object_id, fid_legacy, tile_z, tile_x, tile_y, source_layer, canonical_object_type, status, original_components_json, engineering_components_json ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, rows, ) def persist_audit_run( cur: pymysql.cursors.Cursor, *, original_count: int, engineering_count: int, result_count: int, status_counter: Counter[str], source_layer_counter: Counter[tuple[str, str]], ) -> None: source_layer_issue_counts = [ {"status": status, "source_layer": source_layer, "count": count} for (status, source_layer), count in source_layer_counter.most_common(100) ] cur.execute( """ INSERT INTO navsea_render_audit_run ( audit_name, original_style_path, engineering_style_path, original_tile_root, engineering_tile_root, original_feature_instances, engineering_feature_instances, result_count, status_counts_json, source_layer_issue_counts_json, report_md_path, report_json_path ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( AUDIT_NAME, str(ORIGINAL_STYLE_PATH), str(ENGINEERING_STYLE_PATH), str(ORIGINAL_TILE_ROOT), str(ENGINEERING_TILE_ROOT), original_count, engineering_count, result_count, json.dumps(dict(status_counter), ensure_ascii=False), json.dumps(source_layer_issue_counts, ensure_ascii=False), str(REPORT_MD_PATH), str(REPORT_JSON_PATH), ), ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Audit original vs engineering render outputs.") parser.add_argument("--audit-name", default=DEFAULT_AUDIT_NAME) parser.add_argument("--comparison-label", default="工程版") parser.add_argument("--fid-key") parser.add_argument("--match-on-fid-only", action="store_true") parser.add_argument("--original-style", type=Path, default=DEFAULT_ORIGINAL_STYLE_PATH) parser.add_argument("--engineering-style", type=Path, default=DEFAULT_ENGINEERING_STYLE_PATH) parser.add_argument("--original-tile-root", type=Path, default=DEFAULT_ORIGINAL_TILE_ROOT) parser.add_argument("--engineering-tile-root", type=Path, default=DEFAULT_ENGINEERING_TILE_ROOT) parser.add_argument("--report-md", type=Path, default=DEFAULT_REPORT_MD_PATH) parser.add_argument("--report-json", type=Path, default=DEFAULT_REPORT_JSON_PATH) parser.add_argument("--skip-db-persist", action="store_true") return parser.parse_args() def main() -> None: global AUDIT_NAME global ORIGINAL_STYLE_PATH global ENGINEERING_STYLE_PATH global ORIGINAL_TILE_ROOT global ENGINEERING_TILE_ROOT global REPORT_MD_PATH global REPORT_JSON_PATH global SKIP_DB_PERSIST global COMPARISON_LABEL global FID_CODEC global MATCH_ON_FID_ONLY args = parse_args() AUDIT_NAME = args.audit_name COMPARISON_LABEL = args.comparison_label FID_CODEC = NavSeaFidCodec(args.fid_key.encode("utf-8")) if args.fid_key else None MATCH_ON_FID_ONLY = args.match_on_fid_only ORIGINAL_STYLE_PATH = args.original_style ENGINEERING_STYLE_PATH = args.engineering_style ORIGINAL_TILE_ROOT = args.original_tile_root ENGINEERING_TILE_ROOT = args.engineering_tile_root REPORT_MD_PATH = args.report_md REPORT_JSON_PATH = args.report_json SKIP_DB_PERSIST = args.skip_db_persist original_style = load_style(ORIGINAL_STYLE_PATH) engineering_style = load_style(ENGINEERING_STYLE_PATH) engineering_tiles = iter_tile_paths(ENGINEERING_TILE_ROOT) original_count = 0 engineering_count = 0 result_count = 0 status_counter: Counter[str] = Counter() source_layer_counter: Counter[tuple[str, str]] = Counter() mismatch_examples: list[dict[str, Any]] = [] annotation_issue_counter: Counter[tuple[str, str]] = Counter() annotation_issue_examples: list[dict[str, Any]] = [] style_semantic_issue_counter: Counter[tuple[str, str]] = Counter() style_semantic_issue_examples: list[dict[str, Any]] = [] if SKIP_DB_PERSIST: for engineering_tile in engineering_tiles: rel = engineering_tile.relative_to(ENGINEERING_TILE_ROOT) original_tile = ORIGINAL_TILE_ROOT / rel if not original_tile.exists(): continue original_instances = decode_tile_instances("original", ORIGINAL_TILE_ROOT, original_style, original_tile) engineering_instances = decode_tile_instances( "engineering", ENGINEERING_TILE_ROOT, engineering_style, engineering_tile ) original_count += len(original_instances) engineering_count += len(engineering_instances) all_instance_ids = sorted(set(original_instances) | set(engineering_instances)) for instance_id in all_instance_ids: item = compare_instance(original_instances.get(instance_id), engineering_instances.get(instance_id)) result_count += 1 status_counter[item["status"]] += 1 if item["status"] != "exact_match": source_layer_counter[(item["status"], item["source_layer"] or "unknown")] += 1 if len(mismatch_examples) < MAX_EXAMPLES: mismatch_examples.append(item) annotation_issue = classify_annotation_issue(item) if annotation_issue: source_layer = item["source_layer"] or "unknown" annotation_issue_counter[(annotation_issue, source_layer)] += 1 if len(annotation_issue_examples) < MAX_EXAMPLES: annotation_issue_examples.append( { **item, "annotation_issue": annotation_issue, } ) style_semantic_issue = classify_style_semantic_issue(item) if style_semantic_issue: source_layer = item["source_layer"] or "unknown" style_semantic_issue_counter[(style_semantic_issue, source_layer)] += 1 if len(style_semantic_issue_examples) < MAX_EXAMPLES: style_semantic_issue_examples.append( { **item, "style_semantic_issue": style_semantic_issue, } ) else: with db_connect() as conn: with conn.cursor() as cur: ensure_audit_tables(cur) cur.execute("TRUNCATE TABLE navsea_render_audit_result") cur.execute("TRUNCATE TABLE navsea_render_audit_run") conn.commit() rows: list[tuple[Any, ...]] = [] for engineering_tile in engineering_tiles: rel = engineering_tile.relative_to(ENGINEERING_TILE_ROOT) original_tile = ORIGINAL_TILE_ROOT / rel if not original_tile.exists(): continue original_instances = decode_tile_instances("original", ORIGINAL_TILE_ROOT, original_style, original_tile) engineering_instances = decode_tile_instances( "engineering", ENGINEERING_TILE_ROOT, engineering_style, engineering_tile ) original_count += len(original_instances) engineering_count += len(engineering_instances) all_instance_ids = sorted(set(original_instances) | set(engineering_instances)) for instance_id in all_instance_ids: item = compare_instance(original_instances.get(instance_id), engineering_instances.get(instance_id)) result_count += 1 status_counter[item["status"]] += 1 if item["status"] != "exact_match": source_layer_counter[(item["status"], item["source_layer"] or "unknown")] += 1 if len(mismatch_examples) < MAX_EXAMPLES: mismatch_examples.append(item) annotation_issue = classify_annotation_issue(item) if annotation_issue: source_layer = item["source_layer"] or "unknown" annotation_issue_counter[(annotation_issue, source_layer)] += 1 if len(annotation_issue_examples) < MAX_EXAMPLES: annotation_issue_examples.append( { **item, "annotation_issue": annotation_issue, } ) style_semantic_issue = classify_style_semantic_issue(item) if style_semantic_issue: source_layer = item["source_layer"] or "unknown" style_semantic_issue_counter[(style_semantic_issue, source_layer)] += 1 if len(style_semantic_issue_examples) < MAX_EXAMPLES: style_semantic_issue_examples.append( { **item, "style_semantic_issue": style_semantic_issue, } ) rows.append( ( AUDIT_NAME, hash_text(item["object_instance_id"]), item["object_instance_id"], item["object_id"], item["fid_legacy"], item["tile_z"], item["tile_x"], item["tile_y"], item["source_layer"], item["canonical_object_type"], item["status"], json.dumps(item["original_components"], ensure_ascii=False), json.dumps(item["engineering_components"], ensure_ascii=False), ) ) if len(rows) >= DB_BATCH_SIZE: insert_result_batch(cur, rows) conn.commit() rows.clear() insert_result_batch(cur, rows) persist_audit_run( cur, original_count=original_count, engineering_count=engineering_count, result_count=result_count, status_counter=status_counter, source_layer_counter=source_layer_counter, ) conn.commit() write_reports( original_count=original_count, engineering_count=engineering_count, result_count=result_count, status_counter=status_counter, source_layer_counter=source_layer_counter, mismatch_examples=mismatch_examples, annotation_issue_counter=annotation_issue_counter, annotation_issue_examples=annotation_issue_examples, style_semantic_issue_counter=style_semantic_issue_counter, style_semantic_issue_examples=style_semantic_issue_examples, ) print( json.dumps( { "original_feature_instances": original_count, "engineering_feature_instances": engineering_count, "results": result_count, "report_md": str(REPORT_MD_PATH), "report_json": str(REPORT_JSON_PATH), "audit_name": AUDIT_NAME, "db_run_table": "navsea_render_audit_run", "db_result_table": "navsea_render_audit_result", }, ensure_ascii=False, indent=2, ) ) if __name__ == "__main__": main()