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>
This commit is contained in:
Copilot 2026-03-30 20:17:42 +02:00 committed by GitHub
parent d33715464b
commit 38be1be4b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 116 additions and 4 deletions

View File

@ -164,14 +164,18 @@ def detections_to_coco_annotations(
approximation_percentage: float = 0.75,
) -> tuple[list[CocoDict], int]:
coco_annotations: list[CocoDict] = []
for xyxy, mask, _, class_id, _, _ in detections:
for xyxy, mask, _, class_id, _, data in detections:
if class_id is None:
raise ValueError("Detections must include class_id for COCO export.")
box_width, box_height = xyxy[2] - xyxy[0], xyxy[3] - xyxy[1]
segmentation: Union[list[list[float]], dict[str, list[int]]] = []
iscrowd = 0
if mask is not None:
iscrowd = contains_holes(mask=mask) or contains_multiple_segments(mask=mask)
if "iscrowd" in data:
iscrowd = int(np.asarray(data["iscrowd"]).item())
else:
iscrowd = int(
contains_holes(mask=mask) or contains_multiple_segments(mask=mask)
)
if iscrowd:
segmentation = {
@ -196,12 +200,16 @@ def detections_to_coco_annotations(
"returned no polygons.",
stacklevel=2,
)
else:
iscrowd = int(np.asarray(data.get("iscrowd", 0)).item())
area: float = float(np.asarray(data.get("area", box_width * box_height)).item())
coco_annotation = {
"id": annotation_id,
"image_id": image_id,
"category_id": int(class_id),
"bbox": [xyxy[0], xyxy[1], box_width, box_height],
"area": box_width * box_height,
"area": area,
"segmentation": segmentation,
"iscrowd": iscrowd,
}

View File

@ -947,6 +947,110 @@ def test_detections_to_coco_annotations_handles_empty_approximated_polygons() ->
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: