Source code for pose3d.association

"""
Cross-camera person association for the 3D Pose Reconstruction plugin
(analysis/run_pose3d.py) — item 19 (Multi-Camera Gaze Fusion) never needed
this since it hard-codes "subject 0" only; multi-person sessions need a real
answer to "which detection in camera A is the same physical person as which
detection in camera B" before per-keypoint triangulation makes sense at all.

Deliberately reuses triangulation.py's 2-view DLT + real reprojection error
as the pairwise association COST, rather than a separate classical
epipolar-line-distance/fundamental-matrix module: for a calibrated (not
just relatively-related) room rig, "if these two detections were the same
rigid articulated body, how consistent is their 3D reconstruction" is a
strictly stronger, more physically meaningful signal than a per-keypoint
point-to-epipolar-line distance, and it reuses machinery this plugin
already needs for the real reconstruction step anyway — no separate
fundamental-matrix code path to keep in sync.
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
from scipy.optimize import linear_sum_assignment

from .triangulation import (
    CameraGeom,
    normalize_point,
    projection_matrix,
    reproject_error_px,
    triangulate_point_dlt,
)


[docs] @dataclass class PersonObservation: camera_index: int person_index: int # per-frame YOLO enumeration index from .pose.json — NOT a track id keypoints_px: np.ndarray # (K,2) visibilities: np.ndarray # (K,)
[docs] def pairwise_cost( a: PersonObservation, b: PersonObservation, cam_a: CameraGeom, cam_b: CameraGeom, min_shared_keypoints: int = 4, min_visibility: float = 0.1, ) -> float: """Average 2-view reprojection error (px) across every keypoint index visible (>=min_visibility) in BOTH a and b. inf if fewer than min_shared_keypoints such indices exist, or if 2-view triangulation fails for a majority of them (a near-parallel/degenerate configuration — not evidence of a real match either way, so treated as "can't assess", not "definitely different people").""" n = min(len(a.visibilities), len(b.visibilities)) shared = [ i for i in range(n) if a.visibilities[i] >= min_visibility and b.visibilities[i] >= min_visibility ] if len(shared) < min_shared_keypoints: return float("inf") errors = [] failures = 0 for i in shared: norm_pts = [ normalize_point(a.keypoints_px[i], cam_a), normalize_point(b.keypoints_px[i], cam_b), ] p_mats = [projection_matrix(cam_a), projection_matrix(cam_b)] point = triangulate_point_dlt(norm_pts, p_mats) if point is None: failures += 1 continue e_a = reproject_error_px(point, cam_a, a.keypoints_px[i]) e_b = reproject_error_px(point, cam_b, b.keypoints_px[i]) errors.append((e_a + e_b) / 2.0) if not errors or failures > len(shared) / 2: return float("inf") return float(np.mean(errors))
[docs] def match_camera_pair( obs_a, obs_b, cam_a: CameraGeom, cam_b: CameraGeom, max_pair_cost_px: float = 20.0, min_shared_keypoints: int = 4, ): """Hungarian-assigns obs_a<->obs_b by pairwise_cost(), then discards any assigned pair whose cost exceeds max_pair_cost_px — Hungarian minimizes TOTAL cost but doesn't itself refuse an individually-bad pair when one side has more/fewer detections than the other, so this threshold is the actual "not obviously the same person" gate. Returns (person_index_a, person_index_b, cost) triples for accepted pairs only.""" if not obs_a or not obs_b: return [] cost_matrix = np.full((len(obs_a), len(obs_b)), float("inf")) for i, a in enumerate(obs_a): for j, b in enumerate(obs_b): cost_matrix[i, j] = pairwise_cost(a, b, cam_a, cam_b, min_shared_keypoints) # linear_sum_assignment can't handle a fully-inf row/column (no finite # option at all) — substitute a large-but-finite sentinel so the solver # still runs; the max_pair_cost_px filter below rejects any assignment # that only "won" because everything else was worse, not because it was # actually a good match. finite = cost_matrix[np.isfinite(cost_matrix)] sentinel = ( (float(finite.max()) + max_pair_cost_px + 1.0) if finite.size else (max_pair_cost_px + 1.0) ) safe_matrix = np.where(np.isfinite(cost_matrix), cost_matrix, sentinel) row_idx, col_idx = linear_sum_assignment(safe_matrix) matches = [] for r, c in zip(row_idx, col_idx, strict=False): cost = cost_matrix[r, c] if np.isfinite(cost) and cost <= max_pair_cost_px: matches.append((int(r), int(c), float(cost))) return matches
class _UnionFind: def __init__(self) -> None: self._parent: dict = {} def find(self, x): self._parent.setdefault(x, x) while self._parent[x] != x: self._parent[x] = self._parent[self._parent[x]] x = self._parent[x] return x def union(self, a, b) -> None: ra, rb = self.find(a), self.find(b) if ra != rb: self._parent[ra] = rb
[docs] def cluster_people( observations, cameras, max_pair_cost_px: float = 20.0, min_shared_keypoints: int = 4 ): """observations: {camera_index: [PersonObservation, ...]}. Runs match_camera_pair() over EVERY camera pair (not just adjacent ones — a room-scale rig may have non-adjacent pairs with better mutual visibility than adjacent ones), unions accepted matches via union-find over (camera_index, person_index) nodes, and returns one cluster (list of (camera_index, person_index)) per connected component spanning >=2 distinct cameras. Singleton (1-camera, unmatched) clusters are dropped — a single view can't be triangulated. Known limitation, not fixed here: any single accepted pairwise match unions its two nodes regardless of how many OTHER camera pairs agree — with only 2 cameras total, two genuinely different people can (rarely) still pass max_pair_cost_px if their rays happen to nearly cross by coincidence (2 independent rays in 3D always have SOME closest point; reprojection error alone can't rule out a coincidental near-intersection the way a 3rd independent view's disagreement could). Rooms with 3+ calibrated cameras are far less exposed to this, since a false-positive 2-camera pairing only survives into the union if no other accepted pair disagrees — but it is not structurally impossible even then. A cost threshold tuned tighter than the default (max_pair_cost_px) is the practical mitigation; a full multi-pair consistency vote is future work.""" uf = _UnionFind() nodes: set = set() cam_indices = sorted(observations.keys()) for idx in cam_indices: for p in range(len(observations[idx])): nodes.add((idx, p)) uf.find((idx, p)) for a_i in range(len(cam_indices)): for b_i in range(a_i + 1, len(cam_indices)): cam_a_idx, cam_b_idx = cam_indices[a_i], cam_indices[b_i] if cam_a_idx not in cameras or cam_b_idx not in cameras: continue matches = match_camera_pair( observations[cam_a_idx], observations[cam_b_idx], cameras[cam_a_idx], cameras[cam_b_idx], max_pair_cost_px, min_shared_keypoints, ) for pi, pj, _cost in matches: uf.union((cam_a_idx, pi), (cam_b_idx, pj)) groups: dict = {} for node in nodes: groups.setdefault(uf.find(node), []).append(node) return [sorted(members) for members in groups.values() if len({cam for cam, _ in members}) >= 2]