Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
---
description: Full version history of the supervision Python library — release notes, breaking changes, new features, and deprecations for every version.
date_modified: 2026-09-17
date_modified: 2026-09-18
---

# Changelog

### Unreleased <small>upcoming</small>

- `sv.InferenceSlicer` no longer raises `ValueError: Conflicting metadata for key: 'source_image'` (or `All metadata dictionaries must have the same keys to merge.`) when the callback returns `sv.Detections` whose `metadata` differs between slices — e.g. RF-DETR's `predict()`, which stores each call's source image as collection-level metadata, so the code from the Detect Small Objects tutorial crashed at the merge. Metadata keys equal across all non-empty slice results still merge as before; keys that differ between slices, or are missing from some slices, are now dropped from the tiled result with a `SupervisionWarnings` warning (emitted once per slicer instance) advising that per-slice information belongs in `Detections.data` — except `source_image`, which is restored to the full input image, so detectors and annotators that follow the source-image metadata convention keep their parent image reference. [#2599](https://github.com/roboflow/supervision/pull/2599)

- Removed, as scheduled for `supervision-0.31.0`: `sv.ByteTrack` (use `ByteTrackTracker` from the `trackers` package instead); the `supervision.keypoint` module (use `supervision.key_points`); `create_tiles` and `overlay_image` in `supervision.utils.image`; `ensure_cv2_image_for_annotation`, `ensure_pil_image_for_annotation`, and `ensure_cv2_image_for_processing` in `supervision.utils.conversion`; `validate_keypoint_confidence` and `validate_keypoints_fields` in `supervision.validators`; the `normalized_xyxy` argument of `sv.denormalize_boxes` (use `xyxy`); the `supervision.dataset.utils` import path for `sv.mask_to_rle`/`sv.rle_to_mask` (import from `supervision.detection.utils.converters` instead); `sv.LMM` and `Detections.from_lmm` (use `sv.VLM`/`Detections.from_vlm`); and the legacy `MeanAveragePrecision` in `supervision.metrics.detection` (use `supervision.metrics.mean_average_precision.MeanAveragePrecision`, exposed as `sv.metrics.MeanAveragePrecision`). See [Deprecated](deprecated.md) for the full list. [#2582](https://github.com/roboflow/supervision/pull/2582)

- `sv.IconAnnotator` and `sv.draw_image` now keep the transparency of grayscale PNGs with alpha, and of RGB PNGs with a transparent color, when OpenCV is not installed. Both read images with `cv2.IMREAD_UNCHANGED` to keep the alpha channel, but the OpenCV-free fallback backend returned Pillow's pixel layout for that flag rather than OpenCV's: a grayscale PNG with alpha came back with 2 channels instead of 4, an RGB PNG with a transparent color with 3 channels and no alpha, and a 1-bit PNG as a boolean array instead of 8-bit `0` and `255`. A grayscale icon with alpha therefore failed with `ValueError: could not broadcast input array from shape (16,16,2) into shape (16,16,3)` in `sv.IconAnnotator` and with `ValueError: Image must have 3 or 4 channels.` in `sv.draw_image`, and an RGB icon with a transparent color was pasted with its transparent pixels drawn black, although both work with OpenCV installed. The fallback now returns the same arrays as `cv2.imread` for these images. Other images, and every read with OpenCV installed, are unchanged. [#2588](https://github.com/roboflow/supervision/pull/2588)
Expand Down
123 changes: 120 additions & 3 deletions src/supervision/detection/tools/inference_slicer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from supervision.detection.compact_mask import CompactMask
from supervision.detection.core import Detections
from supervision.detection.utils.boxes import move_boxes, move_oriented_boxes
from supervision.detection.utils.internal import metadata_values_equal
from supervision.detection.utils.iou_and_nms import OverlapFilter, OverlapMetric
from supervision.detection.utils.masks import move_masks
from supervision.draw.base import ImageType
Expand Down Expand Up @@ -302,6 +303,8 @@ def __init__(
self.batch_size = batch_size
self._out_of_slice_bounds_warned: bool = False
self._out_of_slice_bounds_lock = threading.Lock()
self._metadata_conflict_warned: bool = False
self._metadata_conflict_lock = threading.Lock()
self._obb_thread_workers_warned: bool = False
self._obb_thread_workers_lock = threading.Lock()
self._raster_read_lock = threading.Lock()
Expand All @@ -315,7 +318,13 @@ def __call__(self, image: ImageType | WindowedRasterDataset) -> Detections:
followed by any probe slices, then the remaining slices in source order.
If oriented bounding boxes are detected, all remaining slices are
processed sequentially and a ``SupervisionWarnings`` warning is emitted
once per slicer instance.
once per slicer instance. Metadata keys attached by the callback that
differ between slices (or are missing from some slices) cannot be
merged into a single tiled result and are dropped, with a
``SupervisionWarnings`` warning emitted once per slicer instance; a
dropped ``source_image`` key is restored to the full input image, so
detectors and annotators that follow the source-image metadata
convention still find a parent image reference on the tiled result.

Args:
image: The full image to run inference on. In addition to in-memory
Expand Down Expand Up @@ -378,7 +387,9 @@ def __call__(self, image: ImageType | WindowedRasterDataset) -> Detections:
partial(self._run_callback_batch, image), remaining_batches
):
detections_list.extend(batch_detections)
merged = Detections.merge(detections_list=detections_list)
merged = self._merge_detections(
detections_list=detections_list, image=image
)
return self._apply_overlap_filter(merged)

first_offset = offsets[0]
Expand Down Expand Up @@ -430,9 +441,115 @@ def __call__(self, image: ImageType | WindowedRasterDataset) -> Detections:
executor.map(partial(self._run_callback, image), remaining_offsets)
)

merged = Detections.merge(detections_list=detections_list)
merged = self._merge_detections(detections_list=detections_list, image=image)
return self._apply_overlap_filter(merged)

def _merge_detections(
self,
detections_list: list[Detections],
image: ImageType | WindowedRasterDataset,
) -> Detections:
"""Merge slice results, reconciling metadata that cannot merge.

Args:
detections_list: Detections returned by the callback for all slices.
image: The full image (or raster dataset) passed to `__call__`.

Returns:
Merged detections across all slices.
"""
dropped_keys = self._drop_unmergeable_metadata(detections_list)
merged = Detections.merge(detections_list=detections_list)
restored_source_image = "source_image" in dropped_keys and isinstance(
image, np.ndarray
)
Comment on lines +461 to +465
if restored_source_image:
# Detectors and annotators that follow the source-image metadata
# convention (e.g. RF-DETR) expect the key on the final result; the
# tiled detections describe the full input image.
merged.metadata["source_image"] = image
if dropped_keys:
self._warn_metadata_conflict(
dropped_keys=dropped_keys,
restored_source_image=restored_source_image,
)
return merged

def _drop_unmergeable_metadata(self, detections_list: list[Detections]) -> set[str]:
"""Drop slice metadata that cannot be merged into the tiled result.

`Detections.metadata` is collection-level state, so `Detections.merge`
requires every slice to carry identical metadata. Callbacks routinely
attach per-slice metadata instead (e.g. RF-DETR stores the source image
of each call), which used to make the merge raise. This method keeps
only keys present in every non-empty slice result with equal values and
removes the rest. The dictionaries are fresh copies produced by
`move_detections`, so the callback's own `Detections` objects are never
modified.

Args:
detections_list: Detections returned by the callback for all slices.

Returns:
The set of metadata keys that were dropped.
"""
non_empty = [d for d in detections_list if not d.is_empty()]
if len(non_empty) < 2:
return set()

all_keys: set[str] = set()
common_keys = set(non_empty[0].metadata.keys())
for detections in non_empty:
all_keys |= detections.metadata.keys()
common_keys &= detections.metadata.keys()

# A key that only some slices carry is as unmergeable as one whose
# values conflict, so seed the dropped set from the union of all keys.
dropped_keys = all_keys - common_keys
for key in sorted(common_keys):
first_value = non_empty[0].metadata[key]
if any(
not metadata_values_equal(first_value, detections.metadata[key])
for detections in non_empty[1:]
):
dropped_keys.add(key)

for detections in non_empty:
for key in dropped_keys:
detections.metadata.pop(key, None)

return dropped_keys

def _warn_metadata_conflict(
self, dropped_keys: set[str], restored_source_image: bool
) -> None:
"""Warn once per slicer instance about dropped per-slice metadata.

Args:
dropped_keys: Metadata keys removed from the slice results.
restored_source_image: Whether `source_image` was restored to the
full input image after the merge.
"""
with self._metadata_conflict_lock:
if self._metadata_conflict_warned:
return
self._metadata_conflict_warned = True
message = (
"Callback returned Detections with metadata keys that differ "
f"between slices or are missing from some slices: "
f"{sorted(dropped_keys)}. Such keys cannot be merged into a "
"single value for the tiled result and were dropped from the "
"merged metadata."
)
if restored_source_image:
message += " The tiled `source_image` is restored to the full "
"input image."
message += (
" Attach other per-slice information to `Detections.data` "
"instead if you need it per detection."
)
warnings.warn(message, category=SupervisionWarnings, stacklevel=3)

def _get_resolution_wh(
self, image: ImageType | WindowedRasterDataset
) -> tuple[int, int]:
Expand Down
36 changes: 25 additions & 11 deletions src/supervision/detection/utils/internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,28 @@ def merge_data(
return cast(_DetectionDataType, merged_data)


def metadata_values_equal(value_a: Any, value_b: Any) -> bool:
"""Check whether two metadata values for the same key can merge into one.

Defines the value compatibility rule enforced by
[`merge_metadata`][supervision.detection.utils.internal.merge_metadata]:
arrays compare element-wise via `np.array_equal`, and a mixed
array/non-array pair is never equal — `[] == np.array([])` would otherwise
compare equal.

Args:
value_a, value_b: Metadata values to compare.

Returns:
True if the values are equal and could share a single merged entry.
"""
if isinstance(value_a, np.ndarray) and isinstance(value_b, np.ndarray):
return bool(np.array_equal(value_a, value_b))
if isinstance(value_a, np.ndarray) or isinstance(value_b, np.ndarray):
return False
return bool(value_a == value_b)


def merge_metadata(metadata_list: list[_MetadataType]) -> _MetadataType:
"""Merge metadata from a list of metadata dictionaries.

Expand Down Expand Up @@ -627,21 +649,13 @@ def merge_metadata(metadata_list: list[_MetadataType]) -> _MetadataType:
continue

other_value = merged_metadata[key]
if isinstance(value, np.ndarray) and isinstance(other_value, np.ndarray):
if not np.array_equal(merged_metadata[key], value):
if not metadata_values_equal(value, other_value):
if isinstance(value, np.ndarray) or isinstance(other_value, np.ndarray):
raise ValueError(
f"Conflicting metadata for key: '{key}': "
f"{type(value)}, {type(other_value)}."
)
elif isinstance(value, np.ndarray) or isinstance(other_value, np.ndarray):
# Since [] == np.array([]).
raise ValueError(
f"Conflicting metadata for key: '{key}': "
f"{type(value)}, {type(other_value)}."
)
else:
if merged_metadata[key] != value:
raise ValueError(f"Conflicting metadata for key: '{key}'.")
raise ValueError(f"Conflicting metadata for key: '{key}'.")

return merged_metadata

Expand Down
Loading
Loading