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:
parent
d33715464b
commit
38be1be4b2
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue