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

783 lines
30 KiB
Python

import warnings
from contextlib import ExitStack as DoesNotRaise
from pathlib import Path
import numpy as np
import numpy.typing as npt
import pytest
from supervision import (
ClassificationDataset,
Classifications,
DetectionDataset,
Detections,
)
from supervision.config import CLASS_NAME_DATA_FIELD
from supervision.utils.internal import SupervisionWarnings
from tests.helpers import _create_detections, create_yolo_dataset
def _create_image(fill_value: int) -> npt.NDArray[np.uint8]:
return np.full((4, 4, 3), fill_value, dtype=np.uint8)
@pytest.mark.parametrize(
("dataset_list", "expected_result", "exception"),
[
(
[],
DetectionDataset(classes=[], images=[], annotations={}),
DoesNotRaise(),
), # empty dataset list
(
[DetectionDataset(classes=[], images=[], annotations={})],
DetectionDataset(classes=[], images=[], annotations={}),
DoesNotRaise(),
), # single empty dataset
(
[
DetectionDataset(classes=["dog", "person"], images=[], annotations={}),
DetectionDataset(classes=["dog", "person"], images=[], annotations={}),
],
DetectionDataset(classes=["dog", "person"], images=[], annotations={}),
DoesNotRaise(),
), # two datasets; no images and annotations, the same classes
(
[
DetectionDataset(classes=["dog", "person"], images=[], annotations={}),
DetectionDataset(classes=["cat"], images=[], annotations={}),
],
DetectionDataset(
classes=["cat", "dog", "person"], images=[], annotations={}
),
DoesNotRaise(),
), # two datasets; no images and annotations, different classes
(
[
DetectionDataset(
classes=["dog", "person"],
images=["image-1.png", "image-2.png"],
annotations={
"image-1.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
"image-2.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[1]
),
},
),
DetectionDataset(classes=[], images=[], annotations={}),
],
DetectionDataset(
classes=["dog", "person"],
images=["image-1.png", "image-2.png"],
annotations={
"image-1.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
"image-2.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[1]
),
},
),
DoesNotRaise(),
), # two datasets; images and annotations, the same classes
(
[
DetectionDataset(
classes=["dog", "person"],
images=["image-1.png", "image-2.png"],
annotations={
"image-1.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
"image-2.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[1]
),
},
),
DetectionDataset(classes=["cat"], images=[], annotations={}),
],
DetectionDataset(
classes=["cat", "dog", "person"],
images=["image-1.png", "image-2.png"],
annotations={
"image-1.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[1]
),
"image-2.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[2]
),
},
),
DoesNotRaise(),
), # two datasets; images and annotations, different classes
(
[
DetectionDataset(
classes=["dog", "person"],
images=["image-1.png", "image-2.png"],
annotations={
"image-1.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
"image-2.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[1]
),
},
),
DetectionDataset(
classes=["cat"],
images=["image-3.png"],
annotations={
"image-3.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
},
),
],
DetectionDataset(
classes=["cat", "dog", "person"],
images=["image-1.png", "image-2.png", "image-3.png"],
annotations={
"image-1.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[1]
),
"image-2.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[2]
),
"image-3.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
},
),
DoesNotRaise(),
), # two datasets; images and annotations, different classes
(
[
DetectionDataset(
classes=["dog", "person"],
images=["image-1.png", "image-2.png"],
annotations={
"image-1.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
"image-2.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[1]
),
},
),
DetectionDataset(
classes=["dog", "person"],
images=["image-2.png", "image-3.png"],
annotations={
"image-2.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
"image-3.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[1]
),
},
),
],
None,
pytest.raises(ValueError, match="not unique across datasets"),
),
],
)
def test_dataset_merge(
dataset_list: list[DetectionDataset],
expected_result: DetectionDataset | None,
exception: Exception,
) -> None:
"""
Verify that multiple DetectionDataset objects can be successfully merged.
Ensures that multiple `DetectionDataset` objects can be merged into single dataset.
This is vital for users who need to combine data from different sources or
augment their datasets with additional labeled examples.
"""
with exception:
result = DetectionDataset.merge(dataset_list=dataset_list)
assert result == expected_result
class TestClassNamePopulation:
"""Verify that DetectionDataset populates CLASS_NAME_DATA_FIELD on init."""
def test_class_name_populated_on_init(self) -> None:
"""Basic case: class_name data field is set from classes and class_id."""
dataset = DetectionDataset(
classes=["dog", "cat"],
images=["img1.png"],
annotations={
"img1.png": _create_detections(
xyxy=[[0, 0, 10, 10], [20, 20, 30, 30]],
class_id=[0, 1],
),
},
)
annotation = dataset.annotations["img1.png"]
assert CLASS_NAME_DATA_FIELD in annotation.data
np.testing.assert_array_equal(
annotation.data[CLASS_NAME_DATA_FIELD],
np.array(["dog", "cat"]),
)
def test_class_name_with_empty_annotations(self) -> None:
"""Empty Detections should not raise an error."""
dataset = DetectionDataset(
classes=["dog"],
images=["img1.png"],
annotations={"img1.png": Detections.empty()},
)
annotation = dataset.annotations["img1.png"]
assert CLASS_NAME_DATA_FIELD in annotation.data
assert len(annotation.data[CLASS_NAME_DATA_FIELD]) == 0
def test_class_name_with_empty_classes(self) -> None:
"""When classes is empty, class_name should not be populated."""
dataset = DetectionDataset(
classes=[],
images=[],
annotations={},
)
assert len(dataset.annotations) == 0
def test_class_name_after_merge(self) -> None:
"""After merging datasets, class_name must match remapped class_id."""
ds1 = DetectionDataset(
classes=["dog", "person"],
images=["img1.png"],
annotations={
"img1.png": _create_detections(xyxy=[[0, 0, 10, 10]], class_id=[0]),
},
)
ds2 = DetectionDataset(
classes=["cat"],
images=["img2.png"],
annotations={
"img2.png": _create_detections(xyxy=[[0, 0, 10, 10]], class_id=[0]),
},
)
merged = DetectionDataset.merge([ds1, ds2])
# merged.classes is ["cat", "dog", "person"]
# ds1's dog (0) -> dog (1), ds2's cat (0) -> cat (0)
ann1 = merged.annotations["img1.png"]
assert CLASS_NAME_DATA_FIELD in ann1.data
np.testing.assert_array_equal(
ann1.data[CLASS_NAME_DATA_FIELD], np.array(["dog"])
)
ann2 = merged.annotations["img2.png"]
assert CLASS_NAME_DATA_FIELD in ann2.data
np.testing.assert_array_equal(
ann2.data[CLASS_NAME_DATA_FIELD], np.array(["cat"])
)
def test_class_name_from_yolo(self, tmp_path: Path) -> None:
"""Integration test: from_yolo should produce class_name data."""
dataset_info = create_yolo_dataset(
str(tmp_path), num_images=2, classes=["cat", "dog"]
)
dataset = DetectionDataset.from_yolo(
images_directory_path=dataset_info["images_dir"],
annotations_directory_path=dataset_info["labels_dir"],
data_yaml_path=dataset_info["data_yaml_path"],
)
for _, annotation in dataset.annotations.items():
if annotation.class_id is not None and len(annotation.class_id) > 0:
assert CLASS_NAME_DATA_FIELD in annotation.data
expected_names = np.array(dataset.classes)[annotation.class_id]
np.testing.assert_array_equal(
annotation.data[CLASS_NAME_DATA_FIELD], expected_names
)
def test_constructor_does_not_mutate_input_annotations(self) -> None:
"""Adding class_name metadata must not mutate caller-owned Detections."""
annotation = _create_detections(xyxy=[[0, 0, 10, 10]], class_id=[0])
dataset = DetectionDataset(
classes=["dog"],
images=["img1.png"],
annotations={"img1.png": annotation},
)
assert CLASS_NAME_DATA_FIELD not in annotation.data
assert CLASS_NAME_DATA_FIELD in dataset.annotations["img1.png"].data
@pytest.mark.parametrize(
("class_id", "match"),
[
pytest.param([-1], "outside the valid range", id="negative"),
pytest.param([1], "outside the valid range", id="too-large"),
],
)
def test_constructor_rejects_out_of_range_class_id(
self, class_id: list[int], match: str
) -> None:
"""Invalid class ids raise a clear ValueError instead of indexing arrays."""
annotations = {
"img1.png": _create_detections(xyxy=[[0, 0, 10, 10]], class_id=class_id)
}
with pytest.raises(ValueError, match=match):
DetectionDataset(
classes=["dog"],
images=["img1.png"],
annotations=annotations,
)
def test_constructor_rejects_non_integer_class_id(self) -> None:
"""Non-integer class ids raise a clear ValueError before class-name mapping."""
annotations = {
"img1.png": Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0.5]),
)
}
with pytest.raises(ValueError, match="non-integer class_id"):
DetectionDataset(
classes=["dog"],
images=["img1.png"],
annotations=annotations,
)
class TestDetectionDatasetInMemoryImages:
"""Verify DetectionDataset keeps dict-provided images in memory (DAT-01)."""
@staticmethod
def _build_dataset(
images: dict[str, npt.NDArray[np.uint8]],
) -> DetectionDataset:
annotations = {
path: _create_detections(xyxy=[[0, 0, 10, 10]], class_id=[0])
for path in images
}
return DetectionDataset(classes=["dog"], images=images, annotations=annotations)
def test_getitem_returns_in_memory_image(self) -> None:
"""Indexing a dict-constructed dataset returns the in-memory array."""
image = _create_image(fill_value=7)
dataset = self._build_dataset({"imgX.jpg": image})
image_path, loaded_image, _ = dataset[0]
assert image_path == "imgX.jpg"
np.testing.assert_array_equal(loaded_image, image)
def test_len_counts_in_memory_images(self) -> None:
"""`len` of a dict-constructed dataset equals the number of provided images."""
images = {
"img1.jpg": _create_image(fill_value=1),
"img2.jpg": _create_image(fill_value=2),
}
dataset = self._build_dataset(images)
assert len(dataset) == 2
def test_merge_preserves_in_memory_pixel_access(self) -> None:
"""Merging two in-memory datasets keeps pixel access without re-warning."""
image_1 = _create_image(fill_value=10)
image_2 = _create_image(fill_value=20)
with pytest.warns(SupervisionWarnings, match="deprecated"):
ds_1 = self._build_dataset({"img1.jpg": image_1})
with pytest.warns(SupervisionWarnings, match="deprecated"):
ds_2 = self._build_dataset({"img2.jpg": image_2})
with warnings.catch_warnings():
warnings.simplefilter("error", SupervisionWarnings)
merged = DetectionDataset.merge([ds_1, ds_2])
assert len(merged) == 2
_, loaded_1, _ = merged[0]
_, loaded_2, _ = merged[1]
np.testing.assert_array_equal(loaded_1, image_1)
np.testing.assert_array_equal(loaded_2, image_2)
def test_split_preserves_in_memory_pixel_access_without_warning(self) -> None:
"""Splitting an in-memory dataset keeps pixel access without re-warning."""
image_1 = _create_image(fill_value=11)
image_2 = _create_image(fill_value=22)
with pytest.warns(SupervisionWarnings, match="deprecated"):
dataset = self._build_dataset({"img1.jpg": image_1, "img2.jpg": image_2})
with warnings.catch_warnings():
warnings.simplefilter("error", SupervisionWarnings)
train, test = dataset.split(split_ratio=0.5, shuffle=False)
assert train.image_paths == ["img1.jpg"]
assert test.image_paths == ["img2.jpg"]
_, loaded_train, _ = train[0]
_, loaded_test, _ = test[0]
np.testing.assert_array_equal(loaded_train, image_1)
np.testing.assert_array_equal(loaded_test, image_2)
def test_iteration_yields_in_memory_images(self) -> None:
"""Iteration yields (path, image, annotation) with correct pixels."""
images = {
"img1.jpg": _create_image(fill_value=1),
"img2.jpg": _create_image(fill_value=2),
}
dataset = self._build_dataset(images)
entries = list(dataset)
assert [path for path, _, _ in entries] == ["img1.jpg", "img2.jpg"]
for image_path, loaded_image, annotation in entries:
np.testing.assert_array_equal(loaded_image, images[image_path])
assert annotation is dataset.annotations[image_path]
def test_dict_input_emits_deprecation_warning(self) -> None:
"""Passing a dict of images emits the SupervisionWarnings deprecation notice."""
with pytest.warns(SupervisionWarnings, match="deprecated"):
self._build_dataset({"img1.jpg": _create_image(fill_value=3)})
def test_eq_reflexive_in_memory(self) -> None:
"""In-memory dataset equals itself (reflexive __eq__ via pixel comparison)."""
images = {
"img1.jpg": _create_image(fill_value=1),
"img2.jpg": _create_image(fill_value=2),
}
dataset = self._build_dataset(images)
assert dataset == dataset
def test_eq_same_pixels_returns_true(self) -> None:
"""Two in-memory datasets with identical images and annotations are equal."""
images = {"img1.jpg": _create_image(fill_value=5)}
ds_a = self._build_dataset(images)
ds_b = self._build_dataset(dict(images))
assert ds_a == ds_b
def test_eq_different_pixels_returns_false(self) -> None:
"""In-memory datasets with different pixel data are not equal."""
ds_a = self._build_dataset({"img1.jpg": _create_image(fill_value=1)})
ds_b = self._build_dataset({"img1.jpg": _create_image(fill_value=2)})
assert ds_a != ds_b
class TestDatasetEqualityContracts:
"""Dataset equality must respect class order and NumPy-backed annotations."""
def test_detection_dataset_class_order_matters(self) -> None:
"""DetectionDataset equality is sensitive to the ordered class list."""
ds_a = DetectionDataset(classes=["cat", "dog"], images=[], annotations={})
ds_b = DetectionDataset(classes=["dog", "cat"], images=[], annotations={})
assert ds_a != ds_b
def test_classification_dataset_numpy_annotations(self) -> None:
"""ClassificationDataset equality handles multi-value NumPy annotations."""
annotations = {
"img.png": Classifications(
class_id=np.array([0, 1], dtype=np.int_),
confidence=np.array([0.25, 0.75], dtype=np.float32),
)
}
ds_a = ClassificationDataset(
classes=["cat", "dog"],
images=["img.png"],
annotations=annotations,
)
ds_b = ClassificationDataset(
classes=["cat", "dog"],
images=["img.png"],
annotations={
"img.png": Classifications(
class_id=np.array([0, 1], dtype=np.int_),
confidence=np.array([0.25, 0.75], dtype=np.float32),
)
},
)
ds_c = ClassificationDataset(
classes=["dog", "cat"],
images=["img.png"],
annotations=annotations,
)
assert ds_a == ds_b
assert ds_a != ds_c
class TestDetectionDatasetExportCollisions:
"""Regression tests for the basename-collision guard on export (DAT-04)."""
def test_as_yolo_raises_on_same_basename_images(self, tmp_path: Path) -> None:
"""Same-basename images from different directories must not overwrite."""
dataset = DetectionDataset(
classes=["cat"],
images=["dir_a/img.png", "dir_b/img.png"],
annotations={
"dir_a/img.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
"dir_b/img.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
},
)
with pytest.raises(ValueError, match="both map to image file"):
dataset.as_yolo(images_directory_path=str(tmp_path / "images"))
def test_as_yolo_raises_on_same_stem_annotations(self, tmp_path: Path) -> None:
"""Same-stem images must not overwrite annotations."""
dataset = DetectionDataset(
classes=["cat"],
images=["dir_a/img.jpg", "dir_b/img.png"],
annotations={
"dir_a/img.jpg": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
"dir_b/img.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
},
)
with pytest.raises(ValueError, match="both map to YOLO annotation file"):
dataset.as_yolo(
images_directory_path=str(tmp_path / "images"),
annotations_directory_path=str(tmp_path / "labels"),
)
def test_as_pascal_voc_raises_on_same_basename_images(self, tmp_path: Path) -> None:
"""Same-basename images must not overwrite image files on export."""
dataset = DetectionDataset(
classes=["cat"],
images=["dir_a/img.jpg", "dir_b/img.jpg"],
annotations={
"dir_a/img.jpg": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
"dir_b/img.jpg": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
},
)
with pytest.raises(ValueError, match="both map to image file"):
dataset.as_pascal_voc(
images_directory_path=str(tmp_path / "images"),
)
def test_as_pascal_voc_raises_on_same_stem_annotations(
self, tmp_path: Path
) -> None:
"""Same-stem images must not overwrite annotations."""
dataset = DetectionDataset(
classes=["cat"],
images=["dir_a/img.jpg", "dir_b/img.png"],
annotations={
"dir_a/img.jpg": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
"dir_b/img.png": _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
},
)
with pytest.raises(ValueError, match="both map to Pascal VOC annotation file"):
dataset.as_pascal_voc(
annotations_directory_path=str(tmp_path / "annotations"),
)
def test_as_pascal_voc_rejects_annotation_collisions_before_writing(
self, tmp_path: Path
) -> None:
"""Pascal VOC export preflights annotation collisions before copying images."""
source_root = tmp_path / "source"
source_a = source_root / "dir_a"
source_b = source_root / "dir_b"
source_a.mkdir(parents=True)
source_b.mkdir(parents=True)
image_a_path = source_a / "img.jpg"
image_b_path = source_b / "img.png"
image_a_path.write_bytes(b"image-a")
image_b_path.write_bytes(b"image-b")
dataset = DetectionDataset(
classes=["cat"],
images=[str(image_a_path), str(image_b_path)],
annotations={
str(image_a_path): _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
str(image_b_path): _create_detections(
xyxy=[[0, 0, 10, 10]], class_id=[0]
),
},
)
images_directory = tmp_path / "images"
annotations_directory = tmp_path / "annotations"
with pytest.raises(ValueError, match="both map to Pascal VOC annotation file"):
dataset.as_pascal_voc(
images_directory_path=str(images_directory),
annotations_directory_path=str(annotations_directory),
)
assert not images_directory.exists()
assert not annotations_directory.exists()
# ---------------------------------------------------------------------------
# TST-03 - DetectionDataset.split()
# ---------------------------------------------------------------------------
def _make_detection_dataset(
n: int, classes: list[str] | None = None
) -> DetectionDataset:
"""Build a DetectionDataset with n images using list[str] path API."""
if classes is None:
classes = ["cat"]
image_paths = [f"img{i}.jpg" for i in range(n)]
annotations = {
f"img{i}.jpg": _create_detections(xyxy=[[0, 0, 2, 2]], class_id=[0])
for i in range(n)
}
return DetectionDataset(
classes=classes, images=image_paths, annotations=annotations
)
class TestDetectionDatasetSplit:
"""DetectionDataset.split() partitions images correctly."""
def test_split_is_deterministic(self) -> None:
"""Two calls with the same random_state produce identical partitions."""
ds = _make_detection_dataset(10)
train_a, test_a = ds.split(split_ratio=0.7, random_state=42)
train_b, test_b = ds.split(split_ratio=0.7, random_state=42)
assert train_a.image_paths == train_b.image_paths
assert test_a.image_paths == test_b.image_paths
def test_split_union_covers_all_images(self) -> None:
"""Train + test together contain exactly the original image set."""
ds = _make_detection_dataset(10)
train, test = ds.split(split_ratio=0.7, random_state=0)
combined = set(train.image_paths) | set(test.image_paths)
assert combined == set(ds.image_paths)
def test_split_partitions_are_disjoint(self) -> None:
"""No image path appears in both train and test."""
ds = _make_detection_dataset(10)
train, test = ds.split(split_ratio=0.7, random_state=0)
assert set(train.image_paths).isdisjoint(set(test.image_paths))
def test_split_ratio_zero_empties_train(self) -> None:
"""split_ratio=0.0 sends all images to the test set."""
ds = _make_detection_dataset(6)
train, test = ds.split(split_ratio=0.0, shuffle=False)
assert len(train) == 0
assert len(test) == 6
def test_split_ratio_one_empties_test(self) -> None:
"""split_ratio=1.0 sends all images to the train set."""
ds = _make_detection_dataset(6)
train, test = ds.split(split_ratio=1.0, shuffle=False)
assert len(train) == 6
assert len(test) == 0
def test_split_does_not_mutate_source_ordering(self) -> None:
"""Calling split() twice does not change the source image_paths order."""
ds = _make_detection_dataset(8)
original_order = list(ds.image_paths)
ds.split(split_ratio=0.5, random_state=7)
ds.split(split_ratio=0.5, random_state=99)
assert ds.image_paths == original_order
def test_split_classes_preserved(self) -> None:
"""Both halves inherit the full class list from the source dataset."""
ds = _make_detection_dataset(6, classes=["cat", "dog"])
train, test = ds.split(split_ratio=0.5, random_state=1)
assert train.classes == ["cat", "dog"]
assert test.classes == ["cat", "dog"]
# ---------------------------------------------------------------------------
# TST-03 - ClassificationDataset folder-structure round-trip
# ---------------------------------------------------------------------------
class TestClassificationDatasetFolderRoundTrip:
"""from_folder_structure -> as_folder_structure -> reload reproduces the dataset."""
def _make_folder_tree(self, root: Path) -> None:
"""Write a tiny 2-class folder structure under root."""
import cv2 as _cv2
for cls_name, colour in [("cats", 0), ("dogs", 128)]:
cls_dir = root / cls_name
cls_dir.mkdir(parents=True)
for idx in range(2):
img = np.full((8, 8, 3), colour, dtype=np.uint8)
_cv2.imwrite(str(cls_dir / f"{cls_name}_{idx}.png"), img)
def test_reload_has_same_classes(self, tmp_path: Path) -> None:
"""Reloaded dataset has the same sorted class list."""
src = tmp_path / "source"
self._make_folder_tree(src)
ds = ClassificationDataset.from_folder_structure(str(src))
out = tmp_path / "export"
ds.as_folder_structure(str(out))
ds2 = ClassificationDataset.from_folder_structure(str(out))
assert ds2.classes == ds.classes
def test_reload_has_same_image_count(self, tmp_path: Path) -> None:
"""Reloaded dataset has the same number of images."""
src = tmp_path / "source"
self._make_folder_tree(src)
ds = ClassificationDataset.from_folder_structure(str(src))
out = tmp_path / "export"
ds.as_folder_structure(str(out))
ds2 = ClassificationDataset.from_folder_structure(str(out))
assert len(ds2) == len(ds)
def test_reload_annotation_class_ids_match(self, tmp_path: Path) -> None:
"""Reloaded annotations map each image to its original class folder."""
src = tmp_path / "source"
self._make_folder_tree(src)
ds = ClassificationDataset.from_folder_structure(str(src))
out = tmp_path / "export"
ds.as_folder_structure(str(out))
ds2 = ClassificationDataset.from_folder_structure(str(out))
for image_path, ann in ds2.annotations.items():
class_id = int(ann.class_id[0])
assert 0 <= class_id < len(ds2.classes)
assert ds2.classes[class_id] == Path(image_path).parent.name
def test_root_clutter_is_ignored(self, tmp_path: Path) -> None:
"""Clutter and non-image files do not break folder loading."""
root = tmp_path / "source"
cats = root / "cats"
cats.mkdir(parents=True)
(root / ".DS_Store").write_text("metadata", encoding="utf-8")
(root / "README.md").write_text("notes", encoding="utf-8")
(cats / ".DS_Store").write_text("metadata", encoding="utf-8")
(cats / "README.md").write_text("notes", encoding="utf-8")
(cats / "classes.txt").write_text("cats", encoding="utf-8")
(cats / "cat.png").write_bytes(b"image")
dataset = ClassificationDataset.from_folder_structure(str(root))
assert dataset.classes == ["cats"]
assert dataset.image_paths == [str(cats / "cat.png")]