192 lines
9.6 KiB
Python
192 lines
9.6 KiB
Python
#!/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())
|