Files
pbf/tools/provider-pipeline/feature_lineage_replay_audit.py

192 lines
9.6 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.
#!/usr/bin/env python3
"""只读按原始 PBF Feature 重放 Composite Visual Object;不推断 PRIMARY/语义。"""
from __future__ import annotations
import argparse
from collections import Counter, defaultdict
import hashlib
import importlib.util
import json
from pathlib import Path
from typing import Any
from PIL import Image
from mapbox_vector_tile.Mapbox import vector_tile_pb2
def load_instantiator() -> Any:
path = Path(__file__).resolve().parents[1] / "visual-object-instantiation" / "instantiate_pbf_visual_objects.py"
spec = importlib.util.spec_from_file_location("provider_actual_visual_instantiation", path)
if spec is None or spec.loader is None:
raise RuntimeError("无法加载实际 PBF Visual Object 实例化器")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def replay_catalog(
catalog: dict[str, Any],
style: dict[str, Any],
sprite_meta: dict[str, Any],
sprite_hashes: dict[str, str],
pbf_root: Path,
*,
instantiator: Any = None,
max_objects: int | None = None,
) -> dict[str, Any]:
"""Replay the recorded example raw Feature; retain explicit limits of one sample per VO."""
engine = instantiator or load_instantiator()
objects = catalog.get("visual_objects")
style_layers = style.get("layers")
if not isinstance(objects, list) or not isinstance(style_layers, list):
raise ValueError("Catalog visual_objects 或 Style layers 无效")
indexed: dict[str, list[tuple[int, dict[str, Any]]]] = defaultdict(list)
for index, layer in enumerate(style_layers):
if isinstance(layer, dict) and isinstance(layer.get("source-layer"), str):
indexed[layer["source-layer"]].append((index, layer))
source_owners = {source_layer: {layer.get('source') for _, layer in members}
for source_layer, members in indexed.items()}
cache: dict[str, dict[str, Any]] = {}
results: list[dict[str, Any]] = []
seen_ids: set[str] = set()
for obj in sorted(objects, key=lambda row: row.get("visual_object_id", ""))[:max_objects]:
object_id = obj.get("visual_object_id")
if not isinstance(object_id, str) or not object_id or object_id in seen_ids:
raise ValueError("Catalog Visual Object ID 无效或重复")
seen_ids.add(object_id)
provenance = obj.get("provenance") or {}
source_layer = provenance.get("source_layer")
example = obj.get("example") or {}
tile = example.get("tile")
fid = example.get("feature_id")
status = None
diagnostics: list[str] = []
replayed: list[dict[str, Any]] = []
if not isinstance(source_layer, str) or not source_layer or not isinstance(tile, str) or len(tile.split("/")) != 3:
status = "INVALID_CATALOG_EXAMPLE"
elif type(fid) is not int or fid < 0:
status = "UNVERIFIABLE_FEATURE_ID"
elif len(source_owners.get(source_layer, set())) != 1 or None in source_owners.get(source_layer, set()):
# The PBF source is not encoded in the catalog's example identity.
status = "AMBIGUOUS_STYLE_SOURCE"
else:
z, x, y = tile.split("/")
if not all(part.isdigit() for part in (z, x, y)):
status = "INVALID_CATALOG_EXAMPLE"
else:
if tile not in cache:
path = pbf_root / z / x / (y + ".pbf")
if not path.is_file():
cache[tile] = {}
else:
raw = vector_tile_pb2.tile()
raw.ParseFromString(path.read_bytes())
name_counts = Counter(layer.name for layer in raw.layers)
cache[tile] = {layer.name: list(layer.features) for layer in raw.layers}
cache[tile + ":duplicates"] = {name for name, count in name_counts.items() if count != 1}
# Retain original tile layer metadata for key-value decoding.
cache[tile + ":rawLayers"] = {layer.name: layer for layer in raw.layers}
feature_list = cache[tile].get(source_layer)
if source_layer in cache.get(tile + ":duplicates", set()):
status = "DUPLICATE_PBF_SOURCE_LAYER"
elif feature_list is None:
status = "PBF_TILE_OR_SOURCE_LAYER_UNAVAILABLE"
else:
candidates = [feature for feature in feature_list
if feature.HasField("id") and int(feature.id) == fid]
if len(candidates) != 1:
status = "FEATURE_ID_NOT_UNIQUE" if len(candidates) > 1 else "FEATURE_ID_NOT_FOUND"
else:
raw_feature = candidates[0]
tile_layer = cache[tile + ":rawLayers"][source_layer]
props = engine.decode_feature_properties(tile_layer, raw_feature)
for index, layer in indexed.get(source_layer, []):
if not engine.matches(layer, props, int(z)):
continue
replayed.extend(engine.component_from_layer(
layer, index, props, int(z), sprite_meta, sprite_hashes
))
raw_signature = {
"geometry_role": engine.geometry_role(raw_feature.type),
"components": engine.signature_components(replayed),
}
replayed_id = engine.stable_id("VO", raw_signature)
if not replayed:
status = "NO_RENDER_COMPONENT_REPLAYED"
elif replayed_id != object_id:
status = "COMPOSITE_SIGNATURE_MISMATCH"
diagnostics.append("原始 PBF 重放得到的视觉签名与 Catalog ID 不一致")
elif engine.signature_components(replayed) != engine.signature_components(obj.get("components", [])):
status = "CATALOG_COMPONENT_MISMATCH"
else:
status = "SAME_RAW_FEATURE_REPLAY_CONFIRMED"
results.append({
"visualObjectId": object_id,
"status": status,
"example": {"tile": tile, "sourceLayer": source_layer, "featureId": fid},
"componentCount": len(obj.get("components", [])),
"replayedComponentCount": len(replayed),
"sameRawFeature": status == "SAME_RAW_FEATURE_REPLAY_CONFIRMED",
"primaryComponentAuthorized": False,
"displaySelectorAuthorized": False,
"diagnostics": diagnostics,
})
counts = Counter(record["status"] for record in results)
return {
"schemaVersion": "navsea-feature-lineage-replay-audit/v1",
"scope": "ONE_RECORDED_EXAMPLE_PER_VISUAL_OBJECT_NOT_FULL_COVERAGE",
"visualObjectCount": len(results),
"sampleTileCount": len([k for k in cache if ":" not in k]),
"statusCounts": dict(sorted(counts.items())),
"sameRawFeatureConfirmedCount": counts["SAME_RAW_FEATURE_REPLAY_CONFIRMED"],
"primaryComponentAuthorizedCount": 0,
"displaySelectorAuthorizedCount": 0,
"results": results,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--catalog", type=Path, required=True)
parser.add_argument("--style", type=Path, required=True)
parser.add_argument("--sprite-json", type=Path, required=True)
parser.add_argument("--sprite-png", type=Path, required=True)
parser.add_argument("--pbf-root", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--max-objects", type=int)
args = parser.parse_args()
catalog = json.loads(args.catalog.read_text(encoding="utf-8"))
style_bytes = args.style.read_bytes()
style = json.loads(style_bytes.decode("utf-8"))
if catalog.get("schema") != "actual-visual-object-catalog/v1":
raise ValueError("Catalog schema 不受支持")
catalog_style = (catalog.get("inputs") or {}).get("style")
if catalog_style:
source_style_path = Path(catalog_style)
if not source_style_path.is_file():
raise ValueError("Catalog 原始 Style 不存在,不可交叉校验")
if hashlib.sha256(source_style_path.read_bytes()).digest() != hashlib.sha256(style_bytes).digest():
raise ValueError("Catalog Style 与待验证 Style SHA-256 不一致")
if args.max_objects is not None and args.max_objects <= 0:
raise ValueError("max-objects 必须大于零")
sprites = json.loads(args.sprite_json.read_text(encoding="utf-8"))
with Image.open(args.sprite_png) as atlas:
sprite_hashes = load_instantiator().sprite_hashes(sprites, atlas.convert("RGBA"))
result = replay_catalog(
catalog, style, sprites, sprite_hashes, args.pbf_root,
max_objects=args.max_objects,
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({
"scope": result["scope"], "visualObjectCount": result["visualObjectCount"],
"statusCounts": result["statusCounts"],
"primaryAuthorized": 0, "displayAuthorized": 0,
"output": str(args.output),
}, ensure_ascii=False))
return 0 if result["sameRawFeatureConfirmedCount"] == result["visualObjectCount"] else 2
if __name__ == "__main__":
raise SystemExit(main())