Source code for pose3d.triangulation

"""
Pure-geometry multi-view triangulation for the 3D Pose Reconstruction plugin
(analysis/run_pose3d.py). Turns each camera's already-computed 2D COCO
keypoints (analysis/pose/keypoints.py, written by run_pose.py) into 3D
room-space points, using the room extrinsic calibration from item 19's
ChArUco room calibration (src/calibration/room_calibration_manager.hpp,
CalibrationData::extrinsicRt, reaching this script via session_meta.json).

Zero new ML dependencies — everything here is numpy/opencv-python linear
algebra, both already base analysis/ dependencies. Follows the same "pure,
cv2-free, directly-testable core function isolated from the one necessary
cv2 call" discipline as analysis/gaze/ray_math.py: the DLT solve itself
(triangulate_point_dlt) never touches cv2; only normalize_point()
(undistortion) and reproject_error_px() (real per-camera reprojection,
needed for an interpretable, distortion-aware quality metric) do.

Convention (matches CalibrationData::extrinsicRt / room_frame::Mat4 exactly
— see src/core/settings.hpp and src/calibration/room_frame_solver.hpp):
extrinsic_rt is a row-major 4x4 homogeneous transform, ROOM_FROM_CAMERA
(point_room = R @ point_camera + t), camera 0 always identity, translation
in millimetres.
"""

from __future__ import annotations

from dataclasses import dataclass, field

import cv2
import numpy as np


[docs] @dataclass class CameraGeom: """One calibrated camera's geometry, as needed for triangulation. Attributes ---------- index : int Camera index, matching the session's ``camN`` numbering. camera_matrix : numpy.ndarray Real (distorted-space) intrinsic matrix, reshaped to ``(3, 3)``. dist_coeffs : numpy.ndarray OpenCV distortion coefficients, reshaped to a flat vector. extrinsic_rt : numpy.ndarray Row-major rigid transform, reshaped to ``(4, 4)`` — room-from-camera (``point_room = R @ point_camera + t``), matching ``CalibrationData::extrinsicRt`` exactly (see :doc:`/math/room_calibration`). camera_from_room : numpy.ndarray Computed automatically as ``invert_rt(extrinsic_rt)`` — the inverse transform every projection in this module actually uses. """ index: int camera_matrix: np.ndarray # (3,3) dist_coeffs: np.ndarray # (5,) extrinsic_rt: np.ndarray # (4,4) or flat-16, row-major, room_from_camera camera_from_room: np.ndarray = field(init=False) # (4,4), computed via invert_rt() def __post_init__(self) -> None: self.camera_matrix = np.asarray(self.camera_matrix, dtype=np.float64).reshape(3, 3) self.dist_coeffs = np.asarray(self.dist_coeffs, dtype=np.float64).reshape(-1) self.extrinsic_rt = np.asarray(self.extrinsic_rt, dtype=np.float64).reshape(4, 4) self.camera_from_room = invert_rt(self.extrinsic_rt)
[docs] def invert_rt(m: np.ndarray) -> np.ndarray: """Invert a rigid-body (rotation + translation) transform. Parameters ---------- m : array_like A row-major rigid transform, as a ``(4, 4)`` matrix or a flat length-16 array — ``[R | t]`` with the bottom row implicitly ``[0, 0, 0, 1]``. Returns ------- numpy.ndarray The inverse transform, shape ``(4, 4)``: ``[R^T | -R^T @ t]``. Notes ----- Must stay mathematically identical to ``room_frame::invert()`` (``src/calibration/room_frame_solver.cpp``) — Python cannot call the C++ function directly, so this is a small, deliberate reimplementation of the exact same formula. See :doc:`/math/pose3d_reconstruction` for the room-frame convention this assumes. """ m = np.asarray(m, dtype=np.float64).reshape(4, 4) r = m[:3, :3] t = m[:3, 3] out = np.eye(4, dtype=np.float64) out[:3, :3] = r.T out[:3, 3] = -r.T @ t return out
[docs] def normalize_point(uv_px, cam: CameraGeom) -> np.ndarray: """Undistort a single pixel into ideal, K-free normalized coordinates. Parameters ---------- uv_px : array_like A single pixel coordinate ``(u, v)``, in that camera's real distorted pixel space. cam : CameraGeom The camera whose intrinsics/distortion coefficients to undistort against. Returns ------- numpy.ndarray Length-2 array, the ideal (undistorted, ``K``-free) normalized coordinate. Notes ----- Uses :func:`cv2.undistortPoints` with no ``P=`` argument — ``K`` is deliberately left out of the result, since :func:`triangulate_point_dlt` only ever consumes already-normalized points and its projection matrices (see :func:`projection_matrix`) carry no ``K`` term either. """ pts = np.asarray(uv_px, dtype=np.float64).reshape(1, 1, 2) undistorted = cv2.undistortPoints(pts, cam.camera_matrix, cam.dist_coeffs) return undistorted.reshape(2)
[docs] def projection_matrix(cam: CameraGeom) -> np.ndarray: """Build a camera's ``K``-free ``(3, 4)`` projection matrix. Parameters ---------- cam : CameraGeom The camera to build a projection matrix for. Returns ------- numpy.ndarray ``P = camera_from_room[:3, :4]``, shape ``(3, 4)`` — no ``K`` term, matching :func:`normalize_point`'s already-``K``-free output, so the two combine directly in :func:`triangulate_point_dlt`. """ return cam.camera_from_room[:3, :4]
[docs] def triangulate_point_dlt(points_normalized, projection_matrices) -> np.ndarray | None: """Classic linear multi-view DLT (Direct Linear Transform) triangulation. Parameters ---------- points_normalized : sequence of array_like One ideal, ``K``-free normalized ``(u, v)`` point per contributing view (see :func:`normalize_point`), same order as `projection_matrices`. projection_matrices : sequence of array_like One ``(3, 4)`` ``K``-free projection matrix per contributing view (see :func:`projection_matrix`), same order as `points_normalized`. Returns ------- numpy.ndarray or None The triangulated 3D point in room space (mm), shape ``(3,)``, or ``None`` if triangulation isn't possible (see Notes). Notes ----- Pure numpy, zero cv2. Each view *i* contributes 2 homogeneous rows (``u_i * P_i[2,:] - P_i[0,:]`` and ``v_i * P_i[2,:] - P_i[1,:]``) to one ``(2N, 4)`` system; the 3D point is the right-singular-vector for the smallest singular value, dehomogenized. Requires at least 2 views. Returns ``None`` on too few views, a failed SVD, or a degenerate (near-zero homogeneous coordinate) solution — never a fabricated point. See :doc:`/math/pose3d_reconstruction` for the full derivation. """ if len(points_normalized) < 2 or len(points_normalized) != len(projection_matrices): return None rows = [] for (u, v), p in zip(points_normalized, projection_matrices, strict=False): rows.append(u * p[2, :] - p[0, :]) rows.append(v * p[2, :] - p[1, :]) a = np.asarray(rows, dtype=np.float64) try: _, _, vt = np.linalg.svd(a) except np.linalg.LinAlgError: return None x = vt[-1] if abs(x[3]) < 1e-12: return None return x[:3] / x[3]
[docs] def project_point_px(point_room, cam: CameraGeom) -> np.ndarray: """Project a 3D room-space point into one camera's real pixel space. Parameters ---------- point_room : array_like A 3D point in room space (mm), shape ``(3,)``. cam : CameraGeom The camera to project through — its **real** (distorted) intrinsics are used, not the K-free normalized-coordinate space the rest of this module operates in. Returns ------- numpy.ndarray Length-2 array, the projected pixel coordinate. Notes ----- The shared primitive behind both :func:`reproject_error_px` (triangulation quality) and ``run_pose3d.py``'s per-camera overlay precomputation (``Skeleton3DPerson::reprojectedPx``, consumed by :cpp:class:`mosaic::Skeleton3DResult` with zero calibration math on the C++ side). """ r_wc = cam.camera_from_room[:3, :3] t_wc = cam.camera_from_room[:3, 3] rvec, _ = cv2.Rodrigues(r_wc) projected, _ = cv2.projectPoints( np.asarray(point_room, dtype=np.float64).reshape(1, 3), rvec, t_wc.reshape(3, 1), cam.camera_matrix, cam.dist_coeffs, ) return projected.reshape(2)
[docs] def reproject_error_px(point_room, cam: CameraGeom, pixel_observed) -> float: """Compute one view's reprojection error for a triangulated point. Parameters ---------- point_room : array_like A triangulated 3D point in room space (mm), shape ``(3,)``. cam : CameraGeom The camera to reproject through. pixel_observed : array_like The originally observed 2D pixel coordinate this camera actually detected, to compare the reprojection against. Returns ------- float Euclidean pixel distance between the reprojected point and `pixel_observed` — an interpretable, distortion-aware quality metric, consistent with ``RoomCalibrationManager``'s own extrinsic-solve reprojection-RMS report (see :doc:`/math/room_calibration`). """ projected_px = project_point_px(point_room, cam) observed_px = np.asarray(pixel_observed, dtype=np.float64).reshape(2) return float(np.linalg.norm(projected_px - observed_px))
[docs] @dataclass class TriangulationResult: """The outcome of a single :func:`triangulate_with_rejection` call. Attributes ---------- point_room : numpy.ndarray The triangulated 3D point in room space (mm), shape ``(3,)``. used_views : list of int Camera indices that actually contributed to `point_room` — after visibility filtering and, if a re-triangulation happened, after outlier rejection too. per_view_error_px : dict of int to float Each entry in `used_views`' own reprojection error (px) against `point_room`, keyed by camera index. """ point_room: np.ndarray used_views: list per_view_error_px: dict
[docs] def triangulate_with_rejection( observations, cameras, max_reprojection_error_px: float = 15.0, min_visibility: float = 0.1 ) -> TriangulationResult | None: """Triangulate one keypoint across cameras, rejecting bad views once. Parameters ---------- observations : dict of int to tuple ``{cam_idx: (pixel_uv, visibility)}`` — one observation per camera that detected this keypoint at all, regardless of confidence. cameras : dict of int to CameraGeom Every calibrated camera available for this session, keyed by index. max_reprojection_error_px : float, default 15.0 A view whose reprojection error exceeds this (px) after the first triangulation pass is dropped before a single re-triangulation. min_visibility : float, default 0.1 Observations below this visibility/confidence are excluded before triangulation even starts. Returns ------- TriangulationResult or None ``None`` if fewer than 2 views survive visibility filtering, the initial triangulation fails, or fewer than 2 views survive outlier rejection — never a fabricated point. Notes ----- Filters by `min_visibility`, requires ``>=2`` remaining cameras, triangulates via :func:`normalize_point` + :func:`triangulate_point_dlt`, then drops any view whose real reprojection error exceeds `max_reprojection_error_px` and re-triangulates **once** from the remainder — no iterative chase, matching ``pose_kinematics.cpp``'s "skip, don't fabricate" discipline (see :doc:`/math/pose_kinematics`). """ visible = { idx: uv for idx, (uv, vis) in observations.items() if vis >= min_visibility and idx in cameras } if len(visible) < 2: return None def _triangulate(view_ids): norm_pts = [normalize_point(visible[i], cameras[i]) for i in view_ids] p_mats = [projection_matrix(cameras[i]) for i in view_ids] return triangulate_point_dlt(norm_pts, p_mats) view_ids = sorted(visible.keys()) point = _triangulate(view_ids) if point is None: return None errors = {i: reproject_error_px(point, cameras[i], visible[i]) for i in view_ids} kept = [i for i in view_ids if errors[i] <= max_reprojection_error_px] if len(kept) == len(view_ids): return TriangulationResult( point_room=point, used_views=kept, per_view_error_px={i: errors[i] for i in kept} ) if len(kept) < 2: return None point2 = _triangulate(kept) if point2 is None: return None errors2 = {i: reproject_error_px(point2, cameras[i], visible[i]) for i in kept} return TriangulationResult(point_room=point2, used_views=kept, per_view_error_px=errors2)