From 60d748e57d7ee1e8913fe210d6bdb40198d8589b Mon Sep 17 00:00:00 2001 From: Lee Clement Date: Fri, 17 Jul 2026 14:00:24 -0230 Subject: [PATCH] fix: read COCO export image sizes from headers instead of decoding pixels (#2442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com> --- docs/changelog.md | 1 + src/supervision/dataset/formats/coco.py | 27 +++++++++-- tests/dataset/formats/test_coco.py | 63 +++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 232fa9ee..8c333793 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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. diff --git a/src/supervision/dataset/formats/coco.py b/src/supervision/dataset/formats/coco.py index 95d5f681..231e6f23 100644 --- a/src/supervision/dataset/formats/coco.py +++ b/src/supervision/dataset/formats/coco.py @@ -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, diff --git a/tests/dataset/formats/test_coco.py b/tests/dataset/formats/test_coco.py index 041b4549..52bd74d6 100644 --- a/tests/dataset/formats/test_coco.py +++ b/tests/dataset/formats/test_coco.py @@ -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) ---