mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +00:00
`tracker_bridge::tracker_to_person_detections` documented itself as filtering to `is_alive()` but never actually filtered — it forwarded every non-Terminated track to the WebSocket stream. With 3 ESP32-S3 nodes × ~10 Hz CSI, transient detections that fell outside the Mahalanobis gate created a steady stream of new Tentative tracks that aged through Active and into Lost. Lost tracks are kept in the tracker for `reid_window` (~3 s) so re-identification can match them when a similar detection reappears, but they are NOT currently observed and must not render as live skeletons. Up to ~90 ghost skeletons could accumulate at any moment, hence the 22-24 phantoms users saw while `estimated_persons` correctly reported 1. Add `PoseTracker::confirmed_tracks()` that returns only `Tentative ∪ Active` and rewire the bridge to use it. `Lost` tracks remain in the tracker for re-ID; they just no longer ship to the UI. `active_tracks()` is left unchanged for the AETHER re-ID consumers (ADR-024). Regression test `test_lost_tracks_excluded_from_bridge_output` drives a track to Active, lapses for `loss_misses + 1` ticks to push it to Lost, and asserts `tracker_update` returns an empty Vec while the Lost track is still present in `all_tracks()` (re-ID still works). Validated: - cargo test --workspace --no-default-features → 1,539 passed, 0 failed - ESP32-S3 on COM7 still streaming live CSI (cb #32800)
This commit is contained in:
+76
-3
@@ -92,12 +92,15 @@ fn detections_to_tracker_keypoints(persons: &[PersonDetection]) -> Vec<[[f32; 3]
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert active PoseTracker tracks back into server-side PersonDetection values.
|
||||
/// Convert confirmed PoseTracker tracks back into server-side PersonDetection values.
|
||||
///
|
||||
/// Only tracks whose lifecycle `is_alive()` are included.
|
||||
/// Returns only tracks the UI is meant to render right now (Tentative + Active).
|
||||
/// `Lost` tracks — kept around inside `reid_window` for re-identification but
|
||||
/// not currently observed — are excluded so they don't ship to the WebSocket
|
||||
/// stream as ghost skeletons. See ADR-082 and #420.
|
||||
pub fn tracker_to_person_detections(tracker: &PoseTracker) -> Vec<PersonDetection> {
|
||||
tracker
|
||||
.active_tracks()
|
||||
.confirmed_tracks()
|
||||
.into_iter()
|
||||
.map(|track| {
|
||||
let id = track.id.0 as u32;
|
||||
@@ -406,4 +409,74 @@ mod tests {
|
||||
assert_eq!(id1, id2, "Track ID should be stable across updates");
|
||||
assert_eq!(id2, id3, "Track ID should be stable across updates");
|
||||
}
|
||||
|
||||
/// Regression test for #420 (ADR-082): tracks that have transitioned to
|
||||
/// `Lost` must NOT appear in `tracker_update`'s returned PersonDetection
|
||||
/// vector, even though they remain in the tracker for re-identification.
|
||||
#[test]
|
||||
fn test_lost_tracks_excluded_from_bridge_output() {
|
||||
use wifi_densepose_signal::ruvsense::{TrackerConfig, TrackLifecycleState};
|
||||
|
||||
// Tight config so the test doesn't have to spin for hundreds of ticks.
|
||||
let cfg = TrackerConfig {
|
||||
loss_misses: 3,
|
||||
reid_window: 100, // intentionally large — we want Lost, not Terminated
|
||||
..TrackerConfig::default()
|
||||
};
|
||||
let mut tracker = PoseTracker::with_config(cfg);
|
||||
let mut last_instant: Option<Instant> = None;
|
||||
|
||||
let person = make_person(
|
||||
0,
|
||||
vec![
|
||||
make_keypoint("nose", 1.0, 2.0, 0.0),
|
||||
make_keypoint("left_shoulder", 0.8, 2.5, 0.0),
|
||||
make_keypoint("right_shoulder", 1.2, 2.5, 0.0),
|
||||
make_keypoint("left_hip", 0.9, 3.5, 0.0),
|
||||
make_keypoint("right_hip", 1.1, 3.5, 0.0),
|
||||
],
|
||||
);
|
||||
|
||||
// Drive the track to Active (≥2 consecutive hits).
|
||||
let r1 = tracker_update(&mut tracker, &mut last_instant, vec![person.clone()]);
|
||||
let r2 = tracker_update(&mut tracker, &mut last_instant, vec![person.clone()]);
|
||||
assert_eq!(r1.len(), 1);
|
||||
assert_eq!(r2.len(), 1);
|
||||
|
||||
// Submit empty detections enough times to push the track into Lost.
|
||||
// Each empty call increments time_since_update via predict_all().
|
||||
for _ in 0..6 {
|
||||
let _ = tracker_update(&mut tracker, &mut last_instant, vec![]);
|
||||
}
|
||||
|
||||
// Pre-condition: a track exists internally and is in Lost state.
|
||||
let has_lost = tracker
|
||||
.all_tracks()
|
||||
.iter()
|
||||
.any(|t| t.lifecycle == TrackLifecycleState::Lost);
|
||||
assert!(
|
||||
has_lost,
|
||||
"Test setup invariant violated: expected the track to be Lost \
|
||||
after {} empty updates with loss_misses=3",
|
||||
6
|
||||
);
|
||||
|
||||
// The fix: `tracker_update` must NOT return any phantom detections
|
||||
// for the Lost track when there are no current detections.
|
||||
let after_lost = tracker_update(&mut tracker, &mut last_instant, vec![]);
|
||||
assert_eq!(
|
||||
after_lost.len(),
|
||||
0,
|
||||
"Lost tracks must not appear in bridge output (ADR-082, #420). \
|
||||
Got {} phantom detection(s).",
|
||||
after_lost.len()
|
||||
);
|
||||
|
||||
// Sanity: the Lost track is still tracked internally (for re-ID), it
|
||||
// just shouldn't ship to the UI.
|
||||
assert!(
|
||||
tracker.all_tracks().iter().any(|t| t.lifecycle == TrackLifecycleState::Lost),
|
||||
"Lost track must remain in tracker for re-identification window"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ pub use multistatic::FusedSensingFrame;
|
||||
pub use phase_align::{PhaseAligner, PhaseAlignError};
|
||||
pub use pose_tracker::{
|
||||
CompressedPoseHistory, KeypointState, PoseTrack, SkeletonConstraints,
|
||||
TemporalKeypointAttention, TrackLifecycleState,
|
||||
TemporalKeypointAttention, TrackLifecycleState, TrackerConfig,
|
||||
};
|
||||
|
||||
/// Number of keypoints in a full-body pose skeleton (COCO-17).
|
||||
|
||||
@@ -492,6 +492,22 @@ impl PoseTracker {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Tracks the UI is meant to render: Tentative + Active.
|
||||
///
|
||||
/// Excludes `Lost` (re-ID candidates that haven't been observed for
|
||||
/// `loss_misses` ticks) and `Terminated`. Use this at any boundary that
|
||||
/// emits "currently visible" pose state — for example, the WebSocket
|
||||
/// stream sent to the live UI. See ADR-082.
|
||||
pub fn confirmed_tracks(&self) -> Vec<&PoseTrack> {
|
||||
self.tracks
|
||||
.iter()
|
||||
.filter(|t| matches!(
|
||||
t.lifecycle,
|
||||
TrackLifecycleState::Tentative | TrackLifecycleState::Active
|
||||
))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return all tracks including terminated ones.
|
||||
pub fn all_tracks(&self) -> &[PoseTrack] {
|
||||
&self.tracks
|
||||
|
||||
Reference in New Issue
Block a user