fix(pose): fresh arkit joint arrays per frame

Extract apply_skeleton_joints() as a pure module-level function that
copies prev_arr (or zeros) and writes a new array, never mutating the
old one.  The TAG_SKELETON handler now stores the new array under the
state lock, so readers that captured a reference to the previous array
(multi.py shallow-copy + ArkitFuse.apply outside the lock) always see
a complete, consistent snapshot — eliminating the B4 tearing race.

6 new tests cover: shape, identity (is not), value preservation for
invalid joints, value application for valid joints, wrong-shape reset,
and no-mutation of prev_arr.
This commit is contained in:
L'électron rare
2026-07-02 10:41:41 +02:00
parent 0a4f51ebfb
commit 8bc7acbd14
2 changed files with 141 additions and 9 deletions
+33 -7
View File
@@ -88,6 +88,36 @@ def _to_annexb(data: bytes) -> bytes:
return bytes(out)
def apply_skeleton_joints(
prev_arr: "np.ndarray | None",
joints: "list[tuple[float, float, float, bool]]",
n_joints: int = _ARKIT_JOINTS,
) -> np.ndarray:
"""Return a NEW array with valid joints from *joints* applied.
Joints not marked valid this frame keep their values from *prev_arr*
(copy semantics — never mutates *prev_arr*). Callers holding a
reference to the previous array continue to see a consistent snapshot
even without a lock, eliminating the tearing race described in B4.
Args:
prev_arr: Array currently stored for this pid, or None / wrong shape.
joints: Iterable of (x, y, z, valid) from decode_skeleton().
n_joints: Expected joint count (default _ARKIT_JOINTS = 91).
Returns:
A fresh np.ndarray of shape (n_joints, 3), dtype float32.
"""
if prev_arr is None or prev_arr.shape != (n_joints, 3):
new_arr = np.zeros((n_joints, 3), dtype=np.float32)
else:
new_arr = prev_arr.copy()
for i, (x, y, z, valid) in enumerate(joints):
if valid:
new_arr[i] = (x, y, z)
return new_arr
class IphoneUSBSource:
def __init__(self, state=None, target_size=(640, 480),
write_hands: bool = True, mirror: bool = True) -> None:
@@ -160,13 +190,9 @@ class IphoneUSBSource:
joints = decode_skeleton(payload)
if joints is not None:
with self.state.lock():
arr = self.state.persons_arkit_joints.get(pid)
if arr is None or arr.shape != (_ARKIT_JOINTS, 3):
arr = np.zeros((_ARKIT_JOINTS, 3), dtype=np.float32)
for i, (x, y, z, valid) in enumerate(joints):
if valid:
arr[i] = (x, y, z)
self.state.persons_arkit_joints[pid] = arr
prev = self.state.persons_arkit_joints.get(pid)
new_arr = apply_skeleton_joints(prev, joints)
self.state.persons_arkit_joints[pid] = new_arr
self.state.persons_arkit_last_t[pid] = time.perf_counter()
elif tag == TAG_SKELETON2D and self.state is not None:
pts = decode_skeleton2D(payload)
+108 -2
View File
@@ -1,12 +1,17 @@
"""Pure (no-device) unit tests for iphone_usb_source helpers.
Tests _to_annexb and _decode_hands without any hardware or network connection.
Tests _to_annexb, _decode_hands, and apply_skeleton_joints without any
hardware or network connection.
"""
import struct
import numpy as np
import pytest
from data_only_viz.iphone_usb_source import _to_annexb, _decode_hands, _HAND_BYTES
from data_only_viz.iphone_usb_source import (
_to_annexb, _decode_hands, _HAND_BYTES,
apply_skeleton_joints, _ARKIT_JOINTS,
)
# ---------------------------------------------------------------------------
@@ -180,3 +185,104 @@ def test_decode_hands_confidence_clamped_out_of_range():
# hand_high: raw z preserved, c clamped to 1
assert abs(hands[1][0].z - 1.7) < 1e-5
assert hands[1][0].c == 1.0
# ---------------------------------------------------------------------------
# apply_skeleton_joints — B4 tearing fix (chantier 2)
# ---------------------------------------------------------------------------
def _make_all_valid_joints(value: float = 0.1) -> list[tuple[float, float, float, bool]]:
"""Return _ARKIT_JOINTS tuples all marked valid with a constant value."""
return [(value, value, value, True)] * _ARKIT_JOINTS
def _make_joints_with_mask(
values: "list[tuple[float, float, float]]",
valid_mask: "list[bool]",
) -> list[tuple[float, float, float, bool]]:
"""Zip (x,y,z) values with a validity mask into the joints wire format."""
return [(x, y, z, v) for (x, y, z), v in zip(values, valid_mask)]
def test_apply_skeleton_joints_fresh_pid_returns_correct_shape():
"""prev=None → zeros array of shape (_ARKIT_JOINTS, 3), dtype float32."""
joints = [(0.0, 0.0, 0.0, False)] * _ARKIT_JOINTS
result = apply_skeleton_joints(None, joints)
assert result.shape == (_ARKIT_JOINTS, 3)
assert result.dtype == np.float32
assert (result == 0).all()
def test_apply_skeleton_joints_returns_new_array_each_call():
"""Each call returns a different array object — never the same reference.
This is the core identity guarantee for the B4 tearing fix: readers that
captured a reference to arr1 continue to see a consistent arr1 even while
the writer has moved on to arr2.
"""
joints1 = _make_all_valid_joints(0.1)
arr1 = apply_skeleton_joints(None, joints1)
joints2 = _make_all_valid_joints(0.9)
arr2 = apply_skeleton_joints(arr1, joints2)
assert arr2 is not arr1
def test_apply_skeleton_joints_preserves_values_not_valid_this_frame():
"""Joints with valid=False in update 2 keep their values from update 1.
Update 1 sets all joints to their index value. Update 2 marks only
joint 0 as valid with a new value. Joints 1..N-1 must be unchanged.
"""
# Update 1: every joint valid, joint i has x=float(i)
joints1 = [(float(i), 0.0, 0.0, True) for i in range(_ARKIT_JOINTS)]
arr1 = apply_skeleton_joints(None, joints1)
# Update 2: only joint 0 valid (new value), rest invalid
vals2 = [(99.0, 99.0, 99.0)] * _ARKIT_JOINTS
mask2 = [i == 0 for i in range(_ARKIT_JOINTS)]
joints2 = _make_joints_with_mask(vals2, mask2)
arr2 = apply_skeleton_joints(arr1, joints2)
# Joint 0: overwritten by update 2
assert abs(arr2[0, 0] - 99.0) < 1e-5
# Joints 1..N-1: preserved from update 1
for i in range(1, _ARKIT_JOINTS):
assert abs(arr2[i, 0] - float(i)) < 1e-5, f"joint {i} not preserved"
def test_apply_skeleton_joints_valid_joints_applied():
"""Valid joints from update 2 overwrite the previous values correctly."""
joints1 = _make_all_valid_joints(0.0)
arr1 = apply_skeleton_joints(None, joints1)
joints2 = [(1.0, 2.0, 3.0, True)] * _ARKIT_JOINTS
arr2 = apply_skeleton_joints(arr1, joints2)
assert abs(arr2[5, 0] - 1.0) < 1e-5
assert abs(arr2[5, 1] - 2.0) < 1e-5
assert abs(arr2[5, 2] - 3.0) < 1e-5
def test_apply_skeleton_joints_wrong_shape_prev_discarded():
"""prev_arr with wrong shape is discarded and output starts from zeros."""
wrong_shape = np.ones((10, 3), dtype=np.float32)
joints = [(0.5, 0.5, 0.5, True)] * _ARKIT_JOINTS
result = apply_skeleton_joints(wrong_shape, joints)
assert result.shape == (_ARKIT_JOINTS, 3)
assert abs(result[0, 0] - 0.5) < 1e-5
def test_apply_skeleton_joints_does_not_mutate_prev():
"""apply_skeleton_joints must never modify the array passed as prev_arr."""
joints1 = _make_all_valid_joints(1.0)
arr1 = apply_skeleton_joints(None, joints1)
arr1_copy = arr1.copy()
joints2 = _make_all_valid_joints(2.0)
apply_skeleton_joints(arr1, joints2)
# arr1 must be byte-for-byte identical to what it was before the call
np.testing.assert_array_equal(arr1, arr1_copy)