fix(dataset): stop Pascal VOC export from mutating source detections (#2341)

object_to_pascal_voc applied the 1-index offset in place (xyxy += 1).
Because Detections.__iter__ yields each row of xyxy as a view sharing
memory with detections.xyxy, detections_to_pascal_voc wrote the +1 shift
straight back into the caller's array. A single export shifted every box
by +1px; a second export compounded it, producing wrong XML. A single
export-then-reload happened to round-trip because from_pascal_voc
subtracts 1, which is why no test caught it.

Rebind to a new array (xyxy = xyxy + 1) instead of mutating in place.
On-disk output is unchanged; the source detections are left intact.

Add regression tests asserting object_to_pascal_voc does not mutate its
inputs and that two consecutive exports are identical and leave xyxy
unchanged.

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
Ruben 2026-06-18 15:29:37 +02:00 committed by GitHub
parent 6918d44190
commit 4b60bbc9cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 81 additions and 4 deletions

View File

@ -17,6 +17,8 @@ date_modified: 2026-06-16
- Fixed [#2333](https://github.com/roboflow/supervision/pull/2333): [`sv.DetectionsSmoother`](https://supervision.roboflow.com/latest/detection/tools/smoother/#supervision.detection.tools.smoother.DetectionsSmoother) no longer raises when smoothing detections without `confidence`. Confidence is now averaged over the frames that carry it; when tracks in the same frame disagree on confidence presence, `confidence` is set to `None` for all smoothed detections.
- Fixed [#2341](https://github.com/roboflow/supervision/pull/2341): `sv.DetectionDataset.as_pascal_voc` no longer mutates the source `Detections.xyxy` by the 1-index offset on every call. Previously, repeated exports accumulated a `+1` shift in the caller's bounding boxes.
- Fixed [#2331](https://github.com/roboflow/supervision/pull/2331): `sv.Precision` and `sv.F1Score` now count predictions on background images (empty target set) as false positives, and count predictions of classes absent from ground truth as false positives under `MICRO` and `MACRO` averaging. Previously both edge cases were silently ignored, inflating scores. `WEIGHTED` averaging is unchanged — absent classes retain weight 0, consistent with scikit-learn. Users relying on previous scores should re-evaluate after upgrading; no API change is required.
### 0.29.0 <small>Jun 15, 2026</small>

View File

@ -21,13 +21,41 @@ def object_to_pascal_voc(
name: str,
polygon: npt.NDArray[np.number] | None = None,
) -> Element:
"""Build a Pascal VOC ``<object>`` XML element for one detection.
Coordinates are converted to 1-indexed Pascal VOC convention before writing.
The input arrays are never mutated; new arrays are allocated for the offset.
Args:
xyxy: Bounding box in zero-indexed pixel coordinates ``[x1, y1, x2, y2]``.
Shape ``(4,)``.
name: Class label string written to the ``<name>`` child element.
polygon: Optional segmentation polygon in zero-indexed pixel coordinates.
Shape ``(N, 2)``.
Returns:
An XML ``Element`` rooted at ``<object>`` containing ``<name>``,
``<bndbox>``, and optionally ``<polygon>`` children.
Examples:
>>> import numpy as np
>>> from supervision.dataset.formats.pascal_voc import object_to_pascal_voc
>>> elem = object_to_pascal_voc(np.array([0, 0, 9, 9]), name="cat")
>>> elem.find("bndbox/xmin").text
'1'
>>> elem.find("bndbox/xmax").text
'10'
"""
root = Element("object")
object_name = SubElement(root, "name")
object_name.text = name
# https://github.com/roboflow/supervision/issues/144
xyxy += 1
# Pascal VOC coordinates are 1-indexed (https://github.com/roboflow/supervision/issues/144).
# Rebind to a new array instead of `+= 1`: `xyxy` is a view into the source
# `Detections.xyxy` (yielded by `Detections.__iter__`), so an in-place add
# would corrupt the caller's detections by +1 on every export.
xyxy = xyxy + 1
bndbox = SubElement(root, "bndbox")
xmin = SubElement(bndbox, "xmin")
@ -40,8 +68,8 @@ def object_to_pascal_voc(
ymax.text = str(int(xyxy[3]))
if polygon is not None:
# https://github.com/roboflow/supervision/issues/144
polygon += 1
# 1-indexed, rebound to avoid mutating the caller's array (see above).
polygon = polygon + 1
object_polygon = SubElement(root, "polygon")
for index, point in enumerate(polygon, start=1):
x_coordinate, y_coordinate = point
@ -81,6 +109,11 @@ def detections_to_pascal_voc(
polygon points to be removed from the input polygon, in the range [0, 1).
Returns:
An XML string in Pascal VOC format representing the detections.
Note:
``detections`` is never mutated by this function; the source ``xyxy``
array is unchanged after the call. The function is therefore safe to
call multiple times on the same ``Detections`` object.
"""
height, width, depth = image_shape

View File

@ -8,6 +8,7 @@ from defusedxml import ElementTree
from supervision.dataset.formats.pascal_voc import (
detections_from_xml_obj,
detections_to_pascal_voc,
object_to_pascal_voc,
parse_polygon_points,
)
@ -72,6 +73,47 @@ def test_object_to_pascal_voc(
assert are_xml_elements_equal(result, expected_result)
def test_object_to_pascal_voc_does_not_mutate_inputs():
"""Serializing an object must not write the 1-index offset back into the inputs."""
xyxy = np.array([10, 20, 30, 40], dtype=np.float32)
polygon = np.array([[0, 0], [10, 0], [10, 10], [0, 10]], dtype=np.float32)
object_to_pascal_voc(xyxy=xyxy, name="test", polygon=polygon)
assert np.array_equal(xyxy, np.array([10, 20, 30, 40], dtype=np.float32))
assert np.array_equal(
polygon, np.array([[0, 0], [10, 0], [10, 10], [0, 10]], dtype=np.float32)
)
def test_object_to_pascal_voc_does_not_mutate_view_input():
"""Mutation guard holds when xyxy is a NumPy row-view (the actual bug scenario)."""
base = np.array([[10, 20, 30, 40]], dtype=np.float32)
xyxy_view = base[0] # row-view, shares memory with base
object_to_pascal_voc(xyxy=xyxy_view, name="test", polygon=None)
assert np.array_equal(base[0], np.array([10, 20, 30, 40], dtype=np.float32)), (
"object_to_pascal_voc mutated the source array via a view"
)
def test_detections_to_pascal_voc_does_not_mutate_detections():
"""Exporting detections must not shift the source xyxy, and must be repeatable."""
detections = _create_detections(xyxy=[[10, 20, 30, 40]], class_id=[0])
expected_xyxy = detections.xyxy.copy()
first = detections_to_pascal_voc(
detections, classes=["test"], filename="image.jpg", image_shape=(100, 100, 3)
)
second = detections_to_pascal_voc(
detections, classes=["test"], filename="image.jpg", image_shape=(100, 100, 3)
)
assert np.array_equal(detections.xyxy, expected_xyxy)
assert first == second
@pytest.mark.parametrize(
("polygon_element", "expected_result", "exception"),
[