From 06c70814ba1169326df53db41b19d86348c97542 Mon Sep 17 00:00:00 2001 From: kashtennyson Date: Fri, 27 Mar 2026 23:30:34 +0530 Subject: [PATCH 1/6] fix: corrected get_data_shape() in torch.py for base-case handling --- perceptionmetrics/utils/torch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/perceptionmetrics/utils/torch.py b/perceptionmetrics/utils/torch.py index 101c07a3..a290dc2b 100644 --- a/perceptionmetrics/utils/torch.py +++ b/perceptionmetrics/utils/torch.py @@ -43,7 +43,7 @@ def get_data_shape(data: Union[tuple, list]) -> Union[tuple, list]: elif torch.is_tensor(data): return tuple(data.shape) else: - return tuple(data.shape) + return data def unsqueeze_data(data: Union[tuple, list], dim: int = 0) -> Union[tuple, list]: From 7cbc14592052a90dc43cdd1e42e920ff4bedc0d8 Mon Sep 17 00:00:00 2001 From: kashtennyson Date: Fri, 27 Mar 2026 23:34:20 +0530 Subject: [PATCH 2/6] update: added a comprehensive unit testing suite for torch.py and image.py --- tests/test_image.py | 114 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_torch.py | 61 ++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 tests/test_image.py create mode 100644 tests/test_torch.py diff --git a/tests/test_image.py b/tests/test_image.py new file mode 100644 index 00000000..b26992a6 --- /dev/null +++ b/tests/test_image.py @@ -0,0 +1,114 @@ +import numpy as np +from PIL import Image +from unittest.mock import MagicMock, patch +import pytest +import supervision as sv +from perceptionmetrics.utils.image import draw_detections + + +def test_draw_detections_calls_supervision(): + # Setup + img = Image.new("RGB", (100, 100)) + boxes = np.array([[0, 0, 10, 10]]) + class_ids = np.array([0]) + class_names = ["cat"] + scores = np.array([0.9]) + + mock_box_annotator = MagicMock() + mock_box_annotator.annotate.return_value = np.zeros((100, 100, 3), dtype=np.uint8) + + with patch("perceptionmetrics.utils.image.sv.Detections") as mock_detections, patch( + "perceptionmetrics.utils.image.sv.BoxAnnotator", return_value=mock_box_annotator + ), patch("perceptionmetrics.utils.image.sv.Color"), patch( + "perceptionmetrics.utils.image.sv.ColorPalette" + ): + + draw_detections(img, boxes, class_ids, class_names, scores) + + assert mock_detections.called + args, kwargs = mock_detections.call_args + assert np.array_equal(kwargs["xyxy"], boxes) + assert np.array_equal(kwargs["class_id"], class_ids) + assert np.array_equal(kwargs["confidence"], scores) + + assert mock_box_annotator.annotate.called + + +def test_draw_detections_label_construction(): + # Setup + img = Image.new("RGB", (100, 100)) + boxes = np.array([[0, 0, 10, 10], [10, 10, 20, 20]]) + class_ids = np.array([0, 1]) + class_names = ["cat", "dog"] + scores = np.array([0.9, 0.8]) + + # First BoxAnnotator (older style) will fail on annotate + mock_box_annotator_old = MagicMock() + mock_box_annotator_old.annotate.side_effect = TypeError( + "Simulation of mismatching arguments" + ) + + # Second BoxAnnotator (modern style) will succeed + mock_box_annotator_new = MagicMock() + mock_box_annotator_new.annotate.return_value = np.zeros( + (100, 100, 3), dtype=np.uint8 + ) + + mock_label_annotator = MagicMock() + mock_label_annotator.annotate.return_value = np.zeros((100, 100, 3), dtype=np.uint8) + + with patch( + "perceptionmetrics.utils.image.sv.BoxAnnotator", + side_effect=[mock_box_annotator_old, mock_box_annotator_new], + ), patch( + "perceptionmetrics.utils.image.sv.LabelAnnotator", + return_value=mock_label_annotator, + ), patch( + "perceptionmetrics.utils.image.sv.Color" + ), patch( + "perceptionmetrics.utils.image.sv.ColorPalette" + ): + + draw_detections(img, boxes, class_ids, class_names, scores) + + # Check labels passed to LabelAnnotator + assert mock_label_annotator.annotate.called + args, kwargs = mock_label_annotator.annotate.call_args + labels = kwargs.get("labels") + assert labels == ["cat: 0.90", "dog: 0.80"] + + +def test_draw_detections_missing_names(): + img = Image.new("RGB", (100, 100)) + boxes = np.array([[0, 0, 10, 10]]) + class_ids = np.array([5]) + class_names = [] # No name for ID 5 + + mock_box_annotator_old = MagicMock() + mock_box_annotator_old.annotate.side_effect = TypeError() + mock_box_annotator_new = MagicMock() + mock_box_annotator_new.annotate.return_value = np.zeros( + (100, 100, 3), dtype=np.uint8 + ) + + mock_label_annotator = MagicMock() + mock_label_annotator.annotate.return_value = np.zeros((100, 100, 3), dtype=np.uint8) + + with patch( + "perceptionmetrics.utils.image.sv.BoxAnnotator", + side_effect=[mock_box_annotator_old, mock_box_annotator_new], + ), patch( + "perceptionmetrics.utils.image.sv.LabelAnnotator", + return_value=mock_label_annotator, + ), patch( + "perceptionmetrics.utils.image.sv.Color" + ), patch( + "perceptionmetrics.utils.image.sv.ColorPalette" + ): + + draw_detections(img, boxes, class_ids, class_names) + + assert mock_label_annotator.annotate.called + args, kwargs = mock_label_annotator.annotate.call_args + labels = kwargs.get("labels") + assert labels == ["5"] diff --git a/tests/test_torch.py b/tests/test_torch.py new file mode 100644 index 00000000..fc8bff83 --- /dev/null +++ b/tests/test_torch.py @@ -0,0 +1,61 @@ +import torch +import pytest +from perceptionmetrics.utils.torch import data_to_device, get_data_shape, unsqueeze_data + + +def test_data_to_device(): + # Setup + device = torch.device("cpu") + t1 = torch.randn(2, 2) + t2 = torch.randn(3, 3) + data = [t1, (t2, "not_a_tensor")] + + # Execute + moved_data = data_to_device(data, device) + + # Verify + assert torch.equal(moved_data[0], t1.to(device)) + assert torch.equal(moved_data[1][0], t2.to(device)) + assert moved_data[1][1] == "not_a_tensor" + assert isinstance(moved_data, list) + assert isinstance(moved_data[1], tuple) + + +def test_get_data_shape(): + # Setup + t1 = torch.randn(2, 3) + t2 = torch.randn(4, 5, 6) + data = (t1, [t2, "label"]) + + # Execute + shapes = get_data_shape(data) + + # Verify + assert shapes == ((2, 3), [(4, 5, 6), "label"]) + assert isinstance(shapes, tuple) + assert isinstance(shapes[1], list) + + +def test_unsqueeze_data(): + # Setup + t1 = torch.randn(2, 2) + t2 = torch.randn(3, 3) + data = [t1, (t2, "text")] + + # Execute + unsqueezed = unsqueeze_data(data, dim=0) + + # Verify + assert unsqueezed[0].shape == (1, 2, 2) + assert unsqueezed[1][0].shape == (1, 3, 3) + assert unsqueezed[1][1] == "text" + assert isinstance(unsqueezed, list) + assert isinstance(unsqueezed[1], tuple) + + +def test_torch_non_tensor_passthrough(): + # Ensure non-tensors are passed through correctly + data = "string" + assert data_to_device(data, torch.device("cpu")) == "string" + assert get_data_shape(data) == "string" + assert unsqueeze_data(data) == "string" From ff091ceb80d7187dcb58ab3333eed2aadc3ba6d2 Mon Sep 17 00:00:00 2001 From: kashtennyson Date: Sun, 19 Apr 2026 14:02:24 +0530 Subject: [PATCH 3/6] updated torch.py functions to raise an explicit TypeError --- perceptionmetrics/utils/torch.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/perceptionmetrics/utils/torch.py b/perceptionmetrics/utils/torch.py index a290dc2b..2b881858 100644 --- a/perceptionmetrics/utils/torch.py +++ b/perceptionmetrics/utils/torch.py @@ -16,6 +16,7 @@ def data_to_device( :type device: torch.device :return: Data moved to device :rtype: Union[tuple, list] + :raises TypeError: If data is not a tensor, list, or tuple """ if isinstance(data, (tuple, list)): return type(data)( @@ -25,7 +26,9 @@ def data_to_device( elif torch.is_tensor(data): return data.to(device) else: - return data + raise TypeError( + f"data_to_device expected torch.Tensor, list, or tuple, but got {type(data)}" + ) def get_data_shape(data: Union[tuple, list]) -> Union[tuple, list]: @@ -35,6 +38,7 @@ def get_data_shape(data: Union[tuple, list]) -> Union[tuple, list]: :type data: Union[tuple, list] :return: Data shape :rtype: Union[tuple, list] + :raises TypeError: If data is not a tensor, list, or tuple """ if isinstance(data, (tuple, list)): return type(data)( @@ -43,7 +47,9 @@ def get_data_shape(data: Union[tuple, list]) -> Union[tuple, list]: elif torch.is_tensor(data): return tuple(data.shape) else: - return data + raise TypeError( + f"get_data_shape expected torch.Tensor, list, or tuple, but got {type(data)}" + ) def unsqueeze_data(data: Union[tuple, list], dim: int = 0) -> Union[tuple, list]: @@ -55,6 +61,7 @@ def unsqueeze_data(data: Union[tuple, list], dim: int = 0) -> Union[tuple, list] :type dim: int, optional :return: Unsqueezed data :rtype: Union[tuple, list] + :raises TypeError: If data is not a tensor, list, or tuple """ if isinstance(data, (tuple, list)): return type(data)( @@ -64,7 +71,9 @@ def unsqueeze_data(data: Union[tuple, list], dim: int = 0) -> Union[tuple, list] elif torch.is_tensor(data): return data.unsqueeze(dim) else: - return data + raise TypeError( + f"unsqueeze_data expected torch.Tensor, list, or tuple, but got {type(data)}" + ) def get_device_info(): From e69b7805434955586f76adc174db8a10b0d17073 Mon Sep 17 00:00:00 2001 From: kashtennyson Date: Sun, 19 Apr 2026 14:06:16 +0530 Subject: [PATCH 4/6] modified test_torch.py to align with the changes in torch.py --- tests/test_torch.py | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/tests/test_torch.py b/tests/test_torch.py index fc8bff83..4ee41bcf 100644 --- a/tests/test_torch.py +++ b/tests/test_torch.py @@ -8,7 +8,7 @@ def test_data_to_device(): device = torch.device("cpu") t1 = torch.randn(2, 2) t2 = torch.randn(3, 3) - data = [t1, (t2, "not_a_tensor")] + data = [t1, (t2,)] # Execute moved_data = data_to_device(data, device) @@ -16,7 +16,6 @@ def test_data_to_device(): # Verify assert torch.equal(moved_data[0], t1.to(device)) assert torch.equal(moved_data[1][0], t2.to(device)) - assert moved_data[1][1] == "not_a_tensor" assert isinstance(moved_data, list) assert isinstance(moved_data[1], tuple) @@ -25,13 +24,13 @@ def test_get_data_shape(): # Setup t1 = torch.randn(2, 3) t2 = torch.randn(4, 5, 6) - data = (t1, [t2, "label"]) + data = (t1, [t2]) # Execute shapes = get_data_shape(data) # Verify - assert shapes == ((2, 3), [(4, 5, 6), "label"]) + assert shapes == ((2, 3), [(4, 5, 6)]) assert isinstance(shapes, tuple) assert isinstance(shapes[1], list) @@ -40,7 +39,7 @@ def test_unsqueeze_data(): # Setup t1 = torch.randn(2, 2) t2 = torch.randn(3, 3) - data = [t1, (t2, "text")] + data = [t1, (t2,)] # Execute unsqueezed = unsqueeze_data(data, dim=0) @@ -48,14 +47,30 @@ def test_unsqueeze_data(): # Verify assert unsqueezed[0].shape == (1, 2, 2) assert unsqueezed[1][0].shape == (1, 3, 3) - assert unsqueezed[1][1] == "text" assert isinstance(unsqueezed, list) assert isinstance(unsqueezed[1], tuple) -def test_torch_non_tensor_passthrough(): - # Ensure non-tensors are passed through correctly +def test_torch_raises_type_error(): + # Ensure non-tensors raise TypeError data = "string" - assert data_to_device(data, torch.device("cpu")) == "string" - assert get_data_shape(data) == "string" - assert unsqueeze_data(data) == "string" + device = torch.device("cpu") + + with pytest.raises(TypeError, match="expected torch.Tensor"): + data_to_device(data, device) + + with pytest.raises(TypeError, match="expected torch.Tensor"): + get_data_shape(data) + + with pytest.raises(TypeError, match="expected torch.Tensor"): + unsqueeze_data(data) + +def test_torch_raises_type_error_nested(): + # Test nested invalid types + data = [torch.randn(2), "invalid"] + + with pytest.raises(TypeError, match="expected torch.Tensor"): + data_to_device(data, torch.device("cpu")) + + with pytest.raises(TypeError, match="expected torch.Tensor"): + get_data_shape(data) From a3cb345bc7b6085619d1f85f34504e826cff4b1c Mon Sep 17 00:00:00 2001 From: kashtennyson Date: Fri, 10 Jul 2026 19:16:34 +0530 Subject: [PATCH 5/6] refactor: rename and organize test scripts to mirror source structure --- .../{test_datasets_perception.py => datasets/test_perception.py} | 0 tests/{ => datasets}/test_yolo.py | 0 tests/{ => utils}/test_conversion.py | 0 tests/{ => utils}/test_detection_metrics.py | 0 tests/{ => utils}/test_image.py | 0 tests/{ => utils}/test_io.py | 0 tests/{ => utils}/test_lidar.py | 0 tests/{ => utils}/test_segmentation_metrics.py | 0 tests/{ => utils}/test_torch.py | 0 9 files changed, 0 insertions(+), 0 deletions(-) rename tests/{test_datasets_perception.py => datasets/test_perception.py} (100%) rename tests/{ => datasets}/test_yolo.py (100%) rename tests/{ => utils}/test_conversion.py (100%) rename tests/{ => utils}/test_detection_metrics.py (100%) rename tests/{ => utils}/test_image.py (100%) rename tests/{ => utils}/test_io.py (100%) rename tests/{ => utils}/test_lidar.py (100%) rename tests/{ => utils}/test_segmentation_metrics.py (100%) rename tests/{ => utils}/test_torch.py (100%) diff --git a/tests/test_datasets_perception.py b/tests/datasets/test_perception.py similarity index 100% rename from tests/test_datasets_perception.py rename to tests/datasets/test_perception.py diff --git a/tests/test_yolo.py b/tests/datasets/test_yolo.py similarity index 100% rename from tests/test_yolo.py rename to tests/datasets/test_yolo.py diff --git a/tests/test_conversion.py b/tests/utils/test_conversion.py similarity index 100% rename from tests/test_conversion.py rename to tests/utils/test_conversion.py diff --git a/tests/test_detection_metrics.py b/tests/utils/test_detection_metrics.py similarity index 100% rename from tests/test_detection_metrics.py rename to tests/utils/test_detection_metrics.py diff --git a/tests/test_image.py b/tests/utils/test_image.py similarity index 100% rename from tests/test_image.py rename to tests/utils/test_image.py diff --git a/tests/test_io.py b/tests/utils/test_io.py similarity index 100% rename from tests/test_io.py rename to tests/utils/test_io.py diff --git a/tests/test_lidar.py b/tests/utils/test_lidar.py similarity index 100% rename from tests/test_lidar.py rename to tests/utils/test_lidar.py diff --git a/tests/test_segmentation_metrics.py b/tests/utils/test_segmentation_metrics.py similarity index 100% rename from tests/test_segmentation_metrics.py rename to tests/utils/test_segmentation_metrics.py diff --git a/tests/test_torch.py b/tests/utils/test_torch.py similarity index 100% rename from tests/test_torch.py rename to tests/utils/test_torch.py From ef6abdb3dc2e167cc2b62af4f10308f5443cd0a6 Mon Sep 17 00:00:00 2001 From: kashtennyson Date: Tue, 14 Jul 2026 15:31:29 +0530 Subject: [PATCH 6/6] test: skip torch tests when torch is unavailable --- tests/utils/test_torch.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/utils/test_torch.py b/tests/utils/test_torch.py index 4ee41bcf..9af9478c 100644 --- a/tests/utils/test_torch.py +++ b/tests/utils/test_torch.py @@ -1,5 +1,9 @@ -import torch import pytest + +# skip these tests when unavailable +torch = pytest.importorskip("torch") +pytest.importorskip("torchvision") + from perceptionmetrics.utils.torch import data_to_device, get_data_shape, unsqueeze_data