make pixel and kernel size dynamic (#709)

* make pixel and kernel size dynamic
* fix: zero-area guard and is-not-None check in Blur/PixelateAnnotator

- Skip loop iteration when clip_boxes produces x2<=x1 or y2<=y1 (zero-area ROI) to prevent cv2.error crash in both annotators
- Replace falsy `or` pattern with explicit `is not None` so kernel_size=0 / pixel_size=0 are not silently treated as dynamic
- Replace hardcoded `cv2.mean(roi)[:3]` with ndim-aware fill: scalar for grayscale, channel-matched tuple for colour images; avoids shape mismatch broadcast error on single-channel frames
- test_annotate_bbox_smaller_than_pixel_size_does_not_raise: guards against the OpenCV resize crash from issue #703 when bbox < pixel_size
- test_annotate_grayscale_image_does_not_raise: normal pixelation path on 2-D grayscale frame
- test_annotate_grayscale_image_small_roi_does_not_raise: avg-fill fallback on 2-D grayscale frame
- Add ValueError guard in BlurAnnotator.__init__ and PixelateAnnotator.__init__ for explicit sizes < 1; previously passed straight to cv2 causing ZeroDivisionError or OpenCV assertion failures
- Add parametrized tests for invalid sizes (0, -1, -10) and zero-area bbox skipping for both annotators

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
Clemens 2026-03-30 22:17:53 +02:00 committed by GitHub
parent 9ed1f07ca0
commit 129118817a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 152 additions and 7 deletions

View File

@ -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,

View File

@ -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)

View File

@ -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"""