fix(dataset): 3D empty mask for VOC background (#2469)

- `detections_from_xml_obj` now builds `np.empty((0, H, W))` for a background image under `force_masks=True` instead of letting `np.array([])` collapse to shape `(0,)`, which failed `Detections` mask validation
- document the forced `class_id` `dtype=int` with an inline comment and state the integer-dtype guarantee in the `detections_from_xml_obj` docstring Returns section
- add background-image coverage: force_masks empty 3D mask, all-background dataset, background-first ordering, and save-then-load round-trip
- add changelog entry for the `force_masks=True` background-image mask fix

---

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Jirka Borovec 2026-08-03 18:30:24 +02:00 committed by GitHub
parent 475d551908
commit f2efc328f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 97 additions and 2 deletions

View File

@ -22,6 +22,7 @@ date_modified: 2026-07-27
### Fixed
- `DetectionDataset.from_pascal_voc` no longer raises `ValueError` on background images. An annotation file with no `object` elements produced an empty `class_id` array of dtype `float64`, which failed `DetectionDataset` validation, so any Pascal VOC dataset containing an unannotated image could not be loaded.
- `DetectionDataset.from_pascal_voc` with `force_masks=True` no longer raises `ValueError` on background images. An annotation file with no `object` elements produced an empty mask of shape `(0,)` instead of the required `(0, H, W)`, which failed `Detections` validation.
- Reopening an existing `sv.CSVSink` or `sv.JSONSink` now starts a fresh output session: CSV files receive a new header and field schema, while JSON files no longer retain rows from the previous session.
- `sv.Detections.from_vlm` with `sv.VLM.GOOGLE_GEMINI_2_0`, `sv.VLM.GOOGLE_GEMINI_2_5`, and `sv.VLM.GOOGLE_GEMINI_3_5` now salvages the valid entries from a partially malformed JSON array (e.g. a single object with a syntax error) instead of discarding the whole response.
- Geometry-aware IoU dispatch now powers the deprecated `merge_inner_detections_objects`, so overlapping axis-aligned envelopes no longer merge oriented boxes whose true OBB IoU is below the threshold ([#2374](https://github.com/roboflow/supervision/pull/2374)).

View File

@ -294,7 +294,9 @@ def detections_from_xml_obj(
Returns:
A tuple containing a Detections object and an
updated list of class names, extended with the class names
from the XML object.
from the XML object. The Detections ``class_id`` is always an
integer-dtype array, including the zero-``<object>`` (background)
case where it is empty.
"""
xyxy: list[list[int]] = []
class_names: list[str] = []
@ -346,14 +348,28 @@ def detections_from_xml_obj(
for k in sorted(set(class_names)):
if k not in extended_classes:
extended_classes.append(k)
# dtype=int forced: on a background image class_names is empty, so
# np.array([]) would default to float64 and fail Detections' integer
# class_id validation. Redundant on the non-empty path (ints already).
class_id = np.array(
[extended_classes.index(class_name) for class_name in class_names],
dtype=int,
)
mask_arr: npt.NDArray[np.bool_] | None
if not with_masks:
mask_arr = None
elif masks:
mask_arr = np.array(masks, dtype=bool)
else:
# Background image with force_masks=True: masks is empty, and
# np.array([]) would collapse to shape (0,). Detections requires a 3D
# (0, H, W) mask, so build the empty stack explicitly.
mask_arr = np.empty((0, resolution_wh[1], resolution_wh[0]), dtype=bool)
annotation = Detections(
xyxy=xyxy_arr,
mask=np.array(masks, dtype=bool) if with_masks else None,
mask=mask_arr,
class_id=class_id,
)

View File

@ -342,6 +342,57 @@ class TestLoadPascalVocBackgroundImages:
counts = {Path(path).stem: len(d) for path, d in dataset.annotations.items()}
assert counts == {"annotated": 1, "background": 0}
def test_all_background_dataset_has_no_classes_and_empty_detections(
self, tmp_path: Path
) -> None:
"""A dataset where every image is background loads with no classes."""
images_dir = tmp_path / "images"
images_dir.mkdir()
annotations_dir = tmp_path / "annotations"
annotations_dir.mkdir()
_write_voc_sample(images_dir, annotations_dir, "bg_a", [])
_write_voc_sample(images_dir, annotations_dir, "bg_b", [])
dataset = DetectionDataset.from_pascal_voc(
images_directory_path=str(images_dir),
annotations_directory_path=str(annotations_dir),
)
assert dataset.classes == []
counts = {Path(path).stem: len(d) for path, d in dataset.annotations.items()}
assert counts == {"bg_a": 0, "bg_b": 0}
def test_background_image_sorted_first_still_integer_class_id(
self, tmp_path: Path
) -> None:
"""A background image parsed before any annotated one keeps integer ids."""
images_dir = tmp_path / "images"
images_dir.mkdir()
annotations_dir = tmp_path / "annotations"
annotations_dir.mkdir()
_write_voc_sample(images_dir, annotations_dir, "aaa_background", [])
_write_voc_sample(images_dir, annotations_dir, "zzz_annotated", ["cat"])
_, _, annotations = load_pascal_voc_annotations(
images_directory_path=str(images_dir),
annotations_directory_path=str(annotations_dir),
)
by_stem = {Path(path).stem: d for path, d in annotations.items()}
assert np.issubdtype(by_stem["aaa_background"].class_id.dtype, np.integer)
assert by_stem["aaa_background"].class_id.size == 0
def test_force_masks_background_image_gets_empty_3d_mask(self) -> None:
"""force_masks=True on a background XML yields an empty (0, H, W) mask."""
root = ElementTree.fromstring("<annotation></annotation>")
detections, _ = detections_from_xml_obj(
root, classes=[], resolution_wh=(30, 20), force_masks=True
)
assert detections.mask is not None
assert detections.mask.shape == (0, 20, 30)
class TestSavePascalVocAnnotations:
"""save_pascal_voc_annotations: filesystem output contract."""
@ -398,3 +449,30 @@ class TestSavePascalVocAnnotations:
save_pascal_voc_annotations(dataset, str(out_dir), show_progress=True)
assert out_dir.is_dir()
def test_background_image_survives_save_then_load_round_trip(
self, tmp_path: Path
) -> None:
"""A background image written to VOC reloads with integer, empty class_id."""
from supervision.detection.core import Detections
images_dir = tmp_path / "images"
images_dir.mkdir()
img_path = images_dir / "background.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))
_, _, annotations = load_pascal_voc_annotations(
images_directory_path=str(images_dir),
annotations_directory_path=str(out_dir),
)
class_id = next(iter(annotations.values())).class_id
assert np.issubdtype(class_id.dtype, np.integer)
assert class_id.size == 0