Files
pbf/tools/style-visual-object/apply_reviewed_layer_assignment.py

375 lines
15 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
"""把人工视觉复核结果应用到 CM95 Anonymous Visual Object Catalog。
本脚本不参与视觉判断,也不读取 CM95 source-layer 来决定 Layer。
它只消费已经完成的 reviewed anchors,并生成独立的 Layer Assignment 派生结果。
Anonymous Catalog 本身保持不变。
"""
from __future__ import annotations
import argparse
import csv
import html
import json
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
def load_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def anchor_layers(anchor: dict[str, Any] | None) -> list[str]:
if not anchor:
return []
if anchor.get("standard_layer"):
return [str(anchor["standard_layer"])]
return [str(x) for x in anchor.get("standard_layers", []) if x]
def build_html(result: dict[str, Any]) -> str:
summary = result["summary"]
layer_rows: list[str] = []
for layer, stats in sorted(result["layer_summary"].items()):
layer_rows.append(
"<tr>"
f"<td>{html.escape(layer)}</td>"
f"<td>{stats['MATCH']}</td>"
f"<td>{stats['PARTIAL']}</td>"
f"<td>{stats['occurrences']}</td>"
"</tr>"
)
ambiguous_rows: list[str] = []
for item in result["ambiguous_summary"]:
ambiguous_rows.append(
"<tr>"
f"<td>{html.escape(' / '.join(item['candidate_standard_layers']))}</td>"
f"<td>{item['resource_count']}</td>"
f"<td>{item['occurrence_count']}</td>"
"</tr>"
)
group_rows: list[str] = []
for row in result["group_summary"]:
dist = ", ".join(
f"{html.escape(k)}={v}"
for k, v in sorted(row["assigned_layer_counts"].items())
) or "—"
group_rows.append(
"<tr>"
f"<td><code>{html.escape(row['group_id'])}</code></td>"
f"<td>{row['named_occurrences']}</td>"
f"<td>{row['ambiguous_occurrences']}</td>"
f"<td>{row['component_occurrences']}</td>"
f"<td>{row['unknown_occurrences']}</td>"
f"<td>{dist}</td>"
"</tr>"
)
return f"""<!doctype html>
<meta charset="utf-8">
<title>CM95 Visual Object Layer Assignment v1</title>
<style>
body{{font-family:Arial,"Noto Sans CJK JP",sans-serif;margin:24px;color:#17202a}}
.box{{display:inline-block;border:1px solid #d8dee6;border-radius:8px;padding:10px 14px;margin:0 10px 12px 0}}
table{{border-collapse:collapse;width:100%;margin:12px 0 28px}}
th,td{{border:1px solid #d8dee6;padding:7px;font-size:13px;text-align:left}}
th{{background:#eef2f6}}
code{{font-size:11px}}
.note{{background:#fff7df;border:1px solid #ead99c;padding:12px;border-radius:8px}}
</style>
<h1>CM95 Anonymous Visual Object → NavSea Standard Layer</h1>
<p>本结果只应用经人工视觉复核的共同图标/视觉家族锚点。CM95 source-layer 名称、Provider 字段语义和对象类名称均未参与 Layer 判定。</p>
<div class="box">CM95 Visual Objects <b>{summary['total_visual_objects']}</b></div>
<div class="box">Sprite-backed <b>{summary['sprite_backed_visual_objects']}</b></div>
<div class="box">唯一 Layer 已命名 <b>{summary['uniquely_named_visual_objects']}</b></div>
<div class="box">MATCH <b>{summary['match_visual_objects']}</b></div>
<div class="box">PARTIAL <b>{summary['partial_visual_objects']}</b></div>
<div class="box">AMBIGUOUS <b>{summary['ambiguous_visual_objects']}</b></div>
<div class="box">COMPONENT <b>{summary['component_visual_objects']}</b></div>
<div class="box">UNKNOWN <b>{summary['unknown_visual_objects']}</b></div>
<div class="box">Reviewed anchors <b>{summary['reviewed_anchor_count']}</b></div>
<div class="note">
<b>状态定义:</b>
MATCH = 实际图形基本一致;
PARTIAL = 不是同一完整图形,但视觉家族/组件足以支持唯一标准 Layer;
AMBIGUOUS = 图形证据只能得到多个标准 Layer 候选,禁止强行选一个;
COMPONENT = 只是完整 Visual Object 的组成部分,不单独分配 Layer;
UNKNOWN = 当前视觉证据不足。
</div>
<h2>唯一命名的标准 Layer</h2>
<table>
<thead><tr><th>标准 Layer</th><th>MATCH</th><th>PARTIAL</th><th>VO occurrence</th></tr></thead>
<tbody>{''.join(layer_rows)}</tbody>
</table>
<h2>视觉歧义</h2>
<table>
<thead><tr><th>候选标准 Layer</th><th>资源数</th><th>VO occurrence</th></tr></thead>
<tbody>{''.join(ambiguous_rows) if ambiguous_rows else '<tr><td colspan="3">无</td></tr>'}</tbody>
</table>
<h2>匿名 Group 分布</h2>
<p>Group 仅用于审计组合范围,不能由整个 group 直接继承一个 Layer。</p>
<table>
<thead><tr><th>Group</th><th>唯一命名</th><th>AMBIGUOUS</th><th>COMPONENT</th><th>UNKNOWN</th><th>唯一 Layer 分布</th></tr></thead>
<tbody>{''.join(group_rows)}</tbody>
</table>
<p>逐 Visual Object 明细见 <code>cm95-layer-assignment-v1.json</code>;
资源级摘要见 <code>cm95-layer-assignment-v1.csv</code>。
Anonymous Catalog 没有被修改。</p>
"""
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--anonymous", required=True)
ap.add_argument("--reviewed-anchors", required=True)
ap.add_argument("--output", required=True)
args = ap.parse_args()
anon_path = Path(args.anonymous)
anchor_path = Path(args.reviewed_anchors)
out = Path(args.output)
out.mkdir(parents=True, exist_ok=True)
anon = load_json(anon_path)
review = load_json(anchor_path)
anchors = {
x["cm95_resource_key"]: x
for x in review.get("anchors", [])
}
group_provenance = {
g["group_id"]: g.get("provenance", {})
for g in anon.get("groups", [])
}
assignments: list[dict[str, Any]] = []
layer_counts: dict[str, Counter] = defaultdict(Counter)
group_counts: dict[str, Counter] = defaultdict(Counter)
resource_status: dict[str, Counter] = defaultdict(Counter)
ambiguous_occurrence_counts: Counter = Counter()
for obj in anon.get("visual_objects", []):
resource_key = obj.get("resource_key")
anchor = anchors.get(resource_key)
candidate_layers = anchor_layers(anchor)
if anchor and anchor.get("status") in {"MATCH", "PARTIAL"} and len(candidate_layers) == 1:
status = str(anchor["status"])
layer = candidate_layers[0]
basis = "REVIEWED_VISUAL_ANCHOR"
std_resource = anchor.get("standard_resource_key")
layer_counts[layer][status] += 1
layer_counts[layer]["occurrences"] += 1
group_counts[obj["group_id"]][layer] += 1
group_counts[obj["group_id"]]["__named__"] += 1
resource_status[resource_key][status] += 1
elif anchor and anchor.get("status") == "AMBIGUOUS" and len(candidate_layers) >= 2:
status = "AMBIGUOUS"
layer = None
basis = "REVIEWED_VISUAL_AMBIGUITY"
std_resource = anchor.get("standard_resource_key")
group_counts[obj["group_id"]]["__ambiguous__"] += 1
resource_status[resource_key]["AMBIGUOUS"] += 1
ambiguous_occurrence_counts[tuple(sorted(candidate_layers))] += 1
elif anchor and anchor.get("status") == "COMPONENT":
status = "COMPONENT"
layer = None
candidate_layers = []
basis = "REVIEWED_COMPONENT_ONLY"
std_resource = None
group_counts[obj["group_id"]]["__component__"] += 1
resource_status[resource_key]["COMPONENT"] += 1
else:
status = "UNKNOWN"
layer = None
candidate_layers = []
basis = "INSUFFICIENT_VISUAL_EVIDENCE"
std_resource = None
group_counts[obj["group_id"]]["__unknown__"] += 1
if resource_key:
resource_status[resource_key]["UNKNOWN"] += 1
assignments.append({
"visual_object_id": obj["visual_object_id"],
"group_id": obj["group_id"],
"component_role": obj.get("component_role"),
"geometry_role": obj.get("geometry_role"),
"cm95_resource_key": resource_key,
"assignment_status": status,
"assigned_standard_layer": layer,
"candidate_standard_layers": candidate_layers,
"basis": basis,
"reviewed_standard_resource_key": std_resource,
"component_kind": anchor.get("component_kind") if anchor and status == "COMPONENT" else None,
# provenance 仅用于结果审计;不参与上述判定。
"provenance_for_audit_only": obj.get("provenance"),
})
for obj in anon.get("unresolved_visual_objects", []):
assignments.append({
"visual_object_id": obj["visual_object_id"],
"group_id": obj["group_id"],
"component_role": obj.get("component_role"),
"geometry_role": obj.get("geometry_role"),
"cm95_resource_key": None,
"assignment_status": "UNKNOWN",
"assigned_standard_layer": None,
"candidate_standard_layers": [],
"basis": "UNRESOLVED_DYNAMIC_VISUAL",
"reviewed_standard_resource_key": None,
"component_kind": None,
"provenance_for_audit_only": obj.get("provenance"),
})
group_counts[obj["group_id"]]["__unknown__"] += 1
group_summary: list[dict[str, Any]] = []
for gid in sorted(group_provenance):
c = group_counts[gid]
group_summary.append({
"group_id": gid,
"named_occurrences": c["__named__"],
"ambiguous_occurrences": c["__ambiguous__"],
"component_occurrences": c["__component__"],
"unknown_occurrences": c["__unknown__"],
"assigned_layer_counts": {
k: v for k, v in c.items() if not k.startswith("__")
},
"provenance_for_audit_only": group_provenance[gid],
})
layer_summary: dict[str, Any] = {}
for layer, c in layer_counts.items():
layer_summary[layer] = {
"MATCH": c["MATCH"],
"PARTIAL": c["PARTIAL"],
"occurrences": c["occurrences"],
}
all_resource_keys = sorted({
o.get("resource_key")
for o in anon.get("visual_objects", [])
if o.get("resource_key")
})
resource_summary: list[dict[str, Any]] = []
for key in all_resource_keys:
a = anchors.get(key)
layers = anchor_layers(a)
status = str(a["status"]) if a else "UNKNOWN"
unique_layer = layers[0] if status in {"MATCH", "PARTIAL"} and len(layers) == 1 else None
c = resource_status[key]
resource_summary.append({
"cm95_resource_key": key,
"assignment_status": status,
"assigned_standard_layer": unique_layer,
"candidate_standard_layers": layers,
"reviewed_standard_resource_key": a.get("standard_resource_key") if a else None,
"component_kind": a.get("component_kind") if a and status == "COMPONENT" else None,
"occurrence_count": sum(c.values()),
})
ambiguous_resource_counts: Counter = Counter()
for key, a in anchors.items():
if a.get("status") == "AMBIGUOUS":
ambiguous_resource_counts[tuple(sorted(anchor_layers(a)))] += 1
ambiguous_summary = [
{
"candidate_standard_layers": list(layer_set),
"resource_count": ambiguous_resource_counts[layer_set],
"occurrence_count": ambiguous_occurrence_counts[layer_set],
}
for layer_set in sorted(ambiguous_resource_counts)
]
total = len(assignments)
match_count = sum(x["assignment_status"] == "MATCH" for x in assignments)
partial_count = sum(x["assignment_status"] == "PARTIAL" for x in assignments)
ambiguous_count = sum(x["assignment_status"] == "AMBIGUOUS" for x in assignments)
component_count = sum(x["assignment_status"] == "COMPONENT" for x in assignments)
unknown_count = sum(x["assignment_status"] == "UNKNOWN" for x in assignments)
uniquely_named = match_count + partial_count
sprite_backed = sum(bool(x.get("cm95_resource_key")) for x in assignments)
unique_layers: set[str] = set()
for a in anchors.values():
unique_layers.update(anchor_layers(a))
result = {
"schema": "cm95-visual-object-layer-assignment/v1",
"source_anonymous_catalog": str(anon_path),
"reviewed_anchor_file": str(anchor_path),
"policy": {
"cm95_source_layer_used_for_assignment": False,
"provider_field_semantics_used_for_assignment": False,
"standard_layer_is_output_only": True,
"unknown_is_allowed": True,
"ambiguous_is_allowed": True,
"component_is_allowed": True,
"whole_group_inheritance_forbidden": True,
"anonymous_catalog_modified": False,
},
"summary": {
"total_visual_objects": total,
"sprite_backed_visual_objects": sprite_backed,
"reviewed_anchor_count": len(anchors),
"reviewed_standard_layer_candidates": sorted(unique_layers),
"uniquely_named_visual_objects": uniquely_named,
"match_visual_objects": match_count,
"partial_visual_objects": partial_count,
"ambiguous_visual_objects": ambiguous_count,
"component_visual_objects": component_count,
"unknown_visual_objects": unknown_count,
"uniquely_named_ratio": (uniquely_named / total) if total else 0.0,
"sprite_backed_uniquely_named_ratio": (uniquely_named / sprite_backed) if sprite_backed else 0.0,
},
"layer_summary": layer_summary,
"ambiguous_summary": ambiguous_summary,
"resource_summary": resource_summary,
"group_summary": group_summary,
"assignments": assignments,
}
(out / "cm95-layer-assignment-v1.json").write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
(out / "cm95-layer-assignment-v1.html").write_text(
build_html(result), encoding="utf-8"
)
with (out / "cm95-layer-assignment-v1.csv").open("w", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(
f,
lineterminator="\n",
fieldnames=[
"cm95_resource_key",
"assignment_status",
"assigned_standard_layer",
"candidate_standard_layers",
"reviewed_standard_resource_key",
"component_kind",
"occurrence_count",
],
)
writer.writeheader()
for row in resource_summary:
writer.writerow({
**row,
"candidate_standard_layers": ";".join(row["candidate_standard_layers"]),
})
print(json.dumps(result["summary"], ensure_ascii=False, indent=2))
print(json.dumps(result["layer_summary"], ensure_ascii=False, indent=2))
print(json.dumps(result["ambiguous_summary"], ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()