diff --git a/src/supervision/detection/core.py b/src/supervision/detection/core.py index 30baca78..c5118004 100644 --- a/src/supervision/detection/core.py +++ b/src/supervision/detection/core.py @@ -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: diff --git a/src/supervision/detection/utils/internal.py b/src/supervision/detection/utils/internal.py index 1d0d3371..fd566160 100644 --- a/src/supervision/detection/utils/internal.py +++ b/src/supervision/detection/utils/internal.py @@ -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), diff --git a/tests/detection/test_core.py b/tests/detection/test_core.py index 94434c6f..d4e80506 100644 --- a/tests/detection/test_core.py +++ b/tests/detection/test_core.py @@ -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" diff --git a/tests/detection/utils/test_internal.py b/tests/detection/utils/test_internal.py index c5bc08dd..5f2875dd 100644 --- a/tests/detection/utils/test_internal.py +++ b/tests/detection/utils/test_internal.py @@ -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}"