#!/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( "
{g['group_id']}匹配程序不读取 CM95 source-layer 名称;Layer 名仅来自 NavSea 已冻结 Standard Visual Object Catalog。
| 匿名 Group | 状态 | 预测标准 Layer | 分数 | VO 数 | 候选 |
|---|
详细到每个 Visual Object 的匹配、候选和 blind validation 见 match-results.json。