fix(detection): keep `from_inference` aligned on partial masks (#2362)

process_roboflow_result appended a mask only for predictions carrying one
(RLE or polygon), while xyxy/confidence/class_id were appended for every
prediction. A result mixing masked and box-only predictions (e.g. a
segmentation batch where one polygon is empty) produced a mask array shorter
than the boxes, so Detections.from_inference raised a shape-mismatch error.

Append None for box-only predictions and build the mask array only when every
prediction has a mask, otherwise drop masks to preserve alignment, mirroring
the tracker_id handling. Fully-masked and mask-free results are unchanged.

- Update `masks` Returns clause to document partial-drop case and corrupt-RLE blast radius (D1+C1)
- Remove stale "known limitation" note from from_inference docstring; describe actual behavior (D2)
- Extract _all_present_or_none() helper; eliminate duplicated partial-drop-warn pattern (S1)

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
Agis Kounelis 2026-07-01 06:52:57 +08:00 committed by GitHub
parent 15f56dea30
commit e1b7a16101
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 79 additions and 27 deletions

View File

@ -666,10 +666,10 @@ class Detections:
or absent. `detections.tracker_id` is `None` when no
predictions carry a tracker ID, or when only a subset do
(mixed batch) in that case all tracker IDs are dropped to
preserve alignment with the bounding boxes. Note: mixed
batches containing both RLE/polygon predictions and box-only
predictions may misalign the `mask` array; this is a
pre-existing limitation not addressed by this fix.
preserve alignment with the bounding boxes.
`detections.mask` is `None` when no predictions include mask
data, or when only a subset do (mixed batch) in that case
all masks are dropped to preserve alignment.
Example:
```python

View File

@ -50,6 +50,23 @@ def extract_ultralytics_masks(yolov8_results: Any) -> npt.NDArray[np.bool_] | No
return cast(npt.NDArray[np.bool_], np.asarray(mask_maps, dtype=bool))
def _all_present_or_none(
values: list[Any],
label: str,
dtype: npt.DTypeLike,
) -> npt.NDArray[Any] | None:
# Identity check (`v is None`) is required when values may contain numpy arrays:
# `None in values` triggers element-wise comparison and raises ValueError.
missing = sum(v is None for v in values)
if 0 < missing < len(values):
logger.warning(
"Partial %s in batch; dropping all to preserve alignment with xyxy.", label
)
if not values or missing > 0:
return None
return np.array(values, dtype=dtype)
def process_roboflow_result(
roboflow_result: dict[str, Any],
) -> tuple[
@ -74,10 +91,14 @@ def process_roboflow_result(
Returns:
A 6-tuple of ``(xyxy, confidence, class_id, masks, tracker_ids, data)``
where each array is aligned with the others. ``masks`` is ``None``
when no predictions include mask data. ``tracker_ids`` is ``None``
when no predictions carry a tracker ID, or when only a subset do
(mixed batch) in that case all tracker IDs are dropped to preserve
alignment with ``xyxy``.
when no predictions include mask data, or when only a subset do
(mixed batch) in that case all masks are dropped to preserve
alignment with ``xyxy``. A failed RLE decode is treated identically
to a box-only prediction, so one corrupt RLE payload in an otherwise
fully-masked batch also causes all masks to be dropped.
``tracker_ids`` is ``None`` when no predictions carry a tracker ID,
or when only a subset do (mixed batch) in that case all tracker
IDs are dropped to preserve alignment with ``xyxy``.
Examples:
>>> from supervision.detection.utils.internal import process_roboflow_result
@ -100,7 +121,7 @@ def process_roboflow_result(
confidence: list[float] = []
class_id: list[int] = []
class_name: list[str] = []
masks: list[npt.NDArray[np.bool_]] = []
masks: list[npt.NDArray[np.bool_] | None] = []
tracker_ids: list[int | None] = []
image_width = int(roboflow_result["image"]["width"])
@ -151,6 +172,7 @@ def process_roboflow_result(
class_id.append(prediction["class_id"])
class_name.append(prediction["class"])
confidence.append(prediction["confidence"])
masks.append(None)
tracker_ids.append(prediction.get("tracker_id"))
elif len(prediction["points"]) >= 3:
polygon = np.array(
@ -180,18 +202,11 @@ def process_roboflow_result(
class_name_arr: npt.NDArray[np.str_] = (
np.array(class_name) if len(class_name) > 0 else np.empty(0, dtype=str)
)
masks_arr: npt.NDArray[np.bool_] | None = (
np.array(masks, dtype=bool) if len(masks) > 0 else None
masks_arr: npt.NDArray[np.bool_] | None = _all_present_or_none(
masks, "mask", dtype=bool
)
if tracker_ids and 0 < tracker_ids.count(None) < len(tracker_ids):
logger.warning(
"Partial tracker_id in batch; dropping all tracker_ids to preserve "
"alignment with xyxy."
)
tracker_id_arr: npt.NDArray[np.integer] | None = (
np.array(tracker_ids, dtype=np.int64)
if tracker_ids and None not in tracker_ids
else None
tracker_id_arr: npt.NDArray[np.integer] | None = _all_present_or_none(
tracker_ids, "tracker_id", dtype=np.int64
)
data: _DetectionDataType = {CLASS_NAME_DATA_FIELD: class_name_arr}

View File

@ -1013,6 +1013,46 @@ def test_from_inference_partial_tracker_id_does_not_crash() -> None:
assert detections["class_name"] is not None
def test_from_inference_partial_mask_does_not_crash() -> None:
"""Results where only some predictions carry a mask must not raise."""
result = {
"image": {"width": 100, "height": 100},
"predictions": [
{
"x": 20,
"y": 20,
"width": 20,
"height": 20,
"confidence": 0.9,
"class": "a",
"class_id": 0,
"points": [
{"x": 10, "y": 10},
{"x": 30, "y": 10},
{"x": 30, "y": 30},
{"x": 10, "y": 30},
],
},
{
"x": 70,
"y": 70,
"width": 20,
"height": 20,
"confidence": 0.8,
"class": "b",
"class_id": 1,
},
],
}
detections = Detections.from_inference(result)
# all detections are kept; masks are dropped rather than misaligned
assert len(detections) == 2
assert detections.mask is None
assert detections.xyxy.shape == (2, 4)
def test_from_inference_empty_class_name_dtype_matches_non_empty() -> None:
"""Empty and non-empty results should produce string-kind class_name arrays."""
empty_result = {"predictions": [], "image": {"width": 100, "height": 100}}

View File

@ -436,17 +436,14 @@ TEST_RLE_NONCONTIGUOUS_MASK[0, 3, 2:4] = True
np.array([[0.5, 0.5, 2.5, 2.5], [2.0, 2.0, 4.0, 4.0]]),
np.array([0.9, 0.8]),
np.array([0, 1]),
# NOTE: known misalignment — masks has 1 entry, xyxy has 2.
# Mixed RLE + box-only batches produce mask arrays shorter than
# xyxy; constructing Detections from this result would raise
# ValueError. This is a pre-existing limitation shared by the
# polygon + box-only path.
TEST_RLE_MASK,
# When only some predictions carry a mask, all masks are dropped
# so the result stays aligned with xyxy (mirrors tracker_id).
None,
None,
{CLASS_NAME_DATA_FIELD: np.array(["person", "car"])},
),
DoesNotRaise(),
), # mixed RLE + box-only batch — masks misaligned with xyxy (known limitation)
), # mixed RLE + box-only batch — masks dropped to preserve alignment
pytest.param(
{
"predictions": [