fix: resolve remaining High findings from deep codebase review (#2389)
- Fixed crop annotation so overlapping detections sample from the original scene - Fixed dataset exports to reject basename collisions, including case-insensitive collisions - Fixed LMM connector mapping to support mirror enum aliases without a hand-maintained dispatch table - Updated benchmark documentation to install the released inference package with metrics support --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
parent
78aec073c4
commit
bd0f44fcfd
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue