From ecc499296e9ec56497f6806acb11e76da95d5c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Thu, 14 May 2026 11:59:24 +0200 Subject: [PATCH] feat(icp): extrinsic dataclass + JSON persistence --- data_only_viz/lidar_calib.py | 58 +++++++++++++++++++++++++ data_only_viz/tests/test_lidar_calib.py | 41 +++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 data_only_viz/lidar_calib.py create mode 100644 data_only_viz/tests/test_lidar_calib.py diff --git a/data_only_viz/lidar_calib.py b/data_only_viz/lidar_calib.py new file mode 100644 index 0000000..647248f --- /dev/null +++ b/data_only_viz/lidar_calib.py @@ -0,0 +1,58 @@ +"""iPhone LiDAR (ARKit world) <-> webcam (Multi-HMR camera) extrinsic. + +Persisted as a small JSON document so calibration survives across launches. +The default location is ``~/.config/av-live/lidar_extrinsic.json``; override +with the ``ICP_LIDAR_EXTRINSIC`` env var. +""" +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np + +DEFAULT_EXTRINSIC_PATH = Path.home() / ".config" / "av-live" / "lidar_extrinsic.json" + + +@dataclass +class Extrinsic: + """4x4 rigid transform from ARKit world frame to Multi-HMR camera frame.""" + + T_arkit_to_cam: np.ndarray = field(default_factory=lambda: np.eye(4)) + confidence: float = 0.0 + captured_at_iso: str = "" + + @staticmethod + def identity() -> "Extrinsic": + return Extrinsic(T_arkit_to_cam=np.eye(4), confidence=0.0, captured_at_iso="") + + +def save_extrinsic(e: Extrinsic, path: Path | None = None) -> Path: + path = Path(path) if path is not None else _path_from_env() + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "T_arkit_to_cam": e.T_arkit_to_cam.astype(float).tolist(), + "confidence": float(e.confidence), + "captured_at_iso": e.captured_at_iso, + } + path.write_text(json.dumps(payload, indent=2)) + return path + + +def load_extrinsic(path: Path | None = None) -> Extrinsic: + path = Path(path) if path is not None else _path_from_env() + if not path.exists(): + return Extrinsic.identity() + payload = json.loads(path.read_text()) + return Extrinsic( + T_arkit_to_cam=np.array(payload["T_arkit_to_cam"], dtype=np.float64), + confidence=float(payload.get("confidence", 0.0)), + captured_at_iso=str(payload.get("captured_at_iso", "")), + ) + + +def _path_from_env() -> Path: + p = os.environ.get("ICP_LIDAR_EXTRINSIC") + return Path(p) if p else DEFAULT_EXTRINSIC_PATH diff --git a/data_only_viz/tests/test_lidar_calib.py b/data_only_viz/tests/test_lidar_calib.py new file mode 100644 index 0000000..2929e41 --- /dev/null +++ b/data_only_viz/tests/test_lidar_calib.py @@ -0,0 +1,41 @@ +"""Tests for LiDAR <-> webcam extrinsic calibration persistence.""" +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import pytest + + +def test_extrinsic_default_is_identity() -> None: + from data_only_viz.lidar_calib import Extrinsic + + e = Extrinsic.identity() + np.testing.assert_allclose(e.T_arkit_to_cam, np.eye(4)) + assert e.confidence == 0.0 + assert e.captured_at_iso == "" + + +def test_extrinsic_roundtrip_json(tmp_path: Path) -> None: + from data_only_viz.lidar_calib import Extrinsic, load_extrinsic, save_extrinsic + + T = np.eye(4) + T[:3, 3] = [0.1, -0.05, 0.30] + e = Extrinsic(T_arkit_to_cam=T, confidence=0.95, captured_at_iso="2026-05-14T12:00:00Z") + + path = tmp_path / "extrinsic.json" + save_extrinsic(e, path) + loaded = load_extrinsic(path) + + np.testing.assert_allclose(loaded.T_arkit_to_cam, T, atol=1e-10) + assert loaded.confidence == pytest.approx(0.95) + assert loaded.captured_at_iso == "2026-05-14T12:00:00Z" + + +def test_load_extrinsic_missing_path_returns_identity(tmp_path: Path) -> None: + from data_only_viz.lidar_calib import load_extrinsic + + e = load_extrinsic(tmp_path / "does-not-exist.json") + np.testing.assert_allclose(e.T_arkit_to_cam, np.eye(4)) + assert e.confidence == 0.0