import random from contextlib import ExitStack as DoesNotRaise from pathlib import Path from typing import TypeVar import numpy as np import pytest import supervision.dataset.utils as dataset_utils from supervision import Detections from supervision.dataset.utils import ( approximate_mask_with_polygons, build_class_index_mapping, check_no_basename_collisions, map_detections_class_id, merge_class_lists, train_test_split, ) from tests.helpers import _create_detections T = TypeVar("T") @pytest.mark.parametrize( ("data", "train_ratio", "random_state", "shuffle", "expected_result", "exception"), [ ([], 0.5, None, False, ([], []), DoesNotRaise()), # empty data ( [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 0.5, None, False, ([0, 1, 2, 3, 4], [5, 6, 7, 8, 9]), DoesNotRaise(), ), # data with 10 numbers and 50% train split ( [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 1.0, None, False, ([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], []), DoesNotRaise(), ), # data with 10 numbers and 100% train split ( [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 0.0, None, False, ([], [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), DoesNotRaise(), ), # data with 10 numbers and 0% train split ( ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], 0.5, None, False, (["a", "b", "c", "d", "e"], ["f", "g", "h", "i", "j"]), DoesNotRaise(), ), # data with 10 chars and 50% train split ( [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 0.5, 23, True, ([7, 8, 5, 6, 3], [2, 9, 0, 1, 4]), DoesNotRaise(), ), # data with 10 numbers and 50% train split with 23 random seed ( [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 0.5, 32, True, ([4, 6, 0, 8, 9], [5, 7, 2, 3, 1]), DoesNotRaise(), ), # data with 10 numbers and 50% train split with 23 random seed ], ) def test_train_test_split( data: list[T], train_ratio: float, random_state: int, shuffle: bool, expected_result: tuple[list[T], list[T]] | None, exception: Exception, ) -> None: with exception: result = train_test_split( data=data, train_ratio=train_ratio, random_state=random_state, shuffle=shuffle, ) assert result == expected_result def test_approximate_mask_with_polygons_default_preserves_polygon( monkeypatch, ) -> None: """Default mask polygon conversion forwards zero simplification.""" percentages: list[float] = [] def fake_approximate_polygon(polygon: np.ndarray, percentage: float) -> np.ndarray: """Capture simplification percentage while preserving the polygon.""" percentages.append(percentage) return polygon monkeypatch.setattr(dataset_utils, "approximate_polygon", fake_approximate_polygon) approximate_mask_with_polygons(np.ones((3, 3), dtype=bool)) assert percentages == [0.0] @pytest.mark.parametrize( ("class_lists", "expected_result", "exception"), [ ([], [], DoesNotRaise()), # empty class lists ( [["dog", "person"]], ["dog", "person"], DoesNotRaise(), ), # single class list; already alphabetically sorted ( [["person", "dog"]], ["dog", "person"], DoesNotRaise(), ), # single class list; not alphabetically sorted ( [["dog", "person"], ["dog", "person"]], ["dog", "person"], DoesNotRaise(), ), # two class lists; the same classes; already alphabetically sorted ( [["dog", "person"], ["cat"]], ["cat", "dog", "person"], DoesNotRaise(), ), # two class lists; different classes; already alphabetically sorted ], ) def test_merge_class_maps( class_lists: list[list[str]], expected_result: list[str], exception: Exception ) -> None: with exception: result = merge_class_lists(class_lists=class_lists) assert result == expected_result @pytest.mark.parametrize( ("source_classes", "target_classes", "expected_result", "exception"), [ ([], [], {}, DoesNotRaise()), # empty class lists ([], ["dog", "person"], {}, DoesNotRaise()), # empty source class list ( ["dog", "person"], [], None, pytest.raises(ValueError, match="Class dog not found"), ), # empty target class list ( ["dog", "person"], ["dog", "person"], {0: 0, 1: 1}, DoesNotRaise(), ), # same class lists ( ["dog", "person"], ["person", "dog"], {0: 1, 1: 0}, DoesNotRaise(), ), # same class lists but not alphabetically sorted ( ["dog", "person"], ["cat", "dog", "person"], {0: 1, 1: 2}, DoesNotRaise(), ), # source class list is a subset of target class list ( ["dog", "person"], ["cat", "dog"], None, pytest.raises(ValueError, match="Class person not found"), ), # source class list is not a subset of target class list ], ) def test_build_class_index_mapping( source_classes: list[str], target_classes: list[str], expected_result: dict[int, int] | None, exception: Exception, ) -> None: with exception: result = build_class_index_mapping( source_classes=source_classes, target_classes=target_classes ) assert result == expected_result @pytest.mark.parametrize( ("source_to_target_mapping", "detections", "expected_result", "exception"), [ ( {}, _create_detections(xyxy=[[0, 0, 10, 10]], class_id=[0]), None, pytest.raises(ValueError, match="subset of source_to_target_mapping"), ), # empty mapping ( {0: 1}, _create_detections(xyxy=[[0, 0, 10, 10]], class_id=[0]), _create_detections(xyxy=[[0, 0, 10, 10]], class_id=[1]), DoesNotRaise(), ), # single mapping ( {0: 1, 1: 2}, Detections.empty(), Detections.empty(), DoesNotRaise(), ), # empty detections ( {0: 1, 1: 2}, _create_detections(xyxy=[[0, 0, 10, 10]], class_id=[0]), _create_detections(xyxy=[[0, 0, 10, 10]], class_id=[1]), DoesNotRaise(), ), # multiple mappings ( {0: 1, 1: 2}, _create_detections(xyxy=[[0, 0, 10, 10], [0, 0, 10, 10]], class_id=[0, 1]), _create_detections(xyxy=[[0, 0, 10, 10], [0, 0, 10, 10]], class_id=[1, 2]), DoesNotRaise(), ), # multiple mappings ( {0: 1, 1: 2}, _create_detections(xyxy=[[0, 0, 10, 10]], class_id=[2]), None, pytest.raises(ValueError, match="source_to_target_mapping keys"), ), # class_id not in mapping ( {0: 1, 1: 2}, _create_detections(xyxy=[[0, 0, 10, 10]], class_id=[0], confidence=[0.5]), _create_detections(xyxy=[[0, 0, 10, 10]], class_id=[1], confidence=[0.5]), DoesNotRaise(), ), # confidence is not None ], ) def test_map_detections_class_id( source_to_target_mapping: dict[int, int], detections: Detections, expected_result: Detections | None, exception: Exception, ) -> None: with exception: result = map_detections_class_id( source_to_target_mapping=source_to_target_mapping, detections=detections ) assert result == expected_result class TestTrainTestSplitRngIsolation: """Regression tests for train_test_split RNG isolation (DAT-02).""" def test_does_not_mutate_input_list(self) -> None: """split() must not reorder the caller's list in place.""" data = list(range(10)) original = data.copy() train_test_split(data=data, train_ratio=0.5, random_state=42, shuffle=True) assert data == original def test_does_not_pollute_global_rng(self) -> None: """split() must not disturb the process-global random state.""" state_before = random.getstate() train_test_split( data=list(range(10)), train_ratio=0.5, random_state=42, shuffle=True ) assert random.getstate() == state_before def test_result_independent_of_global_rng(self) -> None: """A fixed random_state yields the same split regardless of global RNG.""" first = train_test_split( data=list(range(10)), train_ratio=0.5, random_state=42, shuffle=True ) for _ in range(5): random.random() # noqa: S311 — perturb global RNG; split must ignore it second = train_test_split( data=list(range(10)), train_ratio=0.5, random_state=42, shuffle=True ) assert first == second class TestCheckNoBasenameCollisions: """Regression tests for export basename collision detection (DAT-04).""" def test_raises_on_colliding_output_names(self) -> None: """Two source paths mapping to one output name must raise ValueError.""" with pytest.raises(ValueError, match="both map to image file"): check_no_basename_collisions( image_paths=["a/img.jpg", "b/img.jpg"], key=lambda image_path: Path(image_path).name, output_kind="image", ) def test_passes_on_unique_output_names(self) -> None: """Distinct output names must not raise.""" check_no_basename_collisions( image_paths=["a/img1.jpg", "b/img2.jpg"], key=lambda image_path: Path(image_path).name, output_kind="image", ) def test_passes_on_empty_image_paths(self) -> None: """Empty list must not raise (vacuously no collision).""" check_no_basename_collisions( image_paths=[], key=lambda image_path: Path(image_path).name, output_kind="image", ) def test_passes_on_single_image_path(self) -> None: """Single element list cannot collide with itself.""" check_no_basename_collisions( image_paths=["dir/only.jpg"], key=lambda image_path: Path(image_path).name, output_kind="image", )