fix: read COCO export image sizes from headers instead of decoding pixels (#2442)
save_coco_annotations iterated the dataset, cv2-decoding every image only to read its shape — even for labels-only exports. Sizes now come from the in-memory array when present, else a lazy PIL header read, the same optimization from_yolo uses (#1636). Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
This commit is contained in:
parent
f7b63f149a
commit
60d748e57d
|
|
@ -18,6 +18,7 @@ date_modified: 2026-07-17
|
|||
- `sv.mask_non_max_merge` now computes exact mask overlap at the original mask resolution and ignores the deprecated `mask_dimension` parameter. Code that relied on downscaled mask overlap should recalibrate thresholds; passing `mask_dimension` positionally now emits a deprecation warning, and the parameter is scheduled for removal in `0.33.0` ([#2400](https://github.com/roboflow/supervision/pull/2400)).
|
||||
|
||||
### Fixed
|
||||
- `save_coco_annotations` (and therefore `DetectionDataset.as_coco`) now reads image sizes from file headers via lazy PIL instead of cv2-decoding every image, so labels-only COCO exports no longer decode any pixel data ([#2442](https://github.com/roboflow/supervision/pull/2442)).
|
||||
- Fixed [#2437](https://github.com/roboflow/supervision/pull/2437): `sv.F1Score` no longer emits a spurious `RuntimeWarning` when true positives, false positives, and false negatives are all zero (denominator 0); the score remains `0.0`.
|
||||
- The cv2-free fallback now preserves OpenCV-compatible edge and keyword semantics for image borders, resizing, drawing, and small polygon masks, keeping ordinary production consumers usable without cv2.
|
||||
- The cv2-free fallback's `copyMakeBorder` now fills only channel 0 for a scalar border `value` on multichannel images, matching OpenCV's `Scalar(v)` semantics instead of broadcasting the value to every channel.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, cast
|
|||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from PIL import Image
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
from supervision.config import AREA_DATA_FIELD, COCO_RAW_SEGMENTATION
|
||||
|
|
@ -577,6 +578,21 @@ def _with_seg_mask(annotation: dict[str, Any]) -> bool:
|
|||
return bool(annotation.get("segmentation"))
|
||||
|
||||
|
||||
def _image_resolution_hw(dataset: DetectionDataset, image_path: str) -> tuple[int, int]:
|
||||
"""Return ``(height, width)`` for ``image_path`` without decoding pixels.
|
||||
|
||||
Uses the in-memory array when the dataset holds one; otherwise reads the
|
||||
size from the file header via lazy ``PIL.Image.open``, which parses only
|
||||
image metadata — the same optimization the YOLO loader uses (#1636).
|
||||
"""
|
||||
if dataset._images_in_memory:
|
||||
image_height, image_width = dataset._images_in_memory[image_path].shape[:2]
|
||||
return image_height, image_width
|
||||
with Image.open(image_path) as image:
|
||||
image_width, image_height = image.size
|
||||
return image_height, image_width
|
||||
|
||||
|
||||
def save_coco_annotations(
|
||||
dataset: DetectionDataset,
|
||||
annotation_path: str,
|
||||
|
|
@ -667,13 +683,16 @@ def save_coco_annotations(
|
|||
coco_categories = classes_to_coco_categories(classes=dataset.classes)
|
||||
|
||||
image_id, annotation_id = starting_image_id, starting_annotation_id
|
||||
for image_path, image, annotation in tqdm(
|
||||
dataset,
|
||||
total=len(dataset),
|
||||
for image_path in tqdm(
|
||||
dataset.image_paths,
|
||||
desc="Saving COCO annotations",
|
||||
disable=not show_progress,
|
||||
):
|
||||
image_height, image_width, _ = image.shape
|
||||
annotation = dataset.annotations[image_path]
|
||||
# Only the image size is needed here, so read it from the file header
|
||||
# (or the in-memory array) instead of iterating the dataset, which
|
||||
# would fully decode every image just to inspect its shape.
|
||||
image_height, image_width = _image_resolution_hw(dataset, image_path)
|
||||
image_name = f"{Path(image_path).stem}{Path(image_path).suffix}"
|
||||
coco_image = {
|
||||
"id": image_id,
|
||||
|
|
|
|||
|
|
@ -2102,6 +2102,69 @@ def test_save_coco_annotations_zero_annotation_images(tmp_path):
|
|||
assert next_annotation_id == 1
|
||||
|
||||
|
||||
class TestSaveCocoAnnotationsHeaderSizeReads:
|
||||
"""Annotation export must read image sizes without decoding pixels."""
|
||||
|
||||
def test_labels_only_export_does_not_decode_images(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A labels-only export never loads image pixel data."""
|
||||
|
||||
def _fail_on_decode(self: DetectionDataset, image_path: str) -> None:
|
||||
"""Fail the test if the export tries to load an image."""
|
||||
raise AssertionError(f"export decoded image pixels: {image_path}")
|
||||
|
||||
dataset = _tiny_detection_dataset(
|
||||
tmp_path, "img", num_images=2, dets_per_image=1
|
||||
)
|
||||
monkeypatch.setattr(DetectionDataset, "_get_image", _fail_on_decode)
|
||||
annotation_path = tmp_path / "annotations.json"
|
||||
|
||||
save_coco_annotations(dataset=dataset, annotation_path=str(annotation_path))
|
||||
|
||||
image_ids, annotation_ids = _read_ids(annotation_path)
|
||||
assert image_ids == [1, 2]
|
||||
assert annotation_ids == [1, 2]
|
||||
|
||||
def test_header_sizes_match_image_dimensions(self, tmp_path: Path) -> None:
|
||||
"""Sizes read from headers match the real (non-square) image shape."""
|
||||
image_path = str(tmp_path / "img.jpg")
|
||||
assert cv2.imwrite(image_path, np.zeros((8, 12, 3), dtype=np.uint8))
|
||||
dataset = DetectionDataset(
|
||||
classes=["object"],
|
||||
images=[image_path],
|
||||
annotations={image_path: Detections.empty()},
|
||||
)
|
||||
annotation_path = tmp_path / "annotations.json"
|
||||
|
||||
save_coco_annotations(dataset=dataset, annotation_path=str(annotation_path))
|
||||
|
||||
with open(annotation_path) as f:
|
||||
coco = json.load(f)
|
||||
assert coco["images"][0]["height"] == 8
|
||||
assert coco["images"][0]["width"] == 12
|
||||
|
||||
def test_in_memory_images_use_array_shape(self, tmp_path: Path) -> None:
|
||||
"""Datasets built from in-memory arrays take sizes from the arrays."""
|
||||
from supervision.utils.internal import SupervisionWarnings
|
||||
|
||||
image_key = "in_memory.jpg"
|
||||
with pytest.warns(SupervisionWarnings):
|
||||
dataset = DetectionDataset(
|
||||
classes=["object"],
|
||||
images={image_key: np.zeros((6, 9, 3), dtype=np.uint8)},
|
||||
annotations={image_key: Detections.empty()},
|
||||
)
|
||||
annotation_path = tmp_path / "annotations.json"
|
||||
|
||||
save_coco_annotations(dataset=dataset, annotation_path=str(annotation_path))
|
||||
|
||||
with open(annotation_path) as f:
|
||||
coco = json.load(f)
|
||||
assert coco["images"][0]["height"] == 6
|
||||
assert coco["images"][0]["width"] == 9
|
||||
|
||||
|
||||
# --- Regression: legacy 0-indexed COCO files still load correctly (#1181) ---
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue