diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd13079..375b42f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### 🌱 Changed +- **McByte skips mask-only association work when mask evidence is unavailable** — clear-match locking and reduced assignment are preserved, while ambiguity and isolated-candidate matrices are no longer built for the default mask-disabled path. - **McByte CMC now defaults to `cmc_downscale=6`** — this aggregate-performance default halves median CMC latency versus factor `2` on the complete 45-clip, 1280x720 SportsMOT validation split and passes the dataset-level mean/median quality criterion. The benchmark used ground-truth detections with masks disabled; 9/45 clips regressed under the previous strict per-clip gate. Pass `cmc_downscale=2` to preserve the previous conservative behavior. Generic `CMCConfig` and `BoTSORTTracker` remain at `2`. - **Mask stack moved from `trackers.core.mcbyte.masks` to `trackers.core.masks`** — SAM mask generation, Cutie propagation, and `MaskManager` reference no tracker and are not McByte-specific, so they now live beside the trackers rather than inside one. Import from `trackers.core.masks` instead ([#543](https://github.com/roboflow/trackers/pull/543)). diff --git a/src/trackers/core/mcbyte/mask_association.py b/src/trackers/core/mcbyte/mask_association.py index 286466a3..3708a037 100644 --- a/src/trackers/core/mcbyte/mask_association.py +++ b/src/trackers/core/mcbyte/mask_association.py @@ -231,7 +231,7 @@ def _apply_mask_similarity_boosts( remaining_detection_indices: list[int], tracklet_ids: list[int], detection_boxes: np.ndarray, - mask_output: MaskOutput | None, + mask_output: MaskOutput, minimum_mask_average_confidence: float, minimum_mask_coverage: float, minimum_mask_fill_ratio: float, @@ -285,8 +285,8 @@ def _apply_mask_similarity_boosts( mask_output: Current propagated mask output. Its ``tracklet_mask_dict`` maps stable tracklet IDs to local mask-array indices, while ``mask_avg_prob_dict`` stores average mask confidence - keyed by stable tracklet ID. If the output, masks, or confidence - mapping is unavailable, no scores are modified. + keyed by stable tracklet ID. Callers must have already established + that masks and confidences are present. minimum_mask_average_confidence: Minimum average propagated-mask confidence required before mask evidence may be used. minimum_mask_coverage: Minimum fraction of the complete visible mask @@ -297,7 +297,7 @@ def _apply_mask_similarity_boosts( Returns: None. ``conditioned_similarity`` is updated in place. """ - if mask_output is None or mask_output.masks is None or mask_output.mask_avg_prob_dict is None: + if mask_output.masks is None or mask_output.mask_avg_prob_dict is None: return # Local indices in the reduced matrix. @@ -477,6 +477,21 @@ def condition_similarity_with_masks( ) ].copy() + if ( + mask_output is None + or mask_output.masks is None + or mask_output.masks.shape[0] == 0 + or not mask_output.tracklet_mask_dict + or not mask_output.mask_avg_prob_dict + ): + # No tracklet can receive mask evidence, so the reduced problem is final. + return MaskConditionedAssociation( + conditioned_similarity=reduced_similarity, + locked_matches=locked_matches, + remaining_track_indices=remaining_track_indices, + remaining_detection_indices=remaining_detection_indices, + ) + # Ambiguity is a property of the original association situation before any # modifications, hence computed from base_similarity. ambiguous_candidates = _get_ambiguous_candidate_matrix( diff --git a/tests/core/test_mcbyte_mask_association.py b/tests/core/test_mcbyte_mask_association.py index e01502ab..d05bedea 100644 --- a/tests/core/test_mcbyte_mask_association.py +++ b/tests/core/test_mcbyte_mask_association.py @@ -6,6 +6,8 @@ from __future__ import annotations +from unittest.mock import patch + import numpy as np import pytest @@ -341,28 +343,87 @@ def test_mask_bonus_is_not_clamped_to_one() -> None: assert np.isclose(result.conditioned_similarity[0, 0], 1.8) -def test_missing_mask_output_keeps_ambiguous_scores_unchanged() -> None: - similarity = np.array([[0.7, 0.6]], dtype=np.float32) - - result = condition_similarity_with_masks( - similarity=similarity, - raw_iou_similarity=similarity, - tracklet_ids=[10], - detection_boxes=np.array( - [ - [0, 0, 5, 5], - [5, 5, 10, 10], - ], - dtype=np.float32, +@pytest.mark.parametrize( + "mask_output", + [ + pytest.param(None, id="missing-output"), + pytest.param( + MaskOutput(masks=None, tracklet_mask_dict={}, mask_avg_prob_dict={}), + id="missing-masks", ), - mask_output=None, - minimum_similarity=0.5, + pytest.param( + MaskOutput( + masks=np.ones((1, 10, 10), dtype=bool), + tracklet_mask_dict={10: 0}, + mask_avg_prob_dict=None, + ), + id="missing-confidence-map", + ), + pytest.param( + MaskOutput( + masks=np.zeros((0, 10, 10), dtype=bool), + tracklet_mask_dict={10: 0}, + mask_avg_prob_dict={10: 0.9}, + ), + id="zero-masks", + ), + pytest.param( + MaskOutput( + masks=np.ones((1, 10, 10), dtype=bool), + tracklet_mask_dict={}, + mask_avg_prob_dict={10: 0.9}, + ), + id="empty-tracklet-map", + ), + pytest.param( + MaskOutput( + masks=np.ones((1, 10, 10), dtype=bool), + tracklet_mask_dict={10: 0}, + mask_avg_prob_dict={}, + ), + id="empty-confidence-map", + ), + ], +) +def test_missing_mask_evidence_skips_candidate_matrix_work(mask_output: MaskOutput | None) -> None: + similarity = np.array( + [ + [0.9, 0.1, 0.0], + [0.1, 0.7, 0.6], + [0.0, 0.6, 0.7], + ], + dtype=np.float32, ) + original = similarity.copy() - np.testing.assert_array_equal( - result.conditioned_similarity, - similarity, - ) + with ( + patch("trackers.core.mcbyte.mask_association._get_ambiguous_candidate_matrix") as ambiguous_candidates, + patch("trackers.core.mcbyte.mask_association._get_isolated_candidate_matrix") as isolated_candidates, + ): + result = condition_similarity_with_masks( + similarity=similarity, + raw_iou_similarity=similarity, + tracklet_ids=[10, 20, 30], + detection_boxes=np.array( + [ + [0, 0, 5, 5], + [5, 5, 10, 10], + [10, 10, 15, 15], + ], + dtype=np.float32, + ), + mask_output=mask_output, + minimum_similarity=0.5, + enable_isolated_mask_matching=True, + ) + + ambiguous_candidates.assert_not_called() + isolated_candidates.assert_not_called() + assert result.locked_matches == [(0, 0)] + assert result.remaining_track_indices == [1, 2] + assert result.remaining_detection_indices == [1, 2] + np.testing.assert_array_equal(result.conditioned_similarity, original[1:, 1:]) + np.testing.assert_array_equal(similarity, original) def test_missing_tracklet_mask_keeps_scores_unchanged() -> None: