From 222f0e55f7efa071d2a626cd95e4b785031acd31 Mon Sep 17 00:00:00 2001 From: chk <79915315+ChKendel@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:49:07 +0200 Subject: [PATCH] separate pipeline --- .gitignore | 28 + README.md | 19 +- .../approbot_pipeline/__init__.py | 23 + .../approbot_pipeline/__main__.py | 43 + .../approbot_pipeline/api/__init__.py | 12 + .../approbot_pipeline/api/__main__.py | 17 + .../approbot_pipeline/api/server.py | 83 + .../approbot_pipeline/pipeline.py | 180 ++ .../scripts/1_detect_aruco_observations.py | 608 +++++++ .../2_estimate_camera_from_observations.py | 834 +++++++++ .../3_multiview_bundle_adjustment_v4.py | 1499 +++++++++++++++++ .../scripts/3b_corner_marker_poses.py | 189 +++ .../scripts/pose_estimation.py | 539 ++++++ .../approbot_pipeline/scripts/robot_fk.py | 310 ++++ approbot-pipeline/config/robot.json.example | 19 + approbot-pipeline/doc/README.md | 212 +++ approbot-pipeline/doc/api_integration.md | 239 +++ approbot-pipeline/doc/robot_json.md | 280 +++ .../doc/robot_json_pipeline_schema.md | 28 + approbot-pipeline/docker-compose.yaml | 25 + approbot-pipeline/pyproject.toml | 25 + approbot-pipeline/tests/__init__.py | 0 .../tests/fixtures/robot_minimal.json | 19 + approbot-pipeline/tests/test_pipeline.py | 57 + benchmark/camera_count/analyze.py | 29 +- .../results/camera_count_finger_error_mm.png | Bin 31018 -> 36947 bytes .../results/camera_count_wrist_error_mm.png | Bin 29970 -> 32629 bytes doc/separate_pipeline_solution_roadmap.md | 266 +++ ...tup_with_more_degree_of_Freedom_roadmap.md | 0 pipeline/run_pipeline.py | 11 +- 30 files changed, 5579 insertions(+), 15 deletions(-) create mode 100644 .gitignore create mode 100644 approbot-pipeline/approbot_pipeline/__init__.py create mode 100644 approbot-pipeline/approbot_pipeline/__main__.py create mode 100644 approbot-pipeline/approbot_pipeline/api/__init__.py create mode 100644 approbot-pipeline/approbot_pipeline/api/__main__.py create mode 100644 approbot-pipeline/approbot_pipeline/api/server.py create mode 100644 approbot-pipeline/approbot_pipeline/pipeline.py create mode 100644 approbot-pipeline/approbot_pipeline/scripts/1_detect_aruco_observations.py create mode 100644 approbot-pipeline/approbot_pipeline/scripts/2_estimate_camera_from_observations.py create mode 100644 approbot-pipeline/approbot_pipeline/scripts/3_multiview_bundle_adjustment_v4.py create mode 100644 approbot-pipeline/approbot_pipeline/scripts/3b_corner_marker_poses.py create mode 100644 approbot-pipeline/approbot_pipeline/scripts/pose_estimation.py create mode 100644 approbot-pipeline/approbot_pipeline/scripts/robot_fk.py create mode 100644 approbot-pipeline/config/robot.json.example create mode 100644 approbot-pipeline/doc/README.md create mode 100644 approbot-pipeline/doc/api_integration.md create mode 100644 approbot-pipeline/doc/robot_json.md create mode 100644 approbot-pipeline/doc/robot_json_pipeline_schema.md create mode 100644 approbot-pipeline/docker-compose.yaml create mode 100644 approbot-pipeline/pyproject.toml create mode 100644 approbot-pipeline/tests/__init__.py create mode 100644 approbot-pipeline/tests/fixtures/robot_minimal.json create mode 100644 approbot-pipeline/tests/test_pipeline.py create mode 100644 doc/separate_pipeline_solution_roadmap.md create mode 100644 doc/setup_with_more_degree_of_Freedom_roadmap.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbdd4c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# ── Python ── +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ + +# ── Virtuelle Umgebung ── +.venv/ +venv/ + +# ── Generierte Studien-Ergebnisse ── +# Zwischenergebnisse der Kamera-Studie (groß, jederzeit reproduzierbar). +# Hinweis: bereits getrackte Dateien bleiben getrackt, bis sie mit +# git rm -r --cached data/camera_study +# aus dem Index genommen werden. +data/camera_study/ + +# Generierte Auswertungs-Outputs (JSON/CSV/PNG), Ordner aber behalten. +benchmark/camera_count/results/* +!benchmark/camera_count/results/.gitkeep + +# Optional: Pipeline-Zwischenergebnisse sind ebenfalls reproduzierbar. +# Aktuell noch getrackt — bei Bedarf die nächste Zeile aktivieren. +# data/evaluations/ + +# ── OS ── +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index 2b3dd5e..3de6f2a 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,11 @@ cd run .\run_pipeline.bat ..\data\simulation\Scene8 ``` +Nur eine Teilmenge der Kameras verwenden (für Experimente / die Kamera-Studie): +```bash +python pipeline/run_pipeline.py data/simulation/Scene8 --cameras a,c,f +``` + **Einzelne Stufen** (zum Experimentieren): ```bash python pipeline/3b_corner_marker_poses.py --evalDir data/evaluations/Scene8 --robot data/robot/robot.json @@ -176,11 +181,20 @@ python pipeline/pose_estimation.py data/evaluations/Scene8/aruco_marker_poses.js **Validierung & Benchmark:** ```bash -python benchmark/eval_pose.py data/evaluations/Scene8/robot_state.json data/simulation/Scene8/pose.json +python benchmark/eval_pose.py data/evaluations/Scene8/robot_state.json data/simulation/Scene8/pose.json --robot data/robot/robot.json python benchmark/run_benchmark.py --scenes 4 5 6 7 8 9 9a 9b 11 12 python benchmark/stage0_corner_normals.py --evalDir data/evaluations/Scene5 --gt data/simulation/Scene5/render_a.json python pipeline/9_evaluateMarker.py data/evaluations/Scene8/aruco_marker_poses.json data/simulation/Scene8/render_a.json ``` +Mit `--robot` ergänzt `eval_pose.py` FK-basierte Positionsfehler in mm (Handgelenk +und Finger getrennt; unbeobachtbare Punkte werden als unbekannt gewertet). + +**Kamera-Anzahl-Studie** (Wie viele Kameras braucht es?): +```bash +run\run_camera_study.bat # alle Szenen, k = 3..alle +python benchmark/camera_count/analyze.py # Tabelle + Boxplot (mm) +``` +Details: [`benchmark/camera_count/README.md`](benchmark/camera_count/README.md). **Szenengenerator** (Blender → Bilder + npz + Grundwahrheit): ```bat @@ -196,8 +210,9 @@ Normale, eingefärbt nach Abweichung zur Modellnormale (grün <2° / gelb 2–5 | Pfad | Inhalt | |---|---| -| `pipeline/` | Pipeline-Stufen 1–4, `robot_fk.py`, `pose_estimation.py` | +| `pipeline/` | Pipeline-Stufen 1–4, `robot_fk.py`, `pose_estimation.py`, `run_pipeline.py` (mit `--cameras`) | | `benchmark/` | `stage0_corner_normals.py`, `eval_pose.py`, `run_benchmark.py` | +| `benchmark/camera_count/` | Kamera-Anzahl-Studie (`run_camera_study.py`, `analyze.py`, eigene README) | | `run/` | `.bat`-Starter, `robot_viewer.html` | | `setup/generateSets/` | Blender-Szenengenerator (`render_Loop.py`, `render_robot.py`) | | `data/robot/robot.json` | Modell + Konfiguration | diff --git a/approbot-pipeline/approbot_pipeline/__init__.py b/approbot-pipeline/approbot_pipeline/__init__.py new file mode 100644 index 0000000..802b310 --- /dev/null +++ b/approbot-pipeline/approbot_pipeline/__init__.py @@ -0,0 +1,23 @@ +""" +approbot_pipeline +================= +Roboter-Pose-Schätzung aus Mehrkamera-ArUco-Bildern. + +Zwei Interfaces, gleiche Logik darunter: + + (A) Python-Bibliothek — direkt einbindbar: + from approbot_pipeline import estimate_from_dir, PipelineResult + result = estimate_from_dir("path/to/images", robot_json="robot.json") + print(result.joints) # {"x": 50.2, "y": -2.1, ...} + print(result.confidence) # {"x": "high", "b": "low", ...} + + (B) REST-API — läuft als Service im Docker-Container: + POST /v1/estimate (multipart: images + intrinsics) + GET /v1/health + GET /v1/config + → JSON mit joints, confidence, residual_rms, processing_ms +""" +from approbot_pipeline.pipeline import estimate_from_dir, PipelineResult + +__version__ = "1.0.0" +__all__ = ["estimate_from_dir", "PipelineResult", "__version__"] diff --git a/approbot-pipeline/approbot_pipeline/__main__.py b/approbot-pipeline/approbot_pipeline/__main__.py new file mode 100644 index 0000000..c042478 --- /dev/null +++ b/approbot-pipeline/approbot_pipeline/__main__.py @@ -0,0 +1,43 @@ +"""Einstiegspunkt: python -m approbot_pipeline [--robot ...] [--cameras ...]""" +import argparse +import json +import sys + + +def main() -> None: + ap = argparse.ArgumentParser( + description="approbot-pipeline: Bilder + robot.json → Gelenkwinkel im R⁷" + ) + ap.add_argument("image_dir", help="Ordner mit render_*.png/jpg und render_*.npz") + ap.add_argument("--robot", default=None, help="Pfad zu robot.json") + ap.add_argument("--evalDir", default=None, help="Ausgabeordner (Standard: temporäres Verzeichnis)") + ap.add_argument("--cameras", default=None, help="Kamera-IDs, kommagetrennt, z.B. a,b,d") + ap.add_argument("--lambdaWeight", type=float, default=100.0) + args = ap.parse_args() + + from approbot_pipeline import estimate_from_dir + + camera_filter = args.cameras.split(",") if args.cameras else None + try: + result = estimate_from_dir( + args.image_dir, + robot_json=args.robot, + eval_dir=args.evalDir, + lambda_weight=args.lambdaWeight, + camera_filter=camera_filter, + ) + except Exception as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + sys.exit(1) + + print(json.dumps({ + "joints": result.joints, + "confidence": result.confidence, + "n_markers": result.n_markers, + "residual_rms": result.residual_rms, + "processing_ms": result.processing_ms, + }, indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/approbot-pipeline/approbot_pipeline/api/__init__.py b/approbot-pipeline/approbot_pipeline/api/__init__.py new file mode 100644 index 0000000..cf273b8 --- /dev/null +++ b/approbot-pipeline/approbot_pipeline/api/__init__.py @@ -0,0 +1,12 @@ +"""approbot_pipeline.api — FastAPI REST-Service.""" +from approbot_pipeline.api.server import create_app + + +def start_server( + robot_json=None, + host: str = "0.0.0.0", + port: int = 8080, +) -> None: + import uvicorn + app = create_app(robot_json=robot_json) + uvicorn.run(app, host=host, port=port) diff --git a/approbot-pipeline/approbot_pipeline/api/__main__.py b/approbot-pipeline/approbot_pipeline/api/__main__.py new file mode 100644 index 0000000..bd3b7aa --- /dev/null +++ b/approbot-pipeline/approbot_pipeline/api/__main__.py @@ -0,0 +1,17 @@ +"""Einstiegspunkt: python -m approbot_pipeline.api [--robot ...] [--host ...] [--port ...]""" +import argparse + +from approbot_pipeline.api import start_server + + +def main() -> None: + ap = argparse.ArgumentParser(description="approbot-pipeline REST-API starten") + ap.add_argument("--robot", default=None, help="Pfad zu robot.json") + ap.add_argument("--host", default="0.0.0.0") + ap.add_argument("--port", type=int, default=8080) + args = ap.parse_args() + start_server(robot_json=args.robot, host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/approbot-pipeline/approbot_pipeline/api/server.py b/approbot-pipeline/approbot_pipeline/api/server.py new file mode 100644 index 0000000..44ca7fc --- /dev/null +++ b/approbot-pipeline/approbot_pipeline/api/server.py @@ -0,0 +1,83 @@ +"""FastAPI REST-API für approbot-pipeline.""" +from __future__ import annotations + +import json +import tempfile +from pathlib import Path +from typing import List, Optional + +from fastapi import FastAPI, File, HTTPException, UploadFile +from fastapi.responses import JSONResponse + +from approbot_pipeline import __version__, estimate_from_dir + +_robot_json: Optional[Path] = None + + +def create_app(robot_json: str | Path | None = None) -> FastAPI: + """App-Fabrik — setzt optionale Server-weite robot.json-Konfig.""" + global _robot_json + if robot_json: + _robot_json = Path(robot_json).resolve() + return _app + + +_app = FastAPI(title="approbot-pipeline", version=__version__) + + +@_app.get("/v1/health") +def health(): + return {"status": "ok", "version": __version__} + + +@_app.get("/v1/config") +def config(): + if _robot_json is None or not _robot_json.exists(): + raise HTTPException(404, "Keine robot.json konfiguriert") + data = json.loads(_robot_json.read_text(encoding="utf-8")) + return data.get("pose_estimation", {}) + + +@_app.post("/v1/estimate") +async def estimate( + images: List[UploadFile] = File(..., description="Kamerabilder (render_.png)"), + intrinsics: List[UploadFile] = File(..., description="Kamera-Intrinsiken (render_.npz)"), + robot_json: Optional[UploadFile] = File(default=None, description="robot.json (überschreibt Server-Konfig)"), +): + """Pose-Schätzung aus Kamerabildern. + + Multipart-Upload: + images[] — render_a.png, render_b.png, ... + intrinsics[] — render_a.npz, render_b.npz, ... (gleiche Reihenfolge) + robot_json — optional, überschreibt die Server-Konfiguration + """ + with tempfile.TemporaryDirectory(prefix="approbot_req_") as tmp: + tmp_path = Path(tmp) + + if robot_json is not None: + rj_path = tmp_path / "robot.json" + rj_path.write_bytes(await robot_json.read()) + elif _robot_json and _robot_json.exists(): + rj_path = _robot_json + else: + raise HTTPException(400, "Keine robot.json angegeben (weder Upload noch Server-Konfig)") + + for img in images: + (tmp_path / img.filename).write_bytes(await img.read()) + for npz in intrinsics: + (tmp_path / npz.filename).write_bytes(await npz.read()) + + try: + result = estimate_from_dir(tmp_path, robot_json=rj_path, eval_dir=tmp_path) + except FileNotFoundError as exc: + raise HTTPException(400, str(exc)) + except Exception as exc: + raise HTTPException(500, str(exc)) + + return JSONResponse({ + "joints": result.joints, + "confidence": result.confidence, + "residual_rms": result.residual_rms, + "n_markers": result.n_markers, + "processing_ms": result.processing_ms, + }) diff --git a/approbot-pipeline/approbot_pipeline/pipeline.py b/approbot-pipeline/approbot_pipeline/pipeline.py new file mode 100644 index 0000000..2a44195 --- /dev/null +++ b/approbot-pipeline/approbot_pipeline/pipeline.py @@ -0,0 +1,180 @@ +""" +pipeline.py +=========== +Orchestrator — ruft die Pipeline-Schritte als Subprocess auf und gibt +ein strukturiertes PipelineResult zurück. + +Schritte: + 1 ArUco-Detektion (scripts/1_detect_aruco_observations.py) + 2 Kamera-Posen (scripts/2_estimate_camera_from_observations.py) + 3 Multi-View Triangulation (scripts/3_multiview_bundle_adjustment_v4.py) + 3b Eck-Marker-Posen (scripts/3b_corner_marker_poses.py) + 4 Pose-Estimation (scripts/pose_estimation.py) +""" +from __future__ import annotations + +import glob +import json +import os +import re +import subprocess +import sys +import tempfile +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional + +SCRIPTS = Path(__file__).parent / "scripts" +PY = sys.executable + + +@dataclass +class PipelineResult: + """Ergebnis der Pose-Schätzung.""" + joints: Dict[str, float] # x,y,z,a,b,c,e → Wert (mm oder °) + confidence: Dict[str, str] # x,y,z,a,b,c,e → high|medium|low|none + n_markers: int # Anzahl triangulierter Marker + residual_rms: float # Residuum der Schätzung (mm) + processing_ms: float # Laufzeit der Pipeline + robot_state_path: Optional[Path] = None # Pfad zur erzeugten robot_state.json + errors: List[str] = field(default_factory=list) + + +def _run(cmd: list, step: str) -> None: + """Subprocess-Aufruf mit Fehlerbehandlung.""" + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + raise RuntimeError(f"[{step}] exit {r.returncode}:\n{r.stderr.strip()[-800:]}") + + +def _cam_id(path: str) -> Optional[str]: + m = re.match(r"render_([A-Za-z0-9]+)\.(png|jpg|jpeg)", os.path.basename(path), re.I) + return m.group(1) if m else None + + +def estimate_from_dir( + image_dir: str | Path, + robot_json: str | Path | None = None, + eval_dir: str | Path | None = None, + lambda_weight: float = 100.0, + camera_filter: Optional[List[str]] = None, +) -> PipelineResult: + """ + Pose-Schätzung aus einem Bildordner. + + Parameters + ---------- + image_dir Ordner mit render_*.png und render_*.npz + robot_json Pfad zu robot.json (Default: ROBOT_JSON env oder Exception) + eval_dir Ausgabeordner (Default: temporäres Verzeichnis) + lambda_weight Constraint-Gewicht für Bundle Adjustment + camera_filter Liste von Kamera-IDs; None = alle + + Returns + ------- + PipelineResult + """ + t0 = time.time() + image_dir = Path(image_dir).resolve() + + # robot.json bestimmen + if robot_json is None: + robot_json = os.environ.get("ROBOT_JSON") + if not robot_json: + raise ValueError("robot_json muss angegeben werden oder ROBOT_JSON env gesetzt sein") + robot_json = Path(robot_json).resolve() + + # Ausgabeordner + _tmp = None + if eval_dir is None: + _tmp = tempfile.mkdtemp(prefix="approbot_") + eval_dir = Path(_tmp) + else: + eval_dir = Path(eval_dir).resolve() + eval_dir.mkdir(parents=True, exist_ok=True) + + errors = [] + + try: + # Bilder sammeln + imgs = sorted( + glob.glob(str(image_dir / "render_*.png")) + + glob.glob(str(image_dir / "render_*.PNG")) + + glob.glob(str(image_dir / "render_*.jpg")) + + glob.glob(str(image_dir / "render_*.jpeg")) + ) + if camera_filter: + imgs = [i for i in imgs if _cam_id(i) in set(camera_filter)] + if not imgs: + raise FileNotFoundError(f"Keine render_*.png/jpg in {image_dir}") + + # ── Schritt 1: ArUco-Detektion ────────────────────────────── + for img in imgs: + cid = _cam_id(img) + if not cid: + continue + npz = image_dir / f"render_{cid}.npz" + if not npz.exists(): + npz_candidates = list(image_dir.glob("*.npz")) + if not npz_candidates: + raise FileNotFoundError(f"Keine .npz-Intrinsik in {image_dir}") + npz = npz_candidates[0] + _run([PY, str(SCRIPTS / "1_detect_aruco_observations.py"), + "-i", str(img), "-npz", str(npz), + "-outDir", str(eval_dir), "-robot", str(robot_json), "-cameraId", cid], + "Schritt 1") + + # ── Schritt 2: Kamera-Posen ────────────────────────────────── + dets = sorted(glob.glob(str(eval_dir / "*_aruco_detection.json"))) + if not dets: + raise RuntimeError("Keine ArUco-Detektionen erzeugt (Schritt 1)") + for d in dets: + _run([PY, str(SCRIPTS / "2_estimate_camera_from_observations.py"), + "-i", d, "-robot", str(robot_json), "-outDir", str(eval_dir)], + "Schritt 2") + + # ── Schritt 3: Multi-View Triangulation ────────────────────── + poses = sorted(glob.glob(str(eval_dir / "*_camera_pose.json"))) + if not poses: + raise RuntimeError("Keine Kamera-Posen erzeugt (Schritt 2)") + det_args = sum([["-det", d] for d in dets], []) + pose_args = sum([["-pose", p] for p in poses], []) + _run([PY, str(SCRIPTS / "3_multiview_bundle_adjustment_v4.py"), + "-robot", str(robot_json), "-lambdaWeight", str(lambda_weight)] + + det_args + pose_args, "Schritt 3") + + # ── Schritt 3b: Eck-Marker-Posen ───────────────────────────── + _run([PY, str(SCRIPTS / "3b_corner_marker_poses.py"), + "--evalDir", str(eval_dir), "--robot", str(robot_json)], "Schritt 3b") + + # ── Schritt 4: Pose-Estimation ──────────────────────────────── + marker_poses = eval_dir / "aruco_marker_poses.json" + state_out = eval_dir / "robot_state.json" + _run([PY, str(SCRIPTS / "pose_estimation.py"), + str(marker_poses), "-robot", str(robot_json), "-out", str(state_out)], + "Schritt 4") + + # Ergebnis lesen + state = json.load(open(state_out, "r", encoding="utf-8")) + mv = state.get("movements", {}) + joints = {k: float(v.get("value", 0.0)) for k, v in mv.items() if isinstance(v, dict)} + confidence = {k: str(v.get("confidence", "none")) for k, v in mv.items() if isinstance(v, dict)} + + return PipelineResult( + joints=joints, + confidence=confidence, + n_markers=int(state.get("num_markers", 0)), + residual_rms=float(state.get("residual_rms", 0.0)), + processing_ms=round((time.time() - t0) * 1000), + robot_state_path=state_out, + errors=errors, + ) + + except Exception as exc: + errors.append(str(exc)) + raise + finally: + # Temporäres Verzeichnis bleibt absichtlich erhalten für Debugging; + # Aufrufer kann es über result.robot_state_path.parent aufräumen. + pass diff --git a/approbot-pipeline/approbot_pipeline/scripts/1_detect_aruco_observations.py b/approbot-pipeline/approbot_pipeline/scripts/1_detect_aruco_observations.py new file mode 100644 index 0000000..e9d591d --- /dev/null +++ b/approbot-pipeline/approbot_pipeline/scripts/1_detect_aruco_observations.py @@ -0,0 +1,608 @@ +#!/usr/bin/env python3 + +import argparse +import json +import os +import hashlib +import time +import uuid +from typing import Dict, Any + +import cv2 +import numpy as np + + +# ------------------------------------------------------------ +# Utilities +# ------------------------------------------------------------ + + +def resolve_path(path): + path = os.path.expanduser(path) + + # Absoluter Pfad → direkt verwenden + if os.path.isabs(path): + return path + + # Relativer Pfad → absolut machen (auf Basis aktuellem cwd) + return os.path.abspath(path) + +def load_intrinsics_npz(npz_path: str): + data = np.load(npz_path) + + for k in ('camera_matrix', 'mtx', 'K'): + if k in data: + K = data[k].astype(np.float32) + break + else: + raise KeyError('Camera matrix not found in npz') + + for k in ('dist_coeffs', 'dist', 'D'): + if k in data: + D = data[k].astype(np.float32).reshape(-1, 1) + break + else: + D = np.zeros((5, 1), dtype=np.float32) + + return K, D + + +# ------------------------------------------------------------ + +def load_robot_vision_config(robot_json_path: str): + + + + robot_json_path = resolve_path(robot_json_path) + with open(robot_json_path, 'r', encoding='utf-8') as f: + robot = json.load(f) + + vision_config = robot.get('vision_config', {}) + + marker_type = vision_config.get('MarkerType', 'DICT_4X4_250') + marker_size = float(vision_config.get('MarkerSize', 0.025)) + + return { + 'MarkerType': marker_type, + 'MarkerSize': marker_size + } + + +# ------------------------------------------------------------ + +def get_aruco_detector(dict_name: str): + + mapping = { + 'DICT_4X4_250': cv2.aruco.DICT_4X4_250, + 'DICT_5X5_100': cv2.aruco.DICT_5X5_100, + 'DICT_6X6_250': cv2.aruco.DICT_6X6_250, + 'DICT_ARUCO_ORIGINAL': cv2.aruco.DICT_ARUCO_ORIGINAL, + } + + dict_id = mapping.get(dict_name, cv2.aruco.DICT_4X4_250) + + dictionary = cv2.aruco.getPredefinedDictionary(dict_id) + + try: + params = cv2.aruco.DetectorParameters() + except Exception: + params = cv2.aruco.DetectorParameters_create() + + try: + detector = cv2.aruco.ArucoDetector(dictionary, params) + return detector, None + + except Exception: + return None, (dictionary, params) + + +# ------------------------------------------------------------ + +def detect_markers(image, detector_tuple): + + detector, fallback = detector_tuple + + if detector is not None: + + corners, ids, rejected = detector.detectMarkers(image) + + else: + + dictionary, params = fallback + + corners, ids, rejected = cv2.aruco.detectMarkers( + image, + dictionary, + parameters=params + ) + + return corners, ids, rejected + + +# ------------------------------------------------------------ + +def hash_file(path): + + sha = hashlib.sha256() + + with open(path, 'rb') as f: + + while True: + + chunk = f.read(1024 * 1024) + + if not chunk: + break + + sha.update(chunk) + + return sha.hexdigest() + + +# ------------------------------------------------------------ + +def polygon_mask(shape, polygon): + + mask = np.zeros(shape, dtype=np.uint8) + + cv2.fillConvexPoly( + mask, + polygon.astype(np.int32), + 255 + ) + + return mask + + +# ------------------------------------------------------------ + +def shrink_polygon(points, scale=0.80): + + center = np.mean(points, axis=0) + + shrunk = center + (points - center) * scale + + return shrunk.astype(np.float32) + + +# ------------------------------------------------------------ + +def compute_sharpness(gray_image, polygon): + + shrunk = shrink_polygon(polygon, scale=0.80) + + mask = polygon_mask(gray_image.shape, shrunk) + + pixels = gray_image[mask == 255] + + if pixels.size == 0: + return 0.0 + + temp = np.zeros_like(gray_image) + temp[mask == 255] = gray_image[mask == 255] + + lap = cv2.Laplacian(temp, cv2.CV_64F) + + values = lap[mask == 255] + + if values.size == 0: + return 0.0 + + return float(values.var()) + + +# ------------------------------------------------------------ + +def compute_contrast(gray_image, polygon): + + shrunk = shrink_polygon(polygon, scale=0.80) + + mask = polygon_mask(gray_image.shape, shrunk) + + pixels = gray_image[mask == 255] + + if pixels.size == 0: + + return { + 'p05': 0.0, + 'p95': 0.0, + 'dynamic_range': 0.0, + 'mean_gray': 0.0, + 'std_gray': 0.0 + } + + p05 = float(np.percentile(pixels, 5)) + p95 = float(np.percentile(pixels, 95)) + + return { + 'p05': p05, + 'p95': p95, + 'dynamic_range': float(p95 - p05), + 'mean_gray': float(np.mean(pixels)), + 'std_gray': float(np.std(pixels)) + } + + +# ------------------------------------------------------------ + +def compute_edge_ratio(corners): + + edge_lengths = [] + + for k in range(4): + + p1 = corners[k] + p2 = corners[(k + 1) % 4] + + edge_lengths.append( + float(np.linalg.norm(p1 - p2)) + ) + + edge_ratio = ( + max(edge_lengths) / + max(1e-6, min(edge_lengths)) + ) + + return edge_ratio, edge_lengths + + +# ------------------------------------------------------------ + +def compute_geometry_metrics(center, corners, width, height): + + image_center = np.array( + [width / 2.0, height / 2.0], + dtype=np.float32 + ) + + dist_center = np.linalg.norm(center - image_center) + + max_dist = np.linalg.norm(image_center) + + distance_center_norm = float( + dist_center / max(1e-6, max_dist) + ) + + min_x = np.min(corners[:, 0]) + max_x = np.max(corners[:, 0]) + + min_y = np.min(corners[:, 1]) + max_y = np.max(corners[:, 1]) + + border_distance_px = float(min( + min_x, + min_y, + width - max_x, + height - max_y + )) + + return { + 'distance_to_center_norm': distance_center_norm, + 'distance_to_border_px': border_distance_px + } + + +# ------------------------------------------------------------ + +def compute_confidence( + area_px, + sharpness, + edge_ratio, + dynamic_range, + border_distance_px +): + + score = 1.0 + + # area + score *= min(1.0, area_px / 1500.0) + + # sharpness + score *= min(1.0, sharpness / 120.0) + + # edge distortion + score *= 1.0 / max(1.0, edge_ratio) + + # contrast + score *= min(1.0, dynamic_range / 80.0) + + # border distance + score *= min(1.0, max(0.0, border_distance_px) / 50.0) + + score = max(0.0, min(1.0, score)) + + return float(score) + + +# ------------------------------------------------------------ + +def main(): + + parser = argparse.ArgumentParser() + + parser.add_argument( + '-i', + '--image', + required=True + ) + + parser.add_argument( + '-npz', + '--intrinsics', + required=True + ) + + parser.add_argument( + '-robot', + '--robot', + required=True + ) + + parser.add_argument( + '-cameraId', + '--cameraId', + required=True, + type=str + ) + + parser.add_argument( + '-outDir', + '--outDir', + required=True + ) + + args = parser.parse_args() + + out_dir = resolve_path(args.outDir) + os.makedirs(out_dir, exist_ok=True) + + + # -------------------------------------------------------- + # Load robot vision config + # -------------------------------------------------------- + + vision_config = load_robot_vision_config(args.robot) + + marker_type = vision_config['MarkerType'] + marker_size = vision_config['MarkerSize'] + + # -------------------------------------------------------- + # Load image + # -------------------------------------------------------- + + + image_path = resolve_path(args.image) + image = cv2.imread(image_path) + + + if image is None: + raise RuntimeError(f'Cannot read image: {args.image}') + + gray = cv2.cvtColor( + image, + cv2.COLOR_BGR2GRAY + ) + + height, width = gray.shape[:2] + + # -------------------------------------------------------- + # Intrinsics + # -------------------------------------------------------- + + + intrinsics_path = resolve_path(args.intrinsics) + K, D = load_intrinsics_npz(intrinsics_path) + + # -------------------------------------------------------- + # Detection + # -------------------------------------------------------- + + detector_tuple = get_aruco_detector(marker_type) + + corners_list, ids, rejected = detect_markers( + gray, + detector_tuple + ) + + detections = [] + + # -------------------------------------------------------- + # Valid detections + # -------------------------------------------------------- + + if ids is not None: + + ids = ids.flatten().tolist() + + for i, marker_id in enumerate(ids): + + corners = corners_list[i].reshape((4, 2)).astype(np.float32) + + center = corners.mean(axis=0) + + area_px = float( + cv2.contourArea(corners) + ) + + perimeter_px = float( + cv2.arcLength(corners, True) + ) + + edge_ratio, edge_lengths = compute_edge_ratio(corners) + + sharpness = compute_sharpness( + gray, + corners + ) + + contrast = compute_contrast( + gray, + corners + ) + + geometry = compute_geometry_metrics( + center, + corners, + width, + height + ) + + confidence = compute_confidence( + area_px=area_px, + sharpness=sharpness, + edge_ratio=edge_ratio, + dynamic_range=contrast['dynamic_range'], + border_distance_px=geometry['distance_to_border_px'] + ) + + detection = { + + 'observation_id': str(uuid.uuid4()), + + 'type': 'aruco', + + 'marker_id': int(marker_id), + + 'marker_size_m': marker_size, + + 'image_points_px': corners.tolist(), + + 'center_px': center.tolist(), + + 'quality': { + + 'area_px': area_px, + + 'perimeter_px': perimeter_px, + + 'sharpness': { + 'laplacian_var': sharpness + }, + + 'contrast': contrast, + + 'geometry': geometry, + + 'edge_ratio': edge_ratio, + + 'edge_lengths_px': edge_lengths + }, + + 'confidence': confidence + } + + detections.append(detection) + + # -------------------------------------------------------- + # Rejected candidates + # -------------------------------------------------------- + + rejected_candidates = [] + + if rejected is not None: + + for candidate in rejected: + + pts = candidate.reshape((-1, 2)).astype(np.float32) + + center = pts.mean(axis=0) + + area_px = float( + cv2.contourArea(pts) + ) + + rejected_candidates.append({ + + 'image_points_px': pts.tolist(), + + 'center_px': center.tolist(), + + 'area_px': area_px + }) + + # -------------------------------------------------------- + # Final output + # -------------------------------------------------------- + + output = { + + 'schema_version': '1.0', + + 'created_utc': time.strftime( + '%Y-%m-%dT%H:%M:%SZ', + time.gmtime() + ), + + 'vision_config': { + 'MarkerType': marker_type, + 'MarkerSize': marker_size + }, + + 'camera': { + + 'camera_id': args.cameraId, + + 'intrinsics_file': os.path.abspath(args.intrinsics), + + 'camera_matrix': K.tolist(), + + 'distortion_coefficients': D.reshape(-1).tolist() + }, + + 'image': { + + 'image_file': os.path.abspath(args.image), + + 'image_sha256': hash_file(args.image), + + 'width_px': int(width), + + 'height_px': int(height) + }, + + 'aruco': { + + 'dictionary': marker_type, + + 'num_detected_markers': len(detections), + + 'num_rejected_candidates': len(rejected_candidates) + }, + + 'detections': detections, + + 'rejected_candidates': rejected_candidates + } + + # -------------------------------------------------------- + # Output path + # -------------------------------------------------------- + + input_filename = os.path.basename(args.image) + + input_base = os.path.splitext(input_filename)[0] + + out_json = os.path.join( + out_dir, + f'{input_base}_aruco_detection.json' + ) + + # -------------------------------------------------------- + # Save JSON + # -------------------------------------------------------- + + with open(out_json, 'w', encoding='utf-8') as f: + + json.dump( + output, + f, + indent=2 + ) + + print(f'Saved: {out_json}') + + +# ------------------------------------------------------------ + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/approbot-pipeline/approbot_pipeline/scripts/2_estimate_camera_from_observations.py b/approbot-pipeline/approbot_pipeline/scripts/2_estimate_camera_from_observations.py new file mode 100644 index 0000000..f6197c7 --- /dev/null +++ b/approbot-pipeline/approbot_pipeline/scripts/2_estimate_camera_from_observations.py @@ -0,0 +1,834 @@ +#!/usr/bin/env python3 +""" +2_estimate_camera_from_observations.py + +Estimate a single camera pose from ArUco observations stored in +*_aruco_detection.json, using marker world positions from robot.json. + +This follows the same mathematical idea as readTwoImages.py: +1) use detected marker observations, +2) get an initial pose from a rigid transform, +3) refine with Levenberg-Marquardt on normalized reprojection residuals. + +Difference to readTwoImages.py: +- No image processing here. +- Input is the observation JSON created by 1_detect_aruco_observations.py. +- Output is xxx_camera_pose.json. +- Unknown marker reconstruction is intentionally omitted. + +Assumptions: +- robot.json contains a marker list for the board/world frame. +- At minimum, marker positions are present for the reference markers. +- The detection JSON contains camera intrinsics and marker corners. + +Typical usage: + python3 2_estimate_camera_from_observations.py \ + -i frame_0001_aruco_detection.json \ + -robot robot.json \ + -outDir results/ + +Output: + frame_0001_camera_pose.json + +Notes on uncertainty: +- The script computes an approximate 6x6 covariance for the pose parameters + [rvec_x, rvec_y, rvec_z, t_x, t_y, t_z]. +- It also propagates that covariance to camera center uncertainty in world + coordinates and to approximate roll/pitch/yaw uncertainty. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from typing import Any, Dict, List, Optional, Tuple + +import cv2 +import numpy as np + + +# --------------------------------------------------------------------- +# Path / JSON helpers +# --------------------------------------------------------------------- + +def resolve_path(path: str) -> str: + path = os.path.expanduser(path) + if os.path.isabs(path): + return path + return os.path.abspath(path) + + +def load_json(path: str) -> Dict[str, Any]: + with open(resolve_path(path), "r", encoding="utf-8") as f: + return json.load(f) + + +def save_json(path: str, data: Dict[str, Any]) -> None: + with open(resolve_path(path), "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + +# --------------------------------------------------------------------- +# Intrinsics +# --------------------------------------------------------------------- + +def load_intrinsics_from_detection(detection: Dict[str, Any]) -> Tuple[np.ndarray, np.ndarray]: + """ + Primary source: the embedded camera intrinsics in the detection JSON. + """ + camera = detection.get("camera", {}) + K = camera.get("camera_matrix", None) + D = camera.get("distortion_coefficients", None) + + if K is None: + raise KeyError("camera_matrix missing in detection JSON.") + if D is None: + D = [0, 0, 0, 0, 0] + + K = np.array(K, dtype=np.float32).reshape(3, 3) + D = np.array(D, dtype=np.float32).reshape(-1, 1) + return K, D + + +# --------------------------------------------------------------------- +# Robot JSON parsing +# --------------------------------------------------------------------- + +def _rotation_matrix_from_any(rotation: Any) -> np.ndarray: + """ + Best-effort parser for marker rotation. + + Supported inputs: + - 3x3 matrix as nested list + - flat 9 list + - dict with keys: + * rotation_matrix / matrix + * rvec / rodriques / rodrigues + * euler_deg / rpy_deg / roll_pitch_yaw_deg + * euler_rad / rpy_rad / roll_pitch_yaw_rad + * quaternion / quat (best-effort, expects [x,y,z,w] unless specified) + - None => identity + + The pose estimator below only needs marker positions, but we keep + this parser for completeness and future extension. + """ + if rotation is None: + return np.eye(3, dtype=np.float32) + + # Direct matrix + if isinstance(rotation, (list, tuple, np.ndarray)): + arr = np.array(rotation, dtype=np.float32) + if arr.shape == (3, 3): + return arr + if arr.size == 9: + return arr.reshape(3, 3).astype(np.float32) + if arr.size == 3: + # Treat as Rodrigues vector + R, _ = cv2.Rodrigues(arr.reshape(3, 1)) + return R.astype(np.float32) + return np.eye(3, dtype=np.float32) + + if isinstance(rotation, dict): + for key in ("rotation_matrix", "matrix"): + if key in rotation: + return _rotation_matrix_from_any(rotation[key]) + + for key in ("rvec", "rodrigues", "rodriques"): + if key in rotation: + v = np.array(rotation[key], dtype=np.float32).reshape(3, 1) + R, _ = cv2.Rodrigues(v) + return R.astype(np.float32) + + def euler_to_R(roll: float, pitch: float, yaw: float, degrees: bool = True) -> np.ndarray: + if degrees: + roll = np.deg2rad(roll) + pitch = np.deg2rad(pitch) + yaw = np.deg2rad(yaw) + cr, sr = np.cos(roll), np.sin(roll) + cp, sp = np.cos(pitch), np.sin(pitch) + cy, sy = np.cos(yaw), np.sin(yaw) + + Rx = np.array([[1, 0, 0], + [0, cr, -sr], + [0, sr, cr]], dtype=np.float32) + Ry = np.array([[cp, 0, sp], + [0, 1, 0], + [-sp, 0, cp]], dtype=np.float32) + Rz = np.array([[cy, -sy, 0], + [sy, cy, 0], + [0, 0, 1]], dtype=np.float32) + # ZYX convention + return (Rz @ Ry @ Rx).astype(np.float32) + + for key in ("euler_deg", "rpy_deg", "roll_pitch_yaw_deg"): + if key in rotation: + vals = np.array(rotation[key], dtype=np.float32).reshape(-1) + if vals.size == 3: + return euler_to_R(float(vals[0]), float(vals[1]), float(vals[2]), degrees=True) + + for key in ("euler_rad", "rpy_rad", "roll_pitch_yaw_rad"): + if key in rotation: + vals = np.array(rotation[key], dtype=np.float32).reshape(-1) + if vals.size == 3: + return euler_to_R(float(vals[0]), float(vals[1]), float(vals[2]), degrees=False) + + for key in ("quaternion", "quat"): + if key in rotation: + q = np.array(rotation[key], dtype=np.float32).reshape(-1) + if q.size == 4: + # Best-effort: try [x,y,z,w] + x, y, z, w = [float(v) for v in q] + R = np.array([ + [1 - 2*y*y - 2*z*z, 2*x*y - 2*z*w, 2*x*z + 2*y*w], + [2*x*y + 2*z*w, 1 - 2*x*x - 2*z*z, 2*y*z - 2*x*w], + [2*x*z - 2*y*w, 2*y*z + 2*x*w, 1 - 2*x*x - 2*y*y] + ], dtype=np.float32) + return R + + return np.eye(3, dtype=np.float32) + + +def get_marker_rotation(marker: Dict[str, Any]) -> np.ndarray: + """ + Flexible rotation extraction. Falls back to identity if absent. + """ + for key in ("rotation", "rotation_matrix", "matrix", "pose_rotation", "orientation"): + if key in marker: + return _rotation_matrix_from_any(marker[key]) + + # Also allow flat pose-style fields + if "rvec" in marker or "rodrigues" in marker: + return _rotation_matrix_from_any({"rvec": marker.get("rvec", marker.get("rodrigues"))}) + if "euler_deg" in marker: + return _rotation_matrix_from_any({"euler_deg": marker["euler_deg"]}) + if "rpy_deg" in marker: + return _rotation_matrix_from_any({"rpy_deg": marker["rpy_deg"]}) + if "quaternion" in marker: + return _rotation_matrix_from_any({"quaternion": marker["quaternion"]}) + + return np.eye(3, dtype=np.float32) + + +def load_marker_lookup(robot_json_path: str) -> Dict[int, Dict[str, Any]]: + """ + Supports the new format: + robot_data["links"]["Board"]["markers"] + + Fallback: + robot_data["Marker"] + """ + robot_json_path = resolve_path(robot_json_path) + with open(robot_json_path, "r", encoding="utf-8") as f: + robot_data = json.load(f) + + length_units = str(robot_data.get("units", {}).get("length", "")).strip().lower() + length_scale = 1.0 + if length_units in ("mm", "millimeter", "millimeters"): + length_scale = 1.0 / 1000.0 + elif length_units in ("cm", "centimeter", "centimeters"): + length_scale = 1.0 / 100.0 + + marker_lookup: Dict[int, Dict[str, Any]] = {} + + links = robot_data.get("links", {}) + board = links.get("Board") + + markers = None + if board and "markers" in board: + markers = board["markers"] + + if not markers: + markers = robot_data.get("Marker", []) + + for marker in markers: + marker_id = int(marker.get("id", -1)) + if marker_id < 0: + continue + + if "position" not in marker: + continue + + pos = marker.get("position") + if pos is None: + continue + + if len(pos) != 3: + continue + + rotation = get_marker_rotation(marker) + + marker_lookup[marker_id] = { + "position": np.array(pos, dtype=np.float32) * np.float32(length_scale), + "rotation": rotation, + "on": marker.get("on", "unknown"), + } + + return marker_lookup + + +def load_robot_marker_size(robot_json_path: str) -> Optional[float]: + """ + Best-effort marker size reader from robot.json. + Returns meters if found, otherwise None. + """ + robot_json_path = resolve_path(robot_json_path) + with open(robot_json_path, "r", encoding="utf-8") as f: + robot_data = json.load(f) + + vision_config = robot_data.get("vision_config", {}) + size = vision_config.get("MarkerSize", None) + if size is None: + return None + try: + return float(size) + except Exception: + return None + + +# --------------------------------------------------------------------- +# Geometry / pose helpers +# --------------------------------------------------------------------- + +def marker_local_corners(marker_size_m: float) -> np.ndarray: + half = marker_size_m / 2.0 + # Same corner order as the readTwoImages.py example + return np.array([ + [-half, half, 0.0], + [ half, half, 0.0], + [ half, -half, 0.0], + [-half, -half, 0.0], + ], dtype=np.float32) + + +def rigid_transform_no_scale(A: np.ndarray, B: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """ + Find R, t such that B ≈ R A + t. + A, B: Nx3 + """ + assert A.shape == B.shape and A.shape[1] == 3, "A and B must be Nx3" + N = A.shape[0] + if N < 2: + raise ValueError("Need at least 2 points; 3+ recommended.") + + centroid_A = A.mean(axis=0) + centroid_B = B.mean(axis=0) + + AA = A - centroid_A + BB = B - centroid_B + + H = AA.T @ BB + U, S, Vt = np.linalg.svd(H) + R = Vt.T @ U.T + + if np.linalg.det(R) < 0: + Vt[-1, :] *= -1 + R = Vt.T @ U.T + + t = centroid_B - R @ centroid_A + return R.astype(np.float32), t.astype(np.float32) + + +def undistort_to_normalized(points_px: np.ndarray, K: np.ndarray, D: np.ndarray) -> np.ndarray: + pts = points_px.reshape(-1, 1, 2).astype(np.float32) + und = cv2.undistortPoints(pts, K, D, P=None) + return und.reshape(-1, 2).astype(np.float32) + + +def rvec_to_R(rvec: np.ndarray) -> np.ndarray: + R, _ = cv2.Rodrigues(rvec.reshape(3, 1)) + return R.astype(np.float32) + + +def R_to_euler_zyx(R: np.ndarray) -> Tuple[float, float, float]: + """ + Return roll, pitch, yaw in degrees using ZYX convention. + """ + yaw = float(np.degrees(np.arctan2(R[1, 0], R[0, 0]))) + sp = np.sqrt(R[2, 1] ** 2 + R[2, 2] ** 2) + pitch = float(np.degrees(np.arctan2(-R[2, 0], sp))) + roll = float(np.degrees(np.arctan2(R[2, 1], R[2, 2]))) + return roll, pitch, yaw + + +def theta_to_camera_pose(theta: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + theta = [omega_x, omega_y, omega_z, t_x, t_y, t_z] + Returns: + R_wc, t_wc, camera_center_world + """ + omega = theta[0:3] + t_wc = theta[3:6].reshape(3, 1).astype(np.float32) + R_wc, _ = cv2.Rodrigues(omega.reshape(3, 1)) + R_wc = R_wc.astype(np.float32) + R_cw = R_wc.T + camera_center_world = (-R_cw @ t_wc).reshape(3) + return R_wc, t_wc.reshape(3), camera_center_world + + +def build_projection_matrix(K: np.ndarray, R: np.ndarray, t: np.ndarray) -> np.ndarray: + return K @ np.hstack([R, t.reshape(3, 1)]) + + +# --------------------------------------------------------------------- +# LM on normalized residuals (same style as readTwoImages.py) +# --------------------------------------------------------------------- + +def pack_params(omega: np.ndarray, t: np.ndarray) -> np.ndarray: + return np.hstack([omega.reshape(3), t.reshape(3)]).astype(np.float64) + + +def unpack_params(theta: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + omega = theta[0:3] + t = theta[3:6] + return omega, t + + +def residuals_centers_normalized(theta: np.ndarray, + X_world: np.ndarray, + obs_norm: np.ndarray) -> np.ndarray: + """ + Residuals in normalized coordinates: + obs_norm - project(R X_world + t) + """ + omega, t = unpack_params(theta) + R_wc = cv2.Rodrigues(omega.reshape(3, 1))[0].astype(np.float64) + X_cam = (R_wc @ X_world.T + t.reshape(3, 1)).T + uv = X_cam[:, :2] / X_cam[:, 2:3] + r = (obs_norm - uv).reshape(-1) + return r + + +def numerical_jacobian(f, theta: np.ndarray, eps: float, *args) -> Tuple[np.ndarray, np.ndarray]: + r0 = f(theta, *args) + m = r0.size + n = theta.size + J = np.zeros((m, n), dtype=np.float64) + for k in range(n): + th = theta.copy() + th[k] += eps + rk = f(th, *args) + J[:, k] = (rk - r0) / eps + return J, r0 + + +def lm_solve(theta0: np.ndarray, + X_world: np.ndarray, + obs_norm: np.ndarray, + max_iter: int = 60, + eps_jac: float = 1e-6, + lambda_init: float = 1e-3) -> Tuple[np.ndarray, Dict[str, List[float]]]: + lam = lambda_init + theta = theta0.copy().astype(np.float64) + history = {"iters": [], "rms": [], "lambda": []} + + for it in range(max_iter): + J, r = numerical_jacobian(residuals_centers_normalized, theta, eps_jac, X_world, obs_norm) + rms = float(np.sqrt(np.mean(r * r))) if r.size else 0.0 + history["iters"].append(it) + history["rms"].append(rms) + history["lambda"].append(lam) + + JTJ = J.T @ J + g = J.T @ r + H = JTJ + lam * np.eye(JTJ.shape[0], dtype=np.float64) + + try: + delta = -np.linalg.solve(H, g) + except np.linalg.LinAlgError: + delta, *_ = np.linalg.lstsq(H, -g, rcond=None) + + theta_trial = theta + delta + r_trial = residuals_centers_normalized(theta_trial, X_world, obs_norm) + rms_trial = float(np.sqrt(np.mean(r_trial * r_trial))) if r_trial.size else rms + + if rms_trial < rms: + theta = theta_trial + lam *= 0.5 + else: + lam *= 2.0 + + if np.linalg.norm(delta) < 1e-10: + break + if abs(rms - rms_trial) < 1e-12: + break + + return theta, history + + +def pose_covariance(theta: np.ndarray, + X_world: np.ndarray, + obs_norm: np.ndarray, + eps_jac: float = 1e-6) -> Tuple[np.ndarray, float, np.ndarray]: + """ + Returns: + cov_theta_6x6, sigma2, residual_vector + """ + J, r = numerical_jacobian(residuals_centers_normalized, theta, eps_jac, X_world, obs_norm) + m = r.size + n = theta.size + dof = max(1, m - n) + sigma2 = float((r @ r) / dof) + + JTJ = J.T @ J + cov = sigma2 * np.linalg.pinv(JTJ) + return cov.astype(np.float64), sigma2, r + + +def propagate_covariance(theta: np.ndarray, + cov_theta: np.ndarray) -> Dict[str, Any]: + """ + Propagate pose covariance to camera center and Euler angle uncertainties. + """ + def camera_center_fn(th: np.ndarray) -> np.ndarray: + _, _, c = theta_to_camera_pose(th) + return c.astype(np.float64) + + def euler_fn(th: np.ndarray) -> np.ndarray: + R_wc, _, _ = theta_to_camera_pose(th) + return np.array(R_to_euler_zyx(R_wc), dtype=np.float64) # deg + + Jc, _ = numerical_jacobian(lambda th, *_: camera_center_fn(th), theta, 1e-6) + cov_center = Jc @ cov_theta @ Jc.T + + Je, _ = numerical_jacobian(lambda th, *_: euler_fn(th), theta, 1e-6) + cov_euler = Je @ cov_theta @ Je.T + + center_std_m = np.sqrt(np.maximum(0.0, np.diag(cov_center))) + euler_std_deg = np.sqrt(np.maximum(0.0, np.diag(cov_euler))) + + # Parameter std directly from covariance + param_std = np.sqrt(np.maximum(0.0, np.diag(cov_theta))) + rvec_std_deg = np.degrees(param_std[0:3]) + tvec_std_m = param_std[3:6] + + return { + "pose_covariance_6x6": cov_theta.tolist(), + "parameter_std": { + "rvec_std_deg": [float(x) for x in rvec_std_deg], + "tvec_std_m": [float(x) for x in tvec_std_m], + }, + "camera_center_std_m": [float(x) for x in center_std_m], + "camera_center_std_mm": [float(x * 1000.0) for x in center_std_m], + "orientation_std_deg": { + "roll": float(euler_std_deg[0]), + "pitch": float(euler_std_deg[1]), + "yaw": float(euler_std_deg[2]), + }, + } + + +# --------------------------------------------------------------------- +# Marker processing +# --------------------------------------------------------------------- + +def build_object_corners_from_world_position(position_m: np.ndarray, + marker_size_m: float) -> np.ndarray: + """ + Marker corners in world coordinates, assuming the marker frame is aligned + with the world frame and only translated to 'position_m'. + + This is the direct analogue of readTwoImages.py using marker center positions. + """ + h = marker_size_m / 2.0 + local = np.array([ + [-h, h, 0.0], + [ h, h, 0.0], + [ h, -h, 0.0], + [-h, -h, 0.0], + ], dtype=np.float32) + return local + position_m.reshape(1, 3) + + +def solve_single_marker_pose(corners_px: np.ndarray, + K: np.ndarray, + D: np.ndarray, + marker_size_m: float) -> Optional[Tuple[np.ndarray, np.ndarray]]: + obj = marker_local_corners(marker_size_m) + success, rvec, tvec = cv2.solvePnP( + obj, + corners_px.astype(np.float32), + K, + D, + flags=cv2.SOLVEPNP_IPPE_SQUARE + ) + if not success: + success, rvec, tvec = cv2.solvePnP( + obj, + corners_px.astype(np.float32), + K, + D, + flags=cv2.SOLVEPNP_ITERATIVE + ) + if not success: + return None + return rvec.reshape(3), tvec.reshape(3) + + +# --------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description="Estimate camera pose from ArUco observation JSON") + parser.add_argument("-i", "--input", required=True, help="*_aruco_detection.json") + parser.add_argument("-robot", "--robot", required=True, help="robot.json with board markers") + parser.add_argument("-outDir", "--outDir", default=None, help="Optional output directory") + parser.add_argument("--minConfidence", type=float, default=0.0, + help="Skip detections below this confidence") + parser.add_argument("--minCommonMarkers", type=int, default=3, + help="Minimum number of world markers required") + parser.add_argument("--maxRmsPx", type=float, default=None, + help="Optional soft warning threshold for final reprojection RMS in pixels") + parser.add_argument("--epsJac", type=float, default=1e-6, help="Finite-difference epsilon") + args = parser.parse_args() + + detection_path = resolve_path(args.input) + robot_path = resolve_path(args.robot) + + detection = load_json(detection_path) + marker_lookup = load_marker_lookup(robot_path) + + K, D = load_intrinsics_from_detection(detection) + + robot_marker_size = load_robot_marker_size(robot_path) + det_marker_size = detection.get("vision_config", {}).get("MarkerSize", None) + if det_marker_size is not None: + marker_size_m = float(det_marker_size) + elif robot_marker_size is not None: + marker_size_m = float(robot_marker_size) + else: + marker_size_m = 0.025 + + detections = detection.get("detections", []) + if not isinstance(detections, list): + raise TypeError("detection['detections'] must be a list") + + used_ids: List[int] = [] + used_world_positions: List[np.ndarray] = [] + used_obs_centers_px: List[np.ndarray] = [] + used_obs_centers_norm: List[np.ndarray] = [] + used_marker_cam_centers: List[np.ndarray] = [] + used_marker_meta: List[Dict[str, Any]] = [] + + sanity_notes: List[str] = [] + + for det in detections: + if det.get("type", "aruco") != "aruco": + continue + + marker_id = int(det.get("marker_id", -1)) + if marker_id < 0: + continue + + if marker_id not in marker_lookup: + continue + + confidence = float(det.get("confidence", 1.0)) + if confidence < args.minConfidence: + continue + + corners = det.get("image_points_px", None) + if corners is None: + continue + + corners_px = np.array(corners, dtype=np.float32).reshape(4, 2) + center_from_corners = corners_px.mean(axis=0) + + center_px = np.array(det.get("center_px", center_from_corners), dtype=np.float32).reshape(2) + center_delta = float(np.linalg.norm(center_from_corners - center_px)) + if center_delta > 0.75: + sanity_notes.append( + f"marker {marker_id}: center_px differs from corner-mean by {center_delta:.2f}px" + ) + + pnp = solve_single_marker_pose(corners_px, K, D, marker_size_m) + if pnp is None: + continue + + rvec_m, tvec_m = pnp + world_pos = marker_lookup[marker_id]["position"].astype(np.float32) + + used_ids.append(marker_id) + used_world_positions.append(world_pos) + used_obs_centers_px.append(center_from_corners.astype(np.float32)) + used_obs_centers_norm.append(undistort_to_normalized(center_from_corners.reshape(1, 2), K, D)[0]) + used_marker_cam_centers.append(tvec_m.astype(np.float32)) + used_marker_meta.append({ + "marker_id": marker_id, + "confidence": confidence, + "center_px": [float(center_from_corners[0]), float(center_from_corners[1])], + "marker_size_m": marker_size_m, + }) + + # Unique / deduplicate by marker_id while preserving order + dedup: Dict[int, int] = {} + uniq_ids: List[int] = [] + uniq_world_positions: List[np.ndarray] = [] + uniq_obs_px: List[np.ndarray] = [] + uniq_obs_norm: List[np.ndarray] = [] + uniq_cam_centers: List[np.ndarray] = [] + uniq_meta: List[Dict[str, Any]] = [] + + for idx, mid in enumerate(used_ids): + if mid in dedup: + continue + dedup[mid] = idx + uniq_ids.append(mid) + uniq_world_positions.append(used_world_positions[idx]) + uniq_obs_px.append(used_obs_centers_px[idx]) + uniq_obs_norm.append(used_obs_centers_norm[idx]) + uniq_cam_centers.append(used_marker_cam_centers[idx]) + uniq_meta.append(used_marker_meta[idx]) + + if len(uniq_ids) < args.minCommonMarkers: + raise RuntimeError( + f"Need at least {args.minCommonMarkers} common markers; found {len(uniq_ids)}: {uniq_ids}" + ) + + X_world = np.stack(uniq_world_positions, axis=0).astype(np.float64) + obs_px = np.stack(uniq_obs_px, axis=0).astype(np.float64) + obs_norm = np.stack(uniq_obs_norm, axis=0).astype(np.float64) + marker_cam_centers = np.stack(uniq_cam_centers, axis=0).astype(np.float64) + + # Initial pose from rigid transform of per-marker camera-frame centers to world positions + # B ≈ R A + t -> world = R * camera + t + R_cw_init, t_cw_init = rigid_transform_no_scale(marker_cam_centers, X_world) + R_wc_init = R_cw_init.T + t_wc_init = -(R_wc_init @ t_cw_init).reshape(3) + + omega_init = cv2.Rodrigues(R_wc_init)[0].reshape(3) + theta0 = pack_params(omega_init, t_wc_init) + + theta_opt, hist = lm_solve( + theta0=theta0, + X_world=X_world, + obs_norm=obs_norm, + max_iter=60, + eps_jac=args.epsJac, + lambda_init=1e-3, + ) + + R_wc, t_wc, camera_center_world = theta_to_camera_pose(theta_opt) + + cov_theta, sigma2, residual_vec = pose_covariance( + theta_opt, X_world, obs_norm, eps_jac=args.epsJac + ) + propagated = propagate_covariance(theta_opt, cov_theta) + + # Exact pixel-space reprojection statistics + proj_pts, _ = cv2.projectPoints( + X_world.reshape(-1, 1, 3).astype(np.float32), + theta_opt[0:3].reshape(3, 1).astype(np.float32), + theta_opt[3:6].reshape(3, 1).astype(np.float32), + K, + D, + ) + proj_pts = proj_pts.reshape(-1, 2) + reproj_err_px = np.linalg.norm(proj_pts - obs_px, axis=1) + rms_px = float(np.sqrt(np.mean(reproj_err_px ** 2))) if reproj_err_px.size else 0.0 + median_px = float(np.median(reproj_err_px)) if reproj_err_px.size else 0.0 + max_px = float(np.max(reproj_err_px)) if reproj_err_px.size else 0.0 + + if args.maxRmsPx is not None and rms_px > args.maxRmsPx: + print(f"[WARN] Final reprojection RMS is {rms_px:.3f}px (threshold {args.maxRmsPx:.3f}px).") + + # Convert outputs + roll, pitch, yaw = R_to_euler_zyx(R_wc) + position_mm = (camera_center_world * 1000.0).astype(float).tolist() + + # Reproject each used marker center for QA + per_marker_results = [] + proj_pts_exact, _ = cv2.projectPoints( + X_world.reshape(-1, 1, 3).astype(np.float32), + theta_opt[0:3].reshape(3, 1).astype(np.float32), + theta_opt[3:6].reshape(3, 1).astype(np.float32), + K, + D, + ) + proj_pts_exact = proj_pts_exact.reshape(-1, 2) + + for idx, mid in enumerate(uniq_ids): + x = proj_pts_exact[idx] + err = float(np.linalg.norm(x - obs_px[idx])) + per_marker_results.append({ + "marker_id": int(mid), + "observed_center_px": [float(obs_px[idx, 0]), float(obs_px[idx, 1])], + "projected_center_px": [float(x[0]), float(x[1])], + "reprojection_error_px": err, + "confidence": float(uniq_meta[idx]["confidence"]), + }) + + # Output directory + in_base = os.path.splitext(os.path.basename(detection_path))[0] + out_name = in_base.replace("_aruco_detection", "_camera_pose") + ".json" + + if args.outDir is not None: + out_dir = resolve_path(args.outDir) + else: + out_dir = os.path.dirname(detection_path) or "." + + os.makedirs(out_dir, exist_ok=True) + out_json = os.path.join(out_dir, out_name) + + output = { + "schema_version": "1.0", + "created_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "source": { + "detection_json": detection_path, + "robot_json": robot_path, + }, + "camera": { + "camera_id": detection.get("camera", {}).get("camera_id", "unknown"), + "camera_matrix": K.tolist(), + "distortion_coefficients": D.reshape(-1).tolist(), + }, + "estimation": { + "method": "single_camera_marker_center_lm", + "description": "Rigid init from per-marker pose estimates, followed by LM on normalized marker-center reprojection residuals.", + "marker_size_m": float(marker_size_m), + "num_used_markers": int(len(uniq_ids)), + "used_marker_ids": [int(x) for x in uniq_ids], + "history": hist, + "residual_rms_px": float(rms_px), + "residual_median_px": float(median_px), + "residual_max_px": float(max_px), + "sigma2_normalized": float(sigma2), + }, + "camera_pose": { + "world_to_camera": { + "rotation_matrix": R_wc.tolist(), + "translation_m": [float(x) for x in t_wc.tolist()], + "rvec_rad": [float(x) for x in theta_opt[0:3].tolist()], + }, + "camera_in_world": { + "position_m": [float(x) for x in camera_center_world.tolist()], + "position_mm": [float(x) for x in position_mm], + "orientation_deg": { + "roll": float(roll), + "pitch": float(pitch), + "yaw": float(yaw), + }, + }, + "uncertainty": propagated, + }, + "observations": { + "markers": per_marker_results, + }, + "qa": { + "sanity_notes": sanity_notes, + }, + } + + save_json(out_json, output) + print(f"[INFO] Saved camera pose JSON: {out_json}") + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + sys.exit(1) diff --git a/approbot-pipeline/approbot_pipeline/scripts/3_multiview_bundle_adjustment_v4.py b/approbot-pipeline/approbot_pipeline/scripts/3_multiview_bundle_adjustment_v4.py new file mode 100644 index 0000000..0f629b5 --- /dev/null +++ b/approbot-pipeline/approbot_pipeline/scripts/3_multiview_bundle_adjustment_v4.py @@ -0,0 +1,1499 @@ +#!/usr/bin/env python3 +""" +3_multiview_bundle_adjustment_v4.py + +Multi-view ArUco marker position optimization with explicit, switchable +degrees-of-freedom constraints. + +Mathematical model +------------------ +We estimate 3D marker positions X_i ∈ R^3 by minimizing + + E(X) = + Σ_{i,c} w_ic || π_c(X_i) - u_ic ||² + + λ_r Σ_j w_j^r || ||X_a - X_b|| - d_j ||² + + λ_rev Σ_k w_k^rev || (X_b - X_a)·a_k - t_k ||² + + λ_pri Σ_m w_m^pri ( ||(X_b - X_a)·u_m - t_u||² + + ||(X_b - X_a)·v_m - t_v||² ) + +where: +- u_ic are observed normalized image coordinates for marker i in camera c +- π_c(.) is the normalized reprojection model +- w_ic are observation weights from detection quality / marker priors / range +- rigid-link constraints preserve internal marker geometry of a link +- revolute joints keep the projection along the joint axis constant +- prismatic joints keep the two orthogonal projection components constant + +Important design choices +------------------------ +- robot.json is used as a kinematic description, not as a direct source of + world-space marker positions. +- constraint families are explicit, switchable, and easy to compare across + versions. +- legacy chain-propagation constraints are retained only as an optional family + and are OFF by default. +- observation weighting remains separate from constraint weighting so both can + be tested independently. + +Dependencies: + numpy, opencv-python, scipy (optional for optimization) + +Example: + python 3_multiview_bundle_adjustment_v4.py ^ + -det cam1_aruco_detection.json cam2_aruco_detection.json cam3_aruco_detection.json ^ + -pose cam1_camera_pose.json cam2_camera_pose.json cam3_camera_pose.json ^ + -robot robot.json ^ + -lambdaWeight 100.0 +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from dataclasses import dataclass +from itertools import combinations +from typing import Any, Dict, List, Optional, Tuple + +import cv2 +import numpy as np + + +# =================================================================== +# Path / JSON helpers +# =================================================================== + +def resolve_path(path: str) -> str: + path = os.path.expanduser(path) + if os.path.isabs(path): + return path + return os.path.abspath(path) + + +def load_json(path: str) -> Dict[str, Any]: + with open(resolve_path(path), "r", encoding="utf-8") as f: + return json.load(f) + + +def save_json(path: str, data: Dict[str, Any]) -> None: + with open(resolve_path(path), "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + +# =================================================================== +# Units +# =================================================================== + +def get_length_scale(robot_data: Dict[str, Any]) -> float: + units = robot_data.get("units", {}) or {} + length_unit = str(units.get("length", "")).strip().lower() + if length_unit in ("mm", "millimeter", "millimeters"): + return 1.0 / 1000.0 + if length_unit in ("cm", "centimeter", "centimeters"): + return 1.0 / 100.0 + return 1.0 + + +# =================================================================== +# Small geometry helpers +# =================================================================== + +def safe_norm(v: np.ndarray, eps: float = 1e-12) -> float: + return float(np.linalg.norm(v) + eps) + + +def normalize_vector(v: np.ndarray, eps: float = 1e-12) -> np.ndarray: + return np.asarray(v, dtype=np.float64) / safe_norm(v, eps) + + +def clamp(v: float, lo: float, hi: float) -> float: + return float(max(lo, min(hi, v))) + + +def principal_axis_id(axis: np.ndarray, threshold: float = 0.95) -> Optional[int]: + """Return 0,1,2 for x,y,z if axis is close enough to a principal axis.""" + a = normalize_vector(np.asarray(axis, dtype=np.float64)) + idx = int(np.argmax(np.abs(a))) + if abs(a[idx]) >= threshold: + return idx + return None + + +def camera_center_from_world_to_cam(R_wc: np.ndarray, t_wc: np.ndarray) -> np.ndarray: + """world_to_camera: X_cam = R_wc * X_world + t_wc; camera center is -R^T t.""" + return -R_wc.T @ t_wc + + +def principal_axis_vector(axis: np.ndarray) -> np.ndarray: + """Convert a near-principal axis to an exact signed principal axis vector.""" + a = normalize_vector(axis) + idx = int(np.argmax(np.abs(a))) + out = np.zeros(3, dtype=np.float64) + out[idx] = 1.0 if a[idx] >= 0 else -1.0 + return normalize_vector(out) + + +def orthonormal_basis_from_axis(axis: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """ + Build two unit vectors orthogonal to axis, with a deterministic orientation. + """ + a = normalize_vector(axis) + ref = np.array([1.0, 0.0, 0.0], dtype=np.float64) + if abs(float(np.dot(a, ref))) > 0.90: + ref = np.array([0.0, 1.0, 0.0], dtype=np.float64) + u = np.cross(a, ref) + if np.linalg.norm(u) < 1e-12: + ref = np.array([0.0, 0.0, 1.0], dtype=np.float64) + u = np.cross(a, ref) + u = normalize_vector(u) + v = normalize_vector(np.cross(a, u)) + return u, v + + +# =================================================================== +# Configuration +# =================================================================== + +@dataclass +class ConstraintRuleConfig: + rigid_distance_enabled: bool = True + rigid_distance_mode: str = "mst" # mst | star | full + rigid_distance_weight: float = 1.0 + + # Revolute joints: keep the projection along the axis constant. + revolute_axis_enabled: bool = True + revolute_axis_max_pairs: int = 2 + revolute_axis_weight: float = 0.5 + + # Prismatic joints: keep the two orthogonal projection components constant. + prismatic_orthogonal_enabled: bool = True + prismatic_orthogonal_max_pairs: int = 2 + prismatic_orthogonal_weight: float = 0.35 + + # Legacy / optional chain propagation, disabled by default. + chain_axis_enabled: bool = False + chain_axis_max_depth: int = 3 + chain_axis_max_pairs: int = 2 + chain_axis_weight: float = 0.3 + + axis_alignment_threshold: float = 0.95 + + strict_unique_marker_ids: bool = False + show_skipped_constraints: bool = True + + enable_observation_weights: bool = True + weight_floor: float = 0.30 + weight_ceiling: float = 3.00 + ref_distance_m: float = 0.75 + ref_marker_size_px: float = 50.0 + use_detection_confidence: bool = True + use_detection_size_px: bool = True + use_initial_range: bool = True + use_marker_size_prior: bool = True + + +def _bool_or_default(value: Any, default: bool) -> bool: + if value is None: + return default + return bool(value) + + +def _float_or_default(value: Any, default: float) -> float: + if value is None: + return default + return float(value) + + +def _int_or_default(value: Any, default: int) -> int: + if value is None: + return default + return int(value) + + +def load_constraint_rule_config(robot_data: Dict[str, Any], args: argparse.Namespace) -> ConstraintRuleConfig: + """ + Merge built-in defaults with optional robot.json constraint_rules and CLI flags. + Backward compatibility: + - joint_axis_projection -> revolute_axis + """ + rules = robot_data.get("constraint_rules", {}) or {} + + cfg = ConstraintRuleConfig() + rigid = rules.get("rigid_distance", {}) or {} + revolute = rules.get("joint_revolute_axis", {}) or rules.get("joint_axis_projection", {}) or {} + prismatic = rules.get("joint_prismatic_orthogonal", {}) or {} + chain = rules.get("chain_axis_projection", {}) or {} + obs = rules.get("observation_weights", {}) or {} + + cfg.rigid_distance_enabled = _bool_or_default(rigid.get("enabled"), cfg.rigid_distance_enabled) + cfg.rigid_distance_mode = str(rigid.get("mode", cfg.rigid_distance_mode)).strip().lower() + cfg.rigid_distance_weight = _float_or_default(rigid.get("weight"), cfg.rigid_distance_weight) + + cfg.revolute_axis_enabled = _bool_or_default(revolute.get("enabled"), cfg.revolute_axis_enabled) + cfg.revolute_axis_max_pairs = _int_or_default(revolute.get("max_pairs"), cfg.revolute_axis_max_pairs) + cfg.revolute_axis_weight = _float_or_default(revolute.get("weight"), cfg.revolute_axis_weight) + + cfg.prismatic_orthogonal_enabled = _bool_or_default(prismatic.get("enabled"), cfg.prismatic_orthogonal_enabled) + cfg.prismatic_orthogonal_max_pairs = _int_or_default(prismatic.get("max_pairs"), cfg.prismatic_orthogonal_max_pairs) + cfg.prismatic_orthogonal_weight = _float_or_default(prismatic.get("weight"), cfg.prismatic_orthogonal_weight) + + cfg.chain_axis_enabled = _bool_or_default(chain.get("enabled"), cfg.chain_axis_enabled) + cfg.chain_axis_max_depth = _int_or_default(chain.get("max_depth"), cfg.chain_axis_max_depth) + cfg.chain_axis_max_pairs = _int_or_default(chain.get("max_pairs"), cfg.chain_axis_max_pairs) + cfg.chain_axis_weight = _float_or_default(chain.get("weight"), cfg.chain_axis_weight) + + cfg.axis_alignment_threshold = _float_or_default( + rules.get("axis_alignment_threshold"), cfg.axis_alignment_threshold + ) + + cfg.enable_observation_weights = _bool_or_default(obs.get("enabled"), cfg.enable_observation_weights) + cfg.weight_floor = _float_or_default(obs.get("weight_floor"), cfg.weight_floor) + cfg.weight_ceiling = _float_or_default(obs.get("weight_ceiling"), cfg.weight_ceiling) + cfg.ref_distance_m = _float_or_default(obs.get("ref_distance_m"), cfg.ref_distance_m) + cfg.ref_marker_size_px = _float_or_default(obs.get("ref_marker_size_px"), cfg.ref_marker_size_px) + cfg.use_detection_confidence = _bool_or_default(obs.get("use_detection_confidence"), cfg.use_detection_confidence) + cfg.use_detection_size_px = _bool_or_default(obs.get("use_detection_size_px"), cfg.use_detection_size_px) + cfg.use_initial_range = _bool_or_default(obs.get("use_initial_range"), cfg.use_initial_range) + cfg.use_marker_size_prior = _bool_or_default(obs.get("use_marker_size_prior"), cfg.use_marker_size_prior) + + if getattr(args, "strictUniqueMarkerIds", False): + cfg.strict_unique_marker_ids = True + if getattr(args, "showSkippedConstraints", False): + cfg.show_skipped_constraints = True + if getattr(args, "noShowSkippedConstraints", False): + cfg.show_skipped_constraints = False + + return cfg + + +# =================================================================== +# Observation / constraint definitions +# =================================================================== + +@dataclass +class Observation: + cam_idx: int + norm_coords: np.ndarray + meta: Dict[str, Any] + + +@dataclass +class MarkerDistanceConstraint: + marker_id_a: int + marker_id_b: int + link_name: str + target_distance_m: float + weight: float = 1.0 + enabled: bool = True + source: str = "rigid_distance" + + +@dataclass +class JointAxisConstraint: + marker_id_parent: int + marker_id_child: int + parent_link: str + child_link: str + joint_axis: np.ndarray + target_delta_along_axis_m: float + weight: float = 1.0 + enabled: bool = True + source: str = "joint_axis_projection" + + +Constraint = MarkerDistanceConstraint | JointAxisConstraint + + +# =================================================================== +# Robot parsing +# =================================================================== + +def parse_robot_markers( + robot_data: Dict[str, Any], + length_scale: float, + strict_unique_marker_ids: bool = False +) -> Tuple[Dict[int, str], Dict[str, List[Dict[str, Any]]], List[str], Dict[int, Dict[str, Any]]]: + links = robot_data.get("links", {}) or {} + + marker_to_link: Dict[int, str] = {} + link_markers: Dict[str, List[Dict[str, Any]]] = {} + issues: List[str] = [] + marker_meta: Dict[int, Dict[str, Any]] = {} + + seen_global: Dict[int, str] = {} + + for link_name, link_data in links.items(): + markers = link_data.get("markers", []) or [] + collected: List[Dict[str, Any]] = [] + seen_local: set[int] = set() + + for idx, marker in enumerate(markers): + marker_id = int(marker.get("id", -1)) + pos = marker.get("position", None) + + if marker_id < 0 or pos is None or len(pos) != 3: + issues.append(f"[WARN] link={link_name}: skipped invalid marker entry at index {idx}") + continue + + if marker_id in seen_local: + msg = f"[WARN] duplicate marker id {marker_id} inside link '{link_name}'" + if strict_unique_marker_ids: + raise ValueError(msg) + issues.append(msg + " -> skipped duplicate inside same link") + continue + + if marker_id in seen_global: + msg = ( + f"[WARN] duplicate marker id {marker_id} appears in link '{link_name}' " + f"and already in link '{seen_global[marker_id]}'" + ) + if strict_unique_marker_ids: + raise ValueError(msg) + issues.append(msg + " -> skipped duplicate occurrence") + continue + + seen_local.add(marker_id) + seen_global[marker_id] = link_name + + pos_raw = np.array(pos, dtype=np.float64) + pos_m = pos_raw * float(length_scale) + + item = { + "id": marker_id, + "name": marker.get("name", f"marker_{marker_id}"), + "position_raw": pos_raw, + "position_m": pos_m, + "normal": np.array(marker.get("normal", [0, 0, 1]), dtype=np.float64), + "size": marker.get("size", None), + "spin": marker.get("spin", None), + } + collected.append(item) + marker_to_link[marker_id] = link_name + marker_meta[marker_id] = { + "link_name": link_name, + "name": item["name"], + "position_m": pos_m, + "normal": item["normal"], + "size": item["size"], + "spin": item["spin"], + } + + link_markers[link_name] = collected + + return marker_to_link, link_markers, issues, marker_meta + + +def get_link_parent_map(robot_data: Dict[str, Any]) -> Dict[str, Optional[str]]: + links = robot_data.get("links", {}) or {} + return {link_name: (link_data.get("parent", None)) for link_name, link_data in links.items()} + + +def get_joint_info(robot_data: Dict[str, Any], child_link: str) -> Dict[str, Any]: + links = robot_data.get("links", {}) or {} + return (links.get(child_link, {}) or {}).get("jointToParent", {}) or {} + + +def get_joint_axis(robot_data: Dict[str, Any], child_link: str) -> Optional[np.ndarray]: + joint = get_joint_info(robot_data, child_link) + axis = joint.get("axis", None) + if axis is None: + return None + axis = np.asarray(axis, dtype=np.float64) + if safe_norm(axis) < 1e-12: + return None + return normalize_vector(axis) + + +def get_vision_marker_size_default(robot_data: Dict[str, Any]) -> float: + vision = robot_data.get("vision_config", {}) or {} + ms = vision.get("MarkerSize", None) + if ms is None: + return 0.025 + return float(ms) + + +# =================================================================== +# Constraint compilation helpers +# =================================================================== + +def get_enabled_link_rule( + robot_data: Dict[str, Any], + link_name: str, + rule_name: str, + default_enabled: bool = True +) -> bool: + overrides = robot_data.get("constraint_overrides", {}) or {} + link_override = overrides.get(link_name, {}) or {} + rule_override = link_override.get(rule_name, {}) or {} + if "enabled" in rule_override: + return bool(rule_override["enabled"]) + return default_enabled + + +def select_anchor_marker_ids( + markers: List[Dict[str, Any]], + axis: Optional[np.ndarray] = None, + max_count: int = 2 +) -> List[int]: + if not markers: + return [] + if len(markers) == 1: + return [int(markers[0]["id"])] + + ids = [int(m["id"]) for m in markers] + pos = np.stack([np.asarray(m["position_m"], dtype=np.float64) for m in markers], axis=0) + + selected: List[int] = [] + + if axis is not None and safe_norm(axis) > 1e-12: + a = normalize_vector(axis) + proj = pos @ a + min_idx = int(np.argmin(proj)) + max_idx = int(np.argmax(proj)) + selected = [ids[min_idx], ids[max_idx]] + else: + centroid = np.mean(pos, axis=0) + d = np.linalg.norm(pos - centroid, axis=1) + min_idx = int(np.argmin(d)) + max_idx = int(np.argmax(d)) + selected = [ids[min_idx], ids[max_idx]] + + if len(selected) < max_count: + for mid in ids: + if mid not in selected: + selected.append(mid) + if len(selected) >= max_count: + break + + out: List[int] = [] + seen: set[int] = set() + for mid in selected: + if mid not in seen: + seen.add(mid) + out.append(mid) + if len(out) >= max_count: + break + return out + + +def mst_edges_for_link(markers: List[Dict[str, Any]]) -> List[Tuple[int, int]]: + n = len(markers) + if n < 2: + return [] + + ids = [int(m["id"]) for m in markers] + pos = np.stack([np.asarray(m["position_m"], dtype=np.float64) for m in markers], axis=0) + in_tree = np.zeros(n, dtype=bool) + in_tree[0] = True + edges: List[Tuple[int, int]] = [] + dist = np.linalg.norm(pos[:, None, :] - pos[None, :, :], axis=2) + + for _ in range(n - 1): + best = None + best_d = float("inf") + for i in range(n): + if not in_tree[i]: + continue + for j in range(n): + if in_tree[j]: + continue + d = float(dist[i, j]) + if d < best_d: + best_d = d + best = (i, j) + if best is None: + break + i, j = best + in_tree[j] = True + edges.append((ids[i], ids[j])) + return edges + + +def compile_rigid_distance_constraints( + robot_data: Dict[str, Any], + link_markers: Dict[str, List[Dict[str, Any]]], + cfg: ConstraintRuleConfig +) -> List[MarkerDistanceConstraint]: + constraints: List[MarkerDistanceConstraint] = [] + + for link_name, markers in link_markers.items(): + if not get_enabled_link_rule(robot_data, link_name, "rigid_distance", cfg.rigid_distance_enabled): + continue + if len(markers) < 2: + continue + + mode = cfg.rigid_distance_mode + if mode == "full": + pairs = [(int(a["id"]), int(b["id"])) for a, b in combinations(markers, 2)] + elif mode == "star": + anchor_ids = select_anchor_marker_ids(markers, axis=None, max_count=1) + anchor_id = anchor_ids[0] + pairs = [] + for m in markers: + mid = int(m["id"]) + if mid != anchor_id: + pairs.append((anchor_id, mid)) + elif mode == "mst": + pairs = mst_edges_for_link(markers) + else: + raise ValueError(f"Unknown rigid_distance_mode='{mode}'. Use mst|star|full.") + + pos_map = {int(m["id"]): np.asarray(m["position_m"], dtype=np.float64) for m in markers} + seen_pairs: set[Tuple[int, int]] = set() + + for mid_a, mid_b in pairs: + if mid_a == mid_b: + continue + key = tuple(sorted((int(mid_a), int(mid_b)))) + if key in seen_pairs: + continue + seen_pairs.add(key) + + pos_a = pos_map[mid_a] + pos_b = pos_map[mid_b] + target = float(np.linalg.norm(pos_b - pos_a)) + + constraints.append( + MarkerDistanceConstraint( + marker_id_a=mid_a, + marker_id_b=mid_b, + link_name=link_name, + target_distance_m=target, + weight=cfg.rigid_distance_weight, + enabled=True, + source=f"rigid_distance:{mode}", + ) + ) + + return constraints + + +def compile_joint_dof_constraints( + robot_data: Dict[str, Any], + link_markers: Dict[str, List[Dict[str, Any]]], + cfg: ConstraintRuleConfig +) -> List[JointAxisConstraint]: + """ + Compile local joint constraints from robot.json. + + Revolute joints: one scalar constraint per anchor pair + (projection along the joint axis stays constant) + + Prismatic joints: two scalar constraints per anchor pair + (the orthogonal projections stay constant) + + Both are emitted as JointAxisConstraint objects so the rest of the + optimization pipeline remains unchanged. + """ + constraints: List[JointAxisConstraint] = [] + links = robot_data.get("links", {}) or {} + + for child_link, child_data in links.items(): + parent_link = child_data.get("parent", None) + if not parent_link: + continue + + joint_info = child_data.get("jointToParent", {}) or {} + joint_type = str(joint_info.get("type", "")).strip().lower() + + joint_axis = get_joint_axis(robot_data, child_link) + if joint_axis is None: + continue + + axis_vec = principal_axis_vector(joint_axis) + + parent_markers = link_markers.get(parent_link, []) + child_markers = link_markers.get(child_link, []) + if len(parent_markers) == 0 or len(child_markers) == 0: + continue + + parent_pos = {int(m["id"]): np.asarray(m["position_m"], dtype=np.float64) for m in parent_markers} + child_pos = {int(m["id"]): np.asarray(m["position_m"], dtype=np.float64) for m in child_markers} + + seen: set[Tuple[int, int]] = set() + + if joint_type == "revolute": + if not get_enabled_link_rule( + robot_data, child_link, "joint_revolute_axis", cfg.revolute_axis_enabled + ): + continue + + max_pairs = max(1, int(cfg.revolute_axis_max_pairs)) + parent_anchor_ids = select_anchor_marker_ids(parent_markers, axis=axis_vec, max_count=max_pairs) + child_anchor_ids = select_anchor_marker_ids(child_markers, axis=axis_vec, max_count=max_pairs) + + for mid_p in parent_anchor_ids: + for mid_c in child_anchor_ids: + if mid_p == mid_c: + continue + key = (mid_p, mid_c) + if key in seen: + continue + seen.add(key) + + delta = child_pos[mid_c] - parent_pos[mid_p] + target = float(np.dot(delta, axis_vec)) + + constraints.append( + JointAxisConstraint( + marker_id_parent=mid_p, + marker_id_child=mid_c, + parent_link=parent_link, + child_link=child_link, + joint_axis=axis_vec, + target_delta_along_axis_m=target, + weight=cfg.revolute_axis_weight, + enabled=True, + source="revolute_axis_projection", + ) + ) + + elif joint_type == "linear": + if not get_enabled_link_rule( + robot_data, child_link, "joint_prismatic_orthogonal", cfg.prismatic_orthogonal_enabled + ): + continue + + max_pairs = max(1, int(cfg.prismatic_orthogonal_max_pairs)) + parent_anchor_ids = select_anchor_marker_ids(parent_markers, axis=axis_vec, max_count=max_pairs) + child_anchor_ids = select_anchor_marker_ids(child_markers, axis=axis_vec, max_count=max_pairs) + basis_u, basis_v = orthonormal_basis_from_axis(axis_vec) + + for mid_p in parent_anchor_ids: + for mid_c in child_anchor_ids: + if mid_p == mid_c: + continue + key = (mid_p, mid_c) + if key in seen: + continue + seen.add(key) + + delta = child_pos[mid_c] - parent_pos[mid_p] + + constraints.append( + JointAxisConstraint( + marker_id_parent=mid_p, + marker_id_child=mid_c, + parent_link=parent_link, + child_link=child_link, + joint_axis=basis_u, + target_delta_along_axis_m=float(np.dot(delta, basis_u)), + weight=cfg.prismatic_orthogonal_weight, + enabled=True, + source="prismatic_orthogonal_projection:u", + ) + ) + constraints.append( + JointAxisConstraint( + marker_id_parent=mid_p, + marker_id_child=mid_c, + parent_link=parent_link, + child_link=child_link, + joint_axis=basis_v, + target_delta_along_axis_m=float(np.dot(delta, basis_v)), + weight=cfg.prismatic_orthogonal_weight, + enabled=True, + source="prismatic_orthogonal_projection:v", + ) + ) + + else: + continue + + return constraints + + + + +def compile_constraints( + robot_data: Dict[str, Any], + length_scale: float, + cfg: ConstraintRuleConfig +) -> Tuple[Dict[int, str], Dict[str, List[Dict[str, Any]]], List[Constraint], List[str], Dict[int, Dict[str, Any]]]: + marker_to_link, link_markers, issues, marker_meta = parse_robot_markers( + robot_data, + length_scale=length_scale, + strict_unique_marker_ids=cfg.strict_unique_marker_ids, + ) + + constraints: List[Constraint] = [] + constraints.extend(compile_rigid_distance_constraints(robot_data, link_markers, cfg)) + constraints.extend(compile_joint_dof_constraints(robot_data, link_markers, cfg)) + + # Legacy optional family, OFF by default. + if cfg.chain_axis_enabled: + constraints.extend(compile_chain_axis_constraints(robot_data, link_markers, cfg)) + + unique_constraints: List[Constraint] = [] + seen_keys: set[Tuple[Any, ...]] = set() + + for c in constraints: + if isinstance(c, MarkerDistanceConstraint): + key = ( + "d", + min(c.marker_id_a, c.marker_id_b), + max(c.marker_id_a, c.marker_id_b), + c.link_name, + round(c.target_distance_m, 9), + ) + else: + key = ( + "j", + c.parent_link, + c.child_link, + c.marker_id_parent, + c.marker_id_child, + tuple(np.round(c.joint_axis, 9).tolist()), + round(c.target_delta_along_axis_m, 9), + ) + if key in seen_keys: + continue + seen_keys.add(key) + unique_constraints.append(c) + + return marker_to_link, link_markers, unique_constraints, issues, marker_meta + + +# =================================================================== +# Observation quality / weighting +# =================================================================== + +def _optional_float(meta: Dict[str, Any], keys: List[str]) -> Optional[float]: + for k in keys: + if k in meta and meta[k] is not None: + try: + return float(meta[k]) + except Exception: + pass + return None + + +def detection_quality_from_metadata(det_obj: Dict[str, Any], cfg: ConstraintRuleConfig) -> float: + q = 1.0 + + if cfg.use_detection_confidence: + conf = _optional_float(det_obj, ["confidence", "score", "quality", "det_confidence"]) + if conf is not None: + q *= clamp(conf, 0.1, 1.0) + + if cfg.use_detection_size_px: + size_px = _optional_float(det_obj, ["size_px", "marker_size_px", "side_px", "side_length_px"]) + if size_px is None and "corners_px" in det_obj and isinstance(det_obj["corners_px"], list): + try: + corners = np.asarray(det_obj["corners_px"], dtype=np.float64).reshape(-1, 2) + if len(corners) >= 4: + edges = [] + for i in range(len(corners)): + p = corners[i] + q2 = corners[(i + 1) % len(corners)] + edges.append(float(np.linalg.norm(q2 - p))) + size_px = float(np.mean(edges)) + except Exception: + size_px = None + if size_px is not None: + q *= clamp(size_px / max(cfg.ref_marker_size_px, 1e-6), 0.25, 3.0) + + sharpness = _optional_float(det_obj, ["sharpness", "corner_sharpness"]) + if sharpness is not None: + q *= clamp(sharpness / 2500.0, 0.5, 1.5) + + normal_alignment = _optional_float(det_obj, ["normal_alignment", "view_cosine", "cos_to_camera"]) + if normal_alignment is not None: + q *= clamp(normal_alignment, 0.3, 1.0) + + return float(q) + + +def marker_size_prior_factor(marker_meta: Dict[str, Any], default_marker_size_m: float) -> float: + size_val = marker_meta.get("size", None) + if size_val is None: + return 1.0 + + try: + size_val = float(size_val) + except Exception: + return 1.0 + + size_m = size_val / 1000.0 if size_val > 1.0 else size_val + ref = max(default_marker_size_m, 1e-6) + return clamp(size_m / ref, 0.7, 1.3) + + +def compute_observation_weights( + marker_observations: Dict[int, List[Observation]], + cameras: List[Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]], + initial_positions: Dict[int, np.ndarray], + marker_meta: Dict[int, Dict[str, Any]], + cfg: ConstraintRuleConfig, + robot_data: Dict[str, Any] +) -> Dict[Tuple[int, int], float]: + weights: Dict[Tuple[int, int], float] = {} + default_marker_size_m = get_vision_marker_size_default(robot_data) + + for marker_id, obs_list in marker_observations.items(): + X = initial_positions.get(marker_id, None) + size_prior = marker_size_prior_factor(marker_meta.get(marker_id, {}), default_marker_size_m) + + for obs_idx, obs in enumerate(obs_list): + w = 1.0 + q = detection_quality_from_metadata(obs.meta, cfg) + w *= q + + if cfg.use_marker_size_prior: + w *= size_prior + + if cfg.use_initial_range and X is not None: + _, _, R_wc, t_wc = cameras[obs.cam_idx] + C = camera_center_from_world_to_cam(R_wc, t_wc) + dist = float(np.linalg.norm(X - C)) + if np.isfinite(dist): + w *= clamp(cfg.ref_distance_m / max(dist, 1e-6), 0.4, 2.0) + + weights[(marker_id, obs_idx)] = clamp(w, cfg.weight_floor, cfg.weight_ceiling) + + return weights + + +# =================================================================== +# Multi-view loading +# =================================================================== + +def load_observations_and_poses( + detection_files: List[str], + pose_files: List[str] +) -> Tuple[ + Dict[int, List[Observation]], + List[Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]], + List[Dict[str, Any]] +]: + if len(detection_files) != len(pose_files): + raise ValueError(f"Mismatch: {len(detection_files)} detections vs {len(pose_files)} poses") + + marker_observations: Dict[int, List[Observation]] = {} + cameras: List[Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = [] + obs_metadata: List[Dict[str, Any]] = [] + + for cam_idx, (det_file, pose_file) in enumerate(zip(detection_files, pose_files)): + det = load_json(det_file) + pose_data = load_json(pose_file) + + cam_section = det.get("camera", {}) or {} + K = np.array(cam_section.get("camera_matrix", []), dtype=np.float64).reshape(3, 3) + D = np.array(cam_section.get("distortion_coefficients", []), dtype=np.float64).reshape(-1, 1) + + pose_section = pose_data.get("camera_pose", {}) or {} + world_to_cam = pose_section.get("world_to_camera", {}) or {} + R_wc = np.array(world_to_cam.get("rotation_matrix", []), dtype=np.float64).reshape(3, 3) + t_wc = np.array(world_to_cam.get("translation_m", []), dtype=np.float64).reshape(3) + + cameras.append((K, D, R_wc, t_wc)) + + detections = det.get("detections", []) or [] + for det_obj in detections: + marker_id = int(det_obj.get("marker_id", -1)) + if marker_id < 0: + continue + + center_px = np.array(det_obj.get("center_px", []), dtype=np.float64) + if center_px.shape != (2,): + continue + + pts = center_px.reshape(1, 1, 2).astype(np.float32) + und = cv2.undistortPoints(pts, K.astype(np.float32), D.astype(np.float32), P=None) + norm_coords = und.reshape(2).astype(np.float64) + + obs = Observation(cam_idx=cam_idx, norm_coords=norm_coords, meta=dict(det_obj)) + marker_observations.setdefault(marker_id, []).append(obs) + + obs_metadata.append( + { + "detection_file": det_file, + "pose_file": pose_file, + "num_detections": len(detections), + } + ) + + return marker_observations, cameras, obs_metadata + + +# =================================================================== +# Initial triangulation +# =================================================================== + +def triangulate_marker_initial( + marker_id: int, + observations: List[Observation], + cameras: List[Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] +) -> Optional[np.ndarray]: + if len(observations) < 2: + return None + + best_pair = None + best_baseline = -1.0 + + for obs_i, obs_j in combinations(observations, 2): + cam_i, cam_j = obs_i.cam_idx, obs_j.cam_idx + _, _, R1, t1 = cameras[cam_i] + _, _, R2, t2 = cameras[cam_j] + c1 = camera_center_from_world_to_cam(R1, t1) + c2 = camera_center_from_world_to_cam(R2, t2) + baseline = float(np.linalg.norm(c2 - c1)) + if baseline > best_baseline: + best_baseline = baseline + best_pair = (obs_i, obs_j) + + if best_pair is None: + return None + + obs_i, obs_j = best_pair + cam_i, cam_j = obs_i.cam_idx, obs_j.cam_idx + norm_coords_i = obs_i.norm_coords + norm_coords_j = obs_j.norm_coords + + K1, D1, R1, t1 = cameras[cam_i] + K2, D2, R2, t2 = cameras[cam_j] + + x1_px = K1[0, 0] * norm_coords_i[0] + K1[0, 2] + y1_px = K1[1, 1] * norm_coords_i[1] + K1[1, 2] + x2_px = K2[0, 0] * norm_coords_j[0] + K2[0, 2] + y2_px = K2[1, 1] * norm_coords_j[1] + K2[1, 2] + + P1 = K1 @ np.hstack([R1, t1.reshape(3, 1)]) + P2 = K2 @ np.hstack([R2, t2.reshape(3, 1)]) + + try: + X_h = cv2.triangulatePoints( + P1, + P2, + np.array([[x1_px], [y1_px]], dtype=np.float64), + np.array([[x2_px], [y2_px]], dtype=np.float64), + ) + X = (X_h[:3] / X_h[3]).reshape(3).astype(np.float64) + if not np.all(np.isfinite(X)): + return None + return X + except Exception: + return None + + +def initial_triangulation( + marker_observations: Dict[int, List[Observation]], + cameras: List[Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] +) -> Dict[int, np.ndarray]: + triangulated: Dict[int, np.ndarray] = {} + for marker_id, observations in marker_observations.items(): + X = triangulate_marker_initial(marker_id, observations, cameras) + if X is not None: + triangulated[marker_id] = X + return triangulated + + +# =================================================================== +# Weighted residuals / optimization +# =================================================================== + +def bundle_adjustment_residuals( + marker_positions_flat: np.ndarray, + marker_ids: List[int], + marker_observations: Dict[int, List[Observation]], + cameras: List[Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]], + constraints: List[Constraint], + obs_weights: Dict[Tuple[int, int], float], + lambda_constraint: float = 100.0 +) -> np.ndarray: + marker_dict: Dict[int, np.ndarray] = {} + for i, marker_id in enumerate(marker_ids): + marker_dict[marker_id] = marker_positions_flat[i * 3:(i + 1) * 3] + + residuals: List[float] = [] + + for marker_id, observations in marker_observations.items(): + if marker_id not in marker_dict: + continue + + X_world = marker_dict[marker_id] + for obs_idx, obs in enumerate(observations): + cam_idx, norm_coords_obs = obs.cam_idx, obs.norm_coords + K, D, R_wc, t_wc = cameras[cam_idx] + X_cam = R_wc @ X_world + t_wc + if X_cam[2] > 1e-6: + proj_norm = X_cam[:2] / X_cam[2] + r = proj_norm - norm_coords_obs + w = float(np.sqrt(obs_weights.get((marker_id, obs_idx), 1.0))) + residuals.append(w * float(r[0])) + residuals.append(w * float(r[1])) + + for constraint in constraints: + if isinstance(constraint, MarkerDistanceConstraint): + if constraint.marker_id_a in marker_dict and constraint.marker_id_b in marker_dict: + pos_a = marker_dict[constraint.marker_id_a] + pos_b = marker_dict[constraint.marker_id_b] + actual_dist = float(np.linalg.norm(pos_b - pos_a)) + residuals.append((actual_dist - constraint.target_distance_m) * constraint.weight * lambda_constraint) + + elif isinstance(constraint, JointAxisConstraint): + if constraint.marker_id_parent in marker_dict and constraint.marker_id_child in marker_dict: + pos_parent = marker_dict[constraint.marker_id_parent] + pos_child = marker_dict[constraint.marker_id_child] + delta = pos_child - pos_parent + actual_delta = float(np.dot(delta, constraint.joint_axis)) + residuals.append((actual_delta - constraint.target_delta_along_axis_m) * constraint.weight * lambda_constraint) + + return np.asarray(residuals, dtype=np.float64) + + +def optimize_with_constraints( + initial_positions: Dict[int, np.ndarray], + marker_observations: Dict[int, List[Observation]], + cameras: List[Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]], + constraints: List[Constraint], + obs_weights: Dict[Tuple[int, int], float], + lambda_constraint: float = 100.0, + max_iterations: int = 50 +) -> Dict[int, np.ndarray]: + try: + from scipy.optimize import least_squares + except ImportError: + print("[WARN] scipy not available, skipping optimization.") + return initial_positions + + marker_ids = sorted(initial_positions.keys()) + if not marker_ids: + return {} + + x0 = np.concatenate([initial_positions[mid] for mid in marker_ids]) + + def residuals_fn(x: np.ndarray) -> np.ndarray: + return bundle_adjustment_residuals( + x, marker_ids, marker_observations, cameras, constraints, obs_weights, lambda_constraint + ) + + print(f"[INFO] Starting optimization with {len(x0)} variables and {len(constraints)} constraints...") + + result = least_squares( + residuals_fn, + x0, + max_nfev=max_iterations * max(1, len(marker_ids)), + verbose=1, + ) + + optimized = {} + for i, marker_id in enumerate(marker_ids): + optimized[marker_id] = result.x[i * 3:(i + 1) * 3] + + print(f"[INFO] Optimization complete. Final cost: {float(np.sum(result.fun ** 2)):.6f}") + return optimized + + +# =================================================================== +# Reporting helpers +# =================================================================== + +def print_constraint_summary(constraints: List[Constraint]) -> None: + num_dist = sum(isinstance(c, MarkerDistanceConstraint) for c in constraints) + num_joint = sum(isinstance(c, JointAxisConstraint) for c in constraints) + num_other = len(constraints) - num_dist - num_joint + extra = f" other={num_other}" if num_other else "" + print(f"[INFO] Constraint summary: total={len(constraints)} distance={num_dist} joint/chain={num_joint}{extra}") + + +def print_constraint_list(constraints: List[Constraint]) -> None: + print("\n[INFO] Constraint list:") + for idx, constraint in enumerate(constraints): + if isinstance(constraint, MarkerDistanceConstraint): + print( + f" [{idx:03d}] DISTANCE | " + f"Link='{constraint.link_name}' | " + f"M{constraint.marker_id_a} <-> M{constraint.marker_id_b} | " + f"Target={constraint.target_distance_m:.6f} m | " + f"Weight={constraint.weight} | " + f"Source={constraint.source}" + ) + elif isinstance(constraint, JointAxisConstraint): + axis_str = np.array2string(constraint.joint_axis, precision=3, suppress_small=True) + print( + f" [{idx:03d}] JOINT_AXIS | " + f"{constraint.parent_link}(M{constraint.marker_id_parent}) -> " + f"{constraint.child_link}(M{constraint.marker_id_child}) | " + f"Axis={axis_str} | " + f"TargetDelta={constraint.target_delta_along_axis_m:.6f} m | " + f"Weight={constraint.weight} | " + f"Source={constraint.source}" + ) + else: + print( + f" [{idx:03d}] {type(constraint).__name__} | " + f"weight={getattr(constraint, 'weight', '?')} | " + f"source={getattr(constraint, 'source', '?')}" + ) + + +def print_constraints_with_errors( + title: str, + constraints: List[Constraint], + positions: Dict[int, np.ndarray], + show_skipped: bool = True +) -> None: + print(f"\n[INFO] {title}") + + active = 0 + skipped = 0 + + for idx, constraint in enumerate(constraints): + if isinstance(constraint, MarkerDistanceConstraint): + if constraint.marker_id_a not in positions or constraint.marker_id_b not in positions: + skipped += 1 + if show_skipped: + print( + f" [{idx:03d}] DISTANCE | " + f"M{constraint.marker_id_a} <-> M{constraint.marker_id_b} | SKIPPED (missing marker)" + ) + continue + + pos_a = positions[constraint.marker_id_a] + pos_b = positions[constraint.marker_id_b] + actual = float(np.linalg.norm(pos_b - pos_a)) + error = actual - constraint.target_distance_m + active += 1 + + print( + f" [{idx:03d}] DISTANCE | " + f"Link='{constraint.link_name}' | " + f"M{constraint.marker_id_a} <-> M{constraint.marker_id_b} | " + f"target={constraint.target_distance_m*1000:.2f} mm | " + f"actual={actual*1000:.2f} mm | " + f"error={error*1000:+.2f} mm" + ) + + elif isinstance(constraint, JointAxisConstraint): + if constraint.marker_id_parent not in positions or constraint.marker_id_child not in positions: + skipped += 1 + if show_skipped: + print( + f" [{idx:03d}] JOINT_AXIS | " + f"M{constraint.marker_id_parent} -> M{constraint.marker_id_child} | SKIPPED (missing marker)" + ) + continue + + pos_parent = positions[constraint.marker_id_parent] + pos_child = positions[constraint.marker_id_child] + delta = pos_child - pos_parent + actual = float(np.dot(delta, constraint.joint_axis)) + error = actual - constraint.target_delta_along_axis_m + active += 1 + + axis_str = np.array2string(constraint.joint_axis, precision=2, suppress_small=True) + print( + f" [{idx:03d}] JOINT_AXIS | " + f"{constraint.parent_link}(M{constraint.marker_id_parent}) -> " + f"{constraint.child_link}(M{constraint.marker_id_child}) | " + f"axis={axis_str} | " + f"target={constraint.target_delta_along_axis_m*1000:.2f} mm | " + f"actual={actual*1000:.2f} mm | " + f"error={error*1000:+.2f} mm" + ) + + print(f"[INFO] Active constraints: {active} | Skipped: {skipped}") + + +def print_observation_weight_summary(obs_weights: Dict[Tuple[int, int], float]) -> None: + if not obs_weights: + print("[INFO] Observation weighting: disabled or empty") + return + values = np.array(list(obs_weights.values()), dtype=np.float64) + print( + "[INFO] Observation weights: " + f"min={values.min():.3f} mean={values.mean():.3f} " + f"median={np.median(values):.3f} max={values.max():.3f}" + ) + + +def serialize_vec3(v: Any) -> List[float]: + arr = np.asarray(v, dtype=np.float64).reshape(3) + n = np.linalg.norm(arr) + if n > 1e-12: + arr = arr / n + return [float(x) for x in arr] + + +# =================================================================== +# Main +# =================================================================== + +def main() -> None: + parser = argparse.ArgumentParser( + description="Multi-view bundle adjustment with rule-based geometric constraints" + ) + parser.add_argument( + "-det", "--detections", + action="append", + required=True, + help="*_aruco_detection.json files" + ) + parser.add_argument( + "-pose", "--poses", + action="append", + required=True, + help="*_camera_pose.json files" + ) + parser.add_argument( + "-robot", "--robot", + required=True, + help="robot.json" + ) + parser.add_argument( + "-outDir", "--outDir", + default=None, + help="Output directory" + ) + parser.add_argument( + "-lambdaWeight", "--lambdaWeight", + type=float, + default=100.0, + help="Constraint weight multiplier" + ) + parser.add_argument( + "--strictUniqueMarkerIds", + action="store_true", + help="Fail if a marker ID appears more than once in robot.json" + ) + parser.add_argument( + "--showSkippedConstraints", + action="store_true", + help="Print skipped constraints in the report" + ) + parser.add_argument( + "--noShowSkippedConstraints", + action="store_true", + help="Hide skipped constraints in the report" + ) + parser.add_argument( + "--saveConstraintReport", + action="store_true", + help="Save constraint report JSON files" + ) + parser.add_argument( + "--saveObservationWeightReport", + action="store_true", + help="Save observation-weight report JSON file" + ) + + args = parser.parse_args() + + if args.showSkippedConstraints and args.noShowSkippedConstraints: + print("[ERROR] Choose only one of --showSkippedConstraints or --noShowSkippedConstraints") + sys.exit(1) + + if len(args.detections) != len(args.poses): + print(f"[ERROR] Mismatch: {len(args.detections)} detection files vs {len(args.poses)} pose files") + sys.exit(1) + + robot_data = load_json(args.robot) + length_scale = get_length_scale(robot_data) + cfg = load_constraint_rule_config(robot_data, args) + + print("[STEP 1] Compile constraints from robot.json structure...") + print( + "[INFO] Constraint families: " + f"rigid_distance={'on' if cfg.rigid_distance_enabled else 'off'}, " + f"revolute={'on' if cfg.revolute_axis_enabled else 'off'}, " + f"prismatic={'on' if cfg.prismatic_orthogonal_enabled else 'off'}, " + f"chain_legacy={'on' if cfg.chain_axis_enabled else 'off'}, " + f"observation_weights={'on' if cfg.enable_observation_weights else 'off'}" + ) + marker_to_link, link_markers, constraints, issues, marker_meta = compile_constraints(robot_data, length_scale, cfg) + + for issue in issues: + print(issue) + + print(f"[INFO] Links with markers: {sum(1 for v in link_markers.values() if len(v) > 0)}") + print(f"[INFO] Unique marker IDs: {len(marker_to_link)}") + print_constraint_summary(constraints) + print_constraint_list(constraints) + + print("\n[STEP 2] Load observations and camera poses...") + marker_observations, cameras, obs_metadata = load_observations_and_poses(args.detections, args.poses) + print(f"[INFO] {len(cameras)} cameras, {len(marker_observations)} observed markers") + print(f"[INFO] Detection files loaded: {len(obs_metadata)}") + + print("\n[STEP 3] Initial triangulation...") + initial_pos = initial_triangulation(marker_observations, cameras) + print(f"[INFO] Triangulated {len(initial_pos)} markers") + + out_dir = args.outDir or os.path.dirname(args.detections[0]) or "." + os.makedirs(resolve_path(out_dir), exist_ok=True) + + # camera poses in world (for viewer frusta): centre C = -R^T t, view axis = R[2] + cameras_section = [] + for idx, (K, D, R_wc, t_wc) in enumerate(cameras): + C = -R_wc.T @ t_wc + cam_id = str(idx) + base = os.path.basename(str(obs_metadata[idx].get("pose_file", ""))) if idx < len(obs_metadata) else "" + if base.startswith("render_") and "_camera_pose" in base: + cam_id = base[len("render_"):base.index("_camera_pose")] + cameras_section.append({ + "camera_id": cam_id, + "position_m": [float(v) for v in C], + "position_mm": [float(v * 1000.0) for v in C], + "direction": [float(v) for v in R_wc[2]], + }) + + initial_output_markers = [] + for marker_id, position in sorted(initial_pos.items()): + normal = marker_meta.get(marker_id, {}).get("normal", None) + initial_output_markers.append( + { + "marker_id": int(marker_id), + "position_m": [float(x) for x in position], + "position_mm": [float(x * 1000.0) for x in position], + "link": marker_to_link.get(marker_id, "unknown"), + "normal": serialize_vec3(normal) if normal is not None else None, + } + ) + + initial_output = { + "schema_version": "1.2", + "stage": "initial_triangulation", + "created_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "summary": { + "num_cameras": len(cameras), + "num_markers": len(initial_pos), + "num_constraints": len(constraints), + }, + "cameras": cameras_section, + "markers": initial_output_markers, + } + initial_out_file = os.path.join(out_dir, "aruco_positions_initial.json") + save_json(initial_out_file, initial_output) + print(f"[INFO] Initial triangulation saved to {initial_out_file}") + + obs_weights = compute_observation_weights( + marker_observations=marker_observations, + cameras=cameras, + initial_positions=initial_pos, + marker_meta=marker_meta, + cfg=cfg, + robot_data=robot_data, + ) + print_observation_weight_summary(obs_weights) + + print_constraints_with_errors( + "Constraint list BEFORE optimization", + constraints, + initial_pos, + show_skipped=cfg.show_skipped_constraints, + ) + + print("\n[STEP 4] Bundle adjustment with constraints...") + optimized_pos = optimize_with_constraints( + initial_pos, + marker_observations, + cameras, + constraints, + obs_weights, + lambda_constraint=args.lambdaWeight, + ) + + print_constraints_with_errors( + "Constraint list AFTER optimization", + constraints, + optimized_pos, + show_skipped=cfg.show_skipped_constraints, + ) + + output_markers = [] + for marker_id, position in sorted(optimized_pos.items()): + normal = marker_meta.get(marker_id, {}).get("normal", None) + output_markers.append( + { + "marker_id": int(marker_id), + "position_m": [float(x) for x in position], + "position_mm": [float(x * 1000.0) for x in position], + "link": marker_to_link.get(marker_id, "unknown"), + "normal": serialize_vec3(normal) if normal is not None else None, + } + ) + + output = { + "schema_version": "1.2", + "created_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "summary": { + "num_cameras": len(cameras), + "num_markers": len(optimized_pos), + "num_constraints": len(constraints), + }, + "cameras": cameras_section, + "markers": output_markers, + } + out_file = os.path.join(out_dir, "aruco_positions_optimized.json") + save_json(out_file, output) + print(f"\n[INFO] Saved to {out_file}") + + if args.saveConstraintReport: + report = { + "schema_version": "1.0", + "created_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "summary": { + "num_constraints": len(constraints), + "num_links_with_markers": sum(1 for v in link_markers.values() if len(v) > 0), + "num_observed_markers": len(marker_observations), + "num_triangulated_markers": len(initial_pos), + "num_optimized_markers": len(optimized_pos), + }, + "constraints": [], + } + for c in constraints: + if isinstance(c, MarkerDistanceConstraint): + report["constraints"].append( + { + "kind": "distance", + "link_name": c.link_name, + "marker_id_a": c.marker_id_a, + "marker_id_b": c.marker_id_b, + "target_distance_m": c.target_distance_m, + "weight": c.weight, + "source": c.source, + } + ) + else: + report["constraints"].append( + { + "kind": "joint_axis", + "parent_link": c.parent_link, + "child_link": c.child_link, + "marker_id_parent": c.marker_id_parent, + "marker_id_child": c.marker_id_child, + "joint_axis": [float(x) for x in c.joint_axis], + "target_delta_along_axis_m": c.target_delta_along_axis_m, + "weight": c.weight, + "source": c.source, + } + ) + report_file = os.path.join(out_dir, "constraint_report.json") + save_json(report_file, report) + print(f"[INFO] Constraint report saved to {report_file}") + + if args.saveObservationWeightReport: + obs_report = { + "schema_version": "1.0", + "created_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "summary": { + "num_weighted_observations": len(obs_weights), + }, + "observation_weights": [ + { + "marker_id": int(mid), + "observation_index": int(obs_idx), + "weight": float(w), + } + for (mid, obs_idx), w in sorted(obs_weights.items()) + ], + } + obs_file = os.path.join(out_dir, "observation_weight_report.json") + save_json(obs_file, obs_report) + print(f"[INFO] Observation-weight report saved to {obs_file}") + + +if __name__ == "__main__": + main() diff --git a/approbot-pipeline/approbot_pipeline/scripts/3b_corner_marker_poses.py b/approbot-pipeline/approbot_pipeline/scripts/3b_corner_marker_poses.py new file mode 100644 index 0000000..f02fd5e --- /dev/null +++ b/approbot-pipeline/approbot_pipeline/scripts/3b_corner_marker_poses.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +3b_corner_marker_poses.py +========================= +Produktiver Pipeline-Schritt: leitet aus den 4 ArUco-Ecken jedes Markers eine +volle Marker-Pose ab (Position + gemessene Normale), statt nur den Center zu +triangulieren. + +Validiert in benchmark/stage0_corner_normals.py: die aus triangulierten Ecken +abgeleitete Normale ist ~1 deg genau (Median), auch fuer Finger-Marker. + +Input: + --evalDir Ordner mit render_*_aruco_detection.json + _camera_pose.json + --robot robot.json (fuer marker_id -> link Zuordnung) +Output: + /aruco_marker_poses.json (pro Marker: position, gemessene normal, + 4 triangulierte Ecken, #Kameras, Kantenlaenge) + +Das Format ist kompatibel mit robot_viewer.html (marker_id, position_m/mm, normal) +und mit 9_evaluateMarker.py (position_m), erweitert um die gemessene Orientierung. +""" +from __future__ import annotations + +import argparse +import glob +import json +import os +import re +import time +from typing import Dict, List, Tuple + +import numpy as np +import cv2 + + +# ------------------------------------------------------------------ +# Loading +# ------------------------------------------------------------------ + +def load_cameras(eval_dir: str) -> Dict[str, dict]: + 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 not None: + 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 + + +def load_marker_links(robot_path: str) -> Dict[int, str]: + robot = json.load(open(robot_path, "r", encoding="utf-8")) + out: Dict[int, str] = {} + for link_name, link in (robot.get("links", {}) or {}).items(): + for mk in link.get("markers", []) or []: + mid = int(mk.get("id", -1)) + if mid >= 0: + out[mid] = link_name + return out + + +# ------------------------------------------------------------------ +# Geometry (validated in stage0) +# ------------------------------------------------------------------ + +def triangulate_multiview(observations) -> np.ndarray: + 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)]) + A.append(x * P[2] - P[0]) + A.append(y * P[2] - P[1]) + _, _, Vt = np.linalg.svd(np.asarray(A, dtype=float)) + X = Vt[-1] + return np.array([np.nan] * 3) if abs(X[3]) < 1e-12 else X[:3] / X[3] + + +def corner_plane_normal(corners3d: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + center = corners3d.mean(axis=0) + _, _, Vt = np.linalg.svd(corners3d - center) + n = Vt[-1] + # ArUco corners clockwise from the front: outward (camera-facing) normal, + # matching the Blender/robot.json convention, points opposite cross(e01,e02). + 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 + + +# ------------------------------------------------------------------ +# Main +# ------------------------------------------------------------------ + +def main() -> None: + ap = argparse.ArgumentParser(description="Derive marker poses (position + measured normal) from ArUco corners") + ap.add_argument("--evalDir", required=True, help="folder with detection + camera_pose JSONs") + ap.add_argument("--robot", required=True, help="robot.json (for marker->link)") + ap.add_argument("--minCams", type=int, default=2, help="min cameras to triangulate a marker") + ap.add_argument("--out", default=None, help="output path (default /aruco_marker_poses.json)") + args = ap.parse_args() + + cams = load_cameras(args.evalDir) + if len(cams) < 2: + print("[ERROR] need >=2 cameras") + return + links = load_marker_links(args.robot) + print(f"[INFO] Cameras: {sorted(cams.keys())} | marker-link entries: {len(links)}") + + marker_cams: Dict[int, List[str]] = {} + for cid, cam in cams.items(): + for mid in cam["markers"]: + marker_cams.setdefault(mid, []).append(cid) + + markers_out = [] + for mid, cam_ids in sorted(marker_cams.items()): + if len(cam_ids) < args.minCams: + 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) + normal, center = corner_plane_normal(corners3d) + edge_mm = float(np.mean([np.linalg.norm(corners3d[(i + 1) % 4] - corners3d[i]) for i in range(4)]) * 1000.0) + + markers_out.append({ + "marker_id": int(mid), + "link": links.get(mid, "unknown"), + "position_m": [float(v) for v in center], + "position_mm": [float(v * 1000.0) for v in center], + "normal": [float(v) for v in normal], + "corners_m": [[float(v) for v in c] for c in corners3d], + "num_cameras": len(cam_ids), + "edge_length_mm": edge_mm, + }) + + # camera poses in world (for viewer frusta): centre C = -R^T t, view axis = R[2] + cameras_out = [] + for cid in sorted(cams.keys()): + cam = cams[cid] + C = -cam["R"].T @ cam["t"] + cameras_out.append({ + "camera_id": cid, + "position_m": [float(v) for v in C], + "position_mm": [float(v * 1000.0) for v in C], + "direction": [float(v) for v in cam["R"][2]], + }) + + out_path = args.out or os.path.join(args.evalDir, "aruco_marker_poses.json") + output = { + "schema_version": "1.1", + "stage": "corner_marker_poses", + "created_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "summary": {"num_cameras": len(cams), "num_markers": len(markers_out)}, + "cameras": cameras_out, + "markers": markers_out, + } + json.dump(output, open(out_path, "w", encoding="utf-8"), indent=2) + print(f"[INFO] {len(markers_out)} marker poses -> {out_path}") + + +if __name__ == "__main__": + main() diff --git a/approbot-pipeline/approbot_pipeline/scripts/pose_estimation.py b/approbot-pipeline/approbot_pipeline/scripts/pose_estimation.py new file mode 100644 index 0000000..8bf703c --- /dev/null +++ b/approbot-pipeline/approbot_pipeline/scripts/pose_estimation.py @@ -0,0 +1,539 @@ +#!/usr/bin/env python3 +""" +pose_estimation.py +================== +Estimate robot joint angles (x, y, z, a, b, c, e) from triangulated marker +poses, using the kinematic model in robot.json (via robot_fk.py). + +Design +------ +The estimator is parametrised over JOINT VARIABLES, not links. This handles the +tricky cases of this robot family generically: + * Links with zero own markers (Base/x, Hand/b, Palm/c) — their variable is + observable only through descendant markers. + * A variable shared by several links (FingerA & FingerB share 'e'). + * Occluded middle links — global BA reconstructs them from the fingers. + +Four switchable methods (robot.json -> pose_estimation.method): + sequential_vector : analytic per joint from marker-pair / normal vectors (fast) + sequential_fk : block-wise least squares along the chain (robust, 1 marker ok) + global_ba : all variables jointly, position + normal residuals, robust loss + hybrid : sequential_fk init -> global_ba refine (default, most stable) + +Observation input: + marker_observation = "corner_pose" -> aruco_marker_poses.json (pos + measured normal) + marker_observation = "center_point" -> aruco_positions_*.json (pos only) + +Both the engine (estimate_pose) and a CLI (main) live here. +""" +from __future__ import annotations + +import argparse +import json +import math +import os +import sys +import time +from collections import defaultdict +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np + +sys.path.insert(0, str(Path(__file__).parent)) +from robot_fk import RobotFK, STATE_KEYS # noqa: E402 + +try: + from scipy.optimize import least_squares + HAVE_SCIPY = True +except ImportError: + HAVE_SCIPY = False + + +# ================================================================== +# Config +# ================================================================== + +DEFAULT_CFG: Dict[str, Any] = { + "method": "hybrid", + "marker_observation": "corner_pose", + "use_normals": True, + "normal_weight": 100.0, + "robust_loss": "huber", + "huber_delta_mm": 8.0, + "max_iterations": 200, + "min_cameras_per_marker": 2, + "finger_block_joints": ["b", "c", "e"], + "per_link_method": {}, +} + + +def load_pose_cfg(robot_data: Dict[str, Any]) -> Dict[str, Any]: + cfg = dict(DEFAULT_CFG) + cfg.update(robot_data.get("pose_estimation", {}) or {}) + return cfg + + +# ================================================================== +# Observations +# ================================================================== + +def load_observations(path: str, use_normals: bool, min_cams: int = 2) -> Dict[int, Dict[str, Any]]: + """ + Load marker observations. Accepts aruco_marker_poses.json (with measured + normal + num_cameras) or aruco_positions_*.json (position only). + Returns: marker_id -> {pos_mm:(3,), normal:(3,)|None, link:str, n_cams:int} + """ + data = json.load(open(path, "r", encoding="utf-8")) + out: Dict[int, Dict[str, Any]] = {} + for m in data.get("markers", []): + mid = int(m.get("marker_id", m.get("id", -1))) + if mid < 0: + continue + n_cams = int(m.get("num_cameras", 99)) + if n_cams < min_cams: + continue + if "position_mm" in m: + pos = np.array(m["position_mm"], dtype=float) + elif "position_m" in m: + pos = np.array(m["position_m"], dtype=float) * 1000.0 + else: + continue + nrm = None + if use_normals and m.get("normal") is not None: + nv = np.array(m["normal"], dtype=float) + nn = np.linalg.norm(nv) + if nn > 1e-9: + nrm = nv / nn + out[mid] = {"pos_mm": pos, "normal": nrm, "link": m.get("link", "?"), "n_cams": n_cams} + return out + + +# ================================================================== +# Kinematic chain analysis +# ================================================================== + +def analyze_chain(fk: RobotFK) -> Dict[str, Any]: + """ + Derive, generically from the FK topology: + ordered_vars : movable joint variables, root->tip order, de-duplicated + var_links : variable -> list of links it drives + link_markers : link -> [model marker ids] + blocks : sequential estimation blocks; each block groups the + zero-marker ancestor variables with the next marker- + bearing joint variable, estimated from that link's own + markers (+ siblings sharing the same variable). + """ + links = fk.links + topo = fk._topo + + link_markers: Dict[str, List[int]] = {} + for ln, ld in links.items(): + ids = [] + for mk in ld.get("markers", []) or []: + if "id" in mk and "position" in mk: + ids.append(int(mk["id"])) + link_markers[ln] = ids + + link_var: Dict[str, str] = {} + for ln, ld in links.items(): + j = ld.get("jointToParent", {}) or {} + if str(j.get("type", "")).lower() in ("revolute", "linear"): + v = str(j.get("variable", "")).lower() + if v: + link_var[ln] = v + + var_type: Dict[str, str] = {} + var_links: Dict[str, List[str]] = defaultdict(list) + for ln, v in link_var.items(): + var_links[v].append(ln) + var_type[v] = str(links[ln].get("jointToParent", {}).get("type", "")).lower() + + ordered_vars: List[str] = [] + for ln in topo: + if ln in link_var and link_var[ln] not in ordered_vars: + ordered_vars.append(link_var[ln]) + + # ---- build blocks ---- + blocks: List[Dict[str, Any]] = [] + var_block: Dict[str, int] = {} + pending: List[str] = [] + for ln in topo: + if ln not in link_var: + continue + v = link_var[ln] + own = link_markers.get(ln, []) + if v in var_block: + # shared variable already in a block -> add this link's markers there + if own: + blocks[var_block[v]]["markers"].extend(own) + continue + if own: + bvars = [] + for x in pending + [v]: + if x not in bvars and x not in var_block: + bvars.append(x) + blocks.append({"vars": bvars, "markers": list(own), "anchor": ln}) + for x in bvars: + var_block[x] = len(blocks) - 1 + pending = [] + else: + if v not in pending: + pending.append(v) + if pending: + blocks.append({"vars": pending, "markers": [], "anchor": None}) + for x in pending: + var_block[x] = len(blocks) - 1 + + return { + "ordered_vars": ordered_vars, + "var_type": var_type, + "var_links": dict(var_links), + "link_markers": link_markers, + "blocks": blocks, + } + + +# ================================================================== +# Residuals +# ================================================================== + +def model_markers(fk: RobotFK, state: Dict[str, float]) -> Dict[int, Dict[str, np.ndarray]]: + T = fk.compute(state) + return fk.all_markers_world(T) # mid -> {world_mm, normal_world, link, local_mm} + + +def residual_vector(state: Dict[str, float], fk: RobotFK, obs: Dict[int, Dict[str, Any]], + marker_ids: List[int], cfg: Dict[str, Any]) -> np.ndarray: + """Position (mm) + optional normal (scaled) residuals over the given markers.""" + model = model_markers(fk, state) + res: List[float] = [] + w_n = float(cfg.get("normal_weight", 30.0)) + use_n = bool(cfg.get("use_normals", True)) + for mid in marker_ids: + if mid not in model or mid not in obs: + continue + mm = model[mid] + dp = np.asarray(mm["world_mm"], float) - obs[mid]["pos_mm"] + res.extend(dp.tolist()) + if use_n and obs[mid]["normal"] is not None and "normal_world" in mm: + dn = (np.asarray(mm["normal_world"], float) - obs[mid]["normal"]) * w_n + res.extend(dn.tolist()) + return np.asarray(res, dtype=float) + + +def _state_from_vec(var_names: List[str], vec: np.ndarray, base: Dict[str, float]) -> Dict[str, float]: + s = dict(base) + for name, val in zip(var_names, vec): + s[name] = float(val) + return s + + +# ================================================================== +# Method: global bundle adjustment +# ================================================================== + +def estimate_global_ba(fk: RobotFK, obs: Dict[int, Dict[str, Any]], var_names: List[str], + x0: Dict[str, float], cfg: Dict[str, Any]) -> Dict[str, float]: + if not HAVE_SCIPY: + print("[WARN] scipy missing — global_ba skipped, returning init") + return dict(x0) + marker_ids = list(obs.keys()) + base = {k: 0.0 for k in STATE_KEYS} + base.update(x0) + vec0 = np.array([base.get(v, 0.0) for v in var_names], dtype=float) + + def fun(vec): + st = _state_from_vec(var_names, vec, base) + return residual_vector(st, fk, obs, marker_ids, cfg) + + loss = cfg.get("robust_loss", "huber") + f_scale = float(cfg.get("huber_delta_mm", 8.0)) + try: + sol = least_squares(fun, vec0, loss=loss, f_scale=f_scale, + max_nfev=int(cfg.get("max_iterations", 200)) * max(1, len(var_names))) + return _state_from_vec(var_names, sol.x, base) + except Exception as exc: + print(f"[WARN] global_ba failed: {exc}") + return dict(base) + + +# ================================================================== +# Method: sequential block-wise FK fit +# ================================================================== + +def _multistart_values(vtype: str) -> List[float]: + # revolute: scan the circle to escape local minima at large angles + if vtype == "revolute": + return [0.0, 60.0, 120.0, 180.0, 240.0, 300.0] + return [0.0] + + +def estimate_sequential_fk(fk: RobotFK, obs: Dict[int, Dict[str, Any]], chain: Dict[str, Any], + cfg: Dict[str, Any]) -> Dict[str, float]: + """Estimate block by block along the chain, freezing already-solved variables.""" + state = {k: 0.0 for k in STATE_KEYS} + var_type = chain["var_type"] + + for block in chain["blocks"]: + bvars = block["vars"] + bmarkers = [m for m in block["markers"] if m in obs] + if not bvars: + continue + if not bmarkers: + # unobservable block: leave at 0, flag later + continue + + if not HAVE_SCIPY: + continue + + base = dict(state) + + def fun(vec, _bvars=bvars, _bm=bmarkers, _base=base): + st = _state_from_vec(_bvars, vec, _base) + return residual_vector(st, fk, obs, _bm, cfg) + + # multi-start over the first revolute variable in the block + starts = [[0.0] * len(bvars)] + lead_type = var_type.get(bvars[0], "linear") + if lead_type == "revolute": + starts = [] + for a0 in _multistart_values("revolute"): + s = [0.0] * len(bvars) + s[0] = a0 + starts.append(s) + + best, best_cost = None, float("inf") + for s0 in starts: + try: + sol = least_squares(fun, np.array(s0, dtype=float), + loss=cfg.get("robust_loss", "huber"), + f_scale=float(cfg.get("huber_delta_mm", 8.0)), + max_nfev=200 * max(1, len(bvars))) + if sol.cost < best_cost: + best_cost, best = sol.cost, sol.x + except Exception: + continue + if best is not None: + for name, val in zip(bvars, best): + state[name] = float(val) + + # wrap revolute angles to (-180, 180] + for v, vt in var_type.items(): + if vt == "revolute": + state[v] = (state[v] + 180.0) % 360.0 - 180.0 + return state + + +# ================================================================== +# Method: sequential analytic vector (per revolute joint) +# ================================================================== + +def estimate_sequential_vector(fk: RobotFK, obs: Dict[int, Dict[str, Any]], chain: Dict[str, Any], + cfg: Dict[str, Any]) -> Dict[str, float]: + """ + Analytic angle from marker geometry where possible. For revolute joints with + >=2 markers on the link, use the perpendicular marker-pair vector. Falls back + to the FK block solver for linear / zero-marker / single-marker cases, so it + always returns a full state (still cheaper than full sequential_fk because + well-populated joints are solved in closed form). + """ + state = {k: 0.0 for k in STATE_KEYS} + var_type = chain["var_type"] + link_markers = chain["link_markers"] + var_links = chain["var_links"] + + for block in chain["blocks"]: + bvars = block["vars"] + if len(bvars) == 1 and var_type.get(bvars[0]) == "revolute": + v = bvars[0] + ln = var_links[v][0] + mids = [m for m in link_markers.get(ln, []) if m in obs] + if len(mids) >= 2: + # model vectors must be expressed in the WORLD frame at angle=0 + # (the link frame is already rotated by the parents y,z,...), so + # use FK marker world positions with this joint set to 0. + state_v0 = dict(state) + state_v0[v] = 0.0 + model_v0 = model_markers(fk, state_v0) + axis_world = fk.joint_axis_world(ln, state_v0) + ang = _angle_from_pairs_world(mids, model_v0, obs, axis_world) + if ang is not None: + state[v] = ang + continue + # fallback: block FK fit for this single block + _fit_single_block(fk, obs, block, var_type, cfg, state) + + for v, vt in var_type.items(): + if vt == "revolute": + state[v] = (state[v] + 180.0) % 360.0 - 180.0 + return state + + +def _angle_from_pairs_world(mids: List[int], model_v0: Dict[int, Dict[str, np.ndarray]], + obs: Dict[int, Dict[str, Any]], axis_world: np.ndarray) -> Optional[float]: + from itertools import combinations + a = np.asarray(axis_world, float) + a = a / (np.linalg.norm(a) + 1e-12) + angs, ws = [], [] + for i, j in combinations(mids, 2): + if i not in model_v0 or j not in model_v0: + continue + vm = np.asarray(model_v0[j]["world_mm"], float) - np.asarray(model_v0[i]["world_mm"], float) # world @ angle 0 + vo = obs[j]["pos_mm"] - obs[i]["pos_mm"] # observed vector (world, mm) + vm_p = vm - np.dot(vm, a) * a + vo_p = vo - np.dot(vo, a) * a + if np.linalg.norm(vm_p) < 5 or np.linalg.norm(vo_p) < 5: + continue + ang = math.atan2(float(np.dot(a, np.cross(vm_p, vo_p))), float(np.dot(vm_p, vo_p))) + angs.append(ang) + ws.append(np.linalg.norm(vm_p) * np.linalg.norm(vo_p)) + if not angs: + return None + s = sum(w * math.sin(x) for w, x in zip(ws, angs)) + c = sum(w * math.cos(x) for w, x in zip(ws, angs)) + return math.degrees(math.atan2(s, c)) + + +def _fit_single_block(fk, obs, block, var_type, cfg, state): + if not HAVE_SCIPY: + return + bvars = block["vars"] + bmarkers = [m for m in block["markers"] if m in obs] + if not bvars or not bmarkers: + return + base = dict(state) + + def fun(vec): + return residual_vector(_state_from_vec(bvars, vec, base), fk, obs, bmarkers, cfg) + + starts = [[0.0] * len(bvars)] + if var_type.get(bvars[0]) == "revolute": + starts = [[a0] + [0.0] * (len(bvars) - 1) for a0 in _multistart_values("revolute")] + best, best_cost = None, float("inf") + for s0 in starts: + try: + sol = least_squares(fun, np.array(s0, float), loss=cfg.get("robust_loss", "huber"), + f_scale=float(cfg.get("huber_delta_mm", 8.0)), max_nfev=200 * max(1, len(bvars))) + if sol.cost < best_cost: + best_cost, best = sol.cost, sol.x + except Exception: + continue + if best is not None: + for name, val in zip(bvars, best): + state[name] = float(val) + + +# ================================================================== +# Dispatch +# ================================================================== + +def observability(chain: Dict[str, Any], obs: Dict[int, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: + """ + Per-variable confidence from how well its estimation block is determined. + A block groups coupled variables (e.g. b,c,e on the fingers); confidence is + driven by markers-per-variable in that block: + high : >= 2 markers per variable (well over-determined) + medium : >= 1 marker per variable + low : fewer markers than variables (under-determined — distrust!) + none : no markers at all (variable left at 0) + """ + info: Dict[str, Dict[str, Any]] = {} + for block in chain["blocks"]: + seen = [m for m in block["markers"] if m in obs] + nvars = max(1, len(block["vars"])) + ratio = len(seen) / nvars + if len(seen) == 0: + conf = "none" + elif ratio >= 2.0: + conf = "high" + elif ratio >= 1.0: + conf = "medium" + else: + conf = "low" + for v in block["vars"]: + info[v] = {"observable": len(seen) > 0, "n_markers": len(seen), + "block_vars": len(block["vars"]), "confidence": conf, + "block_anchor": block["anchor"]} + return info + + +def estimate_pose(fk: RobotFK, obs: Dict[int, Dict[str, Any]], cfg: Dict[str, Any]) -> Dict[str, Any]: + chain = analyze_chain(fk) + var_names = chain["ordered_vars"] + method = str(cfg.get("method", "hybrid")).lower() + obsv = observability(chain, obs) + + if method == "sequential_vector": + state = estimate_sequential_vector(fk, obs, chain, cfg) + elif method == "sequential_fk": + state = estimate_sequential_fk(fk, obs, chain, cfg) + elif method == "global_ba": + init = estimate_sequential_fk(fk, obs, chain, cfg) # cheap robust init + state = estimate_global_ba(fk, obs, var_names, init, cfg) + else: # hybrid (default) + init = estimate_sequential_fk(fk, obs, chain, cfg) + state = estimate_global_ba(fk, obs, var_names, init, cfg) + + # final residual stats over all observed markers + final_res = residual_vector(state, fk, obs, list(obs.keys()), cfg) + rms = float(np.sqrt(np.mean(final_res ** 2))) if final_res.size else 0.0 + + return {"state": state, "method": method, "observability": obsv, + "residual_rms": rms, "num_markers": len(obs)} + + +# ================================================================== +# CLI +# ================================================================== + +def main() -> None: + ap = argparse.ArgumentParser(description="Estimate robot joint angles from marker poses") + ap.add_argument("markers", help="aruco_marker_poses.json (corner_pose) or aruco_positions_*.json (center)") + ap.add_argument("-robot", "--robot", required=True) + ap.add_argument("-out", "--out", default=None) + ap.add_argument("--method", default=None, help="override robot.json method") + args = ap.parse_args() + + robot_data = json.load(open(args.robot, "r", encoding="utf-8")) + cfg = load_pose_cfg(robot_data) + if args.method: + cfg["method"] = args.method + + fk = RobotFK(robot_data) + obs = load_observations(args.markers, cfg.get("use_normals", True), + int(cfg.get("min_cameras_per_marker", 2))) + print(f"[INFO] method={cfg['method']} | observed markers={len(obs)} | use_normals={cfg.get('use_normals')}") + + result = estimate_pose(fk, obs, cfg) + st = result["state"] + + print("\nEstimated joint values:") + for v in ["x", "y", "z", "a", "b", "c", "e"]: + ob = result["observability"].get(v, {}) + unit = "mm" if v in ("x", "e") else "deg" + conf = ob.get("confidence", "?") + tag = "" if ob.get("observable", False) else " [UNOBSERVABLE -> 0]" + print(f" {v}: {st.get(v, 0.0):8.2f} {unit} (markers={ob.get('n_markers','?')}, conf={conf}){tag}") + print(f"\n[INFO] residual RMS over {result['num_markers']} markers: {result['residual_rms']:.3f}") + + out = { + "schema_version": "1.0", + "created_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "method": result["method"], + "movements": {v: {"value": st.get(v, 0.0), + "unit": "mm" if v in ("x", "e") else "deg", + "observable": result["observability"].get(v, {}).get("observable", False), + "confidence": result["observability"].get(v, {}).get("confidence", "none"), + "n_markers": result["observability"].get(v, {}).get("n_markers", 0)} + for v in ["x", "y", "z", "a", "b", "c", "e"]}, + "residual_rms": result["residual_rms"], + "num_markers": result["num_markers"], + } + out_path = args.out or os.path.join(os.path.dirname(args.markers), "robot_state.json") + json.dump(out, open(out_path, "w", encoding="utf-8"), indent=2) + print(f"[INFO] wrote {out_path}") + + +if __name__ == "__main__": + main() diff --git a/approbot-pipeline/approbot_pipeline/scripts/robot_fk.py b/approbot-pipeline/approbot_pipeline/scripts/robot_fk.py new file mode 100644 index 0000000..2f9a376 --- /dev/null +++ b/approbot-pipeline/approbot_pipeline/scripts/robot_fk.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +""" +robot_fk.py +----------- +Minimal forward kinematics engine for the robot.json format. + +Matches the Blender hierarchy used by render_robot.py exactly: + world_T_link = world_T_parent + @ Translate(mountPosition) @ Rotate_xyz(mountRotation) + @ Translate(jointOrigin) @ Rotate_xyz(joint.rotation) + @ T_motion + + T_motion = Rotate(axis, value_deg) for revolute joints + Translate(axis * value_mm) for linear joints + +Units throughout: millimetres, degrees. + +Public API +---------- +fk = RobotFK.from_file("robot.json") + +transforms = fk.compute({"x": 180, "y": 86, "z": -120, + "a": -60, "b": 22, "c": 91, "e": 10}) +# → dict link_name -> 4×4 np.ndarray (world frame, mm) + +p_world = fk.marker_world(transforms, "Arm1", [0, -160, 35]) +# → np.ndarray shape (3,), in mm + +all_m = fk.all_markers_world(transforms) +# → dict marker_id -> {"world_mm", "link", "local_mm"} + +# Cumulative x-offset for a link at all-zero state +# (useful for 4a: x_slider = world_x - local_x - link_x_at_zero) +x0 = fk.link_x_at_zero_state("Arm1") # → float mm +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +STATE_KEYS = ("x", "y", "z", "a", "b", "c", "e") + + +# ────────────────────────────────────────────────────────────── +# Low-level matrix helpers +# ────────────────────────────────────────────────────────────── + +def _rot_axis_angle(axis: Sequence[float], angle_deg: float) -> np.ndarray: + """3×3 rotation matrix via Rodrigues (axis need not be normalised).""" + ax = np.asarray(axis, dtype=float) + n = float(np.linalg.norm(ax)) + if n < 1e-12: + return np.eye(3) + ax = ax / n + c = math.cos(math.radians(angle_deg)) + s = math.sin(math.radians(angle_deg)) + t = 1.0 - c + x, y, z = ax + return np.array([ + [t*x*x + c, t*x*y - s*z, t*x*z + s*y], + [t*x*y + s*z, t*y*y + c, t*y*z - s*x], + [t*x*z - s*y, t*y*z + s*x, t*z*z + c ], + ]) + + +def _rot_xyz_euler(rx: float, ry: float, rz: float) -> np.ndarray: + """XYZ Euler angles (degrees) → 3×3 — matches Blender XYZ Euler mode.""" + return (_rot_axis_angle([0, 0, 1], rz) + @ _rot_axis_angle([0, 1, 0], ry) + @ _rot_axis_angle([1, 0, 0], rx)) + + +def make_T(R: np.ndarray, t: Sequence[float]) -> np.ndarray: + """4×4 homogeneous transform.""" + T = np.eye(4) + T[:3, :3] = R + T[:3, 3] = np.asarray(t, dtype=float) + return T + + +def transform_point(T: np.ndarray, p: Sequence[float]) -> np.ndarray: + """Apply 4×4 transform to a 3-D point.""" + h = np.array([p[0], p[1], p[2], 1.0]) + return (T @ h)[:3] + + +# ────────────────────────────────────────────────────────────── +# FK engine +# ────────────────────────────────────────────────────────────── + +class RobotFK: + """Forward kinematics for the robot.json format.""" + + def __init__(self, robot_data: Dict[str, Any]): + self.robot = robot_data + self.links: Dict[str, Any] = robot_data.get("links", {}) + self._topo: List[str] = self._topological_sort() + + # ── construction ───────────────────────────────────────── + + @classmethod + def from_file(cls, path) -> "RobotFK": + with open(path, "r", encoding="utf-8") as f: + return cls(json.load(f)) + + def _topological_sort(self) -> List[str]: + parent_map = {n: d.get("parent") for n, d in self.links.items()} + visited, order = set(), [] + + def visit(name: str) -> None: + if name in visited: + return + visited.add(name) + p = parent_map.get(name) + if p and p in self.links: + visit(p) + order.append(name) + + for name in self.links: + visit(name) + return order + + # ── core computation ────────────────────────────────────── + + def compute(self, joint_values: Dict[str, float]) -> Dict[str, np.ndarray]: + """ + Compute link world transforms for the given joint state. + + Parameters + ---------- + joint_values : dict variable_name -> value + Linear joints (x, e): mm + Revolute joints (y, z, a, b, c): degrees + + Returns + ------- + dict link_name -> 4×4 np.ndarray (world frame, mm) + """ + state = {k: 0.0 for k in STATE_KEYS} + for k, v in joint_values.items(): + if k in state: + state[k] = float(v) + + transforms: Dict[str, np.ndarray] = {} + + for link_name in self._topo: + d = self.links[link_name] + parent = d.get("parent") + T_parent = transforms.get(parent, np.eye(4)) if parent else np.eye(4) + + # 1 · Mount (static in parent frame) + mp = d.get("mountPosition", [0, 0, 0]) + mr = d.get("mountRotation", [0, 0, 0]) + T_m = make_T(_rot_xyz_euler(*mr), mp) + + # 2 · Joint origin (pivot point in mount frame) + ji = d.get("jointToParent", {}) or {} + jp = ji.get("origin", [0, 0, 0]) + jr = ji.get("rotation", [0, 0, 0]) + T_j = make_T(_rot_xyz_euler(*jr), jp) + + # 3 · Motion + jtype = str(ji.get("type", "fixed")).lower() + var = str(ji.get("variable", "")).lower() + axis = np.asarray(ji.get("axis", [1, 0, 0]), dtype=float) + val = state.get(var, 0.0) + + if jtype == "revolute": + T_mot = make_T(_rot_axis_angle(axis, val), [0, 0, 0]) + elif jtype == "linear": + n = float(np.linalg.norm(axis)) + u = axis / n if n > 1e-12 else np.array([1.0, 0, 0]) + T_mot = make_T(np.eye(3), u * val) + else: + T_mot = np.eye(4) + + transforms[link_name] = T_parent @ T_m @ T_j @ T_mot + + return transforms + + # ── marker helpers ──────────────────────────────────────── + + @staticmethod + def marker_world(transforms: Dict[str, np.ndarray], + link_name: str, + local_pos: Sequence[float]) -> np.ndarray: + """Transform a local marker position → world (mm).""" + return transform_point(transforms.get(link_name, np.eye(4)), local_pos) + + def all_markers_world(self, + transforms: Dict[str, np.ndarray] + ) -> Dict[int, Dict[str, Any]]: + """ + Returns + ------- + dict marker_id -> {world_mm, local_mm, link, normal_world} + """ + result: Dict[int, Dict[str, Any]] = {} + for lname, ldata in self.links.items(): + T = transforms.get(lname, np.eye(4)) + R = T[:3, :3] + for m in ldata.get("markers", []): + mid = int(m.get("id", -1)) + if mid < 0 or "position" not in m: + continue + loc = np.array(m["position"], dtype=float) + nor = np.array(m.get("normal", [0, 0, 1]), dtype=float) + result[mid] = { + "world_mm": transform_point(T, loc), + "local_mm": loc, + "link": lname, + "normal_world": (R @ nor) / max(np.linalg.norm(R @ nor), 1e-12), + } + return result + + # ── x-axis invariant helpers (used by 4a) ──────────────── + + def link_x_at_zero_state(self, link_name: str) -> float: + """ + Return the world x-coordinate of the link-frame origin + when ALL joint variables are zero. + + For any link reachable via only x-axis rotations (Arm1, Ellbow, Arm2), + this value is constant regardless of the actual revolute angles. + Adding the slider value x_mm gives the true link origin x: + link_origin_world_x = x_mm + link_x_at_zero_state(link_name) + + And for any marker in that link: + marker_world_x = x_mm + link_x_at_zero_state(link_name) + marker_local_x + """ + T = self.compute({k: 0.0 for k in STATE_KEYS}) + return float(T[link_name][0, 3]) + + def joint_origin_world(self, + link_name: str, + joint_state: Dict[str, float]) -> np.ndarray: + """ + World position of the pivot that link_name rotates / slides around. + """ + d = self.links[link_name] + parent = d.get("parent") + T_all = self.compute(joint_state) + T_parent = T_all.get(parent, np.eye(4)) if parent else np.eye(4) + + mp = d.get("mountPosition", [0, 0, 0]) + mr = d.get("mountRotation", [0, 0, 0]) + T_m = make_T(_rot_xyz_euler(*mr), mp) + + ji = d.get("jointToParent", {}) or {} + jp = ji.get("origin", [0, 0, 0]) + jr = ji.get("rotation", [0, 0, 0]) + T_j = make_T(_rot_xyz_euler(*jr), jp) + + return transform_point(T_parent @ T_m @ T_j, [0, 0, 0]) + + def joint_axis_world(self, + link_name: str, + joint_state: Dict[str, float]) -> np.ndarray: + """ + Joint axis of link_name expressed in world frame. + """ + d = self.links[link_name] + parent = d.get("parent") + T_all = self.compute(joint_state) + T_parent = T_all.get(parent, np.eye(4)) if parent else np.eye(4) + + mp = d.get("mountPosition", [0, 0, 0]) + mr = d.get("mountRotation", [0, 0, 0]) + T_m = make_T(_rot_xyz_euler(*mr), mp) + + ji = d.get("jointToParent", {}) or {} + jp = ji.get("origin", [0, 0, 0]) + jr = ji.get("rotation", [0, 0, 0]) + T_j = make_T(_rot_xyz_euler(*jr), jp) + + R_to_pivot = (T_parent @ T_m @ T_j)[:3, :3] + axis_local = np.asarray(ji.get("axis", [1, 0, 0]), dtype=float) + world = R_to_pivot @ axis_local + n = float(np.linalg.norm(world)) + return world / n if n > 1e-12 else world + + # ── utility ─────────────────────────────────────────────── + + def chain(self, link_name: str) -> List[str]: + """Return chain from root to link_name (inclusive).""" + out, cur = [], link_name + while cur: + out.append(cur) + cur = self.links.get(cur, {}).get("parent") + return list(reversed(out)) + + def board_marker_world_positions(self, length_scale: float = 1.0) -> Dict[int, np.ndarray]: + """ + Return known world positions for all Board markers (in mm). + Board is the root, so its marker positions ARE world positions. + length_scale: 1/1000 if robot.json uses mm. + """ + board = self.links.get("Board", {}) + result: Dict[int, np.ndarray] = {} + for m in board.get("markers", []): + mid = int(m.get("id", -1)) + if mid >= 0 and "position" in m: + p = np.array(m["position"], dtype=float) * length_scale + result[mid] = p + return result diff --git a/approbot-pipeline/config/robot.json.example b/approbot-pipeline/config/robot.json.example new file mode 100644 index 0000000..9bafc02 --- /dev/null +++ b/approbot-pipeline/config/robot.json.example @@ -0,0 +1,19 @@ +{ + "_comment": "Kopiere deine robot.json hierher und benenne sie in robot.json um.", + "_comment2": "Die Pipeline liest nur: links, pose_estimation, vision_config, units.", + "_comment3": "Alle anderen Abschnitte (renderingInfo, robot_test_poses) werden ignoriert.", + "units": { "length": "mm", "angle": "deg" }, + "links": [], + "vision_config": { "aruco_dict": "DICT_4X4_250" }, + "pose_estimation": { + "method": "hybrid", + "marker_observation": "corner_pose", + "use_normals": true, + "normal_weight": 100.0, + "robust_loss": "huber", + "huber_delta_mm": 8.0, + "max_iterations": 200, + "min_cameras_per_marker": 2, + "per_link_method": {} + } +} diff --git a/approbot-pipeline/doc/README.md b/approbot-pipeline/doc/README.md new file mode 100644 index 0000000..7dc9755 --- /dev/null +++ b/approbot-pipeline/doc/README.md @@ -0,0 +1,212 @@ +# approbot-pipeline + +Roboter-Pose-Schätzung aus Mehrkamera-ArUco-Bildern. + +**Input:** Kamerabilder (`render_*.png`) + Kamera-Intrinsiken (`render_*.npz`) + `robot.json` +**Output:** Gelenkwinkel im R⁷ — `{x, y, z, a, b, c, e}` in mm bzw. Grad + +--- + +## Drei Interfaces, eine Logik + +``` +┌──────────────────────────────────────────────────────┐ +│ REST API (FastAPI, POST /v1/estimate) │ ← Robotersteuerung, beliebige Sprache +│ CLI (python -m approbot_pipeline ) │ ← Terminal, Batch, CI +│ Python (from approbot_pipeline import ...) │ ← direkte Einbindung +└──────────────────────────────────────────────────────┘ +``` + +--- + +## Schnellstart + +### Als Python-Bibliothek + +```python +from approbot_pipeline import estimate_from_dir + +result = estimate_from_dir("pfad/zu/bildern", robot_json="robot.json") +print(result.joints) # {"x": 50.2, "y": -2.1, "z": 94.8, ...} +print(result.confidence) # {"x": "high", "b": "low", ...} +``` + +### Als CLI + +```bash +pip install -e . +python -m approbot_pipeline data/Scene8 --robot robot.json +python -m approbot_pipeline data/Scene8 --robot robot.json --cameras a,b,d +``` + +### Als REST-Service (Docker / Portainer) + +```bash +# robot.json in config/ ablegen, dann: +docker compose up + +# oder manuell: +docker compose build +docker compose up -d +``` + +**Anfrage senden:** +```python +import requests + +resp = requests.post( + "http://localhost:8080/v1/estimate", + files=[ + ("images", ("render_a.png", open("render_a.png", "rb"))), + ("images", ("render_b.png", open("render_b.png", "rb"))), + ("intrinsics", ("render_a.npz", open("render_a.npz", "rb"))), + ("intrinsics", ("render_b.npz", open("render_b.npz", "rb"))), + ], +) +joints = resp.json()["joints"] +``` + +--- + +## Verzeichnisstruktur + +``` +approbot-pipeline/ +├── approbot_pipeline/ Python-Package +│ ├── __init__.py öffentliche API: estimate_from_dir, PipelineResult +│ ├── pipeline.py Orchestrator (ruft Scripts auf) +│ ├── __main__.py CLI-Einstiegspunkt +│ ├── scripts/ Pipeline-Scripts (kopiert aus dem Haupt-Repo) +│ │ ├── 1_detect_aruco_observations.py +│ │ ├── 2_estimate_camera_from_observations.py +│ │ ├── 3_multiview_bundle_adjustment_v4.py +│ │ ├── 3b_corner_marker_poses.py +│ │ ├── pose_estimation.py +│ │ └── robot_fk.py +│ └── api/ +│ ├── __init__.py start_server() +│ ├── __main__.py python -m approbot_pipeline.api +│ └── server.py FastAPI-App +├── config/ +│ └── robot.json ← hier die eigene robot.json ablegen +├── doc/ +│ ├── README.md diese Datei +│ └── robot_json_pipeline_schema.md +├── tests/ +│ ├── test_pipeline.py +│ └── fixtures/ +│ └── robot_minimal.json +├── pyproject.toml +└── docker-compose.yaml +``` + +--- + +## Portainer-Stack einrichten + +**Voraussetzung:** Docker Engine 23.0+ / Docker Compose Plugin 2.17+ +(prüfen: `docker compose version`) + +### Schritt 1 — robot.json auf dem Host ablegen + +Auf dem Server, auf dem Portainer läuft, robot.json in einem festen Pfad speichern, +z.B. `/opt/approbot/config/robot.json`. + +Den Volume-Pfad im `docker-compose.yaml` anpassen: + +```yaml +volumes: + - /opt/approbot/config/robot.json:/config/robot.json:ro +``` + +### Schritt 2 — Option A: Stack aus Git-Repository + +Empfohlen — Portainer zieht automatisch Updates wenn das Repo aktualisiert wird. + +1. Portainer öffnen → **Stacks** → **+ Add stack** +2. Name vergeben, z.B. `approbot-pipeline` +3. **Build method:** `Repository` +4. **Repository URL:** Git-URL des `approbot-pipeline`-Verzeichnisses eintragen +5. **Compose path:** `docker-compose.yaml` +6. ✅ **Re-pull image and redeploy** aktivieren (für automatische Updates) +7. **Deploy the stack** klicken + +Portainer führt `docker compose up --build` aus — das Image wird aus dem +`dockerfile_inline` im Compose-File gebaut. + +### Schritt 2 — Option B: Stack aus Compose-Datei einfügen + +Wenn kein Git-Zugang vorhanden ist: + +1. Portainer öffnen → **Stacks** → **+ Add stack** +2. Name vergeben +3. **Build method:** `Web editor` +4. Inhalt von `docker-compose.yaml` vollständig in den Editor einfügen +5. **Deploy the stack** klicken + +> Portainer baut das Image beim ersten Deploy aus dem `dockerfile_inline`-Block. +> Bei erneutem Deploy wird das Image neu gebaut wenn der Compose-Inhalt geändert wurde. + +### Schritt 3 — Service prüfen + +```bash +curl http://:8080/v1/health +# → {"status": "ok", "version": "1.0.0"} +``` + +Oder in Portainer unter **Containers** → `approbot-pipeline` → **Logs** nachschauen. + +### Roboter wechseln (Portainer) + +Neue `robot.json` auf dem Host ablegen (gleicher Pfad), dann in Portainer: +**Stacks** → `approbot-pipeline` → **Recreate** (Container neu starten, kein Rebuild nötig — +die Datei wird per Volume gemountet, nicht ins Image kopiert). + +--- + +## API-Referenz + +| Endpoint | Methode | Beschreibung | +|---|---|---| +| `/v1/estimate` | POST | Bilder → Gelenkwinkel | +| `/v1/health` | GET | `{"status": "ok", "version": "..."}` | +| `/v1/config` | GET | Aktiver `pose_estimation`-Block aus robot.json | + +### POST /v1/estimate — Response + +```json +{ + "joints": {"x": 50.2, "y": -2.1, "z": 94.8, "a": 20.1, "b": 59.9, "c": 9.0, "e": 3.0}, + "confidence": {"x": "high", "y": "high", "z": "high", "a": "high", "b": "low", "c": "low", "e": "low"}, + "residual_rms": 1.45, + "n_markers": 56, + "processing_ms": 1240 +} +``` + +Confidence-Stufen: `high` | `medium` | `low` | `none` + +--- + +## Konfiguration + +Die einzige Konfigurationsdatei ist `robot.json` — sie beschreibt den Roboter vollständig +für alle Werkzeuge der Umgebung (Pipeline, Renderer, Benchmark). +Die Pipeline liest daraus `links`, `pose_estimation`, `vision_config`, `movements` und `units`; +alle anderen Abschnitte werden ignoriert. + +Vollständige Dokumentation: [robot_json.md](robot_json.md) +API-Integrationsbeispiele (Python, Node.js): [api_integration.md](api_integration.md) + +--- + +## Abhängigkeiten + +| Paket | Version | Zweck | +|---|---|---| +| numpy | 1.26.4 | Numerik | +| scipy | 1.13.1 | Bundle Adjustment | +| opencv-contrib-python-headless | 4.10.0.84 | ArUco-Detektion | +| fastapi | 0.115.0 | REST-API | +| uvicorn | 0.30.6 | ASGI-Server | +| python-multipart | 0.0.9 | File-Upload | diff --git a/approbot-pipeline/doc/api_integration.md b/approbot-pipeline/doc/api_integration.md new file mode 100644 index 0000000..4a8f590 --- /dev/null +++ b/approbot-pipeline/doc/api_integration.md @@ -0,0 +1,239 @@ +# API Integration — approbot-pipeline + +Der Service läuft als HTTP-Server auf Port 8080 und ist von jeder Sprache aus +erreichbar. Alle Requests nutzen `multipart/form-data`. + +--- + +## Endpunkte im Überblick + +| Endpunkt | Methode | Input | Output | +|---|---|---|---| +| `/v1/estimate` | POST | Bilder + Intrinsiken (+ optional robot.json) | Gelenkwinkel JSON | +| `/v1/health` | GET | — | `{"status": "ok", "version": "1.0.0"}` | +| `/v1/config` | GET | — | Aktiver `pose_estimation`-Block | + +--- + +## Python (`requests`) + +```python +import requests + +BASE = "http://localhost:8080" + +# ── Health-Check ──────────────────────────────────────────────── +resp = requests.get(f"{BASE}/v1/health") +print(resp.json()) # {"status": "ok", "version": "1.0.0"} + +# ── Pose-Schätzung ─────────────────────────────────────────────── +# Bilder und zugehörige Intrinsiken in der gleichen Reihenfolge übergeben. +# Dateinamen müssen render_.png / render_.npz sein — +# die ID (a, b, c, ...) verknüpft Bild und Intrinsik intern. + +camera_ids = ["a", "b", "c"] + +files = [] +for cid in camera_ids: + files.append(("images", (f"render_{cid}.png", open(f"render_{cid}.png", "rb"), "image/png"))) + files.append(("intrinsics", (f"render_{cid}.npz", open(f"render_{cid}.npz", "rb"), "application/octet-stream"))) + +resp = requests.post(f"{BASE}/v1/estimate", files=files) +resp.raise_for_status() + +result = resp.json() +print(result["joints"]) # {"x": 50.2, "y": -2.1, "z": 94.8, "a": 20.1, "b": 59.9, "c": 9.0, "e": 3.0} +print(result["confidence"]) # {"x": "high", "b": "low", ...} +print(result["residual_rms"]) # 1.45 +print(result["processing_ms"]) # 1240 +``` + +### robot.json pro Request mitschicken (überschreibt Server-Konfig) + +```python +files.append(("robot_json", ("robot.json", open("robot.json", "rb"), "application/json"))) +resp = requests.post(f"{BASE}/v1/estimate", files=files) +``` + +### Fehlerbehandlung + +```python +resp = requests.post(f"{BASE}/v1/estimate", files=files) + +if resp.status_code == 400: + print("Ungültige Eingabe:", resp.json()["detail"]) +elif resp.status_code == 500: + print("Pipeline-Fehler:", resp.json()["detail"]) +else: + resp.raise_for_status() + joints = resp.json()["joints"] +``` + +### Async mit `httpx` + +```python +import asyncio +import httpx + +async def estimate(camera_ids: list[str]) -> dict: + async with httpx.AsyncClient(base_url="http://localhost:8080") as client: + files = [] + for cid in camera_ids: + files.append(("images", (f"render_{cid}.png", open(f"render_{cid}.png", "rb")))) + files.append(("intrinsics", (f"render_{cid}.npz", open(f"render_{cid}.npz", "rb")))) + resp = await client.post("/v1/estimate", files=files, timeout=60.0) + resp.raise_for_status() + return resp.json() + +result = asyncio.run(estimate(["a", "b", "c"])) +``` + +--- + +## Node.js + +### Native `fetch` + `FormData` (Node 18+, kein extra Paket) + +```js +import { readFileSync } from "fs"; + +const BASE = "http://localhost:8080"; +const cameraIds = ["a", "b", "c"]; + +// ── Health-Check ──────────────────────────────────────────────── +const health = await fetch(`${BASE}/v1/health`); +console.log(await health.json()); // { status: 'ok', version: '1.0.0' } + +// ── Pose-Schätzung ─────────────────────────────────────────────── +const form = new FormData(); + +for (const id of cameraIds) { + form.append( + "images", + new Blob([readFileSync(`render_${id}.png`)], { type: "image/png" }), + `render_${id}.png` + ); + form.append( + "intrinsics", + new Blob([readFileSync(`render_${id}.npz`)], { type: "application/octet-stream" }), + `render_${id}.npz` + ); +} + +const resp = await fetch(`${BASE}/v1/estimate`, { method: "POST", body: form }); + +if (!resp.ok) { + const err = await resp.json(); + throw new Error(`Pipeline-Fehler ${resp.status}: ${err.detail}`); +} + +const result = await resp.json(); +console.log(result.joints); // { x: 50.2, y: -2.1, z: 94.8, a: 20.1, b: 59.9, c: 9.0, e: 3.0 } +console.log(result.confidence); // { x: 'high', b: 'low', ... } +``` + +### `axios` + `form-data` (Node 16 / CommonJS-Umgebungen) + +```bash +npm install axios form-data +``` + +```js +const axios = require("axios"); +const FormData = require("form-data"); +const fs = require("fs"); + +const BASE = "http://localhost:8080"; +const cameraIds = ["a", "b", "c"]; + +const form = new FormData(); +for (const id of cameraIds) { + form.append("images", fs.createReadStream(`render_${id}.png`), `render_${id}.png`); + form.append("intrinsics", fs.createReadStream(`render_${id}.npz`), `render_${id}.npz`); +} + +const resp = await axios.post(`${BASE}/v1/estimate`, form, { + headers: form.getHeaders(), + timeout: 60_000, +}); + +const { joints, confidence, residual_rms, n_markers, processing_ms } = resp.data; +console.log(joints); +``` + +--- + +## Response-Format + +```json +{ + "joints": { + "x": 50.2, + "y": -2.1, + "z": 94.8, + "a": 20.1, + "b": 59.9, + "c": 9.0, + "e": 3.0 + }, + "confidence": { + "x": "high", + "y": "high", + "z": "high", + "a": "high", + "b": "low", + "c": "low", + "e": "low" + }, + "residual_rms": 1.45, + "n_markers": 56, + "processing_ms": 1240 +} +``` + +### Felder + +| Feld | Typ | Einheit | Beschreibung | +|---|---|---|---| +| `joints.x` | float | mm | Linearachse X | +| `joints.y` | float | mm | Linearachse Y | +| `joints.z` | float | mm | Linearachse Z (Höhe) | +| `joints.a` | float | ° | Drehgelenk A | +| `joints.b` | float | ° | Drehgelenk B | +| `joints.c` | float | ° | Drehgelenk C | +| `joints.e` | float | ° | Fingergelenk E | +| `confidence.*` | string | — | `high` / `medium` / `low` / `none` | +| `residual_rms` | float | mm | RMS-Restfehler der Schätzung | +| `n_markers` | int | — | Anzahl triangulierter Marker | +| `processing_ms` | int | ms | Gesamtlaufzeit der Pipeline | + +### Confidence-Stufen + +| Wert | Bedeutung | +|---|---| +| `high` | Gelenk gut durch mehrere Marker beobachtet | +| `medium` | Gelenk beobachtet, aber mit eingeschränkter Geometrie | +| `low` | Nur indirekt oder mit wenigen Markern beobachtet | +| `none` | Gelenk nicht beobachtbar (z.B. alle Marker verdeckt) | + +### HTTP-Fehlercodes + +| Code | Bedeutung | +|---|---| +| `400` | Eingabefehler (fehlende Dateien, falsche Namen, keine robot.json) | +| `500` | Pipeline-Fehler (ArUco nicht gefunden, Triangulation fehlgeschlagen, …) | + +--- + +## Dateinamens-Konvention + +Die Kamera-ID in Dateinamen verknüpft Bild und Intrinsik: + +``` +render_a.png ←→ render_a.npz # Kamera "a" +render_b.png ←→ render_b.npz # Kamera "b" +render_c.png ←→ render_c.npz # Kamera "c" +``` + +Die ID kann ein Buchstabe oder eine kurze alphanumerische Zeichenkette sein. +Reihenfolge der `files`-Liste ist egal — die Zuordnung erfolgt über den Dateinamen. diff --git a/approbot-pipeline/doc/robot_json.md b/approbot-pipeline/doc/robot_json.md new file mode 100644 index 0000000..bc347e9 --- /dev/null +++ b/approbot-pipeline/doc/robot_json.md @@ -0,0 +1,280 @@ +# robot.json — Entwurf und Schema + +## Entwurfsprinzip: Eine Datei pro Roboter + +`robot.json` ist die **zentrale Identitätsdatei** des Roboters. Sie beschreibt +alles, was zum Roboter gehört — Kinematik, Marker, Kamera-Setup, Rendering-Parameter +und Algorithmus-Tuning. Es gibt genau eine Datei pro Roboter. + +``` +robot.json → Pipeline-Service (liest: links, vision_config, pose_estimation, ...) + → Blender-Renderer (liest: links, renderingInfo, robot_test_poses, ...) + → Benchmark-Tools (liest: robot_test_poses, test_camera_positions, ...) +``` + +Jeder Konsument liest nur seine Abschnitte und ignoriert alle anderen stillschweigend. +Das macht `robot.json` **additiv erweiterbar**: neue Tools fügen neue Abschnitte hinzu, +ohne bestehende zu berühren. + +**Roboter wechseln = `robot.json` austauschen.** +Alle Werkzeuge der Umgebung stellen sich damit automatisch auf den neuen Roboter ein. + +--- + +## Abschnitts-Übersicht + +| Abschnitt | Pipeline | Renderer | Benchmark | Beschreibung | +|---|:---:|:---:|:---:|---| +| `units` | ✅ | ✅ | ✅ | Maßeinheiten (mm, deg) | +| `coordinateSystem` | ✅ | ✅ | — | Basis-Koordinatensystem | +| `links` | ✅ | ✅ | — | Kinematische Kette + ArUco-Marker | +| `movements` | ✅ | ✅ | ✅ | Gelenkachsen-Definition, Ausgabeformat | +| `vision_config` | ✅ | — | — | ArUco-Dictionary, Markergröße | +| `pose_estimation` | ✅ | — | — | Algorithmus-Parameter | +| `constraint_rules` | ✅ | — | — | Gelenkwinkel-Grenzen | +| `observation_weighting` | ✅ | — | — | Gewichtung pro Gelenk/Beobachtungstyp | +| `multiview_calculation` | ✅ | — | — | Bundle-Adjustment-Einstellungen | +| `renderingInfo` | — | ✅ | — | Blender-Szene, Kamera-Rig, Materialien | +| `robot_test_poses` | — | ✅ | ✅ | Teststellungen für Rendering / Evaluierung | +| `test_camera_positions` | — | ✅ | ✅ | Kamera-Aufstellungen für Tests | +| `test_camera_targets` | — | ✅ | — | Blickziele der Test-Kameras | +| `state_pose_params` | ✅ | ✅ | — | Parameterraum-Definition (R⁷) | +| `defaultPosition` | ✅ | ✅ | — | Referenz-Nullstellung | + +--- + +## Pflichtabschnitte + +### `units` + +```json +"units": { + "length": "mm", + "angle": "deg" +} +``` + +Definiert die Einheiten für alle Längen- und Winkelangaben in der gesamten Datei. + +--- + +### `links` + +Kinematische Kette des Roboters, von der Basis zum Endeffektor. +Jedes Glied kennt sein Gelenk, seine Transformation in Nullstellung und +die auf ihm montierten ArUco-Marker. + +```json +"links": [ + { + "name": "Base", + "joint": "x", + "joint_type": "prismatic", + "axis": [0, 0, 1], + "T_parent_link_home": [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 100], + [0, 0, 0, 1] + ], + "markers": [ + { + "id": 0, + "size_mm": 60.0, + "T_link_marker": [ + [1, 0, 0, 0], + [0, 1, 0, 50], + [0, 0, 1, 0], + [0, 0, 0, 1] + ] + } + ] + } +] +``` + +| Feld | Typ | Pflicht | Beschreibung | +|---|---|:---:|---| +| `name` | string | ✅ | Name des Glieds | +| `joint` | string | ✅ | Gelenkvariable: `x`, `y`, `z`, `a`, `b`, `c`, `e` | +| `joint_type` | string | ✅ | `"prismatic"` oder `"revolute"` | +| `axis` | [x,y,z] | ✅ | Gelenkachse im Eltern-KS | +| `T_parent_link_home` | 4×4 | ✅ | Transformation Eltern→Glied in Nullstellung | +| `markers` | Array | — | ArUco-Marker auf diesem Glied (kann leer sein) | +| `markers[].id` | int | ✅ | ArUco-Marker-ID | +| `markers[].size_mm` | float | ✅ | Kantenlänge in mm | +| `markers[].T_link_marker` | 4×4 | ✅ | Transformation Glied→Marker-Mittelpunkt | + +--- + +### `movements` + +Definiert die sieben Gelenkachsen des Roboters, ihre physikalischen Grenzen +und wie sie im Output (`robot_state.json`) benannt und geordnet werden. + +```json +"movements": { + "x": { "type": "prismatic", "min_mm": 0, "max_mm": 800 }, + "y": { "type": "prismatic", "min_mm": -400, "max_mm": 400 }, + "z": { "type": "prismatic", "min_mm": 0, "max_mm": 1200 }, + "a": { "type": "revolute", "min_deg": -180,"max_deg": 180 }, + "b": { "type": "revolute", "min_deg": -90, "max_deg": 90 }, + "c": { "type": "revolute", "min_deg": -90, "max_deg": 90 }, + "e": { "type": "revolute", "min_deg": 0, "max_deg": 90 } +} +``` + +--- + +## Pipeline-Abschnitte + +### `vision_config` + +```json +"vision_config": { + "aruco_dict": "DICT_4X4_250", + "marker_size_mm": 20.0 +} +``` + +| Feld | Default | Beschreibung | +|---|---|---| +| `aruco_dict` | `"DICT_4X4_250"` | OpenCV-ArUco-Dictionary | +| `marker_size_mm` | aus `links[].markers[].size_mm` | Globale Fallback-Markergröße | + +--- + +### `pose_estimation` + +Algorithmus-Parameter für die Gelenkwinkelschätzung. +Alle Felder haben Defaults — fehlende Felder werden still ignoriert. + +```json +"pose_estimation": { + "method": "hybrid", + "marker_observation": "corner_pose", + "use_normals": true, + "normal_weight": 100.0, + "robust_loss": "huber", + "huber_delta_mm": 8.0, + "max_iterations": 200, + "min_cameras_per_marker": 2, + "per_link_method": {} +} +``` + +| Feld | Default | Beschreibung | +|---|---|---| +| `method` | `"hybrid"` | `sequential_fk` / `global_ba` / `hybrid` | +| `marker_observation` | `"corner_pose"` | `"corner_pose"` (pos+normal) oder `"center_point"` (pos only) | +| `use_normals` | `true` | Marker-Flächennormalen als Zusatz-Constraint | +| `normal_weight` | `100.0` | Gewicht Normal-Residuen vs. Positions-Residuen | +| `robust_loss` | `"huber"` | `"none"` / `"huber"` / `"cauchy"` | +| `huber_delta_mm` | `8.0` | Huber-Schwelle in mm | +| `max_iterations` | `200` | Bundle-Adjustment-Iterationslimit | +| `min_cameras_per_marker` | `2` | Mindestanzahl Kameras für Triangulation | +| `per_link_method` | `{}` | Override pro Gelenk, z.B. `{"e": "sequential_fk"}` | + +--- + +### `observation_weighting` + +Gewichtung der einzelnen Marker-Beobachtungen in der Schätzung, +z.B. um bekannte schwache Geometrien zu dämpfen. + +```json +"observation_weighting": { + "default": 1.0, + "per_link": { "Hand": 0.5 } +} +``` + +--- + +### `multiview_calculation` + +Einstellungen für Schritt 3 (Bundle Adjustment über alle Kameras). + +```json +"multiview_calculation": { + "lambda_weight": 100.0, + "min_views": 2 +} +``` + +--- + +### `constraint_rules` + +Gelenkwinkel-Abhängigkeiten und -Grenzen, die in der Schätzung als +Hard- oder Soft-Constraints wirken. + +```json +"constraint_rules": [ + { "joint": "b", "min_deg": 0, "max_deg": 180 } +] +``` + +--- + +## Renderer-Abschnitte + +### `renderingInfo` + +Blender-spezifische Szenenparameter: Pfad zur `.blend`-Datei, Materialien, +Beleuchtungssetup, Auflösung und Kamera-Rig-Konfiguration. +Wird von der Pipeline vollständig ignoriert. + +--- + +### `robot_test_poses` + +Liste von Roboterstellungen, die im Renderer gerendert und in der Evaluierung +als Ground-Truth verwendet werden. Jeder Eintrag ist ein vollständiger R⁷-Zustand. + +```json +"robot_test_poses": [ + { "x": 50, "y": 0, "z": 600, "a": 30, "b": 45, "c": 0, "e": 20 }, + { "x": 200, "y": -100, "z": 700, "a": -15, "b": 60, "c": 10, "e": 0 } +] +``` + +--- + +### `test_camera_positions` + +Kamera-Aufstellungen für den Renderer, als Liste von 3D-Positionen und Ausrichtungen. + +--- + +## Extensibilität + +Neue Tools oder Features fügen neue Abschnitte hinzu, ohne bestehende zu ändern: + +```json +{ + "units": { ... }, // alle Tools + "links": [ ... ], // alle Tools + "pose_estimation": { ... },// Pipeline + "renderingInfo": { ... }, // Renderer + "my_new_tool": { ... } // neues Tool — alle anderen ignorieren es +} +``` + +**Versionsregel:** Neue Felder innerhalb bestehender Abschnitte haben immer Defaults. +Felder werden nie entfernt, nur als veraltet markiert. Eine ältere `robot.json` +läuft damit auf einer neueren Pipeline-Version unverändert. + +--- + +## Roboter wechseln + +Um auf einen anderen Roboter umzustellen, wird ausschließlich `robot.json` ausgetauscht: + +``` +robot_A.json → robot.json # Roboter A aktiv +robot_B.json → robot.json # Roboter B aktiv +``` + +Pipeline, Renderer, Benchmark-Tools und Portainer-Stack lesen denselben +Volume-Mount `/config/robot.json` — kein weiterer Eingriff nötig. diff --git a/approbot-pipeline/doc/robot_json_pipeline_schema.md b/approbot-pipeline/doc/robot_json_pipeline_schema.md new file mode 100644 index 0000000..b816f43 --- /dev/null +++ b/approbot-pipeline/doc/robot_json_pipeline_schema.md @@ -0,0 +1,28 @@ +# robot.json — Pipeline-Schema + +> Dieses Dokument beschreibt nur die Pipeline-relevanten Felder. +> Die vollständige Beschreibung aller Abschnitte und das Entwurfsprinzip +> (eine Datei für alle Werkzeuge) steht in [robot_json.md](robot_json.md). + +--- + +## Pipeline-Pflichtfelder + +| Abschnitt | Pflicht | Beschreibung | +|---|:---:|---| +| `units` | ✅ | Maßeinheiten (`mm`, `deg`) | +| `links` | ✅ | Kinematische Kette + ArUco-Marker | +| `vision_config` | ✅ | ArUco-Dictionary, Markergröße | + +## Pipeline-Optionalfelder (alle mit Defaults) + +| Abschnitt | Beschreibung | +|---|---| +| `pose_estimation` | Algorithmus-Parameter | +| `observation_weighting` | Gewichtung pro Glied | +| `multiview_calculation` | Bundle-Adjustment-Einstellungen | +| `constraint_rules` | Gelenkwinkel-Grenzen | +| `movements` | Parameterraum-Definition | + +Alle weiteren Abschnitte (`renderingInfo`, `robot_test_poses`, …) werden von +der Pipeline stillschweigend ignoriert. diff --git a/approbot-pipeline/docker-compose.yaml b/approbot-pipeline/docker-compose.yaml new file mode 100644 index 0000000..812a2e5 --- /dev/null +++ b/approbot-pipeline/docker-compose.yaml @@ -0,0 +1,25 @@ +services: + pipeline: + build: + context: . + dockerfile_inline: | + FROM python:3.11-slim + WORKDIR /app + COPY . . + RUN pip install --no-cache-dir . + ENV ROBOT_JSON=/config/robot.json + EXPOSE 8080 + HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/v1/health')" + CMD ["python", "-m", "approbot_pipeline.api", \ + "--robot", "/config/robot.json", \ + "--host", "0.0.0.0", "--port", "8080"] + image: approbot/pose-pipeline:1.0.0 + container_name: approbot-pipeline + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./config/robot.json:/config/robot.json:ro + environment: + - ROBOT_JSON=/config/robot.json diff --git a/approbot-pipeline/pyproject.toml b/approbot-pipeline/pyproject.toml new file mode 100644 index 0000000..6e3bcb8 --- /dev/null +++ b/approbot-pipeline/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "approbot-pipeline" +version = "1.0.0" +description = "Robot pose estimation from multi-camera ArUco images" +readme = "doc/README.md" +requires-python = ">=3.10" +dependencies = [ + "numpy==1.26.4", + "scipy==1.13.1", + "opencv-contrib-python-headless==4.10.0.84", + "fastapi==0.115.0", + "uvicorn[standard]==0.30.6", + "python-multipart==0.0.9", +] + +[project.scripts] +approbot-pipeline = "approbot_pipeline.__main__:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["approbot_pipeline*"] diff --git a/approbot-pipeline/tests/__init__.py b/approbot-pipeline/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/approbot-pipeline/tests/fixtures/robot_minimal.json b/approbot-pipeline/tests/fixtures/robot_minimal.json new file mode 100644 index 0000000..84a28f0 --- /dev/null +++ b/approbot-pipeline/tests/fixtures/robot_minimal.json @@ -0,0 +1,19 @@ +{ + "units": {"length": "mm", "angle": "deg"}, + "links": [], + "pose_estimation": { + "method": "hybrid", + "marker_observation": "corner_pose", + "use_normals": true, + "normal_weight": 100.0, + "robust_loss": "huber", + "huber_delta_mm": 8.0, + "max_iterations": 200, + "min_cameras_per_marker": 2, + "per_link_method": {} + }, + "vision_config": { + "aruco_dict": "DICT_4X4_250", + "marker_size_mm": 20.0 + } +} diff --git a/approbot-pipeline/tests/test_pipeline.py b/approbot-pipeline/tests/test_pipeline.py new file mode 100644 index 0000000..939f2fc --- /dev/null +++ b/approbot-pipeline/tests/test_pipeline.py @@ -0,0 +1,57 @@ +""" +Grundlegende Unit- und Fehler-Tests für approbot_pipeline. + +End-to-end-Tests gegen echte Bilder sind in tests/test_e2e.py +(erfordert fixtures/Scene*-Ordner, nicht im Repo). +""" +import os +from pathlib import Path + +import pytest + +from approbot_pipeline import PipelineResult, __version__, estimate_from_dir + +FIXTURES = Path(__file__).parent / "fixtures" + + +def test_version_format(): + parts = __version__.split(".") + assert len(parts) == 3 + assert all(p.isdigit() for p in parts) + + +def test_pipeline_result_dataclass(): + r = PipelineResult( + joints={"x": 50.0, "y": -2.0, "z": 94.0, "a": 20.0, "b": 59.0, "c": 9.0, "e": 3.0}, + confidence={"x": "high", "y": "high", "z": "high", "a": "high", "b": "low", "c": "low", "e": "low"}, + n_markers=42, + residual_rms=1.45, + processing_ms=1200.0, + ) + assert r.joints["x"] == 50.0 + assert r.confidence["b"] == "low" + assert r.n_markers == 42 + assert r.errors == [] + + +def test_estimate_raises_on_empty_dir(tmp_path): + with pytest.raises(FileNotFoundError, match="render_"): + estimate_from_dir(tmp_path, robot_json=FIXTURES / "robot_minimal.json") + + +def test_estimate_raises_without_robot_json(tmp_path): + env_backup = os.environ.pop("ROBOT_JSON", None) + try: + with pytest.raises(ValueError, match="robot_json"): + estimate_from_dir(tmp_path, robot_json=None) + finally: + if env_backup is not None: + os.environ["ROBOT_JSON"] = env_backup + + +def test_robot_minimal_json_is_valid(): + import json + data = json.loads((FIXTURES / "robot_minimal.json").read_text()) + assert "pose_estimation" in data + assert "links" in data + assert data["pose_estimation"]["method"] == "hybrid" diff --git a/benchmark/camera_count/analyze.py b/benchmark/camera_count/analyze.py index f18bedf..ff71a12 100644 --- a/benchmark/camera_count/analyze.py +++ b/benchmark/camera_count/analyze.py @@ -103,7 +103,7 @@ def plot(by_k: dict[int, list[float]], na_by_k: dict[int, int], data_plot = [by_k[k] for k in ks] fig, ax = plt.subplots(figsize=(8, 5)) - ax.boxplot(data_plot, labels=[str(k) for k in ks], patch_artist=True) + ax.boxplot(data_plot, tick_labels=[str(k) for k in ks], patch_artist=True) ax.set_xlabel("Anzahl Kameras") ax.set_ylabel(metric) ax.set_title("Anzahl Kameras vs. Pose-Schätzfehler") @@ -126,8 +126,9 @@ def main() -> None: ap.add_argument("--metric", choices=["finger_error_mm", "wrist_error_mm", "mean_abs_deg", "max_abs_deg", "mean_abs_mm", "max_abs_mm"], - default="finger_error_mm", - help="Metrik für Tabelle und Plot (Standard: finger_error_mm)") + default=None, + help="Metrik für Tabelle und Plot. " + "Standard: finger_error_mm + wrist_error_mm zusammen.") ap.add_argument("--out-plot", default=None, help="Plot-Pfad (Standard: results/camera_count_.png)") args = ap.parse_args() @@ -136,16 +137,22 @@ def main() -> None: if not data: return - by_k, na_by_k = group_by_k(data, args.metric) - if not by_k and not na_by_k: - print(f"[ERROR] Keine Werte für Metrik '{args.metric}' gefunden.") - return + # Ohne --metric: beide mm-Tabellen ausgeben, dann Plot für Finger + metrics = [args.metric] if args.metric else ["finger_error_mm", "wrist_error_mm"] - out_plot = args.out_plot or str(RESULTS_DIR / f"camera_count_{args.metric}.png") + for metric in metrics: + by_k, na_by_k = group_by_k(data, metric) + if not by_k and not na_by_k: + print(f"[WARN] Keine Werte für Metrik '{metric}' — übersprungen.") + continue + print_table(by_k, na_by_k, metric) + print_best_worst(data, metric) - print_table(by_k, na_by_k, args.metric) - print_best_worst(data, args.metric) - plot(by_k, na_by_k, args.metric, out_plot) + # Plot für alle angezeigten Metriken + for metric in metrics: + by_k, na_by_k = group_by_k(data, metric) + out_plot = args.out_plot or str(RESULTS_DIR / f"camera_count_{metric}.png") + plot(by_k, na_by_k, metric, out_plot) if __name__ == "__main__": diff --git a/benchmark/camera_count/results/camera_count_finger_error_mm.png b/benchmark/camera_count/results/camera_count_finger_error_mm.png index 71b4e0f8de639a983ef1cd83cb78aeb381bea12b..e5d9fc1b411c1ea6e6a68b2e6d1ed42a51333597 100644 GIT binary patch literal 36947 zcmdSB2T)VryDp67r~FU^l%iA-3q?SXF2x2YQluj-6p`MGlu%R@NC@apDN+R_bV8Kg zqC$w$A@mZINR5;bIsxw5{O>t)?l<3e=6-W$?mgETCz@pM?7j9{@B6&Z`#dY=p{^z~ z6E_nB0|WE@dv^^O7><@PFdSZFJO>)$>k%eqO#xl9KNK{tXFFA4kb2mnsn8EPs35d*sW&z;zn> zd&n`f7yMQ{?*83dMuBMy<9}DDk$2VhZV0})$NE5&`*ek`k$Z(C_M*DJapUd)XF+Iz zaBhi#%bc!=RlW>d-#ypGRUCV?Lh{nbf%A7|?cyWPckXYG;Z$eT6cuNFU-WsBaeZd3 zw$!&H?Y-lZ#YY+MgGjU*HNC19@aHJF8iq^#4FgA@e?(L=f;(nl5SBf}3BBfC{&E<+ zeCRuB2L1P43d0HT5@B%0<`8&&=yLcf^tP5x#9!d$X2IY8M-KLt@))b`E3v4en5$@Y;MrA}nmHoKjkcR@>j|D!^i;{5j;u{R~H3F%2uhgL!(H zbk*ufgcw>!ZulZ!*ygXfl{QH|!#rIbhfjS54{{xntmFhZC-*lgqg9?oNjG->e5SeD z+&ZcyzBO-(OVL%^U0wbCR&RRHSPIRr672VV;ZV1txFzKfLwAi;z_7#I?#6`Y_WlNL zKXkh%KFo+H}L40X4&IYwhjXS-b zVYF1gl^<6I%Rd#=h+*n|;)r{+%>DK?A2u8OE+WEX+<(yOgFT{qzLrwjM%Z7?Q_J1o z%G`hSVUXqK?!#w)eYqm-(w7h4M0vu61gzmyyz@ZuBUygqLGS(Djl7b|5m8SCOxZW4 zxy~C)?AAS57nX(h(0Z8}{NZ$}jNfcGoU}Fw%hXk-HXC)t-B_$fFV9n8`@yAJh=&yL zNtbQdQR$hC5RVZ%q_{SZM-+iS{cEMeQkOmr7~ z`ubdgsrCKv{hbL{m2!z+0~Ve-qwZB!!DN(tFSl#)n?-Od<=gW`gTZEgg8D&y-QBNC zYo=3_6C~2Y_qOJ8;#IZQoJPu>=boRC_)cI^{Qa8uXZWIT=7STG^y zzsd1xjV(eFxY=-yk(6(Y47Vb{Xp}Z0_Ad3DaK!DdyQXL;Na}bKV$Z!zWzvKts&iS% z=;@hNAcexH70)7GQZ)~%4QE9EqqC0WK-H0^MqbZQy^m^zuYVcO1kC0}Zwq)a`9Wp6q&PA#+ zd4S!cZlSV#)T3@{CLrr2XG+Q33av{mGi>E;=10DOAwe()TxuNk$2!ejOAsD;&-LStA zzCSqaD#}kb+pyz{X~WZSw9Xqd1;~;Z|LbTOkIx-)&bbLDF2VfZ5j4Q9Fb(VGMN3uK z#V9r*jdrT4Mf6&k%2J#9k*&#E?&P<1C$+n!qu+5vQDb4?>7- zNPF9iPKf{N4j03IKO+O}SHlL$_0J#%c2O1ebll%m)qS!#1~p#m?`jD`9?w2l%jLnt zznY?E+VH>FG`^_+PhAh`-b0k$!xSd}FDe2*wT;8ya&dF?pd}ylU)|HwfnL*6WhtTMvk| zGx1j5JNPNJ&7Ut?KBGICf>Az$5bu>)^=4J1o}I zIynjQcFoa>{7sW#m8<5dsgtreuK5ngH#V6J6(|t0JXT4Ncd=m)v6MlSLmR`5W^ljY!4z9(zA-IE@ zQVdtyno-+V9Cbht_m`v8WJ*7^if#p3YEHRImFDI8Uuq6ExXP7e;Zl(MBt-kzDc+$? zkW?x+!z_x$s0wo7UEE=o)PDK?r;}j=4aK?Md?-SSxxK4Q|+5tUNi`ul@s% z9Az893hkly={tonRq2kXix(8KtL8y{qau-Tpr4NCRWjIyg#Ba{vC3 zViLYmVNQ*(Kl9!w%?AoI{` zhis$xQVxHgzGWS0jz6}f2Lj(Pe^X#d%PZ&l41gx4TGkPxSNY#g`uACm4NeBdb(een zxRfL_J_GW}HMJH&evF7ATE;qPDnYTnKs~~L%$v~RjpGlV&8nB;&vOSz21O>scjXa$ zSusDL?2uWt(kdoDo^-pOgz(?W2wD8Gfi;_%45Dv!g_9hN?3JdjDRq32?uNt4;@{S(VWIL<=^USk>dc=pSKo7E1%%B-HC3c5%UjSql9g2C@ zii%T7#Xu_tS#7B-0S4a-ar0yDdDX)yNQqjf$~9q+rRh{pk1?klR0=<(@*C#D-Aq#` z<))IvQq&w1Zt1#C?p{jV>dRA27$n=N#Qsz!!3IJ2LsR8(OPQ-QbO}R|`4WtQt_JYu zber6$+gltwGkCt>bNXK0#I8hUV+*${2wiqBK9n$LGs_K~&lKR{T41xii~Y*OlPGR{ zyJ89gH6yP*z!cgU?`^S-RP+v`ukhPDFRaiCrSBq8pIcp?O;=r4;|F0L9vZs6`d;W{ zQT@WJSFeT-f4zM)057e7&M5Mv_{)^zyk1}2@(ASW`NE=UTBq7R-Gp%VmetiWf<_N` zBB)g$xypB9hRiG-)BRW0?%p&Kxm~0XI#gzhI$^*j2j8fhaHrA*_Lifz=(k=R>sU|b z4;ZrDT7AKyT8&Ma=Cn~i|1dI3f7q~CNL}IVt&P<=;!O2e6N}oI(fczTiU|nOL;%w2 z#>w^MxEm?Y<1U=h$vib7sk(IB(;2v zy98ZD3KqjW1^C6CbmmEPqZ&4@ISB8|8M26MyxF0?p9><9ia@-oUjkx>`^GCLO!mI1 zvWNIuQ0V;>{XA`%PivlT&dj z>t-P%SwtRCU65wuUVT?TGICqyuQ=wIp|dDdZHL@waX)2?KBG2K?#-{`1)$s{ng#_M z^s70w&95vg#~jD3AK2wwOyMW^tDk4(!HdVB@}11XKqZh1Lg@uho2oubWo}K)dwu_) zGF{h|37%|pPNRz5 zfHV8+`bOhM1Nut^<4om5*p7+hb$|v|p`08qGC*+B61cFrIldH@G`JL4a`!tc2AlZ2 zY#LJ#m;t5h@>3A@7V@%Ht& zyF;>S<{>OhRHf*M;);zAflEj)nUo2=_tFlP=|{?ELT#*@{g*zqNz^J2;q`|30-h4Sv>a#B+E|QyHhQX+!!?zG`)GSx46ui{{WBLA_wyOrlwZ?ItP%wGjZR0g1lQZ^dSy4QN7%L>b@kVN$m1mpnJo__8Wf+@{=1o5UrKWJz<3p~NN2{@_GC0ztEJui( zo9oi6m#qv}M8BFBa-$+Jxea_8n_MA^u{b+A+hcr)K_u0C9AL%$)TKLY_3R10SgwYx zev|5+ky=j5(@123u?N;|N{RKB_Mfe?l8t8oeIWgXz718Q7)OzaiXdaLQ~cJ&!TKjR1W;3oJTl)#Ow-6i=X>tJW=D`em6B-iW| z)i9grjq*)u%(g9)V0+Z2G`D9g_Q7^#^WwL`M~>;kdz{Vh1oHg@Eh2B~KkM%G=ta(% zu`%6^?&qgEH27GFp!b`(-fl|w_8#;4BdsbbnB6KgK(OPsd0@A(B(<1-whoXhE60M} zPU1BJ+>9FOD{VS$l`^?SSK}0?la<%+B+zMQ>;1e?y3Tzj6s9(k#QRIW+kq7*%z)3B%HO-4s~rjbyuPB z1L^prEO){z+W=UY;rEL`0Sd#hw-jhlMOu7@feIO+V!}4dFO+8;(VhY_Y$Flvh(mX#$v}JEs&c!c4VykOI<( z3>aHknop|;O}R9XujQEg(Fgc)P!7{)6pc{K`}yro3%*xx69W0@Ynbp3!3WwnvF+u% z=Q$7Qx*AHQng*~Zh^RY!A8VEzSOq;@DTaQ*yxQ&#@)uf zHB6Vu?#g{@L+aju!THm~)EVP?6DECX<@uJX^8?QUQOz44lY0m2UA%8K9M{M!%1=B& zRic0VI{iGX-F&v`U7i@mkJ&3%Nid{HWQY2cov;3TuBE>#g7sc?R--sG zeH4scS zLWmrtPScwN_O5x#XyT9iJ;74V++x))JuEiG+Ojh{kssWP6sB@ z<}e9oL|t%mS_sn=?k3%|yM;ug*fni^JudL4Y*Zv;e}Ml5qeynIqjhuNH-X(*%^JJx z{FM~b1XLc<`2~KhS&|^t|G6R4UMho8Hb>7qTEILdJPv=%e>ETJ{2I$opj;AV$+5dw zn4S{8B^eee;*rx$+Jj-)V38MlW*cPjwOq`#Go0CY`_qPUNE3aKj89)jb3u7kW~vN6;Xpfp2IPP^ingU zEEDT+Y#c|Xskd28Cw_Hgy34`s9>IsLS>xsOImfg&``OzSDu--iuk4A6ReZ?sYnC%F z_NCez(3YOzXMWWGVT@e4o)lBdjzbu)e~p1T=N$5sK`>*#j%UDDCfoe5-um#bNYu*E zLpNtn%LLElo*W^xDBefv%8lok&BJF@ZcLi`&&Jx3FW%0Z<>TO4N?;BmbCufZ>FOUk zO);Z?~?a`cZ zy2qad(qm?ZQtHsdSmri`*p1}CSbKz{ceqe-w4^__=iElfTEA)L!V1-llex-OQ1p8C z>5GN$?U-PZyza6=aWIdWym(z|V6%PqKgrxfz5HYZLte?oj@=z!50&bX6pgcLUdb1F@x8{Q({G|5{M(&TqZ}E#n+r- zQK)2Tm|||hkj41eU13gR&z?N-meVT9De^Y)GZ$M7SIdgv?k)BsBV_ZMXRsP=c#8&Y zJup6IK694RpOS2HI&+ZLYo<)}ZHbbvV#rt7%@M_kwG1KK zxDfuuoArpMKt8VQT2WH`WN1O41X4hh9GJ!x%4h#VIiQafL+}ixSh%lGA~vYiq{MHJ z^N;gkQJfR9||&kk0KKjg4G>38iV{SE_oK@2L(jwMR4P(S-w08Qn$ zKW7h7L@xG1K7t7m_y?&{jMuQFs&aDp>N^-6eSwwY?6NmHb(hub1e>l#AbXU)`k7za zZkxusSE8AB%`OLyFH5|uU>2#Gp!0)#9Cg<8 z&#rWNe|U*FHCLsvo1hVHbtE!38~ua<93+1p2v-hE{Hu(Hr9&DQ;`=Q#t2+755~l9P|S{J7fLn?so$B} zV)jo(4r=_tUT;rT#+uc^v1`@CYRn8~u08UQ+aEVMs0#*67}Wdu#~bE9zQpQ z@^vw_cUEqz9JnXOCa=mX;?!mDx)tMFbYzCFhWU$yJ@atIq^bLx(B>mstuJBOxWfex zPY&52KA>57PDtUe>Bf^Zsjh+aUVhjIgx2e7OSi|(T$w{=OR;uMYK!)~8oAnI@yQ5I zj%(Ow96X~^##iMM$y+f5nXosp%$WR>C{&ZVtRhNEJ@E8W3ELO^kkE28BAzh@Az6|2 z=|TV~doE3k}_TOkN9B} zV`;YIL#o3KIh)j|I=m8R7jd>HhX!+S(4JcDnJwWU23wq9wPQgfVAjlFWNOTZ{4EIr zQdLbq*Eo9bQpdy*^E0*@ZuNj^i=vd_u>Q*)tcY zDZ<~90{Jwr4c3cFKF>8%B@;brC{=MpH5}O z=~aT|m{Z9A4nSnHOvWF2)oGs6`D=v#t-v!-}6$(+J686Sxb-C25`3BC0}AV{eKe z`^_oe6bf5Q=~N--^0(2;%FX`x5O=t>aZYQ(Je!Sv()4-}tp2`9*s!w>Ttr;1%-;T5 z*nJdJv0YQ{?BAz(o>MP*c^Docw`I4a`I)F+U*9x%_%681oev3nlj9>)csd*F8Of1= zdsR`%-?D|5XifEuW#@V7J)9U|Wo;xNi`z?oN>cRn(op*+8O=fQbOYCEw`9_hH|Q^S zoX_uJ*b=gd@iu~046US|&(UF+b6AW&bLb~RG64IG_nEr>_(L%^ZM=r3k(qjek#;U} zV|a)$mZ|QneWW(_kb>HU>gd9TVawGPlbIZS)Xe~iTp5+($Qv*+Wf>svq zHx0a2q%bz?oN*;JS5eM-rt(U2W%CpgJt$aORndh_R^`A<1WJ&lkWAT({1yw)!4?J54h?nsTR$d#rZHxrK`2oChD%Z0{kVFFL)+zK_4)>N?4u84oAn9{>_ z%TjP%DY!#+@HauF(0o$b8%Ex{o1mkON4m7<_qs^PnPm40i}q)a2tRKNRExPLm{Ap| zqh6tXG*ocsRF4QBh3G{!`*Y8U?>pC_Qn6A-%ZmFTf zcv3xiWWZX{zID7F(e>Xo-I z^`+4Iz5c{(H=L6fQ@W03seQuaAbp56nr8Q23-4ZW2`%dW>BnW7-^2&fAePbc!X~1? zKOy8M67#%=C~-TYa(()dOjNw+TShfk*){go+BkgtJN*S7!6%W<2?u=Lngm*nTAYE5B|pFKc#(+!$18B2@EiXY`Ppg^F2vd z3VTIyM_O1?q{Of5#%n$N#Q|SgiciS2)i2?t*wYGdejbOxHEz9!jT`vD>c6(EB3mi3 z8PZrm);x6wlcT286L;Wq@hI1WvvW))%`(F6H(JHl#G?yp#dQWzt*`A`&-K-9JtEkp zxUEVeU|(~x70_LHztYVFCG-vB#Sgy$G1Zp&2&)b12{=3s|1P%1*!h;}9xsyVXY%Q@ z6MxyyyJ=gYoXglA8te9;RBfaZuA_T3rc4}=F`IvAk2FkWa1<*e4|UA1*_gA2p*b-j zkWY2rEZh$MgurZ)YF> zmz-u`-2)QLFnnnFpA*zT>JC5IBganOBj)Mpy*S2Pxb_e_o--M=SEbhGh*x$2uXz7N z3PVxrh5Bv4Q@ES2#=}q6|U(z(jMG zf^yKo%>(_-sjiC69Q)>t#kg&W?h>rI#oA+LX6CJKCPO}mX)=HOM!grX$d3|6k?*#E zVyBg-ol^W+*W9m1od^Uw=}=356}00EoNG3rZgrRaXdoFyJGKUh`t(FIyBJ6tk^7R< zJYSUjva>vH+>fX;2kL&uz0=ZOKfkO3jFlI~A1n;o*cQ&2q3id>O3Dq0pIDTaZ*K2Q z>}SZg*l(H5EP{p%eQFA{H`{+n`yF$zB24;63!2YJ@DJ08{KV@`2>E?|uK;$4f4u*cK?n2*N7mU5_@9%FQd66@u z(*kXdNBtg2e_=|G6mR-)NOo`9M8l-)Tuk51K8b{;F-})*?&fmlHFAkVIkWs@{Mm6tI2T($|)jwYuGt2{mBUwm?SmIyMkIqceJOF zKZOl8ggfG9)PmpOlm5)=tl!74wXDWKEN{FJDJ|z8(Xa%M#h5D@_W&PeZWzd6*L1$s zT&RH4MqkXqnxG1r++E)0MnQKUu6;WpDU;4gv2{Dyj8RKiqG3DnuY!v04_|f3-Jf^v=I&sPVe4skjdOs`1<Y@zMlKR(orG+)GAG zK8rt(%`Qa8!N7Cz6|MkH@I0u85oLz;x%|_ccWVW5o^^&wf3F z$(!XkZ6|8j{7Z@5I?5X>~6~XPy1gT z$OOIX-}A-OcJ)}c(}pMPgBjo5KQ~x6WFpciWFjiJX`goZsuEJV)V?pN7H(oso`QH- z0Y)|hb#-lrsW#BxdIQ>~YQH~iBPhicrdBx;p&^oD?zhwKhR+I3KCPuchETQCb)(OF^ z&%$ISjnINN@!$8n?3wPrBbmH1RX=Aha{*QGTtCs`s2OM%F)wVq3050s^b?Ti;sBs_a)G=^u-xi^#ZxNb{AHOEso zWOZo*Hy4O?{G6XX0@gH*x_L=3>{cD5`hwI~c_!>3S#Bjt7a7a)E5PV?s9z!jRowgV zl2JIVBULxvMikA`Vy!~1L|$GTgoTG@^zzt?6_$T6QKBggkh-LVQ>9CvN}=;I!<4$V zhrraM`4i-{{wT_uCCzHLR>$eX=6z1oG~hNlaJ`ykto_FNjiUzFYx2J!7$~6k%VCpn z*I_CD;X7;1R)MZ?I9qgvV*+v2uJz6z!8s7Z+H(sNZv1q!uC*G@uWUfbPdGid5ZNwO zkKn-B5Y{*^{SprqbatITn>?I?q zVH5D6{L0SyxGmnC&!XgDE}j*jB((SNJS{GK8*ZYW%#pWMpM72AUk}HVi~0>pjzmK} zl>{7IHuLt`x`kNS8K{jlL!30-z7Xi-+~wVKM=VxAt@*qCC9uLeA?47Mkeriv8C*?_{N$vpp>}Gt{FD}cpW4EQ5aALZ*GS_eui!Jy z#mGt~(FG5}fxME&KAxYC>ZMIXg3~>q9+Tg1-2mFKDM_QA39r6<{hHfiol)lV?G&>y zkJUi8IXSy|oBThI4m;=%tVwMDX;~vEaZyaPn-I>XxAw)9-hu$$7hgN36(_%klx$Sk zKP=Et)TvBsr&BcEU2S1HO{~opv831PD-z&aQ%RoGZ3Hog<>(ZbL!SP^D(I$$#i?8_ z0kYQ59c>bco}?33tgq9QaKSZGiKz>1Az7O^#!s9;3I-2RcMi;j634Rz_apP}d?%>t zm$x}ubkEokc)Ua0V}2HH%3f<>V^&`+2fG~N~X;>55aY&nwLHm6fH3^ zvwex)x95u$kP*{+(OEn|IlGo6aio~Ff!oeBL*Id;V`37s=@ot1Ct)f|{hv|uI}_lM z@N1V}+QxuxU;CN5oH&?S@>9GohwWpB`_4v>B8P~N(@g2x7F!Ag$yTXL*(>FNs*^#e z!u(dE5Zx`FF$d9xmwe2YK?>jBRcsdxdsGd?uPe4j;p}ZKsebReTNb!D9=*My*IB0( z7tv37VJ<>b;+)is{`<`3->-V9Le2HXVpxlr*^;smm!e06{P)2C5$7nWPCy+>G*d}_ zKy2m79{8Y=#`|}n4XzT~d(FjfhBBh1ILVQp=C4xBq=YWFP<#u?evj2=YMIL z!74~ZED+BUv6;IJAYhQLM~*fX0FU((wtNUgZN8n$-k65h*}BK58PzbwxQ1gb9+qV0 z(wc!n>d5IsPTKOQmQ>L6obb8F@yrN*D8^v8RpczzlKzRap+8&GM5Y;4P>jWv0U=HM zj7rqDHR!WFbu%OQ6Qb-Qza&6dP$mIM=tEZTnk{9gsmK)yU5tb6ny2_MI`p0cc*2?^ z(-Z3($yjWsf@~wadO&AM*C`pO8@d;7jRK^Zm2@zJy1Ewq8n@wzEbBfOFdc$8Dds(S zQlcwlxjXbS=aG;Z{iH)C^*^In1Ty3eO6wMD(M(Cn8i9LTGc)JKbOFc=&pZVH1)7E& zFRCi%%D5&e`DgR+kcorIRK_W1+++=6rt*`W1S#_NpOOn`Nl0SgtK0&PxKsKC2}26? z$BP!sF0e66bM^V2)={uOssEDWkaO%)phs)33rEct-ySnLS_~<;t>rqA@?Ew~uPUl7 zeDV{eG4}WnZ@S$;FXjnEnI^;u7P<+QA#N(7@8aDFrxeYL;$FHGB>7(j(x5|g035YH z^DcIzHg#|{F26)`GC$;WuwJqgQbCVvoagNq#5Q25Ej%+z@nKGRpM{~>Lr825xcTNd zJMpLeI?|o?SCP<;;!{^Q(z%jTX#pe7a{z0G?b-SNddb!4y$4eHKR}R)8~Z6=__?%p zzEHXUTG;#eYgxJ1M7irRK%=5#$Tnk&rS_SB!CyJ!8B0wQ0P6FAOeVfBO2_!xl#cZ~ zHoS;);G>vxD_x^Gg~%UDH=@szdOeg+;OF$Sc@PF0pej(-n{p{-EV1MI^A7eWV2DS^ zkC$@R?<(c2QRdnyr9}VOzp^CqSUE$yLzcfDw@_9?zl*f%QtBZJu4{4R*7ejm0W_HE zz9nVf^1BJh)pK8Hc3zPMpqhT%@8tjEM7a`TWe6*;7XCaSF`sA;Ac2A<^`N#U_ps!8 zwM!9xH$e(&XO_QzwTfZdQI$D=Kc!iPKwgDz>innUCJF%at?(a5Dkk@&iE~$s10_C)V=6S~EfxirK<>AIZqF^V;vFFF`_s8WtcPC$6yf3Ep}2 z@yIEK^Wt@XK2`AZgRlCXL$jK^7m1t#0;r{2pd~~DIh}Cm=C3Hq={Oj4Yi z4mmjr?p$Z#FzdcrKjKZ`^X)et0)-4QY-b@U(ZZu<6^z+ODAci>ia_}K*uPxJ zI^$VSZ3)_{<^`X3dmlAhhN({!0nKrh>lRSGs0qaJ$d}$BKu24pGo09S1;ev-jk~gn z5OL_ple&YWq@bga;+1Bqu?@_XRgNZ8Q{d|nae`l-??a^$?H8U4HdZ=?e6@7v)ycVT zZ!qvTO|O7Ym3~GXF^K0^>4oN3OB~62gPEDWu`(|kMc>#6Pfk%WGg**5iFPIdcdL+k7>lht~J z^9%hTV{OIQDR9b&xc(9>`18W^1rt=cdVP!hyMmvoBoHW-dLga6Mm&GID$s^1cE&@; z0ZfarZg7#-09P}s^*##J`A5yt3GYMTS}*O+3cmN`%9lc+ z182?$epx@8>8zL=;NAZ_ygcB^{O@!Tfu)V&k(cnN47itu=Vb)X^CmUSW+Qtwc--G@ zuP>w?a1TV?8@Z6g^700>vOW}9DD)Q`I&$nIG)n_X#g9Js<|bLE#CrnB^Ev%sMfi3q zURhflzroBSceMcsnIXhEhfej-vTupK4`^Zw;}#ZhsxSLQXfjCoOF<~Qw9wr4B}Xr) z;(aceR0C-nu{-My5vZ2aL8m;U98#AO$(x|;?*+t0N(Qi-^kHDQtwurr5aXM0%eojbYn%j! zb^H6*Ft~27&dtpiVLAyLfDu6xA*RBQQc^Wd1`B||n?D1J{xY6XF~cNKzDipUh9-!p(tx8Aw&PDVR(aLc9R1p9;liKmYN%`PHwj^F_;( zNXhA@#BW92z#28SGv6UUp-n{V6n(n~t-X9o18e1P$cLik;ygh|_*4QHl*;_8jD}+{4Ado3|RJ zM$hV>dyxfv7tTPN-a)_spsm;`aQ%t4>KKSYN`A%A9r%4^pa6@M_S?%kTEK?l##CVL zbC4s6dTJ^vwD#*dc@rQ3&H=(pZ+Cm`0Z=U8G;V<&`b{pTGQ{)(pbAXqNeydt^&q?D zL8%2yNR<)*xoS)YbZ;!Y12&wZda>_4Ihr%92M)mbf*l4|Ah_2d?gMX8W{pHRc$xX& z0y+}f_Jp{Oep5ELagch%<-(x{@xk8THTIakkaGxU5@wJ#Q&_6%; z{+IF;{%?*$|0R5s|H(bitA0sQ@^{%DsA^0wfrYO1)jeG*hdhgbsZs-eEN^j%@P&#& zbV(IS3Bn6op5I>!&H+`WML&W0V4g9SK|0~`=@eHqpqZ|(&Gra|&OLx>L6(*Scbz1_ zcZ!K>z=uNwSU&|?Eae~w=7HrUHx5cK=b7}Uy5O(1&LR|sZ<#I>NBU)4i$l+=XT;Hg3-UsN;Vgx`R+8mUdN8CPl^a6cc2RyW>_>-3L5;Qe$nwnN| zm|e+)`vikK=j#9W&GS_n*s6LaT;ue+1~DoFc8BS#)69p&^LbY6sMRzj2ZwqX2yQtBm_dc{vDK zzgrfdsLy>Pz%3Zq9RXLnPw(3Qs29FX)M>*3!gUUymUaTdt8 zfH{$c%&!IE`+JHC2=pS`69~g@9mL`(lOzta(4BR6C6d9J4TS~Z(zpkZ`Howg=1UV- z$ot8{1yt<;D3I>{b~kwfq2q;G2pvase#j}r%vT;36D>iAJkt;A9|H#`vw+Fk4_5$I zMRY-w`osRQ-#^|u)*Uoq4#v(-?>R`3)*xj~^UmNv753TKdW|xO5oz9r1lxoas7>PW zlmAzO3M&+*4jwm~Mkr+IM4?bR#G_&yc^tP)Hz&Dp$t=LGd7ZF=cLrDW;CLK2bXgK- zY+iy=MO7e-Vlklw+>%kvwIag%1L?qJZHz z|6e^SCG~0jDwP9Acs|R2^#J!j*N*!?<8S{@;`0B}WzQ>p;RY7V`GFFPqHsv4j!dfi z{{+ADWGGmk&wXjgwVZ%~Uy9Q2VCGfuiX+CUZqNF4D$aZ)HH72=iX?YqB3uo{cS8>9 zogLJTAP5)r=0mY&R{GEY6iv#3P`lH=2I!X+qf8-#Wi7C2_d>lRy?C;I#{tzo1i-Cm z?7$z-~M_~ByEK{YwRa1Y}T z4S+7ng5R)1Qm!8GRy%{3p$oPCkf8-iw~cffz;Y=726^X-ev`J);$zI*GNaNE6fR|? z{5ueigL0)n9N>jT?V&~eq4m%J zP^TxeSTJF3XR3z!EENF(?RA}0IdO5|-QrV*Kq*5Zx6OfrJBlwL-z5-Sf<^8T7UgU1 zmBUY*K}~Yd=_S3jIcg3y%7_4TXK2Ln4$Cx_frnig9Li<~bu$;3%DB0?&6Pue2iSS} z=U3;}cOsdyjnJ^Y`vEMi?>Mci?X%H~1zM2gdlkUVk1k3e(GnrUV4m1IsCM)Olz&B@ z=`}XBO6XFXdS~dfBnDkxYmp0wS3baL)M-b_;CgRB-by!guXq@M%b3VGLlddG&0E`5RmPM}P0cTdnGa@iV z_xWvu4`1`tO@J(X(EEXmnZzJSQk^kU4Ry$&rmNodliu)D_sSoFgHSVPdyOJLK^6_R zsTdG~ko$-0F0)_SbHF2kOtS#im-K=jagk351dzC;?Cx@3v$guo^0HoS$z@4OCm;tu2)hkRG@GA_$-Vq$c zV`PlE94V0O2W+lW4JTEXzZ|KVFi_K6#p?!n zv7CYHy=Y_;ntS;x?BDF;6fawWIw^?B8gL;7rVdPk;FE{(5Lr2!QTTh6sHZ1ZT5Y#@ zL)Lq;*=77HbOGs>{b*Lmv0d|i8>GR12F&bHyiY*Ly5_&6>iCM2nPjgBeJ|fULKY-F zH2L$Bj#gu{5@S36RemPur)QN5f%)=Zb6D6tLN;*ur^`4ybd3K;7o9QaDHL6HuQb2&5sTEUc6Xy*%A zIXN^~fRZKUy;=o8UD00~1pSCoLE&F!pqnpg(3PP=oB-&c984z@%9Dd@`&~HD916-! zXYi!itPg&zlfe}fp#`8;?5{nzwfVdKhl>G8kq7*V;tc@X^w-{p&OCn;ybn2^oIyLg zmB>f{4$ah>15oXpQY~5DefywNgv`);6j1U(Gh&_;+IbRevEtzj;zK_-6qXq-nN%=P zKv(>GzbWuaf|3uw+griF@Lc3igFg2&FiyQ42zUcDn5eQF-+}>INCa%v!44Ml0?=2C zDh>lCbx{ivX>0>Rf9cIZwFu`hQwdquhA4#!k5k}#5lZ?M3}V|b6o{VY@&~Xz9Xz=; zU{zH(P{2Sw?G!-5R(L>YJ3_bAMWqbd>7yKU{PG~r0O*@;8$#)hTfxib-|q(65~gsV z8@d!>tOo#esQ&6K#=;B9E?pgXpEdK`ez3s@%N6b9hm> zKq_mA7s!P6LfNWy10tWj19pm1U>;EcViP(iPRXTFA_VM)kQ=YI7x*mxZ*V0MD@EXA zsBi<#-qQatk`+HLr+x(cqZI##_x!=$E_r|y6HO1Ae*bTC=gYVL+APS*xd3+in1ftM zknm<3g1;c`y#rmz{>c?cyHfEAN^&=2{9TKC7k43TPpv;iZj^MCygxljR|o}LLKN`J z%Ejg^U#oDVHX&5bOgW{xy`7D7eih1IS>;b z0J}40`T&{=W5F^52GW3oPzbekgEtn_UorC}npk?{c`X~3zny?OINGL`-s;?HVenc2 z(TS%YT-7hhI+y@<;6a2zKG9{DE|m>8Z0kJqpg}%=ybaP_+QC*Pkh}Ze7R|r`{|h2M zXsvw&=aN(bC2s-Pc0yc&_A5#N4ec-l22m`^YAru2OHe>J`C8V%9q6ZK+23pSuisVx zkz_2tp<-FIISAVh;DK3*?ZK7!fGf#fGS)tmmTGo65t@z>g~!Zz+{$j%0$PU%4CO=f z(kv>g54q(%EJwVWm{ksVVI%o3+tc#6_aT;$UguzwBq%j3#&CoKL&IaGt>15npuLm{ z)xk;scGepU<%Up&(hKb9H2z~?JBNk7D$fZzmrf(JPZ3n)!oecAA+bCfzPmO^%u`(} zfVOBF6K)jevhvdhdy)YE3Pi6euLEv1j8Y5^99ec^Mk z^V8)ehcY3+qfUb^$Pe2-NDd`Y{quP_*b*TRY|#)9H{}6#On|l)f&^zX*W7_ats{>> zg_<$Y4m>JAGX|-Ya>sHnf>Ylt@q0Y2U%^xib|Pt;gNm!d%2}-ccfqKS6$9pCz|%3| z;ye7gQ%Ou9BXBZC=hwg5Fd(U>kU(ET25f$GN&qU98DtsDwl|hC6D@syxQtuA6`=n- zJ_ktO2LN<>`u=5RacdATD=G0y;J$s+U}*t>sR;P#1U>cqM-d!`%RqCtXOw#mIlM_F z6M;GYeTca`fPU^0JAHEOsF1`05>wCVIgaxZ%b`y{;4oLLWCI%0(*@Ch77bvS_z9?`}n2Z+cg`f&gRh8*P zV<_zxsMXD_o&dso%jNp4PKjyEA*|8P!oC#GY`n~^o7;z zje#~vTv}>tRC1?+&6fz}bSdo&<(mFwNF9^GUgUj{;qrYVIKFm4{RD``2hzMq9jMia zoKb&moMswn4%Cpr2eQS!0z=;~P(O57f(DT0OcU5GC9jD9MZ25K>$j`HW5`P|F)t9U z;s~uY-wpw7aM~7>V~EZW_)#tSA4%d;U(mAK@=CG_C}<4@=Qt=g!1yF>d@+4O;Pus3 zRo-1C5&;)S^b0mZrGs`tkon}H*q|Wlrvjo$Bba`9U^aXx_@^Ki^0LeL{QkZQ z2>m>W2m)}#13dTuEojA&IDP{n!8iI}S#bReLL?ZPM%&QNWdT5TsmvkCoworxtoSJ* znZlPKdEAIHh3Gtp0Gl8F?0BjYOp}l0!%tHCA8SJwZ1t$B#p$T=UtqJgt`Nbi9ujd? z4*yBg0L@ML!ER{pJ_Z*~8!Mpr?5`Dp{x^TGKc)I$J`{+Ri9$H&0MW5+4L;* zrFS`pUFZ7>oFR$@lG{Qmfe{cAdS7x`5BY`h!oYSpy=q`{2$xd_N&7U#x}&ftzkD0o zECZ^D`~Zg~zwoB)zm@kLKvAXNo{fpz0P72VmXhh3Ig6Gz={gIkyf_iIJ@!Ex&ch;`(M9zseY0Z=dYaLsS7RM!1BjSV; z)9@d!MuL_kWnXcx>uRwbGr@u=QWsJo!eEJu3{ebij4c5QxVRBDlVzf@>JvIpeJ1BmZHN{G*x67@HR%T(ke>AlYm1TN64?6Bk8s$dNt6wQ zpIc=9{ixfA3rkwoj-guD#P>T~zp4?|$36czcMw_PH7<@(I#vSy*>mvz!<&xvS!k}zc2ll$1lq8mg zeyJSKnZfJ%KaM#*!*7#t@k&M%AQbM0;_@B}oVC)B#jxM!pR5pPo`GPsitz~>Ke1DC z+(k!TXnS3a7NI>?OjU-iQdalO$3p|iJ&K|jzsdsov#?1@TYgkxZx`h=l*E56^)L+= z9*vAv&>FjH57UR>^~UJ)mJaAi=eHweOJvQVDi6~yP+4k}0BRd2F@t_iB6MEO@U#unY2tEkO6&LYkHsSM(NKaJL{7v)DO>(ru&SG zO?J0XKgX&6gO$b$)uNv(#~GYV!`go>pGsH7J7}U2o#1Kqbe+AGcBd`=jQ?QmYM+v@ z17!YI!2CYtz)o&X5)i*b2&M5zJ;xvG#%R#Sz;sJw1;oRJso))>k$RKO_CIr$VpRwPxin{0lyL!buRfv<4@TY% z^(Txx#tjRSgDrPm;YuCPv|^xA9Z0S zuC(9|0(x(E97QPFPP(`MSVtsw=V1R``L@|IXdj{#<5$vY#d?{H<__5LP(SqF_gX_w zsoPI#{ehX;YjT+R7^mhmfIns2ZxAItNxKcY;q z#738Set8h8Nxgp3L*HB@fL%t$xDFhUi8n#?rP}Z_ZNk;P8Py6Iz*t)HsFI##<}%+C ztDB7)LK_TY!k1n2Rof=SiCYL+YM=hHhA9YQqZo`_jV+b(5Q^ht(r;0@G17fHE=Ie? zz8@xvTgihd%&r@zKWn>ZhW&jk&oa5H&%##W-AFcqE$@Ynd|X0siPk{)e^3EOO;<%3 z#)e>=4gHfAkh|kqpq0vj0$m+T!evRN7{WfvPq{|{E?lsv`$*cOgcvpCcGc-6?xRPh zR6lw;lr%Y9Y$ciu6{FucWqdaXaW%VUWhE;JkTeNlku znnOlu=V$?_8Q$6W6*5TGfzBEQl3Clyq$4Ni9vuNFH{{0hu&MEE$G%G`8Cw{`owIo%9UJ{6B5Xwifv;IWp@Y75Vdi%UF}yjJ$+h5dX4 z$k5Z*+OehtoN+34Flw0|$RJJ$E;D=K|W;OfXkGyFtscoB0 zFSnhFH)lCrW!d83(WXbvzFZa98>nbNn@6t=8b$ryP(gZd5?l^ty|$hpp_0DfjIWt- z*+D6h0(0aXQexybNRaF123$}PzdU=z0t!QOeI@uSp}%%mSzLnQk{#$*mHy;rp&7J# z+2A8Lv;(?%UZdsE?v>8eK##tY>95F7LaG6Z7CjoNr?Kwh&@}`$nG9p|URjdvir>^6 zCb=C6DqRRuDu_#2Mdls1K&ES?=L9%XtTljCLoG0ZtT0S@O1XrNcnhL zMKS8r!~$Kh9Z#9DL`#EW<;d6osao7U(wW_()@y9NW6P`BG0CVgd4apCu^E?Um(U40 zcxtv~oQ2+@Q8c>GZy4POSNcTt{xI%*2vU=(hH_1r#9W|K?ThjyHy@ccBWTS}KpKBnN#u~BpTZZnsEN;9s-tMC}; zta)4B{~}`)O8-}dDs&z$jrLa^j$y022CvC?!_&tJ$nB4;-DzWQmoDt&E#y9Hs-P-y zr(AR{uX7TZ)Z3gc&%-C{n8rVNZT>KbvS6v?aGh_|Nda$5i_yl_EEVZtQ_;;*&=`v%&Uq|19^DV8NJMO)+2W z*vOkZ(%t~<>90ukk_QF6iA&LBJe6S^PWQS(@@p@j?K>r@O84$}Lwqw<1t%v+mlt?! zUyzUCSxO_7&;oueL2tnr1O5Ek16Sd#QKpxSg zD`B=b^84^7T1xI~479rZ!l$fu6ip&qX%se??Kq;mKHqiD=b5axe}ukB+>Gl0qMF$_Lb zdV&5P?72q0Esyq@`DX+D`iQ2Q6iKiikx|4W5B9Zhy*gUqIXt%eLDkXS10$y zlF&oLC$NWXUqAFhTdBQ;K3S#Wkm*V%y=moe-Ddck)fq$ z%c{^9(Zmjsn&8me=E>7U-so&%j+PnR%Bl_fbr_xu%EP)h87lTsIhwUrnOLApD&o#G%lLJouR{TqA4D^OhQB3+`>*;!r zGvZzbu5g=rvP#gVtIySqI|0opf?Ba3Udk?_~Z%xg!_n!uFo&sLmG? zMiA<9=&zLsjIkagde%woSF0kVp z>U7qq1pucl(WmU!a?%0%{|&J{5uf^Ppd*0fIJFZPRZIJxwa)XYdUTYCkI7Z)OyvY= zouE^;5hBp)_WcY0fu4QNZul4UtV5MP*>$Y#zt~$D-IGG@;SM7~||gOzQMmxK6bjno4wzmEA6$QAZUDQ-A! zr=A6YD{Lg25_iaca$b;2ebC&ZsUoYFN@)FsjmczwyxPgnG+9P+&>;BM8<69QlIL#_ z*49rEuB6Dl+)Prl+EPydGnvNmnaDa{E0KiqTz`%NeKXT}`6M+RE)U4PP5@~CSy_+y zL=>_SC2r&Jjsyv$9+Sv}+yD~IzW_1rdmY*UOBaoUcK1LeMGew@)uj3%3sA5w2)CMS zjs9m-;9zreYq@nWNGlH~Q29`6DDkNQ{A=HP^Gh(`xoOyiZ}8Rze8FWgh8oD^q;JG_P6hEAxGGrV0Dbi(f?D zsIIpVCArKs+rkolq_1bUCB>P_8Oq-*VDxnPce3QSx(e!Z{yjY<vL8i4>)1C4&>=kzAhitBf|~+4;R5@3 z-Dq&<3%b@wbaZi*1(Ps=L(O}ln*QcyeL*vDo$Gfr3>6=5F=|21pTqFy<9W9O{F+0- zK!{uff4x)`qO7(fh=o2Jw5H0~Md(BS+VxL*-KZpkQ$@{Z9jfo!QPZHB5c)6zP2<|Ww4{yE7hSKWvWID}Q1aF!H8} z#4yL08dgV5!}nt(hjGli0cH6s=b^t@A2v9oU}@fU=pGzh-UMK$@V@I-%vL|TTzW8UQ4 zZ*^TQO?0Xg#v7-qg>U2mpNUi?16YwTVW6vFeGUXMj&~$(u*%-3Y)n#ssAI;ZP2K}9 zW|nNp@sqrPkN?JPc8c25Z>>9|60g^zY#lj~P^7a+bXrYBtG-!U*UlOepqZ33Mex^7ENC`X?CZPVDJ&AV zTp*I9|3vX}1x|e3L{D&{4;0bYz44(vjW+Im{m3tOd1tU{RkcS+_)4iYRZH_exXOmC z;t_t82gY`-k0F8iB2fH`Fz^&Lc4Q}E1G;-&v?XHjZ}(M;CbUs!KZ_lE9sHac)J8~M z_fA`3Ic%}o;qBNIi+V;VLC~#g{caci^PHv;Ik$dmP9hJ>Tf_Ut?*FAP;dB{~lUKyn zOFQ5k+*keUHBIAb$x|G@gBZW_!z;y`viqfq0l|JABP6_S8VMJfcWjV9Kl=}~JbjH2 z8P%kXP)3;m^d@ibHsRn?nyixYy#pQTc7orOh>xQDiXT>&2!Wmb78mL=C19aU6JM+r zJ=48%Dy*3%suyEV0+8It|Ijz*5B@nTf5|J^nwLqd?wF!Q{VO}M@vOE(?Q8Ofu_hTz zGir>6Lm;KpaLSTkr``PVkBQEP2JRMQi_FQ~vTV7-E$gMVRaJrR_cx!Pn4c{*JXs5H zm4c(cm(hwXDZ8^9LJ$W^H4PiwYe?r&wQqx8<}@5^d3gEhU@($NDh}OYvHIfd$0xKM zbVe2P+QF@veP1K;--xd7R|yWCduU5(8b~hv{k5zCYZSS~UGXGd(bcb@G8CUI&*a9y z|7_5+o{|)9Il=mbOB(9lHJb12Y{lH$W228YyQb;@KbZROs)17e`;%WXgEj!C*|#G- zCedd?@G;plKyNkh3^_27DA@5TELRQkW&&hLUk&{44T4(wyH1SNI4!Y`jVcQOs!>{_P`QE? z_F%=?>E8V5*e+WOuwXMxi)Ou!iIC(AT-Pz1wW|xYm{Ufeh*5tI(Vdu1Gqh!WLdIOV zL#j$mPbG_Z*9z%1*%1=j8f@RV-z=5h(EKNebo85aHno}- z5?nKqIRI)d`g7eMttF{+z=R&8>%O;Tetz6a9)s2e`KKo4okZWI9*uS_nz+l$eO5bk z_09s_QCyR-PZhAZC>sc@Ht9v;ixNQnQI`95c#Y!QtHMJ5GPf$q*|QI4u}t+(v*NvY zQ`7WH4iQeF?j~Y4H4>z}X-tcph1`hu5*Rr^A4v8q1GX}ArNxu$Uv2gI| zFd&S?cEM^cMkr|I;nXZAB`qj8PP>xux*aY!l6!jzOVfNdWfxp&N(=JaO(qyQ$@g5D zF8Z({H?ivdXDF@p?bC(RCyRBiYZ%fZX#1*v19Z~T_C|*G76Mov5@S@ad3`OAFtEE* zdTY}M%!FuWpYAj*v{c&nY3+t>Q=JwOioHBLKUvUQOKM!?g4D!=;R7Zc?#J?Szl&6^&+A?`@MJN7$HS9hs z-oY}G)59p3nm1W7P}j|@vozkK=co5zf#l~UZz4$2l6lK%@3bgcNN+quO1*Sm%&z|9 z^Pj7&`vq2mH65@k(q!y8*Hr^gP*+7rvE!}^}pvV%QiVaC^ z3Dv+Q4;9KFWNsEWqA$gv`cc5Di|ycc$J_-Tc-z&MZ6@K-`elJCQH>9Ue*UqhHFLyy zzJ4TsdD}7Z{0qPlPp@wQCU+ix2eGq}x<%nPenP;m9keJOF}F6>sjv{pSFNyG=RIPM8hT zT=Z0Y!>oQIOP}ex4wKL=3DN#ee=hCx**_PiK8nfq?T!OqEH5ghrqW(HyWw6;&|grX zo~%-Pk;|MX0mI?_?x3@r-$^|=Hz~~|&o@_v&Kc7r^U}(WT(do)+$kU28~=1DA6;bi z$qt@v^%5!0q?1VrcgL?1;4!#SVdY&RlzZ{*Dp8R)zAV3_7%7hf#-Ti69VAdLn7I); zZG)Vx`5eK^ph0U-AB3=!(Hd4GHmWsdK`!=MMtn{ulJaYUxjR$17g;R6t1Q2XHbV<# zHS65olz5CS-F0Zh`haS^UE`Ma197HJK3&l-T03<+_OYB4dlbucDz-C>5{acnHE5g= z1tr-ibwOQNa^l9cC@WVLH=Jb~y`PSQ8+$Os`OAKnEA_GU9?ayu#%WRDy&=PLQB=TK z9(c^v29sGzKgKWa61m@Zg#_V)&UgMSiOb3V3zbf3>gk`e#Ce3(mQ>x!hmvV`&Mj3> zH^0IT%}-Yn&5p4!6YvU=n}p(I8`jaL{jiTz+R#tU%$fU#9Del07MBW~A9^M2LjkEE zJKw&M_Es7xi|&0GH)M5IVrvW}jreS@^bPlcHr2!mG-u#6a1&Q$VDfXqQ4_@{lQEAB zcdmtt%D!DmqNT)ob2h0v!XIy7NCx(bKljON*iw~hh7g}-o%FaG>}2oJ`#yD5D}FKs z(jGQr&-dp$b<@QMbY#TxOccCUuaf9`*uB7c!1m}r?ikBy3Qh3nO?_1QX9-J20y>tU z+)PW78oGtEI&8*;?l||7VcwMAx3oX_^sNa7{V+dYuCvZvy=i56_p6RvKF6tu^Y#74 zJ8*y!b6P84*Dtq0P25MFjT6eZ=Az&ukAKn^U6RIVLE1O!^;KHBnbt|xHa}EEk z!r)KAgqtP#?HjY+x)I=__Ew(6kF%%M)L7+v^E@!UoU{RVx-?SXF?oN*OwMg%C9}hy zu1pK-qoHZN)UM{JJdl)Ce>*SV^5mXW^&L4Jjs2d%`GxEQUbzQ6j5@B!$tG9@`#2{! za;LaU$G1*O|MGlN=@9K?t^%jgJ~`Caysw=(t-)vNoZV;3bcE|fnhvVRMg7U0&#&5cdcVN zV}<+9Jm+&8?aPvt<)|F za4dG;jhA`2Mqw?e#`Q6Y?cFRG1tyXEX|_1i1=?;tLU zH+ z;jC4wl{S18D&obWxQ^G`r?+LlUKua#wOuOr;^v5Ax6$rO-qgOfzJlZBxQREj22Ji& zZrUfK`rYCH?p!^7U*EPHR!iZpu+cvZ%?U6hC${Ur#IXVY) zIun&9SRB()ROtRq=S%;)pAHmcdwl0nEo?c>^3zT*i{F&BE6S4rkE$2kDQL} z4o-hr_RP8UT}IRc14XX~l?7ocdWyxU1VfDsWB7lP+{|hZjqNUV2(NyBt8cU= zu%&FcvG5I=VyvTz4!y5Qa??};r-JF0WQHwq+A*3W_eGHkzt#W(#C-gUuAs3PG@+|+yVb86#!C1rUG&JvOgk(cYxS7rpJ z>Ih9aal$DXjq*%|*lJQ}#58W>QQB(wwOQ%wK#(zaN=M)TUznHvAJy>&Pxfy_ML~u}orxSYJDJfaoX6%YxZ>sq(Z{laXTWs8}ya`4^M9Fw$!19#t z;^p9W#>gpnsBZTO9B><)dDTXLw$MMX!ha5wVeZpDKMscD=~X!J=WTgUb!2eXjLi8 zYRjS`C$;;r*bI5+uHS5lrSzV-d7=TIW7Xc8b-Q{xnxQmY^R|OhN^jplqNEY2UP`Zk ztdNY!kCTHb*kdj9Bf=%JTK>zt@pmje1gt!6RUOO(VOtlNd!R$3A4TK25xfY!$M8Mqxfz#`lPsD=LvN{o19Boz3C*AvZN-Ccz%kh%`*5S<^ zl~q+$MjacR7ExRe#np0vbeR0`)-0DJRSp{bZNxM31x-ni>;P`zwUTQhWii6T7c#(y zl&PtI{+rOp#j#(kqI7D?@<6}83RG~_?U(=Mbr*L?GE~$5Zld*rSsQ;`4V=~ZC-Of% z_*!}`%u9?D&;W|UR2mIU#=&Xg%4^BT$AyeFexN%niAGm)$)rzyTgHT~GPkXt##FzJ zuKZ58v$23+`(wz!IqA6ri8N$5niwqF!4grWb0HLnA1DpukbC2H=)R{7U`dTT?(QDX z_x8iqyPv3KjN)>lqw%OMA(h|jXw5*m`KC~6aiRXRB4&#N+onm!zKy19NeV~)){cE@ zbwe)=bFU)pW?dndcE}elVIj(-b>TDry79Xpl@Ux*1L+?bl8%D7?h%ffCceOlQA<-b z)RRqQ8WdQoB^r(vNyjU%d>O_#FA^v7?01BEE0T00Ag65~awxGNn(R5`-zOCTlzjX1 zySLZk2id4IwV@U{lJ))+UpXF+J0YH5-WdzfRAUSYT4ekQ)XjnyxGr_S%~)gp8R0`%a*k&V8>x{KtpZT| zA}@uf4>fK5PLhVz^PXQen^ijJjz{l{%%f}L5}@hX;4)DxA||&QN5fe|)9by0f$9WE z`9PSGFt963_ZwC56fJAAYKDh&|A!j(Yh0OTSL9Mn}+2aSMG&}V?8-e>47sQ zhCnr&6rY4;f!a5ZBrk~J&+6Oe>Q1#1u%UQ`M4u_n6k7UX-S-RQ#x)*!yEy;Unp*I1 z%Z|O7V{X6*+U$-H{3<#wKieIrCWp4+w-E>(%d0FnndJm{H3|>j5-Q|eeRVZ#fuE5q zKlB9rXdf>fviKf%zJRFpw`o<6w4wj)^S{$m@CtYT7Ug$&%r%k=_qKpxfpXK>=mc>8 z<$uXPb?iI_l0>%xWOc{9Z|*Fe4dY(jrnTUS8_7kfXoqYZ(_#t{gODqbv<=jdRJg4z zrLrNN>#|d`){kR3c~SnUeZ%u6s-#$mKM5&kG~X0W&5(p}o_P@9`;y9)@r}StoE~x_ zhU{EGo=|dhZx4AHy!M}-xLoo=#81u|g{&4+J>#=vaxyfNPbb2>Akcu<<7k=BSIWQU z_mAkBYYO(SrEF!<@@oRoTEWP2p$7h@Jc6k;;Xzi?rg?WGnbPFu%<7jDuW_bZSg}+;n5~LPmu15Ton4PwzaoaZ|C}FGm*YAOK!|k*}VtTrzrxG>5)=f{q$1o8P=}FXmpR;SBk&3mwGB*j+k!QXxcPU1j!R$ zDUVpgV+cF+K$aZ8fmY!q6@d`AABSA}Vtezm{Ul6j2f@(fKw>(ufPa=dJ3R<5ICyk? zaiAocK6;Y-p%RP)lDquGb}n?sP~iFzmIe>$xcoyQUc=mxIl`k&;V&iKil1Vc*Ujb} z6%`RFr!l0)0yC7-D0rGl9!CYMMf)2C*YsY0%=?>_aU+En3~uFJ9@x@(zu37e>C3t6 zK>tm}OMi)4$s`C)7y%1a)TmEns8iIo@BSFJl?qAN8|(pw;YuG@g$`+iZ2bD^K?!!y z(%9Q|Thoqw+TV=^@1;s|ctZtct}Db)RJX~NsU9ou|0L8fiwq0d^$=x9GxBJ{0I}yM z<6PtT$+@6hDGIfMYRuX!50F2OPqo*hi_OvDL%+iIC7j;YVk*C z_?|b3ul)77vZ*~F&sPDq$&`;MiZC7v#rIFL7wf%03(4JI##MqF9dpnMw?ZmQE zh@_3mFD&n&1!wb(nH}t0xt(J5EB4Z9gJR0ak5%OP0kHiCZ6n~ z!DKy=un~i1Nu?k;S~51G9i;4f)Xbh7cMiU-5x^LUOVa}F#CFbRET;`=n1=qb@BmS$ zO}^8U(!9)9+>Ckd*K9ci3Lu)cU)er9xGjB%!QqV54nqG(tCo#kyeNSEf)ends=6uR zM;lo~G|Iw}tPA3nEVa(%CIvN8S4Z&NtybOLgD?pNceuc)$rW$87d8Yr~GrLxVxTrFQ20U4jV7N;dNprx34z|q3NYYBXjtExf;0Yy5 zvSwp5*%Op=jTUdb@A4_atqaAA7G+#`@$)&jJB&2|izT`?inN5UgXETeSA6Mkv z3wh}J##Q54uhO@&ysr{;60thAhgxY4oAA~?HYH5?1~#f)Q)F`ZTf&3lmqqqaTU=Uo;FYz~C>m6|5q!{9HNS(Zw;7?5D?nm}<LWX~Py z|5}5^AWF(v%qE7o1ybZ7+HmX?geu(N45LC>I2$faJd zuNm1bx8G?YuO+X+$@4YTEU`UMc-!@?n81o7jP<&~1XV#vn4^U>qj6l=D!ysMNz(-9 zh8pcc2bYZb&EZ(|yQ+9^`ltf9nx!h<-`gTmp%` z8uKTxc923@gM`+fll2t!<)Zwa_9q2S64tHcSMJUimX+WP1o_o(ZA4MW9yn%YY^-sR z=#9QN4`Kc%%^B$JWNj$*&k)6*ceKs9)>7w%fw~&b5UXW-Mu8 zpPug)bQWCOe1G|*b#BLbSk3cjd*{*socRUBtACrN?|b00pv9KcgO%eFtgGrwRxcGO zvyELOxV00U#8CIx_pY&W)KJQWE6e&vKtdKbZnIr5!WOqkTOIP{BHN77`(i+NNjbW_ zJML;v2T1ZIEhFlg8Sda#8(lebgMp*;M0bi;8_0gyC)L#fZlSbg{}e$tc?EzW1_2a1 z=Qv#vw#s95=`M3IYc_W;W*@i+9woUhf3hmRSxbsk+!Zp(*Yc`}`^3z)wG+5t zR~PDJJ93YbDlAJKs8s6*CP0<1VEYua|!jo=sin$lI&D^TKyaMR|Je zMm={ZDgVN*cJ=kcI(|hX=t)W%-%+bh91Dz7@OSUtvw6`Ty(?SNswv7>>OREDD{59! zx7)Yxj3V$X`Ak`zTf(|S6tJ(X;>9VM!TL-79Z}&1s3i$)P-YwJGI-Y zBTr?$u1FLFKI3J***f4gWdh4OI#6Wxf~CV}rA97&q!^=32*?em<<`foFYOe*A%b@1+S>cqQ=--PzHp|q(@ z>bUSSjdxXqjnr^34=ZgUJ*SJJdWBSOE%0V*t{%M1b=Qd%6589>0gzRl)UJ*tan`TI z?{2a6efCi>i18!zBNaeV3{KG4i{a`kyIadF2=(z z)>F`cIsoX)Mt`?dCST2K%M)CGIYE@!PDE<#b;1OYBv?ENHaVJZ|>h5hCy?2J)kYTwLUW<=B45-q|c1 zX<1Wyq`>5zJf=x|gnr097gN2LMlCVUr`EV~a(|{`|A@rirZ_mkZ&xR%!xGPw4{67S zxzWd;k4|P)s{@zX^|GbYPTyR}cmD#`5FH#HU&2=|W9mF~3idt8ZI38Ksn(ne#JgzP zlVr#DHp?W`3cIM z{Wdq}@dw4r%Pu*56TJG&DvjJb`R-F$uj1`5C3YG^uS$y5|0>YNv=#r4Ch*-``E88V Yx<78xr1KL}>FaIZqP00`qs6KJ0rB8Za{vGU literal 31018 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 diff --git a/doc/separate_pipeline_solution_roadmap.md b/doc/separate_pipeline_solution_roadmap.md new file mode 100644 index 0000000..806ecfc --- /dev/null +++ b/doc/separate_pipeline_solution_roadmap.md @@ -0,0 +1,266 @@ +# Separate Pipeline Solution — Roadmap + +**Ziel:** Die Pose-Schätzungs-Pipeline als eigenständige, wartbare, +konfigurierbare Einheit, komplett losgelöst von Simulation und Rendering. + +**Input:** Bilder (PNG/JPG) + Kamera-Intrinsiken (npz) + `robot.json` +**Output:** `robot_state.json` — Gelenkwinkel im R⁷-Raum (x,y,z,a,b,c,e) + +--- + +## Status-quo-Analyse + +Die Pipeline ist bereits **de facto** sauber trennbar: + +- Keine einzige Pipeline-Datei referenziert Simulation, Blender oder Rendering. +- Externe Abhängigkeiten: nur `numpy`, `scipy`, `opencv-contrib-python-headless`. +- Internes Modul: `robot_fk.py`. +- `Dockerfile.pipeline` und `requirements.pipeline.txt` existieren bereits. + +**Was heute noch fehlt**, um sie vollständig eigenständig zu machen: +- Kein eigener Package-Einstiegspunkt (kein `setup.py` / `pyproject.toml`) +- Kein stabiles, versioniertes API-Interface +- `robot.json` ist zu stark mit Render-Parametern vermischt +- Kein Health-Check / kein Service-Modus (nur CLI) + +--- + +## Architektur-Entscheidung + +Drei Interface-Ebenen, alle parallel nutzbar: + +``` +┌──────────────────────────────────────────────────────┐ +│ REST API (FastAPI, /v1/estimate) │ ← Integration in Robotersteuerung +│ CLI (python -m approbot_pipeline ) │ ← Terminal, Batch, CI +│ Python (import approbot_pipeline; estimate(...)) │ ← direkte Einbindung in Python-Code +└──────────────────────────────────────────────────────┘ + alle lesen dieselbe robot.json → robot_fk → pose_estimation +``` + +Der **Robot.json-Vertrag** ist das einzige Konfigurationsinterface: +- Kinematik (`links`) → *Modell, ändert sich selten* +- `pose_estimation` → *Algorithmus-Tuning, ändert sich gelegentlich* +- Keine Render-/Sim-Parameter mehr in der Pipeline-Config (die bleiben in `renderingInfo`) + +--- + +## Paketstruktur (Ziel) + +``` +approbot-pipeline/ ← eigenständiges Verzeichnis / eigenes Repo +│ +├── approbot_pipeline/ ← Python-Package +│ ├── __init__.py ← öffentliche API: estimate(), estimate_from_dir() +│ ├── pipeline.py ← Orchestrator (run_pipeline.py → Modul) +│ ├── detect.py ← 1_detect_aruco_observations +│ ├── camera_pose.py ← 2_estimate_camera_from_observations +│ ├── triangulate.py ← 3_multiview_bundle_adjustment_v4 +│ ├── corner_poses.py ← 3b_corner_marker_poses +│ ├── pose_estimation.py ← pose_estimation (unverändert) +│ ├── robot_fk.py ← robot_fk (unverändert) +│ └── api/ +│ ├── __init__.py +│ └── server.py ← FastAPI-Service +│ +├── pyproject.toml ← package metadata, dependencies gepinnt +├── Dockerfile ← = Dockerfile.pipeline (schlankes Image ~200 MB) +├── docker-compose.yml ← Service-Modus, Port-Mapping +├── README.md +└── tests/ + ├── test_pipeline.py ← End-to-end gegen eine Beispiel-Scene + └── fixtures/ ← kleine Testbilder + robot.json-Minimal +``` + +--- + +## Phase 1 — Saubere Python-Package-Struktur + +### Aufgaben + +* [ ] `pyproject.toml` anlegen (Name: `approbot-pipeline`, Versions-Tag, Dependencies) +* [ ] `approbot_pipeline/__init__.py` mit der öffentlichen API: + ```python + from approbot_pipeline import estimate, estimate_from_dir + result = estimate_from_dir("path/to/images", robot_json="robot.json") + # result.joints → {"x": 50.2, "y": -2.1, ..., "e": 3.0} + # result.confidence → {"x": "high", "b": "low", ...} + ``` +* [ ] `pipeline/run_pipeline.py` → `approbot_pipeline/pipeline.py` (Module statt Subprocess-Kette) +* [ ] Interne Schritte als **direkte Python-Funktionsaufrufe** statt `subprocess.run` — + das eliminiert Process-Overhead und macht Fehler besser fangbar. + +### Ergebnis + +```bash +pip install -e . # lokal entwickeln +python -m approbot_pipeline data/sim/Scene8 # CLI +``` + +--- + +## Phase 2 — robot.json bereinigen + +Das Pipeline-Package braucht aus `robot.json` nur zwei Abschnitte: + +| Abschnitt | Braucht Pipeline | Braucht Renderer | +|---|:---:|:---:| +| `links` (Kinematik, Marker) | ✅ | ✅ | +| `pose_estimation` | ✅ | ✗ | +| `vision_config` | ✅ | ✅ | +| `units` | ✅ | ✅ | +| `renderingInfo` | ✗ | ✅ | +| `robot_test_poses` | ✗ | ✅ | +| `test_camera_positions` | ✗ | ✅ | + +### Aufgaben + +* [ ] Minimales **Pipeline-Schema** dokumentieren (welche Felder sind Pflicht, + welche optional-mit-Default): `doc/robot_json_pipeline_schema.md` +* [ ] Validierer: `approbot_pipeline.config.validate(robot_json_path)` → + prüft Pflichtfelder, gibt sinnvolle Fehlermeldungen +* [ ] `robot.json` bleibt eine Datei (kein Split), aber das Package liest nur + seinen Teil — Renderer-Felder werden einfach ignoriert. + +--- + +## Phase 3 — REST API (FastAPI) + +Für die Integration in die Robotersteuerung: die Steuerung postet Bilder, +bekommt Gelenkwinkel zurück. + +### Interface + +``` +POST /v1/estimate + Content-Type: multipart/form-data + - robot_json: file (oder als Pfad-Config beim Server-Start) + - images[]: file (N Kamerabilder, benennt nach cam-ID: render_a.png etc.) + - intrinsics[]: file (N npz-Dateien, gleiche Reihenfolge wie images) + +Response 200: +{ + "joints": {"x": 50.2, "y": -2.1, "z": 94.8, "a": 20.1, "b": 59.9, "c": 9.0, "e": 3.0}, + "confidence": {"x": "high", "y": "high", "z": "high", "a": "high", "b": "low", "c": "low", "e": "low"}, + "residual_rms": 1.45, + "n_markers": 56, + "processing_ms": 1240 +} + +GET /v1/health → {"status": "ok", "version": "1.2.0"} +GET /v1/config → aktuelle robot.json-Konfiguration (pose_estimation-Block) +``` + +### Aufgaben + +* [ ] `approbot_pipeline/api/server.py` mit FastAPI +* [ ] Temporäres Verzeichnis pro Request (keine Race-Conditions bei parallelen Anfragen) +* [ ] `docker-compose.yml` für den Service-Modus: + ```yaml + services: + pipeline: + image: approbot/pose-pipeline:latest + ports: ["8080:8080"] + volumes: + - ./robot.json:/config/robot.json:ro + command: ["python", "-m", "approbot_pipeline.api", "--robot", "/config/robot.json"] + ``` +* [ ] `GET /v1/health` für Container-Health-Check + +--- + +## Phase 4 — Tests & CI + +### Aufgaben + +* [ ] `tests/test_pipeline.py`: End-to-end-Test auf minimaler Fixture-Scene + (kleine PNGs + robot.json-Minimal) ohne Blender → CI-tauglich in < 30s +* [ ] Regression gegen GT: `eval_pose.py` als pytest-Assertion + (`assert mean_error_deg < 0.5`) — fängt Code-Regressions wie den fy-Bug automatisch +* [ ] GitHub Actions (oder äquivalentes CI): `docker build` + Test auf jedem Commit +* [ ] Versions-Tag: `approbot-pipeline==1.0.0` → Pipeline-Verhalten ist reproduzierbar + +--- + +## Phase 5 — Deployment + +### Variante A: Docker-Service auf dem Roboter-PC (empfohlen) + +```bash +docker run -d --rm \ + -p 8080:8080 \ + -v ./robot.json:/config/robot.json:ro \ + approbot/pose-pipeline:latest \ + python -m approbot_pipeline.api --robot /config/robot.json +``` + +Robotersteuerung (beliebige Sprache) postet Bilder per HTTP: +```python +import requests +resp = requests.post("http://localhost:8080/v1/estimate", + files=[("images", open(img, "rb")) for img in cam_images]) +joints = resp.json()["joints"] +``` + +### Variante B: Python direkt eingebunden (ohne Container) + +```python +from approbot_pipeline import estimate_from_dir +result = estimate_from_dir(image_dir, robot_json="robot.json") +move_robot(result.joints) +``` + +### Variante C: Portainer / verteilte Nodes + +Schnittstellt nahtlos an die Docker-Roadmap (Phase 5–8) an: +- `approbot/pose-pipeline` ist das Image für Nomad-Batch-Jobs +- Job-Payload: `{"scene_dir": "/mnt/data/sceneX"}` oder die Bilder direkt + +--- + +## Konfigurationsvertrag (stabiles Interface) + +Der einzige Eingabepunkt für Algorithmus-Tuning: + +```json +"pose_estimation": { + "method": "hybrid", + "marker_observation": "corner_pose", + "use_normals": true, + "normal_weight": 100.0, + "robust_loss": "huber", + "huber_delta_mm": 8.0, + "max_iterations": 200, + "min_cameras_per_marker": 2, + "per_link_method": {} +} +``` + +Dieses Schema ist **versionsstabil**: neue Parameter haben immer Defaults, +alte werden nie entfernt (nur deprecated-markiert). So laufen Roboter-Deployments +mit einer älteren `robot.json` unverändert weiter. + +--- + +## Vorgehen / Empfohlene Reihenfolge + +1. **Phase 1** (pyproject.toml + `__init__.py` + Funktionsaufrufe statt Subprocess) + — bringt sofort sauberere Fehlerbehandlung und elimiert ~2s Process-Startup pro Schritt. +2. **Phase 2** (Schema-Validierer) — frühzeitige, verständliche Fehlermeldungen + statt kryptischen Abbrüchen in Schritt 2. +3. **Phase 3** (REST API) — sobald die Robotersteuerung integriert werden soll. +4. **Phase 4** (CI) — parallel zu Phase 2/3. +5. **Phase 5** (Deployment) — läuft auf Phase 3 auf. + +Phases 1+2 sind **unabhängig von der Docker-Roadmap** und können sofort beginnen. +Phase 3–5 passen in die Docker-Roadmap Phase 4–8. + +--- + +## Was sich NICHT ändert + +- `robot.json` ist und bleibt die einzige Konfigurations-Datei. +- Die Algorithmen (pose_estimation.py, robot_fk.py) bleiben unberührt. +- Bestehende `run/run_pipeline.bat` und `pipeline/run_pipeline.py` bleiben parallel + lauffähig — das Package ist eine sauberere Schicht *darüber*, kein Ersatz. +- Benchmark-Tools (`benchmark/*.py`) arbeiten weiterhin direkt gegen das Dateisystem. diff --git a/doc/setup_with_more_degree_of_Freedom_roadmap.md b/doc/setup_with_more_degree_of_Freedom_roadmap.md new file mode 100644 index 0000000..e69de29 diff --git a/pipeline/run_pipeline.py b/pipeline/run_pipeline.py index bf12834..b8ebc42 100644 --- a/pipeline/run_pipeline.py +++ b/pipeline/run_pipeline.py @@ -14,6 +14,7 @@ Funktionales Äquivalent zu run/run_pipeline.bat: Aufruf: python pipeline/run_pipeline.py data/simulation/Scene8 python pipeline/run_pipeline.py data/simulation/Scene8 --robot data/robot/robot.json + python pipeline/run_pipeline.py data/simulation/Scene8 --cameras a,c,f Im Container (Pipeline-Image) ist das der Standard-Einstiegspunkt. """ @@ -51,6 +52,8 @@ def main() -> None: ap.add_argument("--robot", default=os.environ.get("ROBOT_JSON", str(ROOT / "data" / "robot" / "robot.json"))) ap.add_argument("--evalDir", default=None, help="Ausgabeordner (Default: data/evaluations/)") ap.add_argument("--lambdaWeight", default="100.0") + ap.add_argument("--cameras", default=None, + help="Kommagetrennte Kamera-IDs, z.B. a,b,d (Standard: alle)") args = ap.parse_args() scene = Path(args.scene).resolve() @@ -59,10 +62,14 @@ def main() -> None: robot = args.robot imgs = sorted(glob.glob(str(scene / "render_*.png"))) + if args.cameras: + wanted = set(args.cameras.split(",")) + imgs = [img for img in imgs if cam_id_of(img) in wanted] if not imgs: - print(f"[ERROR] keine render_*.png in {scene}") + print(f"[ERROR] keine passenden render_*.png in {scene}") sys.exit(1) - print(f"[INFO] Scene={scene.name} Bilder={len(imgs)} -> {eval_dir}") + cam_ids = [cam_id_of(i) for i in imgs] + print(f"[INFO] Scene={scene.name} Kameras={cam_ids} -> {eval_dir}") # ── Schritt 1: ArUco-Detektion (pro Bild seine EIGENE npz) ── print("\n[STEP 1] ArUco-Detektion")