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:
parent
6918d44190
commit
4b60bbc9cc
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
Loading…
Reference in New Issue