fix(annotators): avoid divide-by-zero in HeatMapAnnotator on empty detections (#2269)

When HeatMapAnnotator is called on a fresh annotator with empty detections
(common on the first frames of a video before the model produces any output),
self.heat_mask is all zeros, so temp / temp.max() raises
RuntimeWarning: invalid value encountered in divide and produces nan/inf
in-flight. Skip the normalisation when temp.max() == 0; the resulting
all-zero heat mask filters out via the > 0 check below, so the scene is
returned unchanged.

- Fix `kernel_size: int = 25` → `int | None = 25`; document None disables blur
- Add Note to annotate docstring: empty detections returns scene unchanged
- Add happy path test: single detection must produce visible heat output
- Add stateful tests: empty→real and real→empty sequence coverage

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
Mahbod 2026-05-26 22:27:07 +02:00 committed by GitHub
parent fb2dec9775
commit 2fdb970430
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 56 additions and 3 deletions

View File

@ -2095,7 +2095,7 @@ class HeatMapAnnotator(BaseAnnotator):
position: Position = Position.BOTTOM_CENTER,
opacity: float = 0.2,
radius: int = 40,
kernel_size: int = 25,
kernel_size: int | None = 25,
top_hue: int = 0,
low_hue: int = 125,
):
@ -2105,7 +2105,8 @@ class HeatMapAnnotator(BaseAnnotator):
`BOTTOM_CENTER`.
opacity: Opacity of the overlay mask, between 0 and 1.
radius: Radius of the heat circle.
kernel_size: Kernel size for blurring the heatmap.
kernel_size: Kernel size for blurring the heatmap. Pass `None`
to disable blurring entirely.
top_hue: Hue at the top of the heatmap. Defaults to 0 (red).
low_hue: Hue at the bottom of the heatmap. Defaults to 125 (blue).
"""
@ -2132,6 +2133,10 @@ class HeatMapAnnotator(BaseAnnotator):
The annotated image, matching the type of `scene` (`numpy.ndarray`
or `PIL.Image.Image`)
Note:
When `detections` is empty or no heat has accumulated yet, the
scene is returned unchanged without raising a ``RuntimeWarning``.
Example:
```python
import supervision as sv
@ -2174,7 +2179,9 @@ class HeatMapAnnotator(BaseAnnotator):
)
self.heat_mask = mask + self.heat_mask
temp = self.heat_mask.copy()
temp = self.low_hue - temp / temp.max() * (self.low_hue - self.top_hue)
max_val = temp.max()
if max_val > 0:
temp = self.low_hue - temp / max_val * (self.low_hue - self.top_hue)
temp = temp.astype(np.uint8)
if self.kernel_size is not None:
temp = cv2.blur(temp, (self.kernel_size, self.kernel_size))

View File

@ -2,6 +2,8 @@
Tests for supervision/annotators/core.py
"""
import warnings
import numpy as np
import pytest
@ -17,6 +19,7 @@ from supervision.annotators.core import (
DotAnnotator,
EllipseAnnotator,
HaloAnnotator,
HeatMapAnnotator,
LabelAnnotator,
MaskAnnotator,
OrientedBoxAnnotator,
@ -361,6 +364,49 @@ class TestHaloAnnotator:
assert np.array_equal(result_bool, result_uint8)
class TestHeatMapAnnotator:
"""Tests for HeatMapAnnotator class"""
def test_annotate_with_no_detections_does_not_warn(
self, test_image: np.ndarray
) -> None:
"""Empty detections must not trigger a divide-by-zero RuntimeWarning."""
detections = Detections.empty()
annotator = HeatMapAnnotator()
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, test_image: np.ndarray) -> None:
"""Single detection must produce visible heat — result differs from input."""
annotator = HeatMapAnnotator()
detections = _create_detections(xyxy=[[20, 20, 60, 60]])
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert not np.array_equal(test_image, result)
def test_annotate_state_preserved_after_empty_call(
self, test_image: np.ndarray
) -> None:
"""Empty call must not poison accumulated heat."""
annotator = HeatMapAnnotator()
detections = _create_detections(xyxy=[[20, 20, 60, 60]])
annotator.annotate(scene=test_image.copy(), detections=Detections.empty())
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert not np.array_equal(test_image, result)
def test_annotate_empty_after_real_does_not_warn(
self, test_image: np.ndarray
) -> None:
"""Empty call after heat accumulated must not trigger RuntimeWarning."""
annotator = HeatMapAnnotator()
detections = _create_detections(xyxy=[[20, 20, 60, 60]])
annotator.annotate(scene=test_image.copy(), detections=detections)
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
annotator.annotate(scene=test_image.copy(), detections=Detections.empty())
class TestEllipseAnnotator:
"""Tests for EllipseAnnotator class"""