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:
parent
475d551908
commit
f2efc328f7
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue