Skip to content

Commit ad7e570

Browse files
authored
Merge pull request #107 from KumarLabJax/bugfix/empty-detection-edge-cases
Fix edge cases for empty detection sets in vectorized matching
2 parents 9eb4538 + c9a8113 commit ad7e570

4 files changed

Lines changed: 104 additions & 56 deletions

File tree

src/mouse_tracking/matching/core.py

Lines changed: 54 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -601,9 +601,14 @@ def __init__(self, observations: list[list[Detection]]):
601601

602602
# Metadata
603603
self._num_frames = len(observations)
604-
self._median_observation = int(np.median([len(x) for x in observations]))
605-
# Add 0.5 to do proper rounding with int cast
606-
self._avg_observation = int(np.mean([len(x) for x in observations]) + 0.5)
604+
# Handle empty observation list edge case
605+
if len(observations) == 0:
606+
self._median_observation = 0
607+
self._avg_observation = 0
608+
else:
609+
self._median_observation = int(np.median([len(x) for x in observations]))
610+
# Add 0.5 to do proper rounding with int cast
611+
self._avg_observation = int(np.mean([len(x) for x in observations]) + 0.5)
607612
self._tracklet_gen_method = None
608613
self._tracklet_stitch_method = None
609614

@@ -786,6 +791,10 @@ def get_embed_centers(self):
786791
longterm_ids = np.asarray(list(set(self._stitch_translation.values())))
787792
longterm_ids = longterm_ids[longterm_ids != 0]
788793

794+
# Handle edge case where all longterm IDs are 0 (filtered out)
795+
if len(longterm_ids) == 0:
796+
return np.zeros([0, embedding_shape[0]])
797+
789798
# To calculate an average for merged tracklets, we weight by number of frames
790799
longterm_data = {}
791800
for cur_tracklet in self._tracklets:
@@ -841,8 +850,12 @@ def _make_tracklets(self, include_unassigned: bool = True):
841850
for tracklet_id, observation_list in tracklet_dict.items():
842851
tracklet_list.append(Tracklet(tracklet_id, observation_list))
843852

844-
if include_unassigned:
845-
cur_tracklet_id = np.max(np.asarray(list(tracklet_dict.keys())))
853+
if include_unassigned and len(unmatched_observations) > 0:
854+
# Handle edge case where tracklet_dict is empty
855+
if len(tracklet_dict) > 0:
856+
cur_tracklet_id = np.max(np.asarray(list(tracklet_dict.keys())))
857+
else:
858+
cur_tracklet_id = 0
846859
for cur_observation in unmatched_observations:
847860
tracklet_list.append(Tracklet(int(cur_tracklet_id), [cur_observation]))
848861
cur_tracklet_id += 1
@@ -1079,35 +1092,42 @@ def generate_greedy_tracklets(
10791092
if num_threads > 1:
10801093
self._start_pool(num_threads)
10811094

1082-
# Main loop to cycle over greedy matching.
1083-
# Each match problem is posed as a bipartite graph between sequential frames
1084-
for frame in np.arange(len(self._observations) - 1) + 1:
1085-
# Cache the segmentation and rotation data
1086-
for obs in self._observations[frame - 1]:
1087-
obs.cache()
1088-
for obs in self._observations[frame]:
1089-
obs.cache()
1090-
# Calculate cost and greedily match
1091-
match_costs = self._calculate_costs(frame - 1, frame, rotate_pose)
1092-
match_costs = np.ma.array(match_costs, fill_value=max_cost, mask=False)
1093-
matches = {}
1094-
while np.any(~match_costs.mask) and np.any(match_costs.filled() < max_cost):
1095-
next_best = np.unravel_index(np.argmin(match_costs), match_costs.shape)
1096-
matches[next_best[1]] = prev_matches[next_best[0]]
1097-
match_costs.mask[next_best[0], :] = True
1098-
match_costs.mask[:, next_best[1]] = True
1099-
# Fill any unmatched observations
1100-
for j in range(len(self._observations[frame])):
1101-
if j not in matches:
1102-
matches[j] = cur_tracklet_id
1103-
cur_tracklet_id += 1
1104-
frame_dict[frame] = matches
1105-
# Cleanup for next loop iteration
1106-
for cur_obs in self._observations[frame - 1]:
1107-
cur_obs.clear_cache()
1108-
prev_matches = matches
1109-
if self._pool is not None:
1110-
self._kill_pool()
1095+
try:
1096+
# Main loop to cycle over greedy matching.
1097+
# Each match problem is posed as a bipartite graph between sequential frames
1098+
for frame in np.arange(len(self._observations) - 1) + 1:
1099+
# Cache the segmentation and rotation data
1100+
for obs in self._observations[frame - 1]:
1101+
obs.cache()
1102+
for obs in self._observations[frame]:
1103+
obs.cache()
1104+
# Calculate cost and greedily match
1105+
match_costs = self._calculate_costs(frame - 1, frame, rotate_pose)
1106+
match_costs = np.ma.array(match_costs, fill_value=max_cost, mask=False)
1107+
matches = {}
1108+
while np.any(~match_costs.mask) and np.any(
1109+
match_costs.filled() < max_cost
1110+
):
1111+
next_best = np.unravel_index(
1112+
np.argmin(match_costs), match_costs.shape
1113+
)
1114+
matches[next_best[1]] = prev_matches[next_best[0]]
1115+
match_costs.mask[next_best[0], :] = True
1116+
match_costs.mask[:, next_best[1]] = True
1117+
# Fill any unmatched observations
1118+
for j in range(len(self._observations[frame])):
1119+
if j not in matches:
1120+
matches[j] = cur_tracklet_id
1121+
cur_tracklet_id += 1
1122+
frame_dict[frame] = matches
1123+
# Cleanup for next loop iteration
1124+
for cur_obs in self._observations[frame - 1]:
1125+
cur_obs.clear_cache()
1126+
prev_matches = matches
1127+
finally:
1128+
# Ensure pool is always cleaned up, even if an exception occurs
1129+
if self._pool is not None:
1130+
self._kill_pool()
11111131
# Final modification of internal state
11121132
self._observation_id_dict = frame_dict
11131133
self._tracklet_gen_method = "greedy"

src/mouse_tracking/matching/detection.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,12 @@ def rotate_pose(
9898
center: optional center of rotation. If not provided, the mean of non-tail keypoints are used as the center.
9999
100100
Returns:
101-
rotated keypoints
101+
rotated keypoints, or None if points is None
102102
"""
103+
# Handle None input gracefully
104+
if points is None:
105+
return None
106+
103107
points_valid = ~np.all(points == 0, axis=-1)
104108
# No points to rotate, just return original points.
105109
if np.all(~points_valid):
@@ -137,6 +141,9 @@ def embed_distance(embed_1, embed_2) -> float:
137141
Returns:
138142
cosine distance between the embeddings
139143
"""
144+
# Check for None embeddings
145+
if embed_1 is None or embed_2 is None:
146+
return np.nan
140147
# Check for default embeddings
141148
if np.all(embed_1 == 0) or np.all(embed_2 == 0):
142149
return np.nan

src/mouse_tracking/matching/vectorized_features.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ def compute_vectorized_pose_distances(
135135
Returns:
136136
Distance matrix of shape (n1, n2) with mean pose distances
137137
"""
138+
# Handle edge case where either set has no detections
139+
if features1.n_detections == 0 or features2.n_detections == 0:
140+
return np.full((features1.n_detections, features2.n_detections), np.nan)
141+
138142
poses1 = features1.poses # Shape: (n1, 12, 2)
139143
poses2 = features2.poses # Shape: (n2, 12, 2)
140144
valid1 = features1.valid_pose_masks # Shape: (n1, 12)

tests/matching/core/video_observations/test_generate_greedy_tracklets.py

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,14 @@ def test_generate_greedy_tracklets_no_observations(self):
102102
observations = [[] for _ in range(3)] # All empty frames
103103
video_obs = VideoObservations(observations)
104104

105-
# TODO: This reveals a bug - _make_tracklets fails with empty tracklet_dict
106-
# The _make_tracklets method tries to call np.max on empty array
107-
with pytest.raises(
108-
ValueError, match="zero-size array to reduction operation maximum"
109-
):
110-
video_obs.generate_greedy_tracklets()
105+
# Should handle empty frames gracefully
106+
video_obs.generate_greedy_tracklets()
107+
108+
# Should have empty observation_id_dict and empty tracklets
109+
assert video_obs._observation_id_dict is not None
110+
assert video_obs._tracklet_gen_method == "greedy"
111+
assert video_obs._tracklets is not None
112+
assert len(video_obs._tracklets) == 0 # No tracklets for no observations
111113

112114
def test_generate_greedy_tracklets_single_observation_per_frame(
113115
self, basic_detection
@@ -362,24 +364,33 @@ def test_generate_greedy_tracklets_pool_cleanup_on_exception(self, basic_detecti
362364

363365
video_obs = VideoObservations(observations)
364366

367+
# Mock the pool object
368+
mock_pool = MagicMock()
369+
370+
def mock_start_pool_impl(num_threads):
371+
video_obs._pool = mock_pool
372+
373+
def mock_kill_pool_impl():
374+
video_obs._pool = None
375+
365376
with (
366377
patch.object(video_obs, "_start_pool") as mock_start_pool,
367378
patch.object(video_obs, "_kill_pool") as mock_kill_pool,
368379
patch.object(
369380
video_obs, "_calculate_costs", side_effect=RuntimeError("Test error")
370381
),
371382
):
383+
# Set up side effects so the mocks actually update _pool
384+
mock_start_pool.side_effect = mock_start_pool_impl
385+
mock_kill_pool.side_effect = mock_kill_pool_impl
386+
372387
with pytest.raises(RuntimeError):
373388
video_obs.generate_greedy_tracklets(num_threads=2)
374389

375390
# Pool should be started
376391
mock_start_pool.assert_called_once()
377-
# TODO: This reveals a bug - pool is not cleaned up on exception
378-
# The generate_greedy_tracklets method doesn't use try/finally for cleanup
379-
# Currently the pool is NOT cleaned up on exception
380-
assert (
381-
mock_kill_pool.call_count == 0
382-
) # Documents the current buggy behavior
392+
# Pool should be cleaned up even though an exception occurred
393+
mock_kill_pool.assert_called_once()
383394

384395
def test_generate_greedy_tracklets_variable_observations_per_frame(
385396
self, basic_detection
@@ -443,10 +454,12 @@ def test_generate_greedy_tracklets_with_none_values(self, basic_detection):
443454

444455
video_obs = VideoObservations(observations)
445456

446-
# TODO: This reveals a bug - rotate_pose doesn't handle None poses correctly
447-
# The rotate_pose method assumes points is not None
448-
with pytest.raises(TypeError, match="unsupported operand type"):
449-
video_obs.generate_greedy_tracklets()
457+
# Should handle None poses gracefully (using default costs)
458+
video_obs.generate_greedy_tracklets(rotate_pose=True)
459+
460+
# Should complete without crashing
461+
assert video_obs._tracklets is not None
462+
assert video_obs._tracklet_gen_method == "greedy"
450463

451464
def test_generate_greedy_tracklets_large_cost_matrix(self, basic_detection):
452465
"""Test with larger cost matrices to ensure scalability."""
@@ -533,11 +546,15 @@ def test_generate_greedy_tracklets_deterministic_behavior(self, basic_detection)
533546

534547
def test_generate_greedy_tracklets_empty_observation_list(self):
535548
"""Test with empty observation list."""
536-
# TODO: This reveals a bug - VideoObservations constructor can't handle empty lists
537-
# The constructor tries to calculate median of empty list
538-
with pytest.raises(ValueError, match="cannot convert float NaN to integer"):
539-
observations = []
540-
VideoObservations(observations)
549+
# Should handle empty observation list gracefully
550+
observations = []
551+
video_obs = VideoObservations(observations)
552+
553+
# Verify attributes are set correctly
554+
assert video_obs._num_frames == 0
555+
assert video_obs._median_observation == 0
556+
assert video_obs._avg_observation == 0
557+
assert video_obs._observations == []
541558

542559
def test_generate_greedy_tracklets_numerical_stability(self, basic_detection):
543560
"""Test with edge cases that might cause numerical issues."""

0 commit comments

Comments
 (0)