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