Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/trackers/io/mot.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,31 @@ def _remap_ids(
return np.array([id_map[int(original_id)] for original_id in ids], dtype=np.intp)


def _ensure_unique_ids(ids: NDArray[np.intp], frame: int, source: str) -> None:
"""Reject a track id that appears more than once within a single frame.

Metrics accumulate per-id counts with fancy-indexed `+=`, which NumPy buffers rather than adds: a repeated index is
written once, so duplicate ids quietly deflate the totals that every metric divides by. TrackEval raises on this
input as well, so the sequence is rejected instead of scored.

Args:
ids: Track ids for one frame, after ignored and unconfirmed rows are dropped.
frame: 1-based frame number, used in the error message.
source: Human-readable origin of the ids, used in the error message.

Raises:
ValueError: If any id occurs more than once.
"""
unique_ids, counts = np.unique(ids, return_counts=True)
duplicated_ids = unique_ids[counts > 1]
if duplicated_ids.size > 0:
raise ValueError(
f"{source} has duplicate track ids in frame {frame}: {duplicated_ids.tolist()}. "
"Each track id may appear at most once per frame; metrics index accumulators by id, "
"so repeated ids would be undercounted."
)


def _prepare_mot_sequence(
ground_truth_data: dict[int, _MOTFrameData],
tracker_data: dict[int, _MOTFrameData],
Expand All @@ -396,6 +421,10 @@ def _prepare_mot_sequence(

Returns:
`_MOTSequenceData` containing prepared data ready for metric evaluation.

Raises:
ValueError: If a scored ground-truth id or a confirmed tracker id appears
more than once within a single frame.
"""
num_frames = _resolve_num_frames(ground_truth_data, tracker_data, num_frames)
ground_truth_id_map, tracker_id_map = _build_id_mappings(ground_truth_data, tracker_data, num_frames)
Expand All @@ -411,6 +440,8 @@ def _prepare_mot_sequence(
ground_truth_data, frame
)
tracker_boxes, tracker_ids = _extract_tracker_frame(tracker_data, frame)
_ensure_unique_ids(ground_truth_ids, frame, "Ground truth")
_ensure_unique_ids(tracker_ids, frame, "Tracker")
tracker_boxes, tracker_ids = _remove_distractor_matches(all_boxes, distractor_mask, tracker_boxes, tracker_ids)

remapped_ground_truth_ids = _remap_ids(ground_truth_ids, ground_truth_id_map)
Expand Down
62 changes: 62 additions & 0 deletions tests/io/test_mot.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,68 @@ def _frame(
)


class TestPrepareMotSequenceDuplicateIds:
"""A track id repeated inside one frame is rejected rather than silently undercounted.

Metrics accumulate per-id counts with fancy-indexed `+=`, which NumPy buffers: repeated indices are written once
instead of added, so a duplicate id lowers the counts every metric divides by. TrackEval rejects this input too.
"""

def test_duplicate_ground_truth_ids_raise(self) -> None:
"""The same ground-truth id twice in one frame is rejected."""
ground_truth = {1: _frame([1, 1], [[0, 0, 10, 10], [50, 50, 10, 10]], [1.0, 1.0], [1, 1])}
tracker = {1: _frame([10], [[0, 0, 10, 10]], [1.0], [1])}

with pytest.raises(ValueError, match="Ground truth has duplicate track ids in frame 1"):
_prepare_mot_sequence(ground_truth, tracker)

def test_duplicate_tracker_ids_raise(self) -> None:
"""The same tracker id twice in one frame is rejected."""
ground_truth = {1: _frame([1], [[0, 0, 10, 10]], [1.0], [1])}
tracker = {1: _frame([10, 10], [[0, 0, 10, 10], [50, 50, 10, 10]], [1.0, 1.0], [1, 1])}

with pytest.raises(ValueError, match="Tracker has duplicate track ids in frame 1"):
_prepare_mot_sequence(ground_truth, tracker)

def test_duplicate_ids_across_frames_allowed(self) -> None:
"""The same id on consecutive frames is normal tracking, not a duplicate."""
ground_truth = {
1: _frame([1], [[0, 0, 10, 10]], [1.0], [1]),
2: _frame([1], [[1, 1, 10, 10]], [1.0], [1]),
}
tracker = {
1: _frame([10], [[0, 0, 10, 10]], [1.0], [1]),
2: _frame([10], [[1, 1, 10, 10]], [1.0], [1]),
}

sequence = _prepare_mot_sequence(ground_truth, tracker)

assert sequence.num_gt_dets == 2
assert sequence.num_gt_ids == 1

def test_duplicate_filtered_ground_truth_ids_allowed(self) -> None:
"""Duplicate ids on rows the filter drops never reach accumulation, so they are allowed."""
ground_truth = {
1: _frame([1, 1, 2], [[0, 0, 10, 10], [50, 50, 10, 10], [90, 90, 10, 10]], [0.0, 0.0, 1.0], [1, 1, 1])
}
tracker = {1: _frame([10], [[90, 90, 10, 10]], [1.0], [1])}

sequence = _prepare_mot_sequence(ground_truth, tracker)

assert sequence.num_gt_dets == 1

def test_repeated_unconfirmed_tracker_ids_allowed(self) -> None:
"""Unconfirmed detections all carry id -1 and are dropped, so repeats are expected."""
ground_truth = {1: _frame([1], [[0, 0, 10, 10]], [1.0], [1])}
tracker = {
1: _frame([-1, -1, 10], [[0, 0, 10, 10], [50, 50, 10, 10], [90, 90, 10, 10]], [1.0, 1.0, 1.0], [1, 1, 1])
}

sequence = _prepare_mot_sequence(ground_truth, tracker)

assert sequence.num_tracker_dets == 1


class TestMotDistractorPreprocessing:
"""GT preprocessing must follow TrackEval's class-based distractor handling.

Expand Down