This commit is contained in:
chk
2026-06-01 22:09:49 +02:00
parent 7b32a50889
commit e5b41e9110
49 changed files with 16727 additions and 10772 deletions

View File

@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""
stage0_corner_normals.py
========================
Go/No-Go test for corner-based marker orientation.
The current pipeline triangulates only each marker's CENTER (one point) and
copies the normal from robot.json. This script instead triangulates the 4
ArUco CORNERS of every marker (multi-view) from the existing detection + pose
JSONs, derives the marker normal from the triangulated corner plane, and
compares it against the ground-truth normal from render_*.json.
It answers one question, without touching the pipeline:
How accurate is a normal derived purely from triangulated corners?
Inputs (defaults target Scene8):
--evalDir data/evaluations/Scene8 (render_*_aruco_detection.json + _camera_pose.json)
--gt data/simulation/Scene8/render_a.json (ground-truth marker poses)
Output: per-link + overall statistics of the normal angle error (deg),
a text histogram, and optional JSON.
"""
from __future__ import annotations
import argparse
import glob
import json
import math
import os
import re
from collections import defaultdict
from typing import Dict, List, Tuple
import numpy as np
import cv2
# ------------------------------------------------------------------
# Loading
# ------------------------------------------------------------------
def load_cameras(eval_dir: str) -> Dict[str, dict]:
"""Load intrinsics, world->cam pose and per-marker 4-corner pixels per camera."""
cams: Dict[str, dict] = {}
for det_path in glob.glob(os.path.join(eval_dir, "*_aruco_detection.json")):
base = os.path.basename(det_path)
m = re.match(r"render_([A-Za-z0-9]+)_aruco_detection\.json", base)
if not m:
continue
cam_id = m.group(1)
pose_path = os.path.join(eval_dir, f"render_{cam_id}_camera_pose.json")
if not os.path.exists(pose_path):
print(f"[WARN] no pose for camera {cam_id}, skipping")
continue
det = json.load(open(det_path, "r", encoding="utf-8"))
pose = json.load(open(pose_path, "r", encoding="utf-8"))
K = np.array(det["camera"]["camera_matrix"], dtype=float).reshape(3, 3)
D = np.array(det["camera"]["distortion_coefficients"], dtype=float).reshape(-1, 1)
w2c = pose["camera_pose"]["world_to_camera"]
R = np.array(w2c["rotation_matrix"], dtype=float).reshape(3, 3)
t = np.array(w2c["translation_m"], dtype=float).reshape(3)
markers: Dict[int, np.ndarray] = {}
for d in det.get("detections", []):
pts = d.get("image_points_px")
if pts is None:
continue
markers[int(d["marker_id"])] = np.array(pts, dtype=float).reshape(4, 2)
cams[cam_id] = dict(K=K, D=D, R=R, t=t, markers=markers)
return cams
# ------------------------------------------------------------------
# Geometry
# ------------------------------------------------------------------
def triangulate_multiview(observations: List[Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]]) -> np.ndarray:
"""
DLT triangulation of one 3D point from N cameras.
observations: list of (K, D, R_wc, t_wc, uv_pixel).
Uses undistorted normalized coordinates so P = [R | t].
"""
A = []
for K, D, R, t, uv in observations:
und = cv2.undistortPoints(np.array([[uv]], dtype=np.float32), K, D).reshape(2)
x, y = float(und[0]), float(und[1])
P = np.hstack([R, t.reshape(3, 1)]) # 3x4 normalized projection
A.append(x * P[2] - P[0])
A.append(y * P[2] - P[1])
A = np.asarray(A, dtype=float)
_, _, Vt = np.linalg.svd(A)
X = Vt[-1]
if abs(X[3]) < 1e-12:
return np.array([np.nan, np.nan, np.nan])
return X[:3] / X[3]
def corner_plane_normal(corners3d: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
"""
Best-fit plane normal through the 4 triangulated corners (SVD), with the
sign fixed by the ArUco corner ordering (right-hand rule on the first edges).
Returns (unit_normal, center).
"""
center = corners3d.mean(axis=0)
centered = corners3d - center
_, _, Vt = np.linalg.svd(centered)
n = Vt[-1]
# ArUco corners are clockwise seen from the front, so the outward (camera-
# facing) marker normal — matching the Blender ground-truth convention —
# points opposite to cross(edge_01, edge_02). Align the sign to that.
cross = np.cross(corners3d[1] - corners3d[0], corners3d[2] - corners3d[0])
if np.dot(n, cross) > 0:
n = -n
nn = np.linalg.norm(n)
return (n / nn if nn > 1e-12 else n), center
def angle_between_deg(a: np.ndarray, b: np.ndarray) -> float:
na, nb = np.linalg.norm(a), np.linalg.norm(b)
if na < 1e-12 or nb < 1e-12:
return float("nan")
c = float(np.clip(np.dot(a, b) / (na * nb), -1.0, 1.0))
return math.degrees(math.acos(c))
# ------------------------------------------------------------------
# Reporting
# ------------------------------------------------------------------
def stats(values: List[float]) -> dict:
if not values:
return dict(n=0)
arr = np.array(values, dtype=float)
return dict(
n=len(arr),
mean=float(arr.mean()),
median=float(np.median(arr)),
p90=float(np.percentile(arr, 90)),
max=float(arr.max()),
)
def text_histogram(values: List[float], bins: List[float]) -> str:
counts = [0] * (len(bins) + 1)
for v in values:
placed = False
for i, b in enumerate(bins):
if v < b:
counts[i] += 1
placed = True
break
if not placed:
counts[-1] += 1
total = max(1, len(values))
lines = []
edges = [f"<{bins[0]:g}"] + [f"{bins[i-1]:g}-{bins[i]:g}" for i in range(1, len(bins))] + [f">={bins[-1]:g}"]
for label, c in zip(edges, counts):
bar = "#" * int(round(40 * c / total))
lines.append(f" {label:>10}deg | {c:3d} | {bar}")
return "\n".join(lines)
def main() -> None:
ap = argparse.ArgumentParser(description="Stage 0: corner-derived normal accuracy vs ground truth")
ap.add_argument("--evalDir", default="data/evaluations/Scene8",
help="folder with render_*_aruco_detection.json + _camera_pose.json")
ap.add_argument("--gt", default="data/simulation/Scene8/render_a.json",
help="ground-truth marker JSON (render_*.json)")
ap.add_argument("--minCams", type=int, default=2, help="min cameras to triangulate a marker")
ap.add_argument("--out", default=None, help="optional JSON output path")
args = ap.parse_args()
cams = load_cameras(args.evalDir)
print(f"[INFO] Cameras: {sorted(cams.keys())}")
if len(cams) < 2:
print("[ERROR] need >=2 cameras")
return
gt = {int(m["id"]): m for m in json.load(open(args.gt, "r", encoding="utf-8"))}
print(f"[INFO] Ground-truth markers: {len(gt)}")
marker_cams: Dict[int, List[str]] = defaultdict(list)
for cid, cam in cams.items():
for mid in cam["markers"]:
marker_cams[mid].append(cid)
results = []
for mid, cam_ids in sorted(marker_cams.items()):
if len(cam_ids) < args.minCams or mid not in gt:
continue
corners3d = []
ok = True
for ci in range(4):
obs = [(cams[c]["K"], cams[c]["D"], cams[c]["R"], cams[c]["t"], cams[c]["markers"][mid][ci])
for c in cam_ids]
X = triangulate_multiview(obs)
if not np.all(np.isfinite(X)):
ok = False
break
corners3d.append(X)
if not ok:
continue
corners3d = np.array(corners3d)
n_meas, center = corner_plane_normal(corners3d)
n_gt = np.array(gt[mid]["normal"], dtype=float)
a_signed = angle_between_deg(n_meas, n_gt)
a_flip = min(a_signed, 180.0 - a_signed)
center_err_mm = float(np.linalg.norm(center - np.array(gt[mid]["position_m"], dtype=float)) * 1000.0)
edge_mm = float(np.mean([np.linalg.norm(corners3d[(i + 1) % 4] - corners3d[i]) for i in range(4)]) * 1000.0)
results.append(dict(
id=mid, link=gt[mid].get("link", "?"), n_cams=len(cam_ids),
angle_signed_deg=a_signed, angle_flip_deg=a_flip,
center_err_mm=center_err_mm, edge_mm=edge_mm,
))
if not results:
print("[ERROR] no markers triangulated")
return
# ---- per-link breakdown ----
by_link: Dict[str, List[dict]] = defaultdict(list)
for r in results:
by_link[r["link"]].append(r)
print(f"\n{'='*70}\nCORNER-DERIVED NORMAL ERROR vs GROUND TRUTH ({len(results)} markers)\n{'='*70}")
print(f"{'link':>10} | {'n':>3} | {'normalErr(flip) mean/med/p90/max [deg]':>40} | {'ctrErr':>7} | {'edge':>6}")
print("-" * 95)
order = sorted(by_link.keys(), key=lambda k: -len(by_link[k]))
for link in order:
rs = by_link[link]
af = stats([r["angle_flip_deg"] for r in rs])
ce = np.mean([r["center_err_mm"] for r in rs])
ed = np.mean([r["edge_mm"] for r in rs])
print(f"{link:>10} | {af['n']:>3} | "
f"{af['mean']:7.2f} /{af['median']:6.2f} /{af['p90']:6.2f} /{af['max']:6.2f} | "
f"{ce:6.1f}mm | {ed:5.1f}mm")
all_flip = [r["angle_flip_deg"] for r in results]
all_signed = [r["angle_signed_deg"] for r in results]
a = stats(all_flip)
print("-" * 95)
print(f"{'ALL':>10} | {a['n']:>3} | "
f"{a['mean']:7.2f} /{a['median']:6.2f} /{a['p90']:6.2f} /{a['max']:6.2f} |")
# sign consistency: how many are 'flipped' (>90 deg signed)
flipped = sum(1 for s in all_signed if s > 90.0)
print(f"\n[INFO] sign: {flipped}/{len(all_signed)} markers have signed angle >90deg "
f"(consistent flip = trivially fixable; mixed = corner-order issue)")
print(f"[INFO] GT marker edge length is 25.0mm — triangulated mean edge tells corner-triangulation quality.")
print(f"\nHistogram of normal error (flip-invariant), {len(all_flip)} markers:")
print(text_histogram(all_flip, [1, 2, 5, 10, 20, 45]))
if args.out:
json.dump({"results": results, "overall": a}, open(args.out, "w", encoding="utf-8"), indent=2)
print(f"\n[INFO] wrote {args.out}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,515 @@
{
"results": [
{
"id": 41,
"link": "FingerA",
"n_cams": 4,
"angle_signed_deg": 1.1434420142628594,
"angle_flip_deg": 1.1434420142628594,
"center_err_mm": 0.44066643086764096,
"edge_mm": 24.016180354604842
},
{
"id": 42,
"link": "FingerA",
"n_cams": 2,
"angle_signed_deg": 0.7690528091207107,
"angle_flip_deg": 0.7690528091207107,
"center_err_mm": 0.47138540733240264,
"edge_mm": 24.71301484445469
},
{
"id": 43,
"link": "FingerB",
"n_cams": 2,
"angle_signed_deg": 1.4318713194101393,
"angle_flip_deg": 1.4318713194101393,
"center_err_mm": 1.3060871755679715,
"edge_mm": 24.559877670993526
},
{
"id": 44,
"link": "FingerB",
"n_cams": 3,
"angle_signed_deg": 1.4354091396273663,
"angle_flip_deg": 1.4354091396273663,
"center_err_mm": 0.49591116365549415,
"edge_mm": 24.275020717296524
},
{
"id": 46,
"link": "Board",
"n_cams": 3,
"angle_signed_deg": 1.546856095969066,
"angle_flip_deg": 1.546856095969066,
"center_err_mm": 0.5153058457174745,
"edge_mm": 23.732848555198473
},
{
"id": 47,
"link": "Board",
"n_cams": 3,
"angle_signed_deg": 0.4384043190770205,
"angle_flip_deg": 0.4384043190770205,
"center_err_mm": 0.3654847714391029,
"edge_mm": 23.78437453104651
},
{
"id": 51,
"link": "Board",
"n_cams": 2,
"angle_signed_deg": 3.06869698457462,
"angle_flip_deg": 3.06869698457462,
"center_err_mm": 0.7361951285731234,
"edge_mm": 24.111903805060948
},
{
"id": 53,
"link": "Board",
"n_cams": 4,
"angle_signed_deg": 0.8369743799079993,
"angle_flip_deg": 0.8369743799079993,
"center_err_mm": 0.4103871642969998,
"edge_mm": 23.584999176949037
},
{
"id": 54,
"link": "Board",
"n_cams": 2,
"angle_signed_deg": 7.0704376693875295,
"angle_flip_deg": 7.0704376693875295,
"center_err_mm": 0.3377764024826177,
"edge_mm": 23.502812403745992
},
{
"id": 55,
"link": "Board",
"n_cams": 3,
"angle_signed_deg": 0.5805708551806682,
"angle_flip_deg": 0.5805708551806682,
"center_err_mm": 0.4294083846132877,
"edge_mm": 23.63991754007732
},
{
"id": 56,
"link": "Board",
"n_cams": 2,
"angle_signed_deg": 1.4444651006662814,
"angle_flip_deg": 1.4444651006662814,
"center_err_mm": 0.4211345799124642,
"edge_mm": 23.797367435632474
},
{
"id": 58,
"link": "Board",
"n_cams": 4,
"angle_signed_deg": 1.0125201906777848,
"angle_flip_deg": 1.0125201906777848,
"center_err_mm": 0.42801314700148346,
"edge_mm": 23.62386484154858
},
{
"id": 60,
"link": "Board",
"n_cams": 2,
"angle_signed_deg": 2.6210545533711227,
"angle_flip_deg": 2.6210545533711227,
"center_err_mm": 0.4749803803977136,
"edge_mm": 23.77203259223079
},
{
"id": 61,
"link": "Board",
"n_cams": 2,
"angle_signed_deg": 3.072194121255888,
"angle_flip_deg": 3.072194121255888,
"center_err_mm": 0.6970136421239362,
"edge_mm": 22.261411657808225
},
{
"id": 62,
"link": "Board",
"n_cams": 4,
"angle_signed_deg": 1.5093452673644796,
"angle_flip_deg": 1.5093452673644796,
"center_err_mm": 0.5892317628649099,
"edge_mm": 23.687621228611007
},
{
"id": 63,
"link": "Board",
"n_cams": 2,
"angle_signed_deg": 1.7265363196160926,
"angle_flip_deg": 1.7265363196160926,
"center_err_mm": 0.7443757993233989,
"edge_mm": 23.573066344516413
},
{
"id": 64,
"link": "Board",
"n_cams": 5,
"angle_signed_deg": 1.5794667324616416,
"angle_flip_deg": 1.5794667324616416,
"center_err_mm": 0.38746648221661434,
"edge_mm": 23.91280234762309
},
{
"id": 66,
"link": "Board",
"n_cams": 3,
"angle_signed_deg": 1.067206162586506,
"angle_flip_deg": 1.067206162586506,
"center_err_mm": 0.47008892710515954,
"edge_mm": 23.540483661229402
},
{
"id": 68,
"link": "Board",
"n_cams": 2,
"angle_signed_deg": 1.2374042106636467,
"angle_flip_deg": 1.2374042106636467,
"center_err_mm": 0.48097116988306843,
"edge_mm": 23.579267072106983
},
{
"id": 69,
"link": "Board",
"n_cams": 3,
"angle_signed_deg": 2.331907164327804,
"angle_flip_deg": 2.331907164327804,
"center_err_mm": 0.7556520773496269,
"edge_mm": 23.886317251793574
},
{
"id": 72,
"link": "Board",
"n_cams": 5,
"angle_signed_deg": 0.9756163609132039,
"angle_flip_deg": 0.9756163609132039,
"center_err_mm": 0.3559113756299301,
"edge_mm": 23.912165684275976
},
{
"id": 73,
"link": "Board",
"n_cams": 2,
"angle_signed_deg": 1.6450376932823634,
"angle_flip_deg": 1.6450376932823634,
"center_err_mm": 0.5559668493434247,
"edge_mm": 23.691809129413368
},
{
"id": 75,
"link": "Board",
"n_cams": 2,
"angle_signed_deg": 1.5908298530562284,
"angle_flip_deg": 1.5908298530562284,
"center_err_mm": 0.43818306200399165,
"edge_mm": 23.83519637358736
},
{
"id": 79,
"link": "Board",
"n_cams": 4,
"angle_signed_deg": 1.9555389722730987,
"angle_flip_deg": 1.9555389722730987,
"center_err_mm": 0.6330383990904807,
"edge_mm": 23.905818442604684
},
{
"id": 82,
"link": "Board",
"n_cams": 2,
"angle_signed_deg": 5.64999716553466,
"angle_flip_deg": 5.64999716553466,
"center_err_mm": 0.45896967390213783,
"edge_mm": 23.96349217834595
},
{
"id": 83,
"link": "Board",
"n_cams": 3,
"angle_signed_deg": 1.3394651650608178,
"angle_flip_deg": 1.3394651650608178,
"center_err_mm": 0.5125771544940219,
"edge_mm": 23.57790846394849
},
{
"id": 84,
"link": "Board",
"n_cams": 5,
"angle_signed_deg": 1.5903943120945165,
"angle_flip_deg": 1.5903943120945165,
"center_err_mm": 0.46852115904452035,
"edge_mm": 23.782504641797235
},
{
"id": 85,
"link": "Board",
"n_cams": 3,
"angle_signed_deg": 0.16007549674022167,
"angle_flip_deg": 0.16007549674022167,
"center_err_mm": 0.5959941236153405,
"edge_mm": 23.456770837034693
},
{
"id": 86,
"link": "Board",
"n_cams": 4,
"angle_signed_deg": 2.910409435547614,
"angle_flip_deg": 2.910409435547614,
"center_err_mm": 0.5941332401694139,
"edge_mm": 23.616499433317873
},
{
"id": 92,
"link": "Board",
"n_cams": 4,
"angle_signed_deg": 0.703424991461952,
"angle_flip_deg": 0.703424991461952,
"center_err_mm": 0.34516139006794366,
"edge_mm": 23.589186098093407
},
{
"id": 95,
"link": "Board",
"n_cams": 3,
"angle_signed_deg": 0.9077064412203442,
"angle_flip_deg": 0.9077064412203442,
"center_err_mm": 0.4679557488019616,
"edge_mm": 23.640819756206223
},
{
"id": 96,
"link": "Board",
"n_cams": 4,
"angle_signed_deg": 0.6787246328111383,
"angle_flip_deg": 0.6787246328111383,
"center_err_mm": 0.42096508219349743,
"edge_mm": 23.615436793373675
},
{
"id": 97,
"link": "Board",
"n_cams": 3,
"angle_signed_deg": 0.6173651478477283,
"angle_flip_deg": 0.6173651478477283,
"center_err_mm": 0.32409567465563904,
"edge_mm": 23.529095190528608
},
{
"id": 102,
"link": "Board",
"n_cams": 3,
"angle_signed_deg": 1.2014930081148716,
"angle_flip_deg": 1.2014930081148716,
"center_err_mm": 0.8067803284068574,
"edge_mm": 23.658019025511702
},
{
"id": 103,
"link": "Board",
"n_cams": 5,
"angle_signed_deg": 1.0645869855879095,
"angle_flip_deg": 1.0645869855879095,
"center_err_mm": 0.3981807809406007,
"edge_mm": 23.681565447124076
},
{
"id": 105,
"link": "Board",
"n_cams": 3,
"angle_signed_deg": 0.18416716706733607,
"angle_flip_deg": 0.18416716706733607,
"center_err_mm": 0.339889092552321,
"edge_mm": 23.335067539509037
},
{
"id": 114,
"link": "Arm2",
"n_cams": 4,
"angle_signed_deg": 1.035150201301489,
"angle_flip_deg": 1.035150201301489,
"center_err_mm": 0.4746035317615624,
"edge_mm": 24.913426591026695
},
{
"id": 115,
"link": "Arm2",
"n_cams": 4,
"angle_signed_deg": 0.9021760629087481,
"angle_flip_deg": 0.9021760629087481,
"center_err_mm": 0.6218495698238082,
"edge_mm": 24.362103277922028
},
{
"id": 120,
"link": "Arm2",
"n_cams": 4,
"angle_signed_deg": 0.5569138868384742,
"angle_flip_deg": 0.5569138868384742,
"center_err_mm": 0.4409799514799929,
"edge_mm": 24.53632605870948
},
{
"id": 198,
"link": "Arm1",
"n_cams": 5,
"angle_signed_deg": 0.937935423310891,
"angle_flip_deg": 0.937935423310891,
"center_err_mm": 0.3747023863955182,
"edge_mm": 23.855002161679018
},
{
"id": 205,
"link": "Board",
"n_cams": 2,
"angle_signed_deg": 1.7203600028293877,
"angle_flip_deg": 1.7203600028293877,
"center_err_mm": 0.3235782454486941,
"edge_mm": 23.81796815521869
},
{
"id": 206,
"link": "Board",
"n_cams": 3,
"angle_signed_deg": 2.236776293627657,
"angle_flip_deg": 2.236776293627657,
"center_err_mm": 0.8150367716541782,
"edge_mm": 23.7299964756003
},
{
"id": 207,
"link": "Board",
"n_cams": 2,
"angle_signed_deg": 3.752570841912504,
"angle_flip_deg": 3.752570841912504,
"center_err_mm": 1.3596114357486246,
"edge_mm": 22.924583680006528
},
{
"id": 208,
"link": "Board",
"n_cams": 5,
"angle_signed_deg": 0.15504134243057513,
"angle_flip_deg": 0.15504134243057513,
"center_err_mm": 0.34416967908488894,
"edge_mm": 23.893344121965868
},
{
"id": 210,
"link": "Board",
"n_cams": 4,
"angle_signed_deg": 0.8675183900515165,
"angle_flip_deg": 0.8675183900515165,
"center_err_mm": 0.39827550357589053,
"edge_mm": 23.865763944763213
},
{
"id": 211,
"link": "Board",
"n_cams": 2,
"angle_signed_deg": 6.778964810293535,
"angle_flip_deg": 6.778964810293535,
"center_err_mm": 0.6128161118740145,
"edge_mm": 28.95194659717587
},
{
"id": 214,
"link": "Board",
"n_cams": 4,
"angle_signed_deg": 2.263369686442294,
"angle_flip_deg": 2.263369686442294,
"center_err_mm": 0.41274063853843634,
"edge_mm": 24.328457239006813
},
{
"id": 215,
"link": "Board",
"n_cams": 2,
"angle_signed_deg": 0.6839043688207129,
"angle_flip_deg": 0.6839043688207129,
"center_err_mm": 0.3731837721514992,
"edge_mm": 23.315766350549882
},
{
"id": 217,
"link": "Board",
"n_cams": 4,
"angle_signed_deg": 0.5080287913248831,
"angle_flip_deg": 0.5080287913248831,
"center_err_mm": 0.6933389330998323,
"edge_mm": 23.756701261435616
},
{
"id": 219,
"link": "Arm2",
"n_cams": 2,
"angle_signed_deg": 0.6129175586042959,
"angle_flip_deg": 0.6129175586042959,
"center_err_mm": 0.625908527719747,
"edge_mm": 24.523390487648925
},
{
"id": 229,
"link": "Arm1",
"n_cams": 4,
"angle_signed_deg": 0.2605199187659484,
"angle_flip_deg": 0.2605199187659484,
"center_err_mm": 0.32836977551949925,
"edge_mm": 23.868570044407132
},
{
"id": 232,
"link": "Ellbow",
"n_cams": 2,
"angle_signed_deg": 1.1784768319184977,
"angle_flip_deg": 1.1784768319184977,
"center_err_mm": 0.3146279496116689,
"edge_mm": 24.565060454760506
},
{
"id": 243,
"link": "Arm1",
"n_cams": 5,
"angle_signed_deg": 0.7226952116246294,
"angle_flip_deg": 0.7226952116246294,
"center_err_mm": 0.32911426133868327,
"edge_mm": 24.30342997031465
},
{
"id": 244,
"link": "Ellbow",
"n_cams": 2,
"angle_signed_deg": 1.9945025838741741,
"angle_flip_deg": 1.9945025838741741,
"center_err_mm": 0.8529221300355752,
"edge_mm": 24.12077132350216
},
{
"id": 245,
"link": "Ellbow",
"n_cams": 5,
"angle_signed_deg": 0.40470626965848056,
"angle_flip_deg": 0.40470626965848056,
"center_err_mm": 0.49781974099512044,
"edge_mm": 23.935770756719187
},
{
"id": 248,
"link": "Ellbow",
"n_cams": 5,
"angle_signed_deg": 0.25927270212557935,
"angle_flip_deg": 0.25927270212557935,
"center_err_mm": 0.3563043915502283,
"edge_mm": 24.24622073478565
}
],
"overall": {
"n": 56,
"mean": 1.5523294538712056,
"median": 1.1609594230906786,
"p90": 2.989553210061117,
"max": 7.0704376693875295
}
}