diff --git a/src/supervision/annotators/core.py b/src/supervision/annotators/core.py index a2f729b3..52a77a3f 100644 --- a/src/supervision/annotators/core.py +++ b/src/supervision/annotators/core.py @@ -15,6 +15,8 @@ from supervision.annotators.utils import ( PENDING_TRACK_ID, ColorLookup, Trace, + calculate_dynamic_kernel_size, + calculate_dynamic_pixel_size, get_labels_text, hex_to_rgba, resolve_color, @@ -1841,12 +1843,16 @@ class BlurAnnotator(BaseAnnotator): A class for blurring regions in an image using provided detections. """ - def __init__(self, kernel_size: int = 15): + def __init__(self, kernel_size: int | None = None): """ Args: kernel_size: The size of the average pooling kernel used for blurring. + If not set, a dynamic size is computed as one-third of the shorter + bounding-box dimension. Must be >= 1 when provided. """ - self.kernel_size: int = kernel_size + if kernel_size is not None and kernel_size < 1: + raise ValueError(f"kernel_size must be >= 1, got {kernel_size}.") + self.kernel_size: int | None = kernel_size @ensure_cv2_image_for_class_method def annotate( @@ -1895,8 +1901,15 @@ class BlurAnnotator(BaseAnnotator): ).astype(int) for x1, y1, x2, y2 in clipped_xyxy: + if x2 <= x1 or y2 <= y1: + continue roi = scene[y1:y2, x1:x2] - roi = cv2.blur(roi, (self.kernel_size, self.kernel_size)) + kernel_size = ( + self.kernel_size + if self.kernel_size is not None + else calculate_dynamic_kernel_size(x1, y1, x2, y2) + ) + roi = cv2.blur(roi, (kernel_size, kernel_size)) scene[y1:y2, x1:x2] = roi return scene @@ -2145,12 +2158,18 @@ class PixelateAnnotator(BaseAnnotator): A class for pixelating regions in an image using provided detections. """ - def __init__(self, pixel_size: int = 20): + def __init__(self, pixel_size: int | None = None): """ Args: - pixel_size: The size of the pixelation. + pixel_size: The size of the pixelation. If not set, a dynamic size is + computed as one-half of the shorter bounding-box dimension. When set + and the detection area is smaller than `pixel_size`, the region is + filled with its average colour instead to avoid an OpenCV crash. + Must be >= 1 when provided. """ - self.pixel_size: int = pixel_size + if pixel_size is not None and pixel_size < 1: + raise ValueError(f"pixel_size must be >= 1, got {pixel_size}.") + self.pixel_size: int | None = pixel_size @ensure_cv2_image_for_class_method def annotate( @@ -2197,9 +2216,25 @@ class PixelateAnnotator(BaseAnnotator): ).astype(int) for x1, y1, x2, y2 in clipped_xyxy: + if x2 <= x1 or y2 <= y1: + continue roi = scene[y1:y2, x1:x2] + + pixel_size = ( + self.pixel_size + if self.pixel_size is not None + else calculate_dynamic_pixel_size(x1, y1, x2, y2) + ) + if min(y2 - y1, x2 - x1) < pixel_size: + if roi.ndim == 2 or (roi.ndim == 3 and roi.shape[2] == 1): + scene[y1:y2, x1:x2] = cv2.mean(roi)[0] + else: + num_channels = scene.shape[2] + scene[y1:y2, x1:x2] = cv2.mean(roi)[:num_channels] + continue + scaled_up_roi = cv2.resize( - src=roi, dsize=None, fx=1 / self.pixel_size, fy=1 / self.pixel_size + src=roi, dsize=None, fx=1 / pixel_size, fy=1 / pixel_size ) scaled_down_roi = cv2.resize( src=scaled_up_roi, diff --git a/src/supervision/annotators/utils.py b/src/supervision/annotators/utils.py index 2efc053a..cfeaf981 100644 --- a/src/supervision/annotators/utils.py +++ b/src/supervision/annotators/utils.py @@ -429,3 +429,49 @@ def is_valid_hex(hex_color: str) -> bool: True if the string is a valid 6- or 8-digit hex color, otherwise False. """ return bool(re.fullmatch(r"#?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?", hex_color.strip())) + + +def calculate_dynamic_kernel_size(x1: int, y1: int, x2: int, y2: int) -> int: + """ + Computes a blur kernel size proportional to the shorter side of a bounding box. + + Args: + x1: Left edge of the bounding box. + y1: Top edge of the bounding box. + x2: Right edge of the bounding box. + y2: Bottom edge of the bounding box. + + Returns: + Kernel size as one-third of the shorter dimension, minimum 1. + + Examples: + ```pycon + >>> calculate_dynamic_kernel_size(0, 0, 90, 60) + 20 + + ``` + """ + return max(1, min(y2 - y1, x2 - x1) // 3) + + +def calculate_dynamic_pixel_size(x1: int, y1: int, x2: int, y2: int) -> int: + """ + Computes a pixelation size proportional to the shorter side of a bounding box. + + Args: + x1: Left edge of the bounding box. + y1: Top edge of the bounding box. + x2: Right edge of the bounding box. + y2: Bottom edge of the bounding box. + + Returns: + Pixel size as one-half of the shorter dimension, minimum 1. + + Examples: + ```pycon + >>> calculate_dynamic_pixel_size(0, 0, 90, 60) + 30 + + ``` + """ + return max(1, min(y2 - y1, x2 - x1) // 2) diff --git a/tests/annotators/test_core.py b/tests/annotators/test_core.py index 7614943f..15721338 100644 --- a/tests/annotators/test_core.py +++ b/tests/annotators/test_core.py @@ -504,6 +504,19 @@ class TestBlurAnnotator: result = annotator.annotate(scene=gradient_image.copy(), detections=detections) assert not np.array_equal(gradient_image, result) + @pytest.mark.parametrize("bad_size", [0, -1, -10]) + def test_invalid_kernel_size_raises(self, bad_size): + """BlurAnnotator must reject kernel_size < 1 at construction time.""" + with pytest.raises(ValueError, match="kernel_size must be >= 1"): + BlurAnnotator(kernel_size=bad_size) + + def test_annotate_zero_area_bbox_is_skipped(self, test_image): + """Zero-area bounding boxes must be silently skipped, not crash.""" + detections = _create_detections(xyxy=[[10, 10, 10, 50]], class_id=[0]) + annotator = BlurAnnotator(kernel_size=5) + result = annotator.annotate(scene=test_image.copy(), detections=detections) + assert np.array_equal(test_image, result) + class TestPixelateAnnotator: """Tests for PixelateAnnotator class""" @@ -522,6 +535,57 @@ class TestPixelateAnnotator: result = annotator.annotate(scene=gradient_image.copy(), detections=detections) assert not np.array_equal(gradient_image, result) + def test_annotate_bbox_smaller_than_pixel_size_does_not_raise(self): + """PixelateAnnotator must not crash when the bbox is smaller than pixel_size. + + Regression test for https://github.com/roboflow/supervision/issues/703: + a fixed pixel_size larger than the detection dimensions previously caused + an OpenCV assertion error in cv2.resize. + """ + image = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8) + # bbox is 5x5; pixel_size=50 is much larger, triggers the avg-fill fallback + detections = _create_detections(xyxy=[[10, 10, 15, 15]], class_id=[0]) + annotator = PixelateAnnotator(pixel_size=50) + result = annotator.annotate(scene=image.copy(), detections=detections) + assert result.shape == image.shape + + def test_annotate_grayscale_image_does_not_raise(self): + """PixelateAnnotator must work on single-channel (grayscale) images. + + The small-ROI avg-fill branch previously sliced cv2.mean()[:3] into a + 2-D array, causing a NumPy broadcast error on grayscale frames. + """ + gray = np.random.randint(0, 255, (100, 100), dtype=np.uint8) + # Normal-size detection — exercises the resize path on a grayscale frame + detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0]) + annotator = PixelateAnnotator(pixel_size=10) + result = annotator.annotate(scene=gray.copy(), detections=detections) + assert result.shape == gray.shape + + def test_annotate_grayscale_image_small_roi_does_not_raise(self): + """Grayscale image with bbox smaller than pixel_size uses scalar avg fill. + + Exercises the ndim-aware branch added to the small-ROI fallback. + """ + gray = np.random.randint(0, 255, (100, 100), dtype=np.uint8) + detections = _create_detections(xyxy=[[10, 10, 15, 15]], class_id=[0]) + annotator = PixelateAnnotator(pixel_size=50) + result = annotator.annotate(scene=gray.copy(), detections=detections) + assert result.shape == gray.shape + + @pytest.mark.parametrize("bad_size", [0, -1, -10]) + def test_invalid_pixel_size_raises(self, bad_size): + """PixelateAnnotator must reject pixel_size < 1 at construction time.""" + with pytest.raises(ValueError, match="pixel_size must be >= 1"): + PixelateAnnotator(pixel_size=bad_size) + + def test_annotate_zero_area_bbox_is_skipped(self, test_image): + """Zero-area bounding boxes must be silently skipped, not crash.""" + detections = _create_detections(xyxy=[[10, 10, 10, 50]], class_id=[0]) + annotator = PixelateAnnotator(pixel_size=5) + result = annotator.annotate(scene=test_image.copy(), detections=detections) + assert np.array_equal(test_image, result) + class TestTriangleAnnotator: """Tests for TriangleAnnotator class"""