#!/usr/bin/env python3 """ 9_evaluateMarker.py =================== Compare reconstructed markers against ground-truth (render_*.json). Reports, per link and overall: * 3D position error (mm) * orientation error (deg) between measured and GT normal — only meaningful when the detected file carries a MEASURED normal (aruco_marker_poses.json). Backwards compatible: the original positional CLI python 9_evaluateMarker.py detected.json original.json still works and prints the familiar summary; --out adds a JSON report. """ from __future__ import annotations import argparse import json import math from collections import defaultdict from typing import Any, Dict, List, Optional def dist3(a, b) -> float: return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b))) def angle_deg(a, b) -> Optional[float]: na = math.sqrt(sum(x * x for x in a)) nb = math.sqrt(sum(x * x for x in b)) if na < 1e-9 or nb < 1e-9: return None c = sum(x * y for x, y in zip(a, b)) / (na * nb) c = max(-1.0, min(1.0, c)) ang = math.degrees(math.acos(c)) return min(ang, 180.0 - ang) # flip-invariant def _pct(values: List[float], q: float) -> float: if not values: return 0.0 s = sorted(values) idx = max(0, min(len(s) - 1, int(q * len(s)) - 1)) return s[idx] def analyze(detected_file: str, original_file: str) -> Dict[str, Any]: detected = json.load(open(detected_file, "r", encoding="utf-8")) original = json.load(open(original_file, "r", encoding="utf-8")) det_markers = detected.get("markers", detected if isinstance(detected, list) else []) det_by_id = {int(m.get("marker_id", m.get("id", -1))): m for m in det_markers} orig_by_id = {int(m["id"]): m for m in original} det_ids = set(det_by_id) - {-1} orig_ids = set(orig_by_id) recognized = det_ids & orig_ids missing = orig_ids - det_ids per_link_pos: Dict[str, List[float]] = defaultdict(list) per_link_ang: Dict[str, List[float]] = defaultdict(list) rows = [] for mid in sorted(recognized): o = orig_by_id[mid] d = det_by_id[mid] link = o.get("link", d.get("link", "?")) pos_err = dist3(o["position_m"], d["position_m"]) * 1000.0 # mm per_link_pos[link].append(pos_err) ang_err = None if o.get("normal") is not None and d.get("normal") is not None: ang_err = angle_deg(o["normal"], d["normal"]) if ang_err is not None: per_link_ang[link].append(ang_err) rows.append({"marker_id": mid, "link": link, "pos_err_mm": pos_err, "normal_err_deg": ang_err}) all_pos = [r["pos_err_mm"] for r in rows] all_ang = [r["normal_err_deg"] for r in rows if r["normal_err_deg"] is not None] return { "n_original": len(orig_ids), "n_recognized": len(recognized), "n_missing": len(missing), "recognition_rate": (len(recognized) / len(orig_ids) * 100.0) if orig_ids else 0.0, "per_link": { ln: { "n": len(per_link_pos[ln]), "pos_mean_mm": sum(per_link_pos[ln]) / len(per_link_pos[ln]), "pos_max_mm": max(per_link_pos[ln]), "normal_mean_deg": (sum(per_link_ang[ln]) / len(per_link_ang[ln])) if per_link_ang.get(ln) else None, "normal_max_deg": max(per_link_ang[ln]) if per_link_ang.get(ln) else None, } for ln in sorted(per_link_pos, key=lambda k: -len(per_link_pos[k])) }, "overall": { "pos_mean_mm": (sum(all_pos) / len(all_pos)) if all_pos else 0.0, "pos_p90_mm": _pct(all_pos, 0.9), "pos_max_mm": max(all_pos) if all_pos else 0.0, "normal_mean_deg": (sum(all_ang) / len(all_ang)) if all_ang else None, "normal_p90_deg": _pct(all_ang, 0.9) if all_ang else None, "normal_max_deg": max(all_ang) if all_ang else None, }, "rows": rows, } def print_report(r: Dict[str, Any]) -> None: # familiar summary (backwards compatible wording) print(f"Erkannte Marker: {r['n_recognized']}") print(f"Nicht erkannte Marker: {r['n_missing']}") print(f"Gesamtzahl der Original-Marker: {r['n_original']}") print(f"Erkennungsrate: {r['recognition_rate']:.2f}%") o = r["overall"] print(f"Gemittelter 3D-Abstand: {o['pos_mean_mm']/1000.0:.4f}m") print(f"90%-Radius: {o['pos_p90_mm']/1000.0:.4f}m") print(f"Schlechtester Abstand: {o['pos_max_mm']/1000.0:.4f}m") if o["normal_mean_deg"] is not None: print(f"Normalen-Fehler: mean {o['normal_mean_deg']:.2f}deg / p90 {o['normal_p90_deg']:.2f}deg / max {o['normal_max_deg']:.2f}deg") print("\nPro Glied:") print(f" {'link':>10} | {'n':>3} | {'pos mean/max [mm]':>18} | {'normal mean/max [deg]':>21}") print(" " + "-" * 60) for ln, s in r["per_link"].items(): nd = f"{s['normal_mean_deg']:6.2f} /{s['normal_max_deg']:6.2f}" if s["normal_mean_deg"] is not None else " - " print(f" {ln:>10} | {s['n']:>3} | {s['pos_mean_mm']:7.2f} /{s['pos_max_mm']:7.2f} | {nd:>21}") def main() -> None: ap = argparse.ArgumentParser(description="Analysiert die Markererkennung (Position + Orientierung).") ap.add_argument("detected_file", help="aruco_marker_poses.json oder aruco_positions_*.json") ap.add_argument("original_file", help="Ground-Truth render_*.json") ap.add_argument("--out", default=None, help="optional JSON-Report") args = ap.parse_args() r = analyze(args.detected_file, args.original_file) if r["n_recognized"] == 0: print("Keine gemeinsamen Marker gefunden, um die Genauigkeit zu bewerten.") return print_report(r) if args.out: json.dump(r, open(args.out, "w", encoding="utf-8"), indent=2) print(f"\n[INFO] wrote {args.out}") if __name__ == "__main__": main()