Files
wehub-resource-sync 9194ef5abd
Docs/Test Workflow / Test docs build (push) Failing after 0s
Check links & references / links-check (push) Failing after 1s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.10) (push) Failing after 0s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.11) (push) Failing after 0s
PR Conflict Labeler / main (push) Failing after 2s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.12) (push) Failing after 2s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.13) (push) Failing after 0s
Pytest/Test Workflow / Build this Package (push) Failing after 5s
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / testing-guardian (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:06:10 +08:00

362 lines
12 KiB
Python

from contextlib import ExitStack as DoesNotRaise
from pathlib import Path
import cv2
import numpy as np
import pytest
from defusedxml import ElementTree
from supervision.dataset.core import DetectionDataset
from supervision.dataset.formats.pascal_voc import (
detections_from_xml_obj,
detections_to_pascal_voc,
load_pascal_voc_annotations,
object_to_pascal_voc,
parse_polygon_points,
save_pascal_voc_annotations,
)
from tests.helpers import _create_detections
def are_xml_elements_equal(elem1, elem2) -> bool:
if (
elem1.tag != elem2.tag
or elem1.attrib != elem2.attrib
or elem1.text != elem2.text
or len(elem1) != len(elem2)
):
return False
for child1, child2 in zip(elem1, elem2):
if not are_xml_elements_equal(child1, child2):
return False
return True
@pytest.mark.parametrize(
("xyxy", "name", "polygon", "expected_result", "exception"),
[
pytest.param(
np.array([0, 0, 10, 10]),
"test",
None,
ElementTree.fromstring(
"""<object><name>test</name><bndbox><xmin>1</xmin><ymin>1</ymin>
<xmax>11</xmax><ymax>11</ymax></bndbox></object>"""
),
DoesNotRaise(),
id="bbox_only",
),
pytest.param(
np.array([0, 0, 10, 10]),
"test",
np.array([[0, 0], [10, 0], [10, 10], [0, 10]]),
ElementTree.fromstring(
"""<object><name>test</name><bndbox><xmin>1</xmin><ymin>1</ymin>
<xmax>11</xmax><ymax>11</ymax>
</bndbox><polygon><x1>1</x1><y1>1</y1><x2>11</x2>
<y2>1</y2><x3>11</x3><y3>11</y3><x4>1</x4><y4>11</y4>
</polygon></object>"""
),
DoesNotRaise(),
id="bbox_and_polygon",
),
],
)
def test_object_to_pascal_voc(
xyxy: np.ndarray,
name: str,
polygon: np.ndarray | None,
expected_result,
exception: Exception,
) -> None:
with exception:
result = object_to_pascal_voc(xyxy=xyxy, name=name, polygon=polygon)
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"),
[
pytest.param(
ElementTree.fromstring(
"""<polygon><x1>0</x1><y1>0</y1><x2>10</x2><y2>0</y2><x3>10</x3>
<y3>10</y3><x4>0</x4><y4>10</y4></polygon>"""
),
np.array([[0, 0], [10, 0], [10, 10], [0, 10]]),
DoesNotRaise(),
id="standard_polygon",
)
],
)
def test_parse_polygon_points(
polygon_element,
expected_result: list[list],
exception,
) -> None:
with exception:
result = parse_polygon_points(polygon_element)
assert np.array_equal(result, expected_result)
ONE_CLASS_N_BBOX = """<annotation><object><name>test</name><bndbox><xmin>1</xmin>
<ymin>1</ymin><xmax>11</xmax><ymax>11</ymax>
</bndbox></object><object><name>test</name><bndbox><xmin>11</xmin><ymin>11</ymin>
<xmax>21</xmax><ymax>21</ymax></bndbox></object></annotation>"""
ONE_CLASS_ONE_BBOX = """<annotation><object><name>test</name><bndbox>
<xmin>1</xmin><ymin>1</ymin><xmax>11</xmax><ymax>11</ymax></bndbox></object>
</annotation>"""
N_CLASS_N_BBOX = """<annotation><object><name>test</name><bndbox><xmin>1</xmin>
<ymin>1</ymin><xmax>11</xmax><ymax>11</ymax>
</bndbox></object><object><name>test</name><bndbox>
<xmin>21</xmin><ymin>31</ymin><xmax>31</xmax><ymax>41</ymax></bndbox>
</object><object><name>test2</name><bndbox><xmin>
11</xmin><ymin>11</ymin><xmax>21</xmax><ymax>
21</ymax></bndbox></object></annotation>"""
NO_DETECTIONS = """<annotation></annotation>"""
MIXED_POLYGON_AND_BOX = """<annotation><object><name>test</name><bndbox>
<xmin>1</xmin><ymin>1</ymin><xmax>11</xmax><ymax>11</ymax></bndbox>
<polygon><x1>1</x1><y1>1</y1><x2>11</x2><y2>1</y2><x3>11</x3><y3>11</y3>
<x4>1</x4><y4>11</y4></polygon></object><object><name>test</name><bndbox>
<xmin>11</xmin><ymin>11</ymin><xmax>21</xmax><ymax>21</ymax></bndbox></object>
</annotation>"""
@pytest.mark.parametrize(
(
"xml_string",
"classes",
"resolution_wh",
"force_masks",
"expected_result",
"exception",
),
[
pytest.param(
ONE_CLASS_ONE_BBOX,
["test"],
(100, 100),
False,
_create_detections(xyxy=[[0, 0, 10, 10]], class_id=[0]),
DoesNotRaise(),
id="one_class_one_bbox",
),
pytest.param(
ONE_CLASS_N_BBOX,
["test"],
(100, 100),
False,
_create_detections(
xyxy=np.array([[0, 0, 10, 10], [10, 10, 20, 20]]), class_id=[0, 0]
),
DoesNotRaise(),
id="one_class_n_bbox",
),
pytest.param(
N_CLASS_N_BBOX,
["test", "test2"],
(100, 100),
False,
_create_detections(
xyxy=np.array([[0, 0, 10, 10], [20, 30, 30, 40], [10, 10, 20, 20]]),
class_id=[0, 0, 1],
),
DoesNotRaise(),
id="n_class_n_bbox",
),
pytest.param(
NO_DETECTIONS,
[],
(100, 100),
False,
_create_detections(xyxy=np.empty((0, 4)), class_id=[]),
DoesNotRaise(),
id="no_detections",
),
],
)
def test_detections_from_xml_obj(
xml_string, classes, resolution_wh, force_masks, expected_result, exception
) -> None:
with exception:
root = ElementTree.fromstring(xml_string)
result, _ = detections_from_xml_obj(root, classes, resolution_wh, force_masks)
assert result == expected_result
@pytest.mark.parametrize("force_masks", [False, True])
def test_detections_from_xml_obj_mixed_polygon_and_bbox_masks_aligned(
force_masks: bool,
) -> None:
root = ElementTree.fromstring(MIXED_POLYGON_AND_BOX)
detections, _ = detections_from_xml_obj(
root=root,
classes=["test"],
resolution_wh=(30, 30),
force_masks=force_masks,
)
assert detections.mask is not None
assert detections.mask.shape == (2, 30, 30)
assert detections.mask[0].any()
assert not detections.mask[1].any()
def _write_voc_sample(
images_dir: Path, annotations_dir: Path, stem: str, class_names: list[str]
) -> None:
"""Write one VOC image plus its bbox-only XML annotation to disk."""
cv2.imwrite(str(images_dir / f"{stem}.png"), np.zeros((20, 20, 3), dtype=np.uint8))
objects = "".join(
f"<object><name>{name}</name><bndbox><xmin>1</xmin><ymin>1</ymin>"
f"<xmax>10</xmax><ymax>10</ymax></bndbox></object>"
for name in class_names
)
(annotations_dir / f"{stem}.xml").write_text(f"<annotation>{objects}</annotation>")
class TestLoadPascalVocDeterministicClasses:
"""Regression tests for deterministic VOC class ordering (DAT-03)."""
def test_classes_sorted_within_file(self, tmp_path: Path) -> None:
"""Class names from one file are assigned ids in sorted, stable order."""
images_dir = tmp_path / "images"
images_dir.mkdir()
annotations_dir = tmp_path / "annotations"
annotations_dir.mkdir()
_write_voc_sample(images_dir, annotations_dir, "img", ["zebra", "ant", "mango"])
classes, _, _ = load_pascal_voc_annotations(
images_directory_path=str(images_dir),
annotations_directory_path=str(annotations_dir),
)
assert classes == sorted(classes) == ["ant", "mango", "zebra"]
def test_repeated_loads_give_identical_class_ids(self, tmp_path: Path) -> None:
"""Two loads of the same multi-file VOC set produce identical class ids."""
images_dir = tmp_path / "images"
images_dir.mkdir()
annotations_dir = tmp_path / "annotations"
annotations_dir.mkdir()
_write_voc_sample(images_dir, annotations_dir, "a_img", ["zebra"])
_write_voc_sample(images_dir, annotations_dir, "b_img", ["ant", "mango"])
first = load_pascal_voc_annotations(
images_directory_path=str(images_dir),
annotations_directory_path=str(annotations_dir),
)
second = load_pascal_voc_annotations(
images_directory_path=str(images_dir),
annotations_directory_path=str(annotations_dir),
)
assert first[0] == second[0]
assert {p: d.class_id.tolist() for p, d in first[2].items()} == {
p: d.class_id.tolist() for p, d in second[2].items()
}
class TestSavePascalVocAnnotations:
"""save_pascal_voc_annotations: filesystem output contract."""
def test_empty_dataset_creates_directory_and_no_xml_files(
self, tmp_path: Path
) -> None:
"""Empty dataset produces no XML files; output directory is created."""
dataset = DetectionDataset(classes=[], images=[], annotations={})
out_dir = tmp_path / "annotations"
save_pascal_voc_annotations(dataset, str(out_dir))
assert out_dir.is_dir()
assert list(out_dir.glob("*.xml")) == []
def test_zero_detection_image_writes_xml_without_object_elements(
self, tmp_path: Path
) -> None:
"""Image with no detections produces one XML file with no object elements."""
from supervision.detection.core import Detections
img_path = tmp_path / "img.jpg"
cv2.imwrite(str(img_path), np.zeros((50, 50, 3), dtype=np.uint8))
dataset = DetectionDataset(
classes=["cat"],
images=[str(img_path)],
annotations={str(img_path): Detections.empty()},
)
out_dir = tmp_path / "annotations"
save_pascal_voc_annotations(dataset, str(out_dir))
xml_files = list(out_dir.glob("*.xml"))
assert len(xml_files) == 1
tree = ElementTree.parse(str(xml_files[0]))
assert tree.findall("object") == []
def test_show_progress_true_is_accepted_without_error(self, tmp_path: Path) -> None:
"""show_progress=True is accepted by the function without raising."""
from supervision.detection.core import Detections
img_path = tmp_path / "img.jpg"
cv2.imwrite(str(img_path), np.zeros((50, 50, 3), dtype=np.uint8))
dataset = DetectionDataset(
classes=["cat"],
images=[str(img_path)],
annotations={str(img_path): Detections.empty()},
)
out_dir = tmp_path / "annotations"
save_pascal_voc_annotations(dataset, str(out_dir), show_progress=True)
assert out_dir.is_dir()