#!/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(
"
"
f"| {html.escape(layer)} | "
f"{stats['MATCH']} | "
f"{stats['PARTIAL']} | "
f"{stats['occurrences']} | "
"
"
)
ambiguous_rows: list[str] = []
for item in result["ambiguous_summary"]:
ambiguous_rows.append(
""
f"| {html.escape(' / '.join(item['candidate_standard_layers']))} | "
f"{item['resource_count']} | "
f"{item['occurrence_count']} | "
"
"
)
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(
""
f"{html.escape(row['group_id'])} | "
f"{row['named_occurrences']} | "
f"{row['ambiguous_occurrences']} | "
f"{row['component_occurrences']} | "
f"{row['unknown_occurrences']} | "
f"{dist} | "
"
"
)
return f"""
CM95 Visual Object Layer Assignment v1
CM95 Anonymous Visual Object → NavSea Standard Layer
本结果只应用经人工视觉复核的共同图标/视觉家族锚点。CM95 source-layer 名称、Provider 字段语义和对象类名称均未参与 Layer 判定。
CM95 Visual Objects {summary['total_visual_objects']}
Sprite-backed {summary['sprite_backed_visual_objects']}
唯一 Layer 已命名 {summary['uniquely_named_visual_objects']}
MATCH {summary['match_visual_objects']}
PARTIAL {summary['partial_visual_objects']}
AMBIGUOUS {summary['ambiguous_visual_objects']}
COMPONENT {summary['component_visual_objects']}
UNKNOWN {summary['unknown_visual_objects']}
Reviewed anchors {summary['reviewed_anchor_count']}
状态定义:
MATCH = 实际图形基本一致;
PARTIAL = 不是同一完整图形,但视觉家族/组件足以支持唯一标准 Layer;
AMBIGUOUS = 图形证据只能得到多个标准 Layer 候选,禁止强行选一个;
COMPONENT = 只是完整 Visual Object 的组成部分,不单独分配 Layer;
UNKNOWN = 当前视觉证据不足。
唯一命名的标准 Layer
| 标准 Layer | MATCH | PARTIAL | VO occurrence |
{''.join(layer_rows)}
视觉歧义
| 候选标准 Layer | 资源数 | VO occurrence |
{''.join(ambiguous_rows) if ambiguous_rows else '| 无 |
'}
匿名 Group 分布
Group 仅用于审计组合范围,不能由整个 group 直接继承一个 Layer。
| Group | 唯一命名 | AMBIGUOUS | COMPONENT | UNKNOWN | 唯一 Layer 分布 |
{''.join(group_rows)}
逐 Visual Object 明细见 cm95-layer-assignment-v1.json;
资源级摘要见 cm95-layer-assignment-v1.csv。
Anonymous Catalog 没有被修改。
"""
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()