test(dataset): refactor createml tests with parametrize

- Merge 4 separate malformed-annotation raises into parametrized test_raises_on_malformed_annotation
- Merge 3 path-security raises into parametrized test_raises_on_unsafe_image_path (lambda captures per-case images_directory_path)
- Merge 3 malformed-JSON raises into parametrized test_raises_on_malformed_json
- Merge 2 save/load round-trip tests into parametrized test_save_load_round_trip
- Drop unused DoesNotRaise/ExitStack and exception param from test_converts_annotations

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
jirka 2026-06-25 15:43:02 +02:00
parent 9ff41b9706
commit a3a860cf3f
1 changed files with 141 additions and 152 deletions

View File

@ -3,7 +3,7 @@
from __future__ import annotations
import json
from contextlib import ExitStack as DoesNotRaise
from collections.abc import Callable
from pathlib import Path
import numpy as np
@ -21,13 +21,12 @@ from supervision.detection.core import Detections
class TestCreatemlAnnotationsToDetections:
@pytest.mark.parametrize(
("image_annotations", "class_to_index", "expected_result", "exception"),
("image_annotations", "class_to_index", "expected_result"),
[
pytest.param(
[],
{},
Detections.empty(),
DoesNotRaise(),
id="empty-annotations",
),
pytest.param(
@ -42,7 +41,6 @@ class TestCreatemlAnnotationsToDetections:
xyxy=np.array([[40, 40, 60, 60]], dtype=np.float32),
class_id=np.array([0], dtype=int),
),
DoesNotRaise(),
id="single-centre-box-to-xyxy",
),
pytest.param(
@ -61,7 +59,6 @@ class TestCreatemlAnnotationsToDetections:
xyxy=np.array([[8, 8, 12, 12], [25, 16, 35, 24]], dtype=np.float32),
class_id=np.array([0, 1], dtype=int),
),
DoesNotRaise(),
id="multi-class-distinct-ids",
),
pytest.param(
@ -80,7 +77,6 @@ class TestCreatemlAnnotationsToDetections:
xyxy=np.array([[8, 8, 12, 12], [28, 28, 32, 32]], dtype=np.float32),
class_id=np.array([0, 0], dtype=int),
),
DoesNotRaise(),
id="duplicate-labels-two-detections-same-id",
),
],
@ -90,52 +86,51 @@ class TestCreatemlAnnotationsToDetections:
image_annotations: list[dict],
class_to_index: dict[str, int],
expected_result: Detections,
exception: Exception,
) -> None:
"""Converts CreateML annotation list to Detections with correct xyxy and ids."""
with exception:
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)
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)
def test_raises_on_missing_coordinates_key(self) -> None:
"""Raises ValueError when an annotation entry lacks the 'coordinates' key."""
@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=[{"label": "dog"}],
class_to_index={"dog": 0},
)
def test_raises_on_missing_label_key(self) -> None:
"""Raises ValueError when an annotation entry lacks the 'label' key."""
with pytest.raises(ValueError, match="Malformed"):
createml_annotations_to_detections(
image_annotations=[
{"coordinates": {"x": 10, "y": 10, "width": 4, "height": 4}}
],
class_to_index={"dog": 0},
)
def test_raises_on_missing_coordinate_subkey(self) -> None:
"""Raises ValueError when a coordinates dict is missing a required sub-key."""
with pytest.raises(ValueError, match="Malformed"):
createml_annotations_to_detections(
image_annotations=[
{"label": "dog", "coordinates": {"x": 10, "y": 10, "width": 4}}
],
class_to_index={"dog": 0},
)
def test_raises_when_coordinates_is_none(self) -> None:
"""Raises ValueError when the coordinates value is None."""
with pytest.raises(ValueError, match="Malformed"):
createml_annotations_to_detections(
image_annotations=[{"label": "dog", "coordinates": None}],
class_to_index={"dog": 0},
image_annotations=image_annotations,
class_to_index=class_to_index,
)
@ -237,75 +232,77 @@ class TestLoadCreatemlAnnotations:
annotations[str(tmp_path / "b.jpg")].class_id, np.array([0], dtype=int)
)
def test_raises_on_path_traversal(self, tmp_path: Path) -> None:
"""Raises ValueError when 'image' field attempts directory traversal."""
@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"
payload = [{"image": "../evil.jpg", "annotations": []}]
annotations_path.write_text(json.dumps(payload))
with pytest.raises(ValueError, match="outside"):
load_createml_annotations(
images_directory_path=str(tmp_path / "images"),
annotations_path=str(annotations_path),
)
def test_raises_on_absolute_path(self, tmp_path: Path) -> None:
"""Raises ValueError when 'image' is an absolute path outside images dir."""
annotations_path = tmp_path / "annotations.json"
outside = tmp_path.parent / "evil.jpg"
payload = [{"image": str(outside), "annotations": []}]
annotations_path.write_text(json.dumps(payload))
with pytest.raises(ValueError, match="outside"):
load_createml_annotations(
images_directory_path=str(tmp_path),
annotations_path=str(annotations_path),
)
def test_raises_when_image_is_the_directory_itself(self, tmp_path: Path) -> None:
"""Raises ValueError when 'image' resolves to the images directory itself."""
annotations_path = tmp_path / "annotations.json"
payload = [{"image": ".", "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_raises_when_json_root_is_dict(self, tmp_path: Path) -> None:
"""Raises ValueError when the JSON root is a dict instead of a list."""
annotations_path = tmp_path / "annotations.json"
annotations_path.write_text(json.dumps({"image": "a.jpg", "annotations": []}))
with pytest.raises(ValueError, match="JSON list"):
load_createml_annotations(
images_directory_path=str(tmp_path),
annotations_path=str(annotations_path),
)
def test_raises_on_missing_image_key(self, tmp_path: Path) -> None:
"""Raises ValueError when an entry lacks the required 'image' key."""
annotations_path = tmp_path / "annotations.json"
annotations_path.write_text(json.dumps([{"annotations": []}]))
with pytest.raises(ValueError, match="'image'"):
load_createml_annotations(
images_directory_path=str(tmp_path),
annotations_path=str(annotations_path),
)
def test_raises_on_duplicate_image_entry(self, tmp_path: Path) -> None:
"""Raises ValueError when the same image filename appears more than once."""
annotations_path = tmp_path / "annotations.json"
payload = [
{"image": "a.jpg", "annotations": []},
{"image": "a.jpg", "annotations": []},
]
annotations_path.write_text(json.dumps(payload))
with pytest.raises(ValueError, match="duplicate"):
with pytest.raises(ValueError, match=match):
load_createml_annotations(
images_directory_path=str(tmp_path),
annotations_path=str(annotations_path),
@ -324,18 +321,38 @@ class TestSaveCreatemlAnnotations:
assert json.loads(annotations_path.read_text()) == []
def test_save_load_round_trip(self, tmp_path: Path) -> None:
@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"
classes = ["cat", "dog"]
image_paths = [str(images_directory_path / "a.jpg")]
annotations = {
image_paths[0]: Detections(
xyxy=np.array([[8, 8, 12, 12], [25, 16, 35, 24]], dtype=np.float32),
class_id=np.array([0, 1], dtype=int),
)
}
annotations = {image_paths[0]: Detections(xyxy=xyxy, class_id=class_id)}
dataset = DetectionDataset(
classes=classes, images=image_paths, annotations=annotations
)
@ -349,34 +366,6 @@ class TestSaveCreatemlAnnotations:
)
assert loaded_classes == classes
loaded = loaded_annotations[str(images_directory_path / "a.jpg")]
np.testing.assert_array_almost_equal(
loaded.xyxy, annotations[image_paths[0]].xyxy
)
np.testing.assert_array_equal(
loaded.class_id, annotations[image_paths[0]].class_id
)
def test_save_load_round_trip_float_coordinates(self, tmp_path: Path) -> None:
"""Float32 coordinates survive a save/load cycle within float32 precision."""
images_directory_path = tmp_path / "images"
annotations_path = tmp_path / "annotations.json"
xyxy = np.array([[10.3, 7.9, 44.1, 88.6]], dtype=np.float32)
image_paths = [str(images_directory_path / "a.jpg")]
annotations = {
image_paths[0]: Detections(xyxy=xyxy, class_id=np.array([0], dtype=int))
}
dataset = DetectionDataset(
classes=["dog"], images=image_paths, annotations=annotations
)
save_createml_annotations(
dataset=dataset, annotations_path=str(annotations_path)
)
_, _, loaded_annotations = load_createml_annotations(
images_directory_path=str(images_directory_path),
annotations_path=str(annotations_path),
)
loaded = loaded_annotations[str(images_directory_path / "a.jpg")]
np.testing.assert_array_almost_equal(loaded.xyxy, xyxy, decimal=4)
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)