fix(detection): preserve `class_name` string dtype on empty `Detections.from_inference` (#2270)

* fix): preserve class_name string dtype on empty Detections.from_inference
* test): fix stale float64 dtype expectation in test_process_roboflow_result
* docs): document data[class_name] contract in from_inference Returns
* fix): set string-dtype class_name on empty from_ultralytics and from_vlm paths
* test): strengthen from_inference empty-path dtype test
* test): cover SDK .dict() path for empty predictions in from_inference
* docs): add docstring to process_roboflow_result
* test): use dtype.kind comparison for empty/non-empty class_name
* docs): update example in `process_roboflow_result` docstring

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
Mahbod 2026-05-27 10:39:49 +02:00 committed by GitHub
parent 2fdb970430
commit 2c3a2ef6f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 91 additions and 6 deletions

View File

@ -324,7 +324,9 @@ class Detections:
data={CLASS_NAME_DATA_FIELD: class_names},
)
return cls.empty()
empty = cls.empty()
empty.data = {CLASS_NAME_DATA_FIELD: np.empty(0, dtype=str)}
return empty
@classmethod
def from_yolo_nas(cls, yolo_nas_results: Any) -> Detections:
@ -626,6 +628,10 @@ class Detections:
Returns:
A Detections object containing the bounding boxes, class IDs,
and confidences of the predictions.
`detections.data["class_name"]` is always present as a
string-dtype NumPy array aligned with the detections; it is
empty (shape `(0,)`, dtype str) when `predictions` is empty
or absent.
Example:
```python
@ -650,7 +656,7 @@ class Detections:
if np.asarray(xyxy).shape[0] == 0:
empty_detection = cls.empty()
empty_detection.data = {CLASS_NAME_DATA_FIELD: np.empty(0)}
empty_detection.data = data
return empty_detection
return cls(
@ -1910,7 +1916,9 @@ class Detections:
assert isinstance(result, dict)
xyxy, labels, mask, xyxyxyxy = from_florence_2(result, **kwargs)
if len(xyxy) == 0:
return cls.empty()
empty = cls.empty()
empty.data = {CLASS_NAME_DATA_FIELD: np.empty(0, dtype=str)}
return empty
data = {}
if labels is not None:

View File

@ -61,6 +61,29 @@ def process_roboflow_result(
npt.NDArray[np.integer] | None,
dict[str, npt.NDArray[np.generic]],
]:
"""Parse a Roboflow API or Inference package result into detection arrays.
The returned ``data`` dict always contains ``CLASS_NAME_DATA_FIELD`` as a
string-dtype NumPy array. When ``predictions`` is empty, the array has
shape ``(0,)`` with ``dtype=str``, preserving dtype contracts for callers
that mix empty and non-empty results.
Args:
roboflow_result: Raw dict from the Roboflow REST API or the Inference
package (after ``.dict()`` serialisation).
Returns:
A 6-tuple of ``(xyxy, confidence, class_id, masks, tracker_ids, data)``
where each array is aligned with the others. ``masks`` and
``tracker_ids`` are ``None`` when absent from the predictions.
Examples:
>>> from supervision.detection.utils.internal import process_roboflow_result
>>> result = {"predictions": [], "image": {"width": 100, "height": 100}}
>>> _, _, _, _, _, data = process_roboflow_result(result)
>>> data["class_name"].dtype.kind
'U'
"""
if not roboflow_result["predictions"]:
return (
np.empty((0, 4), dtype=np.float64),

View File

@ -933,3 +933,52 @@ def test_merge_inner_detection_object_pair(
def test_is_empty(detections: Detections, expected: bool) -> None:
"""Verify is_empty() returns True iff the Detections object has zero detections."""
assert detections.is_empty() == expected
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}}
non_empty_result = {
"predictions": [
{
"x": 50,
"y": 50,
"width": 20,
"height": 20,
"confidence": 0.9,
"class": "cat",
"class_id": 0,
}
],
"image": {"width": 100, "height": 100},
}
empty = Detections.from_inference(empty_result)
non_empty = Detections.from_inference(non_empty_result)
# null-safety: class_name must be an array, not None
assert empty["class_name"] is not None
assert non_empty["class_name"] is not None
# dtype kind must match between empty and non-empty paths
assert empty["class_name"].dtype.kind == non_empty["class_name"].dtype.kind == "U"
# all data keys and dtypes must match between empty and non-empty paths
assert set(empty.data.keys()) == set(non_empty.data.keys())
for key in non_empty.data:
assert empty.data[key].dtype.kind == non_empty.data[key].dtype.kind, key
# concatenation across empty+non-empty must produce a string-kind array
concat = np.concatenate([empty["class_name"], non_empty["class_name"]])
assert concat.dtype.kind == "U"
def test_from_inference_sdk_dict_path_empty_preserves_class_name_dtype() -> None:
"""SDK objects with .dict() and empty predictions produce string-kind class_name."""
class _FakeSdkResult:
def dict(self, **kwargs: object) -> dict:
return {"predictions": [], "image": {"width": 100, "height": 100}}
detections = Detections.from_inference(_FakeSdkResult())
assert detections["class_name"] is not None
assert detections["class_name"].dtype.kind == "U"

View File

@ -36,7 +36,7 @@ TEST_RLE_NONCONTIGUOUS_MASK[0, 3, 2:4] = True
np.empty(0),
None,
None,
{CLASS_NAME_DATA_FIELD: np.empty(0)},
{CLASS_NAME_DATA_FIELD: np.empty(0, dtype=str)},
),
DoesNotRaise(),
), # empty result
@ -124,7 +124,7 @@ TEST_RLE_NONCONTIGUOUS_MASK[0, 3, 2:4] = True
np.empty(0),
None,
None,
{CLASS_NAME_DATA_FIELD: np.empty(0)},
{CLASS_NAME_DATA_FIELD: np.empty(0, dtype=str)},
),
DoesNotRaise(),
), # single incorrect instance segmentation result with no points
@ -150,7 +150,7 @@ TEST_RLE_NONCONTIGUOUS_MASK[0, 3, 2:4] = True
np.empty(0),
None,
None,
{CLASS_NAME_DATA_FIELD: np.empty(0)},
{CLASS_NAME_DATA_FIELD: np.empty(0, dtype=str)},
),
DoesNotRaise(),
), # single incorrect instance segmentation result with no enough points
@ -474,6 +474,11 @@ def test_process_roboflow_result(
assert np.array_equal(result[5][key], expected_result[5][key]), (
f"Mismatch in arrays for key {key}"
)
assert result[5][key].dtype == expected_result[5][key].dtype, (
f"dtype mismatch for key {key}: "
f"got {result[5][key].dtype}, "
f"expected {expected_result[5][key].dtype}"
)
else:
assert result[5][key] == expected_result[5][key], (
f"Mismatch in non-array data for key {key}"