From fb49be8197136d0c9ab6d7bd62c07405098849ee Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Tue, 7 May 2024 16:06:58 +0300 Subject: [PATCH 01/10] Tests for the new merge function --- test/detection/test_core.py | 169 +++++++++++++++++++++++++++--------- test/test_utils.py | 18 ++-- 2 files changed, 142 insertions(+), 45 deletions(-) diff --git a/test/detection/test_core.py b/test/detection/test_core.py index f3b739e8..8912f4a6 100644 --- a/test/detection/test_core.py +++ b/test/detection/test_core.py @@ -30,7 +30,81 @@ DETECTIONS = Detections( ) -@pytest.mark.parametrize( +# Merge test +TEST_MASK = np.zeros((1000, 1000), dtype=bool) +TEST_MASK[300:351, 200:251] = True +TEST_DET_1 = mock_detections( + xyxy=[[10, 10, 20, 20], [30, 30, 40, 40], [50, 50, 60, 60]], + mask=[TEST_MASK, TEST_MASK, TEST_MASK], + confidence=[0.1, 0.2, 0.3], + class_id=[1, 2, 3], + tracker_id=[1, 2, 3], + data={ + "some_key": [1, 2, 3], + "other_key": [["1", "2"], ["3", "4"], ["5", "6"]], + } +) +TEST_DET_2 = mock_detections( + xyxy=[[70, 70, 80, 80], [90, 90, 100, 100]], + mask=[TEST_MASK, TEST_MASK], + confidence=[0.4, 0.5], + class_id=[4, 5], + tracker_id=[4, 5], + data={ + "some_key": [4, 5], + "other_key": [["7", "8"], ["9", "10"]], + } +) +TEST_DET_1_2 = mock_detections( + xyxy=[[10, 10, 20, 20], [30, 30, 40, 40], [ + 50, 50, 60, 60], [70, 70, 80, 80], [90, 90, 100, 100]], + mask=[TEST_MASK, TEST_MASK, TEST_MASK, TEST_MASK, TEST_MASK], + confidence=[0.1, 0.2, 0.3, 0.4, 0.5], + class_id=[1, 2, 3, 4, 5], + tracker_id=[1, 2, 3, 4, 5], + data={ + "some_key": [1, 2, 3, 4, 5], + "other_key": [["1", "2"], ["3", "4"], ["5", "6"], ["7", "8"], ["9", "10"]], + } +) +TEST_DET_ZERO_LENGTH = mock_detections( + xyxy=np.empty((0, 4), dtype=np.float32), + mask=np.empty((0, *TEST_MASK.shape), dtype=bool), + confidence=[], + class_id=[], + tracker_id=[], + data={ + "some_key": [], + "other_key": [], + } +) +TEST_DET_NONE = mock_detections( + xyxy=np.empty((0, 4), dtype=np.float32), +) +TEST_DET_DIFFERENT_FIELDS = mock_detections( + xyxy=[[88, 88, 99, 99]], + mask=[np.logical_not(TEST_MASK)], + confidence=None, + class_id=None, + tracker_id=[9], + data={ + "some_key": [9], + "other_key": [["11", "12"]] + } +) +TEST_DET_DIFFERENT_DATA = mock_detections( + xyxy=[[88, 88, 99, 99]], + mask=[np.logical_not(TEST_MASK)], + confidence=[0.9], + class_id=[9], + tracker_id=[9], + data={ + "never_seen_key": [9], + } +) + + +@ pytest.mark.parametrize( "detections, index, expected_result, exception", [ ( @@ -115,7 +189,8 @@ DETECTIONS = Detections( DoesNotRaise(), ), # take only first detection by index slice (1, 3) (DETECTIONS, 10, None, pytest.raises(IndexError)), # index out of range - (DETECTIONS, [0, 2, 10], None, pytest.raises(IndexError)), # index out of range + (DETECTIONS, [0, 2, 10], None, pytest.raises( + IndexError)), # index out of range (DETECTIONS, np.array([0, 2, 10]), None, pytest.raises(IndexError)), ( DETECTIONS, @@ -138,63 +213,79 @@ def test_getitem( assert result == expected_result -@pytest.mark.parametrize( +@ pytest.mark.parametrize( "detections_list, expected_result, exception", [ + # Nothing ([], Detections.empty(), DoesNotRaise()), # empty detections list + + # Single ( [Detections.empty()], Detections.empty(), DoesNotRaise(), ), # single empty detections ( - [mock_detections(xyxy=[[10, 10, 20, 20]])], - mock_detections(xyxy=[[10, 10, 20, 20]]), + [TEST_DET_1], + TEST_DET_1, DoesNotRaise(), - ), # single detection with xyxy field + ), # single detection with fields + ( + [TEST_DET_NONE], + TEST_DET_NONE, + DoesNotRaise(), + ), # Single weakly-defined detection + + # Similar + ( + [Detections.empty(), Detections.empty()], + Detections.empty(), + DoesNotRaise(), + ), # Two empty + ( + [TEST_DET_1, TEST_DET_2], + TEST_DET_1_2, + DoesNotRaise(), + ), # Fields with same keys + + # Fields and empty ( [ - mock_detections(xyxy=[[10, 10, 20, 20]]), - mock_detections(xyxy=np.empty((0, 4), dtype=np.float32)), + TEST_DET_1, + Detections.empty() ], - mock_detections(xyxy=[[10, 10, 20, 20]]), + TEST_DET_1, DoesNotRaise(), - ), # single detection with xyxy field + empty detection + ), # single detection with fields ( [ - mock_detections(xyxy=[[10, 10, 20, 20]]), - mock_detections(xyxy=[[20, 20, 30, 30]]), + TEST_DET_1, + TEST_DET_ZERO_LENGTH, ], - mock_detections(xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]]), + TEST_DET_1, DoesNotRaise(), - ), # two detections with xyxy field + ), # Single detection and empty-array fields ( [ - mock_detections(xyxy=[[10, 10, 20, 20]], class_id=[0]), - mock_detections(xyxy=[[20, 20, 30, 30]]), + TEST_DET_1, + TEST_DET_NONE, ], - mock_detections(xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]]), + TEST_DET_1, + DoesNotRaise(), + ), # Single detection and None fields (+ missing Dict keys) + + # Errors: Non-zero-length differently defined keys & data + ( + [TEST_DET_1, TEST_DET_DIFFERENT_FIELDS], + None, + pytest.raises(ValueError) + ), # Non-empty detections with different fields + ( + [TEST_DET_1, TEST_DET_DIFFERENT_DATA], + None, pytest.raises(ValueError), - ), # detection with xyxy, class_id fields + detection with xyxy field - ( - [ - mock_detections(xyxy=[[10, 10, 20, 20]], class_id=[0]), - mock_detections(xyxy=[[20, 20, 30, 30]], class_id=[1]), - ], - mock_detections(xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]], class_id=[0, 1]), - DoesNotRaise(), - ), # two detections with xyxy, class_id fields - ( - [ - mock_detections(xyxy=[[10, 10, 20, 20]], data={"test": [1]}), - mock_detections(xyxy=[[20, 20, 30, 30]], data={"test": [2]}), - ], - mock_detections( - xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]], data={"test": [1, 2]} - ), - DoesNotRaise(), - ), # two detections with xyxy, data fields - ], + ), # Non-empty detections with different data keys + ] ) def test_merge( detections_list: List[Detections], @@ -206,7 +297,7 @@ def test_merge( assert result == expected_result -@pytest.mark.parametrize( +@ pytest.mark.parametrize( "detections, anchor, expected_result, exception", [ ( @@ -288,7 +379,7 @@ def test_get_anchor_coordinates( assert np.array_equal(result, expected_result) -@pytest.mark.parametrize( +@ pytest.mark.parametrize( "detections_a, detections_b, expected_result", [ ( diff --git a/test/test_utils.py b/test/test_utils.py index b676cb54..37be31d3 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -21,11 +21,14 @@ def mock_detections( xyxy=np.array(xyxy, dtype=np.float32), mask=(mask if mask is None else np.array(mask, dtype=bool)), confidence=( - confidence if confidence is None else np.array(confidence, dtype=np.float32) + confidence if confidence is None else np.array( + confidence, dtype=np.float32) ), - class_id=(class_id if class_id is None else np.array(class_id, dtype=int)), + class_id=(class_id if class_id is None else np.array( + class_id, dtype=int)), tracker_id=( - tracker_id if tracker_id is None else np.array(tracker_id, dtype=int) + tracker_id if tracker_id is None else np.array( + tracker_id, dtype=int) ), data=convert_data(data) if data else {}, ) @@ -43,12 +46,15 @@ def mock_keypoints( return KeyPoints( xy=np.array(xy, dtype=np.float32), confidence=( - confidence if confidence is None else np.array(confidence, dtype=np.float32) + confidence if confidence is None else np.array( + confidence, dtype=np.float32) ), - class_id=(class_id if class_id is None else np.array(class_id, dtype=int)), + class_id=(class_id if class_id is None else np.array( + class_id, dtype=int)), data=convert_data(data) if data else {}, ) def assert_almost_equal(actual, expected, tolerance=1e-5): - assert abs(actual - expected) < tolerance, f"Expected {expected}, but got {actual}." + assert abs( + actual - expected) < tolerance, f"Expected {expected}, but got {actual}." From 7bb94ce966465bbcb9815acc8a0b1c8a2100e0a9 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Tue, 7 May 2024 17:30:12 +0300 Subject: [PATCH 02/10] Detections.merge merges None and [] * Detections.merge is much friendlier now. If there's a None or an empty array, it will merge it happily rather than complaining that everything needs to be either None or []. * Data merge follows suit. --- supervision/detection/core.py | 20 +++++++------- supervision/detection/utils.py | 50 ++++++++++++++++++++++++++-------- test/detection/test_core.py | 48 ++++++++++++++------------------ test/detection/test_utils.py | 23 ++++++++++++++++ test/test_utils.py | 18 ++++-------- 5 files changed, 99 insertions(+), 60 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 1900954d..e9baef7a 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -831,9 +831,10 @@ class Detections: This method takes a list of Detections objects and combines their respective fields (`xyxy`, `mask`, `confidence`, `class_id`, and `tracker_id`) - into a single Detections object. If all elements in a field are not - `None`, the corresponding field will be stacked. - Otherwise, the field will be set to `None`. + into a single Detections object. + + For example, if merging Detections with 3 and 4 detected objects, this method + will return a Detections with 7 objects (7 entries in `xyxy`, `mask`, etc). Args: detections_list (List[Detections]): A list of Detections objects to merge. @@ -891,13 +892,12 @@ class Detections: def stack_or_none(name: str): if all(d.__getattribute__(name) is None for d in detections_list): return None - if any(d.__getattribute__(name) is None for d in detections_list): - raise ValueError(f"All or none of the '{name}' fields must be None") - return ( - np.vstack([d.__getattribute__(name) for d in detections_list]) - if name == "mask" - else np.hstack([d.__getattribute__(name) for d in detections_list]) - ) + stack_list = [ + d.__getattribute__(name) + for d in detections_list + if d.__getattribute__(name) is not None + ] + return np.vstack(stack_list) if name == "mask" else np.hstack(stack_list) mask = stack_or_none("mask") confidence = stack_or_none("confidence") diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 3eeba5b4..8fac9f90 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -1,5 +1,5 @@ from itertools import chain -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, List, Optional, Set, Tuple, Union import cv2 import numpy as np @@ -691,23 +691,51 @@ def merge_data( if not data_list: return {} - all_keys_sets = [set(data.keys()) for data in data_list] - if not all(keys_set == all_keys_sets[0] for keys_set in all_keys_sets): - raise ValueError("All data dictionaries must have the same keys to merge.") - for data in data_list: - lengths = [len(value) for value in data.values()] - if len(set(lengths)) > 1: + lengths_set = [len(value) for value in data.values()] + if len(set(lengths_set)) > 1: raise ValueError( "All data values within a single object must have equal length." ) - merged_data = {key: [] for key in all_keys_sets[0]} - + all_keys: Set[str] = set() for data in data_list: - for key in merged_data: - merged_data[key].append(data[key]) + all_keys.update(data.keys()) + # Naively merging entries and then validating length comes with a problem: + # N values may come from data[0]["key_1"] and N values from data[1]["key_2"]. + # These should not be joined together. + # Here, as soon as we find data of len > 0, we lock the key set and raise + # a ValueError if later we find a value of len > 0 with an unknown key. + key_set = None + merged_data = {key: [] for key in all_keys} + for data in data_list: + data_key_set = set() + for key in data: + if len(data[key]) > 0: + if key_set is None: + data_key_set.add(key) + elif key not in key_set: + raise ValueError(f"Unknown key '{key}' found in data payload.") + merged_data[key].append(data[key]) + + if key_set is None and data_key_set: + key_set = data_key_set + + merged_data = {key: val for key, val in merged_data.items() if len(val) > 0} + + sum_lengths = {} # Validation. More useful than set for error message + for key, value in merged_data.items(): + sum_length = sum(len(item) for item in value) + sum_lengths[key] = sum_length + lengths_set = set(sum_lengths.values()) + if len(lengths_set) > 1: + raise ValueError( + f"All data fields should have the same lengths after merge." + f"Resulting lengths: {sum_lengths}" + ) + + key_set = set() for key in merged_data: if all(isinstance(item, list) for item in merged_data[key]): merged_data[key] = list(chain.from_iterable(merged_data[key])) diff --git a/test/detection/test_core.py b/test/detection/test_core.py index 8912f4a6..8f156238 100644 --- a/test/detection/test_core.py +++ b/test/detection/test_core.py @@ -42,7 +42,7 @@ TEST_DET_1 = mock_detections( data={ "some_key": [1, 2, 3], "other_key": [["1", "2"], ["3", "4"], ["5", "6"]], - } + }, ) TEST_DET_2 = mock_detections( xyxy=[[70, 70, 80, 80], [90, 90, 100, 100]], @@ -53,11 +53,16 @@ TEST_DET_2 = mock_detections( data={ "some_key": [4, 5], "other_key": [["7", "8"], ["9", "10"]], - } + }, ) TEST_DET_1_2 = mock_detections( - xyxy=[[10, 10, 20, 20], [30, 30, 40, 40], [ - 50, 50, 60, 60], [70, 70, 80, 80], [90, 90, 100, 100]], + xyxy=[ + [10, 10, 20, 20], + [30, 30, 40, 40], + [50, 50, 60, 60], + [70, 70, 80, 80], + [90, 90, 100, 100], + ], mask=[TEST_MASK, TEST_MASK, TEST_MASK, TEST_MASK, TEST_MASK], confidence=[0.1, 0.2, 0.3, 0.4, 0.5], class_id=[1, 2, 3, 4, 5], @@ -65,7 +70,7 @@ TEST_DET_1_2 = mock_detections( data={ "some_key": [1, 2, 3, 4, 5], "other_key": [["1", "2"], ["3", "4"], ["5", "6"], ["7", "8"], ["9", "10"]], - } + }, ) TEST_DET_ZERO_LENGTH = mock_detections( xyxy=np.empty((0, 4), dtype=np.float32), @@ -76,7 +81,7 @@ TEST_DET_ZERO_LENGTH = mock_detections( data={ "some_key": [], "other_key": [], - } + }, ) TEST_DET_NONE = mock_detections( xyxy=np.empty((0, 4), dtype=np.float32), @@ -87,10 +92,7 @@ TEST_DET_DIFFERENT_FIELDS = mock_detections( confidence=None, class_id=None, tracker_id=[9], - data={ - "some_key": [9], - "other_key": [["11", "12"]] - } + data={"some_key": [9], "other_key": [["11", "12"]]}, ) TEST_DET_DIFFERENT_DATA = mock_detections( xyxy=[[88, 88, 99, 99]], @@ -100,11 +102,11 @@ TEST_DET_DIFFERENT_DATA = mock_detections( tracker_id=[9], data={ "never_seen_key": [9], - } + }, ) -@ pytest.mark.parametrize( +@pytest.mark.parametrize( "detections, index, expected_result, exception", [ ( @@ -189,8 +191,7 @@ TEST_DET_DIFFERENT_DATA = mock_detections( DoesNotRaise(), ), # take only first detection by index slice (1, 3) (DETECTIONS, 10, None, pytest.raises(IndexError)), # index out of range - (DETECTIONS, [0, 2, 10], None, pytest.raises( - IndexError)), # index out of range + (DETECTIONS, [0, 2, 10], None, pytest.raises(IndexError)), # index out of range (DETECTIONS, np.array([0, 2, 10]), None, pytest.raises(IndexError)), ( DETECTIONS, @@ -213,12 +214,11 @@ def test_getitem( assert result == expected_result -@ pytest.mark.parametrize( +@pytest.mark.parametrize( "detections_list, expected_result, exception", [ # Nothing ([], Detections.empty(), DoesNotRaise()), # empty detections list - # Single ( [Detections.empty()], @@ -235,7 +235,6 @@ def test_getitem( TEST_DET_NONE, DoesNotRaise(), ), # Single weakly-defined detection - # Similar ( [Detections.empty(), Detections.empty()], @@ -247,13 +246,9 @@ def test_getitem( TEST_DET_1_2, DoesNotRaise(), ), # Fields with same keys - # Fields and empty ( - [ - TEST_DET_1, - Detections.empty() - ], + [TEST_DET_1, Detections.empty()], TEST_DET_1, DoesNotRaise(), ), # single detection with fields @@ -273,19 +268,18 @@ def test_getitem( TEST_DET_1, DoesNotRaise(), ), # Single detection and None fields (+ missing Dict keys) - # Errors: Non-zero-length differently defined keys & data ( [TEST_DET_1, TEST_DET_DIFFERENT_FIELDS], None, - pytest.raises(ValueError) + pytest.raises(ValueError), ), # Non-empty detections with different fields ( [TEST_DET_1, TEST_DET_DIFFERENT_DATA], None, pytest.raises(ValueError), ), # Non-empty detections with different data keys - ] + ], ) def test_merge( detections_list: List[Detections], @@ -297,7 +291,7 @@ def test_merge( assert result == expected_result -@ pytest.mark.parametrize( +@pytest.mark.parametrize( "detections, anchor, expected_result, exception", [ ( @@ -379,7 +373,7 @@ def test_get_anchor_coordinates( assert np.array_equal(result, expected_result) -@ pytest.mark.parametrize( +@pytest.mark.parametrize( "detections_a, detections_b, expected_result", [ ( diff --git a/test/detection/test_utils.py b/test/detection/test_utils.py index 1c4a1d34..0a48be36 100644 --- a/test/detection/test_utils.py +++ b/test/detection/test_utils.py @@ -1012,6 +1012,29 @@ def test_calculate_masks_centroids( None, pytest.raises(ValueError), ), # two data dicts with the same field name and different length arrays values + ( + [{}, {"test_1": [1, 2, 3]}], + {"test_1": [1, 2, 3]}, + DoesNotRaise(), + ), # No keys in one dict + ( + [{"test_1": [], "test_2": []}, {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}], + {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}, + DoesNotRaise(), + ), # Empty values dicts + ( + [{"test_1": []}, {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}], + {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}, + DoesNotRaise(), + ), # Mix of missing key and empty values + ( + [ + {"test_1": [1, 2, 3]}, + {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}, + ], + None, + pytest.raises(ValueError), + ), # some keys missing in one dict ], ) def test_merge_data( diff --git a/test/test_utils.py b/test/test_utils.py index 37be31d3..b676cb54 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -21,14 +21,11 @@ def mock_detections( xyxy=np.array(xyxy, dtype=np.float32), mask=(mask if mask is None else np.array(mask, dtype=bool)), confidence=( - confidence if confidence is None else np.array( - confidence, dtype=np.float32) + confidence if confidence is None else np.array(confidence, dtype=np.float32) ), - class_id=(class_id if class_id is None else np.array( - class_id, dtype=int)), + class_id=(class_id if class_id is None else np.array(class_id, dtype=int)), tracker_id=( - tracker_id if tracker_id is None else np.array( - tracker_id, dtype=int) + tracker_id if tracker_id is None else np.array(tracker_id, dtype=int) ), data=convert_data(data) if data else {}, ) @@ -46,15 +43,12 @@ def mock_keypoints( return KeyPoints( xy=np.array(xy, dtype=np.float32), confidence=( - confidence if confidence is None else np.array( - confidence, dtype=np.float32) + confidence if confidence is None else np.array(confidence, dtype=np.float32) ), - class_id=(class_id if class_id is None else np.array( - class_id, dtype=int)), + class_id=(class_id if class_id is None else np.array(class_id, dtype=int)), data=convert_data(data) if data else {}, ) def assert_almost_equal(actual, expected, tolerance=1e-5): - assert abs( - actual - expected) < tolerance, f"Expected {expected}, but got {actual}." + assert abs(actual - expected) < tolerance, f"Expected {expected}, but got {actual}." From 1ebbe3a8d104dfc20cd52a5270cd983581861e74 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Thu, 9 May 2024 09:17:37 +0300 Subject: [PATCH 03/10] Removed comments, deindented, removed unused var --- supervision/detection/utils.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 8fac9f90..2f180405 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -702,29 +702,26 @@ def merge_data( for data in data_list: all_keys.update(data.keys()) - # Naively merging entries and then validating length comes with a problem: - # N values may come from data[0]["key_1"] and N values from data[1]["key_2"]. - # These should not be joined together. - # Here, as soon as we find data of len > 0, we lock the key set and raise - # a ValueError if later we find a value of len > 0 with an unknown key. key_set = None merged_data = {key: [] for key in all_keys} for data in data_list: data_key_set = set() for key in data: - if len(data[key]) > 0: - if key_set is None: - data_key_set.add(key) - elif key not in key_set: - raise ValueError(f"Unknown key '{key}' found in data payload.") - merged_data[key].append(data[key]) + if len(data[key]) == 0: + continue + + if key_set is None: + data_key_set.add(key) + elif key not in key_set: + raise ValueError(f"Unknown key '{key}' found in data payload.") + merged_data[key].append(data[key]) if key_set is None and data_key_set: key_set = data_key_set merged_data = {key: val for key, val in merged_data.items() if len(val) > 0} - sum_lengths = {} # Validation. More useful than set for error message + sum_lengths = {} for key, value in merged_data.items(): sum_length = sum(len(item) for item in value) sum_lengths[key] = sum_length @@ -735,7 +732,6 @@ def merge_data( f"Resulting lengths: {sum_lengths}" ) - key_set = set() for key in merged_data: if all(isinstance(item, list) for item in merged_data[key]): merged_data[key] = list(chain.from_iterable(merged_data[key])) From 8c58ebe30db597045b235b640cda80bb8ef16168 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Thu, 9 May 2024 17:06:51 +0300 Subject: [PATCH 04/10] Roll back flex-merge on data, merge when key missing --- supervision/detection/utils.py | 58 ++++++++++++++++------------------ test/detection/test_utils.py | 27 ++++++++++++---- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 2f180405..254a01e8 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -1,5 +1,5 @@ from itertools import chain -from typing import Dict, List, Optional, Set, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union import cv2 import numpy as np @@ -692,48 +692,46 @@ def merge_data( return {} for data in data_list: - lengths_set = [len(value) for value in data.values()] - if len(set(lengths_set)) > 1: + lengths = [len(value) for value in data.values()] + if len(set(lengths)) > 1: raise ValueError( "All data values within a single object must have equal length." ) - all_keys: Set[str] = set() - for data in data_list: - all_keys.update(data.keys()) + data_keys = [set(data.keys()) for data in data_list] + data_keys = [key_set for key_set in data_keys if len(key_set) > 0] + if not data_keys: + return {} + + common_keys = set.intersection(*data_keys) + all_keys = set.union(*data_keys) + if common_keys != all_keys: + raise ValueError( + f"All data dictionaries must have the same keys to merge. Found {data_keys}" + ) + + data_types = {} + for key in all_keys: + for data in data_list: + if key not in data: + continue + data_types[key] = type(data[key]) + break - key_set = None merged_data = {key: [] for key in all_keys} for data in data_list: - data_key_set = set() for key in data: if len(data[key]) == 0: continue - - if key_set is None: - data_key_set.add(key) - elif key not in key_set: - raise ValueError(f"Unknown key '{key}' found in data payload.") merged_data[key].append(data[key]) - if key_set is None and data_key_set: - key_set = data_key_set - - merged_data = {key: val for key, val in merged_data.items() if len(val) > 0} - - sum_lengths = {} - for key, value in merged_data.items(): - sum_length = sum(len(item) for item in value) - sum_lengths[key] = sum_length - lengths_set = set(sum_lengths.values()) - if len(lengths_set) > 1: - raise ValueError( - f"All data fields should have the same lengths after merge." - f"Resulting lengths: {sum_lengths}" - ) - for key in merged_data: - if all(isinstance(item, list) for item in merged_data[key]): + if len(merged_data[key]) == 0: + if data_types[key] == np.ndarray: + merged_data[key] = np.array(merged_data[key]) + else: + merged_data[key] = list(merged_data[key]) + elif all(isinstance(item, list) for item in merged_data[key]): merged_data[key] = list(chain.from_iterable(merged_data[key])) elif all(isinstance(item, np.ndarray) for item in merged_data[key]): ndim = merged_data[key][0].ndim diff --git a/test/detection/test_utils.py b/test/detection/test_utils.py index 0a48be36..22d0a430 100644 --- a/test/detection/test_utils.py +++ b/test/detection/test_utils.py @@ -1016,17 +1016,29 @@ def test_calculate_masks_centroids( [{}, {"test_1": [1, 2, 3]}], {"test_1": [1, 2, 3]}, DoesNotRaise(), - ), # No keys in one dict + ), # Empty, no keys ( [{"test_1": [], "test_2": []}, {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}], {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}, DoesNotRaise(), - ), # Empty values dicts + ), # Empty, same keys ( - [{"test_1": []}, {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}], - {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}, - DoesNotRaise(), - ), # Mix of missing key and empty values + [{"test_1": []}, {"test_1": [1, 2, 3], "test_2": [4, 5, 6]}], + None, + pytest.raises(ValueError), + ), # Empty, missing key + ( + [ + { + "test_1": [1, 2, 3], + "test_2": [4, 5, 6], + "test_3": [7, 8, 9], + }, + {"test_1": [1, 2, 3], "test_2": [4, 5, 6]}, + ], + None, + pytest.raises(ValueError), + ), # Empty, too many keys ( [ {"test_1": [1, 2, 3]}, @@ -1044,6 +1056,9 @@ def test_merge_data( ): with exception: result = merge_data(data_list=data_list) + if expected_result is None: + assert False, f"Expected an error, but got result {result}" + for key in result: if isinstance(result[key], np.ndarray): assert np.array_equal( From b6a55694f2bc0fa52293ae2882e94471f1447c20 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Thu, 9 May 2024 17:12:53 +0300 Subject: [PATCH 05/10] Move type resolution logic to loop where it's used --- supervision/detection/utils.py | 66 +++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 254a01e8..28bde1b6 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -55,7 +55,8 @@ def box_iou_batch(boxes_true: np.ndarray, boxes_detection: np.ndarray) -> np.nda top_left = np.maximum(boxes_true[:, None, :2], boxes_detection[:, :2]) bottom_right = np.minimum(boxes_true[:, None, 2:], boxes_detection[:, 2:]) - area_inter = np.prod(np.clip(bottom_right - top_left, a_min=0, a_max=None), 2) + area_inter = np.prod( + np.clip(bottom_right - top_left, a_min=0, a_max=None), 2) return area_inter / (area_true[:, None] + area_detection - area_inter) @@ -80,7 +81,8 @@ def _mask_iou_batch_split( masks_true_area = masks_true.sum(axis=(1, 2)) masks_detection_area = masks_detection.sum(axis=(1, 2)) - union_area = masks_true_area[:, None] + masks_detection_area - intersection_area + union_area = masks_true_area[:, None] + \ + masks_detection_area - intersection_area return np.divide( intersection_area, @@ -131,7 +133,8 @@ def mask_iou_batch( 1, ) for i in range(0, masks_true.shape[0], step): - ious.append(_mask_iou_batch_split(masks_true[i : i + step], masks_detection)) + ious.append(_mask_iou_batch_split( + masks_true[i: i + step], masks_detection)) return np.vstack(ious) @@ -161,7 +164,8 @@ def resize_masks(masks: np.ndarray, max_dimension: int = 640) -> np.ndarray: resized_masks = masks[:, yv, xv] - resized_masks = resized_masks.reshape(masks.shape[0], new_height, new_width) + resized_masks = resized_masks.reshape( + masks.shape[0], new_height, new_width) return resized_masks @@ -214,8 +218,9 @@ def mask_non_max_suppression( keep = np.ones(rows, dtype=bool) for i in range(rows): if keep[i]: - condition = (ious[i] > iou_threshold) & (categories[i] == categories) - keep[i + 1 :] = np.where(condition[i + 1 :], False, keep[i + 1 :]) + condition = (ious[i] > iou_threshold) & ( + categories[i] == categories) + keep[i + 1:] = np.where(condition[i + 1:], False, keep[i + 1:]) return keep[sort_index.argsort()] @@ -447,7 +452,8 @@ def approximate_polygon( approximated_points = polygon while True: epsilon += epsilon_step - new_approximated_points = cv2.approxPolyDP(polygon, epsilon, closed=True) + new_approximated_points = cv2.approxPolyDP( + polygon, epsilon, closed=True) if len(new_approximated_points) > target_points: approximated_points = new_approximated_points else: @@ -476,7 +482,8 @@ def extract_ultralytics_masks(yolov8_results) -> Optional[np.ndarray]: ) top, left = int(pad[1]), int(pad[0]) - bottom, right = int(inference_shape[0] - pad[1]), int(inference_shape[1] - pad[0]) + bottom, right = int( + inference_shape[0] - pad[1]), int(inference_shape[1] - pad[0]) mask_maps = [] masks = yolov8_results.masks.data.cpu().numpy() @@ -543,7 +550,8 @@ def process_roboflow_result( polygon = np.array( [[point["x"], point["y"]] for point in prediction["points"]], dtype=int ) - mask = polygon_to_mask(polygon, resolution_wh=(image_width, image_height)) + mask = polygon_to_mask( + polygon, resolution_wh=(image_width, image_height)) xyxy.append([x_min, y_min, x_max, y_max]) class_id.append(prediction["class_id"]) class_name.append(prediction["class"]) @@ -554,10 +562,12 @@ def process_roboflow_result( xyxy = np.array(xyxy) if len(xyxy) > 0 else np.empty((0, 4)) confidence = np.array(confidence) if len(confidence) > 0 else np.empty(0) - class_id = np.array(class_id).astype(int) if len(class_id) > 0 else np.empty(0) + class_id = np.array(class_id).astype( + int) if len(class_id) > 0 else np.empty(0) class_name = np.array(class_name) if len(class_name) > 0 else np.empty(0) masks = np.array(masks, dtype=bool) if len(masks) > 0 else None - tracker_id = np.array(tracker_ids).astype(int) if len(tracker_ids) > 0 else None + tracker_id = np.array(tracker_ids).astype( + int) if len(tracker_ids) > 0 else None data = {CLASS_NAME_DATA_FIELD: class_name} return xyxy, confidence, class_id, masks, tracker_id, data @@ -650,8 +660,10 @@ def calculate_masks_centroids(masks: np.ndarray) -> np.ndarray: return np.tensordot(masks, indices, axes=axis) aggregation_axis = ([1, 2], [0, 1]) - centroid_x = sum_over_mask(horizontal_indices, aggregation_axis) / total_pixels - centroid_y = sum_over_mask(vertical_indices, aggregation_axis) / total_pixels + centroid_x = sum_over_mask( + horizontal_indices, aggregation_axis) / total_pixels + centroid_y = sum_over_mask( + vertical_indices, aggregation_axis) / total_pixels return np.column_stack((centroid_x, centroid_y)).astype(int) @@ -710,14 +722,6 @@ def merge_data( f"All data dictionaries must have the same keys to merge. Found {data_keys}" ) - data_types = {} - for key in all_keys: - for data in data_list: - if key not in data: - continue - data_types[key] = type(data[key]) - break - merged_data = {key: [] for key in all_keys} for data in data_list: for key in data: @@ -727,10 +731,20 @@ def merge_data( for key in merged_data: if len(merged_data[key]) == 0: - if data_types[key] == np.ndarray: + for data in data_list: + if key not in data: + continue + data_type = type(data[key]) + break + if data_type == np.ndarray: merged_data[key] = np.array(merged_data[key]) - else: + elif data_type == list: merged_data[key] = list(merged_data[key]) + else: + raise ValueError( + f"Inconsistent data types for key '{key}'. Only np.ndarray and list " + f"types are allowed." + ) elif all(isinstance(item, list) for item in merged_data[key]): merged_data[key] = list(chain.from_iterable(merged_data[key])) elif all(isinstance(item, np.ndarray) for item in merged_data[key]): @@ -740,7 +754,8 @@ def merge_data( elif ndim > 1: merged_data[key] = np.vstack(merged_data[key]) else: - raise ValueError(f"Unexpected array dimension for key '{key}'.") + raise ValueError( + f"Unexpected array dimension for key '{key}'.") else: raise ValueError( f"Inconsistent data types for key '{key}'. Only np.ndarray and list " @@ -785,6 +800,7 @@ def get_data_item( else: raise TypeError(f"Unsupported index type: {type(index)}") else: - raise TypeError(f"Unsupported data type for key '{key}': {type(value)}") + raise TypeError( + f"Unsupported data type for key '{key}': {type(value)}") return subset_data From 4cd1fbcef9ef71b5924a44d416cbad08d4786b81 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 9 May 2024 14:15:29 +0000 Subject: [PATCH 06/10] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20auto?= =?UTF-8?q?=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supervision/detection/utils.py | 44 ++++++++++++---------------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 28bde1b6..805f6bfb 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -55,8 +55,7 @@ def box_iou_batch(boxes_true: np.ndarray, boxes_detection: np.ndarray) -> np.nda top_left = np.maximum(boxes_true[:, None, :2], boxes_detection[:, :2]) bottom_right = np.minimum(boxes_true[:, None, 2:], boxes_detection[:, 2:]) - area_inter = np.prod( - np.clip(bottom_right - top_left, a_min=0, a_max=None), 2) + area_inter = np.prod(np.clip(bottom_right - top_left, a_min=0, a_max=None), 2) return area_inter / (area_true[:, None] + area_detection - area_inter) @@ -81,8 +80,7 @@ def _mask_iou_batch_split( masks_true_area = masks_true.sum(axis=(1, 2)) masks_detection_area = masks_detection.sum(axis=(1, 2)) - union_area = masks_true_area[:, None] + \ - masks_detection_area - intersection_area + union_area = masks_true_area[:, None] + masks_detection_area - intersection_area return np.divide( intersection_area, @@ -133,8 +131,7 @@ def mask_iou_batch( 1, ) for i in range(0, masks_true.shape[0], step): - ious.append(_mask_iou_batch_split( - masks_true[i: i + step], masks_detection)) + ious.append(_mask_iou_batch_split(masks_true[i : i + step], masks_detection)) return np.vstack(ious) @@ -164,8 +161,7 @@ def resize_masks(masks: np.ndarray, max_dimension: int = 640) -> np.ndarray: resized_masks = masks[:, yv, xv] - resized_masks = resized_masks.reshape( - masks.shape[0], new_height, new_width) + resized_masks = resized_masks.reshape(masks.shape[0], new_height, new_width) return resized_masks @@ -218,9 +214,8 @@ def mask_non_max_suppression( keep = np.ones(rows, dtype=bool) for i in range(rows): if keep[i]: - condition = (ious[i] > iou_threshold) & ( - categories[i] == categories) - keep[i + 1:] = np.where(condition[i + 1:], False, keep[i + 1:]) + condition = (ious[i] > iou_threshold) & (categories[i] == categories) + keep[i + 1 :] = np.where(condition[i + 1 :], False, keep[i + 1 :]) return keep[sort_index.argsort()] @@ -452,8 +447,7 @@ def approximate_polygon( approximated_points = polygon while True: epsilon += epsilon_step - new_approximated_points = cv2.approxPolyDP( - polygon, epsilon, closed=True) + new_approximated_points = cv2.approxPolyDP(polygon, epsilon, closed=True) if len(new_approximated_points) > target_points: approximated_points = new_approximated_points else: @@ -482,8 +476,7 @@ def extract_ultralytics_masks(yolov8_results) -> Optional[np.ndarray]: ) top, left = int(pad[1]), int(pad[0]) - bottom, right = int( - inference_shape[0] - pad[1]), int(inference_shape[1] - pad[0]) + bottom, right = int(inference_shape[0] - pad[1]), int(inference_shape[1] - pad[0]) mask_maps = [] masks = yolov8_results.masks.data.cpu().numpy() @@ -550,8 +543,7 @@ def process_roboflow_result( polygon = np.array( [[point["x"], point["y"]] for point in prediction["points"]], dtype=int ) - mask = polygon_to_mask( - polygon, resolution_wh=(image_width, image_height)) + mask = polygon_to_mask(polygon, resolution_wh=(image_width, image_height)) xyxy.append([x_min, y_min, x_max, y_max]) class_id.append(prediction["class_id"]) class_name.append(prediction["class"]) @@ -562,12 +554,10 @@ def process_roboflow_result( xyxy = np.array(xyxy) if len(xyxy) > 0 else np.empty((0, 4)) confidence = np.array(confidence) if len(confidence) > 0 else np.empty(0) - class_id = np.array(class_id).astype( - int) if len(class_id) > 0 else np.empty(0) + class_id = np.array(class_id).astype(int) if len(class_id) > 0 else np.empty(0) class_name = np.array(class_name) if len(class_name) > 0 else np.empty(0) masks = np.array(masks, dtype=bool) if len(masks) > 0 else None - tracker_id = np.array(tracker_ids).astype( - int) if len(tracker_ids) > 0 else None + tracker_id = np.array(tracker_ids).astype(int) if len(tracker_ids) > 0 else None data = {CLASS_NAME_DATA_FIELD: class_name} return xyxy, confidence, class_id, masks, tracker_id, data @@ -660,10 +650,8 @@ def calculate_masks_centroids(masks: np.ndarray) -> np.ndarray: return np.tensordot(masks, indices, axes=axis) aggregation_axis = ([1, 2], [0, 1]) - centroid_x = sum_over_mask( - horizontal_indices, aggregation_axis) / total_pixels - centroid_y = sum_over_mask( - vertical_indices, aggregation_axis) / total_pixels + centroid_x = sum_over_mask(horizontal_indices, aggregation_axis) / total_pixels + centroid_y = sum_over_mask(vertical_indices, aggregation_axis) / total_pixels return np.column_stack((centroid_x, centroid_y)).astype(int) @@ -754,8 +742,7 @@ def merge_data( elif ndim > 1: merged_data[key] = np.vstack(merged_data[key]) else: - raise ValueError( - f"Unexpected array dimension for key '{key}'.") + raise ValueError(f"Unexpected array dimension for key '{key}'.") else: raise ValueError( f"Inconsistent data types for key '{key}'. Only np.ndarray and list " @@ -800,7 +787,6 @@ def get_data_item( else: raise TypeError(f"Unsupported index type: {type(index)}") else: - raise TypeError( - f"Unsupported data type for key '{key}': {type(value)}") + raise TypeError(f"Unsupported data type for key '{key}': {type(value)}") return subset_data From 01f7eb5be61b52c590fbc03da5e8641a2f44b313 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Thu, 9 May 2024 18:19:51 +0300 Subject: [PATCH 07/10] Retain type info by not excluding empty detections --- supervision/detection/utils.py | 19 +------- test/detection/test_core.py | 80 +++++++++++++++++----------------- 2 files changed, 42 insertions(+), 57 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 805f6bfb..512489ca 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -713,27 +713,10 @@ def merge_data( merged_data = {key: [] for key in all_keys} for data in data_list: for key in data: - if len(data[key]) == 0: - continue merged_data[key].append(data[key]) for key in merged_data: - if len(merged_data[key]) == 0: - for data in data_list: - if key not in data: - continue - data_type = type(data[key]) - break - if data_type == np.ndarray: - merged_data[key] = np.array(merged_data[key]) - elif data_type == list: - merged_data[key] = list(merged_data[key]) - else: - raise ValueError( - f"Inconsistent data types for key '{key}'. Only np.ndarray and list " - f"types are allowed." - ) - elif all(isinstance(item, list) for item in merged_data[key]): + if all(isinstance(item, list) for item in merged_data[key]): merged_data[key] = list(chain.from_iterable(merged_data[key])) elif all(isinstance(item, np.ndarray) for item in merged_data[key]): ndim = merged_data[key][0].ndim diff --git a/test/detection/test_core.py b/test/detection/test_core.py index 8f156238..4dd6e467 100644 --- a/test/detection/test_core.py +++ b/test/detection/test_core.py @@ -33,73 +33,75 @@ DETECTIONS = Detections( # Merge test TEST_MASK = np.zeros((1000, 1000), dtype=bool) TEST_MASK[300:351, 200:251] = True -TEST_DET_1 = mock_detections( - xyxy=[[10, 10, 20, 20], [30, 30, 40, 40], [50, 50, 60, 60]], - mask=[TEST_MASK, TEST_MASK, TEST_MASK], - confidence=[0.1, 0.2, 0.3], - class_id=[1, 2, 3], - tracker_id=[1, 2, 3], +TEST_DET_1 = Detections( + xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40], [50, 50, 60, 60]]), + mask=np.array([TEST_MASK, TEST_MASK, TEST_MASK]), + confidence=np.array([0.1, 0.2, 0.3]), + class_id=np.array([1, 2, 3]), + tracker_id=np.array([1, 2, 3]), data={ "some_key": [1, 2, 3], "other_key": [["1", "2"], ["3", "4"], ["5", "6"]], }, ) -TEST_DET_2 = mock_detections( - xyxy=[[70, 70, 80, 80], [90, 90, 100, 100]], - mask=[TEST_MASK, TEST_MASK], - confidence=[0.4, 0.5], - class_id=[4, 5], - tracker_id=[4, 5], +TEST_DET_2 = Detections( + xyxy=np.array([[70, 70, 80, 80], [90, 90, 100, 100]]), + mask=np.array([TEST_MASK, TEST_MASK]), + confidence=np.array([0.4, 0.5]), + class_id=np.array([4, 5]), + tracker_id=np.array([4, 5]), data={ "some_key": [4, 5], "other_key": [["7", "8"], ["9", "10"]], }, ) -TEST_DET_1_2 = mock_detections( - xyxy=[ - [10, 10, 20, 20], - [30, 30, 40, 40], - [50, 50, 60, 60], - [70, 70, 80, 80], - [90, 90, 100, 100], - ], - mask=[TEST_MASK, TEST_MASK, TEST_MASK, TEST_MASK, TEST_MASK], - confidence=[0.1, 0.2, 0.3, 0.4, 0.5], - class_id=[1, 2, 3, 4, 5], - tracker_id=[1, 2, 3, 4, 5], +TEST_DET_1_2 = Detections( + xyxy=np.array( + [ + [10, 10, 20, 20], + [30, 30, 40, 40], + [50, 50, 60, 60], + [70, 70, 80, 80], + [90, 90, 100, 100], + ] + ), + mask=np.array([TEST_MASK, TEST_MASK, TEST_MASK, TEST_MASK, TEST_MASK]), + confidence=np.array([0.1, 0.2, 0.3, 0.4, 0.5]), + class_id=np.array([1, 2, 3, 4, 5]), + tracker_id=np.array([1, 2, 3, 4, 5]), data={ "some_key": [1, 2, 3, 4, 5], "other_key": [["1", "2"], ["3", "4"], ["5", "6"], ["7", "8"], ["9", "10"]], }, ) -TEST_DET_ZERO_LENGTH = mock_detections( +TEST_DET_ZERO_LENGTH = Detections( xyxy=np.empty((0, 4), dtype=np.float32), mask=np.empty((0, *TEST_MASK.shape), dtype=bool), - confidence=[], - class_id=[], - tracker_id=[], + confidence=np.empty((0,)), + class_id=np.empty((0,)), + tracker_id=np.empty((0,)), data={ "some_key": [], "other_key": [], }, ) -TEST_DET_NONE = mock_detections( +TEST_DET_NONE = Detections( xyxy=np.empty((0, 4), dtype=np.float32), ) -TEST_DET_DIFFERENT_FIELDS = mock_detections( - xyxy=[[88, 88, 99, 99]], - mask=[np.logical_not(TEST_MASK)], +TEST_DET_DIFFERENT_FIELDS = Detections( + xyxy=np.array([[88, 88, 99, 99]]), + mask=np.array([np.logical_not(TEST_MASK)]), confidence=None, class_id=None, - tracker_id=[9], + tracker_id=np.array([9]), data={"some_key": [9], "other_key": [["11", "12"]]}, ) -TEST_DET_DIFFERENT_DATA = mock_detections( - xyxy=[[88, 88, 99, 99]], - mask=[np.logical_not(TEST_MASK)], - confidence=[0.9], - class_id=[9], - tracker_id=[9], +TEST_DET_DIFFERENT_DATA = Detections( + xyxy=np.array([[88, 88, 99, 99]]), + mask=np.array([np.logical_not(TEST_MASK)]), + confidence=np.array([0.9]), + class_id=np.array([9]), + tracker_id=np.array([9]), data={ "never_seen_key": [9], }, From 2364abf481bfea634d3066d7c31f561170097e00 Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Fri, 10 May 2024 15:08:18 +0200 Subject: [PATCH 08/10] small error message update + few more test cases for `merge_data` --- supervision/detection/utils.py | 14 +++++---- test/detection/test_utils.py | 54 ++++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 512489ca..c2a8e6dd 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -698,16 +698,18 @@ def merge_data( "All data values within a single object must have equal length." ) - data_keys = [set(data.keys()) for data in data_list] - data_keys = [key_set for key_set in data_keys if len(key_set) > 0] - if not data_keys: + keys_by_data = [set(data.keys()) for data in data_list] + keys_by_data = [keys for keys in keys_by_data if len(keys) > 0] + if not keys_by_data: return {} - common_keys = set.intersection(*data_keys) - all_keys = set.union(*data_keys) + common_keys = set.intersection(*keys_by_data) + all_keys = set.union(*keys_by_data) if common_keys != all_keys: raise ValueError( - f"All data dictionaries must have the same keys to merge. Found {data_keys}" + f"All sv.Detections.data dictionaries must have the same keys. Common " + f"keys: {common_keys}, but some dictionaries have additional keys: " + f"{all_keys.difference(common_keys)}." ) merged_data = {key: [] for key in all_keys} diff --git a/test/detection/test_utils.py b/test/detection/test_utils.py index 22d0a430..0fb72a28 100644 --- a/test/detection/test_utils.py +++ b/test/detection/test_utils.py @@ -911,6 +911,14 @@ def test_calculate_masks_centroids( {"test_1": []}, DoesNotRaise(), ), # single data dict with a single field name and empty list values + ( + [ + {"test_1": []}, + {"test_1": []}, + ], + {"test_1": []}, + DoesNotRaise(), + ), # two data dicts with the same field name and empty list values ( [ {"test_1": np.array([])}, @@ -918,6 +926,14 @@ def test_calculate_masks_centroids( {"test_1": np.array([])}, DoesNotRaise(), ), # single data dict with a single field name and empty np.array values + ( + [ + {"test_1": np.array([])}, + {"test_1": np.array([])}, + ], + {"test_1": np.array([])}, + DoesNotRaise(), + ), # two data dicts with the same field name and empty np.array values ( [ {"test_1": [1, 2, 3]}, @@ -932,7 +948,7 @@ def test_calculate_masks_centroids( ], {"test_1": [3, 2, 1]}, DoesNotRaise(), - ), # two data dicts with the same field name and empty and list values + ), # two data dicts with the same field name; one of with empty list as value ( [ {"test_1": [1, 2, 3]}, @@ -1013,20 +1029,29 @@ def test_calculate_masks_centroids( pytest.raises(ValueError), ), # two data dicts with the same field name and different length arrays values ( - [{}, {"test_1": [1, 2, 3]}], + [ + {}, + {"test_1": [1, 2, 3]} + ], {"test_1": [1, 2, 3]}, DoesNotRaise(), - ), # Empty, no keys + ), # two data dicts; one empty and one non-empty dict ( - [{"test_1": [], "test_2": []}, {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}], + [ + {"test_1": [], "test_2": []}, + {"test_1": [1, 2, 3], "test_2": [1, 2, 3]} + ], {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}, DoesNotRaise(), - ), # Empty, same keys + ), # two data dicts; one empty and one non-empty dict; same keys ( - [{"test_1": []}, {"test_1": [1, 2, 3], "test_2": [4, 5, 6]}], + [ + {"test_1": []}, + {"test_1": [1, 2, 3], "test_2": [4, 5, 6]} + ], None, pytest.raises(ValueError), - ), # Empty, missing key + ), # two data dicts; one empty and one non-empty dict; different keys ( [ { @@ -1034,11 +1059,14 @@ def test_calculate_masks_centroids( "test_2": [4, 5, 6], "test_3": [7, 8, 9], }, - {"test_1": [1, 2, 3], "test_2": [4, 5, 6]}, + { + "test_1": [1, 2, 3], + "test_2": [4, 5, 6] + }, ], None, pytest.raises(ValueError), - ), # Empty, too many keys + ), # two data dicts; one with three keys, one with two keys ( [ {"test_1": [1, 2, 3]}, @@ -1047,6 +1075,14 @@ def test_calculate_masks_centroids( None, pytest.raises(ValueError), ), # some keys missing in one dict + ( + [ + {"test_1": [1, 2, 3], "test_2": ['a', 'b']}, + {"test_1": [4, 5], "test_2": ['c', 'd', 'e']}, + ], + None, + pytest.raises(ValueError), + ), # different value lengths for the same key ], ) def test_merge_data( From 3d0c3d91822507d034c2a6c00286589b35b78004 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 10 May 2024 13:08:32 +0000 Subject: [PATCH 09/10] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20auto?= =?UTF-8?q?=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/detection/test_utils.py | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/test/detection/test_utils.py b/test/detection/test_utils.py index 0fb72a28..097c5c6e 100644 --- a/test/detection/test_utils.py +++ b/test/detection/test_utils.py @@ -1029,26 +1029,17 @@ def test_calculate_masks_centroids( pytest.raises(ValueError), ), # two data dicts with the same field name and different length arrays values ( - [ - {}, - {"test_1": [1, 2, 3]} - ], + [{}, {"test_1": [1, 2, 3]}], {"test_1": [1, 2, 3]}, DoesNotRaise(), ), # two data dicts; one empty and one non-empty dict ( - [ - {"test_1": [], "test_2": []}, - {"test_1": [1, 2, 3], "test_2": [1, 2, 3]} - ], + [{"test_1": [], "test_2": []}, {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}], {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}, DoesNotRaise(), ), # two data dicts; one empty and one non-empty dict; same keys ( - [ - {"test_1": []}, - {"test_1": [1, 2, 3], "test_2": [4, 5, 6]} - ], + [{"test_1": []}, {"test_1": [1, 2, 3], "test_2": [4, 5, 6]}], None, pytest.raises(ValueError), ), # two data dicts; one empty and one non-empty dict; different keys @@ -1059,10 +1050,7 @@ def test_calculate_masks_centroids( "test_2": [4, 5, 6], "test_3": [7, 8, 9], }, - { - "test_1": [1, 2, 3], - "test_2": [4, 5, 6] - }, + {"test_1": [1, 2, 3], "test_2": [4, 5, 6]}, ], None, pytest.raises(ValueError), @@ -1077,8 +1065,8 @@ def test_calculate_masks_centroids( ), # some keys missing in one dict ( [ - {"test_1": [1, 2, 3], "test_2": ['a', 'b']}, - {"test_1": [4, 5], "test_2": ['c', 'd', 'e']}, + {"test_1": [1, 2, 3], "test_2": ["a", "b"]}, + {"test_1": [4, 5], "test_2": ["c", "d", "e"]}, ], None, pytest.raises(ValueError), From a8c44cfaff9780d045316c2695e6565678052423 Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Fri, 10 May 2024 15:36:44 +0200 Subject: [PATCH 10/10] ready for merge --- supervision/detection/utils.py | 4 +++- test/detection/test_core.py | 13 +++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index c2a8e6dd..6b378042 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -678,7 +678,9 @@ def merge_data( Merges the data payloads of a list of Detections instances. Args: - data_list: The data payloads of the instances. + data_list: The data payloads of the Detections instances. Each data payload + is a dictionary with the same keys, and the values are either lists or + np.ndarray. Returns: A single data payload containing the merged data, preserving the original data diff --git a/test/detection/test_core.py b/test/detection/test_core.py index 4dd6e467..12f3de28 100644 --- a/test/detection/test_core.py +++ b/test/detection/test_core.py @@ -219,14 +219,17 @@ def test_getitem( @pytest.mark.parametrize( "detections_list, expected_result, exception", [ - # Nothing ([], Detections.empty(), DoesNotRaise()), # empty detections list - # Single ( [Detections.empty()], Detections.empty(), DoesNotRaise(), ), # single empty detections + ( + [Detections.empty(), Detections.empty()], + Detections.empty(), + DoesNotRaise(), + ), # two empty detections ( [TEST_DET_1], TEST_DET_1, @@ -237,12 +240,6 @@ def test_getitem( TEST_DET_NONE, DoesNotRaise(), ), # Single weakly-defined detection - # Similar - ( - [Detections.empty(), Detections.empty()], - Detections.empty(), - DoesNotRaise(), - ), # Two empty ( [TEST_DET_1, TEST_DET_2], TEST_DET_1_2,