#!/usr/bin/env python3 from __future__ import annotations import argparse import json import math from collections import defaultdict from pathlib import Path from typing import Any import numpy as np from PIL import Image, ImageFilter def load_catalog(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def load_atlas(catalog_dir: Path) -> Image.Image: return Image.open(catalog_dir / "_sprite" / "sprite.png").convert("RGBA") def visual_feature(atlas: Image.Image, meta: dict[str, Any]) -> tuple[np.ndarray, np.ndarray, np.ndarray]: crop = atlas.crop(( int(meta["x"]), int(meta["y"]), int(meta["x"]) + int(meta["width"]), int(meta["y"]) + int(meta["height"]), )) alpha = np.asarray(crop.getchannel("A")) ys, xs = np.where(alpha > 8) if len(xs): crop = crop.crop((int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)) w, h = crop.size scale = min(54.0 / max(w, 1), 54.0 / max(h, 1)) nw = max(1, round(w * scale)) nh = max(1, round(h * scale)) crop = crop.resize((nw, nh), Image.Resampling.LANCZOS) canvas = Image.new("RGBA", (64, 64), (255, 255, 255, 0)) canvas.alpha_composite(crop, ((64 - nw) // 2, (64 - nh) // 2)) arr = np.asarray(canvas, dtype=np.float32) / 255.0 a = arr[:, :, 3] rgb = arr[:, :, :3] * a[:, :, None] + (1.0 - a[:, :, None]) edge = np.asarray(canvas.convert("L").filter(ImageFilter.FIND_EDGES), dtype=np.float32) / 255.0 return rgb, a, edge def cosine(a: np.ndarray, b: np.ndarray) -> float: aa = a.ravel() bb = b.ravel() denom = float(np.linalg.norm(aa) * np.linalg.norm(bb)) return float(np.dot(aa, bb) / denom) if denom else 0.0 def image_similarity(a: tuple[np.ndarray, np.ndarray, np.ndarray], b: tuple[np.ndarray, np.ndarray, np.ndarray]) -> float: rgb1, alpha1, edge1 = a rgb2, alpha2, edge2 = b rgb_score = 1.0 - float(np.mean((rgb1 - rgb2) ** 2)) alpha_score = cosine(alpha1, alpha2) edge_score = cosine(edge1, edge2) return 0.45 * rgb_score + 0.35 * alpha_score + 0.20 * edge_score def unique_sprite_objects(catalog: dict[str, Any]) -> list[dict[str, Any]]: seen: set[tuple[str, str, str | None]] = set() out: list[dict[str, Any]] = [] for obj in catalog.get("visual_objects", []): if not obj.get("resource_key") or not obj.get("resource_meta"): continue key = ( obj.get("component_role") or "", obj.get("visual_hash") or "", obj.get("standard_layer_name"), obj.get("group_id"), ) if key in seen: continue seen.add(key) out.append(obj) return out def build_features(catalog: dict[str, Any], catalog_dir: Path) -> tuple[list[dict[str, Any]], list[tuple[np.ndarray, np.ndarray, np.ndarray]]]: atlas = load_atlas(catalog_dir) objects = unique_sprite_objects(catalog) features = [visual_feature(atlas, obj["resource_meta"]) for obj in objects] return objects, features def compatible(cm: dict[str, Any], std: dict[str, Any]) -> bool: if cm.get("component_role") != std.get("component_role"): return False cg = cm.get("geometry_role") sg = std.get("geometry_role") if cg and sg and cg != sg: return False return True def classify_object( cm_obj: dict[str, Any], cm_feat: tuple[np.ndarray, np.ndarray, np.ndarray], std_objects: list[dict[str, Any]], std_features: list[tuple[np.ndarray, np.ndarray, np.ndarray]], ) -> dict[str, Any]: layer_best: dict[str, tuple[float, str]] = {} for std, feat in zip(std_objects, std_features): layer = std.get("standard_layer_name") if not layer or not compatible(cm_obj, std): continue score = image_similarity(cm_feat, feat) prev = layer_best.get(layer) if prev is None or score > prev[0]: layer_best[layer] = (score, std["visual_object_id"]) ranked = sorted( ( {"layer": layer, "score": score, "standard_visual_object_id": vo_id} for layer, (score, vo_id) in layer_best.items() ), key=lambda x: x["score"], reverse=True, ) top = ranked[0] if ranked else None second = ranked[1] if len(ranked) > 1 else None if not top: status = "UNKNOWN" else: margin = top["score"] - (second["score"] if second else 0.0) if top["score"] >= 0.90 and margin >= 0.025: status = "MATCH" elif top["score"] >= 0.82: status = "PARTIAL" else: status = "UNKNOWN" return { "visual_object_id": cm_obj["visual_object_id"], "group_id": cm_obj["group_id"], "component_role": cm_obj.get("component_role"), "geometry_role": cm_obj.get("geometry_role"), "resource_key": cm_obj.get("resource_key"), "status": status, "best": top, "alternatives": ranked[1:4], } def group_scores(object_matches: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) for item in object_matches: grouped[item["group_id"]].append(item) results: dict[str, dict[str, Any]] = {} for group_id, items in grouped.items(): evidence: dict[str, list[float]] = defaultdict(list) for item in items: for cand in ([item["best"]] if item.get("best") else []) + item.get("alternatives", []): if cand and cand["score"] >= 0.78: evidence[cand["layer"]].append(cand["score"]) ranking = [] for layer, scores in evidence.items(): scores = sorted(scores, reverse=True) strongest = scores[: min(12, len(scores))] coverage = min(1.0, len(scores) / max(3.0, len(items) * 0.25)) score = (sum(strongest) / len(strongest)) * (0.65 + 0.35 * coverage) ranking.append({ "layer": layer, "score": score, "evidence_count": len(scores), "best_evidence": scores[:5], }) ranking.sort(key=lambda x: x["score"], reverse=True) top = ranking[0] if ranking else None second = ranking[1] if len(ranking) > 1 else None if top: margin = top["score"] - (second["score"] if second else 0.0) dominance = top["evidence_count"] / max(1, sum(x["evidence_count"] for x in ranking)) if top["score"] >= 0.86 and margin >= 0.035 and dominance >= 0.40: status = "MATCH" elif top["score"] >= 0.80: status = "PARTIAL" else: status = "UNKNOWN" else: status = "UNKNOWN" results[group_id] = { "group_id": group_id, "status": status, "best": top, "alternatives": ranking[1:4], "object_count": len(items), } return results def apply_group_result(object_matches: list[dict[str, Any]], groups: dict[str, dict[str, Any]]) -> None: for item in object_matches: g = groups.get(item["group_id"]) if g and g["status"] == "MATCH": item["final_layer"] = g["best"]["layer"] item["final_basis"] = "GROUP_VISUAL_CONSENSUS" item["final_status"] = "MATCH" elif item["status"] == "MATCH": item["final_layer"] = item["best"]["layer"] item["final_basis"] = "OBJECT_VISUAL_MATCH" item["final_status"] = "MATCH" elif item["status"] == "PARTIAL": item["final_layer"] = item["best"]["layer"] if item.get("best") else None item["final_basis"] = "OBJECT_VISUAL_PARTIAL" item["final_status"] = "PARTIAL" else: item["final_layer"] = None item["final_basis"] = "INSUFFICIENT_VISUAL_EVIDENCE" item["final_status"] = "UNKNOWN" def blind_validation( cm_catalog: dict[str, Any], group_results: dict[str, dict[str, Any]], ) -> dict[str, Any]: # This is deliberately post-match audit only. Raw source-layer names are read # after predictions have already been produced. provenance = {g["group_id"]: g.get("provenance", {}) for g in cm_catalog.get("groups", [])} rows = [] comparable = correct = 0 for group_id, pred in sorted(group_results.items()): raw_name = provenance.get(group_id, {}).get("source_layer") predicted = pred.get("best", {}).get("layer") if pred.get("best") else None row = { "group_id": group_id, "raw_source_layer_for_audit_only": raw_name, "predicted_standard_layer": predicted, "status": pred.get("status"), "same_name_after_blind_match": bool(raw_name and predicted and raw_name == predicted), } if raw_name and predicted and not str(raw_name).startswith("s52_"): comparable += 1 if raw_name == predicted: correct += 1 rows.append(row) return { "note": "Raw CM95 source-layer is used only after matching for blind validation; it is not matcher input.", "comparable_group_count": comparable, "same_name_count": correct, "same_name_rate": (correct / comparable) if comparable else None, "rows": rows, } def html_report(result: dict[str, Any]) -> str: stats = result["summary"] val = result["blind_validation"] rows = [] for g in result["group_matches"]: best = g.get("best") or {} alts = ", ".join(f'{x["layer"]} {x["score"]:.3f}' for x in g.get("alternatives", [])) rows.append( "" f"{g['group_id']}" f"{g['status']}" f"{best.get('layer','')}" f"{best.get('score',0):.3f}" f"{g['object_count']}" f"{alts}" "" ) return f"""CM95 Visual Object Layer Match

CM95 Visual Object → NavSea Standard Layer

匹配程序不读取 CM95 source-layer 名称;Layer 名仅来自 NavSea 已冻结 Standard Visual Object Catalog。

CM95 Visual Objects {stats['cm95_sprite_visual_objects']}
MATCH {stats['object_match']}
PARTIAL {stats['object_partial']}
UNKNOWN {stats['object_unknown']}
Blind audit {val['same_name_count']}/{val['comparable_group_count']}

匿名 Group 的视觉匹配

{''.join(rows)}
匿名 Group状态预测标准 Layer分数VO 数候选

详细到每个 Visual Object 的匹配、候选和 blind validation 见 match-results.json。

""" def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--standard", required=True) ap.add_argument("--cm95", required=True) ap.add_argument("--output", required=True) args = ap.parse_args() std_path = Path(args.standard) cm_path = Path(args.cm95) output = Path(args.output) output.mkdir(parents=True, exist_ok=True) std_catalog = load_catalog(std_path) cm_catalog = load_catalog(cm_path) std_objects, std_features = build_features(std_catalog, std_path.parent.parent / "navsea-anonymous") cm_objects, cm_features = build_features(cm_catalog, cm_path.parent) # Sanitized match inputs deliberately omit CM95 provenance/source-layer. sanitized = [ { "visual_object_id": o["visual_object_id"], "group_id": o["group_id"], "component_role": o.get("component_role"), "geometry_role": o.get("geometry_role"), "resource_key": o.get("resource_key"), } for o in cm_objects ] (output / "cm95-match-input-sanitized.json").write_text( json.dumps(sanitized, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) object_matches = [ classify_object(obj, feat, std_objects, std_features) for obj, feat in zip(cm_objects, cm_features) ] groups = group_scores(object_matches) apply_group_result(object_matches, groups) validation = blind_validation(cm_catalog, groups) result = { "schema": "style-visual-object-match/v1", "method": { "cm95_source_layer_used_for_matching": False, "provider_name_used_for_matching": False, "resource_key_text_semantics_used_for_matching": False, "visual_inputs": ["sprite pixels", "component role", "geometry role", "anonymous group membership"], "standard_labels_from": "frozen NavSea Standard Visual Object Catalog", }, "summary": { "standard_sprite_visual_objects": len(std_objects), "cm95_sprite_visual_objects": len(cm_objects), "object_match": sum(x["final_status"] == "MATCH" for x in object_matches), "object_partial": sum(x["final_status"] == "PARTIAL" for x in object_matches), "object_unknown": sum(x["final_status"] == "UNKNOWN" for x in object_matches), "group_match": sum(x["status"] == "MATCH" for x in groups.values()), "group_partial": sum(x["status"] == "PARTIAL" for x in groups.values()), "group_unknown": sum(x["status"] == "UNKNOWN" for x in groups.values()), }, "group_matches": sorted(groups.values(), key=lambda x: x["group_id"]), "visual_object_matches": object_matches, "blind_validation": validation, } (output / "match-results.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") (output / "report.html").write_text(html_report(result), encoding="utf-8") print(json.dumps(result["summary"], ensure_ascii=False, indent=2)) print(json.dumps({k: validation[k] for k in ("comparable_group_count", "same_name_count", "same_name_rate")}, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()