feat(icp): extrinsic dataclass + JSON persistence
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user