Files
CM95/tools/cm95_style_contract_unmatched_extract_v1.py

329 lines
11 KiB
Python
Raw Permalink 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
# -*- coding: utf-8 -*-
r"""
CM95 Style Contract unmatched Feature 精确提取器 v1
用途
====
对一个 production release 的 delivery PBF 做只读扫描,使用与
cm95pbf.style_contract 相同的 Style filter evaluator,提取所有
contract_unmatched Feature 的精确属性。
默认只关注当前已知失败的两个 source-layer:
- place_label_land
- seabed_text_point
不会修改 PBF、report、ledger 或 release manifest。
输出:
build/production/cm95_style_contract_unmatched_features_v1.json
build/production/cm95_style_contract_unmatched_features_v1.txt
示例:
& ".\.venv\Scripts\python.exe" .\tools\cm95_style_contract_unmatched_extract_v1.py `
--release-dir .\build\releases\cm95-d1-bfa67dd5625f-f5e95b1e5341 `
--style .\config\style\style.navsea-delivery-full-semantic-unified-20260808-cm95-v1.json
"""
from __future__ import annotations
import argparse
import json
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
import mapbox_vector_tile
try:
from cm95pbf.style_contract import ev
except Exception:
def ev(expr: Any, props: dict[str, Any]):
if not isinstance(expr, list):
return expr
if not expr:
return None
op = expr[0]
if op == "get":
return props.get(expr[1])
if op == "has":
return expr[1] in props
if op == "!":
return not bool(ev(expr[1], props))
if op == "all":
return all(bool(ev(x, props)) for x in expr[1:])
if op == "any":
return any(bool(ev(x, props)) for x in expr[1:])
if op == "==":
return ev(expr[1], props) == ev(expr[2], props)
if op == "!=":
return ev(expr[1], props) != ev(expr[2], props)
if op == "slice":
value = ev(expr[1], props)
if value is None:
return None
start = int(ev(expr[2], props))
end = int(ev(expr[3], props)) if len(expr) > 3 else None
return str(value)[start:end]
if op == "to-number":
value = ev(expr[1], props)
try:
num = float(value)
return int(num) if num.is_integer() else num
except Exception:
return 0
if op == "literal":
return expr[1]
if op == "match":
value = ev(expr[1], props)
pairs = expr[2:-1]
default = expr[-1]
for i in range(0, len(pairs), 2):
label = pairs[i]
output = pairs[i + 1]
if isinstance(label, list) and label and label[0] == "literal":
labels = label[1]
if value in labels:
return ev(output, props)
elif isinstance(label, list):
if value in label:
return ev(output, props)
elif value == label:
return ev(output, props)
return ev(default, props)
raise RuntimeError(f"unsupported style filter op: {op}")
DEFAULT_TARGETS = ("place_label_land", "seabed_text_point")
def parse_args():
p = argparse.ArgumentParser()
p.add_argument("--release-dir", required=True)
p.add_argument("--style", required=True)
p.add_argument(
"--profile",
default="delivery",
help="PBF profile directory under release/pbf (default delivery)",
)
p.add_argument(
"--source-layer",
action="append",
dest="source_layers",
help="可重复;默认只查 place_label_land 和 seabed_text_point",
)
p.add_argument(
"--output",
default=r"build\production\cm95_style_contract_unmatched_features_v1.json",
)
return p.parse_args()
def compact_filter_result(style_layer: dict[str, Any], props: dict[str, Any]) -> dict[str, Any]:
flt = style_layer.get("filter")
matched = True if flt is None else bool(ev(flt, props))
return {
"style_layer_id": style_layer.get("id"),
"filter": flt,
"filter_matched": matched,
"minzoom": style_layer.get("minzoom"),
"maxzoom": style_layer.get("maxzoom"),
}
def classify_missing(source_layer: str, props: dict[str, Any]) -> dict[str, Any]:
if source_layer == "place_label_land":
return {
"canonical_object_type": props.get("canonical_object_type"),
"has_place_name_en": "place_name_en" in props,
"place_name_en": props.get("place_name_en"),
"has_chart_label_text": "chart_label_text" in props,
"chart_label_text": props.get("chart_label_text"),
"likely_issue": (
"NO_RENDERABLE_PLACE_LABEL"
if "place_name_en" not in props and "chart_label_text" not in props
else "OTHER_FILTER_MISMATCH"
),
}
if source_layer == "seabed_text_point":
return {
"canonical_object_type": props.get("canonical_object_type"),
"has_chart_label_text": "chart_label_text" in props,
"chart_label_text": props.get("chart_label_text"),
"depth_value_m": props.get("depth_value_m"),
"likely_issue": (
"NO_RENDERABLE_SEABED_LABEL"
if "chart_label_text" not in props
else "OTHER_FILTER_MISMATCH"
),
}
return {
"canonical_object_type": props.get("canonical_object_type"),
"likely_issue": "GENERIC_UNMATCHED",
}
def main():
args = parse_args()
release_dir = Path(args.release_dir).resolve()
style_path = Path(args.style).resolve()
output_path = Path(args.output).resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
pbf_root = release_dir / "pbf" / args.profile
if not pbf_root.exists():
raise SystemExit(f"找不到 PBF 根目录: {pbf_root}")
if not style_path.exists():
raise SystemExit(f"找不到 Style: {style_path}")
targets = set(args.source_layers or DEFAULT_TARGETS)
style = json.loads(style_path.read_text(encoding="utf-8"))
style_by_source_layer: dict[str, list[dict[str, Any]]] = defaultdict(list)
for layer in style.get("layers", []):
if layer.get("source") == "navsea_delivery" and layer.get("source-layer"):
style_by_source_layer[str(layer["source-layer"])].append(layer)
rows: list[dict[str, Any]] = []
counts = Counter()
type_counts = Counter()
property_key_counts = Counter()
for path in sorted(pbf_root.rglob("*.pbf")):
rel = path.relative_to(pbf_root)
if len(rel.parts) < 3:
continue
z = int(rel.parts[-3])
x = int(rel.parts[-2])
y = int(Path(rel.parts[-1]).stem)
decoded = mapbox_vector_tile.decode(path.read_bytes())
for source_layer, layer_obj in decoded.items():
if source_layer not in targets:
continue
candidates = style_by_source_layer.get(source_layer, [])
for feature in layer_obj.get("features", []):
props = feature.get("properties") or {}
filter_results = [
compact_filter_result(sl, props)
for sl in candidates
]
if any(r["filter_matched"] for r in filter_results):
continue
output_id = feature.get("id")
geom = feature.get("geometry") or {}
geom_type = geom.get("type") if isinstance(geom, dict) else None
classification = classify_missing(source_layer, props)
row = {
"z": z,
"x": x,
"y": y,
"tile": f"{z}/{x}/{y}",
"source_layer": source_layer,
"output_id": output_id,
"geometry_type": geom_type,
"classification": classification,
"properties": dict(sorted(props.items())),
"style_filter_results": filter_results,
"pbf_path": str(path),
}
rows.append(row)
counts[(z, source_layer)] += 1
type_counts[
(
source_layer,
str(props.get("canonical_object_type") or "<missing>"),
)
] += 1
for key in props:
property_key_counts[(source_layer, key)] += 1
result = {
"schema": "navsea.cm95.style-contract-unmatched-features/v1",
"release_dir": str(release_dir),
"style_path": str(style_path),
"profile": args.profile,
"target_source_layers": sorted(targets),
"unmatched_count": len(rows),
"counts_by_zoom_source_layer": [
{"z": z, "source_layer": sl, "count": count}
for (z, sl), count in sorted(counts.items())
],
"canonical_object_type_counts": [
{
"source_layer": sl,
"canonical_object_type": obj_type,
"count": count,
}
for (sl, obj_type), count in sorted(
type_counts.items(),
key=lambda kv: (kv[0][0], -kv[1], kv[0][1]),
)
],
"property_key_counts": [
{"source_layer": sl, "property": key, "count": count}
for (sl, key), count in sorted(
property_key_counts.items(),
key=lambda kv: (kv[0][0], -kv[1], kv[0][1]),
)
],
"features": rows,
}
output_path.write_text(
json.dumps(result, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
txt_path = output_path.with_suffix(".txt")
lines = [
"CM95 Style Contract unmatched Feature 精确提取器 v1",
"=" * 76,
f"Release : {release_dir}",
f"Style : {style_path}",
f"Unmatched : {len(rows)}",
"",
"Canonical object types:",
]
for item in result["canonical_object_type_counts"]:
lines.append(
f" {item['source_layer']:<24} "
f"{item['canonical_object_type']:<28} "
f"{item['count']}"
)
lines += ["", "Exact unmatched features:"]
for row in rows:
c = row["classification"]
lines.append(
f" {row['tile']:<14} "
f"{row['source_layer']:<24} "
f"id={str(row['output_id']):<12} "
f"type={str(c.get('canonical_object_type')):<24} "
f"issue={c.get('likely_issue')}"
)
lines.append(
" props="
+ json.dumps(
row["properties"],
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
)
txt_path.write_text("\n".join(lines), encoding="utf-8")
print("\n".join(lines))
print("")
print("JSON:", output_path)
print("TXT :", txt_path)
if __name__ == "__main__":
main()