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:
Jirka Borovec 2026-07-03 22:58:07 +02:00 committed by GitHub
parent 78aec073c4
commit bd0f44fcfd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 280 additions and 125 deletions

View File

@ -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

View File

@ -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(

View File

@ -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",

View File

@ -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",

View File

@ -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)

View File

@ -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",

View File

@ -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",

View File

@ -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(

View File

@ -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([])

View File

@ -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]

View File

@ -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"""

View File

@ -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()

View File

@ -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
):

View File

@ -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",
)

View File

@ -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}

View File

@ -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: