rpx_benchmark.ar_pose_audit

AR-board consistency audit for RPX single-object camera poses.

The single-object captures contain an 18-marker FewSOL board in the D435 RGB images and a synchronized camera-to-world pose saved from the T265. The RPX capture rig is documented by the dataset owners as already camera-frame aligned. This module deliberately does not estimate a D435-to-T265 extrinsic. Instead it compares relative SE(3) motions, which also cancels the unrelated board and T265 world origins.

The ALVAR MarkerData codes below are the canonical IDs 0--17 distributed with ros-perception/ar_track_alvar. They are represented as an OpenCV custom dictionary (5x5 payload, two-bit black border), allowing an offline audit directly from RPX rgb.tar files without ROS.

  1"""AR-board consistency audit for RPX single-object camera poses.
  2
  3The single-object captures contain an 18-marker FewSOL board in the D435 RGB
  4images and a synchronized camera-to-world pose saved from the T265.  The RPX
  5capture rig is documented by the dataset owners as already camera-frame
  6aligned.  This module deliberately does *not* estimate a D435-to-T265
  7extrinsic.  Instead it compares relative SE(3) motions, which also cancels the
  8unrelated board and T265 world origins.
  9
 10The ALVAR ``MarkerData`` codes below are the canonical IDs 0--17 distributed
 11with ``ros-perception/ar_track_alvar``.  They are represented as an OpenCV
 12custom dictionary (5x5 payload, two-bit black border), allowing an offline
 13audit directly from RPX ``rgb.tar`` files without ROS.
 14"""
 15
 16from __future__ import annotations
 17
 18import csv
 19import io
 20import json
 21import tarfile
 22from dataclasses import dataclass
 23from pathlib import Path
 24from typing import Any, Iterable, Mapping, Sequence
 25
 26import numpy as np
 27from scipy.spatial.transform import Rotation
 28
 29from .exceptions import ConfigError
 30
 31# ALVAR MarkerData IDs 0--17, sampled from the canonical marker generator.
 32_ALVAR_5X5_BITS = (
 33    "1101111011101011111111111",
 34    "1101111011101010011011101",
 35    "1101111011101011011010110",
 36    "1101111011101010111110100",
 37    "1101111011101010111001110",
 38    "1101111011101011011101100",
 39    "1101111011101010011100111",
 40    "1101111011101011111000101",
 41    "1101111011101010010111110",
 42    "1101111011101011110011100",
 43    "1101111011101010110010111",
 44    "1101111011101011010110101",
 45    "1101111011101011010001111",
 46    "1101111011101010110101101",
 47    "1101111011101011110100110",
 48    "1101111011101010010000100",
 49    "0001011001101011111111111",
 50    "0001011001101010011011101",
 51)
 52
 53# FewSOL marker board geometry, metres.  Marker coordinates are measured from
 54# the upper-left of the 70.4 x 50.4 cm board; +Y below is converted to +Y up.
 55BOARD_WIDTH_M = 0.704
 56BOARD_HEIGHT_M = 0.504
 57MARKER_SIZE_M = 0.059
 58_MARKER_CENTRES_CM = (
 59    (2.95, 2.95),
 60    (15.85, 2.95),
 61    (28.75, 2.95),
 62    (41.55, 2.95),
 63    (54.45, 2.95),
 64    (67.45, 2.95),
 65    (67.45, 13.75),
 66    (67.45, 25.00),
 67    (67.45, 36.20),
 68    (67.45, 47.45),
 69    (54.45, 47.45),
 70    (41.55, 47.45),
 71    (28.70, 47.45),
 72    (15.80, 47.45),
 73    (2.95, 47.45),
 74    (2.95, 36.75),
 75    (2.95, 25.55),
 76    (2.95, 14.25),
 77)
 78
 79# FewSOL's published D435 calibration.  CLI callers can override every value.
 80DEFAULT_CAMERA_MATRIX = np.array(
 81    [[611.10888672, 0.0, 315.51083374], [0.0, 610.02844238, 237.73669434], [0.0, 0.0, 1.0]],
 82    dtype=np.float64,
 83)
 84
 85# librealsense pose coordinates are X right, Y up, Z back; OpenCV RGB camera
 86# coordinates are X right, Y down, Z forward.  This fixed basis change is not a
 87# fitted camera extrinsic.  save_device_data.py writes the raw T265 quaternion
 88# and translation, so it must be applied unless poses were converted later.
 89_T265_TO_OPENCV = np.diag([1.0, -1.0, -1.0, 1.0])
 90
 91
 92@dataclass(frozen=True)
 93class BoardObservation:
 94    """One D435 observation of the static marker board."""
 95
 96    camera_from_board: np.ndarray
 97    marker_ids: tuple[int, ...]
 98    reprojection_rmse_px: float
 99    inlier_corners: int
100
101
102def _cv2() -> Any:
103    try:
104        import cv2
105    except ImportError as exc:  # pragma: no cover - environment-dependent
106        raise ConfigError(
107            "AR pose auditing requires OpenCV contrib; install "
108            "`pip install 'rpx-benchmark[ar-pose-audit]'`."
109        ) from exc
110    if not hasattr(cv2, "aruco"):
111        raise ConfigError(
112            "OpenCV was installed without the aruco module; use opencv-contrib-python-headless."
113        )
114    return cv2
115
116
117def alvar_dictionary() -> Any:
118    """Build the canonical ALVAR 0--17 dictionary for OpenCV detection."""
119
120    cv2 = _cv2()
121    rows = []
122    for code in _ALVAR_5X5_BITS:
123        bits = np.fromiter((int(bit) for bit in code), dtype=np.uint8).reshape(5, 5)
124        rows.append(cv2.aruco.Dictionary_getByteListFromBits(bits))
125    byte_list = np.concatenate(rows, axis=0)
126    return cv2.aruco.Dictionary(byte_list, 5, 2)
127
128
129def make_detector() -> Any:
130    """Create an ALVAR detector tuned for the 640x480 RPX RGB frames."""
131
132    cv2 = _cv2()
133    params = cv2.aruco.DetectorParameters()
134    params.markerBorderBits = 2
135    params.minMarkerPerimeterRate = 0.01
136    params.maxMarkerPerimeterRate = 1.0
137    params.cornerRefinementMethod = cv2.aruco.CORNER_REFINE_SUBPIX
138    params.errorCorrectionRate = 0.8
139    return cv2.aruco.ArucoDetector(alvar_dictionary(), params)
140
141
142def marker_object_corners(marker_id: int) -> np.ndarray:
143    """Return TL, TR, BR, BL corners in the board-centred coordinate frame."""
144
145    if not 0 <= marker_id < len(_MARKER_CENTRES_CM):
146        raise ConfigError(f"unknown FewSOL marker id: {marker_id}")
147    x_cm, y_cm = _MARKER_CENTRES_CM[marker_id]
148    cx = x_cm * 0.01 - BOARD_WIDTH_M / 2.0
149    cy = BOARD_HEIGHT_M / 2.0 - y_cm * 0.01
150    half = MARKER_SIZE_M / 2.0
151    return np.array(
152        [
153            [cx - half, cy + half, 0.0],
154            [cx + half, cy + half, 0.0],
155            [cx + half, cy - half, 0.0],
156            [cx - half, cy - half, 0.0],
157        ],
158        dtype=np.float64,
159    )
160
161
162def estimate_board_pose(
163    image: np.ndarray,
164    detector: Any,
165    camera_matrix: np.ndarray = DEFAULT_CAMERA_MATRIX,
166    distortion: np.ndarray | None = None,
167    *,
168    reprojection_threshold_px: float = 4.0,
169) -> BoardObservation | None:
170    """Detect visible ALVAR tags and estimate ``T_camera_from_board``."""
171
172    cv2 = _cv2()
173    corners, ids, _rejected = detector.detectMarkers(image)
174    if ids is None or not len(ids):
175        return None
176
177    # A duplicated ID is geometrically ambiguous; keep the largest detection.
178    by_id: dict[int, np.ndarray] = {}
179    for marker_id, image_corners in zip(ids.ravel().tolist(), corners, strict=True):
180        marker_id = int(marker_id)
181        candidate = np.asarray(image_corners, dtype=np.float64).reshape(4, 2)
182        previous = by_id.get(marker_id)
183        if previous is None or abs(cv2.contourArea(candidate.astype(np.float32))) > abs(
184            cv2.contourArea(previous.astype(np.float32))
185        ):
186            by_id[marker_id] = candidate
187
188    candidate_ids = tuple(sorted(by_id))
189    object_points = np.concatenate([marker_object_corners(i) for i in candidate_ids])
190    image_points = np.concatenate([by_id[i] for i in candidate_ids])
191    distortion = (
192        np.zeros(5, dtype=np.float64)
193        if distortion is None
194        else np.asarray(distortion, dtype=np.float64)
195    )
196    K = np.asarray(camera_matrix, dtype=np.float64)
197
198    if len(candidate_ids) == 1:
199        ok, rvec, tvec = cv2.solvePnP(
200            object_points,
201            image_points,
202            K,
203            distortion,
204            flags=cv2.SOLVEPNP_IPPE,
205        )
206        inliers = np.arange(4, dtype=np.int32).reshape(-1, 1)
207    else:
208        ok, rvec, tvec, inliers = cv2.solvePnPRansac(
209            object_points,
210            image_points,
211            K,
212            distortion,
213            reprojectionError=float(reprojection_threshold_px),
214            confidence=0.999,
215            iterationsCount=200,
216            flags=cv2.SOLVEPNP_ITERATIVE,
217        )
218    if not ok or inliers is None or len(inliers) < 4:
219        return None
220
221    keep = inliers.ravel()
222    marker_ids = tuple(sorted({candidate_ids[int(index) // 4] for index in keep}))
223    ok, rvec, tvec = cv2.solvePnP(
224        object_points[keep],
225        image_points[keep],
226        K,
227        distortion,
228        rvec,
229        tvec,
230        True,
231        flags=cv2.SOLVEPNP_ITERATIVE,
232    )
233    if not ok:
234        return None
235    projected, _ = cv2.projectPoints(object_points[keep], rvec, tvec, K, distortion)
236    residual = projected.reshape(-1, 2) - image_points[keep]
237    rmse = float(np.sqrt(np.mean(np.sum(residual * residual, axis=1))))
238    rotation, _ = cv2.Rodrigues(rvec)
239    transform = np.eye(4, dtype=np.float64)
240    transform[:3, :3] = rotation
241    transform[:3, 3] = np.asarray(tvec).reshape(3)
242    return BoardObservation(transform, marker_ids, rmse, int(len(keep)))
243
244
245def pose_from_npz_bytes(payload: bytes) -> np.ndarray:
246    """Load an RPX T265 camera-to-world pose from an NPZ payload."""
247
248    with np.load(io.BytesIO(payload)) as data:
249        position = np.asarray(data["position"], dtype=np.float64).reshape(3)
250        quaternion = np.asarray(data["orientation"], dtype=np.float64).reshape(4)
251    transform = np.eye(4, dtype=np.float64)
252    transform[:3, :3] = Rotation.from_quat(quaternion).as_matrix()
253    transform[:3, 3] = position
254    return transform
255
256
257def rotation_error_deg(a: np.ndarray, b: np.ndarray) -> float:
258    relative = a[:3, :3].T @ b[:3, :3]
259    cosine = np.clip((np.trace(relative) - 1.0) / 2.0, -1.0, 1.0)
260    return float(np.degrees(np.arccos(cosine)))
261
262
263def relative_pose(reference: np.ndarray, current: np.ndarray) -> np.ndarray:
264    return np.linalg.inv(reference) @ current
265
266
267def t265_to_opencv_pose(pose: np.ndarray) -> np.ndarray:
268    """Express a raw librealsense T265 pose in the OpenCV axis convention."""
269
270    return _T265_TO_OPENCV @ np.asarray(pose, dtype=np.float64) @ _T265_TO_OPENCV
271
272
273def _pearson(a: np.ndarray, b: np.ndarray) -> float | None:
274    a = np.asarray(a, dtype=np.float64).ravel()
275    b = np.asarray(b, dtype=np.float64).ravel()
276    if len(a) < 2 or np.std(a) < 1e-12 or np.std(b) < 1e-12:
277        return None
278    return float(np.corrcoef(a, b)[0, 1])
279
280
281def _distribution(values: Sequence[float]) -> dict[str, float | None]:
282    """Return robust, publication-friendly statistics for one error series."""
283
284    array = np.asarray(values, dtype=np.float64)
285    if not len(array):
286        return {key: None for key in ("rmse", "mean", "median", "std", "p95", "max")}
287    return {
288        "rmse": float(np.sqrt(np.mean(np.square(array)))),
289        "mean": float(np.mean(array)),
290        "median": float(np.median(array)),
291        "std": float(np.std(array)),
292        "p95": float(np.percentile(array, 95)),
293        "max": float(np.max(array)),
294    }
295
296
297def _robust_outliers(values: Sequence[float], *, z_threshold: float = 3.5) -> np.ndarray:
298    """Flag high residuals using a median/MAD threshold without assuming Gaussian noise."""
299
300    array = np.asarray(values, dtype=np.float64)
301    if not len(array):
302        return np.zeros(0, dtype=bool)
303    median = float(np.median(array))
304    mad = float(np.median(np.abs(array - median)))
305    if float(np.ptp(array)) < 1e-9:
306        return np.zeros(len(array), dtype=bool)
307    if mad < 1e-12:
308        return array > median + 1e-9
309    robust_z = (array - median) / (1.4826 * mad)
310    return robust_z > z_threshold
311
312
313def _slope_norm(frame_ids: Sequence[int], vectors: np.ndarray) -> float | None:
314    """Magnitude of a least-squares vector trend, expressed per source frame."""
315
316    if len(frame_ids) < 2:
317        return None
318    x = np.asarray(frame_ids, dtype=np.float64)
319    x -= x.mean()
320    denominator = float(x @ x)
321    if denominator < 1e-12:
322        return None
323    centred = np.asarray(vectors, dtype=np.float64) - np.mean(vectors, axis=0)
324    slope = (x[:, None] * centred).sum(axis=0) / denominator
325    return float(np.linalg.norm(slope))
326
327
328def _rotation_medoid(rotations: Rotation) -> Rotation:
329    """Choose the observed orientation with minimum median geodesic distance."""
330
331    quaternions = rotations.as_quat()
332    cosine_half_angles = np.clip(np.abs(quaternions @ quaternions.T), 0.0, 1.0)
333    distances = 2.0 * np.arccos(cosine_half_angles)
334    return rotations[int(np.argmin(np.median(distances, axis=1)))]
335
336
337def compare_pose_tracks(
338    frame_ids: Sequence[int],
339    cam_poses: Sequence[np.ndarray],
340    board_observations: Sequence[BoardObservation],
341    *,
342    pose_axis_convention: str = "t265",
343) -> tuple[dict[str, Any], list[dict[str, Any]]]:
344    """Compare AR and saved motion without estimating a sensor transform."""
345
346    if not (len(frame_ids) == len(cam_poses) == len(board_observations)):
347        raise ConfigError("frame, camera-pose and board-observation counts differ")
348    if not frame_ids:
349        return {"matched_frames": 0}, []
350
351    if pose_axis_convention == "t265":
352        cam_poses = [t265_to_opencv_pose(pose) for pose in cam_poses]
353    elif pose_axis_convention != "opencv":
354        raise ConfigError("pose_axis_convention must be 't265' or 'opencv'")
355
356    ar_poses = [np.linalg.inv(observation.camera_from_board) for observation in board_observations]
357    cam_anchor, ar_anchor = cam_poses[0], ar_poses[0]
358    cam_relative = [relative_pose(cam_anchor, pose) for pose in cam_poses]
359    ar_relative = [relative_pose(ar_anchor, pose) for pose in ar_poses]
360
361    frame_rows: list[dict[str, Any]] = []
362    translation_errors = []
363    rotation_errors = []
364    cam_translations = []
365    ar_translations = []
366    cam_rotvecs = []
367    ar_rotvecs = []
368    for frame_id, cam_rel, ar_rel, observation in zip(
369        frame_ids, cam_relative, ar_relative, board_observations, strict=True
370    ):
371        trans_error = float(np.linalg.norm(cam_rel[:3, 3] - ar_rel[:3, 3]))
372        rot_error = rotation_error_deg(cam_rel, ar_rel)
373        translation_errors.append(trans_error)
374        rotation_errors.append(rot_error)
375        cam_translations.append(cam_rel[:3, 3])
376        ar_translations.append(ar_rel[:3, 3])
377        cam_rotvecs.append(Rotation.from_matrix(cam_rel[:3, :3]).as_rotvec())
378        ar_rotvecs.append(Rotation.from_matrix(ar_rel[:3, :3]).as_rotvec())
379        frame_rows.append(
380            {
381                "frame_idx": int(frame_id),
382                "detected": True,
383                "marker_count": len(observation.marker_ids),
384                "marker_ids": ";".join(str(i) for i in observation.marker_ids),
385                "reprojection_rmse_px": observation.reprojection_rmse_px,
386                "translation_error_m": trans_error,
387                "rotation_error_deg": rot_error,
388            }
389        )
390
391    cam_t = np.asarray(cam_translations)
392    ar_t = np.asarray(ar_translations)
393    cam_r = np.asarray(cam_rotvecs)
394    ar_r = np.asarray(ar_rotvecs)
395
396    step_translation_errors = []
397    step_rotation_errors = []
398    step_translation_errors_per_frame = []
399    step_rotation_errors_per_frame = []
400    step_gaps = []
401    for index in range(1, len(frame_ids)):
402        cam_step = relative_pose(cam_poses[index - 1], cam_poses[index])
403        ar_step = relative_pose(ar_poses[index - 1], ar_poses[index])
404        step_translation_errors.append(float(np.linalg.norm(cam_step[:3, 3] - ar_step[:3, 3])))
405        step_rotation_errors.append(rotation_error_deg(cam_step, ar_step))
406        gap = frame_ids[index] - frame_ids[index - 1]
407        step_gaps.append(gap)
408        step_translation_errors_per_frame.append(step_translation_errors[-1] / max(gap, 1))
409        step_rotation_errors_per_frame.append(step_rotation_errors[-1] / max(gap, 1))
410
411    # If the frames are aligned and the board is static, this composition must
412    # be constant.  No world-origin or hand-eye fit is involved.
413    world_from_boards = [
414        cam @ obs.camera_from_board for cam, obs in zip(cam_poses, board_observations, strict=True)
415    ]
416    board_anchor = world_from_boards[0]
417    board_translation_drift = [
418        float(np.linalg.norm(pose[:3, 3] - board_anchor[:3, 3])) for pose in world_from_boards
419    ]
420    board_rotation_drift = [rotation_error_deg(board_anchor, pose) for pose in world_from_boards]
421
422    # Jitter is measured around a sequence-level central pose rather than the
423    # first observation, which may itself be noisy.  The translation median is
424    # robust to isolated PnP failures; the geodesic rotation medoid avoids
425    # quaternion sign ambiguity and is not pulled toward planar-PnP flips.
426    board_positions = np.asarray([pose[:3, 3] for pose in world_from_boards])
427    board_rotations = Rotation.from_matrix([pose[:3, :3] for pose in world_from_boards])
428    board_position_centre = np.median(board_positions, axis=0)
429    board_rotation_centre = _rotation_medoid(board_rotations)
430    board_translation_jitter = np.linalg.norm(
431        board_positions - board_position_centre[None, :], axis=1
432    )
433    board_rotation_jitter = np.degrees(
434        (board_rotation_centre.inv() * board_rotations).magnitude()
435    )
436    translation_outliers = _robust_outliers(board_translation_jitter)
437    rotation_outliers = _robust_outliers(board_rotation_jitter)
438    combined_outliers = translation_outliers | rotation_outliers
439    rotation_residual_vectors = (
440        board_rotation_centre.inv() * board_rotations
441    ).as_rotvec()
442
443    for row, position, pose, translation_jitter, rotation_jitter, is_outlier in zip(
444        frame_rows,
445        board_positions,
446        world_from_boards,
447        board_translation_jitter,
448        board_rotation_jitter,
449        combined_outliers,
450        strict=True,
451    ):
452        row.update(
453            {
454                "world_board_x_m": float(position[0]),
455                "world_board_y_m": float(position[1]),
456                "world_board_z_m": float(position[2]),
457                "world_board_qx": float(Rotation.from_matrix(pose[:3, :3]).as_quat()[0]),
458                "world_board_qy": float(Rotation.from_matrix(pose[:3, :3]).as_quat()[1]),
459                "world_board_qz": float(Rotation.from_matrix(pose[:3, :3]).as_quat()[2]),
460                "world_board_qw": float(Rotation.from_matrix(pose[:3, :3]).as_quat()[3]),
461                "static_board_translation_jitter_m": float(translation_jitter),
462                "static_board_rotation_jitter_deg": float(rotation_jitter),
463                "static_board_robust_outlier": bool(is_outlier),
464            }
465        )
466
467    def mean(values: Sequence[float]) -> float | None:
468        return float(np.mean(values)) if values else None
469
470    def rmse(values: Sequence[float]) -> float | None:
471        return float(np.sqrt(np.mean(np.square(values)))) if values else None
472
473    summary: dict[str, Any] = {
474        "matched_frames": len(frame_ids),
475        "first_matched_frame": int(frame_ids[0]),
476        "last_matched_frame": int(frame_ids[-1]),
477        "translation_correlation": {
478            "x": _pearson(cam_t[:, 0], ar_t[:, 0]),
479            "y": _pearson(cam_t[:, 1], ar_t[:, 1]),
480            "z": _pearson(cam_t[:, 2], ar_t[:, 2]),
481            "all_components": _pearson(cam_t, ar_t),
482            "magnitude": _pearson(np.linalg.norm(cam_t, axis=1), np.linalg.norm(ar_t, axis=1)),
483        },
484        "rotation_vector_correlation": {
485            "x": _pearson(cam_r[:, 0], ar_r[:, 0]),
486            "y": _pearson(cam_r[:, 1], ar_r[:, 1]),
487            "z": _pearson(cam_r[:, 2], ar_r[:, 2]),
488            "all_components": _pearson(cam_r, ar_r),
489            "magnitude": _pearson(np.linalg.norm(cam_r, axis=1), np.linalg.norm(ar_r, axis=1)),
490        },
491        "anchor_relative_translation_rmse_m": rmse(translation_errors),
492        "anchor_relative_translation_mean_m": mean(translation_errors),
493        "anchor_relative_rotation_mean_deg": mean(rotation_errors),
494        "anchor_relative_rotation_rmse_deg": rmse(rotation_errors),
495        "step_translation_rmse_m": rmse(step_translation_errors),
496        "step_rotation_rmse_deg": rmse(step_rotation_errors),
497        "step_translation_rmse_m_per_frame": rmse(step_translation_errors_per_frame),
498        "step_rotation_rmse_deg_per_frame": rmse(step_rotation_errors_per_frame),
499        "mean_detected_frame_gap": mean(step_gaps),
500        "static_board_translation_drift_rmse_m": rmse(board_translation_drift),
501        "static_board_rotation_drift_rmse_deg": rmse(board_rotation_drift),
502        "static_board_translation_jitter": _distribution(board_translation_jitter),
503        "static_board_rotation_jitter": _distribution(board_rotation_jitter),
504        "static_board_translation_jitter_robust_inliers": _distribution(
505            board_translation_jitter[~combined_outliers]
506        ),
507        "static_board_rotation_jitter_robust_inliers": _distribution(
508            board_rotation_jitter[~combined_outliers]
509        ),
510        "static_board_translation_drift_slope_m_per_frame": _slope_norm(
511            frame_ids, board_positions
512        ),
513        "static_board_rotation_drift_slope_deg_per_frame": (
514            None
515            if (slope := _slope_norm(frame_ids, rotation_residual_vectors)) is None
516            else float(np.degrees(slope))
517        ),
518        "static_board_robust_outlier_rate": float(np.mean(combined_outliers)),
519        "mean_marker_count": mean([len(obs.marker_ids) for obs in board_observations]),
520        "mean_reprojection_rmse_px": mean([obs.reprojection_rmse_px for obs in board_observations]),
521    }
522    return summary, frame_rows
523
524
525def _member_map(archive: tarfile.TarFile, suffix: str) -> dict[int, tarfile.TarInfo]:
526    result = {}
527    for member in archive.getmembers():
528        if member.isfile() and member.name.endswith(suffix):
529            try:
530                result[int(Path(member.name).stem)] = member
531            except ValueError:
532                continue
533    return result
534
535
536def audit_sequence(
537    sequence_dir: str | Path,
538    camera_matrix: np.ndarray = DEFAULT_CAMERA_MATRIX,
539    distortion: np.ndarray | None = None,
540    *,
541    max_frames: int | None = None,
542    reprojection_threshold_px: float = 4.0,
543    pose_axis_convention: str = "t265",
544    min_markers: int = 2,
545    max_reprojection_rmse_px: float = 2.0,
546) -> tuple[dict[str, Any], list[dict[str, Any]]]:
547    """Audit one ``objects/<id>/0`` directory directly from tar shards."""
548
549    sequence_dir = Path(sequence_dir)
550    rgb_path = sequence_dir / "rgb.tar"
551    pose_path = sequence_dir / "labels" / "cam_pose" / "v1.tar"
552    if not rgb_path.is_file() or not pose_path.is_file():
553        raise FileNotFoundError(f"missing RGB or camera-pose archive under {sequence_dir}")
554
555    detector = make_detector()
556    frame_ids: list[int] = []
557    cam_poses: list[np.ndarray] = []
558    observations: list[BoardObservation] = []
559    all_rows: dict[int, dict[str, Any]] = {}
560    raw_detections = 0
561    cv2 = _cv2()
562    with tarfile.open(rgb_path) as rgb_tar, tarfile.open(pose_path) as pose_tar:
563        rgb_members = _member_map(rgb_tar, ".png")
564        pose_members = _member_map(pose_tar, ".npz")
565        candidates = sorted(set(rgb_members) & set(pose_members))
566        if max_frames is not None:
567            candidates = candidates[:max_frames]
568        for frame_id in candidates:
569            rgb_file = rgb_tar.extractfile(rgb_members[frame_id])
570            pose_file = pose_tar.extractfile(pose_members[frame_id])
571            if rgb_file is None or pose_file is None:
572                continue
573            encoded = np.frombuffer(rgb_file.read(), dtype=np.uint8)
574            image = cv2.imdecode(encoded, cv2.IMREAD_COLOR)
575            observation = estimate_board_pose(
576                image,
577                detector,
578                camera_matrix,
579                distortion,
580                reprojection_threshold_px=reprojection_threshold_px,
581            )
582            accepted = observation is not None
583            reject_reason = ""
584            if observation is not None:
585                raw_detections += 1
586                if len(observation.marker_ids) < min_markers:
587                    accepted = False
588                    reject_reason = "insufficient_inlier_markers"
589                elif observation.reprojection_rmse_px > max_reprojection_rmse_px:
590                    accepted = False
591                    reject_reason = "high_reprojection_error"
592            all_rows[frame_id] = {
593                "frame_idx": frame_id,
594                "raw_detected": observation is not None,
595                "detected": accepted,
596                "reject_reason": reject_reason,
597            }
598            if observation is not None:
599                all_rows[frame_id].update(
600                    {
601                        "marker_count": len(observation.marker_ids),
602                        "marker_ids": ";".join(str(i) for i in observation.marker_ids),
603                        "reprojection_rmse_px": observation.reprojection_rmse_px,
604                    }
605                )
606            if accepted and observation is not None:
607                frame_ids.append(frame_id)
608                cam_poses.append(pose_from_npz_bytes(pose_file.read()))
609                observations.append(observation)
610
611    metrics, matched_rows = compare_pose_tracks(
612        frame_ids,
613        cam_poses,
614        observations,
615        pose_axis_convention=pose_axis_convention,
616    )
617    for row in matched_rows:
618        all_rows[int(row["frame_idx"])].update(row)
619    metrics.update(
620        {
621            "object_id": sequence_dir.parent.name,
622            "sequence": str(sequence_dir),
623            "total_paired_frames": len(all_rows),
624            "detection_coverage": len(frame_ids) / max(len(all_rows), 1),
625            "raw_detected_frames": raw_detections,
626            "raw_detection_coverage": raw_detections / max(len(all_rows), 1),
627        }
628    )
629    rows = [dict(object_id=sequence_dir.parent.name, **all_rows[i]) for i in sorted(all_rows)]
630    return metrics, rows
631
632
633def aggregate_sequence_metrics(sequences: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
634    """Produce frame-weighted aggregate values from per-sequence summaries."""
635
636    total = sum(int(item.get("total_paired_frames", 0)) for item in sequences)
637    matched = sum(int(item.get("matched_frames", 0)) for item in sequences)
638    raw_matched = sum(int(item.get("raw_detected_frames", 0)) for item in sequences)
639    scalar_keys = (
640        "anchor_relative_translation_rmse_m",
641        "anchor_relative_rotation_rmse_deg",
642        "step_translation_rmse_m",
643        "step_rotation_rmse_deg",
644        "step_translation_rmse_m_per_frame",
645        "step_rotation_rmse_deg_per_frame",
646        "static_board_translation_drift_rmse_m",
647        "static_board_rotation_drift_rmse_deg",
648        "static_board_translation_drift_slope_m_per_frame",
649        "static_board_rotation_drift_slope_deg_per_frame",
650        "static_board_robust_outlier_rate",
651        "mean_marker_count",
652        "mean_reprojection_rmse_px",
653    )
654    result: dict[str, Any] = {
655        "sequences": len(sequences),
656        "sequences_with_detections": sum(
657            int(item.get("matched_frames", 0)) > 0 for item in sequences
658        ),
659        "total_frames": total,
660        "matched_frames": matched,
661        "detection_coverage": matched / max(total, 1),
662        "raw_detected_frames": raw_matched,
663        "raw_detection_coverage": raw_matched / max(total, 1),
664    }
665    for key in scalar_keys:
666        values = [
667            (float(item[key]), int(item.get("matched_frames", 0)))
668            for item in sequences
669            if item.get(key) is not None and int(item.get("matched_frames", 0)) > 0
670        ]
671        result[key] = (
672            float(
673                np.average([value for value, _ in values], weights=[weight for _, weight in values])
674            )
675            if values
676            else None
677        )
678    for family in ("translation_correlation", "rotation_vector_correlation"):
679        result[family] = {}
680        for component in ("x", "y", "z", "all_components", "magnitude"):
681            values = [
682                (float(item[family][component]), int(item["matched_frames"]))
683                for item in sequences
684                if item.get(family, {}).get(component) is not None
685            ]
686            result[family][component] = (
687                float(
688                    np.average(
689                        [value for value, _ in values], weights=[weight for _, weight in values]
690                    )
691                )
692                if values
693                else None
694            )
695    return result
696
697
698def write_results(
699    output_dir: str | Path,
700    sequence_metrics: Sequence[Mapping[str, Any]],
701    frame_rows: Iterable[Mapping[str, Any]],
702    metadata: Mapping[str, Any],
703) -> dict[str, Any]:
704    """Write JSON, CSV and a concise Markdown report."""
705
706    output_dir = Path(output_dir)
707    output_dir.mkdir(parents=True, exist_ok=True)
708    frame_rows = list(frame_rows)
709    aggregate = aggregate_sequence_metrics(sequence_metrics)
710    valid_rows = [row for row in frame_rows if row.get("detected")]
711    for family, column in (
712        ("static_board_translation_jitter", "static_board_translation_jitter_m"),
713        ("static_board_rotation_jitter", "static_board_rotation_jitter_deg"),
714    ):
715        aggregate[family] = _distribution(
716            [float(row[column]) for row in valid_rows if row.get(column) is not None]
717        )
718    robust_inlier_rows = [
719        row for row in valid_rows if not bool(row.get("static_board_robust_outlier"))
720    ]
721    for family, column in (
722        (
723            "static_board_translation_jitter_robust_inliers",
724            "static_board_translation_jitter_m",
725        ),
726        (
727            "static_board_rotation_jitter_robust_inliers",
728            "static_board_rotation_jitter_deg",
729        ),
730    ):
731        aggregate[family] = _distribution(
732            [float(row[column]) for row in robust_inlier_rows if row.get(column) is not None]
733        )
734    aggregate["static_board_robust_outlier_rate"] = (
735        float(np.mean([bool(row.get("static_board_robust_outlier")) for row in valid_rows]))
736        if valid_rows
737        else None
738    )
739    payload = {
740        "protocol": dict(metadata),
741        "aggregate": aggregate,
742        "per_sequence": list(sequence_metrics),
743    }
744    (output_dir / "summary.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
745
746    flattened = []
747    nested_families = (
748        "translation_correlation",
749        "rotation_vector_correlation",
750        "static_board_translation_jitter",
751        "static_board_rotation_jitter",
752        "static_board_translation_jitter_robust_inliers",
753        "static_board_rotation_jitter_robust_inliers",
754    )
755    for item in sequence_metrics:
756        row = {key: value for key, value in item.items() if not isinstance(value, Mapping)}
757        for family in nested_families:
758            for key, value in item.get(family, {}).items():
759                row[f"{family}.{key}"] = value
760        flattened.append(row)
761    _write_csv(output_dir / "per_sequence.csv", flattened)
762    _write_csv(output_dir / "per_frame.csv", frame_rows)
763
764    def fmt(value: Any, digits: int = 4) -> str:
765        return "n/a" if value is None else f"{float(value):.{digits}f}"
766
767    report = f"""# RPX SOS AR-board / camera-pose audit
768
769## Protocol
770
771- Dataset: `{metadata.get("dataset_repo", "unknown")}` at revision `{metadata.get("dataset_revision", "unknown")}`
772- Sequences: {aggregate["sequences"]} ({aggregate["sequences_with_detections"]} with detections)
773- Frames inspected: {aggregate["total_frames"]}
774- Frames with a valid AR-board pose: {aggregate["matched_frames"]} ({aggregate["detection_coverage"]:.1%})
775- Frames with any raw tag pose: {aggregate["raw_detected_frames"]} ({aggregate["raw_detection_coverage"]:.1%})
776- Camera matrix: `{metadata["camera_matrix"]}`
777- Sensor-frame assumption: D435 RGB and saved T265 camera poses are already aligned.
778- Sensor transform fitting: **none**.
779- World-origin handling: relative poses from the first detected frame in each sequence.
780- Primary quality gate: at least {metadata["min_markers"]} RANSAC-consistent markers and reprojection RMSE <= {metadata["max_reprojection_rmse_px"]} px.
781
782## Aggregate results
783
784| Measure | Value |
785|---|---:|
786| Translation correlation, all components | {fmt(aggregate["translation_correlation"]["all_components"])} |
787| Translation-magnitude correlation | {fmt(aggregate["translation_correlation"]["magnitude"])} |
788| Rotation-vector correlation, all components | {fmt(aggregate["rotation_vector_correlation"]["all_components"])} |
789| Rotation-magnitude correlation | {fmt(aggregate["rotation_vector_correlation"]["magnitude"])} |
790| Anchor-relative translation RMSE | {fmt(aggregate["anchor_relative_translation_rmse_m"])} m |
791| Anchor-relative rotation RMSE | {fmt(aggregate["anchor_relative_rotation_rmse_deg"])} deg |
792| Step translation RMSE | {fmt(aggregate["step_translation_rmse_m"])} m |
793| Step rotation RMSE | {fmt(aggregate["step_rotation_rmse_deg"])} deg |
794| Gap-normalized step translation RMSE | {fmt(aggregate["step_translation_rmse_m_per_frame"])} m/frame |
795| Gap-normalized step rotation RMSE | {fmt(aggregate["step_rotation_rmse_deg_per_frame"])} deg/frame |
796| Static-board translation drift RMSE | {fmt(aggregate["static_board_translation_drift_rmse_m"])} m |
797| Static-board rotation drift RMSE | {fmt(aggregate["static_board_rotation_drift_rmse_deg"])} deg |
798| Static-board translation jitter, median / P95 | {fmt(aggregate["static_board_translation_jitter"]["median"])} / {fmt(aggregate["static_board_translation_jitter"]["p95"])} m |
799| Static-board rotation jitter, median / P95 | {fmt(aggregate["static_board_rotation_jitter"]["median"])} / {fmt(aggregate["static_board_rotation_jitter"]["p95"])} deg |
800| Robust-inlier translation jitter, median / P95 | {fmt(aggregate["static_board_translation_jitter_robust_inliers"]["median"])} / {fmt(aggregate["static_board_translation_jitter_robust_inliers"]["p95"])} m |
801| Robust-inlier rotation jitter, median / P95 | {fmt(aggregate["static_board_rotation_jitter_robust_inliers"]["median"])} / {fmt(aggregate["static_board_rotation_jitter_robust_inliers"]["p95"])} deg |
802| Translation drift slope | {fmt(aggregate["static_board_translation_drift_slope_m_per_frame"], 6)} m/frame |
803| Rotation drift slope | {fmt(aggregate["static_board_rotation_drift_slope_deg_per_frame"], 6)} deg/frame |
804| Robust residual outlier rate | {fmt(aggregate["static_board_robust_outlier_rate"])} |
805| Mean marker count | {fmt(aggregate["mean_marker_count"], 2)} |
806| Mean marker reprojection RMSE | {fmt(aggregate["mean_reprojection_rmse_px"])} px |
807
808## Automated finding
809
810The AR-derived and stored motion tracks are strongly correlated (translation
811`{fmt(aggregate["translation_correlation"]["all_components"])}`, rotation
812`{fmt(aggregate["rotation_vector_correlation"]["all_components"])}`). Across accepted frames,
813the fixed-board residual has median/P95 translation
814`{fmt(aggregate["static_board_translation_jitter"]["median"])} / {fmt(aggregate["static_board_translation_jitter"]["p95"])} m`
815and median/P95 rotation
816`{fmt(aggregate["static_board_rotation_jitter"]["median"])} / {fmt(aggregate["static_board_rotation_jitter"]["p95"])} deg`.
817After removing the automatically identified median/MAD residual outliers, those P95 values are
818`{fmt(aggregate["static_board_translation_jitter_robust_inliers"]["p95"])} m` and
819`{fmt(aggregate["static_board_rotation_jitter_robust_inliers"]["p95"])} deg`.
820
821These values show measurable disagreement, but they are an upper bound on T265 pose error rather
822than a pure T265 accuracy measurement. The released files do not contain the capture device's
823factory distortion coefficients or a numerical D435-to-T265 extrinsic. The residual therefore also
824contains tag-corner/PnP uncertainty, approximate-intrinsics error, synchronization error and any
825unmodelled sensor-frame offset. Large orientation branches in the per-sequence PDF should be
826treated as planar-PnP/reference failures until independently verified.
827
828## Interpretation
829
830Relative-motion metrics remove only the arbitrary world origin; they do not learn or apply a
831D435-to-T265 transform. The static-board residual is an independent consistency check: with
832correct synchronization, conventions and the stated camera-frame alignment, the composed board
833pose should be constant. Review per-sequence and per-frame files before drawing conclusions from
834the aggregate correlations, especially where marker coverage or reprojection quality is low.
835
836The first-frame drift metrics are directly comparable to anchor-based ATE. The central-pose jitter
837metrics use the sequence median position and rotation medoid, making them less sensitive to one
838bad anchor. Gap-normalized step errors are the RPE-style local-motion measurements. A robust
839outlier is a translation or rotation residual more than 3.5 scaled MAD above its sequence median.
840"""
841    (output_dir / "report.md").write_text(report)
842    return payload
843
844
845def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]]) -> None:
846    keys = sorted({key for row in rows for key in row})
847    with path.open("w", newline="") as handle:
848        writer = csv.DictWriter(handle, fieldnames=keys)
849        writer.writeheader()
850        writer.writerows(rows)
BOARD_WIDTH_M = 0.704
BOARD_HEIGHT_M = 0.504
MARKER_SIZE_M = 0.059
DEFAULT_CAMERA_MATRIX = array([[611.10888672, 0. , 315.51083374], [ 0. , 610.02844238, 237.73669434], [ 0. , 0. , 1. ]])
@dataclass(frozen=True)
class BoardObservation:
 93@dataclass(frozen=True)
 94class BoardObservation:
 95    """One D435 observation of the static marker board."""
 96
 97    camera_from_board: np.ndarray
 98    marker_ids: tuple[int, ...]
 99    reprojection_rmse_px: float
100    inlier_corners: int

One D435 observation of the static marker board.

BoardObservation( camera_from_board: numpy.ndarray, marker_ids: tuple[int, ...], reprojection_rmse_px: float, inlier_corners: int)
camera_from_board: numpy.ndarray
marker_ids: tuple[int, ...]
reprojection_rmse_px: float
inlier_corners: int
def alvar_dictionary() -> Any:
118def alvar_dictionary() -> Any:
119    """Build the canonical ALVAR 0--17 dictionary for OpenCV detection."""
120
121    cv2 = _cv2()
122    rows = []
123    for code in _ALVAR_5X5_BITS:
124        bits = np.fromiter((int(bit) for bit in code), dtype=np.uint8).reshape(5, 5)
125        rows.append(cv2.aruco.Dictionary_getByteListFromBits(bits))
126    byte_list = np.concatenate(rows, axis=0)
127    return cv2.aruco.Dictionary(byte_list, 5, 2)

Build the canonical ALVAR 0--17 dictionary for OpenCV detection.

def make_detector() -> Any:
130def make_detector() -> Any:
131    """Create an ALVAR detector tuned for the 640x480 RPX RGB frames."""
132
133    cv2 = _cv2()
134    params = cv2.aruco.DetectorParameters()
135    params.markerBorderBits = 2
136    params.minMarkerPerimeterRate = 0.01
137    params.maxMarkerPerimeterRate = 1.0
138    params.cornerRefinementMethod = cv2.aruco.CORNER_REFINE_SUBPIX
139    params.errorCorrectionRate = 0.8
140    return cv2.aruco.ArucoDetector(alvar_dictionary(), params)

Create an ALVAR detector tuned for the 640x480 RPX RGB frames.

def marker_object_corners(marker_id: int) -> numpy.ndarray:
143def marker_object_corners(marker_id: int) -> np.ndarray:
144    """Return TL, TR, BR, BL corners in the board-centred coordinate frame."""
145
146    if not 0 <= marker_id < len(_MARKER_CENTRES_CM):
147        raise ConfigError(f"unknown FewSOL marker id: {marker_id}")
148    x_cm, y_cm = _MARKER_CENTRES_CM[marker_id]
149    cx = x_cm * 0.01 - BOARD_WIDTH_M / 2.0
150    cy = BOARD_HEIGHT_M / 2.0 - y_cm * 0.01
151    half = MARKER_SIZE_M / 2.0
152    return np.array(
153        [
154            [cx - half, cy + half, 0.0],
155            [cx + half, cy + half, 0.0],
156            [cx + half, cy - half, 0.0],
157            [cx - half, cy - half, 0.0],
158        ],
159        dtype=np.float64,
160    )

Return TL, TR, BR, BL corners in the board-centred coordinate frame.

def estimate_board_pose( image: numpy.ndarray, detector: Any, camera_matrix: numpy.ndarray = array([[611.10888672, 0. , 315.51083374], [ 0. , 610.02844238, 237.73669434], [ 0. , 0. , 1. ]]), distortion: numpy.ndarray | None = None, *, reprojection_threshold_px: float = 4.0) -> BoardObservation | None:
163def estimate_board_pose(
164    image: np.ndarray,
165    detector: Any,
166    camera_matrix: np.ndarray = DEFAULT_CAMERA_MATRIX,
167    distortion: np.ndarray | None = None,
168    *,
169    reprojection_threshold_px: float = 4.0,
170) -> BoardObservation | None:
171    """Detect visible ALVAR tags and estimate ``T_camera_from_board``."""
172
173    cv2 = _cv2()
174    corners, ids, _rejected = detector.detectMarkers(image)
175    if ids is None or not len(ids):
176        return None
177
178    # A duplicated ID is geometrically ambiguous; keep the largest detection.
179    by_id: dict[int, np.ndarray] = {}
180    for marker_id, image_corners in zip(ids.ravel().tolist(), corners, strict=True):
181        marker_id = int(marker_id)
182        candidate = np.asarray(image_corners, dtype=np.float64).reshape(4, 2)
183        previous = by_id.get(marker_id)
184        if previous is None or abs(cv2.contourArea(candidate.astype(np.float32))) > abs(
185            cv2.contourArea(previous.astype(np.float32))
186        ):
187            by_id[marker_id] = candidate
188
189    candidate_ids = tuple(sorted(by_id))
190    object_points = np.concatenate([marker_object_corners(i) for i in candidate_ids])
191    image_points = np.concatenate([by_id[i] for i in candidate_ids])
192    distortion = (
193        np.zeros(5, dtype=np.float64)
194        if distortion is None
195        else np.asarray(distortion, dtype=np.float64)
196    )
197    K = np.asarray(camera_matrix, dtype=np.float64)
198
199    if len(candidate_ids) == 1:
200        ok, rvec, tvec = cv2.solvePnP(
201            object_points,
202            image_points,
203            K,
204            distortion,
205            flags=cv2.SOLVEPNP_IPPE,
206        )
207        inliers = np.arange(4, dtype=np.int32).reshape(-1, 1)
208    else:
209        ok, rvec, tvec, inliers = cv2.solvePnPRansac(
210            object_points,
211            image_points,
212            K,
213            distortion,
214            reprojectionError=float(reprojection_threshold_px),
215            confidence=0.999,
216            iterationsCount=200,
217            flags=cv2.SOLVEPNP_ITERATIVE,
218        )
219    if not ok or inliers is None or len(inliers) < 4:
220        return None
221
222    keep = inliers.ravel()
223    marker_ids = tuple(sorted({candidate_ids[int(index) // 4] for index in keep}))
224    ok, rvec, tvec = cv2.solvePnP(
225        object_points[keep],
226        image_points[keep],
227        K,
228        distortion,
229        rvec,
230        tvec,
231        True,
232        flags=cv2.SOLVEPNP_ITERATIVE,
233    )
234    if not ok:
235        return None
236    projected, _ = cv2.projectPoints(object_points[keep], rvec, tvec, K, distortion)
237    residual = projected.reshape(-1, 2) - image_points[keep]
238    rmse = float(np.sqrt(np.mean(np.sum(residual * residual, axis=1))))
239    rotation, _ = cv2.Rodrigues(rvec)
240    transform = np.eye(4, dtype=np.float64)
241    transform[:3, :3] = rotation
242    transform[:3, 3] = np.asarray(tvec).reshape(3)
243    return BoardObservation(transform, marker_ids, rmse, int(len(keep)))

Detect visible ALVAR tags and estimate T_camera_from_board.

def pose_from_npz_bytes(payload: bytes) -> numpy.ndarray:
246def pose_from_npz_bytes(payload: bytes) -> np.ndarray:
247    """Load an RPX T265 camera-to-world pose from an NPZ payload."""
248
249    with np.load(io.BytesIO(payload)) as data:
250        position = np.asarray(data["position"], dtype=np.float64).reshape(3)
251        quaternion = np.asarray(data["orientation"], dtype=np.float64).reshape(4)
252    transform = np.eye(4, dtype=np.float64)
253    transform[:3, :3] = Rotation.from_quat(quaternion).as_matrix()
254    transform[:3, 3] = position
255    return transform

Load an RPX T265 camera-to-world pose from an NPZ payload.

def rotation_error_deg(a: numpy.ndarray, b: numpy.ndarray) -> float:
258def rotation_error_deg(a: np.ndarray, b: np.ndarray) -> float:
259    relative = a[:3, :3].T @ b[:3, :3]
260    cosine = np.clip((np.trace(relative) - 1.0) / 2.0, -1.0, 1.0)
261    return float(np.degrees(np.arccos(cosine)))
def relative_pose(reference: numpy.ndarray, current: numpy.ndarray) -> numpy.ndarray:
264def relative_pose(reference: np.ndarray, current: np.ndarray) -> np.ndarray:
265    return np.linalg.inv(reference) @ current
def t265_to_opencv_pose(pose: numpy.ndarray) -> numpy.ndarray:
268def t265_to_opencv_pose(pose: np.ndarray) -> np.ndarray:
269    """Express a raw librealsense T265 pose in the OpenCV axis convention."""
270
271    return _T265_TO_OPENCV @ np.asarray(pose, dtype=np.float64) @ _T265_TO_OPENCV

Express a raw librealsense T265 pose in the OpenCV axis convention.

def compare_pose_tracks( frame_ids: Sequence[int], cam_poses: Sequence[numpy.ndarray], board_observations: Sequence[BoardObservation], *, pose_axis_convention: str = 't265') -> tuple[dict[str, typing.Any], list[dict[str, typing.Any]]]:
338def compare_pose_tracks(
339    frame_ids: Sequence[int],
340    cam_poses: Sequence[np.ndarray],
341    board_observations: Sequence[BoardObservation],
342    *,
343    pose_axis_convention: str = "t265",
344) -> tuple[dict[str, Any], list[dict[str, Any]]]:
345    """Compare AR and saved motion without estimating a sensor transform."""
346
347    if not (len(frame_ids) == len(cam_poses) == len(board_observations)):
348        raise ConfigError("frame, camera-pose and board-observation counts differ")
349    if not frame_ids:
350        return {"matched_frames": 0}, []
351
352    if pose_axis_convention == "t265":
353        cam_poses = [t265_to_opencv_pose(pose) for pose in cam_poses]
354    elif pose_axis_convention != "opencv":
355        raise ConfigError("pose_axis_convention must be 't265' or 'opencv'")
356
357    ar_poses = [np.linalg.inv(observation.camera_from_board) for observation in board_observations]
358    cam_anchor, ar_anchor = cam_poses[0], ar_poses[0]
359    cam_relative = [relative_pose(cam_anchor, pose) for pose in cam_poses]
360    ar_relative = [relative_pose(ar_anchor, pose) for pose in ar_poses]
361
362    frame_rows: list[dict[str, Any]] = []
363    translation_errors = []
364    rotation_errors = []
365    cam_translations = []
366    ar_translations = []
367    cam_rotvecs = []
368    ar_rotvecs = []
369    for frame_id, cam_rel, ar_rel, observation in zip(
370        frame_ids, cam_relative, ar_relative, board_observations, strict=True
371    ):
372        trans_error = float(np.linalg.norm(cam_rel[:3, 3] - ar_rel[:3, 3]))
373        rot_error = rotation_error_deg(cam_rel, ar_rel)
374        translation_errors.append(trans_error)
375        rotation_errors.append(rot_error)
376        cam_translations.append(cam_rel[:3, 3])
377        ar_translations.append(ar_rel[:3, 3])
378        cam_rotvecs.append(Rotation.from_matrix(cam_rel[:3, :3]).as_rotvec())
379        ar_rotvecs.append(Rotation.from_matrix(ar_rel[:3, :3]).as_rotvec())
380        frame_rows.append(
381            {
382                "frame_idx": int(frame_id),
383                "detected": True,
384                "marker_count": len(observation.marker_ids),
385                "marker_ids": ";".join(str(i) for i in observation.marker_ids),
386                "reprojection_rmse_px": observation.reprojection_rmse_px,
387                "translation_error_m": trans_error,
388                "rotation_error_deg": rot_error,
389            }
390        )
391
392    cam_t = np.asarray(cam_translations)
393    ar_t = np.asarray(ar_translations)
394    cam_r = np.asarray(cam_rotvecs)
395    ar_r = np.asarray(ar_rotvecs)
396
397    step_translation_errors = []
398    step_rotation_errors = []
399    step_translation_errors_per_frame = []
400    step_rotation_errors_per_frame = []
401    step_gaps = []
402    for index in range(1, len(frame_ids)):
403        cam_step = relative_pose(cam_poses[index - 1], cam_poses[index])
404        ar_step = relative_pose(ar_poses[index - 1], ar_poses[index])
405        step_translation_errors.append(float(np.linalg.norm(cam_step[:3, 3] - ar_step[:3, 3])))
406        step_rotation_errors.append(rotation_error_deg(cam_step, ar_step))
407        gap = frame_ids[index] - frame_ids[index - 1]
408        step_gaps.append(gap)
409        step_translation_errors_per_frame.append(step_translation_errors[-1] / max(gap, 1))
410        step_rotation_errors_per_frame.append(step_rotation_errors[-1] / max(gap, 1))
411
412    # If the frames are aligned and the board is static, this composition must
413    # be constant.  No world-origin or hand-eye fit is involved.
414    world_from_boards = [
415        cam @ obs.camera_from_board for cam, obs in zip(cam_poses, board_observations, strict=True)
416    ]
417    board_anchor = world_from_boards[0]
418    board_translation_drift = [
419        float(np.linalg.norm(pose[:3, 3] - board_anchor[:3, 3])) for pose in world_from_boards
420    ]
421    board_rotation_drift = [rotation_error_deg(board_anchor, pose) for pose in world_from_boards]
422
423    # Jitter is measured around a sequence-level central pose rather than the
424    # first observation, which may itself be noisy.  The translation median is
425    # robust to isolated PnP failures; the geodesic rotation medoid avoids
426    # quaternion sign ambiguity and is not pulled toward planar-PnP flips.
427    board_positions = np.asarray([pose[:3, 3] for pose in world_from_boards])
428    board_rotations = Rotation.from_matrix([pose[:3, :3] for pose in world_from_boards])
429    board_position_centre = np.median(board_positions, axis=0)
430    board_rotation_centre = _rotation_medoid(board_rotations)
431    board_translation_jitter = np.linalg.norm(
432        board_positions - board_position_centre[None, :], axis=1
433    )
434    board_rotation_jitter = np.degrees(
435        (board_rotation_centre.inv() * board_rotations).magnitude()
436    )
437    translation_outliers = _robust_outliers(board_translation_jitter)
438    rotation_outliers = _robust_outliers(board_rotation_jitter)
439    combined_outliers = translation_outliers | rotation_outliers
440    rotation_residual_vectors = (
441        board_rotation_centre.inv() * board_rotations
442    ).as_rotvec()
443
444    for row, position, pose, translation_jitter, rotation_jitter, is_outlier in zip(
445        frame_rows,
446        board_positions,
447        world_from_boards,
448        board_translation_jitter,
449        board_rotation_jitter,
450        combined_outliers,
451        strict=True,
452    ):
453        row.update(
454            {
455                "world_board_x_m": float(position[0]),
456                "world_board_y_m": float(position[1]),
457                "world_board_z_m": float(position[2]),
458                "world_board_qx": float(Rotation.from_matrix(pose[:3, :3]).as_quat()[0]),
459                "world_board_qy": float(Rotation.from_matrix(pose[:3, :3]).as_quat()[1]),
460                "world_board_qz": float(Rotation.from_matrix(pose[:3, :3]).as_quat()[2]),
461                "world_board_qw": float(Rotation.from_matrix(pose[:3, :3]).as_quat()[3]),
462                "static_board_translation_jitter_m": float(translation_jitter),
463                "static_board_rotation_jitter_deg": float(rotation_jitter),
464                "static_board_robust_outlier": bool(is_outlier),
465            }
466        )
467
468    def mean(values: Sequence[float]) -> float | None:
469        return float(np.mean(values)) if values else None
470
471    def rmse(values: Sequence[float]) -> float | None:
472        return float(np.sqrt(np.mean(np.square(values)))) if values else None
473
474    summary: dict[str, Any] = {
475        "matched_frames": len(frame_ids),
476        "first_matched_frame": int(frame_ids[0]),
477        "last_matched_frame": int(frame_ids[-1]),
478        "translation_correlation": {
479            "x": _pearson(cam_t[:, 0], ar_t[:, 0]),
480            "y": _pearson(cam_t[:, 1], ar_t[:, 1]),
481            "z": _pearson(cam_t[:, 2], ar_t[:, 2]),
482            "all_components": _pearson(cam_t, ar_t),
483            "magnitude": _pearson(np.linalg.norm(cam_t, axis=1), np.linalg.norm(ar_t, axis=1)),
484        },
485        "rotation_vector_correlation": {
486            "x": _pearson(cam_r[:, 0], ar_r[:, 0]),
487            "y": _pearson(cam_r[:, 1], ar_r[:, 1]),
488            "z": _pearson(cam_r[:, 2], ar_r[:, 2]),
489            "all_components": _pearson(cam_r, ar_r),
490            "magnitude": _pearson(np.linalg.norm(cam_r, axis=1), np.linalg.norm(ar_r, axis=1)),
491        },
492        "anchor_relative_translation_rmse_m": rmse(translation_errors),
493        "anchor_relative_translation_mean_m": mean(translation_errors),
494        "anchor_relative_rotation_mean_deg": mean(rotation_errors),
495        "anchor_relative_rotation_rmse_deg": rmse(rotation_errors),
496        "step_translation_rmse_m": rmse(step_translation_errors),
497        "step_rotation_rmse_deg": rmse(step_rotation_errors),
498        "step_translation_rmse_m_per_frame": rmse(step_translation_errors_per_frame),
499        "step_rotation_rmse_deg_per_frame": rmse(step_rotation_errors_per_frame),
500        "mean_detected_frame_gap": mean(step_gaps),
501        "static_board_translation_drift_rmse_m": rmse(board_translation_drift),
502        "static_board_rotation_drift_rmse_deg": rmse(board_rotation_drift),
503        "static_board_translation_jitter": _distribution(board_translation_jitter),
504        "static_board_rotation_jitter": _distribution(board_rotation_jitter),
505        "static_board_translation_jitter_robust_inliers": _distribution(
506            board_translation_jitter[~combined_outliers]
507        ),
508        "static_board_rotation_jitter_robust_inliers": _distribution(
509            board_rotation_jitter[~combined_outliers]
510        ),
511        "static_board_translation_drift_slope_m_per_frame": _slope_norm(
512            frame_ids, board_positions
513        ),
514        "static_board_rotation_drift_slope_deg_per_frame": (
515            None
516            if (slope := _slope_norm(frame_ids, rotation_residual_vectors)) is None
517            else float(np.degrees(slope))
518        ),
519        "static_board_robust_outlier_rate": float(np.mean(combined_outliers)),
520        "mean_marker_count": mean([len(obs.marker_ids) for obs in board_observations]),
521        "mean_reprojection_rmse_px": mean([obs.reprojection_rmse_px for obs in board_observations]),
522    }
523    return summary, frame_rows

Compare AR and saved motion without estimating a sensor transform.

def audit_sequence( sequence_dir: str | pathlib.Path, camera_matrix: numpy.ndarray = array([[611.10888672, 0. , 315.51083374], [ 0. , 610.02844238, 237.73669434], [ 0. , 0. , 1. ]]), distortion: numpy.ndarray | None = None, *, max_frames: int | None = None, reprojection_threshold_px: float = 4.0, pose_axis_convention: str = 't265', min_markers: int = 2, max_reprojection_rmse_px: float = 2.0) -> tuple[dict[str, typing.Any], list[dict[str, typing.Any]]]:
537def audit_sequence(
538    sequence_dir: str | Path,
539    camera_matrix: np.ndarray = DEFAULT_CAMERA_MATRIX,
540    distortion: np.ndarray | None = None,
541    *,
542    max_frames: int | None = None,
543    reprojection_threshold_px: float = 4.0,
544    pose_axis_convention: str = "t265",
545    min_markers: int = 2,
546    max_reprojection_rmse_px: float = 2.0,
547) -> tuple[dict[str, Any], list[dict[str, Any]]]:
548    """Audit one ``objects/<id>/0`` directory directly from tar shards."""
549
550    sequence_dir = Path(sequence_dir)
551    rgb_path = sequence_dir / "rgb.tar"
552    pose_path = sequence_dir / "labels" / "cam_pose" / "v1.tar"
553    if not rgb_path.is_file() or not pose_path.is_file():
554        raise FileNotFoundError(f"missing RGB or camera-pose archive under {sequence_dir}")
555
556    detector = make_detector()
557    frame_ids: list[int] = []
558    cam_poses: list[np.ndarray] = []
559    observations: list[BoardObservation] = []
560    all_rows: dict[int, dict[str, Any]] = {}
561    raw_detections = 0
562    cv2 = _cv2()
563    with tarfile.open(rgb_path) as rgb_tar, tarfile.open(pose_path) as pose_tar:
564        rgb_members = _member_map(rgb_tar, ".png")
565        pose_members = _member_map(pose_tar, ".npz")
566        candidates = sorted(set(rgb_members) & set(pose_members))
567        if max_frames is not None:
568            candidates = candidates[:max_frames]
569        for frame_id in candidates:
570            rgb_file = rgb_tar.extractfile(rgb_members[frame_id])
571            pose_file = pose_tar.extractfile(pose_members[frame_id])
572            if rgb_file is None or pose_file is None:
573                continue
574            encoded = np.frombuffer(rgb_file.read(), dtype=np.uint8)
575            image = cv2.imdecode(encoded, cv2.IMREAD_COLOR)
576            observation = estimate_board_pose(
577                image,
578                detector,
579                camera_matrix,
580                distortion,
581                reprojection_threshold_px=reprojection_threshold_px,
582            )
583            accepted = observation is not None
584            reject_reason = ""
585            if observation is not None:
586                raw_detections += 1
587                if len(observation.marker_ids) < min_markers:
588                    accepted = False
589                    reject_reason = "insufficient_inlier_markers"
590                elif observation.reprojection_rmse_px > max_reprojection_rmse_px:
591                    accepted = False
592                    reject_reason = "high_reprojection_error"
593            all_rows[frame_id] = {
594                "frame_idx": frame_id,
595                "raw_detected": observation is not None,
596                "detected": accepted,
597                "reject_reason": reject_reason,
598            }
599            if observation is not None:
600                all_rows[frame_id].update(
601                    {
602                        "marker_count": len(observation.marker_ids),
603                        "marker_ids": ";".join(str(i) for i in observation.marker_ids),
604                        "reprojection_rmse_px": observation.reprojection_rmse_px,
605                    }
606                )
607            if accepted and observation is not None:
608                frame_ids.append(frame_id)
609                cam_poses.append(pose_from_npz_bytes(pose_file.read()))
610                observations.append(observation)
611
612    metrics, matched_rows = compare_pose_tracks(
613        frame_ids,
614        cam_poses,
615        observations,
616        pose_axis_convention=pose_axis_convention,
617    )
618    for row in matched_rows:
619        all_rows[int(row["frame_idx"])].update(row)
620    metrics.update(
621        {
622            "object_id": sequence_dir.parent.name,
623            "sequence": str(sequence_dir),
624            "total_paired_frames": len(all_rows),
625            "detection_coverage": len(frame_ids) / max(len(all_rows), 1),
626            "raw_detected_frames": raw_detections,
627            "raw_detection_coverage": raw_detections / max(len(all_rows), 1),
628        }
629    )
630    rows = [dict(object_id=sequence_dir.parent.name, **all_rows[i]) for i in sorted(all_rows)]
631    return metrics, rows

Audit one objects/<id>/0 directory directly from tar shards.

def aggregate_sequence_metrics(sequences: Sequence[Mapping[str, Any]]) -> dict[str, typing.Any]:
634def aggregate_sequence_metrics(sequences: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
635    """Produce frame-weighted aggregate values from per-sequence summaries."""
636
637    total = sum(int(item.get("total_paired_frames", 0)) for item in sequences)
638    matched = sum(int(item.get("matched_frames", 0)) for item in sequences)
639    raw_matched = sum(int(item.get("raw_detected_frames", 0)) for item in sequences)
640    scalar_keys = (
641        "anchor_relative_translation_rmse_m",
642        "anchor_relative_rotation_rmse_deg",
643        "step_translation_rmse_m",
644        "step_rotation_rmse_deg",
645        "step_translation_rmse_m_per_frame",
646        "step_rotation_rmse_deg_per_frame",
647        "static_board_translation_drift_rmse_m",
648        "static_board_rotation_drift_rmse_deg",
649        "static_board_translation_drift_slope_m_per_frame",
650        "static_board_rotation_drift_slope_deg_per_frame",
651        "static_board_robust_outlier_rate",
652        "mean_marker_count",
653        "mean_reprojection_rmse_px",
654    )
655    result: dict[str, Any] = {
656        "sequences": len(sequences),
657        "sequences_with_detections": sum(
658            int(item.get("matched_frames", 0)) > 0 for item in sequences
659        ),
660        "total_frames": total,
661        "matched_frames": matched,
662        "detection_coverage": matched / max(total, 1),
663        "raw_detected_frames": raw_matched,
664        "raw_detection_coverage": raw_matched / max(total, 1),
665    }
666    for key in scalar_keys:
667        values = [
668            (float(item[key]), int(item.get("matched_frames", 0)))
669            for item in sequences
670            if item.get(key) is not None and int(item.get("matched_frames", 0)) > 0
671        ]
672        result[key] = (
673            float(
674                np.average([value for value, _ in values], weights=[weight for _, weight in values])
675            )
676            if values
677            else None
678        )
679    for family in ("translation_correlation", "rotation_vector_correlation"):
680        result[family] = {}
681        for component in ("x", "y", "z", "all_components", "magnitude"):
682            values = [
683                (float(item[family][component]), int(item["matched_frames"]))
684                for item in sequences
685                if item.get(family, {}).get(component) is not None
686            ]
687            result[family][component] = (
688                float(
689                    np.average(
690                        [value for value, _ in values], weights=[weight for _, weight in values]
691                    )
692                )
693                if values
694                else None
695            )
696    return result

Produce frame-weighted aggregate values from per-sequence summaries.

def write_results( output_dir: str | pathlib.Path, sequence_metrics: Sequence[Mapping[str, Any]], frame_rows: Iterable[Mapping[str, Any]], metadata: Mapping[str, Any]) -> dict[str, typing.Any]:
699def write_results(
700    output_dir: str | Path,
701    sequence_metrics: Sequence[Mapping[str, Any]],
702    frame_rows: Iterable[Mapping[str, Any]],
703    metadata: Mapping[str, Any],
704) -> dict[str, Any]:
705    """Write JSON, CSV and a concise Markdown report."""
706
707    output_dir = Path(output_dir)
708    output_dir.mkdir(parents=True, exist_ok=True)
709    frame_rows = list(frame_rows)
710    aggregate = aggregate_sequence_metrics(sequence_metrics)
711    valid_rows = [row for row in frame_rows if row.get("detected")]
712    for family, column in (
713        ("static_board_translation_jitter", "static_board_translation_jitter_m"),
714        ("static_board_rotation_jitter", "static_board_rotation_jitter_deg"),
715    ):
716        aggregate[family] = _distribution(
717            [float(row[column]) for row in valid_rows if row.get(column) is not None]
718        )
719    robust_inlier_rows = [
720        row for row in valid_rows if not bool(row.get("static_board_robust_outlier"))
721    ]
722    for family, column in (
723        (
724            "static_board_translation_jitter_robust_inliers",
725            "static_board_translation_jitter_m",
726        ),
727        (
728            "static_board_rotation_jitter_robust_inliers",
729            "static_board_rotation_jitter_deg",
730        ),
731    ):
732        aggregate[family] = _distribution(
733            [float(row[column]) for row in robust_inlier_rows if row.get(column) is not None]
734        )
735    aggregate["static_board_robust_outlier_rate"] = (
736        float(np.mean([bool(row.get("static_board_robust_outlier")) for row in valid_rows]))
737        if valid_rows
738        else None
739    )
740    payload = {
741        "protocol": dict(metadata),
742        "aggregate": aggregate,
743        "per_sequence": list(sequence_metrics),
744    }
745    (output_dir / "summary.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
746
747    flattened = []
748    nested_families = (
749        "translation_correlation",
750        "rotation_vector_correlation",
751        "static_board_translation_jitter",
752        "static_board_rotation_jitter",
753        "static_board_translation_jitter_robust_inliers",
754        "static_board_rotation_jitter_robust_inliers",
755    )
756    for item in sequence_metrics:
757        row = {key: value for key, value in item.items() if not isinstance(value, Mapping)}
758        for family in nested_families:
759            for key, value in item.get(family, {}).items():
760                row[f"{family}.{key}"] = value
761        flattened.append(row)
762    _write_csv(output_dir / "per_sequence.csv", flattened)
763    _write_csv(output_dir / "per_frame.csv", frame_rows)
764
765    def fmt(value: Any, digits: int = 4) -> str:
766        return "n/a" if value is None else f"{float(value):.{digits}f}"
767
768    report = f"""# RPX SOS AR-board / camera-pose audit
769
770## Protocol
771
772- Dataset: `{metadata.get("dataset_repo", "unknown")}` at revision `{metadata.get("dataset_revision", "unknown")}`
773- Sequences: {aggregate["sequences"]} ({aggregate["sequences_with_detections"]} with detections)
774- Frames inspected: {aggregate["total_frames"]}
775- Frames with a valid AR-board pose: {aggregate["matched_frames"]} ({aggregate["detection_coverage"]:.1%})
776- Frames with any raw tag pose: {aggregate["raw_detected_frames"]} ({aggregate["raw_detection_coverage"]:.1%})
777- Camera matrix: `{metadata["camera_matrix"]}`
778- Sensor-frame assumption: D435 RGB and saved T265 camera poses are already aligned.
779- Sensor transform fitting: **none**.
780- World-origin handling: relative poses from the first detected frame in each sequence.
781- Primary quality gate: at least {metadata["min_markers"]} RANSAC-consistent markers and reprojection RMSE <= {metadata["max_reprojection_rmse_px"]} px.
782
783## Aggregate results
784
785| Measure | Value |
786|---|---:|
787| Translation correlation, all components | {fmt(aggregate["translation_correlation"]["all_components"])} |
788| Translation-magnitude correlation | {fmt(aggregate["translation_correlation"]["magnitude"])} |
789| Rotation-vector correlation, all components | {fmt(aggregate["rotation_vector_correlation"]["all_components"])} |
790| Rotation-magnitude correlation | {fmt(aggregate["rotation_vector_correlation"]["magnitude"])} |
791| Anchor-relative translation RMSE | {fmt(aggregate["anchor_relative_translation_rmse_m"])} m |
792| Anchor-relative rotation RMSE | {fmt(aggregate["anchor_relative_rotation_rmse_deg"])} deg |
793| Step translation RMSE | {fmt(aggregate["step_translation_rmse_m"])} m |
794| Step rotation RMSE | {fmt(aggregate["step_rotation_rmse_deg"])} deg |
795| Gap-normalized step translation RMSE | {fmt(aggregate["step_translation_rmse_m_per_frame"])} m/frame |
796| Gap-normalized step rotation RMSE | {fmt(aggregate["step_rotation_rmse_deg_per_frame"])} deg/frame |
797| Static-board translation drift RMSE | {fmt(aggregate["static_board_translation_drift_rmse_m"])} m |
798| Static-board rotation drift RMSE | {fmt(aggregate["static_board_rotation_drift_rmse_deg"])} deg |
799| Static-board translation jitter, median / P95 | {fmt(aggregate["static_board_translation_jitter"]["median"])} / {fmt(aggregate["static_board_translation_jitter"]["p95"])} m |
800| Static-board rotation jitter, median / P95 | {fmt(aggregate["static_board_rotation_jitter"]["median"])} / {fmt(aggregate["static_board_rotation_jitter"]["p95"])} deg |
801| Robust-inlier translation jitter, median / P95 | {fmt(aggregate["static_board_translation_jitter_robust_inliers"]["median"])} / {fmt(aggregate["static_board_translation_jitter_robust_inliers"]["p95"])} m |
802| Robust-inlier rotation jitter, median / P95 | {fmt(aggregate["static_board_rotation_jitter_robust_inliers"]["median"])} / {fmt(aggregate["static_board_rotation_jitter_robust_inliers"]["p95"])} deg |
803| Translation drift slope | {fmt(aggregate["static_board_translation_drift_slope_m_per_frame"], 6)} m/frame |
804| Rotation drift slope | {fmt(aggregate["static_board_rotation_drift_slope_deg_per_frame"], 6)} deg/frame |
805| Robust residual outlier rate | {fmt(aggregate["static_board_robust_outlier_rate"])} |
806| Mean marker count | {fmt(aggregate["mean_marker_count"], 2)} |
807| Mean marker reprojection RMSE | {fmt(aggregate["mean_reprojection_rmse_px"])} px |
808
809## Automated finding
810
811The AR-derived and stored motion tracks are strongly correlated (translation
812`{fmt(aggregate["translation_correlation"]["all_components"])}`, rotation
813`{fmt(aggregate["rotation_vector_correlation"]["all_components"])}`). Across accepted frames,
814the fixed-board residual has median/P95 translation
815`{fmt(aggregate["static_board_translation_jitter"]["median"])} / {fmt(aggregate["static_board_translation_jitter"]["p95"])} m`
816and median/P95 rotation
817`{fmt(aggregate["static_board_rotation_jitter"]["median"])} / {fmt(aggregate["static_board_rotation_jitter"]["p95"])} deg`.
818After removing the automatically identified median/MAD residual outliers, those P95 values are
819`{fmt(aggregate["static_board_translation_jitter_robust_inliers"]["p95"])} m` and
820`{fmt(aggregate["static_board_rotation_jitter_robust_inliers"]["p95"])} deg`.
821
822These values show measurable disagreement, but they are an upper bound on T265 pose error rather
823than a pure T265 accuracy measurement. The released files do not contain the capture device's
824factory distortion coefficients or a numerical D435-to-T265 extrinsic. The residual therefore also
825contains tag-corner/PnP uncertainty, approximate-intrinsics error, synchronization error and any
826unmodelled sensor-frame offset. Large orientation branches in the per-sequence PDF should be
827treated as planar-PnP/reference failures until independently verified.
828
829## Interpretation
830
831Relative-motion metrics remove only the arbitrary world origin; they do not learn or apply a
832D435-to-T265 transform. The static-board residual is an independent consistency check: with
833correct synchronization, conventions and the stated camera-frame alignment, the composed board
834pose should be constant. Review per-sequence and per-frame files before drawing conclusions from
835the aggregate correlations, especially where marker coverage or reprojection quality is low.
836
837The first-frame drift metrics are directly comparable to anchor-based ATE. The central-pose jitter
838metrics use the sequence median position and rotation medoid, making them less sensitive to one
839bad anchor. Gap-normalized step errors are the RPE-style local-motion measurements. A robust
840outlier is a translation or rotation residual more than 3.5 scaled MAD above its sequence median.
841"""
842    (output_dir / "report.md").write_text(report)
843    return payload

Write JSON, CSV and a concise Markdown report.