diff --git a/docs/changelog.md b/docs/changelog.md index 541d5bb0..aba7d8d2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -21,52 +21,100 @@ date_modified: 2026-07-27 ### Fixed +- Fixed [#2467](https://github.com/roboflow/supervision/issues/2467): `sv.Recall` now tracks classes that appear only in predictions, matching `sv.Precision` and `sv.F1Score` after [#2331](https://github.com/roboflow/supervision/pull/2331) and matching sklearn, which infers labels from the union of `y_true` and `y_pred`. `matched_classes` and `recall_per_class` are now aligned across the three metrics, including for samples that have predictions but no targets (background images), so per-class results can be compared row for row. `matched_classes` and `recall_per_class` gain a row for each prediction-only class under every averaging method; only the scalar `MACRO` recall changes value, since such a class now contributes `0.0`, while the scalar `MICRO` and `WEIGHTED` aggregates are unaffected. Users relying on previous scores should re-evaluate after upgrading; no API change is required. + - `DetectionDataset.from_pascal_voc` no longer raises `ValueError` on background images. An annotation file with no `object` elements produced an empty `class_id` array of dtype `float64`, which failed `DetectionDataset` validation, so any Pascal VOC dataset containing an unannotated image could not be loaded. + - `DetectionDataset.from_pascal_voc` with `force_masks=True` no longer raises `ValueError` on background images. An annotation file with no `object` elements produced an empty mask of shape `(0,)` instead of the required `(0, H, W)`, which failed `Detections` validation. + - Reopening an existing `sv.CSVSink` or `sv.JSONSink` now starts a fresh output session: CSV files receive a new header and field schema, while JSON files no longer retain rows from the previous session. + - `sv.Detections.from_vlm` with `sv.VLM.GOOGLE_GEMINI_2_0`, `sv.VLM.GOOGLE_GEMINI_2_5`, and `sv.VLM.GOOGLE_GEMINI_3_5` now salvages the valid entries from a partially malformed JSON array (e.g. a single object with a syntax error) instead of discarding the whole response. + - Geometry-aware IoU dispatch now powers the deprecated `merge_inner_detections_objects`, so overlapping axis-aligned envelopes no longer merge oriented boxes whose true OBB IoU is below the threshold ([#2374](https://github.com/roboflow/supervision/pull/2374)). + - `save_coco_annotations` (and therefore `DetectionDataset.as_coco`) now reads image sizes from file headers via lazy PIL instead of cv2-decoding every image, so labels-only COCO exports no longer decode any pixel data ([#2442](https://github.com/roboflow/supervision/pull/2442)). + - Fixed [#2437](https://github.com/roboflow/supervision/pull/2437): `sv.F1Score` no longer emits a spurious `RuntimeWarning` when true positives, false positives, and false negatives are all zero (denominator 0); the score remains `0.0`. + - Supervision now emits a `UserWarning` at import time when OpenCV is not installed and the cv2-free fallback backend is used, so users relying on OpenCV-specific behavior are alerted instead of silently falling back. + - The cv2-free fallback now preserves OpenCV-compatible edge and keyword semantics for image borders, resizing, drawing, and small polygon masks, keeping ordinary production consumers usable without cv2. + - The cv2-free fallback's `copyMakeBorder` now fills only channel 0 for a scalar border `value` on multichannel images, matching OpenCV's `Scalar(v)` semantics instead of broadcasting the value to every channel. + - The cv2-free fallback's `addWeighted` now raises `ValueError` for a non-default `dtype` instead of silently ignoring it. + - The cv2-free fallback's `approxPolyDP` now follows OpenCV's stack traversal, closed-contour anchor selection, and final cleanup. The implementation avoids the former O(N²) distance matrix and exactly matches OpenCV on the deterministic 100-polygon regression corpus. + - The cv2-free fallback now preserves OpenCV's half-pixel bilinear interpolation when downsampling uint8 images and its anchor selection for contours whose first point is explicitly repeated at the end. + - The cv2-free fallback now uses OpenCV's fixed-point uint8 BGR-to-gray arithmetic, matching all 16,777,216 input colors exactly. Edge-distance filtering applies an O(H×W) two-pass transform with fixed-point 3×3 chamfer weights, preserving threshold decisions without retaining a generic cv2 distance-transform API. + - Reduced cv2-free media overhead by vectorizing connected-component statistics, narrowing contour extraction to the geometry production uses, and rasterizing Pillow text into a clipped glyph-sized mask. + - Removed unused raw `distanceTransform`, `getRotationMatrix2D`, and `warpAffine` compatibility symbols. Their only production consumers now use smaller domain operations: a bounded two-pass chamfer transform for mask filtering and Pillow rotation for line-zone labels. + - PyAV audio/video remuxing now uses its stream-template API directly instead of maintaining a local codec-parameter copy helper. + - The cv2-free fallback now renders text with Pillow and the bundled DejaVu Sans face instead of reproducing OpenCV's Hershey stroke fonts. This drops the packaged 143 KB glyph table and its loader in favor of an existing dependency. Text drawn without cv2 now uses a proportional TrueType face, so glyph shapes and `getTextSize` metrics differ from OpenCV within the documented visual-divergence tier; the OpenCV path is unchanged. All Hershey font faces remain accepted for API compatibility but map to the same face (the italic modifier selects the oblique variant). + - The cv2-free fallback's `getTextSize` now derives its height and baseline padding directly from the same `stroke_width` `putText` renders with, instead of a separate approximation. For `thickness > 2` the old formula under-padded the box, so a heavy stroke's descender pixels could fall outside the reported rectangle. + - Fixed [#2427](https://github.com/roboflow/supervision/issues/2427): size-bucketed `sv.Precision` and `sv.F1Score` no longer count out-of-bucket detections as false positives. `sv.Recall` now matches only targets in the requested bucket, and all three metrics prioritize in-bucket targets during matching, matching COCO evaluation and `sv.MeanAveragePrecision`. A pixel-perfect detector now scores 1.0 in every bucket. + - `sv.hex_to_rgba` now rejects multiple leading `#` characters instead of silently normalizing them, matching `sv.is_valid_hex` and the documented single optional prefix. + - `sv.box_iou_batch` now upcasts box corners to `float64` before computing areas and intersections, returning `float32`. This fixes integer-dtype overflow (e.g. `int32` coordinates around `50_000` could previously wrap to a negative area and produce an incorrect `0.0` IoU) and gives full `float64` precision to callers that pass `float64`/`int64` coordinates directly. It does not recover precision already lost when coordinates are stored as `float32` before this function is called (e.g. `Detections.xyxy`, which is `float32` throughout the library) — such callers must upcast their own arrays to `float64`/`int64` before calling `box_iou_batch` to benefit from this fix. Results for small-coordinate inputs are unchanged. + - Legacy COCO prediction loading in `sv.EvaluationDataset.load_predictions` now raises `ValueError` for image ids absent from the ground-truth COCO set instead of relying on a bare `assert`, so the check is no longer silently skipped under `python -O`. + - `sv.HeatMapAnnotator` now exposes a `reset()` method to clear accumulated heat, so a single annotator instance can be reused across independent streams without carrying over heat from a previous stream. + - `sv.TraceAnnotator` and `sv.DetectionsSmoother` now expose a `reset()` method for interface consistency with `sv.HeatMapAnnotator.reset()`, clearing their accumulated per-track history so a single instance can be reused across independent streams. + - Fixed [#2416](https://github.com/roboflow/supervision/pull/2416): `sv.process_video` no longer risks hanging during shutdown; the sentinel enqueue is best-effort and worker joins are bounded. + - Fixed [#2416](https://github.com/roboflow/supervision/pull/2416): COCO and CreateML dataset loaders now canonicalize resolved image paths and reject duplicate aliases for the same file. + - Fixed [#2416](https://github.com/roboflow/supervision/pull/2416): `DetectionDataset.as_pascal_voc()` now preflights image and annotation basename collisions before writing, so exports fail fast instead of producing partial output. + - `import supervision` no longer surfaces the deprecated `ByteTrack` warning; the top-level tracker alias now resolves lazily when accessed explicitly. + - Fixed dataset export edge cases: `DetectionDataset.split()` and `DetectionDataset.merge()` now preserve in-memory image payloads without re-emitting the deprecation warning, and COCO/CreateML exports now reject duplicate image basenames instead of silently collapsing distinct paths into the same output key. + - Fixed: `sv.Color(...)` now validates direct RGBA channel values and raises `ValueError` when any channel falls outside the 0-255 byte range. + - Fixed: `approximate_mask_with_polygons` now defaults to no polygon simplification, matching the public dataset export methods. + - Fixed: `ImageSink.save_image()` now raises `OSError` when `cv2.imwrite()` fails, and deprecation-warning control accepts the correct `SUPERVISION_DEPRECATION_WARNING` environment variable while still honoring the legacy misspelled alias. + - `sv.Classifications.from_timm` now softmaxes model logits before exposing confidence scores, matching `sv.Classifications.from_clip` and keeping timm confidences on a normalized probability scale. Thresholds calibrated against raw logits may need retuning. + - `sv.download_assets` now verifies MD5 hashes after fresh downloads and retries once when the downloaded payload is corrupted instead of accepting a bad file. + - Fixed metrics scoring edge cases: legacy `sv.MeanAveragePrecision` now uses COCO 101-point AP averaging, `sv.ConfusionMatrix` rejects invalid class ids instead of wrapping them through `int16`/negative indexing, `sv.MeanAveragePrecision` preserves user-provided target `ignore` flags, and `sv.MeanAverageRecallResult.recall_per_class` now exposes per-class recall for each max-detection cutoff. + - Fixed [#2408](https://github.com/roboflow/supervision/pull/2408): `sv.Precision`, `sv.Recall`, `sv.F1Score`, and `sv.MeanAverageRecall` now score size buckets by filtering targets only while leaving predictions eligible to match bucket targets. This preserves bucket matches that would otherwise be stolen by out-of-bucket filtering and keeps mAR top-K ranking intact. + - `sv.ByteTrack` no longer mutates input `Detections` while assigning tracker IDs. It now keeps detections at the activation-threshold boundary eligible for matching, avoids impossible new-track thresholds above score `1.0`, ignores invalid zero-area/non-finite tensor boxes before Kalman updates, and does not emit unconfirmed `-1` IDs from first-frame tensor updates. + - Fixed [#2402](https://github.com/roboflow/supervision/pull/2402): `sv.KeyPoints.as_detections` now accepts NumPy arrays, tuples, and generators in `selected_keypoint_indices` without ambiguous truth-value errors; empty index iterables select all keypoints. Valid zero-area skeletons are preserved, while all-zero and non-finite-only skeletons are filtered out. + - Changed: delayed `sv.ByteTrack`, `supervision.keypoint`, `normalized_xyxy` for `sv.denormalize_boxes`, and `supervision.dataset.utils` RLE compatibility removals from `supervision-0.30.0` to `supervision-0.31.0` so the deprecated APIs keep a full transition window. + - Fixed [#2407](https://github.com/roboflow/supervision/pull/2407): `sv.ColorPalette.by_idx()` now raises a clear `ValueError` when called on an empty palette instead of leaking a `ZeroDivisionError`. Non-empty palettes keep the existing index-wrapping behavior. + - Fixed [#2393](https://github.com/roboflow/supervision/pull/2393): `sv.CropAnnotator.annotate` no longer raises `cv2.error` when detections extend outside the scene; out-of-bounds boxes are clipped to scene bounds and zero-area results are skipped silently. + - Fixed [#2393](https://github.com/roboflow/supervision/pull/2393): `sv.HeatMapAnnotator.annotate` no longer blanks the hottest region when the per-pixel hit count exceeds 255; the heat mask is now derived from the float32 accumulator directly, avoiding uint8 wrap-around. + - Fixed [#2393](https://github.com/roboflow/supervision/pull/2393): `sv.get_video_frames_generator` now releases the underlying `cv2.VideoCapture` via `try/finally`, so the decoder is freed when a consumer breaks out of iteration early rather than waiting for garbage collection. + - Fixed [#2382](https://github.com/roboflow/supervision/pull/2382): `sv.Detections.get_anchors_coordinates` now uses oriented bounding box corners (`data["xyxyxyxy"]`) when OBB data is present, instead of falling back to the axis-aligned envelope. Anchors on rotated detections now lie on the oriented body rather than drifting to the envelope. Non-OBB detections and `Position.CENTER_OF_MASS` (which requires a mask) are unaffected. + - Fixed [#2396](https://github.com/roboflow/supervision/pull/2396): `sv.BackgroundOverlayAnnotator.annotate` no longer leaves detection regions tinted when bounding boxes have negative coordinates (extend outside the left or top scene boundary); boxes are now clipped to scene bounds before the detection region is restored. + - Fixed: dataset IO/export edge cases now avoid mutating caller-owned `Detections` during `DetectionDataset` construction, reject non-integer and out-of-range class ids with a clear `ValueError`, load COCO annotations that omit optional `iscrowd`/`area` fields, expose `DetectionDataset.from_coco(use_iscrowd=...)` without changing the existing positional `show_progress` argument, export mask pixel area to COCO when no stored area is present, ignore folder-structure root clutter and non-image files inside class folders, and accept PIL-readable YOLO images such as RGBA or palette PNGs. ### Added diff --git a/src/supervision/metrics/recall.py b/src/supervision/metrics/recall.py index 16659508..e5be7bb5 100644 --- a/src/supervision/metrics/recall.py +++ b/src/supervision/metrics/recall.py @@ -64,6 +64,31 @@ class Recall(Metric["RecallResult"]): ``` + A class that only ever appears in the predictions (for example a detection + on a background image with no ground truth) has no instances to recall, so + it is tracked with a recall of `0.0` rather than dropped. This keeps the + tracked class set aligned with Precision and F1Score for the same input: + + ```pycon + >>> predictions = sv.Detections( + ... xyxy=np.array([[0, 0, 10, 10], [100, 0, 110, 10]]), + ... class_id=np.array([0, 1]), # class 1 has no ground-truth instance + ... confidence=np.array([0.9, 0.8]) + ... ) + >>> targets = sv.Detections( + ... xyxy=np.array([[0, 0, 10, 10]]), + ... class_id=np.array([0]) + ... ) + >>> recall_result = Recall().update(predictions, targets).compute() + >>> recall_result.matched_classes.tolist() + [0, 1] + >>> round(float(recall_result.recall_per_class[0][0]), 2) # matched class 0 + 1.0 + >>> round(float(recall_result.recall_per_class[1][0]), 2) # prediction-only + 0.0 + + ``` + ![example_plot]( https://media.roboflow.com/supervision-docs/metrics/recall_plot_example.png ){ align=center width="800" } @@ -173,7 +198,40 @@ class Recall(Metric["RecallResult"]): == size_category.value ) - if len(targets) > 0: + if len(targets) == 0 and len(predictions) > 0: + # Only predictions are present (e.g. a background image). They produce + # no false negatives, so no recall value changes, but the classes still + # have to be tracked or `matched_classes` silently disagrees with + # Precision and F1Score for the same input. + if predictions.class_id is None or predictions.confidence is None: + raise ValueError( + "Recall metric requires `class_id` and `confidence` " + "on predictions." + ) + prediction_class_ids = np.asarray(predictions.class_id, dtype=np.int32)[ + prediction_size_mask + ] + prediction_confidence = np.asarray( + predictions.confidence, dtype=np.float32 + )[prediction_size_mask] + if len(prediction_class_ids) == 0: + continue + stats.append( + ( + np.zeros( + (len(prediction_class_ids), iou_thresholds.size), + dtype=np.bool_, + ), + np.zeros( + (len(prediction_class_ids), iou_thresholds.size), + dtype=np.bool_, + ), + prediction_confidence, + prediction_class_ids, + np.zeros((0,), dtype=np.int32), + ) + ) + elif len(targets) > 0: if predictions.class_id is None or targets.class_id is None: raise ValueError( "Recall metric requires `class_id` on both predictions " @@ -328,7 +386,19 @@ class Recall(Metric["RecallResult"]): matches = matches[sorted_indices] ignored_matches = ignored_matches[sorted_indices] prediction_class_ids = prediction_class_ids[sorted_indices] - unique_classes, class_counts = np.unique(true_class_ids, return_counts=True) + # Classes that appear only in predictions have no ground-truth instances, so + # their recall is 0.0 rather than undefined. Including them keeps the tracked + # class set identical to Precision and F1Score and matches sklearn, which infers + # labels from the union of y_true and y_pred. + true_classes, true_counts = np.unique(true_class_ids, return_counts=True) + pred_classes = np.unique(prediction_class_ids) + # Dedupe each side first, then union1d the already-unique arrays: this skips + # union1d's internal re-sort/re-unique of the full concatenation and measured + # ~1.4x faster than np.unique(np.concatenate(...)). Deduping after the union + # (or union1d on the raw arrays) yields no speedup. + unique_classes = np.union1d(true_classes, pred_classes) + class_counts = np.zeros(unique_classes.shape[0], dtype=int) + class_counts[np.searchsorted(unique_classes, true_classes)] = true_counts # Shape: PxTh,P,C,C -> CxThx3 confusion_matrix = self._compute_confusion_matrix( @@ -575,10 +645,12 @@ class RecallResult: recall_scores: the recall scores at each IoU threshold. Shape: `(num_iou_thresholds,)` recall_per_class: the recall scores per class and IoU threshold. - Shape: `(num_target_classes, num_iou_thresholds)` + Shape: `(num_classes, num_iou_thresholds)` iou_thresholds: the IoU thresholds used in the calculations. - matched_classes: the class IDs of all matched classes. - Corresponds to the rows of `recall_per_class`. + matched_classes: the class IDs present in either predictions or ground + truth. Corresponds to the rows of `recall_per_class`. Classes that + appear only in predictions (no ground-truth instances) are included; + their per-threshold recall values will be `0.0`. small_objects: the Recall metric results for small objects (area < 32²). medium_objects: the Recall metric results diff --git a/tests/metrics/test_recall.py b/tests/metrics/test_recall.py index 1b5ec7e7..34c5346b 100644 --- a/tests/metrics/test_recall.py +++ b/tests/metrics/test_recall.py @@ -4,6 +4,8 @@ import pytest from supervision.detection.compact_mask import CompactMask from supervision.detection.core import Detections from supervision.metrics.core import AveragingMethod, MetricTarget +from supervision.metrics.f1_score import F1Score +from supervision.metrics.precision import Precision from supervision.metrics.recall import Recall from tests.helpers import assert_almost_equal @@ -127,6 +129,252 @@ class TestRecall: assert result.recall_at_50 == 0.0 assert result.recall_at_75 == 0.0 + @pytest.mark.parametrize( + ("method", "expected"), + [ + pytest.param( + AveragingMethod.MICRO, 1.0, id="micro-absent-class-adds-no-fn" + ), + pytest.param( + AveragingMethod.MACRO, 0.5, id="macro-includes-absent-class-at-zero" + ), + pytest.param( + AveragingMethod.WEIGHTED, + 1.0, + id="weighted-absent-class-has-no-support-by-design", + ), + ], + ) + def test_absent_class_predictions_are_tracked(self, method, expected): + """A class predicted but never present in the targets is still tracked. + + Recall for such a class is 0.0 rather than undefined, which is what sklearn + reports and what Precision and F1Score already do here. MICRO is unchanged + because an absent class contributes no false negatives, and WEIGHTED is + unchanged because its ground-truth support is zero. + """ + predictions = Detections( + xyxy=np.array( + [[0, 0, 10, 10], [100, 0, 110, 10], [120, 0, 130, 10]], np.float32 + ), + class_id=np.array([0, 1, 1]), # class 1 never appears in the targets + confidence=np.array([0.9, 0.8, 0.7]), + ) + targets = Detections( + xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32), + class_id=np.array([0]), + ) + + metric = Recall(averaging_method=method) + result = metric.update(predictions, targets).compute() + + assert result.recall_at_50 == expected + assert list(result.matched_classes) == [0, 1] + + def test_tracked_classes_match_precision_and_f1(self): + """The three metrics must agree on which classes exist for the same data. + + They return `matched_classes` and a `*_per_class` array that read as parallel + outputs. When the class sets diverge, zipping them silently truncates instead + of raising. + """ + predictions = Detections( + xyxy=np.array([[0, 0, 10, 10], [100, 0, 110, 10]], dtype=np.float32), + class_id=np.array([0, 1]), + confidence=np.array([0.9, 0.8]), + ) + targets = Detections( + xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32), + class_id=np.array([0]), + ) + + recall = Recall().update(predictions, targets).compute() + precision = Precision().update(predictions, targets).compute() + f1 = F1Score().update(predictions, targets).compute() + + assert list(recall.matched_classes) == list(precision.matched_classes) + assert list(recall.matched_classes) == list(f1.matched_classes) + assert ( + recall.recall_per_class.shape[0] == precision.precision_per_class.shape[0] + ) + + @pytest.mark.parametrize( + ("averaging_method", "expected_recall_at_50"), + [ + pytest.param( + AveragingMethod.WEIGHTED, + 1.0, + id="weighted-background-class-has-no-support", + ), + pytest.param( + AveragingMethod.MICRO, + 1.0, + id="micro-background-class-adds-no-fn", + ), + pytest.param( + AveragingMethod.MACRO, + 0.5, + id="macro-background-class-drags-average-down", + ), + ], + ) + def test_tracked_classes_match_precision_and_f1_with_background_images( + self, averaging_method: AveragingMethod, expected_recall_at_50: float + ) -> None: + """The class sets must still agree when a sample has predictions and no targets. + + A background image produces no false negatives, so no recall value changes + under WEIGHTED or MICRO, but its predicted classes still have to be tracked. + Building the class union only inside the targets-present path leaves them + out, and the three metrics disagree again for list inputs that contain one. + MACRO is where the background class's 0.0 recall placeholder visibly shifts + the aggregate, since it is averaged unweighted across classes. + """ + with_targets_pred = Detections( + xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32), + class_id=np.array([0]), + confidence=np.array([0.9]), + ) + with_targets_gt = Detections( + xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32), + class_id=np.array([0]), + ) + background_pred = Detections( + xyxy=np.array([[50, 50, 60, 60]], dtype=np.float32), + class_id=np.array([2]), # class 2 exists only on a target-less sample + confidence=np.array([0.8]), + ) + + preds = [with_targets_pred, background_pred] + gts = [with_targets_gt, Detections.empty()] + + recall = Recall(averaging_method=averaging_method).update(preds, gts).compute() + precision = Precision().update(preds, gts).compute() + f1 = F1Score().update(preds, gts).compute() + + assert list(recall.matched_classes) == [0, 2] + assert list(recall.matched_classes) == list(precision.matched_classes) + assert list(recall.matched_classes) == list(f1.matched_classes) + assert ( + recall.recall_per_class.shape[0] == precision.precision_per_class.shape[0] + ) + assert recall.recall_at_50 == pytest.approx(expected_recall_at_50) + + def test_background_image_size_bucket_filters_predictions_by_size(self) -> None: + """Size buckets restrict background-image prediction-only classes by size. + + A background image (empty targets) with predictions of different sizes must + only surface in the size bucket matching that size; a bucket left with zero + predictions after size filtering must stay empty rather than error. + """ + predictions = Detections( + xyxy=np.array( + [ + [0, 0, 10, 10], # area 100 (Small) + [0, 0, 200, 200], # area 40000 (Large) + ], + dtype=np.float32, + ), + confidence=np.array([0.9, 0.8]), + class_id=np.array([1, 2]), + ) + targets = Detections.empty() + + result = Recall().update(predictions, targets).compute() + + assert result.small_objects is not None + assert list(result.small_objects.matched_classes) == [1] + assert result.small_objects.recall_per_class.shape == (1, 10) + + assert result.large_objects is not None + assert list(result.large_objects.matched_classes) == [2] + assert result.large_objects.recall_per_class.shape == (1, 10) + + # Neither prediction is Medium: size filtering leaves zero predictions, so + # the `continue` path is hit and the sample contributes nothing to this bucket. + assert result.medium_objects is not None + assert list(result.medium_objects.matched_classes) == [] + assert result.medium_objects.recall_per_class.shape == (0, 10) + + def test_multiple_background_image_samples_accumulate_classes(self) -> None: + """Two background-image samples in one list input union their classes. + + Prediction-only classes from separate background-only samples must all be + tracked together, not just the first sample's class. + """ + background_pred_1 = Detections( + xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32), + class_id=np.array([5]), + confidence=np.array([0.9]), + ) + background_pred_2 = Detections( + xyxy=np.array([[20, 20, 30, 30]], dtype=np.float32), + class_id=np.array([9]), + confidence=np.array([0.7]), + ) + preds = [background_pred_1, background_pred_2] + gts = [Detections.empty(), Detections.empty()] + + result = Recall().update(preds, gts).compute() + + assert list(result.matched_classes) == [5, 9] + assert result.recall_per_class.shape == (2, 10) + assert result.recall_at_50 == 0.0 + + def test_background_image_duplicate_class_ids_deduplicated(self) -> None: + """Repeated class ids in one background-image sample collapse to a set.""" + predictions = Detections( + xyxy=np.array( + [[0, 0, 10, 10], [20, 20, 30, 30], [40, 40, 50, 50]], dtype=np.float32 + ), + confidence=np.array([0.9, 0.8, 0.7]), + class_id=np.array([2, 2, 3]), + ) + targets = Detections.empty() + + result = Recall().update(predictions, targets).compute() + + assert list(result.matched_classes) == [2, 3] + assert result.recall_per_class.shape == (2, 10) + assert result.recall_at_50 == 0.0 + + def test_non_contiguous_class_ids_align_by_value_not_index(self) -> None: + """`matched_classes` rows align to class-id values, not positional order. + + Large, non-contiguous class ids are sorted numerically by `np.unique` / + `searchsorted`; a positional-index bug would misalign the prediction-only + class's zero recall with the matched class's row. + """ + predictions = Detections( + xyxy=np.array( + [ + [100, 100, 140, 140], # class 1000, no matching target + [10, 10, 50, 50], # class 5, matches first target exactly + ], + dtype=np.float32, + ), + confidence=np.array([0.95, 0.9]), + class_id=np.array([1000, 5]), + ) + targets = Detections( + xyxy=np.array( + [ + [10, 10, 50, 50], # class 5, matched + [200, 200, 240, 240], # class 5, missed + ], + dtype=np.float32, + ), + class_id=np.array([5, 5]), + ) + + result = Recall().update(predictions, targets).compute() + + assert list(result.matched_classes) == [5, 1000] + assert result.recall_per_class.shape == (2, 10) + assert np.all(result.recall_per_class[0] == 0.5) # class 5: TP=1, FN=1 + assert np.all(result.recall_per_class[1] == 0.0) # class 1000: no targets + assert result.recall_at_50 == pytest.approx(0.5) + def test_empty_predictions(self, targets_50_50): """Test recall with empty predictions but existing targets""" predictions = Detections.empty()