From f2efc328f7b39c6ce5f59dfa7bd7f854df32fd48 Mon Sep 17 00:00:00 2001 From: Jirka Borovec <6035284+Borda@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:30:24 +0200 Subject: [PATCH] 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 --- docs/changelog.md | 1 + src/supervision/dataset/formats/pascal_voc.py | 20 ++++- tests/dataset/formats/test_pascal_voc.py | 78 +++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 0d50f774..541d5bb0 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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)). diff --git a/src/supervision/dataset/formats/pascal_voc.py b/src/supervision/dataset/formats/pascal_voc.py index 7ec06f91..26afe95c 100644 --- a/src/supervision/dataset/formats/pascal_voc.py +++ b/src/supervision/dataset/formats/pascal_voc.py @@ -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-```` (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, ) diff --git a/tests/dataset/formats/test_pascal_voc.py b/tests/dataset/formats/test_pascal_voc.py index 4f6c9a17..363354fc 100644 --- a/tests/dataset/formats/test_pascal_voc.py +++ b/tests/dataset/formats/test_pascal_voc.py @@ -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("") + + 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