diff --git a/docs/how_to/benchmark_a_model.md b/docs/how_to/benchmark_a_model.md index 16b2b4b6..a3dce685 100644 --- a/docs/how_to/benchmark_a_model.md +++ b/docs/how_to/benchmark_a_model.md @@ -42,14 +42,9 @@ We'll use the following libraries: - `supervision` to evaluate the model results ```bash -pip install roboflow supervision -pip install git+https://github.com/roboflow/inference.git@linas/allow-latest-rc-supervision +pip install roboflow inference "supervision[metrics]" ``` -!!! info - - We're updating `inference` at the moment. Please install it as shown above. - Here's how you can download a dataset: ```python diff --git a/src/supervision/annotators/core.py b/src/supervision/annotators/core.py index a5c785b7..3a7f88c7 100644 --- a/src/supervision/annotators/core.py +++ b/src/supervision/annotators/core.py @@ -3032,12 +3032,15 @@ class CropAnnotator(BaseAnnotator): anchors: npt.NDArray[np.int32] = detections.get_anchors_coordinates( anchor=self.position ).astype(np.int32) + # Snapshot before the loop so later crops are taken from the original image, + # not a scene already annotated by earlier iterations (overlapping-box case). + source_scene = scene.copy() for idx, (xyxy, anchor) in enumerate(zip(clipped_xyxy, anchors)): crop_x1, crop_y1, crop_x2, crop_y2 = xyxy if crop_x2 <= crop_x1 or crop_y2 <= crop_y1: continue - crop = crop_image(image=scene, xyxy=xyxy) + crop = crop_image(image=source_scene, xyxy=xyxy) resized_crop = scale_image(image=crop, scale_factor=self.scale_factor) crop_wh = resized_crop.shape[1], resized_crop.shape[0] (x1, y1), (x2, y2) = self.calculate_crop_coordinates( diff --git a/src/supervision/dataset/core.py b/src/supervision/dataset/core.py index 74d189e4..65f087eb 100644 --- a/src/supervision/dataset/core.py +++ b/src/supervision/dataset/core.py @@ -28,8 +28,8 @@ from supervision.dataset.formats.labelme import ( save_labelme_annotations, ) from supervision.dataset.formats.pascal_voc import ( - detections_to_pascal_voc, load_pascal_voc_annotations, + save_pascal_voc_annotations, ) from supervision.dataset.formats.yolo import ( load_yolo_annotations, @@ -37,8 +37,8 @@ from supervision.dataset.formats.yolo import ( save_yolo_annotations, ) from supervision.dataset.utils import ( - _check_no_basename_collisions, build_class_index_mapping, + check_no_basename_collisions, map_detections_class_id, merge_class_lists, save_dataset_images, @@ -387,19 +387,12 @@ class DetectionDataset(BaseDataset): output file to overwrite another. Rename images to ensure unique basenames before exporting a merged dataset. """ - # Pre-flight: validate output uniqueness before writing any file if images_directory_path: - _check_no_basename_collisions( + check_no_basename_collisions( image_paths=self.image_paths, key=lambda image_path: Path(image_path).name, output_kind="image", ) - if annotations_directory_path: - _check_no_basename_collisions( - image_paths=self.image_paths, - key=lambda image_path: f"{Path(image_path).stem}.xml", - output_kind="Pascal VOC annotation", - ) if images_directory_path: save_dataset_images( @@ -408,35 +401,14 @@ class DetectionDataset(BaseDataset): show_progress=show_progress, ) if annotations_directory_path: - _check_no_basename_collisions( - image_paths=self.image_paths, - key=lambda image_path: f"{Path(image_path).stem}.xml", - output_kind="Pascal VOC annotation", + save_pascal_voc_annotations( + dataset=self, + annotations_directory_path=annotations_directory_path, + min_image_area_percentage=min_image_area_percentage, + max_image_area_percentage=max_image_area_percentage, + approximation_percentage=approximation_percentage, + show_progress=show_progress, ) - Path(annotations_directory_path).mkdir(parents=True, exist_ok=True) - for image_path, image, annotations in tqdm( - self, - total=len(self), - desc="Saving Pascal VOC annotations", - disable=not show_progress, - ): - annotation_name = Path(image_path).stem - annotations_path = os.path.join( - annotations_directory_path, f"{annotation_name}.xml" - ) - image_name = Path(image_path).name - pascal_voc_xml = detections_to_pascal_voc( - detections=annotations, - classes=self.classes, - filename=image_name, - image_shape=image.shape, - min_image_area_percentage=min_image_area_percentage, - max_image_area_percentage=max_image_area_percentage, - approximation_percentage=approximation_percentage, - ) - - with open(annotations_path, "w") as f: - f.write(pascal_voc_xml) @classmethod def from_pascal_voc( @@ -630,13 +602,13 @@ class DetectionDataset(BaseDataset): ) # Pre-flight: validate output uniqueness before writing any file if images_directory_path: - _check_no_basename_collisions( + check_no_basename_collisions( image_paths=self.image_paths, key=lambda image_path: Path(image_path).name, output_kind="image", ) if annotations_directory_path: - _check_no_basename_collisions( + check_no_basename_collisions( image_paths=self.image_paths, key=lambda image_path: Path(image_path).stem + ".txt", output_kind="YOLO annotation", diff --git a/src/supervision/dataset/formats/labelme.py b/src/supervision/dataset/formats/labelme.py index edba1e6d..b34570c4 100644 --- a/src/supervision/dataset/formats/labelme.py +++ b/src/supervision/dataset/formats/labelme.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any import numpy as np import numpy.typing as npt -from supervision.dataset.utils import _check_no_basename_collisions +from supervision.dataset.utils import check_no_basename_collisions from supervision.detection.core import Detections from supervision.detection.utils.converters import ( mask_to_polygons, @@ -380,7 +380,7 @@ def save_labelme_annotations( ) ``` """ - _check_no_basename_collisions( + check_no_basename_collisions( image_paths=dataset.image_paths, key=lambda image_path: f"{Path(image_path).stem}.json", output_kind="LabelMe annotation", diff --git a/src/supervision/dataset/formats/pascal_voc.py b/src/supervision/dataset/formats/pascal_voc.py index 342f203b..771a97f4 100644 --- a/src/supervision/dataset/formats/pascal_voc.py +++ b/src/supervision/dataset/formats/pascal_voc.py @@ -1,7 +1,11 @@ import os from pathlib import Path +from typing import TYPE_CHECKING from xml.etree.ElementTree import Element, SubElement +if TYPE_CHECKING: + from supervision.dataset.core import DetectionDataset + import cv2 import numpy as np import numpy.typing as npt @@ -9,7 +13,10 @@ from defusedxml.ElementTree import parse, tostring from defusedxml.minidom import parseString from tqdm.auto import tqdm -from supervision.dataset.utils import approximate_mask_with_polygons +from supervision.dataset.utils import ( + approximate_mask_with_polygons, + check_no_basename_collisions, +) from supervision.detection.core import Detections from supervision.detection.utils.converters import polygon_to_mask, polygon_to_xyxy from supervision.utils.file import list_files_with_extensions @@ -173,7 +180,9 @@ def detections_to_pascal_voc( annotation.append(next_object) # Generate XML string - xml_string = str(parseString(tostring(annotation)).toprettyxml(indent=" ")) + xml_string = str( + parseString(tostring(annotation).decode("utf-8")).toprettyxml(indent=" ") + ) return xml_string @@ -224,6 +233,8 @@ def load_pascal_voc_annotations( tree = parse(annotation_path) root = tree.getroot() + if root is None: + raise ValueError(f"Failed to parse XML root from {annotation_path}") image = cv2.imread(image_path) if image is None: @@ -366,3 +377,73 @@ def _get_required_text(element: Element, tag: str) -> str: if child is None or child.text is None: raise ValueError(f"Missing '{tag}' in Pascal VOC annotation.") return child.text + + +def save_pascal_voc_annotations( + dataset: "DetectionDataset", + annotations_directory_path: str, + min_image_area_percentage: float = 0.0, + max_image_area_percentage: float = 1.0, + approximation_percentage: float = 0.75, + show_progress: bool = False, +) -> None: + """Write Pascal VOC XML annotation files for every image in *dataset*. + + Args: + dataset: Dataset whose annotations are saved. + annotations_directory_path: Destination directory for ``.xml`` files; + created automatically if it does not exist. + min_image_area_percentage: Minimum detection area as a fraction of the + image area. Detections below this threshold are omitted. Must be in + ``[0, 1]``. Default ``0.0`` keeps all detections. + max_image_area_percentage: Maximum detection area as a fraction of the + image area. Detections above this threshold are omitted. Must be in + ``[0, 1]``. Default ``1.0`` keeps all detections. + approximation_percentage: Fraction of polygon vertices to remove when + approximating instance masks as polygons. Range ``[0, 1)``. Default + ``0.75`` applies aggressive simplification. + show_progress: If ``True``, display a tqdm progress bar while writing + annotation files. Default ``False``. + + Raises: + ValueError: If two image paths map to the same ``.xml`` output name. + + Examples: + >>> import tempfile + >>> from supervision.dataset.core import DetectionDataset + >>> from supervision.dataset.formats.pascal_voc import ( + ... save_pascal_voc_annotations, + ... ) + >>> dataset = DetectionDataset(classes=[], images={}, annotations={}) + >>> with tempfile.TemporaryDirectory() as tmpdir: + ... save_pascal_voc_annotations(dataset, tmpdir) + """ + + check_no_basename_collisions( + image_paths=dataset.image_paths, + key=lambda image_path: f"{Path(image_path).stem}.xml", + output_kind="Pascal VOC annotation", + ) + Path(annotations_directory_path).mkdir(parents=True, exist_ok=True) + for image_path, image, annotations in tqdm( + dataset, + total=len(dataset), + desc="Saving Pascal VOC annotations", + disable=not show_progress, + ): + annotation_name = Path(image_path).stem + annotations_path = os.path.join( + annotations_directory_path, f"{annotation_name}.xml" + ) + image_name = Path(image_path).name + pascal_voc_xml = detections_to_pascal_voc( + detections=annotations, + classes=dataset.classes, + filename=image_name, + image_shape=(image.shape[0], image.shape[1], image.shape[2]), + min_image_area_percentage=min_image_area_percentage, + max_image_area_percentage=max_image_area_percentage, + approximation_percentage=approximation_percentage, + ) + with open(annotations_path, "w") as f: + f.write(pascal_voc_xml) diff --git a/src/supervision/dataset/formats/yolo.py b/src/supervision/dataset/formats/yolo.py index 30460e89..47058264 100644 --- a/src/supervision/dataset/formats/yolo.py +++ b/src/supervision/dataset/formats/yolo.py @@ -13,8 +13,8 @@ from tqdm.auto import tqdm from supervision.config import ORIENTED_BOX_COORDINATES from supervision.dataset.utils import ( - _check_no_basename_collisions, approximate_mask_with_polygons, + check_no_basename_collisions, ) from supervision.detection.core import Detections from supervision.detection.utils._typing import _DetectionDataType @@ -473,7 +473,7 @@ def save_yolo_annotations( >>> dataset = DetectionDataset(classes=["cat"], images={}, annotations={}) >>> save_yolo_annotations(dataset, "/tmp/labels") """ - _check_no_basename_collisions( + check_no_basename_collisions( image_paths=dataset.image_paths, key=lambda image_path: _image_name_to_annotation_name(Path(image_path).name), output_kind="YOLO annotation", diff --git a/src/supervision/dataset/utils.py b/src/supervision/dataset/utils.py index b0b7df10..cbad8021 100644 --- a/src/supervision/dataset/utils.py +++ b/src/supervision/dataset/utils.py @@ -1,5 +1,7 @@ from __future__ import annotations +__all__ = ["check_no_basename_collisions", "train_test_split"] + import copy import os import random @@ -127,7 +129,7 @@ def map_detections_class_id( return detections_copy -def _check_no_basename_collisions( +def check_no_basename_collisions( image_paths: list[str], key: Callable[[str], str], output_kind: str, @@ -152,24 +154,26 @@ def _check_no_basename_collisions( Examples: >>> from pathlib import Path - >>> from supervision.dataset.utils import _check_no_basename_collisions - >>> _check_no_basename_collisions( + >>> from supervision.dataset.utils import check_no_basename_collisions + >>> check_no_basename_collisions( ... ["a/img.jpg", "b/img.jpg"], lambda p: Path(p).name, "image" ... ) Traceback (most recent call last): ... ValueError: Cannot export dataset: image paths 'a/img.jpg' and ... """ - seen: dict[str, str] = {} + seen: dict[str, tuple[str, str]] = {} # casefold(key) → (original name, image_path) for image_path in image_paths: output_name = key(image_path) - if output_name in seen: + case_key = output_name.casefold() + if case_key in seen: + first_name, first_path = seen[case_key] raise ValueError( - f"Cannot export dataset: image paths {seen[output_name]!r} and " - f"{image_path!r} both map to {output_kind} file {output_name!r}. " + f"Cannot export dataset: image paths {first_path!r} and " + f"{image_path!r} both map to {output_kind} file {first_name!r}. " "Ensure all image basenames are unique before exporting." ) - seen[output_name] = image_path + seen[case_key] = (output_name, image_path) def save_dataset_images( @@ -195,7 +199,7 @@ def save_dataset_images( >>> dataset = DetectionDataset(classes=["cat"], images={}, annotations={}) >>> save_dataset_images(dataset, "/tmp/images") """ - _check_no_basename_collisions( + check_no_basename_collisions( image_paths=dataset.image_paths, key=lambda image_path: Path(image_path).name, output_kind="image", diff --git a/src/supervision/detection/core.py b/src/supervision/detection/core.py index 2278c4b1..523d1023 100644 --- a/src/supervision/detection/core.py +++ b/src/supervision/detection/core.py @@ -1090,6 +1090,7 @@ class Detections: | PaliGemma | `PALIGEMMA` | detection | `resolution_wh` | `classes` | | PaliGemma 2 | `PALIGEMMA` | detection | `resolution_wh` | `classes` | | Qwen2.5-VL | `QWEN_2_5_VL` | detection | `resolution_wh`, `input_wh` | `classes` | + | Qwen3-VL | `QWEN_3_VL` | detection | `resolution_wh` | `classes` | | Google Gemini 2.0 | `GOOGLE_GEMINI_2_0` | detection | `resolution_wh` | `classes` | | Google Gemini 2.5 | `GOOGLE_GEMINI_2_5` | detection, segmentation | `resolution_wh` | `classes` | | Moondream | `MOONDREAM` | detection | `resolution_wh` | | @@ -1533,20 +1534,10 @@ class Detections: "Use `Detections.from_vlm` instead." ) - # filler logic mapping old from_lmm to new from_vlm - lmm_to_vlm = { - LMM.PALIGEMMA: VLM.PALIGEMMA, - LMM.FLORENCE_2: VLM.FLORENCE_2, - LMM.QWEN_2_5_VL: VLM.QWEN_2_5_VL, - LMM.QWEN_3_VL: VLM.QWEN_3_VL, - LMM.DEEPSEEK_VL_2: VLM.DEEPSEEK_VL_2, - LMM.GOOGLE_GEMINI_2_0: VLM.GOOGLE_GEMINI_2_0, - LMM.GOOGLE_GEMINI_2_5: VLM.GOOGLE_GEMINI_2_5, - LMM.MOONDREAM: VLM.MOONDREAM, - } - + # LMM and VLM are mirror enums (identical string values) so value-based + # lookup is exhaustive by construction — no hand-maintained mapping needed. if isinstance(lmm, LMM): - vlm = lmm_to_vlm[lmm] + vlm = VLM(lmm.value) elif isinstance(lmm, str): try: @@ -1556,7 +1547,7 @@ class Detections: f"Invalid LMM string '{lmm}'. Must be one of " f"{[m.value for m in LMM]}" ) - vlm = lmm_to_vlm[lmm_enum] + vlm = VLM(lmm_enum.value) else: raise ValueError( diff --git a/src/supervision/metrics/detection.py b/src/supervision/metrics/detection.py index d251cbc3..ed93d39e 100644 --- a/src/supervision/metrics/detection.py +++ b/src/supervision/metrics/detection.py @@ -1438,8 +1438,10 @@ class MeanAveragePrecision: ) ) else: - # Predictions on a ground-truth-empty (background) image are all - # false positives; record them so precision/AP is penalized. + # Background image: no GT boxes, so all predictions are FP matches. + # This lowers AP for classes that appear in at least one GT image + # elsewhere; classes absent from all GT images are excluded from AP + # (they never appear in true_class_ids and are skipped by the AP loop). stats.append( ( np.zeros( @@ -1460,19 +1462,14 @@ class MeanAveragePrecision: cast(npt.NDArray[np.int32], concatenated_stats[2]), cast(npt.NDArray[np.int32], concatenated_stats[3]), ) - map50 = ( - float(average_precisions[:, 0].mean()) - if average_precisions.size > 0 - else 0.0 - ) - map75 = ( - float(average_precisions[:, 5].mean()) - if average_precisions.size > 0 - else 0.0 - ) - map50_95 = ( - float(average_precisions.mean()) if average_precisions.size > 0 else 0.0 - ) + if average_precisions.size == 0: + # All images had no ground-truth objects; FPs recorded but no class + # to accumulate AP over → return 0.0 rather than NaN. + map50, map75, map50_95 = 0.0, 0.0, 0.0 + else: + map50 = float(average_precisions[:, 0].mean()) + map75 = float(average_precisions[:, 5].mean()) + map50_95 = float(average_precisions.mean()) else: map50, map75, map50_95 = 0, 0, 0 average_precisions = np.array([]) diff --git a/src/supervision/utils/image.py b/src/supervision/utils/image.py index 6c35533f..e0384d12 100644 --- a/src/supervision/utils/image.py +++ b/src/supervision/utils/image.py @@ -343,6 +343,17 @@ def _overlay_image( Non-deprecated internal implementation backing the public `overlay_image`. Kept separate so library-internal callers do not emit a deprecation warning. + + Args: + image: Background BGR array of shape ``(H, W, 3)``. Modified in place + and returned. + overlay: Overlay array of shape ``(H, W, 3)`` or ``(H, W, 4)``; channel + 4, when present, is treated as alpha. + anchor: ``(x, y)`` pixel position of the overlay top-left corner. May + be negative (partial off-screen placement is clipped). + + Returns: + The ``image`` array with the overlay applied. """ scene_height, scene_width = image.shape[:2] image_height, image_width = overlay.shape[:2] diff --git a/tests/annotators/test_core.py b/tests/annotators/test_core.py index b793a438..f5a90e22 100644 --- a/tests/annotators/test_core.py +++ b/tests/annotators/test_core.py @@ -1419,6 +1419,43 @@ class TestCropAnnotator: assert not np.array_equal(gradient_image, result) + def test_annotate_overlapping_crops_sample_from_original_scene(self) -> None: + """Later crops must sample the original un-annotated scene. + + box1 is pasted into the region that box2 crops from. Without the + source_scene = scene.copy() fix, box2 reads box1's paste value + instead of the original pixel — the aliasing regression. + """ + # Arrange: two distinct pixel bands; box1's paste region overlaps box2's crop + scene = np.full((80, 80, 3), 50, dtype=np.uint8) + scene[0:20, 0:20] = 10 # band A — box1 crops here (value 10) + scene[20:40, 20:40] = 200 # band B — box2 crops here; box1 pastes here + + detections = _create_detections( + xyxy=[[0, 0, 20, 20], [20, 20, 40, 40]], class_id=[0, 1] + ) + # BOTTOM_RIGHT: each crop is pasted at its (x2, y2) corner. + # box1 pastes band A (value 10) at rows 20-39, cols 20-39 — exactly + # where box2 will crop. + annotator = CropAnnotator( + position=Position.BOTTOM_RIGHT, + scale_factor=1.0, + ) + + # Act + result = annotator.annotate(scene=scene.copy(), detections=detections) + + # Assert: box2's crop is pasted at rows 40-59, cols 40-59. + # Interior (rows 42-57, cols 42-57) avoids the 2-pixel default border + # and must equal 200 — the original band B value. Without the fix, + # box2 samples 10 from the painted scene instead of 200 from original. + interior = result[42:58, 42:58] + assert np.all(interior == 200), ( + f"box2 crop paste region should contain original pixel 200, " + f"got {np.unique(interior).tolist()!r}. " + "Regression: source_scene must be scene.copy(), not an alias." + ) + class TestIconAnnotator: """Tests for IconAnnotator class""" diff --git a/tests/dataset/formats/test_pascal_voc.py b/tests/dataset/formats/test_pascal_voc.py index 39355256..f74194c2 100644 --- a/tests/dataset/formats/test_pascal_voc.py +++ b/tests/dataset/formats/test_pascal_voc.py @@ -6,12 +6,14 @@ 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 @@ -300,3 +302,60 @@ class TestLoadPascalVocDeterministicClasses: 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() diff --git a/tests/dataset/test_progress.py b/tests/dataset/test_progress.py index 54d9d284..d58fc1d0 100644 --- a/tests/dataset/test_progress.py +++ b/tests/dataset/test_progress.py @@ -173,7 +173,6 @@ def pascal_voc_dataset(pascal_voc_dir: tuple[str, str]) -> DetectionDataset: _YOLO_TQDM = "supervision.dataset.formats.yolo.tqdm" _COCO_TQDM = "supervision.dataset.formats.coco.tqdm" _PASCAL_TQDM = "supervision.dataset.formats.pascal_voc.tqdm" -_CORE_TQDM = "supervision.dataset.core.tqdm" _UTILS_TQDM = "supervision.dataset.utils.tqdm" @@ -318,7 +317,7 @@ class TestPascalVocProgress: """Pascal VOC save shows progress bar when show_progress=True.""" out = tmp_path / "output" with ( - patch(_CORE_TQDM, wraps=_real_tqdm) as mock_tqdm, + patch(_PASCAL_TQDM, wraps=_real_tqdm) as mock_tqdm, patch(_UTILS_TQDM, wraps=_real_tqdm), ): pascal_voc_dataset.as_pascal_voc( @@ -328,7 +327,7 @@ class TestPascalVocProgress: ) assert mock_tqdm.call_args[1]["disable"] is False - @patch(_CORE_TQDM, wraps=_real_tqdm) + @patch(_PASCAL_TQDM, wraps=_real_tqdm) def test_as_pascal_voc_no_progress_by_default( self, mock_tqdm: object, pascal_voc_dataset: DetectionDataset, tmp_path: Path ): diff --git a/tests/dataset/test_utils.py b/tests/dataset/test_utils.py index 79299d51..fa8cd919 100644 --- a/tests/dataset/test_utils.py +++ b/tests/dataset/test_utils.py @@ -7,8 +7,8 @@ import pytest from supervision import Detections from supervision.dataset.utils import ( - _check_no_basename_collisions, build_class_index_mapping, + check_no_basename_collisions, map_detections_class_id, merge_class_lists, train_test_split, @@ -268,26 +268,10 @@ class TestTrainTestSplitRngIsolation: class TestCheckNoBasenameCollisions: """Regression tests for export basename collision detection (DAT-04).""" - def test_empty_list_does_not_raise(self) -> None: - """Empty image_paths must not raise.""" - _check_no_basename_collisions( - image_paths=[], - key=lambda image_path: Path(image_path).name, - output_kind="image", - ) - - def test_single_path_does_not_raise(self) -> None: - """Single image path cannot collide; must not raise.""" - _check_no_basename_collisions( - image_paths=["a/img.jpg"], - key=lambda image_path: Path(image_path).name, - output_kind="image", - ) - 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( + check_no_basename_collisions( image_paths=["a/img.jpg", "b/img.jpg"], key=lambda image_path: Path(image_path).name, output_kind="image", @@ -295,8 +279,24 @@ class TestCheckNoBasenameCollisions: def test_passes_on_unique_output_names(self) -> None: """Distinct output names must not raise.""" - _check_no_basename_collisions( + 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", + ) diff --git a/tests/detection/test_from_adapters.py b/tests/detection/test_from_adapters.py index 8b34548a..f70ee5c9 100644 --- a/tests/detection/test_from_adapters.py +++ b/tests/detection/test_from_adapters.py @@ -734,3 +734,8 @@ class TestFromLMMEndToEnd: ) assert len(det) == 0 + + +def test_lmm_values_are_subset_of_vlm_values() -> None: + """Every LMM value exists in VLM — required for VLM(lmm.value) to succeed.""" + assert {m.value for m in LMM} <= {m.value for m in VLM} diff --git a/tests/metrics/test_detection.py b/tests/metrics/test_detection.py index 71b64a43..8e7e50df 100644 --- a/tests/metrics/test_detection.py +++ b/tests/metrics/test_detection.py @@ -1700,25 +1700,26 @@ class TestMeanAveragePrecisionBackgroundFalsePositives: ) # Assert - assert round(float(result.map50), 2) == 0.81 + assert result.map50 == pytest.approx(0.81, abs=0.01) - def test_all_background_images_return_zero_not_nan(self) -> None: - """Dataset with only background images must return map50=0, not NaN.""" - # Arrange - background_pred = np.array([[0.0, 0.0, 10.0, 10.0, 0, 0.9]], dtype=np.float32) - background_tgt = np.zeros((0, 5), dtype=np.float32) + def test_all_background_predictions_return_zero_not_nan(self) -> None: + """All-background dataset with predictions must yield 0.0 mAP, not NaN.""" + # Arrange — no GT objects anywhere; model still fires predictions + background_target = np.zeros((0, 5), dtype=np.float32) + background_predictions = np.array( + [[0.0, 0.0, 10.0, 10.0, 0, 0.9]], dtype=np.float32 + ) # Act result = MeanAveragePrecision.from_tensors( - predictions=[background_pred], - targets=[background_tgt], + predictions=[background_predictions], + targets=[background_target], ) - # Assert - assert not np.isnan(result.map50), ( - "map50 must not be NaN for all-background dataset" - ) + # Assert — must be finite 0.0, not NaN (regression for all-background datasets) assert result.map50 == pytest.approx(0.0) + assert result.map75 == pytest.approx(0.0) + assert result.map50_95 == pytest.approx(0.0) class TestSplitDetectionsByOutcome: