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:
parent
2fdb970430
commit
2c3a2ef6f9
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Reference in New Issue