9194ef5abd
Docs/Test Workflow / Test docs build (push) Failing after 0s
Check links & references / links-check (push) Failing after 1s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.10) (push) Failing after 0s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.11) (push) Failing after 0s
PR Conflict Labeler / main (push) Failing after 2s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.12) (push) Failing after 2s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.13) (push) Failing after 0s
Pytest/Test Workflow / Build this Package (push) Failing after 5s
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / testing-guardian (push) Has been cancelled
794 lines
28 KiB
Python
794 lines
28 KiB
Python
import numpy as np
|
|
import pytest
|
|
|
|
import supervision.detection.core as detection_core
|
|
from supervision.config import CLASS_NAME_DATA_FIELD, ORIENTED_BOX_COORDINATES
|
|
from supervision.detection.core import LMM, Detections
|
|
from supervision.detection.vlm import VLM
|
|
from supervision.utils.internal import SupervisionWarnings
|
|
from tests.helpers import (
|
|
_FakeDeepSparseResults,
|
|
_FakeDetachTensor,
|
|
_FakeDetectron2Instances,
|
|
_FakeMMDetPredInstances,
|
|
_FakeMMDetResults,
|
|
_FakeNCNNObject,
|
|
_FakeTensor,
|
|
_FakeUltralyticsBoxes,
|
|
_FakeUltralyticsResults,
|
|
_FakeYoloNasPrediction,
|
|
_FakeYoloNasResults,
|
|
_FakeYOLOv5Results,
|
|
make_panoptic_png,
|
|
)
|
|
|
|
|
|
def test_from_yolov5_maps_columns_correctly() -> None:
|
|
pred = np.array(
|
|
[
|
|
[10, 20, 30, 40, 0.9, 2],
|
|
[1, 2, 3, 4, 0.1, 7],
|
|
],
|
|
dtype=np.float32,
|
|
)
|
|
results = _FakeYOLOv5Results(pred0=pred)
|
|
|
|
det = Detections.from_yolov5(results)
|
|
|
|
assert isinstance(det, Detections)
|
|
np.testing.assert_allclose(det.xyxy, pred[:, :4])
|
|
np.testing.assert_allclose(det.confidence, pred[:, 4])
|
|
np.testing.assert_array_equal(det.class_id, pred[:, 5].astype(int))
|
|
|
|
|
|
def test_from_ultralytics_boxes_branch_maps_fields_and_class_names() -> None:
|
|
xyxy = np.array([[0, 0, 10, 10], [5, 6, 7, 8]], dtype=np.float32)
|
|
conf = np.array([0.8, 0.2], dtype=np.float32)
|
|
cls = np.array([1, 0], dtype=np.float32)
|
|
names = {0: "cat", 1: "dog"}
|
|
|
|
boxes = _FakeUltralyticsBoxes(xyxy=xyxy, conf=conf, cls=cls, id_=None)
|
|
results = _FakeUltralyticsResults(boxes=boxes, names=names)
|
|
|
|
det = Detections.from_ultralytics(results)
|
|
|
|
np.testing.assert_allclose(det.xyxy, xyxy)
|
|
np.testing.assert_allclose(det.confidence, conf)
|
|
np.testing.assert_array_equal(det.class_id, cls.astype(int))
|
|
assert det.tracker_id is None
|
|
|
|
assert CLASS_NAME_DATA_FIELD in det.data
|
|
expected_names = np.array([names[i] for i in cls.astype(int)])
|
|
np.testing.assert_array_equal(det.data[CLASS_NAME_DATA_FIELD], expected_names)
|
|
|
|
|
|
def test_from_ultralytics_segmentation_only_branch_uses_masks_and_arange(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
results = _FakeUltralyticsResults(boxes=None, names={}, length=3)
|
|
|
|
fake_masks = np.zeros((3, 10, 10), dtype=bool)
|
|
fake_xyxy = np.array([[0, 0, 1, 1], [2, 2, 3, 3], [4, 4, 5, 5]], dtype=np.float32)
|
|
|
|
monkeypatch.setattr(
|
|
detection_core, "extract_ultralytics_masks", lambda _: fake_masks
|
|
)
|
|
monkeypatch.setattr(detection_core, "mask_to_xyxy", lambda masks: fake_xyxy)
|
|
|
|
det = Detections.from_ultralytics(results)
|
|
|
|
np.testing.assert_allclose(det.xyxy, fake_xyxy)
|
|
np.testing.assert_array_equal(det.mask, fake_masks)
|
|
np.testing.assert_array_equal(det.class_id, np.arange(len(results)))
|
|
|
|
|
|
def test_from_ultralytics_segmentation_only_without_masks_returns_empty() -> None:
|
|
"""Segmentation-only Ultralytics results without masks return empty detections."""
|
|
results = _FakeUltralyticsResults(boxes=None, names={}, length=0)
|
|
|
|
det = Detections.from_ultralytics(results)
|
|
|
|
assert len(det) == 0
|
|
assert det.xyxy.shape == (0, 4)
|
|
assert det.mask is None
|
|
np.testing.assert_array_equal(
|
|
det.data[CLASS_NAME_DATA_FIELD], np.array([], dtype=str)
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("bboxes", "conf", "labels", "expected_len"),
|
|
[
|
|
(
|
|
np.empty((0, 4), dtype=np.float32),
|
|
np.empty((0,), dtype=np.float32),
|
|
np.empty((0,), dtype=np.int64),
|
|
0,
|
|
),
|
|
(
|
|
np.array([[1, 2, 3, 4], [10, 20, 30, 40]], dtype=np.float32),
|
|
np.array([0.3, 0.9], dtype=np.float32),
|
|
np.array([5, 6], dtype=np.int64),
|
|
2,
|
|
),
|
|
],
|
|
)
|
|
def test_from_yolo_nas_handles_empty_and_non_empty(
|
|
bboxes: np.ndarray,
|
|
conf: np.ndarray,
|
|
labels: np.ndarray,
|
|
expected_len: int,
|
|
) -> None:
|
|
pred = _FakeYoloNasPrediction(
|
|
bboxes_xyxy=bboxes,
|
|
confidence=conf,
|
|
labels=labels,
|
|
)
|
|
results = _FakeYoloNasResults(prediction=pred)
|
|
|
|
det = Detections.from_yolo_nas(results)
|
|
|
|
assert len(det) == expected_len
|
|
if expected_len > 0:
|
|
np.testing.assert_allclose(det.xyxy, bboxes)
|
|
np.testing.assert_allclose(det.confidence, conf)
|
|
np.testing.assert_array_equal(det.class_id, labels.astype(int))
|
|
|
|
|
|
def test_from_tensorflow_scales_axes_on_non_square_image() -> None:
|
|
"""Non-square image exposes swapped scaling: y uses height, x uses width."""
|
|
results = {
|
|
"detection_boxes": [
|
|
_FakeTensor(np.array([[0.1, 0.2, 0.5, 0.6]], dtype=np.float32))
|
|
],
|
|
"detection_scores": [_FakeTensor(np.array([0.9], dtype=np.float32))],
|
|
"detection_classes": [_FakeTensor(np.array([1], dtype=np.float32))],
|
|
}
|
|
|
|
det = Detections.from_tensorflow(results, resolution_wh=(1000, 500))
|
|
|
|
# xmin=0.2*1000, ymin=0.1*500, xmax=0.6*1000, ymax=0.5*500
|
|
np.testing.assert_allclose(det.xyxy, [[200.0, 50.0, 600.0, 250.0]])
|
|
np.testing.assert_allclose(det.confidence, [0.9])
|
|
np.testing.assert_array_equal(det.class_id, [1])
|
|
|
|
|
|
def test_from_tensorflow_does_not_mutate_source_boxes() -> None:
|
|
"""Scaling must copy the tensor buffer, leaving the caller's boxes untouched."""
|
|
source_boxes = np.array([[0.1, 0.2, 0.5, 0.6]], dtype=np.float32)
|
|
original = source_boxes.copy()
|
|
results = {
|
|
"detection_boxes": [_FakeTensor(source_boxes)],
|
|
"detection_scores": [_FakeTensor(np.array([0.9], dtype=np.float32))],
|
|
"detection_classes": [_FakeTensor(np.array([1], dtype=np.float32))],
|
|
}
|
|
|
|
det = Detections.from_tensorflow(results, resolution_wh=(1000, 500))
|
|
|
|
np.testing.assert_array_equal(source_boxes, original)
|
|
np.testing.assert_allclose(det.xyxy, [[200.0, 50.0, 600.0, 250.0]])
|
|
|
|
|
|
class TestFromLMMMapping:
|
|
"""`from_lmm` must map every LMM member to a VLM without raising KeyError."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"lmm_member",
|
|
[pytest.param(member, id=member.name.lower()) for member in LMM],
|
|
)
|
|
def test_from_lmm_maps_every_member_to_vlm(
|
|
self, lmm_member: LMM, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Each LMM member dispatches to the VLM sharing its value with args intact."""
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_from_vlm(vlm: VLM, result: str, **kwargs: object) -> Detections:
|
|
captured["vlm"] = vlm
|
|
captured["result"] = result
|
|
captured["kwargs"] = kwargs
|
|
return Detections.empty()
|
|
|
|
monkeypatch.setattr(Detections, "from_vlm", staticmethod(fake_from_vlm))
|
|
|
|
Detections.from_lmm(lmm_member, result="sentinel", resolution_wh=(10, 10))
|
|
|
|
assert isinstance(captured["vlm"], VLM)
|
|
assert captured["vlm"].value == lmm_member.value # type: ignore[union-attr]
|
|
assert captured["result"] == "sentinel"
|
|
assert captured["kwargs"]["resolution_wh"] == (10, 10) # type: ignore[index]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# from_transformers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFromTransformers:
|
|
"""from_transformers routes detection/segmentation inputs to the right processor."""
|
|
|
|
def test_detection_path_maps_boxes_labels_scores(self) -> None:
|
|
"""Detection result with boxes+labels+scores sets xyxy, class_id, confidence."""
|
|
xyxy = np.array([[10, 20, 30, 40], [5, 6, 7, 8]], dtype=np.float32)
|
|
labels = np.array([1, 0], dtype=np.int64)
|
|
scores = np.array([0.9, 0.5], dtype=np.float32)
|
|
result = {
|
|
"boxes": _FakeDetachTensor(xyxy),
|
|
"labels": _FakeDetachTensor(labels),
|
|
"scores": _FakeDetachTensor(scores),
|
|
}
|
|
|
|
det = Detections.from_transformers(result)
|
|
|
|
np.testing.assert_allclose(det.xyxy, xyxy)
|
|
np.testing.assert_array_equal(det.class_id, labels.astype(int))
|
|
np.testing.assert_allclose(det.confidence, scores)
|
|
|
|
def test_detection_path_empty_returns_zero_detections(self) -> None:
|
|
"""Detection path with zero-row tensors yields an empty Detections."""
|
|
result = {
|
|
"boxes": _FakeDetachTensor(np.empty((0, 4), dtype=np.float32)),
|
|
"labels": _FakeDetachTensor(np.empty(0, dtype=np.int64)),
|
|
"scores": _FakeDetachTensor(np.empty(0, dtype=np.float32)),
|
|
}
|
|
|
|
det = Detections.from_transformers(result)
|
|
|
|
assert len(det) == 0
|
|
|
|
def test_detection_path_with_id2label_populates_class_names(self) -> None:
|
|
"""id2label mapping adds class name strings to data dict."""
|
|
labels = np.array([0, 1], dtype=np.int64)
|
|
result = {
|
|
"boxes": _FakeDetachTensor(np.zeros((2, 4), dtype=np.float32)),
|
|
"labels": _FakeDetachTensor(labels),
|
|
"scores": _FakeDetachTensor(np.array([0.8, 0.7], dtype=np.float32)),
|
|
}
|
|
|
|
det = Detections.from_transformers(result, id2label={0: "cat", 1: "dog"})
|
|
|
|
np.testing.assert_array_equal(det.data[CLASS_NAME_DATA_FIELD], ["cat", "dog"])
|
|
|
|
def test_v4_segmentation_masks_without_boxes_yield_correct_shape(self) -> None:
|
|
"""V4 segmentation with masks-only produces mask of shape (N, H, W)."""
|
|
masks_bool = np.zeros((2, 4, 4), dtype=bool)
|
|
masks_bool[0, 0:2, 0:2] = True
|
|
masks_bool[1, 2:4, 2:4] = True
|
|
result = {
|
|
"masks": _FakeDetachTensor(masks_bool.astype(np.uint8)),
|
|
"labels": _FakeDetachTensor(np.array([0, 1], dtype=np.int64)),
|
|
"scores": _FakeDetachTensor(np.array([0.9, 0.8], dtype=np.float32)),
|
|
}
|
|
|
|
det = Detections.from_transformers(result)
|
|
|
|
assert len(det) == 2
|
|
assert det.mask is not None
|
|
assert det.mask.shape == (2, 4, 4)
|
|
|
|
def test_v4_panoptic_png_string_extracts_masks_from_red_channel(self) -> None:
|
|
"""V4 panoptic: png_string+segments_info produce masks keyed by segment id."""
|
|
seg_map = np.zeros((4, 4), dtype=np.uint8)
|
|
seg_map[0:2, 0:2] = 1
|
|
seg_map[2:4, 2:4] = 2
|
|
png_bytes = make_panoptic_png(seg_map)
|
|
result = {
|
|
"png_string": png_bytes,
|
|
"segments_info": [
|
|
{"id": 1, "category_id": 3},
|
|
{"id": 2, "category_id": 7},
|
|
],
|
|
}
|
|
|
|
det = Detections.from_transformers(result)
|
|
|
|
assert len(det) == 2
|
|
np.testing.assert_array_equal(det.class_id, [3, 7])
|
|
assert det.mask is not None
|
|
|
|
def test_v5_segmentation_key_routes_to_semantic_instance_processor(self) -> None:
|
|
"""'segmentation' key routes through v5 semantic/instance segmentation path."""
|
|
seg_arr = np.zeros((4, 4), dtype=np.int64)
|
|
seg_arr[2:4, :] = 1
|
|
segments_info = [
|
|
{"id": 0, "label_id": 0, "score": 0.9},
|
|
{"id": 1, "label_id": 1, "score": 0.8},
|
|
]
|
|
result = {
|
|
"segmentation": _FakeDetachTensor(seg_arr),
|
|
"segments_info": segments_info,
|
|
}
|
|
|
|
det = Detections.from_transformers(result)
|
|
|
|
assert len(det) == 2
|
|
np.testing.assert_array_equal(det.class_id, [0, 1])
|
|
np.testing.assert_allclose(det.confidence, [0.9, 0.8])
|
|
|
|
def test_unrecognised_keys_raise_value_error(self) -> None:
|
|
"""Dict with no valid keys (no boxes/masks/segmentation) raises ValueError."""
|
|
with pytest.raises(ValueError, match="valid fields"):
|
|
Detections.from_transformers({})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# from_detectron2
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFromDetectron2:
|
|
"""from_detectron2 maps Detectron2 pred_instances to Detections fields."""
|
|
|
|
def test_two_detections_without_masks_maps_fields(self) -> None:
|
|
"""N=2, no pred_masks: xyxy, confidence, class_id extracted correctly."""
|
|
xyxy = np.array([[0, 0, 10, 10], [5, 5, 15, 15]], dtype=np.float32)
|
|
scores = np.array([0.9, 0.7], dtype=np.float32)
|
|
class_ids = np.array([1, 2], dtype=np.int64)
|
|
instances = _FakeDetectron2Instances(xyxy, scores, class_ids)
|
|
result = {"instances": instances}
|
|
|
|
det = Detections.from_detectron2(result)
|
|
|
|
np.testing.assert_allclose(det.xyxy, xyxy)
|
|
np.testing.assert_allclose(det.confidence, scores)
|
|
np.testing.assert_array_equal(det.class_id, class_ids.astype(int))
|
|
assert det.mask is None
|
|
|
|
def test_single_detection_without_masks(self) -> None:
|
|
"""N=1 detection without masks returns one-element Detections."""
|
|
xyxy = np.array([[1, 2, 3, 4]], dtype=np.float32)
|
|
instances = _FakeDetectron2Instances(
|
|
xyxy, np.array([0.5], dtype=np.float32), np.array([0])
|
|
)
|
|
|
|
det = Detections.from_detectron2({"instances": instances})
|
|
|
|
assert len(det) == 1
|
|
|
|
def test_with_pred_masks_sets_mask_field(self) -> None:
|
|
"""When pred_masks present, mask is populated with boolean array."""
|
|
xyxy = np.array([[0, 0, 4, 4]], dtype=np.float32)
|
|
masks = np.ones((1, 4, 4), dtype=bool)
|
|
instances = _FakeDetectron2Instances(
|
|
xyxy, np.array([0.8], dtype=np.float32), np.array([0]), masks=masks
|
|
)
|
|
|
|
det = Detections.from_detectron2({"instances": instances})
|
|
|
|
assert det.mask is not None
|
|
assert det.mask.shape == (1, 4, 4)
|
|
|
|
def test_empty_instances_returns_zero_length(self) -> None:
|
|
"""Empty pred_instances arrays produce a zero-length Detections."""
|
|
instances = _FakeDetectron2Instances(
|
|
np.empty((0, 4), dtype=np.float32),
|
|
np.empty(0, dtype=np.float32),
|
|
np.empty(0, dtype=np.int64),
|
|
)
|
|
|
|
det = Detections.from_detectron2({"instances": instances})
|
|
|
|
assert len(det) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# from_mmdetection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFromMMDetection:
|
|
"""from_mmdetection maps MMDet pred_instances to Detections fields."""
|
|
|
|
def test_two_detections_without_masks(self) -> None:
|
|
"""N=2, no masks attribute: xyxy, confidence, class_id set; mask is None."""
|
|
xyxy = np.array([[0, 0, 10, 10], [5, 5, 20, 20]], dtype=np.float32)
|
|
scores = np.array([0.85, 0.6], dtype=np.float32)
|
|
labels = np.array([0, 3], dtype=np.int64)
|
|
pred_instances = _FakeMMDetPredInstances(xyxy, scores, labels)
|
|
result = _FakeMMDetResults(pred_instances)
|
|
|
|
det = Detections.from_mmdetection(result)
|
|
|
|
np.testing.assert_allclose(det.xyxy, xyxy)
|
|
np.testing.assert_allclose(det.confidence, scores)
|
|
np.testing.assert_array_equal(det.class_id, labels.astype(int))
|
|
assert det.mask is None
|
|
|
|
def test_single_detection_without_masks(self) -> None:
|
|
"""N=1 detection without masks returns one-element Detections."""
|
|
pred_instances = _FakeMMDetPredInstances(
|
|
np.array([[2, 4, 6, 8]], dtype=np.float32),
|
|
np.array([0.75], dtype=np.float32),
|
|
np.array([1]),
|
|
)
|
|
|
|
det = Detections.from_mmdetection(_FakeMMDetResults(pred_instances))
|
|
|
|
assert len(det) == 1
|
|
|
|
def test_with_masks_populates_mask_field(self) -> None:
|
|
"""When masks present in pred_instances, mask field is set."""
|
|
xyxy = np.array([[0, 0, 4, 4]], dtype=np.float32)
|
|
masks = np.ones((1, 4, 4), dtype=bool)
|
|
pred_instances = _FakeMMDetPredInstances(
|
|
xyxy, np.array([0.9], dtype=np.float32), np.array([0]), masks=masks
|
|
)
|
|
|
|
det = Detections.from_mmdetection(_FakeMMDetResults(pred_instances))
|
|
|
|
assert det.mask is not None
|
|
assert det.mask.shape == (1, 4, 4)
|
|
|
|
def test_empty_pred_instances_returns_zero_length(self) -> None:
|
|
"""Empty bboxes/scores/labels arrays yield zero-length Detections."""
|
|
pred_instances = _FakeMMDetPredInstances(
|
|
np.empty((0, 4), dtype=np.float32),
|
|
np.empty(0, dtype=np.float32),
|
|
np.empty(0, dtype=np.int64),
|
|
)
|
|
|
|
det = Detections.from_mmdetection(_FakeMMDetResults(pred_instances))
|
|
|
|
assert len(det) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# from_paddledet
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFromPaddleDet:
|
|
"""from_paddledet extracts xyxy, confidence, class_id from the bbox column array."""
|
|
|
|
def test_empty_bbox_returns_empty_detections(self) -> None:
|
|
"""Empty (0,6) bbox array yields zero-length Detections."""
|
|
det = Detections.from_paddledet({"bbox": np.empty((0, 6), dtype=np.float32)})
|
|
|
|
assert len(det) == 0
|
|
|
|
@pytest.mark.parametrize(
|
|
("bbox_array", "expected_len"),
|
|
[
|
|
pytest.param(
|
|
np.array(
|
|
[[0, 0.9, 10, 20, 30, 40], [1, 0.7, 5, 6, 7, 8]],
|
|
dtype=np.float32,
|
|
),
|
|
2,
|
|
id="two-detections",
|
|
),
|
|
pytest.param(
|
|
np.array([[2, 0.5, 1, 2, 3, 4]], dtype=np.float32),
|
|
1,
|
|
id="single-detection",
|
|
),
|
|
],
|
|
)
|
|
def test_maps_bbox_columns_to_detections(
|
|
self, bbox_array: np.ndarray, expected_len: int
|
|
) -> None:
|
|
"""bbox[:,0]=class_id, [:,1]=confidence, [:,2:6]=xyxy are extracted."""
|
|
result = {"bbox": bbox_array}
|
|
|
|
det = Detections.from_paddledet(result)
|
|
|
|
assert len(det) == expected_len
|
|
np.testing.assert_allclose(det.xyxy, bbox_array[:, 2:6])
|
|
np.testing.assert_allclose(det.confidence, bbox_array[:, 1])
|
|
np.testing.assert_array_equal(det.class_id, bbox_array[:, 0].astype(int))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# from_deepsparse
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFromDeepSparse:
|
|
"""from_deepsparse maps DeepSparse boxes/scores/labels to Detections."""
|
|
|
|
def test_empty_results_return_empty_detections(self) -> None:
|
|
"""Empty boxes/scores/labels arrays yield zero-length Detections."""
|
|
result = _FakeDeepSparseResults(
|
|
boxes=[np.empty((0, 4), dtype=np.float32)],
|
|
scores=[np.empty(0, dtype=np.float32)],
|
|
labels=[np.empty(0, dtype=np.float32)],
|
|
)
|
|
|
|
det = Detections.from_deepsparse(result)
|
|
|
|
assert len(det) == 0
|
|
|
|
@pytest.mark.parametrize(
|
|
("boxes", "scores", "labels", "expected_len"),
|
|
[
|
|
pytest.param(
|
|
np.array([[0, 0, 10, 10], [5, 5, 15, 15]], dtype=np.float32),
|
|
np.array([0.95, 0.8], dtype=np.float32),
|
|
np.array([0, 1], dtype=np.float32),
|
|
2,
|
|
id="two-detections",
|
|
),
|
|
pytest.param(
|
|
np.array([[1, 2, 3, 4]], dtype=np.float32),
|
|
np.array([0.6], dtype=np.float32),
|
|
np.array([3], dtype=np.float32),
|
|
1,
|
|
id="single-detection",
|
|
),
|
|
],
|
|
)
|
|
def test_maps_boxes_scores_labels_to_detections(
|
|
self,
|
|
boxes: np.ndarray,
|
|
scores: np.ndarray,
|
|
labels: np.ndarray,
|
|
expected_len: int,
|
|
) -> None:
|
|
"""boxes[0], scores[0], labels[0] are extracted into Detections fields."""
|
|
result = _FakeDeepSparseResults(boxes=[boxes], scores=[scores], labels=[labels])
|
|
|
|
det = Detections.from_deepsparse(result)
|
|
|
|
assert len(det) == expected_len
|
|
np.testing.assert_allclose(det.xyxy, boxes)
|
|
np.testing.assert_allclose(det.confidence, scores)
|
|
np.testing.assert_array_equal(det.class_id, labels.astype(int))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# from_easyocr
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFromEasyOCR:
|
|
"""from_easyocr converts EasyOCR polygon-corner results to Detections."""
|
|
|
|
def test_two_detections_produces_correct_xyxy_and_text(self) -> None:
|
|
"""Two detections with rectangular corners produce correct xyxy and text."""
|
|
bbox_a = [[10, 10], [30, 10], [30, 20], [10, 20]]
|
|
bbox_b = [[50, 5], [80, 5], [80, 25], [50, 25]]
|
|
results = [
|
|
(bbox_a, "hello", 0.95),
|
|
(bbox_b, "world", 0.80),
|
|
]
|
|
|
|
det = Detections.from_easyocr(results)
|
|
|
|
assert len(det) == 2
|
|
np.testing.assert_allclose(det.xyxy[0], [10, 10, 30, 20])
|
|
np.testing.assert_allclose(det.xyxy[1], [50, 5, 80, 25])
|
|
np.testing.assert_array_equal(
|
|
det.data[CLASS_NAME_DATA_FIELD], ["hello", "world"]
|
|
)
|
|
|
|
def test_single_detection_returns_one_element(self) -> None:
|
|
"""N=1 result returns a single-detection Detections."""
|
|
results = [([[0, 0], [10, 0], [10, 5], [0, 5]], "ok", 0.7)]
|
|
|
|
det = Detections.from_easyocr(results)
|
|
|
|
assert len(det) == 1
|
|
|
|
def test_empty_list_returns_empty_detections(self) -> None:
|
|
"""Empty input returns an empty Detections."""
|
|
det = Detections.from_easyocr([])
|
|
|
|
assert len(det) == 0
|
|
|
|
def test_missing_confidence_defaults_to_zero(self) -> None:
|
|
"""Two-element tuples (no confidence) default confidence to 0."""
|
|
results = [([[0, 0], [5, 0], [5, 3], [0, 3]], "hi")]
|
|
|
|
det = Detections.from_easyocr(results)
|
|
|
|
assert len(det) == 1
|
|
assert float(det.confidence[0]) == pytest.approx(0.0)
|
|
|
|
def test_preserves_oriented_corners_in_data(self) -> None:
|
|
"""Quadrilateral EasyOCR boxes must be preserved in the data payload."""
|
|
bbox = [[0, 0], [8, 1], [7, 5], [1, 4]]
|
|
results = [(bbox, "text", 0.9)]
|
|
|
|
det = Detections.from_easyocr(results)
|
|
|
|
assert ORIENTED_BOX_COORDINATES in det.data
|
|
np.testing.assert_allclose(det.data[ORIENTED_BOX_COORDINATES], np.array([bbox]))
|
|
|
|
def test_detail_zero_results_raise_clear_error(self) -> None:
|
|
"""detail=0 EasyOCR results must fail with a descriptive ValueError."""
|
|
with pytest.raises(ValueError, match="detail=1"):
|
|
Detections.from_easyocr(["text"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# from_azure_analyze_image
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_azure_result(
|
|
detections: list[dict],
|
|
) -> dict:
|
|
"""Build a minimal Azure Image Analysis response dict."""
|
|
return {"objectsResult": {"values": detections}}
|
|
|
|
|
|
def _make_azure_detection(x: int, y: int, w: int, h: int, tags: list[dict]) -> dict:
|
|
"""Build one Azure detection entry."""
|
|
return {
|
|
"boundingBox": {"x": x, "y": y, "w": w, "h": h},
|
|
"tags": tags,
|
|
}
|
|
|
|
|
|
class TestFromAzureAnalyzeImage:
|
|
"""from_azure_analyze_image converts Azure object detection results."""
|
|
|
|
def test_dynamic_class_mapping_assigns_ids_in_order(self) -> None:
|
|
"""Without class_map, unique class names get monotonically increasing IDs."""
|
|
result = _make_azure_result(
|
|
[
|
|
_make_azure_detection(
|
|
0, 0, 10, 10, [{"name": "cat", "confidence": 0.9}]
|
|
),
|
|
_make_azure_detection(
|
|
20, 20, 10, 10, [{"name": "dog", "confidence": 0.7}]
|
|
),
|
|
]
|
|
)
|
|
|
|
det = Detections.from_azure_analyze_image(result)
|
|
|
|
assert len(det) == 2
|
|
# cat gets id=0 (first seen), dog gets id=1
|
|
np.testing.assert_array_equal(det.class_id, [0, 1])
|
|
np.testing.assert_allclose(det.confidence, [0.9, 0.7])
|
|
np.testing.assert_allclose(det.xyxy[0], [0, 0, 10, 10])
|
|
|
|
def test_explicit_class_map_filters_unknown_classes(self) -> None:
|
|
"""With class_map, the highest-confidence mapped tag is selected."""
|
|
class_map = {5: "cat"}
|
|
result = _make_azure_result(
|
|
[
|
|
_make_azure_detection(
|
|
0,
|
|
0,
|
|
10,
|
|
10,
|
|
[
|
|
{"name": "unknown", "confidence": 0.95},
|
|
{"name": "cat", "confidence": 0.9},
|
|
],
|
|
),
|
|
]
|
|
)
|
|
|
|
det = Detections.from_azure_analyze_image(result, class_map=class_map)
|
|
|
|
assert len(det) == 1
|
|
assert int(det.class_id[0]) == 5
|
|
np.testing.assert_allclose(det.confidence, [0.9])
|
|
|
|
def test_unmapped_tags_warn_and_skip_detection(self) -> None:
|
|
"""With class_map, completely unmapped tags should warn before skipping."""
|
|
class_map = {5: "cat"}
|
|
result = _make_azure_result(
|
|
[
|
|
_make_azure_detection(
|
|
0,
|
|
0,
|
|
10,
|
|
10,
|
|
[
|
|
{"name": "unknown", "confidence": 0.95},
|
|
{"name": "other", "confidence": 0.9},
|
|
],
|
|
),
|
|
]
|
|
)
|
|
|
|
with pytest.warns(SupervisionWarnings, match="none of its tags matched"):
|
|
det = Detections.from_azure_analyze_image(result, class_map=class_map)
|
|
|
|
assert len(det) == 0
|
|
|
|
def test_empty_values_list_returns_empty_detections(self) -> None:
|
|
"""Zero detections in values list produce an empty Detections."""
|
|
result = _make_azure_result([])
|
|
|
|
det = Detections.from_azure_analyze_image(result)
|
|
|
|
assert len(det) == 0
|
|
|
|
def test_error_key_raises_value_error(self) -> None:
|
|
"""Response containing 'error' key raises ValueError."""
|
|
result = {"error": {"message": "service unavailable"}}
|
|
|
|
with pytest.raises(ValueError, match="service unavailable"):
|
|
Detections.from_azure_analyze_image(result)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# from_ncnn
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFromNCNN:
|
|
"""from_ncnn converts ncnn rect objects (xywh) to xyxy Detections."""
|
|
|
|
def test_empty_objects_return_empty_detections(self) -> None:
|
|
"""Empty object list yields zero-length Detections."""
|
|
det = Detections.from_ncnn([])
|
|
|
|
assert len(det) == 0
|
|
|
|
@pytest.mark.parametrize(
|
|
("objects", "expected_len"),
|
|
[
|
|
pytest.param(
|
|
[
|
|
_FakeNCNNObject(10, 20, 30, 40, 0.9, 0),
|
|
_FakeNCNNObject(5, 5, 10, 10, 0.7, 1),
|
|
],
|
|
2,
|
|
id="two-detections",
|
|
),
|
|
pytest.param(
|
|
[_FakeNCNNObject(0, 0, 20, 20, 0.5, 2)],
|
|
1,
|
|
id="single-detection",
|
|
),
|
|
],
|
|
)
|
|
def test_maps_xywh_rect_to_xyxy(self, objects: list, expected_len: int) -> None:
|
|
"""rect xywh converts to xyxy; prob and label map to confidence/class_id."""
|
|
det = Detections.from_ncnn(objects)
|
|
|
|
assert len(det) == expected_len
|
|
first = objects[0]
|
|
expected_x2 = first.rect.x + first.rect.w
|
|
expected_y2 = first.rect.y + first.rect.h
|
|
np.testing.assert_allclose(det.xyxy[0, 2], expected_x2)
|
|
np.testing.assert_allclose(det.xyxy[0, 3], expected_y2)
|
|
assert float(det.confidence[0]) == pytest.approx(first.prob)
|
|
assert int(det.class_id[0]) == first.label
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# from_lmm end-to-end
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFromLMMEndToEnd:
|
|
"""from_lmm end-to-end: deprecated dispatcher produces correct Detections."""
|
|
|
|
def test_paligemma_result_produces_correct_xyxy(self) -> None:
|
|
"""PaliGemma loc-token string is correctly parsed through the legacy API."""
|
|
result = "<loc0256><loc0256><loc0768><loc0768> cat"
|
|
|
|
with pytest.warns(SupervisionWarnings):
|
|
det = Detections.from_lmm(
|
|
LMM.PALIGEMMA,
|
|
result,
|
|
resolution_wh=(1000, 1000),
|
|
classes=["cat"],
|
|
)
|
|
|
|
assert len(det) == 1
|
|
np.testing.assert_allclose(det.xyxy, [[250.0, 250.0, 750.0, 750.0]])
|
|
assert int(det.class_id[0]) == 0
|
|
|
|
def test_string_lmm_name_is_accepted_and_dispatches(self) -> None:
|
|
"""Passing LMM name as lowercase string works identically to the enum."""
|
|
with pytest.warns(SupervisionWarnings):
|
|
det = Detections.from_lmm(
|
|
"paligemma",
|
|
"",
|
|
resolution_wh=(1000, 1000),
|
|
)
|
|
|
|
assert len(det) == 0
|
|
|
|
|
|
def test_lmm_values_are_subset_of_vlm_values() -> None:
|
|
"""Every LMM value exists in VLM — required for VLM(lmm.value) to succeed."""
|
|
assert {m.value for m in LMM} <= {m.value for m in VLM}
|