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

439 lines
16 KiB
Python

"""Tests for CreateML object-detection annotation load/save and conversion helpers."""
import json
from collections.abc import Callable
from pathlib import Path
import numpy as np
import pytest
from supervision.dataset.core import DetectionDataset
from supervision.dataset.formats.createml import (
createml_annotations_to_detections,
detections_to_createml_annotations,
load_createml_annotations,
save_createml_annotations,
)
from supervision.detection.core import Detections
class TestCreatemlAnnotationsToDetections:
@pytest.mark.parametrize(
("image_annotations", "class_to_index", "expected_result"),
[
pytest.param(
[],
{},
Detections.empty(),
id="empty-annotations",
),
pytest.param(
[
{
"label": "dog",
"coordinates": {"x": 50, "y": 50, "width": 20, "height": 20},
}
],
{"dog": 0},
Detections(
xyxy=np.array([[40, 40, 60, 60]], dtype=np.float32),
class_id=np.array([0], dtype=int),
),
id="single-centre-box-to-xyxy",
),
pytest.param(
[
{
"label": "cat",
"coordinates": {"x": 10, "y": 10, "width": 4, "height": 4},
},
{
"label": "dog",
"coordinates": {"x": 30, "y": 20, "width": 10, "height": 8},
},
],
{"cat": 0, "dog": 1},
Detections(
xyxy=np.array([[8, 8, 12, 12], [25, 16, 35, 24]], dtype=np.float32),
class_id=np.array([0, 1], dtype=int),
),
id="multi-class-distinct-ids",
),
pytest.param(
[
{
"label": "dog",
"coordinates": {"x": 10, "y": 10, "width": 4, "height": 4},
},
{
"label": "dog",
"coordinates": {"x": 30, "y": 30, "width": 4, "height": 4},
},
],
{"dog": 0},
Detections(
xyxy=np.array([[8, 8, 12, 12], [28, 28, 32, 32]], dtype=np.float32),
class_id=np.array([0, 0], dtype=int),
),
id="duplicate-labels-two-detections-same-id",
),
],
)
def test_converts_annotations(
self,
image_annotations: list[dict],
class_to_index: dict[str, int],
expected_result: Detections,
) -> None:
"""Converts CreateML annotation list to Detections with correct xyxy and ids."""
result = createml_annotations_to_detections(
image_annotations=image_annotations, class_to_index=class_to_index
)
np.testing.assert_array_almost_equal(result.xyxy, expected_result.xyxy)
assert (result.class_id is None) == (expected_result.class_id is None)
if expected_result.class_id is not None:
np.testing.assert_array_equal(result.class_id, expected_result.class_id)
@pytest.mark.parametrize(
("image_annotations", "class_to_index"),
[
pytest.param(
[{"label": "dog"}],
{"dog": 0},
id="missing-coordinates-key",
),
pytest.param(
[{"coordinates": {"x": 10, "y": 10, "width": 4, "height": 4}}],
{"dog": 0},
id="missing-label-key",
),
pytest.param(
[{"label": "dog", "coordinates": {"x": 10, "y": 10, "width": 4}}],
{"dog": 0},
id="missing-coordinate-subkey",
),
pytest.param(
[{"label": "dog", "coordinates": None}],
{"dog": 0},
id="coordinates-is-none",
),
],
)
def test_raises_on_malformed_annotation(
self,
image_annotations: list[dict],
class_to_index: dict[str, int],
) -> None:
"""Raises ValueError with 'Malformed' for any missing required field."""
with pytest.raises(ValueError, match="Malformed"):
createml_annotations_to_detections(
image_annotations=image_annotations,
class_to_index=class_to_index,
)
class TestDetectionsToCreatemlAnnotations:
def test_round_trips_coordinates(self) -> None:
"""Round-trip: xyxy corners convert to CreateML centre+wh and back correctly."""
detections = Detections(
xyxy=np.array([[40, 40, 60, 60]], dtype=np.float32),
class_id=np.array([1], dtype=int),
)
result = detections_to_createml_annotations(
detections=detections, classes=["cat", "dog"]
)
assert result == [
{
"label": "dog",
"coordinates": {"x": 50.0, "y": 50.0, "width": 20.0, "height": 20.0},
}
]
def test_raises_when_class_id_is_none(self) -> None:
"""Raises ValueError when Detections.class_id is None."""
detections = Detections(xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32))
with pytest.raises(ValueError, match="class_id"):
detections_to_createml_annotations(detections=detections, classes=["dog"])
class TestLoadCreatemlAnnotations:
def test_loads_basic_annotations(self, tmp_path: Path) -> None:
"""Loads classes, image_paths, and Detections from a valid CreateML file."""
annotations_path = tmp_path / "annotations.json"
payload = [
{
"image": "a.jpg",
"annotations": [
{
"label": "dog",
"coordinates": {"x": 50, "y": 50, "width": 20, "height": 20},
}
],
},
{"image": "b.jpg", "annotations": []},
]
annotations_path.write_text(json.dumps(payload))
classes, image_paths, annotations = load_createml_annotations(
images_directory_path=str(tmp_path),
annotations_path=str(annotations_path),
)
assert classes == ["dog"]
assert image_paths == [str(tmp_path / "a.jpg"), str(tmp_path / "b.jpg")]
detections = annotations[str(tmp_path / "a.jpg")]
np.testing.assert_array_almost_equal(
detections.xyxy, np.array([[40, 40, 60, 60]], dtype=np.float32)
)
np.testing.assert_array_equal(detections.class_id, np.array([0], dtype=int))
assert len(annotations[str(tmp_path / "b.jpg")]) == 0
def test_rejects_duplicate_resolved_image_paths(self, tmp_path: Path) -> None:
"""Aliases for the same file resolve to one canonical CreateML entry."""
annotations_path = tmp_path / "annotations.json"
(tmp_path / "nested").mkdir()
payload = [
{"image": "a.jpg", "annotations": []},
{"image": "nested/../a.jpg", "annotations": []},
]
annotations_path.write_text(json.dumps(payload))
with pytest.raises(ValueError, match="duplicate entries for image"):
load_createml_annotations(
images_directory_path=str(tmp_path),
annotations_path=str(annotations_path),
)
def test_rejects_unresolvable_image_path(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Unresolvable image paths are rejected before they enter the dataset."""
annotations_path = tmp_path / "annotations.json"
payload = [{"image": "bad.jpg", "annotations": []}]
annotations_path.write_text(json.dumps(payload))
original_resolve = Path.resolve
def fake_resolve(self: Path, *args: object, **kwargs: object) -> Path:
if self == tmp_path / "bad.jpg":
raise OSError("unresolvable path")
return original_resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fake_resolve)
with pytest.raises(ValueError, match="invalid path"):
load_createml_annotations(
images_directory_path=str(tmp_path),
annotations_path=str(annotations_path),
)
def test_rejects_image_path_resolving_to_directory(self, tmp_path: Path) -> None:
"""CreateML loader rejects entries that resolve to a directory."""
annotations_path = tmp_path / "annotations.json"
(tmp_path / "nested").mkdir()
payload = [{"image": "nested", "annotations": []}]
annotations_path.write_text(json.dumps(payload))
with pytest.raises(ValueError, match="directory"):
load_createml_annotations(
images_directory_path=str(tmp_path),
annotations_path=str(annotations_path),
)
def test_assigns_global_sorted_class_ids(self, tmp_path: Path) -> None:
"""Class ids are globally sorted regardless of per-image label order."""
annotations_path = tmp_path / "annotations.json"
payload = [
{
"image": "a.jpg",
"annotations": [
{
"label": "zebra",
"coordinates": {"x": 10, "y": 10, "width": 4, "height": 4},
}
],
},
{
"image": "b.jpg",
"annotations": [
{
"label": "ant",
"coordinates": {"x": 20, "y": 20, "width": 6, "height": 6},
}
],
},
]
annotations_path.write_text(json.dumps(payload))
classes, image_paths, annotations = load_createml_annotations(
images_directory_path=str(tmp_path),
annotations_path=str(annotations_path),
)
assert classes == ["ant", "zebra"]
assert image_paths == [str(tmp_path / "a.jpg"), str(tmp_path / "b.jpg")]
np.testing.assert_array_equal(
annotations[str(tmp_path / "a.jpg")].class_id, np.array([1], dtype=int)
)
np.testing.assert_array_equal(
annotations[str(tmp_path / "b.jpg")].class_id, np.array([0], dtype=int)
)
@pytest.mark.parametrize(
("setup_fn", "match"),
[
pytest.param(
lambda p: ("../evil.jpg", str(p / "images")),
"outside",
id="path-traversal",
),
pytest.param(
lambda p: (str(p.parent / "evil.jpg"), str(p)),
"outside",
id="absolute-outside",
),
pytest.param(
lambda p: (".", str(p)),
"directory",
id="resolves-to-images-dir",
),
],
)
def test_raises_on_unsafe_image_path(
self,
tmp_path: Path,
setup_fn: Callable[[Path], tuple[str, str]],
match: str,
) -> None:
"""Raises ValueError for unsafe image path: traversal, absolute, directory."""
image, images_dir = setup_fn(tmp_path)
annotations_path = tmp_path / "annotations.json"
annotations_path.write_text(json.dumps([{"image": image, "annotations": []}]))
with pytest.raises(ValueError, match=match):
load_createml_annotations(
images_directory_path=images_dir,
annotations_path=str(annotations_path),
)
@pytest.mark.parametrize(
("payload", "match"),
[
pytest.param(
{"image": "a.jpg", "annotations": []},
"JSON list",
id="root-is-dict",
),
pytest.param(
[{"annotations": []}],
"'image'",
id="missing-image-key",
),
pytest.param(
[
{"image": "a.jpg", "annotations": []},
{"image": "a.jpg", "annotations": []},
],
"duplicate",
id="duplicate-image-entry",
),
],
)
def test_raises_on_malformed_json(
self,
tmp_path: Path,
payload: dict | list,
match: str,
) -> None:
"""Raises ValueError for malformed JSON: bad root, missing key, duplicate."""
annotations_path = tmp_path / "annotations.json"
annotations_path.write_text(json.dumps(payload))
with pytest.raises(ValueError, match=match):
load_createml_annotations(
images_directory_path=str(tmp_path),
annotations_path=str(annotations_path),
)
class TestSaveCreatemlAnnotations:
"""CreateML export must reject same-basename images before writing."""
def test_raises_on_duplicate_image_basenames(self, tmp_path: Path) -> None:
"""Duplicate image basenames are rejected instead of becoming duplicates."""
image_paths = ["dir_a/img.jpg", "dir_b/img.jpg"]
dataset = DetectionDataset(
classes=["object"],
images=image_paths,
annotations={path: Detections.empty() for path in image_paths},
)
with pytest.raises(ValueError, match="CreateML image file"):
save_createml_annotations(
dataset=dataset,
annotations_path=str(tmp_path / "annotations.json"),
)
def test_empty_dataset_writes_empty_list(self, tmp_path: Path) -> None:
"""Empty dataset serialises to an empty JSON array."""
annotations_path = tmp_path / "nested" / "annotations.json"
dataset = DetectionDataset(classes=[], images=[], annotations={})
save_createml_annotations(
dataset=dataset, annotations_path=str(annotations_path)
)
assert json.loads(annotations_path.read_text()) == []
@pytest.mark.parametrize(
("classes", "xyxy", "class_id", "decimal"),
[
pytest.param(
["cat", "dog"],
np.array([[8, 8, 12, 12], [25, 16, 35, 24]], dtype=np.float32),
np.array([0, 1], dtype=int),
6,
id="integer-coords-multi-class",
),
pytest.param(
["dog"],
np.array([[10.3, 7.9, 44.1, 88.6]], dtype=np.float32),
np.array([0], dtype=int),
4,
id="float-coords",
),
],
)
def test_save_load_round_trip(
self,
tmp_path: Path,
classes: list[str],
xyxy: np.ndarray,
class_id: np.ndarray,
decimal: int,
) -> None:
"""Save then load preserves class names, image paths, and bounding boxes."""
images_directory_path = tmp_path / "images"
annotations_path = tmp_path / "annotations.json"
image_paths = [str(images_directory_path / "a.jpg")]
annotations = {image_paths[0]: Detections(xyxy=xyxy, class_id=class_id)}
dataset = DetectionDataset(
classes=classes, images=image_paths, annotations=annotations
)
save_createml_annotations(
dataset=dataset, annotations_path=str(annotations_path)
)
loaded_classes, _, loaded_annotations = load_createml_annotations(
images_directory_path=str(images_directory_path),
annotations_path=str(annotations_path),
)
assert loaded_classes == classes
loaded = loaded_annotations[image_paths[0]]
np.testing.assert_array_almost_equal(loaded.xyxy, xyxy, decimal=decimal)
np.testing.assert_array_equal(loaded.class_id, class_id)