Files
38be1be4b2 Fix lossy COCO round-trip: preserve area and iscrowd in as_coco (#2185)
* fix: preserve area and iscrowd from detection data in COCO export
* fix: use np.asarray().item() to satisfy mypy in iscrowd/area extraction
* test: shorten test name to fix ruff E501 line-length violation
* test: verify data["area"] overrides bbox area when mask is present
* test: stricter iscrowd type check (bool subclass fix)
* test: stricter iscrowd type check in preserves_iscrowd_from_data
* test: stricter iscrowd type check in iscrowd_is_int_when_mask_provided
* fix: prefer data["iscrowd"] over geometry when mask is present

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Borda <6035284+Borda@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-03-30 20:17:42 +02:00

1162 lines
38 KiB
Python

from __future__ import annotations
import json
from contextlib import ExitStack as DoesNotRaise
import numpy as np
import pytest
from supervision import Detections
from supervision.dataset.formats.coco import (
build_coco_class_index_mapping,
classes_to_coco_categories,
coco_annotations_to_detections,
coco_categories_to_classes,
detections_to_coco_annotations,
group_coco_annotations_by_image_id,
load_coco_annotations,
)
def mock_coco_annotation(
annotation_id: int = 0,
image_id: int = 0,
category_id: int = 0,
bbox: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
area: float = 0.0,
segmentation: list[list] | dict | None = None,
iscrowd: bool = False,
) -> dict:
if not segmentation:
segmentation = []
return {
"id": annotation_id,
"image_id": image_id,
"category_id": category_id,
"bbox": list(bbox),
"area": area,
"segmentation": segmentation,
"iscrowd": int(iscrowd),
}
@pytest.fixture
def coco_data_with_and_without_segmentation() -> dict[str, object]:
return {
"categories": [{"id": 1, "name": "object", "supercategory": "none"}],
"images": [
{"id": 1, "file_name": "with_segmentation.jpg", "width": 5, "height": 5},
{
"id": 2,
"file_name": "with_polygon_segmentation.jpg",
"width": 5,
"height": 5,
},
{"id": 3, "file_name": "without_segmentation.jpg", "width": 5, "height": 5},
{"id": 4, "file_name": "without_annotations.jpg", "width": 5, "height": 5},
],
"annotations": [
{
"id": 1,
"image_id": 1,
"category_id": 1,
"bbox": [0, 0, 5, 5],
"area": 25,
"segmentation": [[0, 0, 2, 0, 2, 2, 4, 2, 4, 4, 0, 4]],
"iscrowd": 0,
},
{
"id": 2,
"image_id": 1,
"category_id": 1,
"bbox": [3, 0, 2, 2],
"area": 4,
"segmentation": {"size": [5, 5], "counts": [15, 2, 3, 2, 3]},
"iscrowd": 1,
},
{
"id": 3,
"image_id": 2,
"category_id": 1,
"bbox": [0, 0, 2, 2],
"area": 4,
"segmentation": [[0, 0, 1, 0, 1, 1, 0, 1]],
"iscrowd": 0,
},
{
"id": 4,
"image_id": 3,
"category_id": 1,
"bbox": [0, 0, 2, 2],
"area": 4,
"iscrowd": 0,
},
],
}
@pytest.fixture
def coco_data_with_unannotated_image() -> dict[str, object]:
return {
"categories": [{"id": 1, "name": "object", "supercategory": "none"}],
"images": [
{"id": 1, "file_name": "has_segmentation.jpg", "width": 5, "height": 5},
{"id": 2, "file_name": "no_annotations.jpg", "width": 5, "height": 5},
],
"annotations": [
{
"id": 1,
"image_id": 1,
"category_id": 1,
"bbox": [0, 0, 2, 2],
"area": 4,
"segmentation": [[0, 0, 1, 0, 1, 1, 0, 1]],
"iscrowd": 0,
}
],
}
@pytest.mark.parametrize(
("coco_categories", "expected_result", "exception"),
[
([], [], DoesNotRaise()), # empty coco categories
(
[{"id": 0, "name": "fashion-assistant", "supercategory": "none"}],
["fashion-assistant"],
DoesNotRaise(),
), # single coco category with supercategory == "none"
(
[
{"id": 0, "name": "fashion-assistant", "supercategory": "none"},
{"id": 1, "name": "baseball cap", "supercategory": "fashion-assistant"},
],
["fashion-assistant", "baseball cap"],
DoesNotRaise(),
), # two coco categories; one with supercategory == "none" and
# one with supercategory != "none"
(
[
{"id": 0, "name": "fashion-assistant", "supercategory": "none"},
{"id": 1, "name": "baseball cap", "supercategory": "fashion-assistant"},
{"id": 2, "name": "hoodie", "supercategory": "fashion-assistant"},
],
["fashion-assistant", "baseball cap", "hoodie"],
DoesNotRaise(),
), # three coco categories; one with supercategory == "none" and
# two with supercategory != "none"
(
[
{"id": 0, "name": "fashion-assistant", "supercategory": "none"},
{"id": 2, "name": "hoodie", "supercategory": "fashion-assistant"},
{"id": 1, "name": "baseball cap", "supercategory": "fashion-assistant"},
],
["fashion-assistant", "baseball cap", "hoodie"],
DoesNotRaise(),
), # three coco categories; one with supercategory == "none" and
# two with supercategory != "none" (different order)
],
)
def test_coco_categories_to_classes(
coco_categories: list[dict], expected_result: list[str], exception: Exception
) -> None:
with exception:
result = coco_categories_to_classes(coco_categories=coco_categories)
assert result == expected_result
@pytest.mark.parametrize(
("classes", "exception"),
[
([], DoesNotRaise()), # empty classes
(["baseball cap"], DoesNotRaise()), # single class
(["baseball cap", "hoodie"], DoesNotRaise()), # two classes
],
)
def test_classes_to_coco_categories_and_back_to_classes(
classes: list[str], exception: Exception
) -> None:
with exception:
coco_categories = classes_to_coco_categories(classes=classes)
result = coco_categories_to_classes(coco_categories=coco_categories)
assert result == classes
@pytest.mark.parametrize(
("coco_annotations", "expected_result", "exception"),
[
([], {}, DoesNotRaise()), # empty coco annotations
(
[mock_coco_annotation(annotation_id=0, image_id=0, category_id=0)],
{0: [mock_coco_annotation(annotation_id=0, image_id=0, category_id=0)]},
DoesNotRaise(),
), # single coco annotation
(
[
mock_coco_annotation(annotation_id=0, image_id=0, category_id=0),
mock_coco_annotation(annotation_id=1, image_id=1, category_id=0),
],
{
0: [mock_coco_annotation(annotation_id=0, image_id=0, category_id=0)],
1: [mock_coco_annotation(annotation_id=1, image_id=1, category_id=0)],
},
DoesNotRaise(),
), # two coco annotations
(
[
mock_coco_annotation(annotation_id=0, image_id=0, category_id=0),
mock_coco_annotation(annotation_id=1, image_id=1, category_id=1),
mock_coco_annotation(annotation_id=2, image_id=1, category_id=2),
mock_coco_annotation(annotation_id=3, image_id=2, category_id=3),
mock_coco_annotation(annotation_id=4, image_id=3, category_id=1),
mock_coco_annotation(annotation_id=5, image_id=3, category_id=2),
mock_coco_annotation(annotation_id=5, image_id=3, category_id=3),
],
{
0: [
mock_coco_annotation(annotation_id=0, image_id=0, category_id=0),
],
1: [
mock_coco_annotation(annotation_id=1, image_id=1, category_id=1),
mock_coco_annotation(annotation_id=2, image_id=1, category_id=2),
],
2: [
mock_coco_annotation(annotation_id=3, image_id=2, category_id=3),
],
3: [
mock_coco_annotation(annotation_id=4, image_id=3, category_id=1),
mock_coco_annotation(annotation_id=5, image_id=3, category_id=2),
mock_coco_annotation(annotation_id=5, image_id=3, category_id=3),
],
},
DoesNotRaise(),
), # two coco annotations
],
)
def test_group_coco_annotations_by_image_id(
coco_annotations: list[dict], expected_result: dict, exception: Exception
) -> None:
with exception:
result = group_coco_annotations_by_image_id(coco_annotations=coco_annotations)
assert result == expected_result
@pytest.mark.parametrize(
(
"image_annotations",
"resolution_wh",
"with_masks",
"use_iscrowd",
"expected_result",
"exception",
),
[
(
[],
(1000, 1000),
False,
False,
Detections.empty(),
DoesNotRaise(),
), # empty image annotations
(
[],
(1000, 1000),
False,
True,
Detections.empty(),
DoesNotRaise(),
), # empty image annotations
(
[
mock_coco_annotation(
category_id=0, bbox=(0, 0, 100, 100), area=100 * 100
)
],
(1000, 1000),
False,
False,
Detections(
xyxy=np.array([[0, 0, 100, 100]], dtype=np.float32),
class_id=np.array([0], dtype=int),
),
DoesNotRaise(),
), # single image annotations
(
[
mock_coco_annotation(
category_id=0, bbox=(0, 0, 100, 100), area=100 * 100
)
],
(1000, 1000),
False,
True,
Detections(
xyxy=np.array([[0, 0, 100, 100]], dtype=np.float32),
class_id=np.array([0], dtype=int),
data={
"iscrowd": np.array([0], dtype=int),
"area": np.array([100 * 100]),
},
),
DoesNotRaise(),
),
(
[
mock_coco_annotation(
category_id=0, bbox=(0, 0, 100, 100), area=100 * 100
),
mock_coco_annotation(
category_id=0, bbox=(100, 100, 100, 100), area=100 * 100
),
],
(1000, 1000),
False,
False,
Detections(
xyxy=np.array(
[[0, 0, 100, 100], [100, 100, 200, 200]], dtype=np.float32
),
class_id=np.array([0, 0], dtype=int),
),
DoesNotRaise(),
), # two image annotations
(
[
mock_coco_annotation(
category_id=0, bbox=(0, 0, 100, 100), area=100 * 100
),
mock_coco_annotation(
category_id=0, bbox=(100, 100, 100, 100), area=100 * 100
),
],
(1000, 1000),
False,
True,
Detections(
xyxy=np.array(
[[0, 0, 100, 100], [100, 100, 200, 200]], dtype=np.float32
),
class_id=np.array([0, 0], dtype=int),
data={
"iscrowd": np.array([0, 0], dtype=int),
"area": np.array([100 * 100, 100 * 100]),
},
),
DoesNotRaise(),
),
(
[
mock_coco_annotation(
category_id=0,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation=[[0, 0, 2, 0, 2, 2, 4, 2, 4, 4, 0, 4]],
)
],
(5, 5),
True,
False,
Detections(
xyxy=np.array([[0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array(
[
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
]
],
dtype=bool,
),
),
DoesNotRaise(),
), # single image annotations with mask as polygon
(
[
mock_coco_annotation(
category_id=0,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation=[
[0, 0, 1, 0, 1, 1, 0, 1],
[3, 3, 4, 3, 4, 4, 3, 4],
],
)
],
(5, 5),
True,
False,
Detections(
xyxy=np.array([[0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array(
[
[
[1, 1, 0, 0, 0],
[1, 1, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 1, 1],
[0, 0, 0, 1, 1],
]
],
dtype=bool,
),
),
DoesNotRaise(),
), # single image annotation with disjoint polygon segments
(
[
mock_coco_annotation(
category_id=0,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation=[[0, 0, 2, 0, 2, 2, 4, 2, 4, 4, 0, 4]],
)
],
(5, 5),
True,
True,
Detections(
xyxy=np.array([[0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array(
[
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
]
],
dtype=bool,
),
data={"iscrowd": np.array([0], dtype=int), "area": np.array([25])},
),
DoesNotRaise(),
),
(
[
mock_coco_annotation(
category_id=0,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation={
"size": [5, 5],
"counts": [0, 15, 2, 3, 2, 3],
},
iscrowd=True,
)
],
(5, 5),
True,
False,
Detections(
xyxy=np.array([[0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array(
[
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
]
],
dtype=bool,
),
),
DoesNotRaise(),
), # single image annotations with mask, RLE segmentation mask
(
[
mock_coco_annotation(
category_id=0,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation={
"size": [5, 5],
"counts": [0, 15, 2, 3, 2, 3],
},
iscrowd=True,
)
],
(5, 5),
True,
True,
Detections(
xyxy=np.array([[0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array(
[
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
]
],
dtype=bool,
),
data={"iscrowd": np.array([1], dtype=int), "area": np.array([25])},
),
DoesNotRaise(),
),
(
[
mock_coco_annotation(
category_id=0,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation=[[0, 0, 2, 0, 2, 2, 4, 2, 4, 4, 0, 4]],
),
mock_coco_annotation(
category_id=0,
bbox=(3, 0, 2, 2),
area=2 * 2,
segmentation={
"size": [5, 5],
"counts": [15, 2, 3, 2, 3],
},
iscrowd=True,
),
],
(5, 5),
True,
False,
Detections(
xyxy=np.array([[0, 0, 5, 5], [3, 0, 5, 2]], dtype=np.float32),
class_id=np.array([0, 0], dtype=int),
mask=np.array(
[
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
],
[
[0, 0, 0, 1, 1],
[0, 0, 0, 1, 1],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
],
],
dtype=bool,
),
),
DoesNotRaise(),
), # two image annotations with mask, one mask as polygon and second as RLE
(
[
mock_coco_annotation(
category_id=0,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation=[[0, 0, 2, 0, 2, 2, 4, 2, 4, 4, 0, 4]],
),
mock_coco_annotation(
category_id=0,
bbox=(3, 0, 2, 2),
area=2 * 2,
segmentation={
"size": [5, 5],
"counts": [15, 2, 3, 2, 3],
},
iscrowd=True,
),
],
(5, 5),
True,
True,
Detections(
xyxy=np.array([[0, 0, 5, 5], [3, 0, 5, 2]], dtype=np.float32),
class_id=np.array([0, 0], dtype=int),
mask=np.array(
[
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
],
[
[0, 0, 0, 1, 1],
[0, 0, 0, 1, 1],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
],
],
dtype=bool,
),
data={
"iscrowd": np.array([0, 1], dtype=int),
"area": np.array([25, 4]),
},
),
DoesNotRaise(),
), # two image annotations with mask, one mask as polygon with iscrowd,
# and second as RLE without iscrowd
(
[
mock_coco_annotation(
category_id=0,
bbox=(3, 0, 2, 2),
area=2 * 2,
segmentation={
"size": [5, 5],
"counts": [15, 2, 3, 2, 3],
},
iscrowd=True,
),
mock_coco_annotation(
category_id=1,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation=[[0, 0, 2, 0, 2, 2, 4, 2, 4, 4, 0, 4]],
),
],
(5, 5),
True,
False,
Detections(
xyxy=np.array([[3, 0, 5, 2], [0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0, 1], dtype=int),
mask=np.array(
[
[
[0, 0, 0, 1, 1],
[0, 0, 0, 1, 1],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
],
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
],
],
dtype=bool,
),
),
DoesNotRaise(),
), # two image annotations with mask, first mask as RLE and second as polygon
(
[
mock_coco_annotation(
category_id=0,
bbox=(3, 0, 2, 2),
area=2 * 2,
segmentation={
"size": [5, 5],
"counts": [15, 2, 3, 2, 3],
},
iscrowd=True,
),
mock_coco_annotation(
category_id=1,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation=[[0, 0, 2, 0, 2, 2, 4, 2, 4, 4, 0, 4]],
),
],
(5, 5),
True,
True,
Detections(
xyxy=np.array([[3, 0, 5, 2], [0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0, 1], dtype=int),
mask=np.array(
[
[
[0, 0, 0, 1, 1],
[0, 0, 0, 1, 1],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
],
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
],
],
dtype=bool,
),
data={
"iscrowd": np.array([1, 0], dtype=int),
"area": np.array([4, 25]),
},
),
DoesNotRaise(),
), # two image annotations with mask, first mask as RLE with is crowd,
# and second as polygon without iscrowd
],
)
def test_coco_annotations_to_detections(
image_annotations: list[dict],
resolution_wh: tuple[int, int],
with_masks: bool,
use_iscrowd: bool,
expected_result: Detections,
exception: Exception,
) -> None:
with exception:
result = coco_annotations_to_detections(
image_annotations=image_annotations,
resolution_wh=resolution_wh,
with_masks=with_masks,
use_iscrowd=use_iscrowd,
)
assert result == expected_result
@pytest.mark.parametrize(
("coco_categories", "target_classes", "expected_result", "exception"),
[
([], [], {}, DoesNotRaise()), # empty coco categories
(
[{"id": 0, "name": "fashion-assistant", "supercategory": "none"}],
["fashion-assistant"],
{0: 0},
DoesNotRaise(),
), # single coco category starting from 0
(
[{"id": 1, "name": "fashion-assistant", "supercategory": "none"}],
["fashion-assistant"],
{1: 0},
DoesNotRaise(),
), # single coco category starting from 1
(
[
{"id": 0, "name": "fashion-assistant", "supercategory": "none"},
{"id": 2, "name": "hoodie", "supercategory": "fashion-assistant"},
{"id": 1, "name": "baseball cap", "supercategory": "fashion-assistant"},
],
["fashion-assistant", "baseball cap", "hoodie"],
{0: 0, 1: 1, 2: 2},
DoesNotRaise(),
), # three coco categories
(
[
{"id": 2, "name": "hoodie", "supercategory": "fashion-assistant"},
{"id": 1, "name": "baseball cap", "supercategory": "fashion-assistant"},
],
["baseball cap", "hoodie"],
{2: 1, 1: 0},
DoesNotRaise(),
), # two coco categories
(
[
{"id": 3, "name": "hoodie", "supercategory": "fashion-assistant"},
{"id": 1, "name": "baseball cap", "supercategory": "fashion-assistant"},
],
["baseball cap", "hoodie"],
{3: 1, 1: 0},
DoesNotRaise(),
), # two coco categories with missing category
],
)
def test_build_coco_class_index_mapping(
coco_categories: list[dict],
target_classes: list[str],
expected_result: dict[int, int],
exception: Exception,
) -> None:
with exception:
result = build_coco_class_index_mapping(
coco_categories=coco_categories, target_classes=target_classes
)
assert result == expected_result
@pytest.mark.parametrize(
("detections", "image_id", "annotation_id", "expected_result", "exception"),
[
(
Detections(
xyxy=np.array([[0, 0, 100, 100]], dtype=np.float32),
class_id=np.array([0], dtype=int),
),
0,
0,
[
mock_coco_annotation(
category_id=0, bbox=(0, 0, 100, 100), area=100 * 100
)
],
DoesNotRaise(),
), # no segmentation mask
(
Detections(
xyxy=np.array([[0, 0, 4, 5]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array(
[
[
[1, 1, 1, 1, 0],
[1, 1, 1, 1, 0],
[1, 1, 1, 1, 0],
[1, 1, 1, 1, 0],
[1, 1, 1, 1, 0],
]
],
dtype=bool,
),
),
0,
0,
[
mock_coco_annotation(
category_id=0,
bbox=(0, 0, 4, 5),
area=4 * 5,
segmentation=[[0, 0, 0, 4, 3, 4, 3, 0]],
)
],
DoesNotRaise(),
), # segmentation mask in single component,no holes in mask,
# expects polygon mask
(
Detections(
xyxy=np.array([[0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array(
[
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[0, 0, 0, 1, 1],
[0, 0, 0, 1, 1],
]
],
dtype=bool,
),
),
0,
0,
[
mock_coco_annotation(
category_id=0,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation={
"size": [5, 5],
"counts": [0, 3, 2, 3, 2, 3, 5, 2, 3, 2],
},
iscrowd=True,
)
],
DoesNotRaise(),
), # segmentation mask with 2 components, no holes in mask, expects RLE mask
(
Detections(
xyxy=np.array([[0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array(
[
[
[0, 1, 1, 1, 1],
[0, 1, 1, 1, 1],
[1, 1, 0, 0, 1],
[1, 1, 0, 0, 1],
[1, 1, 1, 1, 1],
]
],
dtype=bool,
),
),
0,
0,
[
mock_coco_annotation(
category_id=0,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation={
"size": [5, 5],
"counts": [2, 10, 2, 3, 2, 6],
},
iscrowd=True,
)
],
DoesNotRaise(),
), # seg mask in single component, with holes in mask, expects RLE mask
],
)
def test_detections_to_coco_annotations(
detections: Detections,
image_id: int,
annotation_id: int,
expected_result: list[dict],
exception: Exception,
) -> None:
with exception:
result, _ = detections_to_coco_annotations(
detections=detections,
image_id=image_id,
annotation_id=annotation_id,
)
assert result == expected_result
def test_detections_to_coco_annotations_handles_empty_approximated_polygons() -> None:
detections = Detections(
xyxy=np.array([[0, 0, 4, 4]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array(
[
[
[1, 1, 1, 1, 0],
[1, 1, 1, 1, 0],
[1, 1, 1, 1, 0],
[1, 1, 1, 1, 0],
[1, 1, 1, 1, 0],
]
],
dtype=bool,
),
)
with pytest.warns(Warning, match="mask approximation returned no polygons"):
annotations, _ = detections_to_coco_annotations(
detections=detections,
image_id=0,
annotation_id=0,
max_image_area_percentage=0.01,
)
assert len(annotations) == 1
assert annotations[0]["segmentation"] == []
assert annotations[0]["iscrowd"] == 0
def test_detections_to_coco_annotations_preserves_area_from_data() -> None:
"""area stored in detections.data should be used instead of bbox area."""
detections = Detections(
xyxy=np.array([[10.0, 20.0, 110.0, 120.0]], dtype=np.float32),
class_id=np.array([0], dtype=int),
data={"iscrowd": np.array([0], dtype=int), "area": np.array([5000.0])},
)
annotations, _ = detections_to_coco_annotations(
detections=detections,
image_id=1,
annotation_id=1,
)
assert len(annotations) == 1
assert annotations[0]["area"] == 5000.0
assert annotations[0]["iscrowd"] == 0
assert type(annotations[0]["iscrowd"]) is int
def test_detections_to_coco_annotations_preserves_iscrowd_from_data_when_no_mask() -> (
None
):
"""iscrowd stored in detections.data should be used when no mask is present."""
detections = Detections(
xyxy=np.array([[0.0, 0.0, 100.0, 100.0]], dtype=np.float32),
class_id=np.array([0], dtype=int),
data={"iscrowd": np.array([1], dtype=int), "area": np.array([1234.5])},
)
annotations, _ = detections_to_coco_annotations(
detections=detections,
image_id=1,
annotation_id=1,
)
assert len(annotations) == 1
assert annotations[0]["iscrowd"] == 1
assert type(annotations[0]["iscrowd"]) is int
assert annotations[0]["area"] == 1234.5
def test_detections_to_coco_annotations_iscrowd_is_int_when_mask_provided() -> None:
"""iscrowd should be stored as int (0 or 1), not as Python bool."""
mask = np.zeros((1, 5, 5), dtype=bool)
mask[0, 0:3, 0:3] = True # simple single-component rectangle
detections = Detections(
xyxy=np.array([[0.0, 0.0, 3.0, 3.0]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=mask,
)
annotations, _ = detections_to_coco_annotations(
detections=detections,
image_id=1,
annotation_id=1,
)
assert len(annotations) == 1
assert annotations[0]["iscrowd"] == 0
assert type(annotations[0]["iscrowd"]) is int
def test_detections_to_coco_annotations_data_area_overrides_bbox_with_mask() -> None:
"""data["area"] should override computed bbox area even when a mask is present."""
mask = np.zeros((1, 10, 10), dtype=bool)
mask[0, 0:4, 0:4] = True # 16-pixel polygon area
detections = Detections(
xyxy=np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=mask,
data={"area": np.array([999.0])},
)
annotations, _ = detections_to_coco_annotations(
detections=detections,
image_id=1,
annotation_id=1,
)
assert len(annotations) == 1
assert annotations[0]["area"] == 999.0
def test_detections_to_coco_annotations_fallback_area_when_no_data() -> None:
"""When detections have no area in data, area should fall back to bbox area."""
detections = Detections(
xyxy=np.array([[10.0, 20.0, 110.0, 120.0]], dtype=np.float32),
class_id=np.array([0], dtype=int),
)
annotations, _ = detections_to_coco_annotations(
detections=detections,
image_id=1,
annotation_id=1,
)
assert len(annotations) == 1
assert annotations[0]["area"] == 100.0 * 100.0
assert annotations[0]["iscrowd"] == 0
def test_load_coco_annotations_infers_masks_from_segmentation_field(
tmp_path, coco_data_with_and_without_segmentation: dict[str, object]
) -> None:
images_directory = tmp_path / "images"
images_directory.mkdir()
annotations_path = tmp_path / "annotations.json"
annotations_path.write_text(
json.dumps(coco_data_with_and_without_segmentation), encoding="utf-8"
)
classes, images, annotations = load_coco_annotations(
images_directory_path=str(images_directory),
annotations_path=str(annotations_path),
force_masks=False,
use_iscrowd=True,
)
assert classes == ["object"]
assert len(images) == 4
with_segmentation_path = str(images_directory / "with_segmentation.jpg")
with_segmentation = annotations[with_segmentation_path]
assert with_segmentation.mask is not None
assert with_segmentation.mask.shape == (2, 5, 5)
assert np.array_equal(with_segmentation.data["iscrowd"], np.array([0, 1]))
with_polygon_segmentation_path = str(
images_directory / "with_polygon_segmentation.jpg"
)
with_polygon_segmentation = annotations[with_polygon_segmentation_path]
assert with_polygon_segmentation.mask is not None
assert with_polygon_segmentation.mask.shape == (1, 5, 5)
assert with_polygon_segmentation.mask[0].any()
without_segmentation_path = str(images_directory / "without_segmentation.jpg")
without_segmentation = annotations[without_segmentation_path]
assert without_segmentation.mask is None
assert np.array_equal(
without_segmentation.xyxy, np.array([[0, 0, 2, 2]], dtype=np.float32)
)
without_annotations_path = str(images_directory / "without_annotations.jpg")
assert annotations[without_annotations_path] == Detections.empty()
def test_load_coco_annotations_force_masks_with_no_annotations(
tmp_path, coco_data_with_unannotated_image: dict[str, object]
) -> None:
images_directory = tmp_path / "images"
images_directory.mkdir()
annotations_path = tmp_path / "annotations.json"
annotations_path.write_text(
json.dumps(coco_data_with_unannotated_image),
encoding="utf-8",
)
_, _, annotations = load_coco_annotations(
images_directory_path=str(images_directory),
annotations_path=str(annotations_path),
force_masks=True,
)
has_segmentation_path = str(images_directory / "has_segmentation.jpg")
has_segmentation = annotations[has_segmentation_path]
assert has_segmentation.mask is not None
assert has_segmentation.mask.shape == (1, 5, 5)
no_annotations_path = str(images_directory / "no_annotations.jpg")
assert annotations[no_annotations_path] == Detections.empty()
def test_load_coco_annotations_force_masks_handles_missing_segmentation(
tmp_path,
) -> None:
images_directory = tmp_path / "images"
images_directory.mkdir()
annotations_path = tmp_path / "annotations.json"
coco_data = {
"categories": [{"id": 1, "name": "object", "supercategory": "none"}],
"images": [{"id": 1, "file_name": "image.jpg", "width": 5, "height": 5}],
"annotations": [
{
"id": 1,
"image_id": 1,
"category_id": 1,
"bbox": [0, 0, 2, 2],
"area": 4,
"iscrowd": 0,
}
],
}
annotations_path.write_text(json.dumps(coco_data), encoding="utf-8")
_, _, annotations = load_coco_annotations(
images_directory_path=str(images_directory),
annotations_path=str(annotations_path),
force_masks=True,
)
image_path = str(images_directory / "image.jpg")
image_annotations = annotations[image_path]
assert image_annotations.mask is not None
assert image_annotations.mask.shape == (1, 5, 5)
assert not image_annotations.mask.any()
assert np.array_equal(image_annotations.xyxy, np.array([[0, 0, 2, 2]], dtype=float))