From 0d4c3a4fcf2c6f1ca74e66c7f0c2dabe874f18aa Mon Sep 17 00:00:00 2001 From: Abhijith Neil Abraham Date: Fri, 3 Jul 2026 01:33:49 -0700 Subject: [PATCH] fix(annotators): clip CropAnnotator boxes to the scene before cropping (#2391) --- src/supervision/annotators/core.py | 18 +++++++---- tests/annotators/test_core.py | 52 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/supervision/annotators/core.py b/src/supervision/annotators/core.py index de212839..0233729c 100644 --- a/src/supervision/annotators/core.py +++ b/src/supervision/annotators/core.py @@ -3019,17 +3019,21 @@ class CropAnnotator(BaseAnnotator): """ if not isinstance(scene, np.ndarray): return scene - crops = [ - crop_image(image=scene, xyxy=xyxy) for xyxy in detections.xyxy.astype(int) - ] - resized_crops = [ - scale_image(image=crop, scale_factor=self.scale_factor) for crop in crops - ] + image_height, image_width = scene.shape[:2] + clipped_xyxy: npt.NDArray[np.int32] = clip_boxes( + xyxy=detections.xyxy, + resolution_wh=(image_width, image_height), + ).astype(int) anchors: npt.NDArray[np.int32] = detections.get_anchors_coordinates( anchor=self.position ).astype(int) - for idx, (resized_crop, anchor) in enumerate(zip(resized_crops, anchors)): + for idx, (xyxy, anchor) in enumerate(zip(clipped_xyxy, anchors)): + x_min, y_min, x_max, y_max = xyxy + if x_max - x_min <= 0 or y_max - y_min <= 0: + continue + crop = crop_image(image=scene, xyxy=xyxy) + resized_crop = scale_image(image=crop, scale_factor=self.scale_factor) crop_wh = resized_crop.shape[1], resized_crop.shape[0] (x1, y1), (x2, y2) = self.calculate_crop_coordinates( anchor=anchor, crop_wh=crop_wh, position=self.position diff --git a/tests/annotators/test_core.py b/tests/annotators/test_core.py index 35ecfcc2..c713f170 100644 --- a/tests/annotators/test_core.py +++ b/tests/annotators/test_core.py @@ -1323,6 +1323,58 @@ class TestCropAnnotator: result = annotator.annotate(scene=gradient_image.copy(), detections=detections) assert not np.array_equal(gradient_image, result) + @pytest.mark.parametrize( + "xyxy", + [ + pytest.param([-5, 20, 40, 60], id="negative-x-min"), + pytest.param([20, -5, 60, 40], id="negative-y-min"), + pytest.param([-10, -10, 30, 30], id="negative-x-and-y-min"), + pytest.param([60, 20, 140, 60], id="past-right-edge"), + pytest.param([-20, -20, 140, 140], id="larger-than-scene"), + ], + ) + def test_annotate_with_box_crossing_scene_border( + self, gradient_image, xyxy: list[int] + ) -> None: + """Boxes extending past the scene border are clipped instead of raising""" + detections = _create_detections(xyxy=[xyxy], class_id=[0]) + annotator = CropAnnotator() + + result = annotator.annotate(scene=gradient_image.copy(), detections=detections) + + assert result.shape == gradient_image.shape + + @pytest.mark.parametrize( + "xyxy", + [ + pytest.param([150, 150, 200, 200], id="fully-outside"), + pytest.param([-50, -50, -10, -10], id="fully-negative"), + pytest.param([30, 20, 30, 60], id="zero-width"), + pytest.param([30, 30, 30, 30], id="zero-area"), + ], + ) + def test_annotate_skips_boxes_empty_after_clipping( + self, gradient_image, xyxy: list[int] + ) -> None: + """Boxes with no visible area are skipped instead of raising cv2.error""" + detections = _create_detections(xyxy=[xyxy], class_id=[0]) + annotator = CropAnnotator() + + result = annotator.annotate(scene=gradient_image.copy(), detections=detections) + + assert np.array_equal(gradient_image, result) + + def test_annotate_mixed_valid_and_degenerate_boxes(self, gradient_image) -> None: + """A degenerate box does not prevent valid boxes from being drawn""" + detections = _create_detections( + xyxy=[[150, 150, 200, 200], [10, 10, 90, 90]], class_id=[0, 1] + ) + annotator = CropAnnotator() + + result = annotator.annotate(scene=gradient_image.copy(), detections=detections) + + assert not np.array_equal(gradient_image, result) + class TestBackgroundOverlayAnnotator: """Tests for BackgroundOverlayAnnotator class"""