From 4b60bbc9cc4701060cdb9c6d1d1b6db55e10b804 Mon Sep 17 00:00:00 2001 From: Ruben <74268046+RubenHaisma@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:29:37 +0200 Subject: [PATCH] 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> --- docs/changelog.md | 2 + src/supervision/dataset/formats/pascal_voc.py | 41 ++++++++++++++++-- tests/dataset/formats/test_pascal_voc.py | 42 +++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index bf204918..db2e8b87 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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 Jun 15, 2026 diff --git a/src/supervision/dataset/formats/pascal_voc.py b/src/supervision/dataset/formats/pascal_voc.py index 91b6664c..e6914075 100644 --- a/src/supervision/dataset/formats/pascal_voc.py +++ b/src/supervision/dataset/formats/pascal_voc.py @@ -21,13 +21,41 @@ def object_to_pascal_voc( name: str, polygon: npt.NDArray[np.number] | None = None, ) -> Element: + """Build a Pascal VOC ```` 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 ```` child element. + polygon: Optional segmentation polygon in zero-indexed pixel coordinates. + Shape ``(N, 2)``. + + Returns: + An XML ``Element`` rooted at ```` containing ````, + ````, and optionally ```` 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 diff --git a/tests/dataset/formats/test_pascal_voc.py b/tests/dataset/formats/test_pascal_voc.py index aca08b82..25fc56a4 100644 --- a/tests/dataset/formats/test_pascal_voc.py +++ b/tests/dataset/formats/test_pascal_voc.py @@ -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"), [