Files
appRobotHoming/scripts/9_evaluateMarker.py
2026-06-14 16:03:15 +02:00

146 lines
5.8 KiB
Python

#!/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()