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:
parent
9ed1f07ca0
commit
129118817a
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
Loading…
Reference in New Issue