From c78ae33e43c95e067e2ae34ff9e7616fe696cac3 Mon Sep 17 00:00:00 2001 From: mario-dg Date: Fri, 13 Oct 2023 18:24:22 +0200 Subject: [PATCH 01/94] =?UTF-8?q?feat:=20=F0=9F=9A=80=20Added=20Non-Maximu?= =?UTF-8?q?m=20Merging=20to=20Detections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supervision/detection/core.py | 107 ++++++++++ .../detection/tools/inference_slicer.py | 17 +- supervision/detection/utils.py | 190 +++++++++++++++++- 3 files changed, 310 insertions(+), 4 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 77bfca9d..006bc6e7 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -6,7 +6,15 @@ from typing import Any, Iterator, List, Optional, Tuple, Union import numpy as np from supervision.detection.utils import ( + batched_greedy_nmm, + box_iou_batch, extract_ultralytics_masks, + get_merged_bbox, + get_merged_class_id, + get_merged_confidence, + get_merged_mask, + get_merged_tracker_id, + greedy_nmm, non_max_suppression, process_roboflow_result, xywh_to_xyxy, @@ -729,6 +737,105 @@ class Detections: """ return (self.xyxy[:, 3] - self.xyxy[:, 1]) * (self.xyxy[:, 2] - self.xyxy[:, 0]) + def with_nmm( + self, threshold: float = 0.5, class_agnostic: bool = False + ) -> Detections: + """ + Perform non-maximum merging on the current set of object detections. + + Args: + threshold (float, optional): The intersection-over-union threshold + to use for non-maximum merging. Defaults to 0.5. + class_agnostic (bool, optional): Whether to perform class-agnostic + non-maximum merging. If True, the class_id of each detection + will be ignored. Defaults to False. + + Returns: + Detections: A new Detections object containing the subset of detections + after non-maximum merging. + + Raises: + AssertionError: If `confidence` is None and class_agnostic is False. + If `class_id` is None and class_agnostic is False. + """ + if len(self) == 0: + return self + + assert ( + self.confidence is not None + ), "Detections confidence must be given for NMM to be executed." + + if class_agnostic: + predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) + keep_to_merge_list = greedy_nmm(predictions, threshold) + else: + predictions = np.hstack( + ( + self.xyxy, + self.confidence.reshape(-1, 1), + self.class_id.reshape(-1, 1), + ) + ) + keep_to_merge_list = batched_greedy_nmm(predictions, threshold) + + result = [] + + for keep_ind, merge_ind_list in keep_to_merge_list.items(): + for merge_ind in merge_ind_list: + if ( + box_iou_batch(self[keep_ind].xyxy, self[merge_ind].xyxy).item() + > threshold + ): + self[keep_ind].xyxy = np.vstack( + ( + self[keep_ind].xyxy, + get_merged_bbox(self.xyxy[keep_ind], self.xyxy[merge_ind]), + ) + ) + self[keep_ind].class_id = np.hstack( + ( + self[keep_ind].class_id, + get_merged_class_id( + self.class_id[keep_ind].item(), + self.class_id[merge_ind].item(), + ), + ) + ) + self[keep_ind].confidence = np.hstack( + ( + self[keep_ind].confidence, + get_merged_confidence( + self.confidence[keep_ind].item(), + self.confidence[merge_ind].item(), + ), + ) + ) + if self.mask is not None: + merged_mask = get_merged_mask( + self.mask[keep_ind], self.mask[merge_ind] + ) + if self[keep_ind].mask is None: + self[keep_ind].mask = np.array([merged_mask]) + else: + self[keep_ind].mask = np.vstack( + (self[keep_ind].mask, merged_mask[np.newaxis]) + ) + if self.tracker_id is not None: + merged_tracker_id = get_merged_tracker_id( + self.tracker_id[keep_ind].item(), + self.tracker_id[merge_ind].item(), + ) + if self[keep_ind].tracker_id is None: + self[keep_ind].tracker_id = np.array( + [merged_tracker_id], dtype=int + ) + else: + self[keep_ind].tracker_id = np.hstack( + (self[keep_ind].tracker_id, merged_tracker_id) + ) + result.append(self[keep_ind]) + return Detections.merge(result) + def with_nms( self, threshold: float = 0.5, class_agnostic: bool = False ) -> Detections: diff --git a/supervision/detection/tools/inference_slicer.py b/supervision/detection/tools/inference_slicer.py index 5f6fb391..2098c79c 100644 --- a/supervision/detection/tools/inference_slicer.py +++ b/supervision/detection/tools/inference_slicer.py @@ -36,6 +36,10 @@ class InferenceSlicer: slices in the format `(width_ratio, height_ratio)`. iou_threshold (Optional[float]): Intersection over Union (IoU) threshold used for non-max suppression. + merge_detections (Optional[bool]): Whether to merge the detection from all + slices or simply concatenate them. If `True`, Non-Maximum Merging (NMM), + otherwise Non-Maximum Suppression (NMS), + is applied to the final detections. callback (Callable): A function that performs inference on a given image slice and returns detections. thread_workers (int): Number of threads for parallel execution. @@ -53,11 +57,13 @@ class InferenceSlicer: slice_wh: Tuple[int, int] = (320, 320), overlap_ratio_wh: Tuple[float, float] = (0.2, 0.2), iou_threshold: Optional[float] = 0.5, + merge_detections: Optional[bool] = False, thread_workers: int = 1, ): self.slice_wh = slice_wh self.overlap_ratio_wh = overlap_ratio_wh self.iou_threshold = iou_threshold + self.merge_detections = merge_detections self.callback = callback self.thread_workers = thread_workers validate_inference_callback(callback=callback) @@ -109,9 +115,14 @@ class InferenceSlicer: for future in as_completed(futures): detections_list.append(future.result()) - return Detections.merge(detections_list=detections_list).with_nms( - threshold=self.iou_threshold - ) + if self.merge_detections: + return Detections.merge(detections_list=detections_list).with_nmm( + threshold=self.iou_threshold + ) + else: + return Detections.merge(detections_list=detections_list).with_nms( + threshold=self.iou_threshold + ) def _run_callback(self, image, offset) -> Detections: """ diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 7a5eb546..b0414eb4 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple import cv2 import numpy as np @@ -110,6 +110,194 @@ def non_max_suppression( return keep[sort_index.argsort()] +def greedy_nmm(predictions: np.ndarray, threshold: float = 0.5) -> Dict[int, List[int]]: + """ + Apply greedy version of non-maximum merging to avoid detecting too many + overlapping bounding boxes for a given object. + + Args: + predictions (np.ndarray): An array of shape `(n, 5)` containing + the bounding boxes coordinates in format `[x1, y1, x2, y2]` + and the confidence scores. + threshold (float, optional): The intersection-over-union threshold + to use for non-maximum suppression. Defaults to 0.5. + + Returns: + Dict[int, List[int]]: Mapping from prediction indices + to keep to a list of prediction indices to be merged. + """ + keep_to_merge_list = {} + + x1 = predictions[:, 0] + y1 = predictions[:, 1] + x2 = predictions[:, 2] + y2 = predictions[:, 3] + + scores = predictions[:, 4] + + areas = (x2 - x1) * (y2 - y1) + + order = scores.argsort() + + keep = [] + + while len(order) > 0: + idx = order[-1] + + keep.append(idx.tolist()) + + order = order[:-1] + + if len(order) == 0: + keep_to_merge_list[idx.tolist()] = [] + break + + xx1 = np.take(x1, axis=0, indices=order) + xx2 = np.take(x2, axis=0, indices=order) + yy1 = np.take(y1, axis=0, indices=order) + yy2 = np.take(y2, axis=0, indices=order) + + xx1 = np.maximum(xx1, x1[idx]) + yy1 = np.maximum(yy1, y1[idx]) + xx2 = np.minimum(xx2, x2[idx]) + yy2 = np.minimum(yy2, y2[idx]) + + w = np.maximum(0.0, xx2 - xx1) + h = np.maximum(0.0, yy2 - yy1) + + inter = w * h + + rem_areas = np.take(areas, axis=0, indices=order) + + union = (rem_areas - inter) + areas[idx] + match_metric_value = inter / union + + mask = match_metric_value < threshold + mask = mask.astype(np.uint8) + matched_box_indices = np.flip(order[np.where(mask == 0)[0]]) + unmatched_indices = order[np.where(mask == 1)[0]] + + order = unmatched_indices[scores[unmatched_indices].argsort()] + + keep_to_merge_list[idx.tolist()] = [] + + for matched_box_ind in matched_box_indices.tolist(): + keep_to_merge_list[idx.tolist()].append(matched_box_ind) + + return keep_to_merge_list + + +def batched_greedy_nmm( + predictions: np.ndarray, threshold: float = 0.5 +) -> Dict[int, List[int]]: + """ + Apply greedy version of non-maximum merging per category to avoid detecting + too many overlapping bounding boxes for a given object. + + Args: + predictions (np.ndarray): An array of shape `(n, 6)` containing + the bounding boxes coordinates in format `[x1, y1, x2, y2]`, + the confidence scores and class_ids. + threshold (float, optional): The intersection-over-union threshold + to use for non-maximum suppression. Defaults to 0.5. + + Returns: + Dict[int, List[int]]: Mapping from prediction indices + to keep to a list of prediction indices to be merged. + """ + category_ids = predictions[:, 5] + keep_to_merge_list = {} + for category_id in np.unique(category_ids): + curr_indices = np.where(category_ids == category_id)[0] + curr_keep_to_merge_list = greedy_nmm(predictions[curr_indices], threshold) + curr_indices_list = curr_indices.tolist() + for curr_keep, curr_merge_list in curr_keep_to_merge_list.items(): + keep = curr_indices_list[curr_keep] + merge_list = [curr_indices_list[i] for i in curr_merge_list] + keep_to_merge_list[keep] = merge_list + return keep_to_merge_list + + +def get_merged_bbox(bbox1: np.ndarray, bbox2: np.ndarray) -> np.ndarray: + """ + Merges two bounding boxes into one. + + Args: + bbox1 (np.ndarray): A numpy array of shape `(, 4)` where the + row corresponds to a bounding box in + the format `(x_min, y_min, x_max, y_max)`. + bbox2 (np.ndarray): A numpy array of shape `(, 4)` where the + row corresponds to a bounding box in + the format `(x_min, y_min, x_max, y_max)`. + + Returns: + np.ndarray: A numpy array of shape `(, 4)` where the new + bounding box is the merged bounding box of `bbox1` and `bbox2`. + """ + left_top = np.minimum(bbox1[:2], bbox2[:2]) + right_bottom = np.maximum(bbox1[2:], bbox2[2:]) + return np.concatenate([left_top, right_bottom]) + + +def get_merged_class_id(id1: int, id2: int) -> int: + """ + Merges two class ids into one. + + Args: + id1 (int): The first class id. + id2 (int): The second class id. + + Returns: + int: The merged class id. + """ + return max(id1, id2) + + +def get_merged_confidence(confidence1: float, confidence2: float) -> float: + """ + Merges two confidences into one. + + Args: + confidence1 (float): The first confidence. + confidence2 (float): The second confidence. + + Returns: + float: The merged confidence. + """ + return max(confidence1, confidence2) + + +def get_merged_mask(mask1: np.ndarray, mask2: np.ndarray) -> np.ndarray: + """ + Merges two masks into one. + + Args: + mask1 (np.ndarray): A numpy array of shape `(H, W)` where `H` and `W` + are the height and width of the mask, respectively. + mask2 (np.ndarray): A numpy array of shape `(H, W)` where `H` and `W` + are the height and width of the mask, respectively. + + Returns: + np.ndarray: A numpy array of shape `(H, W)` where the new mask is the + merged mask of `mask1` and `mask2`. + """ + return np.logical_or(mask1, mask2) + + +def get_merged_tracker_id(tracker_id1: int, tracker_id2: int) -> int: + """ + Merges two tracker ids into one. + + Args: + tracker_id1 (int): The first tracker id. + tracker_id2 (int): The second tracker id. + + Returns: + int: The merged tracker id. + """ + return max(tracker_id1, tracker_id2) + + def clip_boxes( boxes_xyxy: np.ndarray, frame_resolution_wh: Tuple[int, int] ) -> np.ndarray: From 57b12e6e00069d9064df783eaac40d230c4626bd Mon Sep 17 00:00:00 2001 From: mario-dg Date: Thu, 19 Oct 2023 00:03:36 +0200 Subject: [PATCH 02/94] Added __setitem__ to Detections and refactored the object prediction merging --- supervision/detection/core.py | 104 +++++++++++++++++++--------------- 1 file changed, 58 insertions(+), 46 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 006bc6e7..bd729a96 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -67,6 +67,27 @@ def _validate_tracker_id(tracker_id: Any, n: int) -> None: raise ValueError("tracker_id must be None or 1d np.ndarray with (n,) shape") +def _merge_object_detection_pair(pred1: Detections, pred2: Detections) -> Detections: + merged_bbox = get_merged_bbox(pred1.xyxy, pred2.xyxy) + merged_conf = get_merged_confidence(pred1.confidence, pred2.confidence) + merged_class_id = get_merged_class_id(pred1.class_id, pred2.class_id) + merged_tracker_id = None + merged_mask = None + + if pred1.mask and pred2.mask: + merged_mask = get_merged_mask(pred1.mask, pred2.mask) + if pred1.tracker_id and pred2.tracker_id: + merged_tracker_id = get_merged_tracker_id(pred1.tracker_id, pred2.tracker_id) + + return Detections( + xyxy=merged_bbox, + mask=merged_mask, + confidence=merged_conf, + class_id=merged_class_id, + tracker_id=merged_tracker_id, + ) + + @dataclass class Detections: """ @@ -668,6 +689,38 @@ class Detections: raise ValueError(f"{anchor} is not supported.") + def __setitem__( + self, index: Union[int, slice, List[int], np.ndarray], value: Detections + ) -> None: + """ + Set a subset of the Detections object. + + Args: + index (Union[int, slice, List[int], np.ndarray]): + The index or indices of the subset of the Detections + value (Detections): The new value of the subset of the Detections + + Example: + ```python + >>> import supervision as sv + + >>> detections = sv.Detections(...) + + >>> detections[0] = sv.Detections(...) + ``` + """ + if isinstance(index, int): + index = [index] + self.xyxy[index] = value.xyxy + if self.mask is not None: + self.mask[index] = value.mask + if self.confidence is not None: + self.confidence[index] = value.confidence + if self.class_id is not None: + self.class_id[index] = value.class_id + if self.tracker_id is not None: + self.tracker_id[index] = value.tracker_id + def __getitem__( self, index: Union[int, slice, List[int], np.ndarray] ) -> Detections: @@ -761,6 +814,8 @@ class Detections: if len(self) == 0: return self + assert 0.0 <= threshold <= 1.0, "Threshold must be between 0 and 1." + assert ( self.confidence is not None ), "Detections confidence must be given for NMM to be executed." @@ -786,54 +841,11 @@ class Detections: box_iou_batch(self[keep_ind].xyxy, self[merge_ind].xyxy).item() > threshold ): - self[keep_ind].xyxy = np.vstack( - ( - self[keep_ind].xyxy, - get_merged_bbox(self.xyxy[keep_ind], self.xyxy[merge_ind]), - ) + self[keep_ind] = _merge_object_detection_pair( + self[keep_ind], self[merge_ind] ) - self[keep_ind].class_id = np.hstack( - ( - self[keep_ind].class_id, - get_merged_class_id( - self.class_id[keep_ind].item(), - self.class_id[merge_ind].item(), - ), - ) - ) - self[keep_ind].confidence = np.hstack( - ( - self[keep_ind].confidence, - get_merged_confidence( - self.confidence[keep_ind].item(), - self.confidence[merge_ind].item(), - ), - ) - ) - if self.mask is not None: - merged_mask = get_merged_mask( - self.mask[keep_ind], self.mask[merge_ind] - ) - if self[keep_ind].mask is None: - self[keep_ind].mask = np.array([merged_mask]) - else: - self[keep_ind].mask = np.vstack( - (self[keep_ind].mask, merged_mask[np.newaxis]) - ) - if self.tracker_id is not None: - merged_tracker_id = get_merged_tracker_id( - self.tracker_id[keep_ind].item(), - self.tracker_id[merge_ind].item(), - ) - if self[keep_ind].tracker_id is None: - self[keep_ind].tracker_id = np.array( - [merged_tracker_id], dtype=int - ) - else: - self[keep_ind].tracker_id = np.hstack( - (self[keep_ind].tracker_id, merged_tracker_id) - ) result.append(self[keep_ind]) + return Detections.merge(result) def with_nms( From 9f222736e129df769a9771bda12eb235795e0801 Mon Sep 17 00:00:00 2001 From: mario-dg Date: Thu, 19 Oct 2023 00:05:05 +0200 Subject: [PATCH 03/94] Added standard full image inference after sliced inference to increase large object detection accuracy --- supervision/detection/tools/inference_slicer.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/supervision/detection/tools/inference_slicer.py b/supervision/detection/tools/inference_slicer.py index 2098c79c..c0a30ff6 100644 --- a/supervision/detection/tools/inference_slicer.py +++ b/supervision/detection/tools/inference_slicer.py @@ -38,8 +38,10 @@ class InferenceSlicer: used for non-max suppression. merge_detections (Optional[bool]): Whether to merge the detection from all slices or simply concatenate them. If `True`, Non-Maximum Merging (NMM), - otherwise Non-Maximum Suppression (NMS), - is applied to the final detections. + otherwise Non-Maximum Suppression (NMS), is applied to the detections. + perform_standard_pred (Optional[bool]): Whether to perform inference on the + whole image in addition to the slices to increase the accuracy of + large object detection. callback (Callable): A function that performs inference on a given image slice and returns detections. thread_workers (int): Number of threads for parallel execution. @@ -58,12 +60,14 @@ class InferenceSlicer: overlap_ratio_wh: Tuple[float, float] = (0.2, 0.2), iou_threshold: Optional[float] = 0.5, merge_detections: Optional[bool] = False, + perform_standard_pred: Optional[bool] = False, thread_workers: int = 1, ): self.slice_wh = slice_wh self.overlap_ratio_wh = overlap_ratio_wh self.iou_threshold = iou_threshold self.merge_detections = merge_detections + self.perform_standard_pred = perform_standard_pred self.callback = callback self.thread_workers = thread_workers validate_inference_callback(callback=callback) @@ -115,6 +119,9 @@ class InferenceSlicer: for future in as_completed(futures): detections_list.append(future.result()) + if self.perform_standard_pred: + detections_list.append(self.callback(image)) + if self.merge_detections: return Detections.merge(detections_list=detections_list).with_nmm( threshold=self.iou_threshold From 6f4704625b16ba69068b3a19f6d55bc21c80c434 Mon Sep 17 00:00:00 2001 From: mario-dg Date: Thu, 19 Oct 2023 00:05:42 +0200 Subject: [PATCH 04/94] Refactored merging of Detection attributes to better work with np.ndarrays --- supervision/detection/utils.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index b0414eb4..a79900b4 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -162,8 +162,8 @@ def greedy_nmm(predictions: np.ndarray, threshold: float = 0.5) -> Dict[int, Lis xx2 = np.minimum(xx2, x2[idx]) yy2 = np.minimum(yy2, y2[idx]) - w = np.maximum(0.0, xx2 - xx1) - h = np.maximum(0.0, yy2 - yy1) + w = np.maximum(0, xx2 - xx1) + h = np.maximum(0, yy2 - yy1) inter = w * h @@ -234,37 +234,39 @@ def get_merged_bbox(bbox1: np.ndarray, bbox2: np.ndarray) -> np.ndarray: np.ndarray: A numpy array of shape `(, 4)` where the new bounding box is the merged bounding box of `bbox1` and `bbox2`. """ - left_top = np.minimum(bbox1[:2], bbox2[:2]) - right_bottom = np.maximum(bbox1[2:], bbox2[2:]) - return np.concatenate([left_top, right_bottom]) + left_top = np.minimum(bbox1[0][:2], bbox2[0][:2]) + right_bottom = np.maximum(bbox1[0][2:], bbox2[0][2:]) + return np.array([np.concatenate([left_top, right_bottom])]) -def get_merged_class_id(id1: int, id2: int) -> int: +def get_merged_class_id(id1: np.ndarray, id2: np.ndarray) -> np.ndarray: """ Merges two class ids into one. Args: - id1 (int): The first class id. - id2 (int): The second class id. + id1 (np.ndarray): The first class id. + id2 (np.ndarray): The second class id. Returns: - int: The merged class id. + np.ndarray: The merged class id. """ - return max(id1, id2) + return np.array([max(id1.item(), id2.item())]) -def get_merged_confidence(confidence1: float, confidence2: float) -> float: +def get_merged_confidence( + confidence1: np.ndarray, confidence2: np.ndarray +) -> np.ndarray: """ Merges two confidences into one. Args: - confidence1 (float): The first confidence. - confidence2 (float): The second confidence. + confidence1 (np.ndarray): The first confidence. + confidence2 (np.ndarray): The second confidence. Returns: - float: The merged confidence. + np.ndarray: The merged confidence. """ - return max(confidence1, confidence2) + return np.array([max(confidence1.item(), confidence2.item())]) def get_merged_mask(mask1: np.ndarray, mask2: np.ndarray) -> np.ndarray: From 166a8da9a07b20852c4559624fe029fc87bc8751 Mon Sep 17 00:00:00 2001 From: mario-dg Date: Thu, 11 Apr 2024 12:22:44 +0200 Subject: [PATCH 05/94] Implement Feedback --- supervision/detection/core.py | 182 +++++++++++------- .../detection/tools/inference_slicer.py | 24 +-- supervision/detection/utils.py | 69 +------ 3 files changed, 117 insertions(+), 158 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 66387087..a9a4ee92 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -8,22 +8,17 @@ import numpy as np from supervision.config import CLASS_NAME_DATA_FIELD, ORIENTED_BOX_COORDINATES from supervision.detection.utils import ( - batched_greedy_nmm, + batch_non_max_merge, box_iou_batch, box_non_max_suppression, calculate_masks_centroids, extract_ultralytics_masks, get_data_item, - get_merged_bbox, - get_merged_class_id, - get_merged_confidence, - get_merged_mask, - get_merged_tracker_id, - greedy_nmm, is_data_equal, mask_non_max_suppression, mask_to_xyxy, merge_data, + non_max_merge, process_roboflow_result, validate_detections_fields, xywh_to_xyxy, @@ -32,17 +27,57 @@ from supervision.geometry.core import Position from supervision.utils.internal import deprecated -def _merge_object_detection_pair(pred1: Detections, pred2: Detections) -> Detections: - merged_bbox = get_merged_bbox(pred1.xyxy, pred2.xyxy) - merged_conf = get_merged_confidence(pred1.confidence, pred2.confidence) - merged_class_id = get_merged_class_id(pred1.class_id, pred2.class_id) +def _merge_object_detection_pair(det1: Detections, det2: Detections) -> Detections: + """ + Merges two Detections object into a single Detections object. + + A `winning` detection is determined based on the confidence score of the two + input detections. This winning detection is then used to specify which `class_id`, + `tracker_id`, and `data` to include in the merged Detections object. + The resulting `confidence` of the merged object is calculated by the weighted + contribution of each detection to the merged object. + The bounding boxes and masks of the two input detections are merged into a single + bounding box and mask, respectively. + + Args: + det1 (Detections): + The first Detections object + det2 (Detections): + The second Detections object + + Returns: + Detections: A new Detections object, with merged attributes. + """ + assert ( + len(det1) == len(det2) == 1 + ), "Both Detections should have exactly 1 detected object." + winning_det = det1 if det1.confidence.item() > det2.confidence.item() else det2 + + area_det1 = (det1.xyxy[0][2] - det1.xyxy[0][0]) * ( + det1.xyxy[0][3] - det1.xyxy[0][1] + ) + area_det2 = (det2.xyxy[0][2] - det2.xyxy[0][0]) * ( + det2.xyxy[0][3] - det2.xyxy[0][1] + ) + merged_x1, merged_y1 = np.minimum(det1.xyxy[0][:2], det2.xyxy[0][:2]) + merged_x2, merged_y2 = np.maximum(det1.xyxy[0][2:], det2.xyxy[0][2:]) + merged_area = (merged_x2 - merged_x1) * (merged_y2 - merged_y1) + + merged_conf = ( + area_det1 * det1.confidence.item() + area_det2 * det2.confidence.item() + ) / merged_area + merged_bbox = [np.concatenate([merged_x1, merged_y1, merged_x2, merged_y2])] + merged_class_id = winning_det.class_id.item() merged_tracker_id = None merged_mask = None + merged_data = None - if pred1.mask and pred2.mask: - merged_mask = get_merged_mask(pred1.mask, pred2.mask) - if pred1.tracker_id and pred2.tracker_id: - merged_tracker_id = get_merged_tracker_id(pred1.tracker_id, pred2.tracker_id) + if det1.mask and det2.mask: + merged_mask = np.logical_or(det1.mask, det2.mask) + if det1.tracker_id and det2.tracker_id: + merged_tracker_id = winning_det.tracker_id.item() + if det1.data and det2.data: + merged_data = winning_det.data return Detections( xyxy=merged_bbox, @@ -50,6 +85,7 @@ def _merge_object_detection_pair(pred1: Detections, pred2: Detections) -> Detect confidence=merged_conf, class_id=merged_class_id, tracker_id=merged_tracker_id, + data=merged_data, ) @@ -1091,64 +1127,6 @@ class Detections: """ return (self.xyxy[:, 3] - self.xyxy[:, 1]) * (self.xyxy[:, 2] - self.xyxy[:, 0]) - def with_nmm( - self, threshold: float = 0.5, class_agnostic: bool = False - ) -> Detections: - """ - Perform non-maximum merging on the current set of object detections. - - Args: - threshold (float, optional): The intersection-over-union threshold - to use for non-maximum merging. Defaults to 0.5. - class_agnostic (bool, optional): Whether to perform class-agnostic - non-maximum merging. If True, the class_id of each detection - will be ignored. Defaults to False. - - Returns: - Detections: A new Detections object containing the subset of detections - after non-maximum merging. - - Raises: - AssertionError: If `confidence` is None and class_agnostic is False. - If `class_id` is None and class_agnostic is False. - """ - if len(self) == 0: - return self - - assert 0.0 <= threshold <= 1.0, "Threshold must be between 0 and 1." - - assert ( - self.confidence is not None - ), "Detections confidence must be given for NMM to be executed." - - if class_agnostic: - predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) - keep_to_merge_list = greedy_nmm(predictions, threshold) - else: - predictions = np.hstack( - ( - self.xyxy, - self.confidence.reshape(-1, 1), - self.class_id.reshape(-1, 1), - ) - ) - keep_to_merge_list = batched_greedy_nmm(predictions, threshold) - - result = [] - - for keep_ind, merge_ind_list in keep_to_merge_list.items(): - for merge_ind in merge_ind_list: - if ( - box_iou_batch(self[keep_ind].xyxy, self[merge_ind].xyxy).item() - > threshold - ): - self[keep_ind] = _merge_object_detection_pair( - self[keep_ind], self[merge_ind] - ) - result.append(self[keep_ind]) - - return Detections.merge(result) - def with_nms( self, threshold: float = 0.5, class_agnostic: bool = False ) -> Detections: @@ -1204,3 +1182,61 @@ class Detections: ) return self[indices] + + def with_nmm( + self, threshold: float = 0.5, class_agnostic: bool = False + ) -> Detections: + """ + Perform non-maximum merging on the current set of object detections. + + Args: + threshold (float, optional): The intersection-over-union threshold + to use for non-maximum merging. Defaults to 0.5. + class_agnostic (bool, optional): Whether to perform class-agnostic + non-maximum merging. If True, the class_id of each detection + will be ignored. Defaults to False. + + Returns: + Detections: A new Detections object containing the subset of detections + after non-maximum merging. + + Raises: + AssertionError: If `confidence` is None and class_agnostic is False. + If `class_id` is None and class_agnostic is False. + """ + if len(self) == 0: + return self + + assert 0.0 <= threshold <= 1.0, "Threshold must be between 0 and 1." + + assert ( + self.confidence is not None + ), "Detections confidence must be given for NMM to be executed." + + if class_agnostic: + predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) + keep_to_merge_list = non_max_merge(predictions, threshold) + else: + predictions = np.hstack( + ( + self.xyxy, + self.confidence.reshape(-1, 1), + self.class_id.reshape(-1, 1), + ) + ) + keep_to_merge_list = batch_non_max_merge(predictions, threshold) + + result = [] + + for keep_ind, merge_ind_list in keep_to_merge_list.items(): + for merge_ind in merge_ind_list: + if ( + box_iou_batch(self[keep_ind].xyxy, self[merge_ind].xyxy).item() + > threshold + ): + self[keep_ind] = _merge_object_detection_pair( + self[keep_ind], self[merge_ind] + ) + result.append(self[keep_ind]) + + return Detections.merge(result) diff --git a/supervision/detection/tools/inference_slicer.py b/supervision/detection/tools/inference_slicer.py index 2aff9f6d..7157723f 100644 --- a/supervision/detection/tools/inference_slicer.py +++ b/supervision/detection/tools/inference_slicer.py @@ -36,12 +36,6 @@ class InferenceSlicer: slices in the format `(width_ratio, height_ratio)`. iou_threshold (Optional[float]): Intersection over Union (IoU) threshold used for non-max suppression. - merge_detections (Optional[bool]): Whether to merge the detection from all - slices or simply concatenate them. If `True`, Non-Maximum Merging (NMM), - otherwise Non-Maximum Suppression (NMS), is applied to the detections. - perform_standard_pred (Optional[bool]): Whether to perform inference on the - whole image in addition to the slices to increase the accuracy of - large object detection. callback (Callable): A function that performs inference on a given image slice and returns detections. thread_workers (int): Number of threads for parallel execution. @@ -59,15 +53,11 @@ class InferenceSlicer: slice_wh: Tuple[int, int] = (320, 320), overlap_ratio_wh: Tuple[float, float] = (0.2, 0.2), iou_threshold: Optional[float] = 0.5, - merge_detections: Optional[bool] = False, - perform_standard_pred: Optional[bool] = False, thread_workers: int = 1, ): self.slice_wh = slice_wh self.overlap_ratio_wh = overlap_ratio_wh self.iou_threshold = iou_threshold - self.merge_detections = merge_detections - self.perform_standard_pred = perform_standard_pred self.callback = callback self.thread_workers = thread_workers @@ -118,17 +108,9 @@ class InferenceSlicer: for future in as_completed(futures): detections_list.append(future.result()) - if self.perform_standard_pred: - detections_list.append(self.callback(image)) - - if self.merge_detections: - return Detections.merge(detections_list=detections_list).with_nmm( - threshold=self.iou_threshold - ) - else: - return Detections.merge(detections_list=detections_list).with_nms( - threshold=self.iou_threshold - ) + return Detections.merge(detections_list=detections_list).with_nms( + threshold=self.iou_threshold + ) def _run_callback(self, image, offset) -> Detections: """ diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index b9edb9d6..9e732aeb 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -274,7 +274,9 @@ def box_non_max_suppression( return keep[sort_index.argsort()] -def greedy_nmm(predictions: np.ndarray, threshold: float = 0.5) -> Dict[int, List[int]]: +def non_max_merge( + predictions: np.ndarray, threshold: float = 0.5 +) -> Dict[int, List[int]]: """ Apply greedy version of non-maximum merging to avoid detecting too many overlapping bounding boxes for a given object. @@ -351,7 +353,7 @@ def greedy_nmm(predictions: np.ndarray, threshold: float = 0.5) -> Dict[int, Lis return keep_to_merge_list -def batched_greedy_nmm( +def batch_non_max_merge( predictions: np.ndarray, threshold: float = 0.5 ) -> Dict[int, List[int]]: """ @@ -373,7 +375,7 @@ def batched_greedy_nmm( keep_to_merge_list = {} for category_id in np.unique(category_ids): curr_indices = np.where(category_ids == category_id)[0] - curr_keep_to_merge_list = greedy_nmm(predictions[curr_indices], threshold) + curr_keep_to_merge_list = non_max_merge(predictions[curr_indices], threshold) curr_indices_list = curr_indices.tolist() for curr_keep, curr_merge_list in curr_keep_to_merge_list.items(): keep = curr_indices_list[curr_keep] @@ -403,67 +405,6 @@ def get_merged_bbox(bbox1: np.ndarray, bbox2: np.ndarray) -> np.ndarray: return np.array([np.concatenate([left_top, right_bottom])]) -def get_merged_class_id(id1: np.ndarray, id2: np.ndarray) -> np.ndarray: - """ - Merges two class ids into one. - - Args: - id1 (np.ndarray): The first class id. - id2 (np.ndarray): The second class id. - - Returns: - np.ndarray: The merged class id. - """ - return np.array([max(id1.item(), id2.item())]) - - -def get_merged_confidence( - confidence1: np.ndarray, confidence2: np.ndarray -) -> np.ndarray: - """ - Merges two confidences into one. - - Args: - confidence1 (np.ndarray): The first confidence. - confidence2 (np.ndarray): The second confidence. - - Returns: - np.ndarray: The merged confidence. - """ - return np.array([max(confidence1.item(), confidence2.item())]) - - -def get_merged_mask(mask1: np.ndarray, mask2: np.ndarray) -> np.ndarray: - """ - Merges two masks into one. - - Args: - mask1 (np.ndarray): A numpy array of shape `(H, W)` where `H` and `W` - are the height and width of the mask, respectively. - mask2 (np.ndarray): A numpy array of shape `(H, W)` where `H` and `W` - are the height and width of the mask, respectively. - - Returns: - np.ndarray: A numpy array of shape `(H, W)` where the new mask is the - merged mask of `mask1` and `mask2`. - """ - return np.logical_or(mask1, mask2) - - -def get_merged_tracker_id(tracker_id1: int, tracker_id2: int) -> int: - """ - Merges two tracker ids into one. - - Args: - tracker_id1 (int): The first tracker id. - tracker_id2 (int): The second tracker id. - - Returns: - int: The merged tracker id. - """ - return max(tracker_id1, tracker_id2) - - def clip_boxes(xyxy: np.ndarray, resolution_wh: Tuple[int, int]) -> np.ndarray: """ Clips bounding boxes coordinates to fit within the frame resolution. From d7e52bee264fb1b3b5c47a3f27b5eb67deae86a6 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Mon, 6 May 2024 17:20:31 +0300 Subject: [PATCH 06/94] NMM: Add None-checks, fix area normalization, style --- supervision/detection/core.py | 179 +++++++++++++++++++++++++--------- 1 file changed, 131 insertions(+), 48 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index b60e3363..3d1c135a 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -30,14 +30,16 @@ from supervision.validators import validate_detections_fields def _merge_object_detection_pair(det1: Detections, det2: Detections) -> Detections: """ Merges two Detections object into a single Detections object. + Assumes each Detections contains exactly one object. A `winning` detection is determined based on the confidence score of the two - input detections. This winning detection is then used to specify which `class_id`, - `tracker_id`, and `data` to include in the merged Detections object. + input detections. This winning detection is then used to specify which + `class_id`, `tracker_id`, and `data` to include in the merged Detections object. + The resulting `confidence` of the merged object is calculated by the weighted contribution of each detection to the merged object. - The bounding boxes and masks of the two input detections are merged into a single - bounding box and mask, respectively. + The bounding boxes and masks of the two input detections are merged into a + single bounding box and mask, respectively. Args: det1 (Detections): @@ -47,11 +49,39 @@ def _merge_object_detection_pair(det1: Detections, det2: Detections) -> Detectio Returns: Detections: A new Detections object, with merged attributes. + + Raises: + ValueError: If the input Detections objects do not have exactly 1 detected + object. + + Example: + ```python + import cv2 + import supervision as sv + from inference import get_model + + image = cv2.imread() + model = get_model(model_id="yolov8s-640") + + result = model.infer(image)[0] + detections = sv.Detections.from_inference(result) + + merged_detections = merge_object_detection_pair( + detections[0], detections[1]) + ``` """ - assert ( - len(det1) == len(det2) == 1 - ), "Both Detections should have exactly 1 detected object." - winning_det = det1 if det1.confidence.item() > det2.confidence.item() else det2 + if len(det1) != 1 or len(det2) != 1: + raise ValueError( + "Both Detections should have exactly 1 detected object.") + + if det2.confidence is None: + winning_det = det1 + elif det1.confidence is None: + winning_det = det2 + elif det1.confidence[0] >= det2.confidence[0]: + winning_det = det1 + else: + winning_det = det2 area_det1 = (det1.xyxy[0][2] - det1.xyxy[0][0]) * ( det1.xyxy[0][3] - det1.xyxy[0][1] @@ -59,33 +89,39 @@ def _merge_object_detection_pair(det1: Detections, det2: Detections) -> Detectio area_det2 = (det2.xyxy[0][2] - det2.xyxy[0][0]) * ( det2.xyxy[0][3] - det2.xyxy[0][1] ) + merged_x1, merged_y1 = np.minimum(det1.xyxy[0][:2], det2.xyxy[0][:2]) merged_x2, merged_y2 = np.maximum(det1.xyxy[0][2:], det2.xyxy[0][2:]) - merged_area = (merged_x2 - merged_x1) * (merged_y2 - merged_y1) - merged_conf = ( - area_det1 * det1.confidence.item() + area_det2 * det2.confidence.item() - ) / merged_area - merged_bbox = [np.concatenate([merged_x1, merged_y1, merged_x2, merged_y2])] - merged_class_id = winning_det.class_id.item() - merged_tracker_id = None + merged_xy = np.array([[merged_x1, merged_y1, merged_x2, merged_y2]]) + + winning_class_id = winning_det.class_id + + if det1.confidence is None or det2.confidence is None: + merged_confidence = None + else: + merged_confidence = ( + area_det1 * det1.confidence[0] + area_det2 * det2.confidence[0] + ) / (area_det1 + area_det2) + merged_confidence = np.array([merged_confidence]) + merged_mask = None - merged_data = None - - if det1.mask and det2.mask: + if det1.mask is not None and det2.mask is not None: merged_mask = np.logical_or(det1.mask, det2.mask) - if det1.tracker_id and det2.tracker_id: - merged_tracker_id = winning_det.tracker_id.item() + + winning_tracker_id = winning_det.tracker_id + + winning_data = None if det1.data and det2.data: - merged_data = winning_det.data + winning_data = winning_det.data return Detections( - xyxy=merged_bbox, + xyxy=merged_xy, mask=merged_mask, - confidence=merged_conf, - class_id=merged_class_id, - tracker_id=merged_tracker_id, - data=merged_data, + confidence=merged_confidence, + class_id=winning_class_id, + tracker_id=winning_tracker_id, + data=winning_data, ) @@ -260,7 +296,8 @@ class Detections: detections = sv.Detections.from_yolov5(result) ``` """ - yolov5_detections_predictions = yolov5_results.pred[0].cpu().cpu().numpy() + yolov5_detections_predictions = yolov5_results.pred[0].cpu( + ).cpu().numpy() return cls( xyxy=yolov5_detections_predictions[:, :4], @@ -307,7 +344,8 @@ class Detections: if "obb" in ultralytics_results and ultralytics_results.obb is not None: class_id = ultralytics_results.obb.cls.cpu().numpy().astype(int) - class_names = np.array([ultralytics_results.names[i] for i in class_id]) + class_names = np.array( + [ultralytics_results.names[i] for i in class_id]) oriented_box_coordinates = ultralytics_results.obb.xyxyxyxy.cpu().numpy() return cls( xyxy=ultralytics_results.obb.xyxy.cpu().numpy(), @@ -323,7 +361,8 @@ class Detections: ) class_id = ultralytics_results.boxes.cls.cpu().numpy().astype(int) - class_names = np.array([ultralytics_results.names[i] for i in class_id]) + class_names = np.array([ultralytics_results.names[i] + for i in class_id]) return cls( xyxy=ultralytics_results.boxes.xyxy.cpu().numpy(), confidence=ultralytics_results.boxes.conf.cpu().numpy(), @@ -411,7 +450,8 @@ class Detections: return cls( xyxy=boxes, confidence=tensorflow_results["detection_scores"][0].numpy(), - class_id=tensorflow_results["detection_classes"][0].numpy().astype(int), + class_id=tensorflow_results["detection_classes"][0].numpy().astype( + int), ) @classmethod @@ -448,7 +488,8 @@ class Detections: return cls( xyxy=np.array(deepsparse_results.boxes[0]), confidence=np.array(deepsparse_results.scores[0]), - class_id=np.array(deepsparse_results.labels[0]).astype(float).astype(int), + class_id=np.array(deepsparse_results.labels[0]).astype( + float).astype(int), ) @classmethod @@ -535,24 +576,29 @@ class Detections: Class names values can be accessed using `detections["class_name"]`. """ # noqa: E501 // docs - class_ids = transformers_results["labels"].cpu().detach().numpy().astype(int) + class_ids = transformers_results["labels"].cpu( + ).detach().numpy().astype(int) data = {} if id2label is not None: - class_names = np.array([id2label[class_id] for class_id in class_ids]) + class_names = np.array([id2label[class_id] + for class_id in class_ids]) data[CLASS_NAME_DATA_FIELD] = class_names if "boxes" in transformers_results: return cls( xyxy=transformers_results["boxes"].cpu().detach().numpy(), - confidence=transformers_results["scores"].cpu().detach().numpy(), + confidence=transformers_results["scores"].cpu( + ).detach().numpy(), class_id=class_ids, data=data, ) elif "masks" in transformers_results: - masks = transformers_results["masks"].cpu().detach().numpy().astype(bool) + masks = transformers_results["masks"].cpu( + ).detach().numpy().astype(bool) return cls( xyxy=mask_to_xyxy(masks), mask=masks, - confidence=transformers_results["scores"].cpu().detach().numpy(), + confidence=transformers_results["scores"].cpu( + ).detach().numpy(), class_id=class_ids, data=data, ) @@ -595,7 +641,8 @@ class Detections: """ return cls( - xyxy=detectron2_results["instances"].pred_boxes.tensor.cpu().numpy(), + xyxy=detectron2_results["instances"].pred_boxes.tensor.cpu( + ).numpy(), confidence=detectron2_results["instances"].scores.cpu().numpy(), class_id=detectron2_results["instances"] .pred_classes.cpu() @@ -638,7 +685,8 @@ class Detections: Class names values can be accessed using `detections["class_name"]`. """ with suppress(AttributeError): - roboflow_result = roboflow_result.dict(exclude_none=True, by_alias=True) + roboflow_result = roboflow_result.dict( + exclude_none=True, by_alias=True) xyxy, confidence, class_id, masks, trackers, data = process_roboflow_result( roboflow_result=roboflow_result ) @@ -730,7 +778,8 @@ class Detections: ) xywh = np.array([mask["bbox"] for mask in sorted_generated_masks]) - mask = np.array([mask["segmentation"] for mask in sorted_generated_masks]) + mask = np.array([mask["segmentation"] + for mask in sorted_generated_masks]) if np.asarray(xywh).shape[0] == 0: return cls.empty() @@ -957,7 +1006,8 @@ class Detections: 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") + 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" @@ -1128,6 +1178,34 @@ class Detections: self.data[key] = value + def _set_at_index(self, index: int, other: Detections): + """ + Set detection values (xyxy, confidence, ...) at a specified index + to those of another Detections object, at index 0. + + Args: + index (int): The index in current detection, where values + will be set. + other (Detections): Detections object with exactly one element + to set the values from. + + Raises: + ValueError: If `other` is not made of exactly one element. + """ + if len(other) != 1: + raise ValueError( + "Detection to set from must have exactly one element.") + + self.xyxy[index] = other.xyxy[0] + if self.mask is not None and other.mask is not None: + self.mask[index] = other.mask[0] + if self.confidence is not None and other.confidence is not None: + self.confidence[index] = other.confidence[0] + if self.class_id is not None and other.class_id is not None: + self.class_id[index] = other.class_id[0] + if self.tracker_id is not None and other.tracker_id is not None: + self.tracker_id[index] = other.tracker_id[0] + @property def area(self) -> np.ndarray: """ @@ -1188,7 +1266,8 @@ class Detections: ), "Detections confidence must be given for NMS to be executed." if class_agnostic: - predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) + predictions = np.hstack( + (self.xyxy, self.confidence.reshape(-1, 1))) else: assert self.class_id is not None, ( "Detections class_id must be given for NMS to be executed. If you" @@ -1244,9 +1323,14 @@ class Detections: ), "Detections confidence must be given for NMM to be executed." if class_agnostic: - predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) + predictions = np.hstack( + (self.xyxy, self.confidence.reshape(-1, 1))) keep_to_merge_list = non_max_merge(predictions, threshold) else: + assert self.class_id is not None, ( + "Detections class_id must be given for NMS to be executed. If you" + " intended to perform class agnostic NMM set class_agnostic=True." + ) predictions = np.hstack( ( self.xyxy, @@ -1257,16 +1341,15 @@ class Detections: keep_to_merge_list = batch_non_max_merge(predictions, threshold) result = [] - for keep_ind, merge_ind_list in keep_to_merge_list.items(): for merge_ind in merge_ind_list: - if ( - box_iou_batch(self[keep_ind].xyxy, self[merge_ind].xyxy).item() - > threshold - ): - self[keep_ind] = _merge_object_detection_pair( + box_iou = box_iou_batch( + self[keep_ind].xyxy, self[merge_ind].xyxy)[0] + if box_iou > threshold: + merged_detection = _merge_object_detection_pair( self[keep_ind], self[merge_ind] ) + self._set_at_index(keep_ind, merged_detection) result.append(self[keep_ind]) return Detections.merge(result) From bee3252110887fe941028ef696ebe0f36eae3b7e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 May 2024 14:22:31 +0000 Subject: [PATCH 07/94] =?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/core.py | 57 ++++++++++++----------------------- 1 file changed, 19 insertions(+), 38 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 3d1c135a..fa34c158 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -71,8 +71,7 @@ def _merge_object_detection_pair(det1: Detections, det2: Detections) -> Detectio ``` """ if len(det1) != 1 or len(det2) != 1: - raise ValueError( - "Both Detections should have exactly 1 detected object.") + raise ValueError("Both Detections should have exactly 1 detected object.") if det2.confidence is None: winning_det = det1 @@ -296,8 +295,7 @@ class Detections: detections = sv.Detections.from_yolov5(result) ``` """ - yolov5_detections_predictions = yolov5_results.pred[0].cpu( - ).cpu().numpy() + yolov5_detections_predictions = yolov5_results.pred[0].cpu().cpu().numpy() return cls( xyxy=yolov5_detections_predictions[:, :4], @@ -344,8 +342,7 @@ class Detections: if "obb" in ultralytics_results and ultralytics_results.obb is not None: class_id = ultralytics_results.obb.cls.cpu().numpy().astype(int) - class_names = np.array( - [ultralytics_results.names[i] for i in class_id]) + class_names = np.array([ultralytics_results.names[i] for i in class_id]) oriented_box_coordinates = ultralytics_results.obb.xyxyxyxy.cpu().numpy() return cls( xyxy=ultralytics_results.obb.xyxy.cpu().numpy(), @@ -361,8 +358,7 @@ class Detections: ) class_id = ultralytics_results.boxes.cls.cpu().numpy().astype(int) - class_names = np.array([ultralytics_results.names[i] - for i in class_id]) + class_names = np.array([ultralytics_results.names[i] for i in class_id]) return cls( xyxy=ultralytics_results.boxes.xyxy.cpu().numpy(), confidence=ultralytics_results.boxes.conf.cpu().numpy(), @@ -450,8 +446,7 @@ class Detections: return cls( xyxy=boxes, confidence=tensorflow_results["detection_scores"][0].numpy(), - class_id=tensorflow_results["detection_classes"][0].numpy().astype( - int), + class_id=tensorflow_results["detection_classes"][0].numpy().astype(int), ) @classmethod @@ -488,8 +483,7 @@ class Detections: return cls( xyxy=np.array(deepsparse_results.boxes[0]), confidence=np.array(deepsparse_results.scores[0]), - class_id=np.array(deepsparse_results.labels[0]).astype( - float).astype(int), + class_id=np.array(deepsparse_results.labels[0]).astype(float).astype(int), ) @classmethod @@ -576,29 +570,24 @@ class Detections: Class names values can be accessed using `detections["class_name"]`. """ # noqa: E501 // docs - class_ids = transformers_results["labels"].cpu( - ).detach().numpy().astype(int) + class_ids = transformers_results["labels"].cpu().detach().numpy().astype(int) data = {} if id2label is not None: - class_names = np.array([id2label[class_id] - for class_id in class_ids]) + class_names = np.array([id2label[class_id] for class_id in class_ids]) data[CLASS_NAME_DATA_FIELD] = class_names if "boxes" in transformers_results: return cls( xyxy=transformers_results["boxes"].cpu().detach().numpy(), - confidence=transformers_results["scores"].cpu( - ).detach().numpy(), + confidence=transformers_results["scores"].cpu().detach().numpy(), class_id=class_ids, data=data, ) elif "masks" in transformers_results: - masks = transformers_results["masks"].cpu( - ).detach().numpy().astype(bool) + masks = transformers_results["masks"].cpu().detach().numpy().astype(bool) return cls( xyxy=mask_to_xyxy(masks), mask=masks, - confidence=transformers_results["scores"].cpu( - ).detach().numpy(), + confidence=transformers_results["scores"].cpu().detach().numpy(), class_id=class_ids, data=data, ) @@ -641,8 +630,7 @@ class Detections: """ return cls( - xyxy=detectron2_results["instances"].pred_boxes.tensor.cpu( - ).numpy(), + xyxy=detectron2_results["instances"].pred_boxes.tensor.cpu().numpy(), confidence=detectron2_results["instances"].scores.cpu().numpy(), class_id=detectron2_results["instances"] .pred_classes.cpu() @@ -685,8 +673,7 @@ class Detections: Class names values can be accessed using `detections["class_name"]`. """ with suppress(AttributeError): - roboflow_result = roboflow_result.dict( - exclude_none=True, by_alias=True) + roboflow_result = roboflow_result.dict(exclude_none=True, by_alias=True) xyxy, confidence, class_id, masks, trackers, data = process_roboflow_result( roboflow_result=roboflow_result ) @@ -778,8 +765,7 @@ class Detections: ) xywh = np.array([mask["bbox"] for mask in sorted_generated_masks]) - mask = np.array([mask["segmentation"] - for mask in sorted_generated_masks]) + mask = np.array([mask["segmentation"] for mask in sorted_generated_masks]) if np.asarray(xywh).shape[0] == 0: return cls.empty() @@ -1006,8 +992,7 @@ class Detections: 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") + 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" @@ -1193,8 +1178,7 @@ class Detections: ValueError: If `other` is not made of exactly one element. """ if len(other) != 1: - raise ValueError( - "Detection to set from must have exactly one element.") + raise ValueError("Detection to set from must have exactly one element.") self.xyxy[index] = other.xyxy[0] if self.mask is not None and other.mask is not None: @@ -1266,8 +1250,7 @@ class Detections: ), "Detections confidence must be given for NMS to be executed." if class_agnostic: - predictions = np.hstack( - (self.xyxy, self.confidence.reshape(-1, 1))) + predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) else: assert self.class_id is not None, ( "Detections class_id must be given for NMS to be executed. If you" @@ -1323,8 +1306,7 @@ class Detections: ), "Detections confidence must be given for NMM to be executed." if class_agnostic: - predictions = np.hstack( - (self.xyxy, self.confidence.reshape(-1, 1))) + predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) keep_to_merge_list = non_max_merge(predictions, threshold) else: assert self.class_id is not None, ( @@ -1343,8 +1325,7 @@ class Detections: result = [] for keep_ind, merge_ind_list in keep_to_merge_list.items(): for merge_ind in merge_ind_list: - box_iou = box_iou_batch( - self[keep_ind].xyxy, self[merge_ind].xyxy)[0] + box_iou = box_iou_batch(self[keep_ind].xyxy, self[merge_ind].xyxy)[0] if box_iou > threshold: merged_detection = _merge_object_detection_pair( self[keep_ind], self[merge_ind] From 97c407101a2755db3288613c97cbbcda4e8105c0 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Mon, 6 May 2024 17:24:41 +0300 Subject: [PATCH 08/94] NMM: Move detections merge into Detections class. * No other changes! --- supervision/detection/core.py | 251 ++++++++++++++++++---------------- 1 file changed, 135 insertions(+), 116 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index fa34c158..501a27e9 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -27,103 +27,6 @@ from supervision.utils.internal import deprecated from supervision.validators import validate_detections_fields -def _merge_object_detection_pair(det1: Detections, det2: Detections) -> Detections: - """ - Merges two Detections object into a single Detections object. - Assumes each Detections contains exactly one object. - - A `winning` detection is determined based on the confidence score of the two - input detections. This winning detection is then used to specify which - `class_id`, `tracker_id`, and `data` to include in the merged Detections object. - - The resulting `confidence` of the merged object is calculated by the weighted - contribution of each detection to the merged object. - The bounding boxes and masks of the two input detections are merged into a - single bounding box and mask, respectively. - - Args: - det1 (Detections): - The first Detections object - det2 (Detections): - The second Detections object - - Returns: - Detections: A new Detections object, with merged attributes. - - Raises: - ValueError: If the input Detections objects do not have exactly 1 detected - object. - - Example: - ```python - import cv2 - import supervision as sv - from inference import get_model - - image = cv2.imread() - model = get_model(model_id="yolov8s-640") - - result = model.infer(image)[0] - detections = sv.Detections.from_inference(result) - - merged_detections = merge_object_detection_pair( - detections[0], detections[1]) - ``` - """ - if len(det1) != 1 or len(det2) != 1: - raise ValueError("Both Detections should have exactly 1 detected object.") - - if det2.confidence is None: - winning_det = det1 - elif det1.confidence is None: - winning_det = det2 - elif det1.confidence[0] >= det2.confidence[0]: - winning_det = det1 - else: - winning_det = det2 - - area_det1 = (det1.xyxy[0][2] - det1.xyxy[0][0]) * ( - det1.xyxy[0][3] - det1.xyxy[0][1] - ) - area_det2 = (det2.xyxy[0][2] - det2.xyxy[0][0]) * ( - det2.xyxy[0][3] - det2.xyxy[0][1] - ) - - merged_x1, merged_y1 = np.minimum(det1.xyxy[0][:2], det2.xyxy[0][:2]) - merged_x2, merged_y2 = np.maximum(det1.xyxy[0][2:], det2.xyxy[0][2:]) - - merged_xy = np.array([[merged_x1, merged_y1, merged_x2, merged_y2]]) - - winning_class_id = winning_det.class_id - - if det1.confidence is None or det2.confidence is None: - merged_confidence = None - else: - merged_confidence = ( - area_det1 * det1.confidence[0] + area_det2 * det2.confidence[0] - ) / (area_det1 + area_det2) - merged_confidence = np.array([merged_confidence]) - - merged_mask = None - if det1.mask is not None and det2.mask is not None: - merged_mask = np.logical_or(det1.mask, det2.mask) - - winning_tracker_id = winning_det.tracker_id - - winning_data = None - if det1.data and det2.data: - winning_data = winning_det.data - - return Detections( - xyxy=merged_xy, - mask=merged_mask, - confidence=merged_confidence, - class_id=winning_class_id, - tracker_id=winning_tracker_id, - data=winning_data, - ) - - @dataclass class Detections: """ @@ -295,7 +198,8 @@ class Detections: detections = sv.Detections.from_yolov5(result) ``` """ - yolov5_detections_predictions = yolov5_results.pred[0].cpu().cpu().numpy() + yolov5_detections_predictions = yolov5_results.pred[0].cpu( + ).cpu().numpy() return cls( xyxy=yolov5_detections_predictions[:, :4], @@ -342,7 +246,8 @@ class Detections: if "obb" in ultralytics_results and ultralytics_results.obb is not None: class_id = ultralytics_results.obb.cls.cpu().numpy().astype(int) - class_names = np.array([ultralytics_results.names[i] for i in class_id]) + class_names = np.array( + [ultralytics_results.names[i] for i in class_id]) oriented_box_coordinates = ultralytics_results.obb.xyxyxyxy.cpu().numpy() return cls( xyxy=ultralytics_results.obb.xyxy.cpu().numpy(), @@ -358,7 +263,8 @@ class Detections: ) class_id = ultralytics_results.boxes.cls.cpu().numpy().astype(int) - class_names = np.array([ultralytics_results.names[i] for i in class_id]) + class_names = np.array([ultralytics_results.names[i] + for i in class_id]) return cls( xyxy=ultralytics_results.boxes.xyxy.cpu().numpy(), confidence=ultralytics_results.boxes.conf.cpu().numpy(), @@ -446,7 +352,8 @@ class Detections: return cls( xyxy=boxes, confidence=tensorflow_results["detection_scores"][0].numpy(), - class_id=tensorflow_results["detection_classes"][0].numpy().astype(int), + class_id=tensorflow_results["detection_classes"][0].numpy().astype( + int), ) @classmethod @@ -483,7 +390,8 @@ class Detections: return cls( xyxy=np.array(deepsparse_results.boxes[0]), confidence=np.array(deepsparse_results.scores[0]), - class_id=np.array(deepsparse_results.labels[0]).astype(float).astype(int), + class_id=np.array(deepsparse_results.labels[0]).astype( + float).astype(int), ) @classmethod @@ -570,24 +478,29 @@ class Detections: Class names values can be accessed using `detections["class_name"]`. """ # noqa: E501 // docs - class_ids = transformers_results["labels"].cpu().detach().numpy().astype(int) + class_ids = transformers_results["labels"].cpu( + ).detach().numpy().astype(int) data = {} if id2label is not None: - class_names = np.array([id2label[class_id] for class_id in class_ids]) + class_names = np.array([id2label[class_id] + for class_id in class_ids]) data[CLASS_NAME_DATA_FIELD] = class_names if "boxes" in transformers_results: return cls( xyxy=transformers_results["boxes"].cpu().detach().numpy(), - confidence=transformers_results["scores"].cpu().detach().numpy(), + confidence=transformers_results["scores"].cpu( + ).detach().numpy(), class_id=class_ids, data=data, ) elif "masks" in transformers_results: - masks = transformers_results["masks"].cpu().detach().numpy().astype(bool) + masks = transformers_results["masks"].cpu( + ).detach().numpy().astype(bool) return cls( xyxy=mask_to_xyxy(masks), mask=masks, - confidence=transformers_results["scores"].cpu().detach().numpy(), + confidence=transformers_results["scores"].cpu( + ).detach().numpy(), class_id=class_ids, data=data, ) @@ -630,7 +543,8 @@ class Detections: """ return cls( - xyxy=detectron2_results["instances"].pred_boxes.tensor.cpu().numpy(), + xyxy=detectron2_results["instances"].pred_boxes.tensor.cpu( + ).numpy(), confidence=detectron2_results["instances"].scores.cpu().numpy(), class_id=detectron2_results["instances"] .pred_classes.cpu() @@ -673,7 +587,8 @@ class Detections: Class names values can be accessed using `detections["class_name"]`. """ with suppress(AttributeError): - roboflow_result = roboflow_result.dict(exclude_none=True, by_alias=True) + roboflow_result = roboflow_result.dict( + exclude_none=True, by_alias=True) xyxy, confidence, class_id, masks, trackers, data = process_roboflow_result( roboflow_result=roboflow_result ) @@ -765,7 +680,8 @@ class Detections: ) xywh = np.array([mask["bbox"] for mask in sorted_generated_masks]) - mask = np.array([mask["segmentation"] for mask in sorted_generated_masks]) + mask = np.array([mask["segmentation"] + for mask in sorted_generated_masks]) if np.asarray(xywh).shape[0] == 0: return cls.empty() @@ -992,7 +908,8 @@ class Detections: 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") + 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" @@ -1178,7 +1095,8 @@ class Detections: ValueError: If `other` is not made of exactly one element. """ if len(other) != 1: - raise ValueError("Detection to set from must have exactly one element.") + raise ValueError( + "Detection to set from must have exactly one element.") self.xyxy[index] = other.xyxy[0] if self.mask is not None and other.mask is not None: @@ -1250,7 +1168,8 @@ class Detections: ), "Detections confidence must be given for NMS to be executed." if class_agnostic: - predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) + predictions = np.hstack( + (self.xyxy, self.confidence.reshape(-1, 1))) else: assert self.class_id is not None, ( "Detections class_id must be given for NMS to be executed. If you" @@ -1306,7 +1225,8 @@ class Detections: ), "Detections confidence must be given for NMM to be executed." if class_agnostic: - predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) + predictions = np.hstack( + (self.xyxy, self.confidence.reshape(-1, 1))) keep_to_merge_list = non_max_merge(predictions, threshold) else: assert self.class_id is not None, ( @@ -1325,12 +1245,111 @@ class Detections: result = [] for keep_ind, merge_ind_list in keep_to_merge_list.items(): for merge_ind in merge_ind_list: - box_iou = box_iou_batch(self[keep_ind].xyxy, self[merge_ind].xyxy)[0] + box_iou = box_iou_batch( + self[keep_ind].xyxy, self[merge_ind].xyxy)[0] if box_iou > threshold: - merged_detection = _merge_object_detection_pair( + merged_detection = self._merge_object_detection_pair( self[keep_ind], self[merge_ind] ) self._set_at_index(keep_ind, merged_detection) result.append(self[keep_ind]) return Detections.merge(result) + + @staticmethod + def _merge_object_detection_pair(det1: Detections, det2: Detections) -> Detections: + """ + Merges two Detections object into a single Detections object. + Assumes each Detections contains exactly one object. + + A `winning` detection is determined based on the confidence score of the two + input detections. This winning detection is then used to specify which + `class_id`, `tracker_id`, and `data` to include in the merged Detections object. + + The resulting `confidence` of the merged object is calculated by the weighted + contribution of each detection to the merged object. + The bounding boxes and masks of the two input detections are merged into a + single bounding box and mask, respectively. + + Args: + det1 (Detections): + The first Detections object + det2 (Detections): + The second Detections object + + Returns: + Detections: A new Detections object, with merged attributes. + + Raises: + ValueError: If the input Detections objects do not have exactly 1 detected + object. + + Example: + ```python + import cv2 + import supervision as sv + from inference import get_model + + image = cv2.imread() + model = get_model(model_id="yolov8s-640") + + result = model.infer(image)[0] + detections = sv.Detections.from_inference(result) + + merged_detections = merge_object_detection_pair( + detections[0], detections[1]) + ``` + """ + if len(det1) != 1 or len(det2) != 1: + raise ValueError( + "Both Detections should have exactly 1 detected object.") + + if det2.confidence is None: + winning_det = det1 + elif det1.confidence is None: + winning_det = det2 + elif det1.confidence[0] >= det2.confidence[0]: + winning_det = det1 + else: + winning_det = det2 + + area_det1 = (det1.xyxy[0][2] - det1.xyxy[0][0]) * ( + det1.xyxy[0][3] - det1.xyxy[0][1] + ) + area_det2 = (det2.xyxy[0][2] - det2.xyxy[0][0]) * ( + det2.xyxy[0][3] - det2.xyxy[0][1] + ) + + merged_x1, merged_y1 = np.minimum(det1.xyxy[0][:2], det2.xyxy[0][:2]) + merged_x2, merged_y2 = np.maximum(det1.xyxy[0][2:], det2.xyxy[0][2:]) + + merged_xy = np.array([[merged_x1, merged_y1, merged_x2, merged_y2]]) + + winning_class_id = winning_det.class_id + + if det1.confidence is None or det2.confidence is None: + merged_confidence = None + else: + merged_confidence = ( + area_det1 * det1.confidence[0] + area_det2 * det2.confidence[0] + ) / (area_det1 + area_det2) + merged_confidence = np.array([merged_confidence]) + + merged_mask = None + if det1.mask is not None and det2.mask is not None: + merged_mask = np.logical_or(det1.mask, det2.mask) + + winning_tracker_id = winning_det.tracker_id + + winning_data = None + if det1.data and det2.data: + winning_data = winning_det.data + + return Detections( + xyxy=merged_xy, + mask=merged_mask, + confidence=merged_confidence, + class_id=winning_class_id, + tracker_id=winning_tracker_id, + data=winning_data, + ) From 204669b08c650378cb03553c55ec417975a4371e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 May 2024 14:25:13 +0000 Subject: [PATCH 09/94] =?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/core.py | 57 ++++++++++++----------------------- 1 file changed, 19 insertions(+), 38 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 501a27e9..beb68923 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -198,8 +198,7 @@ class Detections: detections = sv.Detections.from_yolov5(result) ``` """ - yolov5_detections_predictions = yolov5_results.pred[0].cpu( - ).cpu().numpy() + yolov5_detections_predictions = yolov5_results.pred[0].cpu().cpu().numpy() return cls( xyxy=yolov5_detections_predictions[:, :4], @@ -246,8 +245,7 @@ class Detections: if "obb" in ultralytics_results and ultralytics_results.obb is not None: class_id = ultralytics_results.obb.cls.cpu().numpy().astype(int) - class_names = np.array( - [ultralytics_results.names[i] for i in class_id]) + class_names = np.array([ultralytics_results.names[i] for i in class_id]) oriented_box_coordinates = ultralytics_results.obb.xyxyxyxy.cpu().numpy() return cls( xyxy=ultralytics_results.obb.xyxy.cpu().numpy(), @@ -263,8 +261,7 @@ class Detections: ) class_id = ultralytics_results.boxes.cls.cpu().numpy().astype(int) - class_names = np.array([ultralytics_results.names[i] - for i in class_id]) + class_names = np.array([ultralytics_results.names[i] for i in class_id]) return cls( xyxy=ultralytics_results.boxes.xyxy.cpu().numpy(), confidence=ultralytics_results.boxes.conf.cpu().numpy(), @@ -352,8 +349,7 @@ class Detections: return cls( xyxy=boxes, confidence=tensorflow_results["detection_scores"][0].numpy(), - class_id=tensorflow_results["detection_classes"][0].numpy().astype( - int), + class_id=tensorflow_results["detection_classes"][0].numpy().astype(int), ) @classmethod @@ -390,8 +386,7 @@ class Detections: return cls( xyxy=np.array(deepsparse_results.boxes[0]), confidence=np.array(deepsparse_results.scores[0]), - class_id=np.array(deepsparse_results.labels[0]).astype( - float).astype(int), + class_id=np.array(deepsparse_results.labels[0]).astype(float).astype(int), ) @classmethod @@ -478,29 +473,24 @@ class Detections: Class names values can be accessed using `detections["class_name"]`. """ # noqa: E501 // docs - class_ids = transformers_results["labels"].cpu( - ).detach().numpy().astype(int) + class_ids = transformers_results["labels"].cpu().detach().numpy().astype(int) data = {} if id2label is not None: - class_names = np.array([id2label[class_id] - for class_id in class_ids]) + class_names = np.array([id2label[class_id] for class_id in class_ids]) data[CLASS_NAME_DATA_FIELD] = class_names if "boxes" in transformers_results: return cls( xyxy=transformers_results["boxes"].cpu().detach().numpy(), - confidence=transformers_results["scores"].cpu( - ).detach().numpy(), + confidence=transformers_results["scores"].cpu().detach().numpy(), class_id=class_ids, data=data, ) elif "masks" in transformers_results: - masks = transformers_results["masks"].cpu( - ).detach().numpy().astype(bool) + masks = transformers_results["masks"].cpu().detach().numpy().astype(bool) return cls( xyxy=mask_to_xyxy(masks), mask=masks, - confidence=transformers_results["scores"].cpu( - ).detach().numpy(), + confidence=transformers_results["scores"].cpu().detach().numpy(), class_id=class_ids, data=data, ) @@ -543,8 +533,7 @@ class Detections: """ return cls( - xyxy=detectron2_results["instances"].pred_boxes.tensor.cpu( - ).numpy(), + xyxy=detectron2_results["instances"].pred_boxes.tensor.cpu().numpy(), confidence=detectron2_results["instances"].scores.cpu().numpy(), class_id=detectron2_results["instances"] .pred_classes.cpu() @@ -587,8 +576,7 @@ class Detections: Class names values can be accessed using `detections["class_name"]`. """ with suppress(AttributeError): - roboflow_result = roboflow_result.dict( - exclude_none=True, by_alias=True) + roboflow_result = roboflow_result.dict(exclude_none=True, by_alias=True) xyxy, confidence, class_id, masks, trackers, data = process_roboflow_result( roboflow_result=roboflow_result ) @@ -680,8 +668,7 @@ class Detections: ) xywh = np.array([mask["bbox"] for mask in sorted_generated_masks]) - mask = np.array([mask["segmentation"] - for mask in sorted_generated_masks]) + mask = np.array([mask["segmentation"] for mask in sorted_generated_masks]) if np.asarray(xywh).shape[0] == 0: return cls.empty() @@ -908,8 +895,7 @@ class Detections: 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") + 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" @@ -1095,8 +1081,7 @@ class Detections: ValueError: If `other` is not made of exactly one element. """ if len(other) != 1: - raise ValueError( - "Detection to set from must have exactly one element.") + raise ValueError("Detection to set from must have exactly one element.") self.xyxy[index] = other.xyxy[0] if self.mask is not None and other.mask is not None: @@ -1168,8 +1153,7 @@ class Detections: ), "Detections confidence must be given for NMS to be executed." if class_agnostic: - predictions = np.hstack( - (self.xyxy, self.confidence.reshape(-1, 1))) + predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) else: assert self.class_id is not None, ( "Detections class_id must be given for NMS to be executed. If you" @@ -1225,8 +1209,7 @@ class Detections: ), "Detections confidence must be given for NMM to be executed." if class_agnostic: - predictions = np.hstack( - (self.xyxy, self.confidence.reshape(-1, 1))) + predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) keep_to_merge_list = non_max_merge(predictions, threshold) else: assert self.class_id is not None, ( @@ -1245,8 +1228,7 @@ class Detections: result = [] for keep_ind, merge_ind_list in keep_to_merge_list.items(): for merge_ind in merge_ind_list: - box_iou = box_iou_batch( - self[keep_ind].xyxy, self[merge_ind].xyxy)[0] + box_iou = box_iou_batch(self[keep_ind].xyxy, self[merge_ind].xyxy)[0] if box_iou > threshold: merged_detection = self._merge_object_detection_pair( self[keep_ind], self[merge_ind] @@ -1301,8 +1283,7 @@ class Detections: ``` """ if len(det1) != 1 or len(det2) != 1: - raise ValueError( - "Both Detections should have exactly 1 detected object.") + raise ValueError("Both Detections should have exactly 1 detected object.") if det2.confidence is None: winning_det = det1 From c3b77d05c09f4a0192fb48aa95ab6ef701c557ed Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Tue, 14 May 2024 17:12:19 +0300 Subject: [PATCH 10/94] Rename, remove functions, unit-test & change `merge_object_detection_pair` --- supervision/detection/core.py | 164 ++++++++++++++++----------------- supervision/detection/utils.py | 29 +----- test/detection/test_core.py | 129 +++++++++++++++++++++++++- 3 files changed, 213 insertions(+), 109 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index d56ba516..0777571f 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -8,8 +8,9 @@ import numpy as np from supervision.config import CLASS_NAME_DATA_FIELD, ORIENTED_BOX_COORDINATES from supervision.detection.utils import ( - batch_non_max_merge, + box_batch_non_max_merge, box_iou_batch, + box_non_max_merge, box_non_max_suppression, calculate_masks_centroids, extract_ultralytics_masks, @@ -18,7 +19,6 @@ from supervision.detection.utils import ( mask_non_max_suppression, mask_to_xyxy, merge_data, - non_max_merge, process_roboflow_result, xywh_to_xyxy, ) @@ -1213,7 +1213,7 @@ class Detections: if class_agnostic: predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) - keep_to_merge_list = non_max_merge(predictions, threshold) + keep_to_merge_list = box_non_max_merge(predictions, threshold) else: assert self.class_id is not None, ( "Detections class_id must be given for NMS to be executed. If you" @@ -1226,14 +1226,14 @@ class Detections: self.class_id.reshape(-1, 1), ) ) - keep_to_merge_list = batch_non_max_merge(predictions, threshold) + keep_to_merge_list = box_batch_non_max_merge(predictions, threshold) result = [] for keep_ind, merge_ind_list in keep_to_merge_list.items(): for merge_ind in merge_ind_list: box_iou = box_iou_batch(self[keep_ind].xyxy, self[merge_ind].xyxy)[0] if box_iou > threshold: - merged_detection = self._merge_object_detection_pair( + merged_detection = self.merge_object_detection_pair( self[keep_ind], self[merge_ind] ) self._set_at_index(keep_ind, merged_detection) @@ -1241,99 +1241,95 @@ class Detections: return Detections.merge(result) - @staticmethod - def _merge_object_detection_pair(det1: Detections, det2: Detections) -> Detections: - """ - Merges two Detections object into a single Detections object. - Assumes each Detections contains exactly one object. - A `winning` detection is determined based on the confidence score of the two - input detections. This winning detection is then used to specify which - `class_id`, `tracker_id`, and `data` to include in the merged Detections object. +def merge_object_detection_pair(det1: Detections, det2: Detections) -> Detections: + """ + Merges two Detections object into a single Detections object. + Assumes each Detections contains exactly one object. - The resulting `confidence` of the merged object is calculated by the weighted - contribution of each detection to the merged object. - The bounding boxes and masks of the two input detections are merged into a - single bounding box and mask, respectively. + A `winning` detection is determined based on the confidence score of the two + input detections. This winning detection is then used to specify which + `class_id`, `tracker_id`, and `data` to include in the merged Detections object. - Args: - det1 (Detections): - The first Detections object - det2 (Detections): - The second Detections object + The resulting `confidence` of the merged object is calculated by the weighted + contribution of ea detection to the merged object. + The bounding boxes and masks of the two input detections are merged into a + single bounding box and mask, respectively. - Returns: - Detections: A new Detections object, with merged attributes. + Args: + det1 (Detections): + The first Detections object + det2 (Detections): + The second Detections object - Raises: - ValueError: If the input Detections objects do not have exactly 1 detected - object. + Returns: + Detections: A new Detections object, with merged attributes. - Example: - ```python - import cv2 - import supervision as sv - from inference import get_model + Raises: + ValueError: If the input Detections objects do not have exactly 1 detected + object. - image = cv2.imread() - model = get_model(model_id="yolov8s-640") + Example: + ```python + import cv2 + import supervision as sv + from inference import get_model - result = model.infer(image)[0] - detections = sv.Detections.from_inference(result) + image = cv2.imread() + model = get_model(model_id="yolov8s-640") - merged_detections = merge_object_detection_pair( - detections[0], detections[1]) - ``` - """ - if len(det1) != 1 or len(det2) != 1: - raise ValueError("Both Detections should have exactly 1 detected object.") + result = model.infer(image)[0] + detections = sv.Detections.from_inference(result) - if det2.confidence is None: - winning_det = det1 - elif det1.confidence is None: - winning_det = det2 - elif det1.confidence[0] >= det2.confidence[0]: - winning_det = det1 - else: - winning_det = det2 + merged_detections = merge_object_detection_pair( + detections[0], detections[1]) + ``` + """ + if len(det1) != 1 or len(det2) != 1: + raise ValueError("Both Detections should have exactly 1 detected object.") - area_det1 = (det1.xyxy[0][2] - det1.xyxy[0][0]) * ( - det1.xyxy[0][3] - det1.xyxy[0][1] - ) - area_det2 = (det2.xyxy[0][2] - det2.xyxy[0][0]) * ( - det2.xyxy[0][3] - det2.xyxy[0][1] - ) + if det2.confidence is None: + winning_det = det1 + elif det1.confidence is None: + winning_det = det2 + elif det1.confidence[0] >= det2.confidence[0]: + winning_det = det1 + else: + winning_det = det2 - merged_x1, merged_y1 = np.minimum(det1.xyxy[0][:2], det2.xyxy[0][:2]) - merged_x2, merged_y2 = np.maximum(det1.xyxy[0][2:], det2.xyxy[0][2:]) + area_det1 = (det1.xyxy[0][2] - det1.xyxy[0][0]) * ( + det1.xyxy[0][3] - det1.xyxy[0][1] + ) + area_det2 = (det2.xyxy[0][2] - det2.xyxy[0][0]) * ( + det2.xyxy[0][3] - det2.xyxy[0][1] + ) - merged_xy = np.array([[merged_x1, merged_y1, merged_x2, merged_y2]]) + merged_x1, merged_y1 = np.minimum(det1.xyxy[0][:2], det2.xyxy[0][:2]) + merged_x2, merged_y2 = np.maximum(det1.xyxy[0][2:], det2.xyxy[0][2:]) + merged_xy = np.array([[merged_x1, merged_y1, merged_x2, merged_y2]]) - winning_class_id = winning_det.class_id + if det2.mask is None or det1.mask is None: + merged_mask = winning_det.mask + else: + merged_mask = np.logical_or(det1.mask, det2.mask) - if det1.confidence is None or det2.confidence is None: - merged_confidence = None - else: - merged_confidence = ( - area_det1 * det1.confidence[0] + area_det2 * det2.confidence[0] - ) / (area_det1 + area_det2) - merged_confidence = np.array([merged_confidence]) + if det1.confidence is None or det2.confidence is None: + merged_confidence = winning_det.confidence + else: + merged_confidence = ( + area_det1 * det1.confidence[0] + area_det2 * det2.confidence[0] + ) / (area_det1 + area_det2) + merged_confidence = np.array([merged_confidence]) - merged_mask = None - if det1.mask is not None and det2.mask is not None: - merged_mask = np.logical_or(det1.mask, det2.mask) + winning_class_id = winning_det.class_id + winning_tracker_id = winning_det.tracker_id + winning_data = winning_det.data - winning_tracker_id = winning_det.tracker_id - - winning_data = None - if det1.data and det2.data: - winning_data = winning_det.data - - return Detections( - xyxy=merged_xy, - mask=merged_mask, - confidence=merged_confidence, - class_id=winning_class_id, - tracker_id=winning_tracker_id, - data=winning_data, - ) + return Detections( + xyxy=merged_xy, + mask=merged_mask, + confidence=merged_confidence, + class_id=winning_class_id, + tracker_id=winning_tracker_id, + data=winning_data, + ) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index d2e403a4..bd20ab37 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -274,7 +274,7 @@ def box_non_max_suppression( return keep[sort_index.argsort()] -def non_max_merge( +def box_non_max_merge( predictions: np.ndarray, threshold: float = 0.5 ) -> Dict[int, List[int]]: """ @@ -353,7 +353,7 @@ def non_max_merge( return keep_to_merge_list -def batch_non_max_merge( +def box_batch_non_max_merge( predictions: np.ndarray, threshold: float = 0.5 ) -> Dict[int, List[int]]: """ @@ -375,7 +375,9 @@ def batch_non_max_merge( keep_to_merge_list = {} for category_id in np.unique(category_ids): curr_indices = np.where(category_ids == category_id)[0] - curr_keep_to_merge_list = non_max_merge(predictions[curr_indices], threshold) + curr_keep_to_merge_list = box_non_max_merge( + predictions[curr_indices], threshold + ) curr_indices_list = curr_indices.tolist() for curr_keep, curr_merge_list in curr_keep_to_merge_list.items(): keep = curr_indices_list[curr_keep] @@ -384,27 +386,6 @@ def batch_non_max_merge( return keep_to_merge_list -def get_merged_bbox(bbox1: np.ndarray, bbox2: np.ndarray) -> np.ndarray: - """ - Merges two bounding boxes into one. - - Args: - bbox1 (np.ndarray): A numpy array of shape `(, 4)` where the - row corresponds to a bounding box in - the format `(x_min, y_min, x_max, y_max)`. - bbox2 (np.ndarray): A numpy array of shape `(, 4)` where the - row corresponds to a bounding box in - the format `(x_min, y_min, x_max, y_max)`. - - Returns: - np.ndarray: A numpy array of shape `(, 4)` where the new - bounding box is the merged bounding box of `bbox1` and `bbox2`. - """ - left_top = np.minimum(bbox1[0][:2], bbox2[0][:2]) - right_bottom = np.maximum(bbox1[0][2:], bbox2[0][2:]) - return np.array([np.concatenate([left_top, right_bottom])]) - - def clip_boxes(xyxy: np.ndarray, resolution_wh: Tuple[int, int]) -> np.ndarray: """ Clips bounding boxes coordinates to fit within the frame resolution. diff --git a/test/detection/test_core.py b/test/detection/test_core.py index 12f3de28..31e56dec 100644 --- a/test/detection/test_core.py +++ b/test/detection/test_core.py @@ -5,7 +5,7 @@ from typing import List, Optional, Union import numpy as np import pytest -from supervision.detection.core import Detections +from supervision.detection.core import Detections, merge_object_detection_pair from supervision.geometry.core import Position PREDICTIONS = np.array( @@ -421,3 +421,130 @@ def test_equal( detections_a: Detections, detections_b: Detections, expected_result: bool ) -> None: assert (detections_a == detections_b) == expected_result + + +@pytest.mark.parametrize( + "detection_1, detection_2, expected_result, exception", + [ + ( + mock_detections( + xyxy=[[10, 10, 30, 30]], + ), + mock_detections( + xyxy=[[10, 10, 30, 30]], + ), + mock_detections( + xyxy=[[10, 10, 30, 30]], + ), + DoesNotRaise(), + ), # Merge with self + ( + mock_detections( + xyxy=[[10, 10, 30, 30]], + ), + Detections.empty(), + None, + pytest.raises(ValueError), + ), # merge with empty: error + ( + mock_detections( + xyxy=[[10, 10, 30, 30]], + ), + mock_detections( + xyxy=[[10, 10, 30, 30], [40, 40, 60, 60]], + ), + None, + pytest.raises(ValueError), + ), # merge with 2+ objects: error + ( + mock_detections( + xyxy=[[10, 10, 30, 30]], + confidence=[0.1], + class_id=[1], + mask=[np.array([[1, 1, 0], [1, 1, 0], [0, 0, 0]], dtype=bool)], + tracker_id=[1], + data={"key_1": [1]}, + ), + mock_detections( + xyxy=[[20, 20, 40, 40]], + confidence=[0.1], + class_id=[2], + mask=[np.array([[0, 0, 0], [0, 1, 1], [0, 1, 1]], dtype=bool)], + tracker_id=[2], + data={"key_2": [2]}, + ), + mock_detections( + xyxy=[[10, 10, 40, 40]], + confidence=[0.1], + class_id=[1], + mask=[np.array([[1, 1, 0], [1, 1, 1], [0, 1, 1]], dtype=bool)], + tracker_id=[1], + data={"key_1": [1]}, + ), + DoesNotRaise(), + ), # Same confidence - merge box & mask, tiebreak to detection_1 + ( + mock_detections( + xyxy=[[0, 0, 20, 20]], + confidence=[0.1], + class_id=[1], + mask=[np.array([[1, 1, 0], [1, 1, 0], [0, 0, 0]], dtype=bool)], + tracker_id=[1], + data={"key_1": [1]}, + ), + mock_detections( + xyxy=[[10, 10, 50, 50]], + confidence=[0.2], + class_id=[2], + mask=[np.array([[0, 0, 0], [0, 1, 1], [0, 1, 1]], dtype=bool)], + tracker_id=[2], + data={"key_2": [2]}, + ), + mock_detections( + xyxy=[[0, 0, 50, 50]], + confidence=[(1 * 0.1 + 4 * 0.2) / 5], + class_id=[2], + mask=[np.array([[1, 1, 0], [1, 1, 1], [0, 1, 1]], dtype=bool)], + tracker_id=[2], + data={"key_2": [2]}, + ), + DoesNotRaise(), + ), # Different confidence, different area + ( + mock_detections( + xyxy=[[0, 0, 20, 20]], + confidence=None, + class_id=[1], + mask=[np.array([[1, 1, 0], [1, 1, 0], [0, 0, 0]], dtype=bool)], + tracker_id=[1], + data={"key_1": [1]}, + ), + mock_detections( + xyxy=[[10, 10, 30, 30]], + confidence=[0.2], + class_id=[2], + mask=[np.array([[0, 0, 0], [0, 1, 1], [0, 1, 1]], dtype=bool)], + tracker_id=[2], + data={"key_2": [2]}, + ), + mock_detections( + xyxy=[[0, 0, 30, 30]], + confidence=[0.2], + class_id=[2], + mask=[np.array([[1, 1, 0], [1, 1, 1], [0, 1, 1]], dtype=bool)], + tracker_id=[2], + data={"key_2": [2]}, + ), + DoesNotRaise(), + ), # merge with no confidence + ], +) +def test_merge_object_detection_pair( + detection_1: Detections, + detection_2: Detections, + expected_result: Optional[Detections], + exception: Exception, +): + with exception: + result = merge_object_detection_pair(detection_1, detection_2) + assert result == expected_result From 8014e88944b9f1135448761b0c7f0832df7589ae Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Tue, 14 May 2024 17:42:47 +0300 Subject: [PATCH 11/94] Test box_non_max_merge --- supervision/detection/utils.py | 6 +- test/detection/test_utils.py | 126 +++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index bd20ab37..f177d088 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -275,7 +275,7 @@ def box_non_max_suppression( def box_non_max_merge( - predictions: np.ndarray, threshold: float = 0.5 + predictions: np.ndarray, iou_threshold: float = 0.5 ) -> Dict[int, List[int]]: """ Apply greedy version of non-maximum merging to avoid detecting too many @@ -285,7 +285,7 @@ def box_non_max_merge( predictions (np.ndarray): An array of shape `(n, 5)` containing the bounding boxes coordinates in format `[x1, y1, x2, y2]` and the confidence scores. - threshold (float, optional): The intersection-over-union threshold + iou_threshold (float, optional): The intersection-over-union threshold to use for non-maximum suppression. Defaults to 0.5. Returns: @@ -338,7 +338,7 @@ def box_non_max_merge( union = (rem_areas - inter) + areas[idx] match_metric_value = inter / union - mask = match_metric_value < threshold + mask = match_metric_value < iou_threshold mask = mask.astype(np.uint8) matched_box_indices = np.flip(order[np.where(mask == 0)[0]]) unmatched_indices = order[np.where(mask == 1)[0]] diff --git a/test/detection/test_utils.py b/test/detection/test_utils.py index 097c5c6e..e6f33084 100644 --- a/test/detection/test_utils.py +++ b/test/detection/test_utils.py @@ -6,6 +6,7 @@ import pytest from supervision.config import CLASS_NAME_DATA_FIELD from supervision.detection.utils import ( + box_non_max_merge, box_non_max_suppression, calculate_masks_centroids, clip_boxes, @@ -127,6 +128,131 @@ def test_box_non_max_suppression( assert np.array_equal(result, expected_result) +@pytest.mark.parametrize( + "predictions, iou_threshold, expected_result, exception", + [ + ( + np.empty(shape=(0, 5), dtype=float), + 0.5, + {}, + DoesNotRaise(), + ), + ( + np.array([[0, 0, 10, 10, 1.0]]), + 0.5, + {0: []}, + DoesNotRaise(), + ), + ( + np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]), + 0.5, + {1: [0]}, + DoesNotRaise(), + ), # High overlap, tie-break to second det + ( + np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 0.99]]), + 0.5, + {0: [1]}, + DoesNotRaise(), + ), # High overlap, merge to high confidence + ( + np.array([[0, 0, 10, 10, 0.99], [0, 0, 9, 9, 1.0]]), + 0.5, + {1: [0]}, + DoesNotRaise(), + ), # (test symmetry) High overlap, merge to high confidence + ( + np.array([[0, 0, 10, 10, 0.99], [0, 0, 9, 9, 1.0]]), + 0.5, + {1: [0]}, + DoesNotRaise(), + ), # (test symmetry) High overlap, merge to high confidence + ( + np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]), + 1.0, + {0: [], 1: []}, + DoesNotRaise(), + ), # High IOU required + ( + np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]), + 0.0, + {1: [0]}, + DoesNotRaise(), + ), # No IOU required + ( + np.array([[0, 0, 10, 10, 1.0], [0, 0, 5, 5, 0.9]]), + 0.25, + {0: [1]}, + DoesNotRaise(), + ), # Below IOU requirement + ( + np.array([[0, 0, 10, 10, 1.0], [0, 0, 5, 5, 0.9]]), + 0.26, + {0: [], 1: []}, + DoesNotRaise(), + ), # Above IOU requirement + ( + np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0], [0, 0, 8, 8, 1.0]]), + 0.5, + {2: [1, 0]}, + DoesNotRaise(), + ), # 3 boxes + ( + np.array( + [ + [0, 0, 10, 10, 1.0], + [0, 0, 9, 9, 1.0], + [5, 5, 10, 10, 1.0], + [6, 6, 10, 10, 1.0], + [9, 9, 10, 10, 1.0], + ] + ), + 0.5, + {1: [0], 3: [2], 4: []}, + DoesNotRaise(), + ), # 5 boxes, 2 merges, 1 separate + ( + np.array( + [ + [0, 0, 2, 1, 1.0], + [1, 0, 3, 1, 1.0], + [2, 0, 4, 1, 1.0], + [3, 0, 5, 1, 1.0], + [4, 0, 6, 1, 1.0], + ] + ), + 0.33, + {0: [], 2: [1], 4: [3]}, + DoesNotRaise(), + ), # sequential merge, half overlap + ( + np.array( + [ + [0, 0, 2, 1, 0.9], + [1, 0, 3, 1, 0.9], + [2, 0, 4, 1, 1.0], + [3, 0, 5, 1, 0.9], + [4, 0, 6, 1, 0.9], + ] + ), + 0.33, + {0: [], 2: [3, 1], 4: []}, + DoesNotRaise(), + ), # confidence + ], +) +def test_box_non_max_merge( + predictions: np.ndarray, + iou_threshold: float, + expected_result: Dict[int, List[int]], + exception: Exception, +) -> None: + with exception: + result = box_non_max_merge(predictions=predictions, iou_threshold=iou_threshold) + + assert result == expected_result + + @pytest.mark.parametrize( "predictions, masks, iou_threshold, expected_result, exception", [ From 26bafec8f732ae921fc44ac068e9ed564a067331 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Wed, 15 May 2024 09:23:34 +0300 Subject: [PATCH 12/94] Test box_non_max_merge, rename threshold,to __init__ --- supervision/__init__.py | 4 +++- supervision/detection/core.py | 4 ++-- supervision/detection/utils.py | 8 ++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/supervision/__init__.py b/supervision/__init__.py index 16de484a..3eae2e17 100644 --- a/supervision/__init__.py +++ b/supervision/__init__.py @@ -35,7 +35,7 @@ from supervision.dataset.core import ( DetectionDataset, ) from supervision.detection.annotate import BoxAnnotator -from supervision.detection.core import Detections +from supervision.detection.core import Detections, merge_object_detection_pair from supervision.detection.line_zone import LineZone, LineZoneAnnotator from supervision.detection.tools.csv_sink import CSVSink from supervision.detection.tools.inference_slicer import InferenceSlicer @@ -43,7 +43,9 @@ from supervision.detection.tools.json_sink import JSONSink from supervision.detection.tools.polygon_zone import PolygonZone, PolygonZoneAnnotator from supervision.detection.tools.smoother import DetectionsSmoother from supervision.detection.utils import ( + batch_box_non_max_merge, box_iou_batch, + box_non_max_merge, box_non_max_suppression, calculate_masks_centroids, clip_boxes, diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 0777571f..1b3a385d 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -8,7 +8,7 @@ import numpy as np from supervision.config import CLASS_NAME_DATA_FIELD, ORIENTED_BOX_COORDINATES from supervision.detection.utils import ( - box_batch_non_max_merge, + batch_box_non_max_merge, box_iou_batch, box_non_max_merge, box_non_max_suppression, @@ -1226,7 +1226,7 @@ class Detections: self.class_id.reshape(-1, 1), ) ) - keep_to_merge_list = box_batch_non_max_merge(predictions, threshold) + keep_to_merge_list = batch_box_non_max_merge(predictions, threshold) result = [] for keep_ind, merge_ind_list in keep_to_merge_list.items(): diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index f177d088..c2f02c1b 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -353,8 +353,8 @@ def box_non_max_merge( return keep_to_merge_list -def box_batch_non_max_merge( - predictions: np.ndarray, threshold: float = 0.5 +def batch_box_non_max_merge( + predictions: np.ndarray, iou_threshold: float = 0.5 ) -> Dict[int, List[int]]: """ Apply greedy version of non-maximum merging per category to avoid detecting @@ -364,7 +364,7 @@ def box_batch_non_max_merge( predictions (np.ndarray): An array of shape `(n, 6)` containing the bounding boxes coordinates in format `[x1, y1, x2, y2]`, the confidence scores and class_ids. - threshold (float, optional): The intersection-over-union threshold + iou_threshold (float, optional): The intersection-over-union threshold to use for non-maximum suppression. Defaults to 0.5. Returns: @@ -376,7 +376,7 @@ def box_batch_non_max_merge( for category_id in np.unique(category_ids): curr_indices = np.where(category_ids == category_id)[0] curr_keep_to_merge_list = box_non_max_merge( - predictions[curr_indices], threshold + predictions[curr_indices], iou_threshold ) curr_indices_list = curr_indices.tolist() for curr_keep, curr_merge_list in curr_keep_to_merge_list.items(): From d2d50fbe467ca3fec33e46619c63ac0548ced50b Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Wed, 15 May 2024 09:26:18 +0300 Subject: [PATCH 13/94] renamed bbox -> xyxy --- supervision/detection/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index c2f02c1b..f6308f57 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -458,7 +458,7 @@ def mask_to_xyxy(masks: np.ndarray) -> np.ndarray: `(x_min, y_min, x_max, y_max)` for each mask """ n = masks.shape[0] - bboxes = np.zeros((n, 4), dtype=int) + xyxy = np.zeros((n, 4), dtype=int) for i, mask in enumerate(masks): rows, cols = np.where(mask) @@ -466,9 +466,9 @@ def mask_to_xyxy(masks: np.ndarray) -> np.ndarray: if len(rows) > 0 and len(cols) > 0: x_min, x_max = np.min(cols), np.max(cols) y_min, y_max = np.min(rows), np.max(rows) - bboxes[i, :] = [x_min, y_min, x_max, y_max] + xyxy[i, :] = [x_min, y_min, x_max, y_max] - return bboxes + return xyxy def mask_to_polygons(mask: np.ndarray) -> List[np.ndarray]: From 2d740bdcb6b197f6aefe7436a718191c53884042 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Wed, 15 May 2024 09:38:58 +0300 Subject: [PATCH 14/94] fix: merge_object_detection_pair --- supervision/detection/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 1b3a385d..76224bb7 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -1233,7 +1233,7 @@ class Detections: for merge_ind in merge_ind_list: box_iou = box_iou_batch(self[keep_ind].xyxy, self[merge_ind].xyxy)[0] if box_iou > threshold: - merged_detection = self.merge_object_detection_pair( + merged_detection = merge_object_detection_pair( self[keep_ind], self[merge_ind] ) self._set_at_index(keep_ind, merged_detection) From 145b5fe56c1b1daec6e8161fece90a5f23155c76 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Wed, 15 May 2024 10:46:56 +0300 Subject: [PATCH 15/94] Rename to batch_box_non_max_merge to box_non_max_merge_batch --- supervision/__init__.py | 2 +- supervision/detection/core.py | 4 ++-- supervision/detection/utils.py | 8 +------- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/supervision/__init__.py b/supervision/__init__.py index 3eae2e17..03f52086 100644 --- a/supervision/__init__.py +++ b/supervision/__init__.py @@ -43,9 +43,9 @@ from supervision.detection.tools.json_sink import JSONSink from supervision.detection.tools.polygon_zone import PolygonZone, PolygonZoneAnnotator from supervision.detection.tools.smoother import DetectionsSmoother from supervision.detection.utils import ( - batch_box_non_max_merge, box_iou_batch, box_non_max_merge, + box_non_max_merge_batch, box_non_max_suppression, calculate_masks_centroids, clip_boxes, diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 76224bb7..2489ef80 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -8,9 +8,9 @@ import numpy as np from supervision.config import CLASS_NAME_DATA_FIELD, ORIENTED_BOX_COORDINATES from supervision.detection.utils import ( - batch_box_non_max_merge, box_iou_batch, box_non_max_merge, + box_non_max_merge_batch, box_non_max_suppression, calculate_masks_centroids, extract_ultralytics_masks, @@ -1226,7 +1226,7 @@ class Detections: self.class_id.reshape(-1, 1), ) ) - keep_to_merge_list = batch_box_non_max_merge(predictions, threshold) + keep_to_merge_list = box_non_max_merge_batch(predictions, threshold) result = [] for keep_ind, merge_ind_list in keep_to_merge_list.items(): diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index f6308f57..c159de59 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -300,18 +300,12 @@ def box_non_max_merge( y2 = predictions[:, 3] scores = predictions[:, 4] - areas = (x2 - x1) * (y2 - y1) order = scores.argsort() - keep = [] - while len(order) > 0: idx = order[-1] - - keep.append(idx.tolist()) - order = order[:-1] if len(order) == 0: @@ -353,7 +347,7 @@ def box_non_max_merge( return keep_to_merge_list -def batch_box_non_max_merge( +def box_non_max_merge_batch( predictions: np.ndarray, iou_threshold: float = 0.5 ) -> Dict[int, List[int]]: """ From 6c4093526607b4b37db4f2bcb05087ef53db83ad Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Wed, 15 May 2024 11:32:30 +0300 Subject: [PATCH 16/94] box_non_max_merge: use our functions to compute iou --- supervision/detection/utils.py | 35 +++++----------------------------- 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index c159de59..cb254552 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -294,14 +294,7 @@ def box_non_max_merge( """ keep_to_merge_list = {} - x1 = predictions[:, 0] - y1 = predictions[:, 1] - x2 = predictions[:, 2] - y2 = predictions[:, 3] - scores = predictions[:, 4] - areas = (x2 - x1) * (y2 - y1) - order = scores.argsort() while len(order) > 0: @@ -312,30 +305,12 @@ def box_non_max_merge( keep_to_merge_list[idx.tolist()] = [] break - xx1 = np.take(x1, axis=0, indices=order) - xx2 = np.take(x2, axis=0, indices=order) - yy1 = np.take(y1, axis=0, indices=order) - yy2 = np.take(y2, axis=0, indices=order) + candidate = np.expand_dims(predictions[idx], axis=0) + ious = box_iou_batch(predictions[order][:, :4], candidate[:, :4]) - xx1 = np.maximum(xx1, x1[idx]) - yy1 = np.maximum(yy1, y1[idx]) - xx2 = np.minimum(xx2, x2[idx]) - yy2 = np.minimum(yy2, y2[idx]) - - w = np.maximum(0, xx2 - xx1) - h = np.maximum(0, yy2 - yy1) - - inter = w * h - - rem_areas = np.take(areas, axis=0, indices=order) - - union = (rem_areas - inter) + areas[idx] - match_metric_value = inter / union - - mask = match_metric_value < iou_threshold - mask = mask.astype(np.uint8) - matched_box_indices = np.flip(order[np.where(mask == 0)[0]]) - unmatched_indices = order[np.where(mask == 1)[0]] + mask = ious < iou_threshold + matched_box_indices = np.flip(order[np.where(mask is False)[0]]) + unmatched_indices = order[np.where(mask is True)[0]] order = unmatched_indices[scores[unmatched_indices].argsort()] From 53f345e91614a72b20a1f19c04d5369fa17a26ed Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Wed, 15 May 2024 11:35:59 +0300 Subject: [PATCH 17/94] Minor renaming --- supervision/detection/utils.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index cb254552..7985c739 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -299,18 +299,18 @@ def box_non_max_merge( while len(order) > 0: idx = order[-1] - order = order[:-1] + merge_candidate = np.expand_dims(predictions[idx], axis=0) + order = order[:-1] if len(order) == 0: keep_to_merge_list[idx.tolist()] = [] break - candidate = np.expand_dims(predictions[idx], axis=0) - ious = box_iou_batch(predictions[order][:, :4], candidate[:, :4]) + ious = box_iou_batch(predictions[order][:, :4], merge_candidate[:, :4]) - mask = ious < iou_threshold - matched_box_indices = np.flip(order[np.where(mask is False)[0]]) - unmatched_indices = order[np.where(mask is True)[0]] + below_threshold = ious < iou_threshold + matched_box_indices = np.flip(order[np.where(below_threshold is False)[0]]) + unmatched_indices = order[np.where(below_threshold is True)[0]] order = unmatched_indices[scores[unmatched_indices].argsort()] From 0e2eec08c8ed9ccc4ae21f63ca8a6f3ae658ca94 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Wed, 15 May 2024 11:48:48 +0300 Subject: [PATCH 18/94] Revert np.bool comparisons with `is` * Ruff complains when `== True` is used * Different behaviour with `is True` --- supervision/detection/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 7985c739..56420ed6 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -308,9 +308,9 @@ def box_non_max_merge( ious = box_iou_batch(predictions[order][:, :4], merge_candidate[:, :4]) - below_threshold = ious < iou_threshold - matched_box_indices = np.flip(order[np.where(below_threshold is False)[0]]) - unmatched_indices = order[np.where(below_threshold is True)[0]] + below_threshold = (ious < iou_threshold).astype(np.uint8) + matched_box_indices = np.flip(order[np.where(below_threshold == 0)[0]]) + unmatched_indices = order[np.where(below_threshold == 1)[0]] order = unmatched_indices[scores[unmatched_indices].argsort()] From 559ef90d83507994091cc7d0f76fa79ce9b7a8c1 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Wed, 15 May 2024 11:58:15 +0300 Subject: [PATCH 19/94] Simplify box_non_max_merge --- supervision/detection/utils.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 56420ed6..85b741c3 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -292,7 +292,7 @@ def box_non_max_merge( Dict[int, List[int]]: Mapping from prediction indices to keep to a list of prediction indices to be merged. """ - keep_to_merge_list = {} + keep_to_merge_list: Dict[int, List[int]] = {} scores = predictions[:, 4] order = scores.argsort() @@ -307,17 +307,11 @@ def box_non_max_merge( break ious = box_iou_batch(predictions[order][:, :4], merge_candidate[:, :4]) + ious = ious.flatten() - below_threshold = (ious < iou_threshold).astype(np.uint8) - matched_box_indices = np.flip(order[np.where(below_threshold == 0)[0]]) - unmatched_indices = order[np.where(below_threshold == 1)[0]] - - order = unmatched_indices[scores[unmatched_indices].argsort()] - - keep_to_merge_list[idx.tolist()] = [] - - for matched_box_ind in matched_box_indices.tolist(): - keep_to_merge_list[idx.tolist()].append(matched_box_ind) + above_threshold = ious >= iou_threshold + keep_to_merge_list[idx] = np.flip(order[above_threshold]).tolist() + order = order[~above_threshold] return keep_to_merge_list From f8f3647a983529aa2e7f2bff8599d33b2a7ebe83 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Wed, 15 May 2024 15:32:26 +0300 Subject: [PATCH 20/94] Removed suprplus NMM code for 20% speedup --- supervision/detection/core.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 2489ef80..2f358c6b 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -8,7 +8,6 @@ import numpy as np from supervision.config import CLASS_NAME_DATA_FIELD, ORIENTED_BOX_COORDINATES from supervision.detection.utils import ( - box_iou_batch, box_non_max_merge, box_non_max_merge_batch, box_non_max_suppression, @@ -1231,12 +1230,10 @@ class Detections: result = [] for keep_ind, merge_ind_list in keep_to_merge_list.items(): for merge_ind in merge_ind_list: - box_iou = box_iou_batch(self[keep_ind].xyxy, self[merge_ind].xyxy)[0] - if box_iou > threshold: - merged_detection = merge_object_detection_pair( - self[keep_ind], self[merge_ind] - ) - self._set_at_index(keep_ind, merged_detection) + merged_detection = merge_object_detection_pair( + self[keep_ind], self[merge_ind] + ) + self._set_at_index(keep_ind, merged_detection) result.append(self[keep_ind]) return Detections.merge(result) From 9024396f6c49f5f5496dac5347859f07721e1f76 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Fri, 17 May 2024 10:58:45 +0300 Subject: [PATCH 21/94] Add npt.NDarray[x] types, remove resolution_wh default val --- supervision/detection/utils.py | 58 +++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 85b741c3..db33ab01 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -3,6 +3,7 @@ from typing import Dict, List, Optional, Tuple, Union import cv2 import numpy as np +import numpy.typing as npt from supervision.config import CLASS_NAME_DATA_FIELD @@ -275,14 +276,14 @@ def box_non_max_suppression( def box_non_max_merge( - predictions: np.ndarray, iou_threshold: float = 0.5 + predictions: npt.NDArray[np.float64], iou_threshold: float = 0.5 ) -> Dict[int, List[int]]: """ Apply greedy version of non-maximum merging to avoid detecting too many overlapping bounding boxes for a given object. Args: - predictions (np.ndarray): An array of shape `(n, 5)` containing + predictions (npt.NDArray[np.float64]): An array of shape `(n, 5)` containing the bounding boxes coordinates in format `[x1, y1, x2, y2]` and the confidence scores. iou_threshold (float, optional): The intersection-over-union threshold @@ -317,14 +318,14 @@ def box_non_max_merge( def box_non_max_merge_batch( - predictions: np.ndarray, iou_threshold: float = 0.5 + predictions: npt.NDArray[np.float64], iou_threshold: float = 0.5 ) -> Dict[int, List[int]]: """ Apply greedy version of non-maximum merging per category to avoid detecting too many overlapping bounding boxes for a given object. Args: - predictions (np.ndarray): An array of shape `(n, 6)` containing + predictions (npt.NDArray[np.float64]): An array of shape `(n, 6)` containing the bounding boxes coordinates in format `[x1, y1, x2, y2]`, the confidence scores and class_ids. iou_threshold (float, optional): The intersection-over-union threshold @@ -667,16 +668,18 @@ def process_roboflow_result( return xyxy, confidence, class_id, masks, tracker_id, data -def move_boxes(xyxy: np.ndarray, offset: np.ndarray) -> np.ndarray: +def move_boxes( + xyxy: npt.NDArray[np.float64], offset: npt.NDArray[np.int32] +) -> npt.NDArray[np.float64]: """ Parameters: - xyxy (np.ndarray): An array of shape `(n, 4)` containing the bounding boxes - coordinates in format `[x1, y1, x2, y2]` + xyxy (npt.NDArray[np.float64]): An array of shape `(n, 4)` containing the + bounding boxes coordinates in format `[x1, y1, x2, y2]` offset (np.array): An array of shape `(2,)` containing offset values in format is `[dx, dy]`. Returns: - np.ndarray: Repositioned bounding boxes. + npt.NDArray[np.float64]: Repositioned bounding boxes. Example: ```python @@ -697,24 +700,25 @@ def move_boxes(xyxy: np.ndarray, offset: np.ndarray) -> np.ndarray: def move_masks( - masks: np.ndarray, - offset: np.ndarray, - resolution_wh: Tuple[int, int] = None, -) -> np.ndarray: + masks: npt.NDArray[np.bool_], + offset: npt.NDArray[np.int32], + resolution_wh: Tuple[int, int], +) -> npt.NDArray[np.bool_]: """ Offset the masks in an array by the specified (x, y) amount. Args: - masks (np.ndarray): A 3D array of binary masks corresponding to the predictions. - Shape: `(N, H, W)`, where N is the number of predictions, and H, W are the - dimensions of each mask. - offset (np.ndarray): An array of shape `(2,)` containing non-negative int values - `[dx, dy]`. + masks (npt.NDArray[np.bool_]): A 3D array of binary masks corresponding to the + predictions. Shape: `(N, H, W)`, where N is the number of predictions, and + H, W are the dimensions of each mask. + offset (npt.NDArray[np.int32]): An array of shape `(2,)` containing non-negative + int values `[dx, dy]`. resolution_wh (Tuple[int, int]): The width and height of the desired mask resolution. Returns: - (np.ndarray) repositioned masks, optionally padded to the specified shape. + (npt.NDArray[np.bool_]) repositioned masks, optionally padded to the specified + shape. """ if offset[0] < 0 or offset[1] < 0: @@ -730,19 +734,21 @@ def move_masks( return mask_array -def scale_boxes(xyxy: np.ndarray, factor: float) -> np.ndarray: +def scale_boxes( + xyxy: npt.NDArray[np.float64], factor: float +) -> npt.NDArray[np.float64]: """ Scale the dimensions of bounding boxes. Parameters: - xyxy (np.ndarray): An array of shape `(n, 4)` containing the bounding boxes - coordinates in format `[x1, y1, x2, y2]` + xyxy (npt.NDArray[np.float64]): An array of shape `(n, 4)` containing the + bounding boxes coordinates in format `[x1, y1, x2, y2]` factor (float): A float value representing the factor by which the box dimensions are scaled. A factor greater than 1 enlarges the boxes, while a factor less than 1 shrinks them. Returns: - np.ndarray: Scaled bounding boxes. + npt.NDArray[np.float64]: Scaled bounding boxes. Example: ```python @@ -810,19 +816,19 @@ def is_data_equal(data_a: Dict[str, np.ndarray], data_b: Dict[str, np.ndarray]) def merge_data( - data_list: List[Dict[str, Union[np.ndarray, List]]], -) -> Dict[str, Union[np.ndarray, List]]: + data_list: List[Dict[str, Union[npt.NDArray[np.generic], List]]], +) -> Dict[str, Union[npt.NDArray[np.generic], List]]: """ Merges the data payloads of a list of Detections instances. Args: 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. + npt.NDArray[np.generic]. Returns: A single data payload containing the merged data, preserving the original data - types (list or np.ndarray). + types (list or npt.NDArray[np.generic]). Raises: ValueError: If data values within a single object have different lengths or if From da904ad2da5b2fe57bcd759d753e406e7aae86e6 Mon Sep 17 00:00:00 2001 From: tc360950 Date: Fri, 17 May 2024 16:17:31 +0200 Subject: [PATCH 22/94] Add LineZone unit tests --- test/detection/test_line_counter.py | 326 +++++++++++++++++++++++++++- 1 file changed, 323 insertions(+), 3 deletions(-) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 73780414..997da352 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -1,10 +1,13 @@ from contextlib import ExitStack as DoesNotRaise -from typing import Optional, Tuple +from itertools import chain, combinations +from test.test_utils import mock_detections +from typing import Optional, Tuple, List +import numpy as np import pytest -from supervision import LineZone -from supervision.geometry.core import Point, Vector +from supervision import Detections, LineZone +from supervision.geometry.core import Point, Position, Vector @pytest.mark.parametrize( @@ -70,3 +73,320 @@ def test_calculate_region_of_interest_limits( with exception: result = LineZone.calculate_region_of_interest_limits(vector=vector) assert result == expected_result + + +@pytest.mark.parametrize( + "vector, bbox_sequence, expected_count_in, expected_count_out", + [ + ( + Vector( + Point(0, 0), + Point(0, 100), + ), + [ + [100, 50, 120, 70], + [-100, 50, -80, 70], + ], + [False, False], + [False, True], + ), + ( + Vector( + Point(0, 0), + Point(0, 100), + ), + [ + [-100, 50, -80, 70], + [100, 50, 120, 70], + ], + [False, True], + [False, False], + ), + ( + Vector( + Point(0, 0), + Point(0, 100), + ), + [ + [-100, 50, -80, 70], + [-10, 50, 20, 70], + [100, 50, 120, 70], + ], + [False, False, True], + [False, False, False], + ), + ( + Vector( + Point(0, 0), + Point(100, 100), + ), + [ + [50, 45, 70, 30], + [40, 50, 50, 40], + [0, 50, 10, 40], + ], + [False, False, False], + [False, False, True], + ), + ( + Vector( + Point(0, 0), + Point(100, 0), + ), + [ + [50, -45, 70, -30], + [40, 50, 50, 40], + ], + [False, False], + [False, True], + ), + ( + Vector( + Point(0, 0), + Point(0, -100), + ), + [ + [100, -50, 120, -70], + [-100, -50, -80, -70], + ], + [False, True], + [False, False], + ), + ( + Vector( + Point(0, 0), + Point(50, 100), + ), + [ + [50, 50, 70, 30], + [40, 50, 50, 40], + [0, 50, 10, 40], + ], + [False, False, False], + [False, False, True], + ), + ( + Vector( + Point(0, 0), + Point(0, 100), + ), + [ + [100, 50, 120, 70], + [-100, 50, -80, 70], + [100, 50, 120, 70], + [-100, 50, -80, 70], + [100, 50, 120, 70], + [-100, 50, -80, 70], + [100, 50, 120, 70], + [-100, 50, -80, 70], + ], + [False, False, True, False, True, False, True, False], + [False, True, False, True, False, True, False, True], + ), + ( + Vector( + Point(0, 0), + Point(-100, 0), + ), + [ + [-50, 70, -40, 50], + [-50, -70, -40, -50], + [-50, 70, -40, 50], + [-50, -70, -40, -50], + [-50, 70, -40, 50], + [-50, -70, -40, -50], + [-50, 70, -40, 50], + [-50, -70, -40, -50], + ], + [False, False, True, False, True, False, True, False], + [False, True, False, True, False, True, False, True], + ), + ], +) +def test_line_zone_single_detection( + vector, bbox_sequence, expected_count_in: List[bool], expected_count_out: List[bool] +) -> None: + line_zone = LineZone(start=vector.start, end=vector.end) + for i, bbox in enumerate(bbox_sequence): + detections = mock_detections( + xyxy=[bbox], + tracker_id=[i for i in range(0, 1)], + ) + count_in, count_out = line_zone.trigger(detections) + assert count_in[0] == expected_count_in[i] + assert count_out[0] == expected_count_out[i] + assert line_zone.in_count == sum(expected_count_in[: (i + 1)]) + assert line_zone.out_count == sum(expected_count_out[: (i + 1)]) + + +@pytest.mark.parametrize( + "vector, bbox_sequence, expected_count_in, expected_count_out, crossing_anchors", + [ + ( + Vector( + Point(0, 0), + Point(100, 100), + ), + [ + [50, 30, 60, 20], + [20, 50, 40, 30], + ], + [False, False], + [False, True], + [Position.TOP_LEFT, Position.TOP_RIGHT, Position.BOTTOM_LEFT], + ), + ( + Vector( + Point(0, 0), + Point(0, 100), + ), + [ + [-100, 50, -80, 70], + [-100, 50, 120, 70], + ], + [False, True], + [False, False], + [Position.TOP_RIGHT, Position.BOTTOM_RIGHT], + ), + ], +) +def test_line_zone_single_detection_on_subset_of_anchors( + vector, + bbox_sequence, + expected_count_in: List[bool], + expected_count_out: List[bool], + crossing_anchors, +) -> None: + def powerset(s): + return chain.from_iterable(combinations(s, r) for r in range(len(s) + 1)) + + for anchors in powerset( + [ + Position.TOP_LEFT, + Position.TOP_RIGHT, + Position.BOTTOM_LEFT, + Position.BOTTOM_RIGHT, + ] + ): + if not anchors: + continue + line_zone = LineZone( + start=vector.start, end=vector.end, triggering_anchors=anchors + ) + for i, bbox in enumerate(bbox_sequence): + detections = mock_detections( + xyxy=[bbox], + tracker_id=[i for i in range(0, 1)], + ) + count_in, count_out = line_zone.trigger(detections) + if all(anchor in crossing_anchors for anchor in anchors): + assert count_in == expected_count_in[i] + assert count_out == expected_count_out[i] + else: + assert np.all(not count_in) + assert np.all(not count_out) + + +@pytest.mark.parametrize( + "vector, bbox_sequence, expected_count_in, expected_count_out", + [ + ( + Vector( + Point(0, 0), + Point(0, 100), + ), + [ + [[100, 50, 120, 70], [100, 50, 120, 70]], + [[-100, 50, -80, 70], [100, 50, 120, 70]], + [[100, 50, 120, 70], [100, 50, 120, 70]], + ], + [[False, False], [False, False], [True, False]], + [[False, False], [True, False], [False, False]], + ), + ( + Vector( + Point(0, 0), + Point(-100, 0), + ), + [ + [[-50, 70, -40, 50], [-80, -50, -70, -40]], + [[-50, -70, -40, -50], [-80, 50, -70, 40]], + [[-50, 70, -40, 50], [-80, 50, -70, 40]], + [[-50, -70, -40, -50], [-80, 50, -70, 40]], + [[-50, 70, -40, 50], [-80, 50, -70, 40]], + [[-50, -70, -40, -50], [-80, 50, -70, 40]], + [[-50, 70, -40, 50], [-80, 50, -70, 40]], + [[-50, -70, -40, -50], [-80, -50, -70, -40]], + ], + [ + (False, False), + (False, True), + (True, False), + (False, False), + (True, False), + (False, False), + (True, False), + (False, False), + ], + [ + (False, False), + (True, False), + (False, False), + (True, False), + (False, False), + (True, False), + (False, False), + (True, True), + ], + ), + ], +) +def test_line_zone_multiple_detections( + vector, bbox_sequence, expected_count_in: List[bool], expected_count_out: List[bool] +) -> None: + line_zone = LineZone(start=vector.start, end=vector.end) + for i, bboxes in enumerate(bbox_sequence): + detections = mock_detections( + xyxy=bboxes, + tracker_id=[i for i in range(0, len(bboxes))], + ) + count_in, count_out = line_zone.trigger(detections) + assert np.all(count_in == expected_count_in[i]) + assert np.all(count_out == expected_count_out[i]) + + +@pytest.mark.parametrize( + "vector, bbox_sequence", + [ + ( + Vector( + Point(0, 0), + Point(0, 100), + ), + [ + [100, 50, 120, 70], + [-100, 50, -80, 70], + ], + ), + ( + Vector( + Point(0, 0), + Point(0, 100), + ), + [ + [-100, 50, -80, 70], + [100, 50, 120, 70], + ], + ), + ], +) +def test_line_zone_does_not_count_detections_without_tracker_id(vector, bbox_sequence): + line_zone = LineZone(start=vector.start, end=vector.end) + for bbox in bbox_sequence: + detections = Detections( + xyxy=np.array([bbox]).reshape((-1, 4)), + tracker_id=np.array([None for _ in range(0, 1)]), + ) + count_in, count_out = line_zone.trigger(detections) + assert np.all(not count_in) + assert np.all(not count_out) From 6c9f8302a53758c85e4763f89ebe0911a81379b8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 17 May 2024 14:19:50 +0000 Subject: [PATCH 23/94] =?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_line_counter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 997da352..875d8c0c 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -1,7 +1,7 @@ from contextlib import ExitStack as DoesNotRaise from itertools import chain, combinations from test.test_utils import mock_detections -from typing import Optional, Tuple, List +from typing import List, Optional, Tuple import numpy as np import pytest From 12a455eafa2f14aa82a2aa45a7836ce48ef2fe31 Mon Sep 17 00:00:00 2001 From: tc360950 Date: Sat, 18 May 2024 17:05:09 +0200 Subject: [PATCH 24/94] Replace unnecessary generator expressions with explicit lists --- test/detection/test_line_counter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 875d8c0c..c8a2b037 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -210,7 +210,7 @@ def test_line_zone_single_detection( for i, bbox in enumerate(bbox_sequence): detections = mock_detections( xyxy=[bbox], - tracker_id=[i for i in range(0, 1)], + tracker_id=[0], ) count_in, count_out = line_zone.trigger(detections) assert count_in[0] == expected_count_in[i] @@ -276,7 +276,7 @@ def test_line_zone_single_detection_on_subset_of_anchors( for i, bbox in enumerate(bbox_sequence): detections = mock_detections( xyxy=[bbox], - tracker_id=[i for i in range(0, 1)], + tracker_id=[0], ) count_in, count_out = line_zone.trigger(detections) if all(anchor in crossing_anchors for anchor in anchors): @@ -385,7 +385,7 @@ def test_line_zone_does_not_count_detections_without_tracker_id(vector, bbox_seq for bbox in bbox_sequence: detections = Detections( xyxy=np.array([bbox]).reshape((-1, 4)), - tracker_id=np.array([None for _ in range(0, 1)]), + tracker_id=np.array([None]), ) count_in, count_out = line_zone.trigger(detections) assert np.all(not count_in) From 41ca70aab6792845e0555fb3f124b881dd1083b7 Mon Sep 17 00:00:00 2001 From: tc360950 Date: Mon, 20 May 2024 17:01:46 +0200 Subject: [PATCH 25/94] Add missing type hints and consistent variable naming --- test/detection/test_line_counter.py | 66 +++++++++++++++++------------ 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index c8a2b037..76f87c2d 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -76,7 +76,7 @@ def test_calculate_region_of_interest_limits( @pytest.mark.parametrize( - "vector, bbox_sequence, expected_count_in, expected_count_out", + "vector, bbox_sequence, expected_crossed_in, expected_crossed_out", [ ( Vector( @@ -204,7 +204,10 @@ def test_calculate_region_of_interest_limits( ], ) def test_line_zone_single_detection( - vector, bbox_sequence, expected_count_in: List[bool], expected_count_out: List[bool] + vector: Vector, + bbox_sequence: List[List[int]], + expected_crossed_in: List[bool], + expected_crossed_out: List[bool], ) -> None: line_zone = LineZone(start=vector.start, end=vector.end) for i, bbox in enumerate(bbox_sequence): @@ -212,15 +215,19 @@ def test_line_zone_single_detection( xyxy=[bbox], tracker_id=[0], ) - count_in, count_out = line_zone.trigger(detections) - assert count_in[0] == expected_count_in[i] - assert count_out[0] == expected_count_out[i] - assert line_zone.in_count == sum(expected_count_in[: (i + 1)]) - assert line_zone.out_count == sum(expected_count_out[: (i + 1)]) + crossed_in, crossed_out = line_zone.trigger(detections) + assert crossed_in[0] == expected_crossed_in[i] + assert crossed_out[0] == expected_crossed_out[i] + assert line_zone.in_count == sum(expected_crossed_in[: (i + 1)]) + assert line_zone.out_count == sum(expected_crossed_out[: (i + 1)]) @pytest.mark.parametrize( - "vector, bbox_sequence, expected_count_in, expected_count_out, crossing_anchors", + "vector," + "bbox_sequence," + "expected_crossed_in," + "expected_crossed_out," + "crossing_anchors", [ ( Vector( @@ -251,11 +258,11 @@ def test_line_zone_single_detection( ], ) def test_line_zone_single_detection_on_subset_of_anchors( - vector, - bbox_sequence, - expected_count_in: List[bool], - expected_count_out: List[bool], - crossing_anchors, + vector: Vector, + bbox_sequence: List[List[int]], + expected_crossed_in: List[bool], + expected_crossed_out: List[bool], + crossing_anchors: List[Position], ) -> None: def powerset(s): return chain.from_iterable(combinations(s, r) for r in range(len(s) + 1)) @@ -278,17 +285,17 @@ def test_line_zone_single_detection_on_subset_of_anchors( xyxy=[bbox], tracker_id=[0], ) - count_in, count_out = line_zone.trigger(detections) + crossed_in, crossed_out = line_zone.trigger(detections) if all(anchor in crossing_anchors for anchor in anchors): - assert count_in == expected_count_in[i] - assert count_out == expected_count_out[i] + assert crossed_in == expected_crossed_in[i] + assert crossed_out == expected_crossed_out[i] else: - assert np.all(not count_in) - assert np.all(not count_out) + assert np.all(not crossed_in) + assert np.all(not crossed_out) @pytest.mark.parametrize( - "vector, bbox_sequence, expected_count_in, expected_count_out", + "vector, bbox_sequence, expected_crossed_in, expected_crossed_out", [ ( Vector( @@ -342,7 +349,10 @@ def test_line_zone_single_detection_on_subset_of_anchors( ], ) def test_line_zone_multiple_detections( - vector, bbox_sequence, expected_count_in: List[bool], expected_count_out: List[bool] + vector: Vector, + bbox_sequence: List[List[List[int]]], + expected_crossed_in: List[bool], + expected_crossed_out: List[bool], ) -> None: line_zone = LineZone(start=vector.start, end=vector.end) for i, bboxes in enumerate(bbox_sequence): @@ -350,9 +360,9 @@ def test_line_zone_multiple_detections( xyxy=bboxes, tracker_id=[i for i in range(0, len(bboxes))], ) - count_in, count_out = line_zone.trigger(detections) - assert np.all(count_in == expected_count_in[i]) - assert np.all(count_out == expected_count_out[i]) + crossed_in, crossed_out = line_zone.trigger(detections) + assert np.all(crossed_in == expected_crossed_in[i]) + assert np.all(crossed_out == expected_crossed_out[i]) @pytest.mark.parametrize( @@ -380,13 +390,15 @@ def test_line_zone_multiple_detections( ), ], ) -def test_line_zone_does_not_count_detections_without_tracker_id(vector, bbox_sequence): +def test_line_zone_does_not_count_detections_without_tracker_id( + vector: Vector, bbox_sequence: List[List[int]] +): line_zone = LineZone(start=vector.start, end=vector.end) for bbox in bbox_sequence: detections = Detections( xyxy=np.array([bbox]).reshape((-1, 4)), tracker_id=np.array([None]), ) - count_in, count_out = line_zone.trigger(detections) - assert np.all(not count_in) - assert np.all(not count_out) + crossed_in, crossed_out = line_zone.trigger(detections) + assert np.all(not crossed_in) + assert np.all(not crossed_out) From 089916e26f14842056c0ad2b94480bba343f4393 Mon Sep 17 00:00:00 2001 From: tc360950 Date: Tue, 21 May 2024 21:08:18 +0200 Subject: [PATCH 26/94] Improve variable naming in LineZone unit tests, remove redundant test for empty tracker_id --- test/detection/test_line_counter.py | 57 +++++------------------------ 1 file changed, 9 insertions(+), 48 deletions(-) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 76f87c2d..77784c40 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -76,7 +76,7 @@ def test_calculate_region_of_interest_limits( @pytest.mark.parametrize( - "vector, bbox_sequence, expected_crossed_in, expected_crossed_out", + "vector, xyxy_sequence, expected_crossed_in, expected_crossed_out", [ ( Vector( @@ -205,12 +205,12 @@ def test_calculate_region_of_interest_limits( ) def test_line_zone_single_detection( vector: Vector, - bbox_sequence: List[List[int]], + xyxy_sequence: List[List[int]], expected_crossed_in: List[bool], expected_crossed_out: List[bool], ) -> None: line_zone = LineZone(start=vector.start, end=vector.end) - for i, bbox in enumerate(bbox_sequence): + for i, bbox in enumerate(xyxy_sequence): detections = mock_detections( xyxy=[bbox], tracker_id=[0], @@ -224,7 +224,7 @@ def test_line_zone_single_detection( @pytest.mark.parametrize( "vector," - "bbox_sequence," + "xyxy_sequence," "expected_crossed_in," "expected_crossed_out," "crossing_anchors", @@ -259,7 +259,7 @@ def test_line_zone_single_detection( ) def test_line_zone_single_detection_on_subset_of_anchors( vector: Vector, - bbox_sequence: List[List[int]], + xyxy_sequence: List[List[int]], expected_crossed_in: List[bool], expected_crossed_out: List[bool], crossing_anchors: List[Position], @@ -280,7 +280,7 @@ def test_line_zone_single_detection_on_subset_of_anchors( line_zone = LineZone( start=vector.start, end=vector.end, triggering_anchors=anchors ) - for i, bbox in enumerate(bbox_sequence): + for i, bbox in enumerate(xyxy_sequence): detections = mock_detections( xyxy=[bbox], tracker_id=[0], @@ -295,7 +295,7 @@ def test_line_zone_single_detection_on_subset_of_anchors( @pytest.mark.parametrize( - "vector, bbox_sequence, expected_crossed_in, expected_crossed_out", + "vector, xyxy_sequence, expected_crossed_in, expected_crossed_out", [ ( Vector( @@ -350,12 +350,12 @@ def test_line_zone_single_detection_on_subset_of_anchors( ) def test_line_zone_multiple_detections( vector: Vector, - bbox_sequence: List[List[List[int]]], + xyxy_sequence: List[List[List[int]]], expected_crossed_in: List[bool], expected_crossed_out: List[bool], ) -> None: line_zone = LineZone(start=vector.start, end=vector.end) - for i, bboxes in enumerate(bbox_sequence): + for i, bboxes in enumerate(xyxy_sequence): detections = mock_detections( xyxy=bboxes, tracker_id=[i for i in range(0, len(bboxes))], @@ -363,42 +363,3 @@ def test_line_zone_multiple_detections( crossed_in, crossed_out = line_zone.trigger(detections) assert np.all(crossed_in == expected_crossed_in[i]) assert np.all(crossed_out == expected_crossed_out[i]) - - -@pytest.mark.parametrize( - "vector, bbox_sequence", - [ - ( - Vector( - Point(0, 0), - Point(0, 100), - ), - [ - [100, 50, 120, 70], - [-100, 50, -80, 70], - ], - ), - ( - Vector( - Point(0, 0), - Point(0, 100), - ), - [ - [-100, 50, -80, 70], - [100, 50, 120, 70], - ], - ), - ], -) -def test_line_zone_does_not_count_detections_without_tracker_id( - vector: Vector, bbox_sequence: List[List[int]] -): - line_zone = LineZone(start=vector.start, end=vector.end) - for bbox in bbox_sequence: - detections = Detections( - xyxy=np.array([bbox]).reshape((-1, 4)), - tracker_id=np.array([None]), - ) - crossed_in, crossed_out = line_zone.trigger(detections) - assert np.all(not crossed_in) - assert np.all(not crossed_out) From 54a0422b5841245920b4258ecb828fb6a141befc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 May 2024 19:08:33 +0000 Subject: [PATCH 27/94] =?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_line_counter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 77784c40..803eed9a 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -6,7 +6,7 @@ from typing import List, Optional, Tuple import numpy as np import pytest -from supervision import Detections, LineZone +from supervision import LineZone from supervision.geometry.core import Point, Position, Vector From dcfa916c7507f3f93505b05da13d753575a5f670 Mon Sep 17 00:00:00 2001 From: tc360950 Date: Tue, 21 May 2024 21:17:58 +0200 Subject: [PATCH 28/94] Add docstrings with test description for LineZone tests --- test/detection/test_line_counter.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 803eed9a..9c530cca 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -209,6 +209,12 @@ def test_line_zone_single_detection( expected_crossed_in: List[bool], expected_crossed_out: List[bool], ) -> None: + """ + Test LineZone with single detection which crosses the line. + The detection is represented by a sequence of xyxy bboxes which represent + subsequent positions of the detected object. If a line is crossed (in either + direction) it is crossed by all anchors simultaneously. + """ line_zone = LineZone(start=vector.start, end=vector.end) for i, bbox in enumerate(xyxy_sequence): detections = mock_detections( @@ -264,6 +270,13 @@ def test_line_zone_single_detection_on_subset_of_anchors( expected_crossed_out: List[bool], crossing_anchors: List[Position], ) -> None: + """ + Test LineZone with single detection which crosses the line with only a subset of + anchors. + The detection is represented by a sequence of xyxy bboxes which represent + subsequent positions of the detected object. The line is crossed by only a subset + of anchors - this subset is given by @crossing_anchors. + """ def powerset(s): return chain.from_iterable(combinations(s, r) for r in range(len(s) + 1)) @@ -354,6 +367,12 @@ def test_line_zone_multiple_detections( expected_crossed_in: List[bool], expected_crossed_out: List[bool], ) -> None: + """ + Test LineZone with multiple detections. + A detection is represented by a sequence of xyxy bboxes which represent + subsequent positions of the detected object. If a line is crossed (in either + direction) by a detection it is crossed by all its anchors simultaneously. + """ line_zone = LineZone(start=vector.start, end=vector.end) for i, bboxes in enumerate(xyxy_sequence): detections = mock_detections( From 8a2f5d4726f33a5cd10521b7a6d22a546b7a13d6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 May 2024 19:19:14 +0000 Subject: [PATCH 29/94] =?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_line_counter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 9c530cca..9083a2df 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -277,6 +277,7 @@ def test_line_zone_single_detection_on_subset_of_anchors( subsequent positions of the detected object. The line is crossed by only a subset of anchors - this subset is given by @crossing_anchors. """ + def powerset(s): return chain.from_iterable(combinations(s, r) for r in range(len(s) + 1)) From 6fbca8333e373d06312e823e03ef8899208f1a7a Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Thu, 23 May 2024 16:01:34 +0300 Subject: [PATCH 30/94] Address review comments, simplify merge * Reintroduced iou check before response - necessary for algorithm --- supervision/__init__.py | 3 +- supervision/detection/core.py | 145 ++++++++++++++++++++++----------- supervision/detection/utils.py | 118 ++++++++++++++++----------- test/detection/test_core.py | 77 +++++++++++++---- test/detection/test_utils.py | 56 +++++++------ 5 files changed, 265 insertions(+), 134 deletions(-) diff --git a/supervision/__init__.py b/supervision/__init__.py index 03f52086..816142b9 100644 --- a/supervision/__init__.py +++ b/supervision/__init__.py @@ -35,7 +35,7 @@ from supervision.dataset.core import ( DetectionDataset, ) from supervision.detection.annotate import BoxAnnotator -from supervision.detection.core import Detections, merge_object_detection_pair +from supervision.detection.core import Detections from supervision.detection.line_zone import LineZone, LineZoneAnnotator from supervision.detection.tools.csv_sink import CSVSink from supervision.detection.tools.inference_slicer import InferenceSlicer @@ -45,7 +45,6 @@ from supervision.detection.tools.smoother import DetectionsSmoother from supervision.detection.utils import ( box_iou_batch, box_non_max_merge, - box_non_max_merge_batch, box_non_max_suppression, calculate_masks_centroids, clip_boxes, diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 2f358c6b..6abc8dad 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -8,8 +8,8 @@ import numpy as np from supervision.config import CLASS_NAME_DATA_FIELD, ORIENTED_BOX_COORDINATES from supervision.detection.utils import ( + box_iou_batch, box_non_max_merge, - box_non_max_merge_batch, box_non_max_suppression, calculate_masks_centroids, extract_ultralytics_masks, @@ -1198,24 +1198,21 @@ class Detections: after non-maximum merging. Raises: - AssertionError: If `confidence` is None and class_agnostic is False. - If `class_id` is None and class_agnostic is False. + AssertionError: If `confidence` is None or `class_id` is None and + class_agnostic is False. """ if len(self) == 0: return self - assert 0.0 <= threshold <= 1.0, "Threshold must be between 0 and 1." - assert ( self.confidence is not None ), "Detections confidence must be given for NMM to be executed." if class_agnostic: predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) - keep_to_merge_list = box_non_max_merge(predictions, threshold) else: assert self.class_id is not None, ( - "Detections class_id must be given for NMS to be executed. If you" + "Detections class_id must be given for NMM to be executed. If you" " intended to perform class agnostic NMM set class_agnostic=True." ) predictions = np.hstack( @@ -1225,21 +1222,25 @@ class Detections: self.class_id.reshape(-1, 1), ) ) - keep_to_merge_list = box_non_max_merge_batch(predictions, threshold) + + merge_groups = box_non_max_merge( + predictions=predictions, iou_threshold=threshold + ) result = [] - for keep_ind, merge_ind_list in keep_to_merge_list.items(): - for merge_ind in merge_ind_list: - merged_detection = merge_object_detection_pair( - self[keep_ind], self[merge_ind] - ) - self._set_at_index(keep_ind, merged_detection) - result.append(self[keep_ind]) + for merge_group in merge_groups: + unmerged_detections = [self[i] for i in merge_group] + merged_detections = _merge_inner_detections_objects( + unmerged_detections, threshold + ) + result.append(merged_detections) return Detections.merge(result) -def merge_object_detection_pair(det1: Detections, det2: Detections) -> Detections: +def _merge_inner_detection_object_pair( + detections_1: Detections, detections_2: Detections +) -> Detections: """ Merges two Detections object into a single Detections object. Assumes each Detections contains exactly one object. @@ -1254,9 +1255,9 @@ def merge_object_detection_pair(det1: Detections, det2: Detections) -> Detection single bounding box and mask, respectively. Args: - det1 (Detections): + detections_1 (Detections): The first Detections object - det2 (Detections): + detections_2 (Detections): The second Detections object Returns: @@ -1282,51 +1283,99 @@ def merge_object_detection_pair(det1: Detections, det2: Detections) -> Detection detections[0], detections[1]) ``` """ - if len(det1) != 1 or len(det2) != 1: + if len(detections_1) != 1 or len(detections_2) != 1: raise ValueError("Both Detections should have exactly 1 detected object.") - if det2.confidence is None: - winning_det = det1 - elif det1.confidence is None: - winning_det = det2 - elif det1.confidence[0] >= det2.confidence[0]: - winning_det = det1 - else: - winning_det = det2 - - area_det1 = (det1.xyxy[0][2] - det1.xyxy[0][0]) * ( - det1.xyxy[0][3] - det1.xyxy[0][1] - ) - area_det2 = (det2.xyxy[0][2] - det2.xyxy[0][0]) * ( - det2.xyxy[0][3] - det2.xyxy[0][1] - ) - - merged_x1, merged_y1 = np.minimum(det1.xyxy[0][:2], det2.xyxy[0][:2]) - merged_x2, merged_y2 = np.maximum(det1.xyxy[0][2:], det2.xyxy[0][2:]) - merged_xy = np.array([[merged_x1, merged_y1, merged_x2, merged_y2]]) - - if det2.mask is None or det1.mask is None: - merged_mask = winning_det.mask - else: - merged_mask = np.logical_or(det1.mask, det2.mask) - - if det1.confidence is None or det2.confidence is None: - merged_confidence = winning_det.confidence + _verify_fields_both_defined_or_none(detections_1, detections_2) + + if detections_1.confidence is None and detections_2.confidence is None: + merged_confidence = None else: + area_det1 = (detections_1.xyxy[0][2] - detections_1.xyxy[0][0]) * ( + detections_1.xyxy[0][3] - detections_1.xyxy[0][1] + ) + area_det2 = (detections_2.xyxy[0][2] - detections_2.xyxy[0][0]) * ( + detections_2.xyxy[0][3] - detections_2.xyxy[0][1] + ) merged_confidence = ( - area_det1 * det1.confidence[0] + area_det2 * det2.confidence[0] + area_det1 * detections_1.confidence[0] + + area_det2 * detections_2.confidence[0] ) / (area_det1 + area_det2) merged_confidence = np.array([merged_confidence]) + merged_x1, merged_y1 = np.minimum( + detections_1.xyxy[0][:2], detections_2.xyxy[0][:2] + ) + merged_x2, merged_y2 = np.maximum( + detections_1.xyxy[0][2:], detections_2.xyxy[0][2:] + ) + merged_xyxy = np.array([[merged_x1, merged_y1, merged_x2, merged_y2]]) + + if detections_1.mask is None and detections_2.mask is None: + merged_mask = None + else: + merged_mask = np.logical_or(detections_1.mask, detections_2.mask) + + if detections_1.confidence is None and detections_2.confidence is None: + winning_det = detections_1 + elif detections_1.confidence[0] >= detections_2.confidence[0]: + winning_det = detections_1 + else: + winning_det = detections_2 + winning_class_id = winning_det.class_id winning_tracker_id = winning_det.tracker_id winning_data = winning_det.data return Detections( - xyxy=merged_xy, + xyxy=merged_xyxy, mask=merged_mask, confidence=merged_confidence, class_id=winning_class_id, tracker_id=winning_tracker_id, data=winning_data, ) + + +def _merge_inner_detections_objects( + detections: List[Detections], threshold=0.5 +) -> Detections: + """ + Given N detections each of length 1 (exactly one object inside), combine them into a + single detection object of length 1. The contained inner object will be the merged + result of all the input detections. + + For example, this lets you merge N boxes into one big box, N masks into one mask, + etc. + """ + detections_1 = detections[0] + for detections_2 in detections[1:]: + box_iou = box_iou_batch(detections_1.xyxy, detections_2.xyxy)[0] + if box_iou < threshold: + break + detections_1 = _merge_inner_detection_object_pair(detections_1, detections_2) + return detections_1 + + +def _verify_fields_both_defined_or_none( + detections_1: Detections, detections_2: Detections +) -> None: + """ + Verify that for each optional field in the Detections, both instances either have + the field set to None or both have it set to non-None values. + + `data` field is ignored. + + Raises: + ValueError: If one field is None and the other is not, for any of the fields. + """ + attributes = ["mask", "confidence", "class_id", "tracker_id"] + for attribute in attributes: + value_1 = getattr(detections_1, attribute) + value_2 = getattr(detections_2, attribute) + + if (value_1 is None) != (value_2 is None): + raise ValueError( + f"Field '{attribute}' should be consistently None or not None in both " + "Detections." + ) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index db33ab01..b8b8f7c1 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -56,7 +56,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) @@ -81,7 +82,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, @@ -132,7 +134,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) @@ -162,7 +165,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 @@ -215,8 +219,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()] @@ -275,9 +280,9 @@ def box_non_max_suppression( return keep[sort_index.argsort()] -def box_non_max_merge( +def _box_non_max_merge_all( predictions: npt.NDArray[np.float64], iou_threshold: float = 0.5 -) -> Dict[int, List[int]]: +) -> List[List[int]]: """ Apply greedy version of non-maximum merging to avoid detecting too many overlapping bounding boxes for a given object. @@ -290,64 +295,74 @@ def box_non_max_merge( to use for non-maximum suppression. Defaults to 0.5. Returns: - Dict[int, List[int]]: Mapping from prediction indices - to keep to a list of prediction indices to be merged. + List[List[int]]: Groups of prediction indices be merged. + Each group may have 1 or more elements. """ - keep_to_merge_list: Dict[int, List[int]] = {} + merge_groups: List[List[int]] = [] scores = predictions[:, 4] order = scores.argsort() while len(order) > 0: - idx = order[-1] - merge_candidate = np.expand_dims(predictions[idx], axis=0) + idx = int(order[-1]) order = order[:-1] if len(order) == 0: - keep_to_merge_list[idx.tolist()] = [] + merge_groups.append([idx]) break + merge_candidate = np.expand_dims(predictions[idx], axis=0) ious = box_iou_batch(predictions[order][:, :4], merge_candidate[:, :4]) ious = ious.flatten() above_threshold = ious >= iou_threshold - keep_to_merge_list[idx] = np.flip(order[above_threshold]).tolist() + merge_group = [idx] + np.flip(order[above_threshold]).tolist() + merge_groups.append(merge_group) order = order[~above_threshold] - - return keep_to_merge_list + return merge_groups -def box_non_max_merge_batch( - predictions: npt.NDArray[np.float64], iou_threshold: float = 0.5 -) -> Dict[int, List[int]]: +def box_non_max_merge( + predictions: npt.NDArray[np.float64], + iou_threshold: float = 0.5, +) -> List[List[int]]: """ Apply greedy version of non-maximum merging per category to avoid detecting too many overlapping bounding boxes for a given object. Args: - predictions (npt.NDArray[np.float64]): An array of shape `(n, 6)` containing - the bounding boxes coordinates in format `[x1, y1, x2, y2]`, - the confidence scores and class_ids. + predictions (npt.NDArray[np.float64]): An array of shape `(n, 5)` or `(n, 6)` + containing the bounding boxes coordinates in format `[x1, y1, x2, y2]`, + the confidence scores and class_ids. Omit class_id column to allow + detections of different classes to be merged. iou_threshold (float, optional): The intersection-over-union threshold to use for non-maximum suppression. Defaults to 0.5. Returns: - Dict[int, List[int]]: Mapping from prediction indices - to keep to a list of prediction indices to be merged. + List[List[int]]: Groups of prediction indices be merged. + Each group may have 1 or more elements. """ + if predictions.shape[1] == 5: + return _box_non_max_merge_all(predictions, iou_threshold) + category_ids = predictions[:, 5] - keep_to_merge_list = {} + merge_groups = [] for category_id in np.unique(category_ids): curr_indices = np.where(category_ids == category_id)[0] - curr_keep_to_merge_list = box_non_max_merge( + merge_class_groups = _box_non_max_merge_all( predictions[curr_indices], iou_threshold ) - curr_indices_list = curr_indices.tolist() - for curr_keep, curr_merge_list in curr_keep_to_merge_list.items(): - keep = curr_indices_list[curr_keep] - merge_list = [curr_indices_list[i] for i in curr_merge_list] - keep_to_merge_list[keep] = merge_list - return keep_to_merge_list + + for merge_class_group in merge_class_groups: + merge_groups.append(curr_indices[merge_class_group].tolist()) + + for merge_group in merge_groups: + if len(merge_group) == 0: + raise ValueError( + f"Empty group detected when non-max-merging " + f"detections: {merge_groups}" + ) + return merge_groups def clip_boxes(xyxy: np.ndarray, resolution_wh: Tuple[int, int]) -> np.ndarray: @@ -552,7 +567,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: @@ -581,7 +597,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() @@ -648,7 +665,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"]) @@ -659,10 +677,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 @@ -722,13 +742,15 @@ def move_masks( """ if offset[0] < 0 or offset[1] < 0: - raise ValueError(f"Offset values must be non-negative integers. Got: {offset}") + raise ValueError( + f"Offset values must be non-negative integers. Got: {offset}") - mask_array = np.full((masks.shape[0], resolution_wh[1], resolution_wh[0]), False) + mask_array = np.full( + (masks.shape[0], resolution_wh[1], resolution_wh[0]), False) mask_array[ :, - offset[1] : masks.shape[1] + offset[1], - offset[0] : masks.shape[2] + offset[0], + offset[1]: masks.shape[1] + offset[1], + offset[0]: masks.shape[2] + offset[0], ] = masks return mask_array @@ -794,8 +816,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) @@ -873,7 +897,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 " @@ -918,6 +943,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 diff --git a/test/detection/test_core.py b/test/detection/test_core.py index 31e56dec..bef511e5 100644 --- a/test/detection/test_core.py +++ b/test/detection/test_core.py @@ -5,7 +5,7 @@ from typing import List, Optional, Union import numpy as np import pytest -from supervision.detection.core import Detections, merge_object_detection_pair +from supervision.detection.core import Detections, _merge_inner_detection_object_pair from supervision.geometry.core import Position PREDICTIONS = np.array( @@ -193,7 +193,8 @@ TEST_DET_DIFFERENT_DATA = 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, @@ -482,7 +483,7 @@ def test_equal( data={"key_1": [1]}, ), DoesNotRaise(), - ), # Same confidence - merge box & mask, tiebreak to detection_1 + ), # Same confidence - merge box & mask, tie-break to detection_1 ( mock_detections( xyxy=[[0, 0, 20, 20]], @@ -512,7 +513,7 @@ def test_equal( ), # Different confidence, different area ( mock_detections( - xyxy=[[0, 0, 20, 20]], + xyxy=[[10, 10, 30, 30]], confidence=None, class_id=[1], mask=[np.array([[1, 1, 0], [1, 1, 0], [0, 0, 0]], dtype=bool)], @@ -520,31 +521,79 @@ def test_equal( data={"key_1": [1]}, ), mock_detections( - xyxy=[[10, 10, 30, 30]], - confidence=[0.2], + xyxy=[[20, 20, 40, 40]], + confidence=None, class_id=[2], mask=[np.array([[0, 0, 0], [0, 1, 1], [0, 1, 1]], dtype=bool)], tracker_id=[2], data={"key_2": [2]}, ), mock_detections( - xyxy=[[0, 0, 30, 30]], - confidence=[0.2], - class_id=[2], + xyxy=[[10, 10, 40, 40]], + confidence=None, + class_id=[1], mask=[np.array([[1, 1, 0], [1, 1, 1], [0, 1, 1]], dtype=bool)], - tracker_id=[2], - data={"key_2": [2]}, + tracker_id=[1], + data={"key_1": [1]}, ), DoesNotRaise(), - ), # merge with no confidence + ), # No confidence at all + ( + mock_detections( + xyxy=[[0, 0, 20, 20]], + confidence=None, + ), + mock_detections( + xyxy=[[10, 10, 30, 30]], + confidence=[0.2], + ), + None, + pytest.raises(ValueError), + ), # confidence: None + [x] + ( + mock_detections( + xyxy=[[0, 0, 20, 20]], + mask=[np.array([[1, 1, 0], [1, 1, 0], [0, 0, 0]], dtype=bool)], + ), + mock_detections( + xyxy=[[10, 10, 30, 30]], + mask=None, + ), + None, + pytest.raises(ValueError), + ), # mask: None + [x] + ( + mock_detections( + xyxy=[[0, 0, 20, 20]], + tracker_id=[1] + ), + mock_detections( + xyxy=[[10, 10, 30, 30]], + tracker_id=None, + ), + None, + pytest.raises(ValueError), + ), # tracker_id: None + [] + ( + mock_detections( + xyxy=[[0, 0, 20, 20]], + class_id=[1] + ), + mock_detections( + xyxy=[[10, 10, 30, 30]], + class_id=None, + ), + None, + pytest.raises(ValueError), + ) # class_id: None + [] ], ) -def test_merge_object_detection_pair( +def test_merge_inner_detection_object_pair( detection_1: Detections, detection_2: Detections, expected_result: Optional[Detections], exception: Exception, ): with exception: - result = merge_object_detection_pair(detection_1, detection_2) + result = _merge_inner_detection_object_pair(detection_1, detection_2) assert result == expected_result diff --git a/test/detection/test_utils.py b/test/detection/test_utils.py index e6f33084..cb7537e1 100644 --- a/test/detection/test_utils.py +++ b/test/detection/test_utils.py @@ -6,7 +6,7 @@ import pytest from supervision.config import CLASS_NAME_DATA_FIELD from supervision.detection.utils import ( - box_non_max_merge, + _box_non_max_merge_all, box_non_max_suppression, calculate_masks_centroids, clip_boxes, @@ -134,67 +134,67 @@ def test_box_non_max_suppression( ( np.empty(shape=(0, 5), dtype=float), 0.5, - {}, + [], DoesNotRaise(), ), ( np.array([[0, 0, 10, 10, 1.0]]), 0.5, - {0: []}, + [[0]], DoesNotRaise(), ), ( np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]), 0.5, - {1: [0]}, + [[1, 0]], DoesNotRaise(), ), # High overlap, tie-break to second det ( np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 0.99]]), 0.5, - {0: [1]}, + [[0, 1]], DoesNotRaise(), ), # High overlap, merge to high confidence ( np.array([[0, 0, 10, 10, 0.99], [0, 0, 9, 9, 1.0]]), 0.5, - {1: [0]}, + [[1, 0]], DoesNotRaise(), ), # (test symmetry) High overlap, merge to high confidence ( - np.array([[0, 0, 10, 10, 0.99], [0, 0, 9, 9, 1.0]]), + np.array([[0, 0, 10, 10, 0.90], [0, 0, 9, 9, 1.0]]), 0.5, - {1: [0]}, + [[1, 0]], DoesNotRaise(), ), # (test symmetry) High overlap, merge to high confidence ( np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]), 1.0, - {0: [], 1: []}, + [[1], [0]], DoesNotRaise(), ), # High IOU required ( np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]), 0.0, - {1: [0]}, + [[1, 0]], DoesNotRaise(), ), # No IOU required ( np.array([[0, 0, 10, 10, 1.0], [0, 0, 5, 5, 0.9]]), 0.25, - {0: [1]}, + [[0, 1]], DoesNotRaise(), ), # Below IOU requirement ( np.array([[0, 0, 10, 10, 1.0], [0, 0, 5, 5, 0.9]]), 0.26, - {0: [], 1: []}, + [[0], [1]], DoesNotRaise(), ), # Above IOU requirement ( np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0], [0, 0, 8, 8, 1.0]]), 0.5, - {2: [1, 0]}, + [[2, 1, 0]], DoesNotRaise(), ), # 3 boxes ( @@ -208,7 +208,7 @@ def test_box_non_max_suppression( ] ), 0.5, - {1: [0], 3: [2], 4: []}, + [[4], [3, 2], [1, 0]], DoesNotRaise(), ), # 5 boxes, 2 merges, 1 separate ( @@ -222,7 +222,7 @@ def test_box_non_max_suppression( ] ), 0.33, - {0: [], 2: [1], 4: [3]}, + [[4, 3], [2, 1], [0]], DoesNotRaise(), ), # sequential merge, half overlap ( @@ -236,7 +236,7 @@ def test_box_non_max_suppression( ] ), 0.33, - {0: [], 2: [3, 1], 4: []}, + [[2, 3, 1], [4], [0]], DoesNotRaise(), ), # confidence ], @@ -244,11 +244,13 @@ def test_box_non_max_suppression( def test_box_non_max_merge( predictions: np.ndarray, iou_threshold: float, - expected_result: Dict[int, List[int]], + expected_result: List[List[int]], exception: Exception, ) -> None: with exception: - result = box_non_max_merge(predictions=predictions, iou_threshold=iou_threshold) + result = _box_non_max_merge_all( + predictions=predictions, iou_threshold=iou_threshold + ) assert result == expected_result @@ -664,7 +666,8 @@ def test_filter_polygons_by_area( "image": {"width": 1000, "height": 1000}, }, ( - np.array([[175.0, 275.0, 225.0, 325.0], [450.0, 450.0, 550.0, 550.0]]), + np.array([[175.0, 275.0, 225.0, 325.0], + [450.0, 450.0, 550.0, 550.0]]), np.array([0.9, 0.8]), np.array([0, 7]), None, @@ -1118,8 +1121,10 @@ def test_calculate_masks_centroids( ), # two data dicts with the same field name and np.array values as 2D arrays ( [ - {"test_1": np.array([1, 2, 3]), "test_2": np.array(["a", "b", "c"])}, - {"test_1": np.array([3, 2, 1]), "test_2": np.array(["c", "b", "a"])}, + {"test_1": np.array([1, 2, 3]), + "test_2": np.array(["a", "b", "c"])}, + {"test_1": np.array([3, 2, 1]), + "test_2": np.array(["c", "b", "a"])}, ], { "test_1": np.array([1, 2, 3, 3, 2, 1]), @@ -1148,8 +1153,10 @@ def test_calculate_masks_centroids( ), # two data dicts with the same field name and 1D and 2D arrays values ( [ - {"test_1": np.array([1, 2, 3]), "test_2": np.array(["a", "b"])}, - {"test_1": np.array([3, 2, 1]), "test_2": np.array(["c", "b", "a"])}, + {"test_1": np.array([1, 2, 3]), + "test_2": np.array(["a", "b"])}, + {"test_1": np.array([3, 2, 1]), + "test_2": np.array(["c", "b", "a"])}, ], None, pytest.raises(ValueError), @@ -1160,7 +1167,8 @@ def test_calculate_masks_centroids( 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 From db1b4737fec31de88de5c0f946faf95a4ca88372 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 23 May 2024 13:04:09 +0000 Subject: [PATCH 31/94] =?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 | 54 ++++++++++++---------------------- test/detection/test_core.py | 17 ++++------- test/detection/test_utils.py | 18 ++++-------- 3 files changed, 30 insertions(+), 59 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index b8b8f7c1..4beea2ed 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -56,8 +56,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) @@ -82,8 +81,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, @@ -134,8 +132,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) @@ -165,8 +162,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 @@ -219,9 +215,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()] @@ -567,8 +562,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: @@ -597,8 +591,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() @@ -665,8 +658,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"]) @@ -677,12 +669,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 @@ -742,15 +732,13 @@ def move_masks( """ if offset[0] < 0 or offset[1] < 0: - raise ValueError( - f"Offset values must be non-negative integers. Got: {offset}") + raise ValueError(f"Offset values must be non-negative integers. Got: {offset}") - mask_array = np.full( - (masks.shape[0], resolution_wh[1], resolution_wh[0]), False) + mask_array = np.full((masks.shape[0], resolution_wh[1], resolution_wh[0]), False) mask_array[ :, - offset[1]: masks.shape[1] + offset[1], - offset[0]: masks.shape[2] + offset[0], + offset[1] : masks.shape[1] + offset[1], + offset[0] : masks.shape[2] + offset[0], ] = masks return mask_array @@ -816,10 +804,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) @@ -897,8 +883,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 " @@ -943,7 +928,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 diff --git a/test/detection/test_core.py b/test/detection/test_core.py index bef511e5..dc58c9e8 100644 --- a/test/detection/test_core.py +++ b/test/detection/test_core.py @@ -193,8 +193,7 @@ TEST_DET_DIFFERENT_DATA = 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, @@ -550,7 +549,7 @@ def test_equal( None, pytest.raises(ValueError), ), # confidence: None + [x] - ( + ( mock_detections( xyxy=[[0, 0, 20, 20]], mask=[np.array([[1, 1, 0], [1, 1, 0], [0, 0, 0]], dtype=bool)], @@ -563,10 +562,7 @@ def test_equal( pytest.raises(ValueError), ), # mask: None + [x] ( - mock_detections( - xyxy=[[0, 0, 20, 20]], - tracker_id=[1] - ), + mock_detections(xyxy=[[0, 0, 20, 20]], tracker_id=[1]), mock_detections( xyxy=[[10, 10, 30, 30]], tracker_id=None, @@ -575,17 +571,14 @@ def test_equal( pytest.raises(ValueError), ), # tracker_id: None + [] ( - mock_detections( - xyxy=[[0, 0, 20, 20]], - class_id=[1] - ), + mock_detections(xyxy=[[0, 0, 20, 20]], class_id=[1]), mock_detections( xyxy=[[10, 10, 30, 30]], class_id=None, ), None, pytest.raises(ValueError), - ) # class_id: None + [] + ), # class_id: None + [] ], ) def test_merge_inner_detection_object_pair( diff --git a/test/detection/test_utils.py b/test/detection/test_utils.py index cb7537e1..9a1fa8c9 100644 --- a/test/detection/test_utils.py +++ b/test/detection/test_utils.py @@ -666,8 +666,7 @@ def test_filter_polygons_by_area( "image": {"width": 1000, "height": 1000}, }, ( - np.array([[175.0, 275.0, 225.0, 325.0], - [450.0, 450.0, 550.0, 550.0]]), + np.array([[175.0, 275.0, 225.0, 325.0], [450.0, 450.0, 550.0, 550.0]]), np.array([0.9, 0.8]), np.array([0, 7]), None, @@ -1121,10 +1120,8 @@ def test_calculate_masks_centroids( ), # two data dicts with the same field name and np.array values as 2D arrays ( [ - {"test_1": np.array([1, 2, 3]), - "test_2": np.array(["a", "b", "c"])}, - {"test_1": np.array([3, 2, 1]), - "test_2": np.array(["c", "b", "a"])}, + {"test_1": np.array([1, 2, 3]), "test_2": np.array(["a", "b", "c"])}, + {"test_1": np.array([3, 2, 1]), "test_2": np.array(["c", "b", "a"])}, ], { "test_1": np.array([1, 2, 3, 3, 2, 1]), @@ -1153,10 +1150,8 @@ def test_calculate_masks_centroids( ), # two data dicts with the same field name and 1D and 2D arrays values ( [ - {"test_1": np.array([1, 2, 3]), - "test_2": np.array(["a", "b"])}, - {"test_1": np.array([3, 2, 1]), - "test_2": np.array(["c", "b", "a"])}, + {"test_1": np.array([1, 2, 3]), "test_2": np.array(["a", "b"])}, + {"test_1": np.array([3, 2, 1]), "test_2": np.array(["c", "b", "a"])}, ], None, pytest.raises(ValueError), @@ -1167,8 +1162,7 @@ def test_calculate_masks_centroids( 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 From 0721bc289b8f9cea901ac3e9004e2b305f618c9b Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Thu, 23 May 2024 16:21:54 +0300 Subject: [PATCH 32/94] Remove _set_at_index --- supervision/detection/core.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 6abc8dad..069eaf09 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -1068,33 +1068,6 @@ class Detections: self.data[key] = value - def _set_at_index(self, index: int, other: Detections): - """ - Set detection values (xyxy, confidence, ...) at a specified index - to those of another Detections object, at index 0. - - Args: - index (int): The index in current detection, where values - will be set. - other (Detections): Detections object with exactly one element - to set the values from. - - Raises: - ValueError: If `other` is not made of exactly one element. - """ - if len(other) != 1: - raise ValueError("Detection to set from must have exactly one element.") - - self.xyxy[index] = other.xyxy[0] - if self.mask is not None and other.mask is not None: - self.mask[index] = other.mask[0] - if self.confidence is not None and other.confidence is not None: - self.confidence[index] = other.confidence[0] - if self.class_id is not None and other.class_id is not None: - self.class_id[index] = other.class_id[0] - if self.tracker_id is not None and other.tracker_id is not None: - self.tracker_id[index] = other.tracker_id[0] - @property def area(self) -> np.ndarray: """ From 7d0488efc06b972db2d4b48fabe7069ebf7227df Mon Sep 17 00:00:00 2001 From: tc360950 Date: Fri, 24 May 2024 16:39:30 +0200 Subject: [PATCH 33/94] Add unit tests for negative coordinates and empty anchors --- supervision/detection/line_zone.py | 4 +- test/detection/test_line_counter.py | 101 ++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/supervision/detection/line_zone.py b/supervision/detection/line_zone.py index 53d762a0..dc1751f4 100644 --- a/supervision/detection/line_zone.py +++ b/supervision/detection/line_zone.py @@ -80,7 +80,9 @@ class LineZone: self.tracker_state: Dict[str, bool] = {} self.in_count: int = 0 self.out_count: int = 0 - self.triggering_anchors = triggering_anchors + self.triggering_anchors = list(triggering_anchors) + if not self.triggering_anchors: + raise ValueError("Triggering anchors cannot be empty.") @staticmethod def calculate_region_of_interest_limits(vector: Vector) -> Tuple[Vector, Vector]: diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 9c530cca..931f5f52 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -201,7 +201,48 @@ def test_calculate_region_of_interest_limits( [False, False, True, False, True, False, True, False], [False, True, False, True, False, True, False, True], ), - ], + ( + Vector( + Point(0, 100), + Point(0, 200), + ), + [ + [-100, 150, -80, 170], + [-100, 50, -80, 70], + [-10, 50, 20, 70], + [100, 50, 120, 70], + ], # detection goes "around" line start and hence never crosses it + [False, False, False, False], + [False, False, False, False], + ), + ( + Vector( + Point(0, 100), + Point(0, 200), + ), + [ + [-100, 150, -80, 170], + [-100, 250, -80, 270], + [-10, 250, 20, 270], + [100, 250, 120, 270], + ], # detection goes "around" line end and hence never crosses it + [False, False, False, False], + [False, False, False, False], + ), + ( + Vector( + Point(-50, -50), + Point(-100, -150), + ), + [ + [-30, -80, -20, -100], + [-150, -60, -110, -70], + [-10, -100, 20, -130], + ], + [False, True, False], + [False, False, True], + ) + ], ) def test_line_zone_single_detection( vector: Vector, @@ -210,7 +251,7 @@ def test_line_zone_single_detection( expected_crossed_out: List[bool], ) -> None: """ - Test LineZone with single detection which crosses the line. + Test LineZone with single detection. The detection is represented by a sequence of xyxy bboxes which represent subsequent positions of the detected object. If a line is crossed (in either direction) it is crossed by all anchors simultaneously. @@ -308,7 +349,7 @@ def test_line_zone_single_detection_on_subset_of_anchors( @pytest.mark.parametrize( - "vector, xyxy_sequence, expected_crossed_in, expected_crossed_out", + "vector, xyxy_sequence, expected_crossed_in, expected_crossed_out, anchors, exception", [ ( Vector( @@ -322,6 +363,8 @@ def test_line_zone_single_detection_on_subset_of_anchors( ], [[False, False], [False, False], [True, False]], [[False, False], [True, False], [False, False]], + [Position.TOP_LEFT, Position.TOP_RIGHT, Position.BOTTOM_LEFT, Position.BOTTOM_RIGHT], + DoesNotRaise(), ), ( Vector( @@ -358,7 +401,34 @@ def test_line_zone_single_detection_on_subset_of_anchors( (False, False), (True, True), ], + [Position.TOP_LEFT, Position.TOP_RIGHT, Position.BOTTOM_LEFT, Position.BOTTOM_RIGHT], + DoesNotRaise(), ), + ( + Vector( + Point(-50, -50), + Point(-100, -150), + ), + [ + [[-30, -80, -20, -100], [100, 50, 120, 70]], + [[-100, -80, -20, -100], [100, 50, 120, 70]], + ], + [[False, False], [True, False]], + [[False, False], [False, False]], + [Position.TOP_LEFT], + DoesNotRaise(), + ), + ( + Vector( + Point(0, 0), + Point(-100, 0), + ), + [[[-50, 70, -40, 50], [-80, -50, -70, -40]]], + [(False, False)], + [(False, False)], + [], # raise because of empty anchors + pytest.raises(ValueError), + ) ], ) def test_line_zone_multiple_detections( @@ -366,19 +436,24 @@ def test_line_zone_multiple_detections( xyxy_sequence: List[List[List[int]]], expected_crossed_in: List[bool], expected_crossed_out: List[bool], + anchors: list[Position], + exception: Exception + ) -> None: """ Test LineZone with multiple detections. A detection is represented by a sequence of xyxy bboxes which represent subsequent positions of the detected object. If a line is crossed (in either - direction) by a detection it is crossed by all its anchors simultaneously. + direction) by a detection it is crossed by exactly all anchors from @anchors. """ - line_zone = LineZone(start=vector.start, end=vector.end) - for i, bboxes in enumerate(xyxy_sequence): - detections = mock_detections( - xyxy=bboxes, - tracker_id=[i for i in range(0, len(bboxes))], - ) - crossed_in, crossed_out = line_zone.trigger(detections) - assert np.all(crossed_in == expected_crossed_in[i]) - assert np.all(crossed_out == expected_crossed_out[i]) + with exception: + line_zone = LineZone(start=vector.start, end=vector.end, triggering_anchors=anchors) + for i, bboxes in enumerate(xyxy_sequence): + detections = mock_detections( + xyxy=bboxes, + tracker_id=[i for i in range(0, len(bboxes))], + ) + crossed_in, crossed_out = line_zone.trigger(detections) + assert np.all(crossed_in == expected_crossed_in[i]) + assert np.all(crossed_out == expected_crossed_out[i]) + From 1a021d57922cb31bb5c987383cb6fabbd0b1f98a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 May 2024 14:40:29 +0000 Subject: [PATCH 34/94] =?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_line_counter.py | 36 ++++++++++++++++++----------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 0b7a7fd8..454afb5b 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -211,7 +211,7 @@ def test_calculate_region_of_interest_limits( [-100, 50, -80, 70], [-10, 50, 20, 70], [100, 50, 120, 70], - ], # detection goes "around" line start and hence never crosses it + ], # detection goes "around" line start and hence never crosses it [False, False, False, False], [False, False, False, False], ), @@ -225,7 +225,7 @@ def test_calculate_region_of_interest_limits( [-100, 250, -80, 270], [-10, 250, 20, 270], [100, 250, 120, 270], - ], # detection goes "around" line end and hence never crosses it + ], # detection goes "around" line end and hence never crosses it [False, False, False, False], [False, False, False, False], ), @@ -241,8 +241,8 @@ def test_calculate_region_of_interest_limits( ], [False, True, False], [False, False, True], - ) - ], + ), + ], ) def test_line_zone_single_detection( vector: Vector, @@ -364,7 +364,12 @@ def test_line_zone_single_detection_on_subset_of_anchors( ], [[False, False], [False, False], [True, False]], [[False, False], [True, False], [False, False]], - [Position.TOP_LEFT, Position.TOP_RIGHT, Position.BOTTOM_LEFT, Position.BOTTOM_RIGHT], + [ + Position.TOP_LEFT, + Position.TOP_RIGHT, + Position.BOTTOM_LEFT, + Position.BOTTOM_RIGHT, + ], DoesNotRaise(), ), ( @@ -402,7 +407,12 @@ def test_line_zone_single_detection_on_subset_of_anchors( (False, False), (True, True), ], - [Position.TOP_LEFT, Position.TOP_RIGHT, Position.BOTTOM_LEFT, Position.BOTTOM_RIGHT], + [ + Position.TOP_LEFT, + Position.TOP_RIGHT, + Position.BOTTOM_LEFT, + Position.BOTTOM_RIGHT, + ], DoesNotRaise(), ), ( @@ -427,9 +437,9 @@ def test_line_zone_single_detection_on_subset_of_anchors( [[[-50, 70, -40, 50], [-80, -50, -70, -40]]], [(False, False)], [(False, False)], - [], # raise because of empty anchors + [], # raise because of empty anchors pytest.raises(ValueError), - ) + ), ], ) def test_line_zone_multiple_detections( @@ -437,9 +447,8 @@ def test_line_zone_multiple_detections( xyxy_sequence: List[List[List[int]]], expected_crossed_in: List[bool], expected_crossed_out: List[bool], - anchors: list[Position], - exception: Exception - + anchors: list[Position], + exception: Exception, ) -> None: """ Test LineZone with multiple detections. @@ -448,7 +457,9 @@ def test_line_zone_multiple_detections( direction) by a detection it is crossed by exactly all anchors from @anchors. """ with exception: - line_zone = LineZone(start=vector.start, end=vector.end, triggering_anchors=anchors) + line_zone = LineZone( + start=vector.start, end=vector.end, triggering_anchors=anchors + ) for i, bboxes in enumerate(xyxy_sequence): detections = mock_detections( xyxy=bboxes, @@ -457,4 +468,3 @@ def test_line_zone_multiple_detections( crossed_in, crossed_out = line_zone.trigger(detections) assert np.all(crossed_in == expected_crossed_in[i]) assert np.all(crossed_out == expected_crossed_out[i]) - From 4e2eb0afc24d97692c12fd44b82f273c4ef62131 Mon Sep 17 00:00:00 2001 From: tc360950 Date: Fri, 24 May 2024 16:42:40 +0200 Subject: [PATCH 35/94] Code reformatting --- test/detection/test_line_counter.py | 36 ++++++++++++++++++----------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 0b7a7fd8..7e141277 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -211,7 +211,7 @@ def test_calculate_region_of_interest_limits( [-100, 50, -80, 70], [-10, 50, 20, 70], [100, 50, 120, 70], - ], # detection goes "around" line start and hence never crosses it + ], # detection goes "around" line start and hence never crosses it [False, False, False, False], [False, False, False, False], ), @@ -225,7 +225,7 @@ def test_calculate_region_of_interest_limits( [-100, 250, -80, 270], [-10, 250, 20, 270], [100, 250, 120, 270], - ], # detection goes "around" line end and hence never crosses it + ], # detection goes "around" line end and hence never crosses it [False, False, False, False], [False, False, False, False], ), @@ -241,8 +241,8 @@ def test_calculate_region_of_interest_limits( ], [False, True, False], [False, False, True], - ) - ], + ), + ], ) def test_line_zone_single_detection( vector: Vector, @@ -364,7 +364,12 @@ def test_line_zone_single_detection_on_subset_of_anchors( ], [[False, False], [False, False], [True, False]], [[False, False], [True, False], [False, False]], - [Position.TOP_LEFT, Position.TOP_RIGHT, Position.BOTTOM_LEFT, Position.BOTTOM_RIGHT], + [ + Position.TOP_LEFT, + Position.TOP_RIGHT, + Position.BOTTOM_LEFT, + Position.BOTTOM_RIGHT, + ], DoesNotRaise(), ), ( @@ -402,7 +407,12 @@ def test_line_zone_single_detection_on_subset_of_anchors( (False, False), (True, True), ], - [Position.TOP_LEFT, Position.TOP_RIGHT, Position.BOTTOM_LEFT, Position.BOTTOM_RIGHT], + [ + Position.TOP_LEFT, + Position.TOP_RIGHT, + Position.BOTTOM_LEFT, + Position.BOTTOM_RIGHT, + ], DoesNotRaise(), ), ( @@ -427,9 +437,9 @@ def test_line_zone_single_detection_on_subset_of_anchors( [[[-50, 70, -40, 50], [-80, -50, -70, -40]]], [(False, False)], [(False, False)], - [], # raise because of empty anchors + [], # raise because of empty anchors pytest.raises(ValueError), - ) + ), ], ) def test_line_zone_multiple_detections( @@ -437,9 +447,8 @@ def test_line_zone_multiple_detections( xyxy_sequence: List[List[List[int]]], expected_crossed_in: List[bool], expected_crossed_out: List[bool], - anchors: list[Position], - exception: Exception - + anchors: List[Position], + exception: Exception, ) -> None: """ Test LineZone with multiple detections. @@ -448,7 +457,9 @@ def test_line_zone_multiple_detections( direction) by a detection it is crossed by exactly all anchors from @anchors. """ with exception: - line_zone = LineZone(start=vector.start, end=vector.end, triggering_anchors=anchors) + line_zone = LineZone( + start=vector.start, end=vector.end, triggering_anchors=anchors + ) for i, bboxes in enumerate(xyxy_sequence): detections = mock_detections( xyxy=bboxes, @@ -457,4 +468,3 @@ def test_line_zone_multiple_detections( crossed_in, crossed_out = line_zone.trigger(detections) assert np.all(crossed_in == expected_crossed_in[i]) assert np.all(crossed_out == expected_crossed_out[i]) - From fb30bab0fd0d79849742f1d288d35eb6066e9f11 Mon Sep 17 00:00:00 2001 From: tc360950 Date: Fri, 24 May 2024 16:50:42 +0200 Subject: [PATCH 36/94] Vectorize line zone --- supervision/detection/line_zone.py | 98 +++++++++++++++++------------- 1 file changed, 55 insertions(+), 43 deletions(-) diff --git a/supervision/detection/line_zone.py b/supervision/detection/line_zone.py index 53d762a0..7df6dd2a 100644 --- a/supervision/detection/line_zone.py +++ b/supervision/detection/line_zone.py @@ -55,15 +55,15 @@ class LineZone: """ # noqa: E501 // docs def __init__( - self, - start: Point, - end: Point, - triggering_anchors: Iterable[Position] = ( - Position.TOP_LEFT, - Position.TOP_RIGHT, - Position.BOTTOM_LEFT, - Position.BOTTOM_RIGHT, - ), + self, + start: Point, + end: Point, + triggering_anchors: Iterable[Position] = ( + Position.TOP_LEFT, + Position.TOP_RIGHT, + Position.BOTTOM_LEFT, + Position.BOTTOM_RIGHT, + ), ): """ Args: @@ -147,31 +147,28 @@ class LineZone: ] ) + cross_products_1 = self._cross_product(all_anchors, self.limits[0]) + cross_products_2 = self._cross_product(all_anchors, self.limits[1]) + # anchor is in limits if it's on the same side of both limit vectors + in_limits = ~ np.logical_xor(cross_products_1 > 0, cross_products_2 > 0) + # Reduce array to find out if all anchors for a detection are within limits + in_limits = np.min(in_limits, axis=0) + + triggers = self._cross_product(all_anchors, self.vector) < 0 + max_triggers = np.max(triggers, axis=0) + min_triggers = np.min(triggers, axis=0) for i, tracker_id in enumerate(detections.tracker_id): if tracker_id is None: continue - box_anchors = [Point(x=x, y=y) for x, y in all_anchors[:, i, :]] - - in_limits = all( - [ - self.is_point_in_limits(point=anchor, limits=self.limits) - for anchor in box_anchors - ] - ) - - if not in_limits: + if not in_limits[i]: continue - triggers = [ - self.vector.cross_product(point=anchor) < 0 for anchor in box_anchors - ] - - if len(set(triggers)) == 2: + if min_triggers[i] != max_triggers[i]: + # One anchor lies to the left of the line whilst another lies to the right continue - tracker_state = triggers[0] - + tracker_state = max_triggers[i] if tracker_id not in self.tracker_state: self.tracker_state[tracker_id] = tracker_state continue @@ -189,21 +186,36 @@ class LineZone: return crossed_in, crossed_out + @staticmethod + def _cross_product(anchors: np.ndarray, vector: Vector) -> np.ndarray: + """ + Get array of cross products of each anchor with a vector. + Args: + anchors: Array of anchors of shape (number of anchors, detections, 2) + vector: Vector to calculate cross product with + + Returns: + Array of cross products of shape (number of anchors, detections) + """ + vector_at_zero = np.array([vector.end.x - vector.start.x, vector.end.y - vector.start.y]) + vector_start = np.array([vector.start.x, vector.start.y]) + return np.cross(vector_at_zero, anchors - vector_start) + class LineZoneAnnotator: def __init__( - self, - thickness: float = 2, - color: Color = Color.WHITE, - text_thickness: float = 2, - text_color: Color = Color.BLACK, - text_scale: float = 0.5, - text_offset: float = 1.5, - text_padding: int = 10, - custom_in_text: Optional[str] = None, - custom_out_text: Optional[str] = None, - display_in_count: bool = True, - display_out_count: bool = True, + self, + thickness: float = 2, + color: Color = Color.WHITE, + text_thickness: float = 2, + text_color: Color = Color.BLACK, + text_scale: float = 0.5, + text_offset: float = 1.5, + text_padding: int = 10, + custom_in_text: Optional[str] = None, + custom_out_text: Optional[str] = None, + display_in_count: bool = True, + display_out_count: bool = True, ): """ Initialize the LineCounterAnnotator object with default values. @@ -233,11 +245,11 @@ class LineZoneAnnotator: self.display_out_count: bool = display_out_count def _annotate_count( - self, - frame: np.ndarray, - center_text_anchor: Point, - text: str, - is_in_count: bool, + self, + frame: np.ndarray, + center_text_anchor: Point, + text: str, + is_in_count: bool, ) -> None: """This method is drawing the text on the frame. From 80feb2b12ff997a345f0a2c415b5a0debe67f120 Mon Sep 17 00:00:00 2001 From: tc360950 Date: Fri, 24 May 2024 17:43:02 +0200 Subject: [PATCH 37/94] Add comments --- supervision/detection/line_zone.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/supervision/detection/line_zone.py b/supervision/detection/line_zone.py index 7df6dd2a..4d6db6ef 100644 --- a/supervision/detection/line_zone.py +++ b/supervision/detection/line_zone.py @@ -154,7 +154,10 @@ class LineZone: # Reduce array to find out if all anchors for a detection are within limits in_limits = np.min(in_limits, axis=0) + # Calculate which anchors lie to the left of the line triggers = self._cross_product(all_anchors, self.vector) < 0 + # Reduce to find out if all anchors for a + # detection lie to the left (or right) of the line max_triggers = np.max(triggers, axis=0) min_triggers = np.min(triggers, axis=0) for i, tracker_id in enumerate(detections.tracker_id): From d23cc008f86d5a46aa9b39da7d8c29db56945e6f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 May 2024 16:02:29 +0000 Subject: [PATCH 38/94] =?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/line_zone.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/supervision/detection/line_zone.py b/supervision/detection/line_zone.py index f85604c4..e01001c0 100644 --- a/supervision/detection/line_zone.py +++ b/supervision/detection/line_zone.py @@ -161,7 +161,7 @@ class LineZone: cross_products_1 = self._cross_product(all_anchors, self.limits[0]) cross_products_2 = self._cross_product(all_anchors, self.limits[1]) # anchor is in limits if it's on the same side of both limit vectors - in_limits = ~ np.logical_xor(cross_products_1 > 0, cross_products_2 > 0) + in_limits = ~np.logical_xor(cross_products_1 > 0, cross_products_2 > 0) # Reduce array to find out if all anchors for a detection are within limits in_limits = np.min(in_limits, axis=0) @@ -208,7 +208,9 @@ class LineZone: Returns: Array of cross products of shape (number of anchors, detections) """ - vector_at_zero = np.array([vector.end.x - vector.start.x, vector.end.y - vector.start.y]) + vector_at_zero = np.array( + [vector.end.x - vector.start.x, vector.end.y - vector.start.y] + ) vector_start = np.array([vector.start.x, vector.start.y]) return np.cross(vector_at_zero, anchors - vector_start) From ed5c26527a77f7b71072e0f34f86ec291bc91718 Mon Sep 17 00:00:00 2001 From: tc360950 Date: Fri, 24 May 2024 18:04:43 +0200 Subject: [PATCH 39/94] Code reformatting --- supervision/detection/line_zone.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/supervision/detection/line_zone.py b/supervision/detection/line_zone.py index f85604c4..e31cc6cd 100644 --- a/supervision/detection/line_zone.py +++ b/supervision/detection/line_zone.py @@ -176,7 +176,8 @@ class LineZone: continue if min_triggers[i] != max_triggers[i]: - # One anchor lies to the left of the line whilst another lies to the right + # One anchor lies to the left of the line + # whilst another lies to the right continue tracker_state = max_triggers[i] @@ -208,7 +209,9 @@ class LineZone: Returns: Array of cross products of shape (number of anchors, detections) """ - vector_at_zero = np.array([vector.end.x - vector.start.x, vector.end.y - vector.start.y]) + vector_at_zero = np.array( + [vector.end.x - vector.start.x, vector.end.y - vector.start.y] + ) vector_start = np.array([vector.start.x, vector.start.y]) return np.cross(vector_at_zero, anchors - vector_start) From 404be1612690a9e5645351c54e110ed64a9cabea Mon Sep 17 00:00:00 2001 From: tc360950 Date: Fri, 24 May 2024 18:14:18 +0200 Subject: [PATCH 40/94] Code reformatting --- test/detection/test_line_counter.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 7e141277..cd184b73 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -350,7 +350,11 @@ def test_line_zone_single_detection_on_subset_of_anchors( @pytest.mark.parametrize( - "vector, xyxy_sequence, expected_crossed_in, expected_crossed_out, anchors, exception", + "vector," + "xyxy_sequence," + "expected_crossed_in," + "expected_crossed_out," + "anchors, exception", [ ( Vector( From 530e1d01e152e45bd9f5bb37553f8bacbc6aeb75 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Mon, 27 May 2024 16:17:27 +0300 Subject: [PATCH 41/94] Address comments --- supervision/detection/core.py | 52 ++++++++++++++--------------------- test/detection/test_core.py | 4 +-- 2 files changed, 23 insertions(+), 33 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 069eaf09..f85d403d 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -1203,7 +1203,7 @@ class Detections: result = [] for merge_group in merge_groups: unmerged_detections = [self[i] for i in merge_group] - merged_detections = _merge_inner_detections_objects( + merged_detections = merge_inner_detections_objects( unmerged_detections, threshold ) result.append(merged_detections) @@ -1211,7 +1211,7 @@ class Detections: return Detections.merge(result) -def _merge_inner_detection_object_pair( +def merge_inner_detection_object_pair( detections_1: Detections, detections_2: Detections ) -> Detections: """ @@ -1259,29 +1259,23 @@ def _merge_inner_detection_object_pair( if len(detections_1) != 1 or len(detections_2) != 1: raise ValueError("Both Detections should have exactly 1 detected object.") - _verify_fields_both_defined_or_none(detections_1, detections_2) + validate_fields_both_defined_or_none(detections_1, detections_2) + xyxy_1 = detections_1.xyxy[0] + xyxy_2 = detections_2.xyxy[0] if detections_1.confidence is None and detections_2.confidence is None: merged_confidence = None else: - area_det1 = (detections_1.xyxy[0][2] - detections_1.xyxy[0][0]) * ( - detections_1.xyxy[0][3] - detections_1.xyxy[0][1] - ) - area_det2 = (detections_2.xyxy[0][2] - detections_2.xyxy[0][0]) * ( - detections_2.xyxy[0][3] - detections_2.xyxy[0][1] - ) + detection_1_area = (xyxy_1[2] - xyxy_1[0]) * (xyxy_1[3] - xyxy_1[1]) + detections_2_area = (xyxy_2[2] - xyxy_2[0]) * (xyxy_2[3] - xyxy_2[1]) merged_confidence = ( - area_det1 * detections_1.confidence[0] - + area_det2 * detections_2.confidence[0] - ) / (area_det1 + area_det2) + detection_1_area * detections_1.confidence[0] + + detections_2_area * detections_2.confidence[0] + ) / (detection_1_area + detections_2_area) merged_confidence = np.array([merged_confidence]) - merged_x1, merged_y1 = np.minimum( - detections_1.xyxy[0][:2], detections_2.xyxy[0][:2] - ) - merged_x2, merged_y2 = np.maximum( - detections_1.xyxy[0][2:], detections_2.xyxy[0][2:] - ) + merged_x1, merged_y1 = np.minimum(xyxy_1[:2], xyxy_2[:2]) + merged_x2, merged_y2 = np.maximum(xyxy_1[2:], xyxy_2[2:]) merged_xyxy = np.array([[merged_x1, merged_y1, merged_x2, merged_y2]]) if detections_1.mask is None and detections_2.mask is None: @@ -1290,27 +1284,23 @@ def _merge_inner_detection_object_pair( merged_mask = np.logical_or(detections_1.mask, detections_2.mask) if detections_1.confidence is None and detections_2.confidence is None: - winning_det = detections_1 + winning_detection = detections_1 elif detections_1.confidence[0] >= detections_2.confidence[0]: - winning_det = detections_1 + winning_detection = detections_1 else: - winning_det = detections_2 - - winning_class_id = winning_det.class_id - winning_tracker_id = winning_det.tracker_id - winning_data = winning_det.data + winning_detection = detections_2 return Detections( xyxy=merged_xyxy, mask=merged_mask, confidence=merged_confidence, - class_id=winning_class_id, - tracker_id=winning_tracker_id, - data=winning_data, + class_id=winning_detection.class_id, + tracker_id=winning_detection.tracker_id, + data=winning_detection.data, ) -def _merge_inner_detections_objects( +def merge_inner_detections_objects( detections: List[Detections], threshold=0.5 ) -> Detections: """ @@ -1326,11 +1316,11 @@ def _merge_inner_detections_objects( box_iou = box_iou_batch(detections_1.xyxy, detections_2.xyxy)[0] if box_iou < threshold: break - detections_1 = _merge_inner_detection_object_pair(detections_1, detections_2) + detections_1 = merge_inner_detection_object_pair(detections_1, detections_2) return detections_1 -def _verify_fields_both_defined_or_none( +def validate_fields_both_defined_or_none( detections_1: Detections, detections_2: Detections ) -> None: """ diff --git a/test/detection/test_core.py b/test/detection/test_core.py index dc58c9e8..af1d5876 100644 --- a/test/detection/test_core.py +++ b/test/detection/test_core.py @@ -5,7 +5,7 @@ from typing import List, Optional, Union import numpy as np import pytest -from supervision.detection.core import Detections, _merge_inner_detection_object_pair +from supervision.detection.core import Detections, merge_inner_detection_object_pair from supervision.geometry.core import Position PREDICTIONS = np.array( @@ -588,5 +588,5 @@ def test_merge_inner_detection_object_pair( exception: Exception, ): with exception: - result = _merge_inner_detection_object_pair(detection_1, detection_2) + result = merge_inner_detection_object_pair(detection_1, detection_2) assert result == expected_result From 2ee9e08446a071c50ff8acf000f80fdc0bb6c0a9 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Mon, 27 May 2024 16:21:40 +0300 Subject: [PATCH 42/94] Renamed to group_overlapping_boxes --- supervision/detection/utils.py | 6 +++--- test/detection/test_utils.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 4beea2ed..74726995 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -275,7 +275,7 @@ def box_non_max_suppression( return keep[sort_index.argsort()] -def _box_non_max_merge_all( +def group_overlapping_boxes( predictions: npt.NDArray[np.float64], iou_threshold: float = 0.5 ) -> List[List[int]]: """ @@ -338,13 +338,13 @@ def box_non_max_merge( Each group may have 1 or more elements. """ if predictions.shape[1] == 5: - return _box_non_max_merge_all(predictions, iou_threshold) + return group_overlapping_boxes(predictions, iou_threshold) category_ids = predictions[:, 5] merge_groups = [] for category_id in np.unique(category_ids): curr_indices = np.where(category_ids == category_id)[0] - merge_class_groups = _box_non_max_merge_all( + merge_class_groups = group_overlapping_boxes( predictions[curr_indices], iou_threshold ) diff --git a/test/detection/test_utils.py b/test/detection/test_utils.py index 9a1fa8c9..b62faa61 100644 --- a/test/detection/test_utils.py +++ b/test/detection/test_utils.py @@ -6,12 +6,12 @@ import pytest from supervision.config import CLASS_NAME_DATA_FIELD from supervision.detection.utils import ( - _box_non_max_merge_all, box_non_max_suppression, calculate_masks_centroids, clip_boxes, filter_polygons_by_area, get_data_item, + group_overlapping_boxes, mask_non_max_suppression, merge_data, move_boxes, @@ -241,14 +241,14 @@ def test_box_non_max_suppression( ), # confidence ], ) -def test_box_non_max_merge( +def test_group_overlapping_boxes( predictions: np.ndarray, iou_threshold: float, expected_result: List[List[int]], exception: Exception, ) -> None: with exception: - result = _box_non_max_merge_all( + result = group_overlapping_boxes( predictions=predictions, iou_threshold=iou_threshold ) From c1ebb81d475d180d0c6d6dcbdbb33f000bdcaa27 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 May 2024 17:42:51 +0000 Subject: [PATCH 43/94] =?UTF-8?q?chore(pre=5Fcommit):=20=E2=AC=86=20pre=5F?= =?UTF-8?q?commit=20autoupdate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.4 → v0.4.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.4...v0.4.5) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9465c2af..0c47f2b6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.4 + rev: v0.4.5 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 4a365fb907ac4f5efa8623664e4c05273751f372 Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Mon, 27 May 2024 21:31:25 +0200 Subject: [PATCH 44/94] add `LMM` to `__init__.py` --- supervision/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/supervision/__init__.py b/supervision/__init__.py index abe63390..715b9085 100644 --- a/supervision/__init__.py +++ b/supervision/__init__.py @@ -39,6 +39,7 @@ from supervision.dataset.utils import mask_to_rle, rle_to_mask from supervision.detection.annotate import BoxAnnotator from supervision.detection.core import Detections from supervision.detection.line_zone import LineZone, LineZoneAnnotator +from supervision.detection.lmm import LMM from supervision.detection.tools.csv_sink import CSVSink from supervision.detection.tools.inference_slicer import InferenceSlicer from supervision.detection.tools.json_sink import JSONSink From e7dfc04fd363e54595df13769910749865b18c5c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 May 2024 00:31:55 +0000 Subject: [PATCH 45/94] :arrow_up: Bump mkdocs-material from 9.5.24 to 9.5.25 Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.5.24 to 9.5.25. - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.24...9.5.25) --- updated-dependencies: - dependency-name: mkdocs-material dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0a9276f8..e5dd7c74 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2199,13 +2199,13 @@ pygments = ">2.12.0" [[package]] name = "mkdocs-material" -version = "9.5.24" +version = "9.5.25" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.24-py3-none-any.whl", hash = "sha256:e12cd75954c535b61e716f359cf2a5056bf4514889d17161fdebd5df4b0153c6"}, - {file = "mkdocs_material-9.5.24.tar.gz", hash = "sha256:02d5aaba0ee755e707c3ef6e748f9acb7b3011187c0ea766db31af8905078a34"}, + {file = "mkdocs_material-9.5.25-py3-none-any.whl", hash = "sha256:68fdab047a0b9bfbefe79ce267e8a7daaf5128bcf7867065fcd201ee335fece1"}, + {file = "mkdocs_material-9.5.25.tar.gz", hash = "sha256:d0662561efb725b712207e0ee01f035ca15633f29a64628e24f01ec99d7078f4"}, ] [package.dependencies] From f0e88b1982f8a562cf480bdc5e9263aacda8bc88 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Tue, 28 May 2024 09:52:46 +0300 Subject: [PATCH 46/94] Select overlap filtering strategy --- supervision/__init__.py | 1 + .../detection/tools/inference_slicer.py | 26 ++++++++++++++----- supervision/detection/utils.py | 17 ++++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/supervision/__init__.py b/supervision/__init__.py index 1a46226f..6cd5f9ff 100644 --- a/supervision/__init__.py +++ b/supervision/__init__.py @@ -46,6 +46,7 @@ from supervision.detection.tools.json_sink import JSONSink from supervision.detection.tools.polygon_zone import PolygonZone, PolygonZoneAnnotator from supervision.detection.tools.smoother import DetectionsSmoother from supervision.detection.utils import ( + OverlapFilter, box_iou_batch, box_non_max_merge, box_non_max_suppression, diff --git a/supervision/detection/tools/inference_slicer.py b/supervision/detection/tools/inference_slicer.py index 82551434..3302b139 100644 --- a/supervision/detection/tools/inference_slicer.py +++ b/supervision/detection/tools/inference_slicer.py @@ -1,10 +1,11 @@ +import warnings from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Callable, Optional, Tuple import numpy as np from supervision.detection.core import Detections -from supervision.detection.utils import move_boxes, move_masks +from supervision.detection.utils import OverlapFilter, move_boxes, move_masks from supervision.utils.image import crop_image @@ -50,8 +51,10 @@ class InferenceSlicer: `(width, height)`. overlap_ratio_wh (Tuple[float, float]): Overlap ratio between consecutive slices in the format `(width_ratio, height_ratio)`. - iou_threshold (Optional[float]): Intersection over Union (IoU) threshold - used for non-max suppression. + overlap_filter (OverlapFilter): Strategy for + filtering or merging overlapping detections in slices. + iou_threshold (float): Intersection over Union (IoU) threshold + used when filtering by overlap. callback (Callable): A function that performs inference on a given image slice and returns detections. thread_workers (int): Number of threads for parallel execution. @@ -68,12 +71,14 @@ class InferenceSlicer: callback: Callable[[np.ndarray], Detections], slice_wh: Tuple[int, int] = (320, 320), overlap_ratio_wh: Tuple[float, float] = (0.2, 0.2), - iou_threshold: Optional[float] = 0.5, + overlap_filter: OverlapFilter = OverlapFilter.NON_MAX_SUPPRESSION, + iou_threshold: float = 0.5, thread_workers: int = 1, ): self.slice_wh = slice_wh self.overlap_ratio_wh = overlap_ratio_wh self.iou_threshold = iou_threshold + self.overlap_filter = overlap_filter self.callback = callback self.thread_workers = thread_workers @@ -124,9 +129,16 @@ class InferenceSlicer: for future in as_completed(futures): detections_list.append(future.result()) - return Detections.merge(detections_list=detections_list).with_nms( - threshold=self.iou_threshold - ) + merged = Detections.merge(detections_list=detections_list) + if self.overlap_filter == OverlapFilter.NONE: + return merged + elif self.overlap_filter == OverlapFilter.NON_MAX_SUPPRESSION: + return merged.with_nms(threshold=self.iou_threshold) + elif self.overlap_filter == OverlapFilter.NON_MAX_MERGE: + return merged.with_nmm(threshold=self.iou_threshold) + else: + warnings.warn(f"Invalid overlap filter strategy: {self.overlap_filter}") + return merged def _run_callback(self, image, offset) -> Detections: """ diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 1ca48791..86b730be 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -1,3 +1,4 @@ +from enum import Enum from itertools import chain from typing import Dict, List, Optional, Tuple, Union @@ -1056,3 +1057,19 @@ def contains_multiple_segments( mask_uint8, labels, connectivity=connectivity ) return number_of_labels > 2 + + +class OverlapFilter(Enum): + """ + Enum specifying the strategy for filtering overlapping detections. + + Attributes: + NONE: Do not filter detections based on overlap. + NON_MAX_SUPPRESSION: Filter detections using non-max suppression. + NON_MAX_MERGE: Merge detections with non-max-merging instead of + discarding them. + """ + + NONE = "none" + NON_MAX_SUPPRESSION = "non_max_suppression" + NON_MAX_MERGE = "non_max_merge" From 1d133975038c5d059f01a797f558216645f5c20d Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Tue, 28 May 2024 10:38:54 +0300 Subject: [PATCH 47/94] Dynamically select Detections fields, not hardcoded --- supervision/detection/core.py | 4 ++-- supervision/utils/internal.py | 42 ++++++++++++++++++++++++++++++++++- test/utils/test_internal.py | 38 +++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 test/utils/test_internal.py diff --git a/supervision/detection/core.py b/supervision/detection/core.py index be610482..f93aed1c 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -23,7 +23,7 @@ from supervision.detection.utils import ( xywh_to_xyxy, ) from supervision.geometry.core import Position -from supervision.utils.internal import deprecated +from supervision.utils.internal import deprecated, get_instance_variables from supervision.validators import validate_detections_fields @@ -1379,7 +1379,7 @@ def validate_fields_both_defined_or_none( Raises: ValueError: If one field is None and the other is not, for any of the fields. """ - attributes = ["mask", "confidence", "class_id", "tracker_id"] + attributes = get_instance_variables(detections_1) for attribute in attributes: value_1 = getattr(detections_1, attribute) value_2 = getattr(detections_2, attribute) diff --git a/supervision/utils/internal.py b/supervision/utils/internal.py index 978a1448..1e84da61 100644 --- a/supervision/utils/internal.py +++ b/supervision/utils/internal.py @@ -1,7 +1,8 @@ import functools +import inspect import os import warnings -from typing import Callable +from typing import Any, Callable, Set class SupervisionWarnings(Warning): @@ -141,3 +142,42 @@ class classproperty(property): The result of calling the function stored in 'fget' with 'owner_cls'. """ return self.fget(owner_cls) + + +def get_instance_variables(cls: Any, include_properties=False) -> Set[str]: + """ + Get the non-private variables of a class or instance. + Some variables are only during initialization, so passing an instance + is more reliable. + + Args: + cls (Any): The class or instance + include_properties (bool): Whether to include properties in the result + + Usage: + ```python + detections = Detections(xyxy=np.array([1,2,3,4])) + variables = get_class_variables(detections) + # Returns ["xyxy", "mask", "confidence", ..., "data"] + ``` + """ + fields = set( + ( + name + for name, val in inspect.getmembers(cls) + if not name.startswith("__") and not callable(val) + ) + ) + + if not include_properties: + class_type = cls if isinstance(cls, type) else type(cls) + properties = set( + ( + name + for name, val in inspect.getmembers(class_type) + if isinstance(val, property) + ) + ) + fields -= properties + + return fields diff --git a/test/utils/test_internal.py b/test/utils/test_internal.py new file mode 100644 index 00000000..d268a837 --- /dev/null +++ b/test/utils/test_internal.py @@ -0,0 +1,38 @@ +import pytest +from contextlib import ExitStack as DoesNotRaise +from supervision.detection.core import Detections +from supervision.utils.internal import get_instance_variables + + +@pytest.mark.parametrize( + "input_obj, include_properties, expected, exception", + [ + ( + Detections, + False, + {"class_id", "confidence", "mask", "tracker_id"}, + DoesNotRaise() + ), + ( + Detections.empty(), + False, + {"xyxy", "class_id", "confidence", "mask", "tracker_id", "data"}, + DoesNotRaise() + ), + ( + Detections, + True, + {"class_id", "confidence", "mask", "tracker_id", "area", "box_area"}, + DoesNotRaise() + ), + ( + Detections.empty(), + True, + {"xyxy", "class_id", "confidence", "mask", "tracker_id", "data", "area", "box_area"}, + DoesNotRaise() + ), + ], +) +def test_get_instance_variables(input_obj, include_properties, expected, exception) -> None: + result = get_instance_variables(input_obj, include_properties=include_properties) + assert result == expected From 3c3a0792f3c97e431f361b78d858e696f81b46e4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 May 2024 07:42:31 +0000 Subject: [PATCH 48/94] =?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/utils/test_internal.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/test/utils/test_internal.py b/test/utils/test_internal.py index d268a837..ecb1ff29 100644 --- a/test/utils/test_internal.py +++ b/test/utils/test_internal.py @@ -1,5 +1,7 @@ -import pytest from contextlib import ExitStack as DoesNotRaise + +import pytest + from supervision.detection.core import Detections from supervision.utils.internal import get_instance_variables @@ -11,28 +13,39 @@ from supervision.utils.internal import get_instance_variables Detections, False, {"class_id", "confidence", "mask", "tracker_id"}, - DoesNotRaise() + DoesNotRaise(), ), ( Detections.empty(), False, {"xyxy", "class_id", "confidence", "mask", "tracker_id", "data"}, - DoesNotRaise() + DoesNotRaise(), ), ( Detections, True, {"class_id", "confidence", "mask", "tracker_id", "area", "box_area"}, - DoesNotRaise() + DoesNotRaise(), ), ( Detections.empty(), True, - {"xyxy", "class_id", "confidence", "mask", "tracker_id", "data", "area", "box_area"}, - DoesNotRaise() + { + "xyxy", + "class_id", + "confidence", + "mask", + "tracker_id", + "data", + "area", + "box_area", + }, + DoesNotRaise(), ), ], ) -def test_get_instance_variables(input_obj, include_properties, expected, exception) -> None: +def test_get_instance_variables( + input_obj, include_properties, expected, exception +) -> None: result = get_instance_variables(input_obj, include_properties=include_properties) assert result == expected From a6c995c9a97113b031951037ab8a49c76a411afa Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Tue, 28 May 2024 11:14:29 +0300 Subject: [PATCH 49/94] More tests for `get_instance_variables` --- test/utils/test_internal.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/utils/test_internal.py b/test/utils/test_internal.py index ecb1ff29..f5dfe429 100644 --- a/test/utils/test_internal.py +++ b/test/utils/test_internal.py @@ -1,5 +1,6 @@ from contextlib import ExitStack as DoesNotRaise +import numpy as np import pytest from supervision.detection.core import Detections @@ -42,6 +43,39 @@ from supervision.utils.internal import get_instance_variables }, DoesNotRaise(), ), + ( + Detections(xyxy=np.array([[1, 2, 3, 4]])), + False, + { + "xyxy", + "class_id", + "confidence", + "mask", + "tracker_id", + "data", + }, + DoesNotRaise(), + ), + ( + Detections( + xyxy=np.array([[1, 2, 3, 4], [5, 6, 7, 8]]), + class_id=np.array([1, 2]), + confidence=np.array([0.1, 0.2]), + mask=np.array([[[1]], [[2]]]), + tracker_id=np.array([1, 2]), + data={"key_1": [1, 2], "key_2": [3, 4]}, + ), + False, + { + "xyxy", + "class_id", + "confidence", + "mask", + "tracker_id", + "data", + }, + DoesNotRaise(), + ), ], ) def test_get_instance_variables( From f24c6f38a059d22586bf56927742b8b78b934de0 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Tue, 28 May 2024 14:09:38 +0300 Subject: [PATCH 50/94] get_instance_variables only accepts instance, more tests --- supervision/utils/internal.py | 18 ++--- test/utils/test_internal.py | 119 ++++++++++++++++++++++++++++++---- 2 files changed, 117 insertions(+), 20 deletions(-) diff --git a/supervision/utils/internal.py b/supervision/utils/internal.py index 1e84da61..b773e6e6 100644 --- a/supervision/utils/internal.py +++ b/supervision/utils/internal.py @@ -144,14 +144,12 @@ class classproperty(property): return self.fget(owner_cls) -def get_instance_variables(cls: Any, include_properties=False) -> Set[str]: +def get_instance_variables(instance: Any, include_properties=False) -> Set[str]: """ - Get the non-private variables of a class or instance. - Some variables are only during initialization, so passing an instance - is more reliable. + Get the public variables of a class instance. Args: - cls (Any): The class or instance + instance (Any): The class or instance include_properties (bool): Whether to include properties in the result Usage: @@ -161,20 +159,22 @@ def get_instance_variables(cls: Any, include_properties=False) -> Set[str]: # Returns ["xyxy", "mask", "confidence", ..., "data"] ``` """ + if isinstance(instance, type): + raise ValueError("Only class instances are supported, not classes.") + fields = set( ( name - for name, val in inspect.getmembers(cls) - if not name.startswith("__") and not callable(val) + for name, val in inspect.getmembers(instance) + if not callable(val) and not name.startswith("_") ) ) if not include_properties: - class_type = cls if isinstance(cls, type) else type(cls) properties = set( ( name - for name, val in inspect.getmembers(class_type) + for name, val in inspect.getmembers(instance.__class__) if isinstance(val, property) ) ) diff --git a/test/utils/test_internal.py b/test/utils/test_internal.py index f5dfe429..b3aedad7 100644 --- a/test/utils/test_internal.py +++ b/test/utils/test_internal.py @@ -1,4 +1,6 @@ from contextlib import ExitStack as DoesNotRaise +from dataclasses import dataclass +from typing import Any, Set import numpy as np import pytest @@ -7,14 +9,106 @@ from supervision.detection.core import Detections from supervision.utils.internal import get_instance_variables +class MockClass: + def __init__(self): + self.public = 0 + self._protected = 1 + self.__private = 2 + + def public_method(self): + pass + + def _protected_method(self): + pass + + def __private_method(self): + pass + + @property + def public_property(self): + return 0 + + @property + def _protected_property(self): + return 1 + + @property + def __private_property(self): + return 2 + + +@dataclass +class MockDataclass: + public: int = 0 + _protected: int = 1 + __private: int = 2 + + def public_method(self): + pass + + def _protected_method(self): + pass + + def __private_method(self): + pass + + @property + def public_property(self): + return 0 + + @property + def _protected_property(self): + return 1 + + @property + def __private_property(self): + return 2 + + @pytest.mark.parametrize( "input_obj, include_properties, expected, exception", [ + ( + MockClass, + False, + None, + pytest.raises(ValueError), + ), + ( + MockClass(), + False, + {"public"}, + DoesNotRaise(), + ), + ( + MockClass(), + True, + {"public", "public_property"}, + DoesNotRaise(), + ), + ( + MockDataclass(), + False, + {"public"}, + DoesNotRaise(), + ), + ( + MockDataclass(), + True, + {"public", "public_property"}, + DoesNotRaise(), + ), ( Detections, False, - {"class_id", "confidence", "mask", "tracker_id"}, - DoesNotRaise(), + None, + pytest.raises(ValueError), + ), + ( + Detections, + True, + None, + pytest.raises(ValueError), ), ( Detections.empty(), @@ -22,12 +116,6 @@ from supervision.utils.internal import get_instance_variables {"xyxy", "class_id", "confidence", "mask", "tracker_id", "data"}, DoesNotRaise(), ), - ( - Detections, - True, - {"class_id", "confidence", "mask", "tracker_id", "area", "box_area"}, - DoesNotRaise(), - ), ( Detections.empty(), True, @@ -76,10 +164,19 @@ from supervision.utils.internal import get_instance_variables }, DoesNotRaise(), ), + ( + Detections.empty(), + False, + {"xyxy", "class_id", "confidence", "mask", "tracker_id", "data"}, + DoesNotRaise(), + ), ], ) def test_get_instance_variables( - input_obj, include_properties, expected, exception + input_obj: Any, include_properties: bool, expected: Set[str], exception: Exception ) -> None: - result = get_instance_variables(input_obj, include_properties=include_properties) - assert result == expected + with exception: + result = get_instance_variables( + input_obj, include_properties=include_properties + ) + assert result == expected From c0486b7f947457e08d068440b78b54decc6666c9 Mon Sep 17 00:00:00 2001 From: LinasKo Date: Tue, 28 May 2024 14:46:55 +0300 Subject: [PATCH 51/94] Remove docstrings The class names were descriptive enough --- test/detection/test_line_counter.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index cd184b73..d9c21f15 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -250,12 +250,6 @@ def test_line_zone_single_detection( expected_crossed_in: List[bool], expected_crossed_out: List[bool], ) -> None: - """ - Test LineZone with single detection. - The detection is represented by a sequence of xyxy bboxes which represent - subsequent positions of the detected object. If a line is crossed (in either - direction) it is crossed by all anchors simultaneously. - """ line_zone = LineZone(start=vector.start, end=vector.end) for i, bbox in enumerate(xyxy_sequence): detections = mock_detections( @@ -311,14 +305,6 @@ def test_line_zone_single_detection_on_subset_of_anchors( expected_crossed_out: List[bool], crossing_anchors: List[Position], ) -> None: - """ - Test LineZone with single detection which crosses the line with only a subset of - anchors. - The detection is represented by a sequence of xyxy bboxes which represent - subsequent positions of the detected object. The line is crossed by only a subset - of anchors - this subset is given by @crossing_anchors. - """ - def powerset(s): return chain.from_iterable(combinations(s, r) for r in range(len(s) + 1)) @@ -454,12 +440,6 @@ def test_line_zone_multiple_detections( anchors: List[Position], exception: Exception, ) -> None: - """ - Test LineZone with multiple detections. - A detection is represented by a sequence of xyxy bboxes which represent - subsequent positions of the detected object. If a line is crossed (in either - direction) by a detection it is crossed by exactly all anchors from @anchors. - """ with exception: line_zone = LineZone( start=vector.start, end=vector.end, triggering_anchors=anchors From d59467b4f42b56efe19c36a80c5ef9b5cae3b9dd Mon Sep 17 00:00:00 2001 From: LinasKo Date: Tue, 28 May 2024 15:12:48 +0300 Subject: [PATCH 52/94] Add anchor check and tests to polygon zone --- supervision/detection/line_zone.py | 4 ++-- supervision/detection/tools/polygon_zone.py | 2 ++ test/detection/test_polygonzone.py | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/supervision/detection/line_zone.py b/supervision/detection/line_zone.py index 761d27c0..45d4c1ed 100644 --- a/supervision/detection/line_zone.py +++ b/supervision/detection/line_zone.py @@ -82,8 +82,8 @@ class LineZone: self.tracker_state: Dict[str, bool] = {} self.in_count: int = 0 self.out_count: int = 0 - self.triggering_anchors = list(triggering_anchors) - if not self.triggering_anchors: + self.triggering_anchors = triggering_anchors + if not list(self.triggering_anchors): raise ValueError("Triggering anchors cannot be empty.") @staticmethod diff --git a/supervision/detection/tools/polygon_zone.py b/supervision/detection/tools/polygon_zone.py index a1997212..f1c48f94 100644 --- a/supervision/detection/tools/polygon_zone.py +++ b/supervision/detection/tools/polygon_zone.py @@ -54,6 +54,8 @@ class PolygonZone: self.polygon = polygon.astype(int) self.triggering_anchors = triggering_anchors + if not list(self.triggering_anchors): + raise ValueError("Triggering anchors cannot be empty.") self.current_count = 0 diff --git a/test/detection/test_polygonzone.py b/test/detection/test_polygonzone.py index 1a86a45b..ed899615 100644 --- a/test/detection/test_polygonzone.py +++ b/test/detection/test_polygonzone.py @@ -92,3 +92,19 @@ def test_polygon_zone_trigger( with exception: in_zone = polygon_zone.trigger(detections) assert np.all(in_zone == expected_results) + + +@pytest.mark.parametrize( + "polygon, triggering_anchors, exception", + [ + (POLYGON, [sv.Position.CENTER], DoesNotRaise()), + ( + POLYGON, + [], + pytest.raises(ValueError), + ), + ], +) +def test_polygon_zone_initialization(polygon, triggering_anchors, exception): + with exception: + sv.PolygonZone(polygon, FRAME_RESOLUTION, triggering_anchors=triggering_anchors) From ba2b60ffb4a4d8df40f63ab29139978e50db1ca5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 May 2024 01:04:41 +0000 Subject: [PATCH 53/94] :arrow_up: Bump ipywidgets from 8.1.2 to 8.1.3 Bumps [ipywidgets](https://github.com/jupyter-widgets/ipywidgets) from 8.1.2 to 8.1.3. - [Release notes](https://github.com/jupyter-widgets/ipywidgets/releases) - [Commits](https://github.com/jupyter-widgets/ipywidgets/compare/8.1.2...8.1.3) --- updated-dependencies: - dependency-name: ipywidgets dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/poetry.lock b/poetry.lock index e5dd7c74..3187955d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1253,21 +1253,21 @@ test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pa [[package]] name = "ipywidgets" -version = "8.1.2" +version = "8.1.3" description = "Jupyter interactive widgets" optional = false python-versions = ">=3.7" files = [ - {file = "ipywidgets-8.1.2-py3-none-any.whl", hash = "sha256:bbe43850d79fb5e906b14801d6c01402857996864d1e5b6fa62dd2ee35559f60"}, - {file = "ipywidgets-8.1.2.tar.gz", hash = "sha256:d0b9b41e49bae926a866e613a39b0f0097745d2b9f1f3dd406641b4a57ec42c9"}, + {file = "ipywidgets-8.1.3-py3-none-any.whl", hash = "sha256:efafd18f7a142248f7cb0ba890a68b96abd4d6e88ddbda483c9130d12667eaf2"}, + {file = "ipywidgets-8.1.3.tar.gz", hash = "sha256:f5f9eeaae082b1823ce9eac2575272952f40d748893972956dc09700a6392d9c"}, ] [package.dependencies] comm = ">=0.1.3" ipython = ">=6.1.0" -jupyterlab-widgets = ">=3.0.10,<3.1.0" +jupyterlab-widgets = ">=3.0.11,<3.1.0" traitlets = ">=4.3.1" -widgetsnbextension = ">=4.0.10,<4.1.0" +widgetsnbextension = ">=4.0.11,<4.1.0" [package.extras] test = ["ipykernel", "jsonschema", "pytest (>=3.6.0)", "pytest-cov", "pytz"] @@ -1638,13 +1638,13 @@ test = ["hatch", "ipykernel", "openapi-core (>=0.18.0,<0.19.0)", "openapi-spec-v [[package]] name = "jupyterlab-widgets" -version = "3.0.10" +version = "3.0.11" description = "Jupyter interactive widgets for JupyterLab" optional = false python-versions = ">=3.7" files = [ - {file = "jupyterlab_widgets-3.0.10-py3-none-any.whl", hash = "sha256:dd61f3ae7a5a7f80299e14585ce6cf3d6925a96c9103c978eda293197730cb64"}, - {file = "jupyterlab_widgets-3.0.10.tar.gz", hash = "sha256:04f2ac04976727e4f9d0fa91cdc2f1ab860f965e504c29dbd6a65c882c9d04c0"}, + {file = "jupyterlab_widgets-3.0.11-py3-none-any.whl", hash = "sha256:78287fd86d20744ace330a61625024cf5521e1c012a352ddc0a3cdc2348becd0"}, + {file = "jupyterlab_widgets-3.0.11.tar.gz", hash = "sha256:dd5ac679593c969af29c9bed054c24f26842baa51352114736756bc035deee27"}, ] [[package]] @@ -4227,13 +4227,13 @@ test = ["pytest (>=6.0.0)", "setuptools (>=65)"] [[package]] name = "widgetsnbextension" -version = "4.0.10" +version = "4.0.11" description = "Jupyter interactive widgets for Jupyter Notebook" optional = false python-versions = ">=3.7" files = [ - {file = "widgetsnbextension-4.0.10-py3-none-any.whl", hash = "sha256:d37c3724ec32d8c48400a435ecfa7d3e259995201fbefa37163124a9fcb393cc"}, - {file = "widgetsnbextension-4.0.10.tar.gz", hash = "sha256:64196c5ff3b9a9183a8e699a4227fb0b7002f252c814098e66c4d1cd0644688f"}, + {file = "widgetsnbextension-4.0.11-py3-none-any.whl", hash = "sha256:55d4d6949d100e0d08b94948a42efc3ed6dfdc0e9468b2c4b128c9a2ce3a7a36"}, + {file = "widgetsnbextension-4.0.11.tar.gz", hash = "sha256:8b22a8f1910bfd188e596fe7fc05dcbd87e810c8a4ba010bdb3da86637398474"}, ] [[package]] From 8fb843ec8efb64efc26343aa61d228ee63219e09 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 May 2024 01:10:56 +0000 Subject: [PATCH 54/94] :arrow_up: Bump ruff from 0.4.5 to 0.4.6 Bumps [ruff](https://github.com/astral-sh/ruff) from 0.4.5 to 0.4.6. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.4.5...v0.4.6) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/poetry.lock b/poetry.lock index e5dd7c74..e5c1a29a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3662,28 +3662,28 @@ files = [ [[package]] name = "ruff" -version = "0.4.5" +version = "0.4.6" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.4.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8f58e615dec58b1a6b291769b559e12fdffb53cc4187160a2fc83250eaf54e96"}, - {file = "ruff-0.4.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:84dd157474e16e3a82745d2afa1016c17d27cb5d52b12e3d45d418bcc6d49264"}, - {file = "ruff-0.4.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25f483ad9d50b00e7fd577f6d0305aa18494c6af139bce7319c68a17180087f4"}, - {file = "ruff-0.4.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:63fde3bf6f3ad4e990357af1d30e8ba2730860a954ea9282c95fc0846f5f64af"}, - {file = "ruff-0.4.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78e3ba4620dee27f76bbcad97067766026c918ba0f2d035c2fc25cbdd04d9c97"}, - {file = "ruff-0.4.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:441dab55c568e38d02bbda68a926a3d0b54f5510095c9de7f95e47a39e0168aa"}, - {file = "ruff-0.4.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1169e47e9c4136c997f08f9857ae889d614c5035d87d38fda9b44b4338909cdf"}, - {file = "ruff-0.4.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:755ac9ac2598a941512fc36a9070a13c88d72ff874a9781493eb237ab02d75df"}, - {file = "ruff-0.4.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4b02a65985be2b34b170025a8b92449088ce61e33e69956ce4d316c0fe7cce0"}, - {file = "ruff-0.4.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:75a426506a183d9201e7e5664de3f6b414ad3850d7625764106f7b6d0486f0a1"}, - {file = "ruff-0.4.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6e1b139b45e2911419044237d90b60e472f57285950e1492c757dfc88259bb06"}, - {file = "ruff-0.4.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a6f29a8221d2e3d85ff0c7b4371c0e37b39c87732c969b4d90f3dad2e721c5b1"}, - {file = "ruff-0.4.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d6ef817124d72b54cc923f3444828ba24fa45c3164bc9e8f1813db2f3d3a8a11"}, - {file = "ruff-0.4.5-py3-none-win32.whl", hash = "sha256:aed8166c18b1a169a5d3ec28a49b43340949e400665555b51ee06f22813ef062"}, - {file = "ruff-0.4.5-py3-none-win_amd64.whl", hash = "sha256:b0b03c619d2b4350b4a27e34fd2ac64d0dabe1afbf43de57d0f9d8a05ecffa45"}, - {file = "ruff-0.4.5-py3-none-win_arm64.whl", hash = "sha256:9d15de3425f53161b3f5a5658d4522e4eee5ea002bf2ac7aa380743dd9ad5fba"}, - {file = "ruff-0.4.5.tar.gz", hash = "sha256:286eabd47e7d4d521d199cab84deca135557e6d1e0f0d01c29e757c3cb151b54"}, + {file = "ruff-0.4.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ef995583a038cd4a7edf1422c9e19118e2511b8ba0b015861b4abd26ec5367c5"}, + {file = "ruff-0.4.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:602ebd7ad909eab6e7da65d3c091547781bb06f5f826974a53dbe563d357e53c"}, + {file = "ruff-0.4.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f9ced5cbb7510fd7525448eeb204e0a22cabb6e99a3cb160272262817d49786"}, + {file = "ruff-0.4.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04a80acfc862e0e1630c8b738e70dcca03f350bad9e106968a8108379e12b31f"}, + {file = "ruff-0.4.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be47700ecb004dfa3fd4dcdddf7322d4e632de3c06cd05329d69c45c0280e618"}, + {file = "ruff-0.4.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1ff930d6e05f444090a0139e4e13e1e2e1f02bd51bb4547734823c760c621e79"}, + {file = "ruff-0.4.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f13410aabd3b5776f9c5699f42b37a3a348d65498c4310589bc6e5c548dc8a2f"}, + {file = "ruff-0.4.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0cf5cc02d3ae52dfb0c8a946eb7a1d6ffe4d91846ffc8ce388baa8f627e3bd50"}, + {file = "ruff-0.4.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea3424793c29906407e3cf417f28fc33f689dacbbadfb52b7e9a809dd535dcef"}, + {file = "ruff-0.4.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1fa8561489fadf483ffbb091ea94b9c39a00ed63efacd426aae2f197a45e67fc"}, + {file = "ruff-0.4.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4d5b914818d8047270308fe3e85d9d7f4a31ec86c6475c9f418fbd1624d198e0"}, + {file = "ruff-0.4.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4f02284335c766678778475e7698b7ab83abaf2f9ff0554a07b6f28df3b5c259"}, + {file = "ruff-0.4.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3a6a0a4f4b5f54fff7c860010ab3dd81425445e37d35701a965c0248819dde7a"}, + {file = "ruff-0.4.6-py3-none-win32.whl", hash = "sha256:9018bf59b3aa8ad4fba2b1dc0299a6e4e60a4c3bc62bbeaea222679865453062"}, + {file = "ruff-0.4.6-py3-none-win_amd64.whl", hash = "sha256:a769ae07ac74ff1a019d6bd529426427c3e30d75bdf1e08bb3d46ac8f417326a"}, + {file = "ruff-0.4.6-py3-none-win_arm64.whl", hash = "sha256:735a16407a1a8f58e4c5b913ad6102722e80b562dd17acb88887685ff6f20cf6"}, + {file = "ruff-0.4.6.tar.gz", hash = "sha256:a797a87da50603f71e6d0765282098245aca6e3b94b7c17473115167d8dfb0b7"}, ] [[package]] From c98698d44242d5dfe08f622be7da3cee09d42674 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Wed, 29 May 2024 11:08:17 +0300 Subject: [PATCH 55/94] get_instance_variables: test dataclass fields --- supervision/utils/internal.py | 4 ++-- test/utils/test_internal.py | 23 +++++++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/supervision/utils/internal.py b/supervision/utils/internal.py index b773e6e6..072c03b7 100644 --- a/supervision/utils/internal.py +++ b/supervision/utils/internal.py @@ -149,14 +149,14 @@ def get_instance_variables(instance: Any, include_properties=False) -> Set[str]: Get the public variables of a class instance. Args: - instance (Any): The class or instance + instance (Any): The instance of a class include_properties (bool): Whether to include properties in the result Usage: ```python detections = Detections(xyxy=np.array([1,2,3,4])) variables = get_class_variables(detections) - # Returns ["xyxy", "mask", "confidence", ..., "data"] + # ["xyxy", "mask", "confidence", ..., "data"] ``` """ if isinstance(instance, type): diff --git a/test/utils/test_internal.py b/test/utils/test_internal.py index b3aedad7..eee614e6 100644 --- a/test/utils/test_internal.py +++ b/test/utils/test_internal.py @@ -1,5 +1,5 @@ from contextlib import ExitStack as DoesNotRaise -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Set import numpy as np @@ -43,6 +43,14 @@ class MockDataclass: _protected: int = 1 __private: int = 2 + public_field: int = field(default=0) + _protected_field: int = field(default=1) + __private_field: int = field(default=2) + + public_field_with_factory: dict = field(default_factory=dict) + _protected_field_with_factory: dict = field(default_factory=dict) + __private_field_with_factory: dict = field(default_factory=dict) + def public_method(self): pass @@ -66,7 +74,7 @@ class MockDataclass: @pytest.mark.parametrize( - "input_obj, include_properties, expected, exception", + "input_instance, include_properties, expected, exception", [ ( MockClass, @@ -89,13 +97,13 @@ class MockDataclass: ( MockDataclass(), False, - {"public"}, + {"public", "public_field", "public_field_with_factory"}, DoesNotRaise(), ), ( MockDataclass(), True, - {"public", "public_property"}, + {"public", "public_field", "public_field_with_factory", "public_property"}, DoesNotRaise(), ), ( @@ -173,10 +181,13 @@ class MockDataclass: ], ) def test_get_instance_variables( - input_obj: Any, include_properties: bool, expected: Set[str], exception: Exception + input_instance: Any, + include_properties: bool, + expected: Set[str], + exception: Exception, ) -> None: with exception: result = get_instance_variables( - input_obj, include_properties=include_properties + input_instance, include_properties=include_properties ) assert result == expected From 4dc001d9bf17e058fa7981deca1aad3a16c2d046 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Wed, 29 May 2024 15:53:04 +0300 Subject: [PATCH 56/94] Renamed overlap strategy, added into InferenceSlicer docs page --- docs/detection/tools/inference_slicer.md | 4 +++ supervision/__init__.py | 2 +- .../detection/tools/inference_slicer.py | 36 ++++++++++++++----- supervision/detection/utils.py | 25 ++++++++++--- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/docs/detection/tools/inference_slicer.md b/docs/detection/tools/inference_slicer.md index 5d5d08bc..3a00b879 100644 --- a/docs/detection/tools/inference_slicer.md +++ b/docs/detection/tools/inference_slicer.md @@ -5,3 +5,7 @@ comments: true # InferenceSlicer :::supervision.detection.tools.inference_slicer.InferenceSlicer + +# Overlap Handling Strategy + +:::supervision.detection.utils.OverlapHandlingStrategy diff --git a/supervision/__init__.py b/supervision/__init__.py index 6cd5f9ff..084af390 100644 --- a/supervision/__init__.py +++ b/supervision/__init__.py @@ -46,7 +46,7 @@ from supervision.detection.tools.json_sink import JSONSink from supervision.detection.tools.polygon_zone import PolygonZone, PolygonZoneAnnotator from supervision.detection.tools.smoother import DetectionsSmoother from supervision.detection.utils import ( - OverlapFilter, + OverlapHandlingStrategy, box_iou_batch, box_non_max_merge, box_non_max_suppression, diff --git a/supervision/detection/tools/inference_slicer.py b/supervision/detection/tools/inference_slicer.py index 3302b139..89369271 100644 --- a/supervision/detection/tools/inference_slicer.py +++ b/supervision/detection/tools/inference_slicer.py @@ -1,12 +1,18 @@ import warnings from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Callable, Optional, Tuple +from typing import Callable, Optional, Tuple, Union import numpy as np from supervision.detection.core import Detections -from supervision.detection.utils import OverlapFilter, move_boxes, move_masks +from supervision.detection.utils import ( + OverlapHandlingStrategy, + move_boxes, + move_masks, + validate_overlapping_handling_strategy, +) from supervision.utils.image import crop_image +from supervision.utils.internal import SupervisionWarnings def move_detections( @@ -51,7 +57,7 @@ class InferenceSlicer: `(width, height)`. overlap_ratio_wh (Tuple[float, float]): Overlap ratio between consecutive slices in the format `(width_ratio, height_ratio)`. - overlap_filter (OverlapFilter): Strategy for + overlap_handling_strategy (Union[OverlapHandlingStrategy, str]): Strategy for filtering or merging overlapping detections in slices. iou_threshold (float): Intersection over Union (IoU) threshold used when filtering by overlap. @@ -71,14 +77,20 @@ class InferenceSlicer: callback: Callable[[np.ndarray], Detections], slice_wh: Tuple[int, int] = (320, 320), overlap_ratio_wh: Tuple[float, float] = (0.2, 0.2), - overlap_filter: OverlapFilter = OverlapFilter.NON_MAX_SUPPRESSION, + overlap_handling_strategy: Union[ + OverlapHandlingStrategy, str + ] = OverlapHandlingStrategy.NON_MAX_SUPPRESSION, iou_threshold: float = 0.5, thread_workers: int = 1, ): + overlap_handling_strategy = validate_overlapping_handling_strategy( + overlap_handling_strategy + ) + self.slice_wh = slice_wh self.overlap_ratio_wh = overlap_ratio_wh self.iou_threshold = iou_threshold - self.overlap_filter = overlap_filter + self.overlap_handling_strategy = overlap_handling_strategy self.callback = callback self.thread_workers = thread_workers @@ -130,14 +142,20 @@ class InferenceSlicer: detections_list.append(future.result()) merged = Detections.merge(detections_list=detections_list) - if self.overlap_filter == OverlapFilter.NONE: + if self.overlap_handling_strategy == OverlapHandlingStrategy.NONE: return merged - elif self.overlap_filter == OverlapFilter.NON_MAX_SUPPRESSION: + elif ( + self.overlap_handling_strategy + == OverlapHandlingStrategy.NON_MAX_SUPPRESSION + ): return merged.with_nms(threshold=self.iou_threshold) - elif self.overlap_filter == OverlapFilter.NON_MAX_MERGE: + elif self.overlap_handling_strategy == OverlapHandlingStrategy.NON_MAX_MERGE: return merged.with_nmm(threshold=self.iou_threshold) else: - warnings.warn(f"Invalid overlap filter strategy: {self.overlap_filter}") + warnings.warn( + f"Invalid overlap filter strategy: {self.overlap_handling_strategy}", + category=SupervisionWarnings, + ) return merged def _run_callback(self, image, offset) -> Detections: diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 86b730be..71ca4786 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -1059,17 +1059,34 @@ def contains_multiple_segments( return number_of_labels > 2 -class OverlapFilter(Enum): +class OverlapHandlingStrategy(Enum): """ Enum specifying the strategy for filtering overlapping detections. Attributes: NONE: Do not filter detections based on overlap. - NON_MAX_SUPPRESSION: Filter detections using non-max suppression. - NON_MAX_MERGE: Merge detections with non-max-merging instead of - discarding them. + NON_MAX_SUPPRESSION: Filter detections using non-max suppression. This means, + detections that overlap by more than a set threshold will be discarded, + except for the one with the highest confidence. + NON_MAX_MERGE: Merge detections with non-max-merging. This means, + detections that overlap by more than a set threshold will be merged + into a single detection. """ NONE = "none" NON_MAX_SUPPRESSION = "non_max_suppression" NON_MAX_MERGE = "non_max_merge" + + +def validate_overlapping_handling_strategy( + strategy: Union[OverlapHandlingStrategy, str], +) -> OverlapHandlingStrategy: + if isinstance(strategy, str): + try: + strategy = OverlapHandlingStrategy(strategy.lower()) + except ValueError: + raise ValueError( + f"Invalid strategy value: {strategy}. Must be one of " + f"{[e.value for e in OverlapHandlingStrategy]}" + ) + return strategy From 315c4295ffb43c02c26003261eb0695cc8245b6b Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Wed, 29 May 2024 16:17:20 +0300 Subject: [PATCH 57/94] Move NMS and NMM related methods from utils into overlap_handling.py --- supervision/__init__.py | 10 +- supervision/detection/core.py | 8 +- supervision/detection/overlap_handling.py | 263 ++++++++++ .../detection/tools/inference_slicer.py | 5 +- supervision/detection/utils.py | 257 ---------- test/detection/test_overlap_handling.py | 449 ++++++++++++++++++ test/detection/test_utils.py | 441 ----------------- 7 files changed, 725 insertions(+), 708 deletions(-) create mode 100644 supervision/detection/overlap_handling.py create mode 100644 test/detection/test_overlap_handling.py diff --git a/supervision/__init__.py b/supervision/__init__.py index 084af390..83e67795 100644 --- a/supervision/__init__.py +++ b/supervision/__init__.py @@ -40,23 +40,25 @@ from supervision.detection.annotate import BoxAnnotator from supervision.detection.core import Detections from supervision.detection.line_zone import LineZone, LineZoneAnnotator from supervision.detection.lmm import LMM +from supervision.detection.overlap_handling import ( + OverlapHandlingStrategy, + box_non_max_merge, + box_non_max_suppression, + mask_non_max_suppression, +) from supervision.detection.tools.csv_sink import CSVSink from supervision.detection.tools.inference_slicer import InferenceSlicer from supervision.detection.tools.json_sink import JSONSink from supervision.detection.tools.polygon_zone import PolygonZone, PolygonZoneAnnotator from supervision.detection.tools.smoother import DetectionsSmoother from supervision.detection.utils import ( - OverlapHandlingStrategy, box_iou_batch, - box_non_max_merge, - box_non_max_suppression, calculate_masks_centroids, clip_boxes, contains_holes, contains_multiple_segments, filter_polygons_by_area, mask_iou_batch, - mask_non_max_suppression, mask_to_polygons, mask_to_xyxy, move_boxes, diff --git a/supervision/detection/core.py b/supervision/detection/core.py index be610482..482e1109 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -8,15 +8,17 @@ import numpy as np from supervision.config import CLASS_NAME_DATA_FIELD, ORIENTED_BOX_COORDINATES from supervision.detection.lmm import LMM, from_paligemma, validate_lmm_and_kwargs -from supervision.detection.utils import ( - box_iou_batch, +from supervision.detection.overlap_handling import ( box_non_max_merge, box_non_max_suppression, + mask_non_max_suppression, +) +from supervision.detection.utils import ( + box_iou_batch, calculate_masks_centroids, extract_ultralytics_masks, get_data_item, is_data_equal, - mask_non_max_suppression, mask_to_xyxy, merge_data, process_roboflow_result, diff --git a/supervision/detection/overlap_handling.py b/supervision/detection/overlap_handling.py new file mode 100644 index 00000000..7fa21b7e --- /dev/null +++ b/supervision/detection/overlap_handling.py @@ -0,0 +1,263 @@ +from enum import Enum +from typing import List, Union + +import numpy as np +import numpy.typing as npt + +from supervision.detection.utils import box_iou_batch, mask_iou_batch + + +def resize_masks(masks: np.ndarray, max_dimension: int = 640) -> np.ndarray: + """ + Resize all masks in the array to have a maximum dimension of max_dimension, + maintaining aspect ratio. + + Args: + masks (np.ndarray): 3D array of binary masks with shape (N, H, W). + max_dimension (int): The maximum dimension for the resized masks. + + Returns: + np.ndarray: Array of resized masks. + """ + max_height = np.max(masks.shape[1]) + max_width = np.max(masks.shape[2]) + scale = min(max_dimension / max_height, max_dimension / max_width) + + new_height = int(scale * max_height) + new_width = int(scale * max_width) + + x = np.linspace(0, max_width - 1, new_width).astype(int) + y = np.linspace(0, max_height - 1, new_height).astype(int) + xv, yv = np.meshgrid(x, y) + + resized_masks = masks[:, yv, xv] + + resized_masks = resized_masks.reshape(masks.shape[0], new_height, new_width) + return resized_masks + + +def mask_non_max_suppression( + predictions: np.ndarray, + masks: np.ndarray, + iou_threshold: float = 0.5, + mask_dimension: int = 640, +) -> np.ndarray: + """ + Perform Non-Maximum Suppression (NMS) on segmentation predictions. + + Args: + predictions (np.ndarray): A 2D array of object detection predictions in + the format of `(x_min, y_min, x_max, y_max, score)` + or `(x_min, y_min, x_max, y_max, score, class)`. Shape: `(N, 5)` or + `(N, 6)`, where N is the number of predictions. + masks (np.ndarray): A 3D array of binary masks corresponding to the predictions. + Shape: `(N, H, W)`, where N is the number of predictions, and H, W are the + dimensions of each mask. + iou_threshold (float, optional): The intersection-over-union threshold + to use for non-maximum suppression. + mask_dimension (int, optional): The dimension to which the masks should be + resized before computing IOU values. Defaults to 640. + + Returns: + np.ndarray: A boolean array indicating which predictions to keep after + non-maximum suppression. + + Raises: + AssertionError: If `iou_threshold` is not within the closed + range from `0` to `1`. + """ + assert 0 <= iou_threshold <= 1, ( + "Value of `iou_threshold` must be in the closed range from 0 to 1, " + f"{iou_threshold} given." + ) + rows, columns = predictions.shape + + if columns == 5: + predictions = np.c_[predictions, np.zeros(rows)] + + sort_index = predictions[:, 4].argsort()[::-1] + predictions = predictions[sort_index] + masks = masks[sort_index] + masks_resized = resize_masks(masks, mask_dimension) + ious = mask_iou_batch(masks_resized, masks_resized) + categories = predictions[:, 5] + + 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 :]) + + return keep[sort_index.argsort()] + + +def box_non_max_suppression( + predictions: np.ndarray, iou_threshold: float = 0.5 +) -> np.ndarray: + """ + Perform Non-Maximum Suppression (NMS) on object detection predictions. + + Args: + predictions (np.ndarray): An array of object detection predictions in + the format of `(x_min, y_min, x_max, y_max, score)` + or `(x_min, y_min, x_max, y_max, score, class)`. + iou_threshold (float, optional): The intersection-over-union threshold + to use for non-maximum suppression. + + Returns: + np.ndarray: A boolean array indicating which predictions to keep after n + on-maximum suppression. + + Raises: + AssertionError: If `iou_threshold` is not within the + closed range from `0` to `1`. + """ + assert 0 <= iou_threshold <= 1, ( + "Value of `iou_threshold` must be in the closed range from 0 to 1, " + f"{iou_threshold} given." + ) + rows, columns = predictions.shape + + # add column #5 - category filled with zeros for agnostic nms + if columns == 5: + predictions = np.c_[predictions, np.zeros(rows)] + + # sort predictions column #4 - score + sort_index = np.flip(predictions[:, 4].argsort()) + predictions = predictions[sort_index] + + boxes = predictions[:, :4] + categories = predictions[:, 5] + ious = box_iou_batch(boxes, boxes) + ious = ious - np.eye(rows) + + keep = np.ones(rows, dtype=bool) + + for index, (iou, category) in enumerate(zip(ious, categories)): + if not keep[index]: + continue + + # drop detections with iou > iou_threshold and + # same category as current detections + condition = (iou > iou_threshold) & (categories == category) + keep = keep & ~condition + + return keep[sort_index.argsort()] + + +def group_overlapping_boxes( + predictions: npt.NDArray[np.float64], iou_threshold: float = 0.5 +) -> List[List[int]]: + """ + Apply greedy version of non-maximum merging to avoid detecting too many + overlapping bounding boxes for a given object. + + Args: + predictions (npt.NDArray[np.float64]): An array of shape `(n, 5)` containing + the bounding boxes coordinates in format `[x1, y1, x2, y2]` + and the confidence scores. + iou_threshold (float, optional): The intersection-over-union threshold + to use for non-maximum suppression. Defaults to 0.5. + + Returns: + List[List[int]]: Groups of prediction indices be merged. + Each group may have 1 or more elements. + """ + merge_groups: List[List[int]] = [] + + scores = predictions[:, 4] + order = scores.argsort() + + while len(order) > 0: + idx = int(order[-1]) + + order = order[:-1] + if len(order) == 0: + merge_groups.append([idx]) + break + + merge_candidate = np.expand_dims(predictions[idx], axis=0) + ious = box_iou_batch(predictions[order][:, :4], merge_candidate[:, :4]) + ious = ious.flatten() + + above_threshold = ious >= iou_threshold + merge_group = [idx] + np.flip(order[above_threshold]).tolist() + merge_groups.append(merge_group) + order = order[~above_threshold] + return merge_groups + + +def box_non_max_merge( + predictions: npt.NDArray[np.float64], + iou_threshold: float = 0.5, +) -> List[List[int]]: + """ + Apply greedy version of non-maximum merging per category to avoid detecting + too many overlapping bounding boxes for a given object. + + Args: + predictions (npt.NDArray[np.float64]): An array of shape `(n, 5)` or `(n, 6)` + containing the bounding boxes coordinates in format `[x1, y1, x2, y2]`, + the confidence scores and class_ids. Omit class_id column to allow + detections of different classes to be merged. + iou_threshold (float, optional): The intersection-over-union threshold + to use for non-maximum suppression. Defaults to 0.5. + + Returns: + List[List[int]]: Groups of prediction indices be merged. + Each group may have 1 or more elements. + """ + if predictions.shape[1] == 5: + return group_overlapping_boxes(predictions, iou_threshold) + + category_ids = predictions[:, 5] + merge_groups = [] + for category_id in np.unique(category_ids): + curr_indices = np.where(category_ids == category_id)[0] + merge_class_groups = group_overlapping_boxes( + predictions[curr_indices], iou_threshold + ) + + for merge_class_group in merge_class_groups: + merge_groups.append(curr_indices[merge_class_group].tolist()) + + for merge_group in merge_groups: + if len(merge_group) == 0: + raise ValueError( + f"Empty group detected when non-max-merging " + f"detections: {merge_groups}" + ) + return merge_groups + + +class OverlapHandlingStrategy(Enum): + """ + Enum specifying the strategy for filtering overlapping detections. + + Attributes: + NONE: Do not filter detections based on overlap. + NON_MAX_SUPPRESSION: Filter detections using non-max suppression. This means, + detections that overlap by more than a set threshold will be discarded, + except for the one with the highest confidence. + NON_MAX_MERGE: Merge detections with non-max-merging. This means, + detections that overlap by more than a set threshold will be merged + into a single detection. + """ + + NONE = "none" + NON_MAX_SUPPRESSION = "non_max_suppression" + NON_MAX_MERGE = "non_max_merge" + + +def validate_overlapping_handling_strategy( + strategy: Union[OverlapHandlingStrategy, str], +) -> OverlapHandlingStrategy: + if isinstance(strategy, str): + try: + strategy = OverlapHandlingStrategy(strategy.lower()) + except ValueError: + raise ValueError( + f"Invalid strategy value: {strategy}. Must be one of " + f"{[e.value for e in OverlapHandlingStrategy]}" + ) + return strategy diff --git a/supervision/detection/tools/inference_slicer.py b/supervision/detection/tools/inference_slicer.py index 89369271..372352e6 100644 --- a/supervision/detection/tools/inference_slicer.py +++ b/supervision/detection/tools/inference_slicer.py @@ -5,12 +5,11 @@ from typing import Callable, Optional, Tuple, Union import numpy as np from supervision.detection.core import Detections -from supervision.detection.utils import ( +from supervision.detection.overlap_handling import ( OverlapHandlingStrategy, - move_boxes, - move_masks, validate_overlapping_handling_strategy, ) +from supervision.detection.utils import move_boxes, move_masks from supervision.utils.image import crop_image from supervision.utils.internal import SupervisionWarnings diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 71ca4786..b36b6853 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -1,4 +1,3 @@ -from enum import Enum from itertools import chain from typing import Dict, List, Optional, Tuple, Union @@ -140,229 +139,6 @@ def mask_iou_batch( return np.vstack(ious) -def resize_masks(masks: np.ndarray, max_dimension: int = 640) -> np.ndarray: - """ - Resize all masks in the array to have a maximum dimension of max_dimension, - maintaining aspect ratio. - - Args: - masks (np.ndarray): 3D array of binary masks with shape (N, H, W). - max_dimension (int): The maximum dimension for the resized masks. - - Returns: - np.ndarray: Array of resized masks. - """ - max_height = np.max(masks.shape[1]) - max_width = np.max(masks.shape[2]) - scale = min(max_dimension / max_height, max_dimension / max_width) - - new_height = int(scale * max_height) - new_width = int(scale * max_width) - - x = np.linspace(0, max_width - 1, new_width).astype(int) - y = np.linspace(0, max_height - 1, new_height).astype(int) - xv, yv = np.meshgrid(x, y) - - resized_masks = masks[:, yv, xv] - - resized_masks = resized_masks.reshape(masks.shape[0], new_height, new_width) - return resized_masks - - -def mask_non_max_suppression( - predictions: np.ndarray, - masks: np.ndarray, - iou_threshold: float = 0.5, - mask_dimension: int = 640, -) -> np.ndarray: - """ - Perform Non-Maximum Suppression (NMS) on segmentation predictions. - - Args: - predictions (np.ndarray): A 2D array of object detection predictions in - the format of `(x_min, y_min, x_max, y_max, score)` - or `(x_min, y_min, x_max, y_max, score, class)`. Shape: `(N, 5)` or - `(N, 6)`, where N is the number of predictions. - masks (np.ndarray): A 3D array of binary masks corresponding to the predictions. - Shape: `(N, H, W)`, where N is the number of predictions, and H, W are the - dimensions of each mask. - iou_threshold (float, optional): The intersection-over-union threshold - to use for non-maximum suppression. - mask_dimension (int, optional): The dimension to which the masks should be - resized before computing IOU values. Defaults to 640. - - Returns: - np.ndarray: A boolean array indicating which predictions to keep after - non-maximum suppression. - - Raises: - AssertionError: If `iou_threshold` is not within the closed - range from `0` to `1`. - """ - assert 0 <= iou_threshold <= 1, ( - "Value of `iou_threshold` must be in the closed range from 0 to 1, " - f"{iou_threshold} given." - ) - rows, columns = predictions.shape - - if columns == 5: - predictions = np.c_[predictions, np.zeros(rows)] - - sort_index = predictions[:, 4].argsort()[::-1] - predictions = predictions[sort_index] - masks = masks[sort_index] - masks_resized = resize_masks(masks, mask_dimension) - ious = mask_iou_batch(masks_resized, masks_resized) - categories = predictions[:, 5] - - 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 :]) - - return keep[sort_index.argsort()] - - -def box_non_max_suppression( - predictions: np.ndarray, iou_threshold: float = 0.5 -) -> np.ndarray: - """ - Perform Non-Maximum Suppression (NMS) on object detection predictions. - - Args: - predictions (np.ndarray): An array of object detection predictions in - the format of `(x_min, y_min, x_max, y_max, score)` - or `(x_min, y_min, x_max, y_max, score, class)`. - iou_threshold (float, optional): The intersection-over-union threshold - to use for non-maximum suppression. - - Returns: - np.ndarray: A boolean array indicating which predictions to keep after n - on-maximum suppression. - - Raises: - AssertionError: If `iou_threshold` is not within the - closed range from `0` to `1`. - """ - assert 0 <= iou_threshold <= 1, ( - "Value of `iou_threshold` must be in the closed range from 0 to 1, " - f"{iou_threshold} given." - ) - rows, columns = predictions.shape - - # add column #5 - category filled with zeros for agnostic nms - if columns == 5: - predictions = np.c_[predictions, np.zeros(rows)] - - # sort predictions column #4 - score - sort_index = np.flip(predictions[:, 4].argsort()) - predictions = predictions[sort_index] - - boxes = predictions[:, :4] - categories = predictions[:, 5] - ious = box_iou_batch(boxes, boxes) - ious = ious - np.eye(rows) - - keep = np.ones(rows, dtype=bool) - - for index, (iou, category) in enumerate(zip(ious, categories)): - if not keep[index]: - continue - - # drop detections with iou > iou_threshold and - # same category as current detections - condition = (iou > iou_threshold) & (categories == category) - keep = keep & ~condition - - return keep[sort_index.argsort()] - - -def group_overlapping_boxes( - predictions: npt.NDArray[np.float64], iou_threshold: float = 0.5 -) -> List[List[int]]: - """ - Apply greedy version of non-maximum merging to avoid detecting too many - overlapping bounding boxes for a given object. - - Args: - predictions (npt.NDArray[np.float64]): An array of shape `(n, 5)` containing - the bounding boxes coordinates in format `[x1, y1, x2, y2]` - and the confidence scores. - iou_threshold (float, optional): The intersection-over-union threshold - to use for non-maximum suppression. Defaults to 0.5. - - Returns: - List[List[int]]: Groups of prediction indices be merged. - Each group may have 1 or more elements. - """ - merge_groups: List[List[int]] = [] - - scores = predictions[:, 4] - order = scores.argsort() - - while len(order) > 0: - idx = int(order[-1]) - - order = order[:-1] - if len(order) == 0: - merge_groups.append([idx]) - break - - merge_candidate = np.expand_dims(predictions[idx], axis=0) - ious = box_iou_batch(predictions[order][:, :4], merge_candidate[:, :4]) - ious = ious.flatten() - - above_threshold = ious >= iou_threshold - merge_group = [idx] + np.flip(order[above_threshold]).tolist() - merge_groups.append(merge_group) - order = order[~above_threshold] - return merge_groups - - -def box_non_max_merge( - predictions: npt.NDArray[np.float64], - iou_threshold: float = 0.5, -) -> List[List[int]]: - """ - Apply greedy version of non-maximum merging per category to avoid detecting - too many overlapping bounding boxes for a given object. - - Args: - predictions (npt.NDArray[np.float64]): An array of shape `(n, 5)` or `(n, 6)` - containing the bounding boxes coordinates in format `[x1, y1, x2, y2]`, - the confidence scores and class_ids. Omit class_id column to allow - detections of different classes to be merged. - iou_threshold (float, optional): The intersection-over-union threshold - to use for non-maximum suppression. Defaults to 0.5. - - Returns: - List[List[int]]: Groups of prediction indices be merged. - Each group may have 1 or more elements. - """ - if predictions.shape[1] == 5: - return group_overlapping_boxes(predictions, iou_threshold) - - category_ids = predictions[:, 5] - merge_groups = [] - for category_id in np.unique(category_ids): - curr_indices = np.where(category_ids == category_id)[0] - merge_class_groups = group_overlapping_boxes( - predictions[curr_indices], iou_threshold - ) - - for merge_class_group in merge_class_groups: - merge_groups.append(curr_indices[merge_class_group].tolist()) - - for merge_group in merge_groups: - if len(merge_group) == 0: - raise ValueError( - f"Empty group detected when non-max-merging " - f"detections: {merge_groups}" - ) - return merge_groups - - def clip_boxes(xyxy: np.ndarray, resolution_wh: Tuple[int, int]) -> np.ndarray: """ Clips bounding boxes coordinates to fit within the frame resolution. @@ -1057,36 +833,3 @@ def contains_multiple_segments( mask_uint8, labels, connectivity=connectivity ) return number_of_labels > 2 - - -class OverlapHandlingStrategy(Enum): - """ - Enum specifying the strategy for filtering overlapping detections. - - Attributes: - NONE: Do not filter detections based on overlap. - NON_MAX_SUPPRESSION: Filter detections using non-max suppression. This means, - detections that overlap by more than a set threshold will be discarded, - except for the one with the highest confidence. - NON_MAX_MERGE: Merge detections with non-max-merging. This means, - detections that overlap by more than a set threshold will be merged - into a single detection. - """ - - NONE = "none" - NON_MAX_SUPPRESSION = "non_max_suppression" - NON_MAX_MERGE = "non_max_merge" - - -def validate_overlapping_handling_strategy( - strategy: Union[OverlapHandlingStrategy, str], -) -> OverlapHandlingStrategy: - if isinstance(strategy, str): - try: - strategy = OverlapHandlingStrategy(strategy.lower()) - except ValueError: - raise ValueError( - f"Invalid strategy value: {strategy}. Must be one of " - f"{[e.value for e in OverlapHandlingStrategy]}" - ) - return strategy diff --git a/test/detection/test_overlap_handling.py b/test/detection/test_overlap_handling.py new file mode 100644 index 00000000..0186a23e --- /dev/null +++ b/test/detection/test_overlap_handling.py @@ -0,0 +1,449 @@ +from contextlib import ExitStack as DoesNotRaise +from typing import List, Optional + +import numpy as np +import pytest + +from supervision.detection.overlap_handling import ( + box_non_max_suppression, + group_overlapping_boxes, + mask_non_max_suppression, +) + + +@pytest.mark.parametrize( + "predictions, iou_threshold, expected_result, exception", + [ + ( + np.empty(shape=(0, 5), dtype=float), + 0.5, + [], + DoesNotRaise(), + ), + ( + np.array([[0, 0, 10, 10, 1.0]]), + 0.5, + [[0]], + DoesNotRaise(), + ), + ( + np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]), + 0.5, + [[1, 0]], + DoesNotRaise(), + ), # High overlap, tie-break to second det + ( + np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 0.99]]), + 0.5, + [[0, 1]], + DoesNotRaise(), + ), # High overlap, merge to high confidence + ( + np.array([[0, 0, 10, 10, 0.99], [0, 0, 9, 9, 1.0]]), + 0.5, + [[1, 0]], + DoesNotRaise(), + ), # (test symmetry) High overlap, merge to high confidence + ( + np.array([[0, 0, 10, 10, 0.90], [0, 0, 9, 9, 1.0]]), + 0.5, + [[1, 0]], + DoesNotRaise(), + ), # (test symmetry) High overlap, merge to high confidence + ( + np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]), + 1.0, + [[1], [0]], + DoesNotRaise(), + ), # High IOU required + ( + np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]), + 0.0, + [[1, 0]], + DoesNotRaise(), + ), # No IOU required + ( + np.array([[0, 0, 10, 10, 1.0], [0, 0, 5, 5, 0.9]]), + 0.25, + [[0, 1]], + DoesNotRaise(), + ), # Below IOU requirement + ( + np.array([[0, 0, 10, 10, 1.0], [0, 0, 5, 5, 0.9]]), + 0.26, + [[0], [1]], + DoesNotRaise(), + ), # Above IOU requirement + ( + np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0], [0, 0, 8, 8, 1.0]]), + 0.5, + [[2, 1, 0]], + DoesNotRaise(), + ), # 3 boxes + ( + np.array( + [ + [0, 0, 10, 10, 1.0], + [0, 0, 9, 9, 1.0], + [5, 5, 10, 10, 1.0], + [6, 6, 10, 10, 1.0], + [9, 9, 10, 10, 1.0], + ] + ), + 0.5, + [[4], [3, 2], [1, 0]], + DoesNotRaise(), + ), # 5 boxes, 2 merges, 1 separate + ( + np.array( + [ + [0, 0, 2, 1, 1.0], + [1, 0, 3, 1, 1.0], + [2, 0, 4, 1, 1.0], + [3, 0, 5, 1, 1.0], + [4, 0, 6, 1, 1.0], + ] + ), + 0.33, + [[4, 3], [2, 1], [0]], + DoesNotRaise(), + ), # sequential merge, half overlap + ( + np.array( + [ + [0, 0, 2, 1, 0.9], + [1, 0, 3, 1, 0.9], + [2, 0, 4, 1, 1.0], + [3, 0, 5, 1, 0.9], + [4, 0, 6, 1, 0.9], + ] + ), + 0.33, + [[2, 3, 1], [4], [0]], + DoesNotRaise(), + ), # confidence + ], +) +def test_group_overlapping_boxes( + predictions: np.ndarray, + iou_threshold: float, + expected_result: List[List[int]], + exception: Exception, +) -> None: + with exception: + result = group_overlapping_boxes( + predictions=predictions, iou_threshold=iou_threshold + ) + + assert result == expected_result + + +@pytest.mark.parametrize( + "predictions, iou_threshold, expected_result, exception", + [ + ( + np.empty(shape=(0, 5)), + 0.5, + np.array([]), + DoesNotRaise(), + ), # single box with no category + ( + np.array([[10.0, 10.0, 40.0, 40.0, 0.8]]), + 0.5, + np.array([True]), + DoesNotRaise(), + ), # single box with no category + ( + np.array([[10.0, 10.0, 40.0, 40.0, 0.8, 0]]), + 0.5, + np.array([True]), + DoesNotRaise(), + ), # single box with category + ( + np.array( + [ + [10.0, 10.0, 40.0, 40.0, 0.8], + [15.0, 15.0, 40.0, 40.0, 0.9], + ] + ), + 0.5, + np.array([False, True]), + DoesNotRaise(), + ), # two boxes with no category + ( + np.array( + [ + [10.0, 10.0, 40.0, 40.0, 0.8, 0], + [15.0, 15.0, 40.0, 40.0, 0.9, 1], + ] + ), + 0.5, + np.array([True, True]), + DoesNotRaise(), + ), # two boxes with different category + ( + np.array( + [ + [10.0, 10.0, 40.0, 40.0, 0.8, 0], + [15.0, 15.0, 40.0, 40.0, 0.9, 0], + ] + ), + 0.5, + np.array([False, True]), + DoesNotRaise(), + ), # two boxes with same category + ( + np.array( + [ + [0.0, 0.0, 30.0, 40.0, 0.8], + [5.0, 5.0, 35.0, 45.0, 0.9], + [10.0, 10.0, 40.0, 50.0, 0.85], + ] + ), + 0.5, + np.array([False, True, False]), + DoesNotRaise(), + ), # three boxes with no category + ( + np.array( + [ + [0.0, 0.0, 30.0, 40.0, 0.8, 0], + [5.0, 5.0, 35.0, 45.0, 0.9, 1], + [10.0, 10.0, 40.0, 50.0, 0.85, 2], + ] + ), + 0.5, + np.array([True, True, True]), + DoesNotRaise(), + ), # three boxes with same category + ( + np.array( + [ + [0.0, 0.0, 30.0, 40.0, 0.8, 0], + [5.0, 5.0, 35.0, 45.0, 0.9, 0], + [10.0, 10.0, 40.0, 50.0, 0.85, 1], + ] + ), + 0.5, + np.array([False, True, True]), + DoesNotRaise(), + ), # three boxes with different category + ], +) +def test_box_non_max_suppression( + predictions: np.ndarray, + iou_threshold: float, + expected_result: Optional[np.ndarray], + exception: Exception, +) -> None: + with exception: + result = box_non_max_suppression( + predictions=predictions, iou_threshold=iou_threshold + ) + assert np.array_equal(result, expected_result) + + +@pytest.mark.parametrize( + "predictions, masks, iou_threshold, expected_result, exception", + [ + ( + np.empty((0, 6)), + np.empty((0, 5, 5)), + 0.5, + np.array([]), + DoesNotRaise(), + ), # empty predictions and masks + ( + np.array([[0, 0, 0, 0, 0.8]]), + np.array( + [ + [ + [False, False, False, False, False], + [False, True, True, True, False], + [False, True, True, True, False], + [False, True, True, True, False], + [False, False, False, False, False], + ] + ] + ), + 0.5, + np.array([True]), + DoesNotRaise(), + ), # single mask with no category + ( + np.array([[0, 0, 0, 0, 0.8, 0]]), + np.array( + [ + [ + [False, False, False, False, False], + [False, True, True, True, False], + [False, True, True, True, False], + [False, True, True, True, False], + [False, False, False, False, False], + ] + ] + ), + 0.5, + np.array([True]), + DoesNotRaise(), + ), # single mask with category + ( + np.array([[0, 0, 0, 0, 0.8], [0, 0, 0, 0, 0.9]]), + np.array( + [ + [ + [False, False, False, False, False], + [False, True, True, False, False], + [False, True, True, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + ], + [ + [False, False, False, False, False], + [False, False, False, False, False], + [False, False, False, True, True], + [False, False, False, True, True], + [False, False, False, False, False], + ], + ] + ), + 0.5, + np.array([True, True]), + DoesNotRaise(), + ), # two masks non-overlapping with no category + ( + np.array([[0, 0, 0, 0, 0.8], [0, 0, 0, 0, 0.9]]), + np.array( + [ + [ + [False, False, False, False, False], + [False, True, True, True, False], + [False, True, True, True, False], + [False, True, True, True, False], + [False, False, False, False, False], + ], + [ + [False, False, False, False, False], + [False, False, True, True, True], + [False, False, True, True, True], + [False, False, True, True, True], + [False, False, False, False, False], + ], + ] + ), + 0.4, + np.array([False, True]), + DoesNotRaise(), + ), # two masks partially overlapping with no category + ( + np.array([[0, 0, 0, 0, 0.8, 0], [0, 0, 0, 0, 0.9, 1]]), + np.array( + [ + [ + [False, False, False, False, False], + [False, True, True, True, False], + [False, True, True, True, False], + [False, True, True, True, False], + [False, False, False, False, False], + ], + [ + [False, False, False, False, False], + [False, False, True, True, True], + [False, False, True, True, True], + [False, False, True, True, True], + [False, False, False, False, False], + ], + ] + ), + 0.5, + np.array([True, True]), + DoesNotRaise(), + ), # two masks partially overlapping with different category + ( + np.array( + [ + [0, 0, 0, 0, 0.8], + [0, 0, 0, 0, 0.85], + [0, 0, 0, 0, 0.9], + ] + ), + np.array( + [ + [ + [False, False, False, False, False], + [False, True, True, False, False], + [False, True, True, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + ], + [ + [False, False, False, False, False], + [False, True, True, False, False], + [False, True, True, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + ], + [ + [False, False, False, False, False], + [False, False, False, True, True], + [False, False, False, True, True], + [False, False, False, False, False], + [False, False, False, False, False], + ], + ] + ), + 0.5, + np.array([False, True, True]), + DoesNotRaise(), + ), # three masks with no category + ( + np.array( + [ + [0, 0, 0, 0, 0.8, 0], + [0, 0, 0, 0, 0.85, 1], + [0, 0, 0, 0, 0.9, 2], + ] + ), + np.array( + [ + [ + [False, False, False, False, False], + [False, True, True, False, False], + [False, True, True, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + ], + [ + [False, False, False, False, False], + [False, True, True, False, False], + [False, True, True, False, False], + [False, True, True, False, False], + [False, False, False, False, False], + ], + [ + [False, False, False, False, False], + [False, True, True, False, False], + [False, True, True, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + ], + ] + ), + 0.5, + np.array([True, True, True]), + DoesNotRaise(), + ), # three masks with different category + ], +) +def test_mask_non_max_suppression( + predictions: np.ndarray, + masks: np.ndarray, + iou_threshold: float, + expected_result: Optional[np.ndarray], + exception: Exception, +) -> None: + with exception: + result = mask_non_max_suppression( + predictions=predictions, masks=masks, iou_threshold=iou_threshold + ) + assert np.array_equal(result, expected_result) diff --git a/test/detection/test_utils.py b/test/detection/test_utils.py index 837b3b84..f0f0a6b1 100644 --- a/test/detection/test_utils.py +++ b/test/detection/test_utils.py @@ -7,15 +7,12 @@ import pytest from supervision.config import CLASS_NAME_DATA_FIELD from supervision.detection.utils import ( - box_non_max_suppression, calculate_masks_centroids, clip_boxes, contains_holes, contains_multiple_segments, filter_polygons_by_area, get_data_item, - group_overlapping_boxes, - mask_non_max_suppression, merge_data, move_boxes, process_roboflow_result, @@ -26,444 +23,6 @@ TEST_MASK = np.zeros((1, 1000, 1000), dtype=bool) TEST_MASK[:, 300:351, 200:251] = True -@pytest.mark.parametrize( - "predictions, iou_threshold, expected_result, exception", - [ - ( - np.empty(shape=(0, 5)), - 0.5, - np.array([]), - DoesNotRaise(), - ), # single box with no category - ( - np.array([[10.0, 10.0, 40.0, 40.0, 0.8]]), - 0.5, - np.array([True]), - DoesNotRaise(), - ), # single box with no category - ( - np.array([[10.0, 10.0, 40.0, 40.0, 0.8, 0]]), - 0.5, - np.array([True]), - DoesNotRaise(), - ), # single box with category - ( - np.array( - [ - [10.0, 10.0, 40.0, 40.0, 0.8], - [15.0, 15.0, 40.0, 40.0, 0.9], - ] - ), - 0.5, - np.array([False, True]), - DoesNotRaise(), - ), # two boxes with no category - ( - np.array( - [ - [10.0, 10.0, 40.0, 40.0, 0.8, 0], - [15.0, 15.0, 40.0, 40.0, 0.9, 1], - ] - ), - 0.5, - np.array([True, True]), - DoesNotRaise(), - ), # two boxes with different category - ( - np.array( - [ - [10.0, 10.0, 40.0, 40.0, 0.8, 0], - [15.0, 15.0, 40.0, 40.0, 0.9, 0], - ] - ), - 0.5, - np.array([False, True]), - DoesNotRaise(), - ), # two boxes with same category - ( - np.array( - [ - [0.0, 0.0, 30.0, 40.0, 0.8], - [5.0, 5.0, 35.0, 45.0, 0.9], - [10.0, 10.0, 40.0, 50.0, 0.85], - ] - ), - 0.5, - np.array([False, True, False]), - DoesNotRaise(), - ), # three boxes with no category - ( - np.array( - [ - [0.0, 0.0, 30.0, 40.0, 0.8, 0], - [5.0, 5.0, 35.0, 45.0, 0.9, 1], - [10.0, 10.0, 40.0, 50.0, 0.85, 2], - ] - ), - 0.5, - np.array([True, True, True]), - DoesNotRaise(), - ), # three boxes with same category - ( - np.array( - [ - [0.0, 0.0, 30.0, 40.0, 0.8, 0], - [5.0, 5.0, 35.0, 45.0, 0.9, 0], - [10.0, 10.0, 40.0, 50.0, 0.85, 1], - ] - ), - 0.5, - np.array([False, True, True]), - DoesNotRaise(), - ), # three boxes with different category - ], -) -def test_box_non_max_suppression( - predictions: np.ndarray, - iou_threshold: float, - expected_result: Optional[np.ndarray], - exception: Exception, -) -> None: - with exception: - result = box_non_max_suppression( - predictions=predictions, iou_threshold=iou_threshold - ) - assert np.array_equal(result, expected_result) - - -@pytest.mark.parametrize( - "predictions, iou_threshold, expected_result, exception", - [ - ( - np.empty(shape=(0, 5), dtype=float), - 0.5, - [], - DoesNotRaise(), - ), - ( - np.array([[0, 0, 10, 10, 1.0]]), - 0.5, - [[0]], - DoesNotRaise(), - ), - ( - np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]), - 0.5, - [[1, 0]], - DoesNotRaise(), - ), # High overlap, tie-break to second det - ( - np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 0.99]]), - 0.5, - [[0, 1]], - DoesNotRaise(), - ), # High overlap, merge to high confidence - ( - np.array([[0, 0, 10, 10, 0.99], [0, 0, 9, 9, 1.0]]), - 0.5, - [[1, 0]], - DoesNotRaise(), - ), # (test symmetry) High overlap, merge to high confidence - ( - np.array([[0, 0, 10, 10, 0.90], [0, 0, 9, 9, 1.0]]), - 0.5, - [[1, 0]], - DoesNotRaise(), - ), # (test symmetry) High overlap, merge to high confidence - ( - np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]), - 1.0, - [[1], [0]], - DoesNotRaise(), - ), # High IOU required - ( - np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]), - 0.0, - [[1, 0]], - DoesNotRaise(), - ), # No IOU required - ( - np.array([[0, 0, 10, 10, 1.0], [0, 0, 5, 5, 0.9]]), - 0.25, - [[0, 1]], - DoesNotRaise(), - ), # Below IOU requirement - ( - np.array([[0, 0, 10, 10, 1.0], [0, 0, 5, 5, 0.9]]), - 0.26, - [[0], [1]], - DoesNotRaise(), - ), # Above IOU requirement - ( - np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0], [0, 0, 8, 8, 1.0]]), - 0.5, - [[2, 1, 0]], - DoesNotRaise(), - ), # 3 boxes - ( - np.array( - [ - [0, 0, 10, 10, 1.0], - [0, 0, 9, 9, 1.0], - [5, 5, 10, 10, 1.0], - [6, 6, 10, 10, 1.0], - [9, 9, 10, 10, 1.0], - ] - ), - 0.5, - [[4], [3, 2], [1, 0]], - DoesNotRaise(), - ), # 5 boxes, 2 merges, 1 separate - ( - np.array( - [ - [0, 0, 2, 1, 1.0], - [1, 0, 3, 1, 1.0], - [2, 0, 4, 1, 1.0], - [3, 0, 5, 1, 1.0], - [4, 0, 6, 1, 1.0], - ] - ), - 0.33, - [[4, 3], [2, 1], [0]], - DoesNotRaise(), - ), # sequential merge, half overlap - ( - np.array( - [ - [0, 0, 2, 1, 0.9], - [1, 0, 3, 1, 0.9], - [2, 0, 4, 1, 1.0], - [3, 0, 5, 1, 0.9], - [4, 0, 6, 1, 0.9], - ] - ), - 0.33, - [[2, 3, 1], [4], [0]], - DoesNotRaise(), - ), # confidence - ], -) -def test_group_overlapping_boxes( - predictions: np.ndarray, - iou_threshold: float, - expected_result: List[List[int]], - exception: Exception, -) -> None: - with exception: - result = group_overlapping_boxes( - predictions=predictions, iou_threshold=iou_threshold - ) - - assert result == expected_result - - -@pytest.mark.parametrize( - "predictions, masks, iou_threshold, expected_result, exception", - [ - ( - np.empty((0, 6)), - np.empty((0, 5, 5)), - 0.5, - np.array([]), - DoesNotRaise(), - ), # empty predictions and masks - ( - np.array([[0, 0, 0, 0, 0.8]]), - np.array( - [ - [ - [False, False, False, False, False], - [False, True, True, True, False], - [False, True, True, True, False], - [False, True, True, True, False], - [False, False, False, False, False], - ] - ] - ), - 0.5, - np.array([True]), - DoesNotRaise(), - ), # single mask with no category - ( - np.array([[0, 0, 0, 0, 0.8, 0]]), - np.array( - [ - [ - [False, False, False, False, False], - [False, True, True, True, False], - [False, True, True, True, False], - [False, True, True, True, False], - [False, False, False, False, False], - ] - ] - ), - 0.5, - np.array([True]), - DoesNotRaise(), - ), # single mask with category - ( - np.array([[0, 0, 0, 0, 0.8], [0, 0, 0, 0, 0.9]]), - np.array( - [ - [ - [False, False, False, False, False], - [False, True, True, False, False], - [False, True, True, False, False], - [False, False, False, False, False], - [False, False, False, False, False], - ], - [ - [False, False, False, False, False], - [False, False, False, False, False], - [False, False, False, True, True], - [False, False, False, True, True], - [False, False, False, False, False], - ], - ] - ), - 0.5, - np.array([True, True]), - DoesNotRaise(), - ), # two masks non-overlapping with no category - ( - np.array([[0, 0, 0, 0, 0.8], [0, 0, 0, 0, 0.9]]), - np.array( - [ - [ - [False, False, False, False, False], - [False, True, True, True, False], - [False, True, True, True, False], - [False, True, True, True, False], - [False, False, False, False, False], - ], - [ - [False, False, False, False, False], - [False, False, True, True, True], - [False, False, True, True, True], - [False, False, True, True, True], - [False, False, False, False, False], - ], - ] - ), - 0.4, - np.array([False, True]), - DoesNotRaise(), - ), # two masks partially overlapping with no category - ( - np.array([[0, 0, 0, 0, 0.8, 0], [0, 0, 0, 0, 0.9, 1]]), - np.array( - [ - [ - [False, False, False, False, False], - [False, True, True, True, False], - [False, True, True, True, False], - [False, True, True, True, False], - [False, False, False, False, False], - ], - [ - [False, False, False, False, False], - [False, False, True, True, True], - [False, False, True, True, True], - [False, False, True, True, True], - [False, False, False, False, False], - ], - ] - ), - 0.5, - np.array([True, True]), - DoesNotRaise(), - ), # two masks partially overlapping with different category - ( - np.array( - [ - [0, 0, 0, 0, 0.8], - [0, 0, 0, 0, 0.85], - [0, 0, 0, 0, 0.9], - ] - ), - np.array( - [ - [ - [False, False, False, False, False], - [False, True, True, False, False], - [False, True, True, False, False], - [False, False, False, False, False], - [False, False, False, False, False], - ], - [ - [False, False, False, False, False], - [False, True, True, False, False], - [False, True, True, False, False], - [False, False, False, False, False], - [False, False, False, False, False], - ], - [ - [False, False, False, False, False], - [False, False, False, True, True], - [False, False, False, True, True], - [False, False, False, False, False], - [False, False, False, False, False], - ], - ] - ), - 0.5, - np.array([False, True, True]), - DoesNotRaise(), - ), # three masks with no category - ( - np.array( - [ - [0, 0, 0, 0, 0.8, 0], - [0, 0, 0, 0, 0.85, 1], - [0, 0, 0, 0, 0.9, 2], - ] - ), - np.array( - [ - [ - [False, False, False, False, False], - [False, True, True, False, False], - [False, True, True, False, False], - [False, False, False, False, False], - [False, False, False, False, False], - ], - [ - [False, False, False, False, False], - [False, True, True, False, False], - [False, True, True, False, False], - [False, True, True, False, False], - [False, False, False, False, False], - ], - [ - [False, False, False, False, False], - [False, True, True, False, False], - [False, True, True, False, False], - [False, False, False, False, False], - [False, False, False, False, False], - ], - ] - ), - 0.5, - np.array([True, True, True]), - DoesNotRaise(), - ), # three masks with different category - ], -) -def test_mask_non_max_suppression( - predictions: np.ndarray, - masks: np.ndarray, - iou_threshold: float, - expected_result: Optional[np.ndarray], - exception: Exception, -) -> None: - with exception: - result = mask_non_max_suppression( - predictions=predictions, masks=masks, iou_threshold=iou_threshold - ) - assert np.array_equal(result, expected_result) - - @pytest.mark.parametrize( "xyxy, resolution_wh, expected_result", [ From 6ad32406afcc06daca058701cae5d63f80b8bf70 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Wed, 29 May 2024 16:31:29 +0300 Subject: [PATCH 58/94] fix: change doc path for overlap_handling, add box_non_max_merge --- docs/detection/tools/inference_slicer.md | 2 +- docs/detection/utils.md | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/detection/tools/inference_slicer.md b/docs/detection/tools/inference_slicer.md index 3a00b879..51301e86 100644 --- a/docs/detection/tools/inference_slicer.md +++ b/docs/detection/tools/inference_slicer.md @@ -8,4 +8,4 @@ comments: true # Overlap Handling Strategy -:::supervision.detection.utils.OverlapHandlingStrategy +:::supervision.detection.overlap_handling.OverlapHandlingStrategy diff --git a/docs/detection/utils.md b/docs/detection/utils.md index f9c9473b..dd14a23e 100644 --- a/docs/detection/utils.md +++ b/docs/detection/utils.md @@ -18,16 +18,22 @@ status: new :::supervision.detection.utils.mask_iou_batch -:::supervision.detection.utils.box_non_max_suppression +:::supervision.detection.overlap_handling.box_non_max_suppression -:::supervision.detection.utils.mask_non_max_suppression +:::supervision.detection.overlap_handling.mask_non_max_suppression + + + +:::supervision.detection.overlap_handling.box_non_max_merge

polygon_to_mask

From 34353aac543163e51b933843744b8409125f178f Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Wed, 29 May 2024 18:39:41 +0300 Subject: [PATCH 59/94] Add overlap handling example image :) --- supervision/detection/overlap_handling.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/supervision/detection/overlap_handling.py b/supervision/detection/overlap_handling.py index 7fa21b7e..f9acc4a6 100644 --- a/supervision/detection/overlap_handling.py +++ b/supervision/detection/overlap_handling.py @@ -242,6 +242,8 @@ class OverlapHandlingStrategy(Enum): NON_MAX_MERGE: Merge detections with non-max-merging. This means, detections that overlap by more than a set threshold will be merged into a single detection. + + ![overlap-handling-strategies-example](https://media.roboflow.com/supervision-docs/overlap-handling-strategies-example.png) """ NONE = "none" From dc1249969f9e28424f02723122ddf28b8bee5bf0 Mon Sep 17 00:00:00 2001 From: tc360950 Date: Wed, 29 May 2024 18:34:35 +0200 Subject: [PATCH 60/94] Improve variable naming --- supervision/detection/line_zone.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/supervision/detection/line_zone.py b/supervision/detection/line_zone.py index 45bc9644..4198135d 100644 --- a/supervision/detection/line_zone.py +++ b/supervision/detection/line_zone.py @@ -167,20 +167,19 @@ class LineZone: # Calculate which anchors lie to the left of the line triggers = self._cross_product(all_anchors, self.vector) < 0 - # Reduce to find out if all anchors for a - # detection lie to the left (or right) of the line - max_triggers = np.max(triggers, axis=0) - min_triggers = np.min(triggers, axis=0) + has_any_left_trigger = np.any(triggers, axis=0) + has_any_right_trigger = np.any(~triggers, axis=0) + is_uniformly_triggered = ~(has_any_left_trigger & has_any_right_trigger) for i, tracker_id in enumerate(detections.tracker_id): if not in_limits[i]: continue - if min_triggers[i] != max_triggers[i]: + if not is_uniformly_triggered[i]: # One anchor lies to the left of the line # whilst another lies to the right continue - tracker_state = max_triggers[i] + tracker_state = has_any_left_trigger[i] if tracker_id not in self.tracker_state: self.tracker_state[tracker_id] = tracker_state continue From bdc10f83fe429474d74d5bd5469afdc3d6d5522d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 May 2024 01:05:20 +0000 Subject: [PATCH 61/94] :arrow_up: Bump requests from 2.32.2 to 2.32.3 Bumps [requests](https://github.com/psf/requests) from 2.32.2 to 2.32.3. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.32.2...v2.32.3) --- updated-dependencies: - dependency-name: requests dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 061b284b..319db4a3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3461,13 +3461,13 @@ files = [ [[package]] name = "requests" -version = "2.32.2" +version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" files = [ - {file = "requests-2.32.2-py3-none-any.whl", hash = "sha256:fc06670dd0ed212426dfeb94fc1b983d917c4f9847c863f313c9dfaaffb7c23c"}, - {file = "requests-2.32.2.tar.gz", hash = "sha256:dd951ff5ecf3e3b3aa26b40703ba77495dab41da839ae72ef3c8e5d8e2433289"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -4258,4 +4258,4 @@ desktop = ["opencv-python"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "ad8402ec1767f9427ab38bad7dab54b302a30f9e08b6489fad224c8481745b37" +content-hash = "e3d79f6c93041323b04c7b45e93bb3c4198b21889044004af8a0485a6145a207" diff --git a/pyproject.toml b/pyproject.toml index ff83f5fa..640d462e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ pyyaml = ">=5.3" defusedxml = "^0.7.1" opencv-python = { version = ">=4.5.5.64", optional = true } opencv-python-headless = ">=4.5.5.64" -requests = { version = ">=2.26.0,<=2.32.2", optional = true } +requests = { version = ">=2.26.0,<=2.32.3", optional = true } tqdm = { version = ">=4.62.3,<=4.66.4", optional = true } pillow = ">=9.4" From f7bd7011fedd9f2ec802997f20b6d061e595d710 Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Thu, 30 May 2024 13:45:12 +0300 Subject: [PATCH 62/94] OverlapFilter: Add and validate docs, rename --- docs/detection/double_detection_filter.md | 30 ++++++++++++++++ docs/detection/tools/inference_slicer.md | 4 --- docs/detection/utils.md | 18 ---------- mkdocs.yml | 1 + supervision/__init__.py | 4 +-- supervision/detection/core.py | 2 +- ...{overlap_handling.py => overlap_filter.py} | 16 ++++----- .../detection/tools/inference_slicer.py | 35 ++++++++----------- ...lap_handling.py => test_overlap_filter.py} | 2 +- 9 files changed, 57 insertions(+), 55 deletions(-) create mode 100644 docs/detection/double_detection_filter.md rename supervision/detection/{overlap_handling.py => overlap_filter.py} (94%) rename test/detection/{test_overlap_handling.py => test_overlap_filter.py} (99%) diff --git a/docs/detection/double_detection_filter.md b/docs/detection/double_detection_filter.md new file mode 100644 index 00000000..1631852f --- /dev/null +++ b/docs/detection/double_detection_filter.md @@ -0,0 +1,30 @@ +--- +comments: true +status: new +--- + +# Double Detection Filter + + + +:::supervision.detection.overlap_filter.OverlapFilter + + + +:::supervision.detection.overlap_filter.box_non_max_suppression + + + +:::supervision.detection.overlap_filter.mask_non_max_suppression + + + +:::supervision.detection.overlap_filter.box_non_max_merge diff --git a/docs/detection/tools/inference_slicer.md b/docs/detection/tools/inference_slicer.md index 51301e86..5d5d08bc 100644 --- a/docs/detection/tools/inference_slicer.md +++ b/docs/detection/tools/inference_slicer.md @@ -5,7 +5,3 @@ comments: true # InferenceSlicer :::supervision.detection.tools.inference_slicer.InferenceSlicer - -# Overlap Handling Strategy - -:::supervision.detection.overlap_handling.OverlapHandlingStrategy diff --git a/docs/detection/utils.md b/docs/detection/utils.md index dd14a23e..369746a3 100644 --- a/docs/detection/utils.md +++ b/docs/detection/utils.md @@ -17,24 +17,6 @@ status: new :::supervision.detection.utils.mask_iou_batch - - -:::supervision.detection.overlap_handling.box_non_max_suppression - - - -:::supervision.detection.overlap_handling.mask_non_max_suppression - - - -:::supervision.detection.overlap_handling.box_non_max_merge - diff --git a/mkdocs.yml b/mkdocs.yml index f257238d..19d6a4fd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,6 +48,7 @@ nav: - Core: detection/core.md - Annotators: detection/annotators.md - Metrics: detection/metrics.md + - Double Detection Filter: detection/double_detection_filter.md - Utils: detection/utils.md - Keypoint Detection: - Core: keypoint/core.md diff --git a/supervision/__init__.py b/supervision/__init__.py index 83e67795..4f28d49f 100644 --- a/supervision/__init__.py +++ b/supervision/__init__.py @@ -40,8 +40,8 @@ from supervision.detection.annotate import BoxAnnotator from supervision.detection.core import Detections from supervision.detection.line_zone import LineZone, LineZoneAnnotator from supervision.detection.lmm import LMM -from supervision.detection.overlap_handling import ( - OverlapHandlingStrategy, +from supervision.detection.overlap_filter import ( + OverlapFilter, box_non_max_merge, box_non_max_suppression, mask_non_max_suppression, diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 482e1109..c47bc819 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -8,7 +8,7 @@ import numpy as np from supervision.config import CLASS_NAME_DATA_FIELD, ORIENTED_BOX_COORDINATES from supervision.detection.lmm import LMM, from_paligemma, validate_lmm_and_kwargs -from supervision.detection.overlap_handling import ( +from supervision.detection.overlap_filter import ( box_non_max_merge, box_non_max_suppression, mask_non_max_suppression, diff --git a/supervision/detection/overlap_handling.py b/supervision/detection/overlap_filter.py similarity index 94% rename from supervision/detection/overlap_handling.py rename to supervision/detection/overlap_filter.py index f9acc4a6..ab4408d1 100644 --- a/supervision/detection/overlap_handling.py +++ b/supervision/detection/overlap_filter.py @@ -230,7 +230,7 @@ def box_non_max_merge( return merge_groups -class OverlapHandlingStrategy(Enum): +class OverlapFilter(Enum): """ Enum specifying the strategy for filtering overlapping detections. @@ -239,11 +239,9 @@ class OverlapHandlingStrategy(Enum): NON_MAX_SUPPRESSION: Filter detections using non-max suppression. This means, detections that overlap by more than a set threshold will be discarded, except for the one with the highest confidence. - NON_MAX_MERGE: Merge detections with non-max-merging. This means, + NON_MAX_MERGE: Merge detections with non-max merging. This means, detections that overlap by more than a set threshold will be merged into a single detection. - - ![overlap-handling-strategies-example](https://media.roboflow.com/supervision-docs/overlap-handling-strategies-example.png) """ NONE = "none" @@ -251,15 +249,15 @@ class OverlapHandlingStrategy(Enum): NON_MAX_MERGE = "non_max_merge" -def validate_overlapping_handling_strategy( - strategy: Union[OverlapHandlingStrategy, str], -) -> OverlapHandlingStrategy: +def validate_overlap_filter( + strategy: Union[OverlapFilter, str], +) -> OverlapFilter: if isinstance(strategy, str): try: - strategy = OverlapHandlingStrategy(strategy.lower()) + strategy = OverlapFilter(strategy.lower()) except ValueError: raise ValueError( f"Invalid strategy value: {strategy}. Must be one of " - f"{[e.value for e in OverlapHandlingStrategy]}" + f"{[e.value for e in OverlapFilter]}" ) return strategy diff --git a/supervision/detection/tools/inference_slicer.py b/supervision/detection/tools/inference_slicer.py index 372352e6..134361bd 100644 --- a/supervision/detection/tools/inference_slicer.py +++ b/supervision/detection/tools/inference_slicer.py @@ -5,10 +5,7 @@ from typing import Callable, Optional, Tuple, Union import numpy as np from supervision.detection.core import Detections -from supervision.detection.overlap_handling import ( - OverlapHandlingStrategy, - validate_overlapping_handling_strategy, -) +from supervision.detection.overlap_filter import OverlapFilter, validate_overlap_filter from supervision.detection.utils import move_boxes, move_masks from supervision.utils.image import crop_image from supervision.utils.internal import SupervisionWarnings @@ -56,7 +53,7 @@ class InferenceSlicer: `(width, height)`. overlap_ratio_wh (Tuple[float, float]): Overlap ratio between consecutive slices in the format `(width_ratio, height_ratio)`. - overlap_handling_strategy (Union[OverlapHandlingStrategy, str]): Strategy for + overlap_filter_strategy (Union[OverlapFilter, str]): Strategy for filtering or merging overlapping detections in slices. iou_threshold (float): Intersection over Union (IoU) threshold used when filtering by overlap. @@ -76,20 +73,18 @@ class InferenceSlicer: callback: Callable[[np.ndarray], Detections], slice_wh: Tuple[int, int] = (320, 320), overlap_ratio_wh: Tuple[float, float] = (0.2, 0.2), - overlap_handling_strategy: Union[ - OverlapHandlingStrategy, str - ] = OverlapHandlingStrategy.NON_MAX_SUPPRESSION, + overlap_filter_strategy: Union[ + OverlapFilter, str + ] = OverlapFilter.NON_MAX_SUPPRESSION, iou_threshold: float = 0.5, thread_workers: int = 1, ): - overlap_handling_strategy = validate_overlapping_handling_strategy( - overlap_handling_strategy - ) + overlap_filter_strategy = validate_overlap_filter(overlap_filter_strategy) self.slice_wh = slice_wh self.overlap_ratio_wh = overlap_ratio_wh self.iou_threshold = iou_threshold - self.overlap_handling_strategy = overlap_handling_strategy + self.overlap_filter_strategy = overlap_filter_strategy self.callback = callback self.thread_workers = thread_workers @@ -120,7 +115,10 @@ class InferenceSlicer: result = model(image_slice)[0] return sv.Detections.from_ultralytics(result) - slicer = sv.InferenceSlicer(callback = callback) + slicer = sv.InferenceSlicer( + callback=callback, + overlap_filter_strategy=sv.OverlapFilter.NON_MAX_SUPPRESSION, + ) detections = slicer(image) ``` @@ -141,18 +139,15 @@ class InferenceSlicer: detections_list.append(future.result()) merged = Detections.merge(detections_list=detections_list) - if self.overlap_handling_strategy == OverlapHandlingStrategy.NONE: + if self.overlap_filter_strategy == OverlapFilter.NONE: return merged - elif ( - self.overlap_handling_strategy - == OverlapHandlingStrategy.NON_MAX_SUPPRESSION - ): + elif self.overlap_filter_strategy == OverlapFilter.NON_MAX_SUPPRESSION: return merged.with_nms(threshold=self.iou_threshold) - elif self.overlap_handling_strategy == OverlapHandlingStrategy.NON_MAX_MERGE: + elif self.overlap_filter_strategy == OverlapFilter.NON_MAX_MERGE: return merged.with_nmm(threshold=self.iou_threshold) else: warnings.warn( - f"Invalid overlap filter strategy: {self.overlap_handling_strategy}", + f"Invalid overlap filter strategy: {self.overlap_filter_strategy}", category=SupervisionWarnings, ) return merged diff --git a/test/detection/test_overlap_handling.py b/test/detection/test_overlap_filter.py similarity index 99% rename from test/detection/test_overlap_handling.py rename to test/detection/test_overlap_filter.py index 0186a23e..f628c30f 100644 --- a/test/detection/test_overlap_handling.py +++ b/test/detection/test_overlap_filter.py @@ -4,7 +4,7 @@ from typing import List, Optional import numpy as np import pytest -from supervision.detection.overlap_handling import ( +from supervision.detection.overlap_filter import ( box_non_max_suppression, group_overlapping_boxes, mask_non_max_suppression, From 33e10ff722c4708b0ed39f35a841a2e5ffc6729d Mon Sep 17 00:00:00 2001 From: Linas Kondrackis Date: Fri, 31 May 2024 20:13:13 +0300 Subject: [PATCH 63/94] Add unit tests covering most single-detection cases * Add Colab for visuals: https://colab.research.google.com/drive/179sq8joJ-7JSYMqYNBQlMIPYEClq1PDi?usp=sharing * Need to visualise other cases - not sure if all are necessary --- test/detection/test_line_counter.py | 501 ++++++++++++++++------------ 1 file changed, 279 insertions(+), 222 deletions(-) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index d9c21f15..7642d965 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -1,5 +1,4 @@ from contextlib import ExitStack as DoesNotRaise -from itertools import chain, combinations from test.test_utils import mock_detections from typing import List, Optional, Tuple @@ -78,279 +77,331 @@ def test_calculate_region_of_interest_limits( @pytest.mark.parametrize( "vector, xyxy_sequence, expected_crossed_in, expected_crossed_out", [ - ( - Vector( - Point(0, 0), - Point(0, 100), - ), + ( # Vertical line, simple crossing + Vector(Point(0, 0), Point(0, 10)), [ - [100, 50, 120, 70], - [-100, 50, -80, 70], + [4, 4, 6, 6], + [4 - 10, 4, 6 - 10, 6], + [4, 4, 6, 6], + [4 - 10, 4, 6 - 10, 6], ], - [False, False], - [False, True], + [False, False, True, False], + [False, True, False, True], ), - ( - Vector( - Point(0, 0), - Point(0, 100), - ), + ( # Vertical line reversed, simple crossing + Vector(Point(0, 10), Point(0, 0)), [ - [-100, 50, -80, 70], - [100, 50, 120, 70], + [4, 4, 6, 6], + [4 - 10, 4, 6 - 10, 6], + [4, 4, 6, 6], + [4 - 10, 4, 6 - 10, 6], ], - [False, True], - [False, False], + [False, True, False, True], + [False, False, True, False], ), - ( - Vector( - Point(0, 0), - Point(0, 100), - ), + ( # Horizontal line, simple crossing + Vector(Point(0, 0), Point(10, 0)), [ - [-100, 50, -80, 70], - [-10, 50, 20, 70], - [100, 50, 120, 70], + [4, 4, 6, 6], + [4, 4 - 10, 6, 6 - 10], + [4, 4, 6, 6], + [4, 4 - 10, 6, 6 - 10], ], - [False, False, True], - [False, False, False], + [False, True, False, True], + [False, False, True, False], ), - ( - Vector( - Point(0, 0), - Point(100, 100), - ), + ( # Horizontal line reversed, simple crossing + Vector(Point(10, 0), Point(0, 0)), [ - [50, 45, 70, 30], - [40, 50, 50, 40], - [0, 50, 10, 40], + [4, 4, 6, 6], + [4, 4 - 10, 6, 6 - 10], + [4, 4, 6, 6], + [4, 4 - 10, 6, 6 - 10], ], - [False, False, False], - [False, False, True], + [False, False, True, False], + [False, True, False, True], ), - ( - Vector( - Point(0, 0), - Point(100, 0), - ), + ( # Diagonal line, simple crossing + Vector(Point(5, 0), Point(0, 5)), [ - [50, -45, 70, -30], - [40, 50, 50, 40], + [0, 0, 2, 2], + [0 + 10, 0 + 10, 2 + 10, 2 + 10], + [0, 0, 2, 2], + [0 + 10, 0 + 10, 2 + 10, 2 + 10], ], - [False, False], - [False, True], + [False, True, False, True], + [False, False, True, False], ), - ( - Vector( - Point(0, 0), - Point(0, -100), - ), + ( # Crossing beside - right side + Vector(Point(0, 0), Point(10, 0)), [ - [100, -50, 120, -70], - [-100, -50, -80, -70], + [20, 4, 24, 6], + [20, 4 - 10, 24, 6 - 10], + [20, 4, 24, 6], + [20, 4 - 10, 24, 6 - 10], ], - [False, True], - [False, False], - ), - ( - Vector( - Point(0, 0), - Point(50, 100), - ), - [ - [50, 50, 70, 30], - [40, 50, 50, 40], - [0, 50, 10, 40], - ], - [False, False, False], - [False, False, True], - ), - ( - Vector( - Point(0, 0), - Point(0, 100), - ), - [ - [100, 50, 120, 70], - [-100, 50, -80, 70], - [100, 50, 120, 70], - [-100, 50, -80, 70], - [100, 50, 120, 70], - [-100, 50, -80, 70], - [100, 50, 120, 70], - [-100, 50, -80, 70], - ], - [False, False, True, False, True, False, True, False], - [False, True, False, True, False, True, False, True], - ), - ( - Vector( - Point(0, 0), - Point(-100, 0), - ), - [ - [-50, 70, -40, 50], - [-50, -70, -40, -50], - [-50, 70, -40, 50], - [-50, -70, -40, -50], - [-50, 70, -40, 50], - [-50, -70, -40, -50], - [-50, 70, -40, 50], - [-50, -70, -40, -50], - ], - [False, False, True, False, True, False, True, False], - [False, True, False, True, False, True, False, True], - ), - ( - Vector( - Point(0, 100), - Point(0, 200), - ), - [ - [-100, 150, -80, 170], - [-100, 50, -80, 70], - [-10, 50, 20, 70], - [100, 50, 120, 70], - ], # detection goes "around" line start and hence never crosses it [False, False, False, False], [False, False, False, False], ), - ( - Vector( - Point(0, 100), - Point(0, 200), - ), + ( # Crossing beside - left side + Vector(Point(0, 0), Point(10, 0)), [ - [-100, 150, -80, 170], - [-100, 250, -80, 270], - [-10, 250, 20, 270], - [100, 250, 120, 270], - ], # detection goes "around" line end and hence never crosses it - [False, False, False, False], - [False, False, False, False], - ), - ( - Vector( - Point(-50, -50), - Point(-100, -150), - ), - [ - [-30, -80, -20, -100], - [-150, -60, -110, -70], - [-10, -100, 20, -130], + [-20, 4, -24, 6], + [-20, 4 - 10, -24, 6 - 10], + [-20, 4, -24, 6], + [-20, 4 - 10, -24, 6 - 10], ], - [False, True, False], - [False, False, True], + [False, False, False, False], + [False, False, False, False], ), + ( # Move above + Vector(Point(0, 0), Point(10, 0)), + [ + [-4, 4, -2, 6], + [-4 + 20, 4, -2 + 20, 6], + [-4, 4, -2, 6], + [-4 + 20, 4, -2 + 20, 6], + ], + [False, False, False, False], + [False, False, False, False], + ), + ( # Move below + Vector(Point(0, 0), Point(10, 0)), + [ + [-4, -6, -2, -4], + [-4 + 20, -6, -2 + 20, -4], + [-4, -6, -2, -4], + [-4 + 20, -6, -2 + 20, -4], + ], + [False, False, False, False], + [False, False, False, False], + ), + ( # Move into line partway + Vector(Point(0, 0), Point(10, 0)), + [ + [4, 4, 6, 6], + [4 + 5, 4, 6 + 5, 6], + [4, 4, 6, 6], + [4 + 5, 4, 6 + 5, 6], + ], + [False, False, False, False], + [False, False, False, False], + ), + # ( # Diagonal crossing of a straight line - does not work. + # Vector(Point(0, 0), Point(10, 0)), + # [ + # [-4, 4, -2, 8], + # [-4 + 16, -4, -2 + 16, -6], + # [-4, 4, -2, 8], + # [-4 + 16, -4, -2 + 16, -6] + # ], + # [False, False, True, False], + # [False, True, False, True], + # ), + # ( # V-shaped crossing - does not work. + # Vector(Point(0, 0), Point(10, 0)), + # [ + # [-3, 6, -1, 8], + # [4, -6, 6, -4], + # [11, 6, 13, 8] + # ], + # [False, False, True], + # [False, True, False] + # ), ], ) -def test_line_zone_single_detection( +def test_line_zone_one_detection_default_anchors( vector: Vector, xyxy_sequence: List[List[int]], expected_crossed_in: List[bool], expected_crossed_out: List[bool], ) -> None: line_zone = LineZone(start=vector.start, end=vector.end) + + crossed_in_list = [] + crossed_out_list = [] for i, bbox in enumerate(xyxy_sequence): detections = mock_detections( xyxy=[bbox], tracker_id=[0], ) crossed_in, crossed_out = line_zone.trigger(detections) - assert crossed_in[0] == expected_crossed_in[i] - assert crossed_out[0] == expected_crossed_out[i] - assert line_zone.in_count == sum(expected_crossed_in[: (i + 1)]) - assert line_zone.out_count == sum(expected_crossed_out[: (i + 1)]) + crossed_in_list.append(crossed_in[0]) + crossed_out_list.append(crossed_out[0]) + + assert ( + crossed_in_list == expected_crossed_in + ), f"expected {expected_crossed_in}, got {crossed_in_list}" + assert ( + crossed_out_list == expected_crossed_out + ), f"expected {expected_crossed_out}, got {crossed_out_list}" @pytest.mark.parametrize( - "vector," - "xyxy_sequence," - "expected_crossed_in," - "expected_crossed_out," - "crossing_anchors", + "vector, xyxy_sequence, triggering_anchors, expected_crossed_in, " + "expected_crossed_out", [ - ( - Vector( - Point(0, 0), - Point(100, 100), - ), + ( # Scrape line, left side, corner anchors + Vector(Point(0, 0), Point(10, 0)), [ - [50, 30, 60, 20], - [20, 50, 40, 30], + [-2, 4, 2, 6], + [-2, 4 - 10, 2, 6 - 10], + [-2, 4, 2, 6], + [-2, 4 - 10, 2, 6 - 10], ], - [False, False], - [False, True], - [Position.TOP_LEFT, Position.TOP_RIGHT, Position.BOTTOM_LEFT], + [ + Position.TOP_LEFT, + Position.BOTTOM_LEFT, + Position.TOP_RIGHT, + Position.BOTTOM_RIGHT, + ], + [False, False, False, False], + [False, False, False, False], ), - ( - Vector( - Point(0, 0), - Point(0, 100), - ), + ( # Scrape line, left side, right anchors + Vector(Point(0, 0), Point(10, 0)), [ - [-100, 50, -80, 70], - [-100, 50, 120, 70], + [-2, 4, 2, 6], + [-2, 4 - 10, 2, 6 - 10], + [-2, 4, 2, 6], + [-2, 4 - 10, 2, 6 - 10], ], - [False, True], - [False, False], [Position.TOP_RIGHT, Position.BOTTOM_RIGHT], + [False, True, False, True], + [False, False, True, False], + ), + ( # Scrape line, left side, center anchor (along line point) + Vector(Point(0, 0), Point(10, 0)), + [ + [-2, 4, 2, 6], + [-2, 4 - 10, 2, 6 - 10], + [-2, 4, 2, 6], + [-2, 4 - 10, 2, 6 - 10], + ], + [Position.CENTER], + [False, True, False, True], + [False, False, True, False], + ), + ( # Scrape line, right side, corner anchors + Vector(Point(0, 0), Point(10, 0)), + [ + [8, 4, 12, 6], + [8, 4 - 10, 12, 6 - 10], + [8, 4, 12, 6], + [8, 4 - 10, 12, 6 - 10], + ], + [ + Position.TOP_LEFT, + Position.BOTTOM_LEFT, + Position.TOP_RIGHT, + Position.BOTTOM_RIGHT, + ], + [False, False, False, False], + [False, False, False, False], + ), + ( # Scrape line, right side, left anchors + Vector(Point(0, 0), Point(10, 0)), + [ + [8, 4, 12, 6], + [8, 4 - 10, 12, 6 - 10], + [8, 4, 12, 6], + [8, 4 - 10, 12, 6 - 10], + ], + [Position.TOP_LEFT, Position.BOTTOM_LEFT], + [False, True, False, True], + [False, False, True, False], + ), + ( # Scrape line, right side, center anchor (along line point) + Vector(Point(0, 0), Point(10, 0)), + [ + [8, 4, 12, 6], + [8, 4 - 10, 12, 6 - 10], + [8, 4, 12, 6], + [8, 4 - 10, 12, 6 - 10], + ], + [Position.CENTER], + [False, True, False, True], + [False, False, True, False], + ), + ( # Simple crossing, one anchor + Vector(Point(0, 0), Point(10, 0)), + [ + [4, 4, 6, 6], + [4, 4 - 10, 6, 6 - 10], + [4, 4, 6, 6], + [4, 4 - 10, 6, 6 - 10], + ], + [Position.CENTER], + [False, True, False, True], + [False, False, True, False], + ), + ( # Simple crossing, all box anchors + Vector(Point(0, 0), Point(10, 0)), + [ + [4, 4, 6, 6], + [4, 4 - 10, 6, 6 - 10], + [4, 4, 6, 6], + [4, 4 - 10, 6, 6 - 10], + ], + [ + Position.CENTER, + Position.CENTER_LEFT, + Position.CENTER_RIGHT, + Position.TOP_CENTER, + Position.TOP_LEFT, + Position.TOP_RIGHT, + Position.BOTTOM_LEFT, + Position.BOTTOM_CENTER, + Position.BOTTOM_RIGHT, + ], + [False, True, False, True], + [False, False, True, False], ), ], ) -def test_line_zone_single_detection_on_subset_of_anchors( +def test_line_zone_one_detection( vector: Vector, xyxy_sequence: List[List[int]], + triggering_anchors: List[Position], expected_crossed_in: List[bool], expected_crossed_out: List[bool], - crossing_anchors: List[Position], ) -> None: - def powerset(s): - return chain.from_iterable(combinations(s, r) for r in range(len(s) + 1)) + line_zone = LineZone( + start=vector.start, end=vector.end, triggering_anchors=triggering_anchors + ) - for anchors in powerset( - [ - Position.TOP_LEFT, - Position.TOP_RIGHT, - Position.BOTTOM_LEFT, - Position.BOTTOM_RIGHT, - ] - ): - if not anchors: - continue - line_zone = LineZone( - start=vector.start, end=vector.end, triggering_anchors=anchors + crossed_in_list = [] + crossed_out_list = [] + for i, bbox in enumerate(xyxy_sequence): + detections = mock_detections( + xyxy=[bbox], + tracker_id=[0], ) - for i, bbox in enumerate(xyxy_sequence): - detections = mock_detections( - xyxy=[bbox], - tracker_id=[0], - ) - crossed_in, crossed_out = line_zone.trigger(detections) - if all(anchor in crossing_anchors for anchor in anchors): - assert crossed_in == expected_crossed_in[i] - assert crossed_out == expected_crossed_out[i] - else: - assert np.all(not crossed_in) - assert np.all(not crossed_out) + crossed_in, crossed_out = line_zone.trigger(detections) + crossed_in_list.append(crossed_in[0]) + crossed_out_list.append(crossed_out[0]) + + assert ( + crossed_in_list == expected_crossed_in + ), f"expected {expected_crossed_in}, got {crossed_in_list}" + assert ( + crossed_out_list == expected_crossed_out + ), f"expected {expected_crossed_out}, got {crossed_out_list}" @pytest.mark.parametrize( - "vector," - "xyxy_sequence," - "expected_crossed_in," - "expected_crossed_out," + "vector, xyxy_sequence, expected_crossed_in, expected_crossed_out, " "anchors, exception", [ ( Vector( Point(0, 0), - Point(0, 100), + Point(0, 10), ), [ - [[100, 50, 120, 70], [100, 50, 120, 70]], - [[-100, 50, -80, 70], [100, 50, 120, 70]], - [[100, 50, 120, 70], [100, 50, 120, 70]], + [[10, 5, 12, 7], [10, 5, 12, 7]], + [[-10, 5, -8, 7], [10, 5, 12, 7]], + [[10, 5, 12, 7], [10, 5, 12, 7]], ], [[False, False], [False, False], [True, False]], [[False, False], [True, False], [False, False]], @@ -365,17 +416,17 @@ def test_line_zone_single_detection_on_subset_of_anchors( ( Vector( Point(0, 0), - Point(-100, 0), + Point(-10, 0), ), [ - [[-50, 70, -40, 50], [-80, -50, -70, -40]], - [[-50, -70, -40, -50], [-80, 50, -70, 40]], - [[-50, 70, -40, 50], [-80, 50, -70, 40]], - [[-50, -70, -40, -50], [-80, 50, -70, 40]], - [[-50, 70, -40, 50], [-80, 50, -70, 40]], - [[-50, -70, -40, -50], [-80, 50, -70, 40]], - [[-50, 70, -40, 50], [-80, 50, -70, 40]], - [[-50, -70, -40, -50], [-80, -50, -70, -40]], + [[-5, 7, -4, 5], [-8, -5, -7, -4]], + [[-5, -7, -4, -5], [-8, 5, -7, 4]], + [[-5, 7, -4, 5], [-8, 5, -7, 4]], + [[-5, -7, -4, -5], [-8, 5, -7, 4]], + [[-5, 7, -4, 5], [-8, 5, -7, 4]], + [[-5, -7, -4, -5], [-8, 5, -7, 4]], + [[-5, 7, -4, 5], [-8, 5, -7, 4]], + [[-5, -7, -4, -5], [-8, -5, -7, -4]], ], [ (False, False), @@ -407,12 +458,12 @@ def test_line_zone_single_detection_on_subset_of_anchors( ), ( Vector( - Point(-50, -50), - Point(-100, -150), + Point(-5, -5), + Point(-10, -15), ), [ - [[-30, -80, -20, -100], [100, 50, 120, 70]], - [[-100, -80, -20, -100], [100, 50, 120, 70]], + [[-3, -8, -2, -10], [10, 5, 12, 7]], + [[-10, -8, -2, -10], [10, 5, 12, 7]], ], [[False, False], [True, False]], [[False, False], [False, False]], @@ -422,9 +473,9 @@ def test_line_zone_single_detection_on_subset_of_anchors( ( Vector( Point(0, 0), - Point(-100, 0), + Point(-10, 0), ), - [[[-50, 70, -40, 50], [-80, -50, -70, -40]]], + [[[-5, 7, -4, 5], [-8, -5, -7, -4]]], [(False, False)], [(False, False)], [], # raise because of empty anchors @@ -440,6 +491,12 @@ def test_line_zone_multiple_detections( anchors: List[Position], exception: Exception, ) -> None: + """ + Test LineZone with multiple detections. + A detection is represented by a sequence of xyxy bboxes which represent + subsequent positions of the detected object. If a line is crossed (in either + direction) by a detection it is crossed by exactly all anchors from @anchors. + """ with exception: line_zone = LineZone( start=vector.start, end=vector.end, triggering_anchors=anchors From d2f169bcfa043028dfec9db90ba44417008fc3a0 Mon Sep 17 00:00:00 2001 From: LinasKo Date: Sat, 1 Jun 2024 15:59:46 +0300 Subject: [PATCH 64/94] Line zone tests - replace multi detection tests --- test/detection/test_line_counter.py | 128 ++++++++-------------------- 1 file changed, 37 insertions(+), 91 deletions(-) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 7642d965..6804b20e 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -2,7 +2,6 @@ from contextlib import ExitStack as DoesNotRaise from test.test_utils import mock_detections from typing import List, Optional, Tuple -import numpy as np import pytest from supervision import LineZone @@ -212,7 +211,7 @@ def test_calculate_region_of_interest_limits( ) def test_line_zone_one_detection_default_anchors( vector: Vector, - xyxy_sequence: List[List[int]], + xyxy_sequence: List[List[float]], expected_crossed_in: List[bool], expected_crossed_out: List[bool], ) -> None: @@ -361,7 +360,7 @@ def test_line_zone_one_detection_default_anchors( ) def test_line_zone_one_detection( vector: Vector, - xyxy_sequence: List[List[int]], + xyxy_sequence: List[List[float]], triggering_anchors: List[Position], expected_crossed_in: List[bool], expected_crossed_out: List[bool], @@ -390,63 +389,16 @@ def test_line_zone_one_detection( @pytest.mark.parametrize( - "vector, xyxy_sequence, expected_crossed_in, expected_crossed_out, " - "anchors, exception", + "vector, xyxy_sequence, anchors, expected_crossed_in, " + "expected_crossed_out, exception", [ - ( - Vector( - Point(0, 0), - Point(0, 10), - ), + ( # One stays, one crosses + Vector(Point(0, 0), Point(10, 0)), [ - [[10, 5, 12, 7], [10, 5, 12, 7]], - [[-10, 5, -8, 7], [10, 5, 12, 7]], - [[10, 5, 12, 7], [10, 5, 12, 7]], - ], - [[False, False], [False, False], [True, False]], - [[False, False], [True, False], [False, False]], - [ - Position.TOP_LEFT, - Position.TOP_RIGHT, - Position.BOTTOM_LEFT, - Position.BOTTOM_RIGHT, - ], - DoesNotRaise(), - ), - ( - Vector( - Point(0, 0), - Point(-10, 0), - ), - [ - [[-5, 7, -4, 5], [-8, -5, -7, -4]], - [[-5, -7, -4, -5], [-8, 5, -7, 4]], - [[-5, 7, -4, 5], [-8, 5, -7, 4]], - [[-5, -7, -4, -5], [-8, 5, -7, 4]], - [[-5, 7, -4, 5], [-8, 5, -7, 4]], - [[-5, -7, -4, -5], [-8, 5, -7, 4]], - [[-5, 7, -4, 5], [-8, 5, -7, 4]], - [[-5, -7, -4, -5], [-8, -5, -7, -4]], - ], - [ - (False, False), - (False, True), - (True, False), - (False, False), - (True, False), - (False, False), - (True, False), - (False, False), - ], - [ - (False, False), - (True, False), - (False, False), - (True, False), - (False, False), - (True, False), - (False, False), - (True, True), + [[4, 4, 6, 6], [4, 4, 6, 6]], + [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]], + [[4, 4, 6, 6], [4, 4, 6, 6]], + [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]], ], [ Position.TOP_LEFT, @@ -454,58 +406,52 @@ def test_line_zone_one_detection( Position.BOTTOM_LEFT, Position.BOTTOM_RIGHT, ], + [[False, False], [False, True], [False, False], [False, True]], + [[False, False], [False, False], [False, True], [False, False]], DoesNotRaise(), ), - ( - Vector( - Point(-5, -5), - Point(-10, -15), - ), + ( # Both cross at the same time + Vector(Point(0, 0), Point(10, 0)), [ - [[-3, -8, -2, -10], [10, 5, 12, 7]], - [[-10, -8, -2, -10], [10, 5, 12, 7]], + [[4, 4, 6, 6], [4, 4, 6, 6]], + [[4, 4 - 10, 6, 6 - 10], [4, 4 - 10, 6, 6 - 10]], + [[4, 4, 6, 6], [4, 4, 6, 6]], + [[4, 4 - 10, 6, 6 - 10], [4, 4 - 10, 6, 6 - 10]], ], - [[False, False], [True, False]], - [[False, False], [False, False]], - [Position.TOP_LEFT], + [ + Position.TOP_LEFT, + Position.TOP_RIGHT, + Position.BOTTOM_LEFT, + Position.BOTTOM_RIGHT, + ], + [[False, False], [True, True], [False, False], [True, True]], + [[False, False], [False, False], [True, True], [False, False]], DoesNotRaise(), ), - ( - Vector( - Point(0, 0), - Point(-10, 0), - ), - [[[-5, 7, -4, 5], [-8, -5, -7, -4]]], - [(False, False)], - [(False, False)], - [], # raise because of empty anchors - pytest.raises(ValueError), - ), ], ) def test_line_zone_multiple_detections( vector: Vector, - xyxy_sequence: List[List[List[int]]], - expected_crossed_in: List[bool], - expected_crossed_out: List[bool], + xyxy_sequence: List[List[List[float]]], anchors: List[Position], + expected_crossed_in: List[List[bool]], + expected_crossed_out: List[List[bool]], exception: Exception, ) -> None: - """ - Test LineZone with multiple detections. - A detection is represented by a sequence of xyxy bboxes which represent - subsequent positions of the detected object. If a line is crossed (in either - direction) by a detection it is crossed by exactly all anchors from @anchors. - """ with exception: line_zone = LineZone( start=vector.start, end=vector.end, triggering_anchors=anchors ) - for i, bboxes in enumerate(xyxy_sequence): + crossed_in_list = [] + crossed_out_list = [] + for bboxes in xyxy_sequence: detections = mock_detections( xyxy=bboxes, tracker_id=[i for i in range(0, len(bboxes))], ) crossed_in, crossed_out = line_zone.trigger(detections) - assert np.all(crossed_in == expected_crossed_in[i]) - assert np.all(crossed_out == expected_crossed_out[i]) + crossed_in_list.append(list(crossed_in)) + crossed_out_list.append(list(crossed_out)) + + assert crossed_in_list == expected_crossed_in + assert crossed_out_list == expected_crossed_out From b6ff9242fc2e094393bd8369c27edff43b1c7b16 Mon Sep 17 00:00:00 2001 From: LinasKo Date: Sat, 1 Jun 2024 16:19:09 +0300 Subject: [PATCH 65/94] LineZone tests: add extreme coordinate test --- test/detection/test_line_counter.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 6804b20e..84977130 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -142,6 +142,17 @@ def test_calculate_region_of_interest_limits( [False, False, False, False], [False, False, False, False], ), + ( # Horizontal line, simple crossing, far away + Vector(Point(0, 0), Point(10, 0)), + [ + [4, 1e32, 6, 1e32 + 2], + [4, -1e32, 6, -1e32 + 2], + [4, 1e32, 6, 1e32 + 2], + [4, -1e32, 6, -1e32 + 2], + ], + [False, True, False, True], + [False, False, True, False], + ), ( # Crossing beside - left side Vector(Point(0, 0), Point(10, 0)), [ From 2b5d4303824275cfcc8ae85015234c012d81893f Mon Sep 17 00:00:00 2001 From: LinasKo Date: Sat, 1 Jun 2024 15:44:20 +0200 Subject: [PATCH 66/94] LineZone - does not work with movement to or from zones outside limits --- test/detection/test_line_counter.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 84977130..3984be94 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -218,6 +218,24 @@ def test_calculate_region_of_interest_limits( # [False, False, True], # [False, True, False] # ), + # ( # Diagonal movement, from within limits to outside - does not work + # Vector(Point(0, 0), Point(10, 0)), + # [ + # [4, 1, 6, 3], + # [11, 1-20, 13, 3-20] + # ], + # [False, False], + # [False, True] + # ), + # ( # Diagonal movement, from within outside limits to inside - does not work + # Vector(Point(0, 0), Point(10, 0)), + # [ + # [11, 21, 13, 23], + # [4, -3, 6, -1], + # ], + # [False, False], + # [False, True] + # ) ], ) def test_line_zone_one_detection_default_anchors( From 612ac6145a5ae85c74ecbff726446ce50a052952 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 01:05:47 +0000 Subject: [PATCH 67/94] :arrow_up: Bump mkdocs-git-revision-date-localized-plugin Bumps [mkdocs-git-revision-date-localized-plugin](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin) from 1.2.5 to 1.2.6. - [Release notes](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin/releases) - [Commits](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin/compare/v1.2.5...v1.2.6) --- updated-dependencies: - dependency-name: mkdocs-git-revision-date-localized-plugin dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 319db4a3..e78589a6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2164,13 +2164,13 @@ requests = "*" [[package]] name = "mkdocs-git-revision-date-localized-plugin" -version = "1.2.5" +version = "1.2.6" description = "Mkdocs plugin that enables displaying the localized date of the last git modification of a markdown file." optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_git_revision_date_localized_plugin-1.2.5-py3-none-any.whl", hash = "sha256:d796a18b07cfcdb154c133e3ec099d2bb5f38389e4fd54d3eb516a8a736815b8"}, - {file = "mkdocs_git_revision_date_localized_plugin-1.2.5.tar.gz", hash = "sha256:0c439816d9d0dba48e027d9d074b2b9f1d7cd179f74ba46b51e4da7bb3dc4b9b"}, + {file = "mkdocs_git_revision_date_localized_plugin-1.2.6-py3-none-any.whl", hash = "sha256:f015cb0f3894a39b33447b18e270ae391c4e25275cac5a626e80b243784e2692"}, + {file = "mkdocs_git_revision_date_localized_plugin-1.2.6.tar.gz", hash = "sha256:e432942ce4ee8aa9b9f4493e993dee9d2cc08b3ea2b40a3d6b03ca0f2a4bcaa2"}, ] [package.dependencies] From 8f198e80cde962cb9b018c93ea0b1a1f4cdb7ee3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 01:14:18 +0000 Subject: [PATCH 68/94] :arrow_up: Bump ruff from 0.4.6 to 0.4.7 Bumps [ruff](https://github.com/astral-sh/ruff) from 0.4.6 to 0.4.7. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.4.6...v0.4.7) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/poetry.lock b/poetry.lock index 319db4a3..7e7fbac4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3662,28 +3662,28 @@ files = [ [[package]] name = "ruff" -version = "0.4.6" +version = "0.4.7" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.4.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ef995583a038cd4a7edf1422c9e19118e2511b8ba0b015861b4abd26ec5367c5"}, - {file = "ruff-0.4.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:602ebd7ad909eab6e7da65d3c091547781bb06f5f826974a53dbe563d357e53c"}, - {file = "ruff-0.4.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f9ced5cbb7510fd7525448eeb204e0a22cabb6e99a3cb160272262817d49786"}, - {file = "ruff-0.4.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04a80acfc862e0e1630c8b738e70dcca03f350bad9e106968a8108379e12b31f"}, - {file = "ruff-0.4.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be47700ecb004dfa3fd4dcdddf7322d4e632de3c06cd05329d69c45c0280e618"}, - {file = "ruff-0.4.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1ff930d6e05f444090a0139e4e13e1e2e1f02bd51bb4547734823c760c621e79"}, - {file = "ruff-0.4.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f13410aabd3b5776f9c5699f42b37a3a348d65498c4310589bc6e5c548dc8a2f"}, - {file = "ruff-0.4.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0cf5cc02d3ae52dfb0c8a946eb7a1d6ffe4d91846ffc8ce388baa8f627e3bd50"}, - {file = "ruff-0.4.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea3424793c29906407e3cf417f28fc33f689dacbbadfb52b7e9a809dd535dcef"}, - {file = "ruff-0.4.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1fa8561489fadf483ffbb091ea94b9c39a00ed63efacd426aae2f197a45e67fc"}, - {file = "ruff-0.4.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4d5b914818d8047270308fe3e85d9d7f4a31ec86c6475c9f418fbd1624d198e0"}, - {file = "ruff-0.4.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4f02284335c766678778475e7698b7ab83abaf2f9ff0554a07b6f28df3b5c259"}, - {file = "ruff-0.4.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3a6a0a4f4b5f54fff7c860010ab3dd81425445e37d35701a965c0248819dde7a"}, - {file = "ruff-0.4.6-py3-none-win32.whl", hash = "sha256:9018bf59b3aa8ad4fba2b1dc0299a6e4e60a4c3bc62bbeaea222679865453062"}, - {file = "ruff-0.4.6-py3-none-win_amd64.whl", hash = "sha256:a769ae07ac74ff1a019d6bd529426427c3e30d75bdf1e08bb3d46ac8f417326a"}, - {file = "ruff-0.4.6-py3-none-win_arm64.whl", hash = "sha256:735a16407a1a8f58e4c5b913ad6102722e80b562dd17acb88887685ff6f20cf6"}, - {file = "ruff-0.4.6.tar.gz", hash = "sha256:a797a87da50603f71e6d0765282098245aca6e3b94b7c17473115167d8dfb0b7"}, + {file = "ruff-0.4.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e089371c67892a73b6bb1525608e89a2aca1b77b5440acf7a71dda5dac958f9e"}, + {file = "ruff-0.4.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:10f973d521d910e5f9c72ab27e409e839089f955be8a4c8826601a6323a89753"}, + {file = "ruff-0.4.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59c3d110970001dfa494bcd95478e62286c751126dfb15c3c46e7915fc49694f"}, + {file = "ruff-0.4.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa9773c6c00f4958f73b317bc0fd125295110c3776089f6ef318f4b775f0abe4"}, + {file = "ruff-0.4.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07fc80bbb61e42b3b23b10fda6a2a0f5a067f810180a3760c5ef1b456c21b9db"}, + {file = "ruff-0.4.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fa4dafe3fe66d90e2e2b63fa1591dd6e3f090ca2128daa0be33db894e6c18648"}, + {file = "ruff-0.4.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7c0083febdec17571455903b184a10026603a1de078428ba155e7ce9358c5f6"}, + {file = "ruff-0.4.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad1b20e66a44057c326168437d680a2166c177c939346b19c0d6b08a62a37589"}, + {file = "ruff-0.4.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbf5d818553add7511c38b05532d94a407f499d1a76ebb0cad0374e32bc67202"}, + {file = "ruff-0.4.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:50e9651578b629baec3d1513b2534de0ac7ed7753e1382272b8d609997e27e83"}, + {file = "ruff-0.4.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8874a9df7766cb956b218a0a239e0a5d23d9e843e4da1e113ae1d27ee420877a"}, + {file = "ruff-0.4.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b9de9a6e49f7d529decd09381c0860c3f82fa0b0ea00ea78409b785d2308a567"}, + {file = "ruff-0.4.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:13a1768b0691619822ae6d446132dbdfd568b700ecd3652b20d4e8bc1e498f78"}, + {file = "ruff-0.4.7-py3-none-win32.whl", hash = "sha256:769e5a51df61e07e887b81e6f039e7ed3573316ab7dd9f635c5afaa310e4030e"}, + {file = "ruff-0.4.7-py3-none-win_amd64.whl", hash = "sha256:9e3ab684ad403a9ed1226894c32c3ab9c2e0718440f6f50c7c5829932bc9e054"}, + {file = "ruff-0.4.7-py3-none-win_arm64.whl", hash = "sha256:10f2204b9a613988e3484194c2c9e96a22079206b22b787605c255f130db5ed7"}, + {file = "ruff-0.4.7.tar.gz", hash = "sha256:2331d2b051dc77a289a653fcc6a42cce357087c5975738157cd966590b18b5e1"}, ] [[package]] From a5d4612402462677423784c354e0046ac34d4ac0 Mon Sep 17 00:00:00 2001 From: LinasKo Date: Mon, 3 Jun 2024 10:43:31 +0200 Subject: [PATCH 69/94] Revert Detections.merge to 0.20.0, but filter out empty detections --- supervision/detection/core.py | 19 +++++++++++++------ supervision/detection/utils.py | 20 +++++--------------- test/detection/test_core.py | 26 ++++++++++++++++++++++---- test/detection/test_utils.py | 4 ++-- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index a1239d96..7699ade3 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -928,6 +928,12 @@ class Detections: array([0.1, 0.2, 0.3]) ``` """ + detections_list = [ + detections + for detections in detections_list + if detections != Detections.empty() + ] + if len(detections_list) == 0: return Detections.empty() @@ -946,12 +952,13 @@ class Detections: def stack_or_none(name: str): if all(d.__getattribute__(name) is None for d in detections_list): return None - 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) + 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]) + ) mask = stack_or_none("mask") confidence = stack_or_none("confidence") diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index b36b6853..d712bc65 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -631,6 +631,10 @@ 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: @@ -638,21 +642,7 @@ def merge_data( "All data values within a single object must have equal length." ) - 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(*keys_by_data) - all_keys = set.union(*keys_by_data) - if common_keys != all_keys: - raise ValueError( - 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} + merged_data = {key: [] for key in all_keys_sets[0]} for data in data_list: for key in data: merged_data[key].append(data[key]) diff --git a/test/detection/test_core.py b/test/detection/test_core.py index af1d5876..237e5e08 100644 --- a/test/detection/test_core.py +++ b/test/detection/test_core.py @@ -245,7 +245,6 @@ def test_getitem( TEST_DET_1_2, DoesNotRaise(), ), # Fields with same keys - # Fields and empty ( [TEST_DET_1, Detections.empty()], TEST_DET_1, @@ -264,9 +263,9 @@ def test_getitem( TEST_DET_1, TEST_DET_NONE, ], - TEST_DET_1, - DoesNotRaise(), - ), # Single detection and None fields (+ missing Dict keys) + None, + pytest.raises(ValueError), + ), # Empty detection, but not Detections.empty() # Errors: Non-zero-length differently defined keys & data ( [TEST_DET_1, TEST_DET_DIFFERENT_FIELDS], @@ -278,6 +277,22 @@ def test_getitem( None, pytest.raises(ValueError), ), # Non-empty detections with different data keys + ( + [ + mock_detections( + xyxy=[[10, 10, 20, 20]], + class_id=[1], + mask=[np.zeros((4, 4), dtype=bool)], + ), + Detections.empty(), + ], + mock_detections( + xyxy=[[10, 10, 20, 20]], + class_id=[1], + mask=[np.zeros((4, 4), dtype=bool)], + ), + DoesNotRaise(), + ), # Segmentation + Empty ], ) def test_merge( @@ -285,6 +300,9 @@ def test_merge( expected_result: Optional[Detections], exception: Exception, ) -> None: + print(len(detections_list)) + for det in detections_list: + print(det) with exception: result = Detections.merge(detections_list=detections_list) assert result == expected_result diff --git a/test/detection/test_utils.py b/test/detection/test_utils.py index f0f0a6b1..20c818e6 100644 --- a/test/detection/test_utils.py +++ b/test/detection/test_utils.py @@ -720,8 +720,8 @@ def test_calculate_masks_centroids( ), # two data dicts with the same field name and different length arrays values ( [{}, {"test_1": [1, 2, 3]}], - {"test_1": [1, 2, 3]}, - DoesNotRaise(), + None, + pytest.raises(ValueError), ), # two data dicts; one empty and one non-empty dict ( [{"test_1": [], "test_2": []}, {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}], From 5bfd51fdec38fa163666ea8f538f44db71abcf2c Mon Sep 17 00:00:00 2001 From: LinasKo Date: Mon, 3 Jun 2024 14:01:03 +0200 Subject: [PATCH 70/94] is_empty check that includes data * We need to decide if this or from_inference data appended to empty Detections is correct --- supervision/detection/core.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 7699ade3..0abece68 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -929,9 +929,7 @@ class Detections: ``` """ detections_list = [ - detections - for detections in detections_list - if detections != Detections.empty() + detections for detections in detections_list if not is_empty(detections) ] if len(detections_list) == 0: @@ -1398,3 +1396,9 @@ def validate_fields_both_defined_or_none( f"Field '{attribute}' should be consistently None or not None in both " "Detections." ) + + +def is_empty(detections: Detections) -> bool: + empty_detections = Detections.empty() + empty_detections.data = detections.data + return detections == empty_detections From 56eb8e169819d6f7ed770be725b6f92cf00c728b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 17:47:16 +0000 Subject: [PATCH 71/94] =?UTF-8?q?chore(pre=5Fcommit):=20=E2=AC=86=20pre=5F?= =?UTF-8?q?commit=20autoupdate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.5 → v0.4.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.5...v0.4.7) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c47f2b6..2acdf342 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.5 + rev: v0.4.7 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 38d442c176e02a6da104f7f5c48044ed8e09a0ef Mon Sep 17 00:00:00 2001 From: tc360950 Date: Tue, 4 Jun 2024 21:06:51 +0200 Subject: [PATCH 72/94] PR fixes --- supervision/detection/line_zone.py | 32 ++++++------------------------ supervision/detection/utils.py | 18 +++++++++++++++++ 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/supervision/detection/line_zone.py b/supervision/detection/line_zone.py index 4198135d..a1642aee 100644 --- a/supervision/detection/line_zone.py +++ b/supervision/detection/line_zone.py @@ -5,6 +5,7 @@ import cv2 import numpy as np from supervision.detection.core import Detections +from supervision.detection.utils import cross_product from supervision.draw.color import Color from supervision.draw.utils import draw_text from supervision.geometry.core import Point, Position, Vector @@ -158,15 +159,13 @@ class LineZone: ] ) - cross_products_1 = self._cross_product(all_anchors, self.limits[0]) - cross_products_2 = self._cross_product(all_anchors, self.limits[1]) + cross_products_1 = cross_product(all_anchors, self.limits[0]) + cross_products_2 = cross_product(all_anchors, self.limits[1]) # anchor is in limits if it's on the same side of both limit vectors - in_limits = ~np.logical_xor(cross_products_1 > 0, cross_products_2 > 0) - # Reduce array to find out if all anchors for a detection are within limits - in_limits = np.min(in_limits, axis=0) + in_limits = (cross_products_1 > 0) == (cross_products_2 > 0) + in_limits = np.all(in_limits, axis=0) - # Calculate which anchors lie to the left of the line - triggers = self._cross_product(all_anchors, self.vector) < 0 + triggers = cross_product(all_anchors, self.vector) < 0 has_any_left_trigger = np.any(triggers, axis=0) has_any_right_trigger = np.any(~triggers, axis=0) is_uniformly_triggered = ~(has_any_left_trigger & has_any_right_trigger) @@ -175,8 +174,6 @@ class LineZone: continue if not is_uniformly_triggered[i]: - # One anchor lies to the left of the line - # whilst another lies to the right continue tracker_state = has_any_left_trigger[i] @@ -197,23 +194,6 @@ class LineZone: return crossed_in, crossed_out - @staticmethod - def _cross_product(anchors: np.ndarray, vector: Vector) -> np.ndarray: - """ - Get array of cross products of each anchor with a vector. - Args: - anchors: Array of anchors of shape (number of anchors, detections, 2) - vector: Vector to calculate cross product with - - Returns: - Array of cross products of shape (number of anchors, detections) - """ - vector_at_zero = np.array( - [vector.end.x - vector.start.x, vector.end.y - vector.start.y] - ) - vector_start = np.array([vector.start.x, vector.start.y]) - return np.cross(vector_at_zero, anchors - vector_start) - class LineZoneAnnotator: def __init__( diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index aac0d627..671ac8ac 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -6,6 +6,7 @@ import numpy as np import numpy.typing as npt from supervision.config import CLASS_NAME_DATA_FIELD +from supervision.geometry.core import Vector MIN_POLYGON_POINT_COUNT = 3 @@ -966,3 +967,20 @@ def contains_multiple_segments( mask_uint8, labels, connectivity=connectivity ) return number_of_labels > 2 + + +def cross_product(anchors: np.ndarray, vector: Vector) -> np.ndarray: + """ + Get array of cross products of each anchor with a vector. + Args: + anchors: Array of anchors of shape (number of anchors, detections, 2) + vector: Vector to calculate cross product with + + Returns: + Array of cross products of shape (number of anchors, detections) + """ + vector_at_zero = np.array( + [vector.end.x - vector.start.x, vector.end.y - vector.start.y] + ) + vector_start = np.array([vector.start.x, vector.start.y]) + return np.cross(vector_at_zero, anchors - vector_start) \ No newline at end of file From 8c92072ecd39b7c6191e26cb579418d2c23ec31a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Jun 2024 19:07:57 +0000 Subject: [PATCH 73/94] =?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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index 671ac8ac..5c9447fb 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -983,4 +983,4 @@ def cross_product(anchors: np.ndarray, vector: Vector) -> np.ndarray: [vector.end.x - vector.start.x, vector.end.y - vector.start.y] ) vector_start = np.array([vector.start.x, vector.start.y]) - return np.cross(vector_at_zero, anchors - vector_start) \ No newline at end of file + return np.cross(vector_at_zero, anchors - vector_start) From 24b1db9e2573fead97efca708c83c4d5ebe1ede7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Jun 2024 00:43:32 +0000 Subject: [PATCH 74/94] :arrow_up: Bump pytest from 8.2.1 to 8.2.2 Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.2.1 to 8.2.2. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.2.1...8.2.2) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4bdbe42b..4d1b52ae 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3039,13 +3039,13 @@ tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} [[package]] name = "pytest" -version = "8.2.1" +version = "8.2.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.2.1-py3-none-any.whl", hash = "sha256:faccc5d332b8c3719f40283d0d44aa5cf101cec36f88cde9ed8f2bc0538612b1"}, - {file = "pytest-8.2.1.tar.gz", hash = "sha256:5046e5b46d8e4cac199c373041f26be56fdb81eb4e67dc11d4e10811fc3408fd"}, + {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, + {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, ] [package.dependencies] From c27a28235efb0f97a20001e0dfb89bf0a39aabf9 Mon Sep 17 00:00:00 2001 From: LinasKo Date: Wed, 5 Jun 2024 10:24:25 +0200 Subject: [PATCH 75/94] LineZone: Remove comment --- supervision/detection/line_zone.py | 1 - 1 file changed, 1 deletion(-) diff --git a/supervision/detection/line_zone.py b/supervision/detection/line_zone.py index a1642aee..facb6aff 100644 --- a/supervision/detection/line_zone.py +++ b/supervision/detection/line_zone.py @@ -161,7 +161,6 @@ class LineZone: cross_products_1 = cross_product(all_anchors, self.limits[0]) cross_products_2 = cross_product(all_anchors, self.limits[1]) - # anchor is in limits if it's on the same side of both limit vectors in_limits = (cross_products_1 > 0) == (cross_products_2 > 0) in_limits = np.all(in_limits, axis=0) From 57fa637eea13885d81afd1260389dfd92d3f138e Mon Sep 17 00:00:00 2001 From: LinasKo Date: Wed, 5 Jun 2024 12:18:57 +0200 Subject: [PATCH 76/94] Move is_empty into Detections --- supervision/detection/core.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 0abece68..100d21d0 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -878,6 +878,14 @@ class Detections: class_id=np.array([], dtype=int), ) + def is_empty(self) -> bool: + """ + Returns `True` if the `Detections` object is considered empty. + """ + empty_detections = Detections.empty() + empty_detections.data = self.data + return self == empty_detections + @classmethod def merge(cls, detections_list: List[Detections]) -> Detections: """ @@ -929,7 +937,7 @@ class Detections: ``` """ detections_list = [ - detections for detections in detections_list if not is_empty(detections) + detections for detections in detections_list if not detections.is_empty() ] if len(detections_list) == 0: @@ -1396,9 +1404,3 @@ def validate_fields_both_defined_or_none( f"Field '{attribute}' should be consistently None or not None in both " "Detections." ) - - -def is_empty(detections: Detections) -> bool: - empty_detections = Detections.empty() - empty_detections.data = detections.data - return detections == empty_detections From ca482967c46aade16f32be0a1653e358c1a6bf1a Mon Sep 17 00:00:00 2001 From: LinasKo Date: Wed, 5 Jun 2024 12:28:09 +0200 Subject: [PATCH 77/94] Add note that merge ignores empty detections --- supervision/detection/core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 100d21d0..7b2e8ba9 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -898,6 +898,10 @@ class Detections: 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). + !!! Note + + When merging, empty `Detections` objects are ignored. + Args: detections_list (List[Detections]): A list of Detections objects to merge. From c28c93bd4fa8b96d5f64b7d5cfd1cb530541533d Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Wed, 5 Jun 2024 15:40:28 +0200 Subject: [PATCH 78/94] non-max-merging visualization + `from_lmm` updates --- supervision/detection/core.py | 4 +++- supervision/detection/lmm.py | 2 +- test/detection/test_lmm.py | 22 +++++++++++++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index a1239d96..9fbb357d 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -1222,7 +1222,9 @@ class Detections: Raises: AssertionError: If `confidence` is None or `class_id` is None and class_agnostic is False. - """ + + ![non-max-merging](https://media.roboflow.com/supervision-docs/non-max-merging.png){ align=center width="800" } + """ # noqa: E501 // docs if len(self) == 0: return self diff --git a/supervision/detection/lmm.py b/supervision/detection/lmm.py index 0278fc00..5f61db0a 100644 --- a/supervision/detection/lmm.py +++ b/supervision/detection/lmm.py @@ -41,7 +41,7 @@ def from_paligemma( ) -> Tuple[np.ndarray, Optional[np.ndarray], np.ndarray]: w, h = resolution_wh pattern = re.compile( - r"(?) ([\w\s]+)" + r"(?) ([\w\s\-]+)" ) matches = pattern.findall(result) matches = np.array(matches) if matches else np.empty((0, 5)) diff --git a/test/detection/test_lmm.py b/test/detection/test_lmm.py index 129aa44b..4448d8db 100644 --- a/test/detection/test_lmm.py +++ b/test/detection/test_lmm.py @@ -76,7 +76,27 @@ from supervision.detection.lmm import from_paligemma None, np.array(["black cat"]).astype(np.dtype("U")), ), - ), # correct response; no classes + ), # correct response; class name with space; no classes + ( + " black-cat", + (1000, 1000), + None, + ( + np.array([[250.0, 250.0, 750.0, 750.0]]), + None, + np.array(["black-cat"]).astype(np.dtype("U")), + ), + ), # correct response; class name with hyphen; no classes + ( + " black_cat", + (1000, 1000), + None, + ( + np.array([[250.0, 250.0, 750.0, 750.0]]), + None, + np.array(["black_cat"]).astype(np.dtype("U")), + ), + ), # correct response; class name with underscore; no classes ( " cat ;", (1000, 1000), From ceecb8bbd7666f9a6e882b8332d610805c42ffe6 Mon Sep 17 00:00:00 2001 From: LinasKo Date: Wed, 5 Jun 2024 15:42:00 +0200 Subject: [PATCH 79/94] Cleanup: Remove print statements from tests --- test/detection/test_core.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/detection/test_core.py b/test/detection/test_core.py index 237e5e08..300d6dfe 100644 --- a/test/detection/test_core.py +++ b/test/detection/test_core.py @@ -300,9 +300,6 @@ def test_merge( expected_result: Optional[Detections], exception: Exception, ) -> None: - print(len(detections_list)) - for det in detections_list: - print(det) with exception: result = Detections.merge(detections_list=detections_list) assert result == expected_result From a77a2ba8aa8ed6b05f587e38d6b481b80a31b785 Mon Sep 17 00:00:00 2001 From: LinasKo Date: Wed, 5 Jun 2024 16:27:53 +0200 Subject: [PATCH 80/94] LineZone: uncomment unit tests, set expected results --- test/detection/test_line_counter.py | 71 +++++++++++++---------------- 1 file changed, 32 insertions(+), 39 deletions(-) diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py index 3984be94..66118e97 100644 --- a/test/detection/test_line_counter.py +++ b/test/detection/test_line_counter.py @@ -197,45 +197,38 @@ def test_calculate_region_of_interest_limits( [False, False, False, False], [False, False, False, False], ), - # ( # Diagonal crossing of a straight line - does not work. - # Vector(Point(0, 0), Point(10, 0)), - # [ - # [-4, 4, -2, 8], - # [-4 + 16, -4, -2 + 16, -6], - # [-4, 4, -2, 8], - # [-4 + 16, -4, -2 + 16, -6] - # ], - # [False, False, True, False], - # [False, True, False, True], - # ), - # ( # V-shaped crossing - does not work. - # Vector(Point(0, 0), Point(10, 0)), - # [ - # [-3, 6, -1, 8], - # [4, -6, 6, -4], - # [11, 6, 13, 8] - # ], - # [False, False, True], - # [False, True, False] - # ), - # ( # Diagonal movement, from within limits to outside - does not work - # Vector(Point(0, 0), Point(10, 0)), - # [ - # [4, 1, 6, 3], - # [11, 1-20, 13, 3-20] - # ], - # [False, False], - # [False, True] - # ), - # ( # Diagonal movement, from within outside limits to inside - does not work - # Vector(Point(0, 0), Point(10, 0)), - # [ - # [11, 21, 13, 23], - # [4, -3, 6, -1], - # ], - # [False, False], - # [False, True] - # ) + ( # V-shaped crossing from outside limits - not supported. + Vector(Point(0, 0), Point(10, 0)), + [[-3, 6, -1, 8], [4, -6, 6, -4], [11, 6, 13, 8]], + [False, False, False], + [False, False, False], + ), + ( # Diagonal movement, from within limits to outside - not supported + Vector(Point(0, 0), Point(10, 0)), + [[4, 1, 6, 3], [11, 1 - 20, 13, 3 - 20]], + [False, False], + [False, False], + ), + ( # Diagonal movement, from outside limits to within - not supported + Vector(Point(0, 0), Point(10, 0)), + [ + [11, 21, 13, 23], + [4, -3, 6, -1], + ], + [False, False], + [False, False], + ), + ( # Diagonal crossing, from outside to outside limits - not supported. + Vector(Point(0, 0), Point(10, 0)), + [ + [-4, 4, -2, 8], + [-4 + 16, -4, -2 + 16, -6], + [-4, 4, -2, 8], + [-4 + 16, -4, -2 + 16, -6], + ], + [False, False, False, False], + [False, False, False, False], + ), ], ) def test_line_zone_one_detection_default_anchors( From 3cee266f7111266978117521590626a03cbb26eb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 5 Jun 2024 14:33:06 +0000 Subject: [PATCH 81/94] =?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/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supervision/detection/core.py b/supervision/detection/core.py index 9fbb357d..53d576fb 100644 --- a/supervision/detection/core.py +++ b/supervision/detection/core.py @@ -1222,7 +1222,7 @@ class Detections: Raises: AssertionError: If `confidence` is None or `class_id` is None and class_agnostic is False. - + ![non-max-merging](https://media.roboflow.com/supervision-docs/non-max-merging.png){ align=center width="800" } """ # noqa: E501 // docs if len(self) == 0: From b476acc1f3ac4451fca265277a8967c7c4ef1266 Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Wed, 5 Jun 2024 16:52:18 +0200 Subject: [PATCH 82/94] bump version from `0.21.0rc5` to `0.21.0` --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 640d462e..59d4176d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "supervision" -version = "0.21.0rc5" +version = "0.21.0" description = "A set of easy-to-use utils that will come in handy in any Computer Vision project" authors = ["Piotr Skalski "] maintainers = ["Piotr Skalski "] From 9a42372ff649605e9dd3ea8291bf164cf9b1a1ba Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Wed, 5 Jun 2024 17:37:01 +0200 Subject: [PATCH 83/94] changelog updated --- docs/changelog.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 47d160aa..024edd1a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,83 @@ +### 0.21.0 Jun 5, 2024 + +- Added [#500](https://github.com/roboflow/supervision/pull/500): [`sv.Detections.with_nmm`](https://supervision.roboflow.com/develop/detection/core/#supervision.detection.core.Detections.with_nmm) to perform non-maximum merging on the current set of object detections. + +- Added [#1221](https://github.com/roboflow/supervision/pull/1221): [`sv.Detections.from_lmm`](https://supervision.roboflow.com/develop/detection/core/#supervision.detection.core.Detections.from_lmm) allowing to parse Large Multimodal Model (LMM) text result into [`sv.Detections`](https://supervision.roboflow.com/develop/detection/core/) object. For now `from_lmm` supports only [PaliGemma](https://colab.research.google.com/github/roboflow-ai/notebooks/blob/main/notebooks/how-to-finetune-paligemma-on-detection-dataset.ipynb) result parsing. + +```python +import supervision as sv + +paligemma_result = " cat" +detections = sv.Detections.from_lmm( + sv.LMM.PALIGEMMA, + paligemma_result, + resolution_wh=(1000, 1000), + classes=['cat', 'dog'] +) +detections.xyxy +# array([[250., 250., 750., 750.]]) + +detections.class_id +# array([0]) +``` + +- Added [#1236](https://github.com/roboflow/supervision/pull/1236): [`sv.VertexLabelAnnotator`](https://supervision.roboflow.com/develop/keypoint/annotators/#supervision.keypoint.annotators.EdgeAnnotator.annotate) allowing to annotate every vertex of a keypoint skeleton with custom text and color. + +```python +import supervision as sv + +image = ... +key_points = sv.KeyPoints(...) + +edge_annotator = sv.EdgeAnnotator( + color=sv.Color.GREEN, + thickness=5 +) +annotated_frame = edge_annotator.annotate( + scene=image.copy(), + key_points=key_points +) +``` + +- Added [#1147](https://github.com/roboflow/supervision/pull/1147): [`sv.KeyPoints.from_inference`](https://supervision.roboflow.com/develop/keypoint/core/#supervision.keypoint.core.KeyPoints.from_inference) allowing to create [`sv.KeyPoints`](https://supervision.roboflow.com/develop/keypoint/core/#supervision.keypoint.core.KeyPoints) from [Inference](https://github.com/roboflow/inference) result. + +- Added [#1138](https://github.com/roboflow/supervision/pull/1138): [`sv.KeyPoints.from_yolo_nas`](https://supervision.roboflow.com/develop/keypoint/core/#supervision.keypoint.core.KeyPoints.from_yolo_nas) allowing to create [`sv.KeyPoints`](https://supervision.roboflow.com/develop/keypoint/core/#supervision.keypoint.core.KeyPoints) from [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) result. + +- Added [#1163](https://github.com/roboflow/supervision/pull/1163): [`sv.mask_to_rle`](https://supervision.roboflow.com/develop/datasets/utils/#supervision.dataset.utils.rle_to_mask) and [`sv.rle_to_mask`](https://supervision.roboflow.com/develop/datasets/utils/#supervision.dataset.utils.rle_to_mask) allowing for easy conversion between mask and rle formats. + +- Changed [#1236](https://github.com/roboflow/supervision/pull/1236): [`sv.InferenceSlicer`](https://supervision.roboflow.com/develop/detection/tools/inference_slicer/) allowing to select overlap filtering strategy (`NONE`, `NON_MAX_SUPPRESSION` and `NON_MAX_MERGE`). + +- Changed [#1178](https://github.com/roboflow/supervision/pull/1178): [`sv.InferenceSlicer`](https://supervision.roboflow.com/develop/detection/tools/inference_slicer/) adding instance segmentation model support. + +```python +import cv2 +import numpy as np +import supervision as sv +from inference import get_model + +model = get_model(model_id="yolov8x-seg-640") +image = cv2.imread() + +def callback(image_slice: np.ndarray) -> sv.Detections: + results = model.infer(image_slice)[0] + return sv.Detections.from_inference(results) + +slicer = sv.InferenceSlicer(callback = callback) +detections = slicer(image) + +mask_annotator = sv.MaskAnnotator() +label_annotator = sv.LabelAnnotator() + +annotated_image = mask_annotator.annotate( + scene=image, detections=detections) +annotated_image = label_annotator.annotate( + scene=annotated_image, detections=detections) +``` + +- Changed [#1228](https://github.com/roboflow/supervision/pull/1228): [`sv.LineZone`](https://supervision.roboflow.com/develop/detection/tools/line_zone/) making it 10-20 times faster, depending on the use case. + +- Changed [#1163](https://github.com/roboflow/supervision/pull/1163): [`sv.DetectionDataset.from_coco`](https://supervision.roboflow.com/develop/datasets/core/#supervision.dataset.core.DetectionDataset.from_coco) and [`sv.DetectionDataset.as_coco`](https://supervision.roboflow.com/develop/datasets/core/#supervision.dataset.core.DetectionDataset.as_coco) adding support for run-length encoding (RLE) mask format. + ### 0.20.0 April 24, 2024 - Added [#1128](https://github.com/roboflow/supervision/pull/1128): [`sv.KeyPoints`](/0.20.0/keypoint/core/#supervision.keypoint.core.KeyPoints) to provide initial support for pose estimation and broader keypoint detection models. From a3f299ceb04155c7385ed0772092d07a2cdb1f4f Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Wed, 5 Jun 2024 17:40:39 +0200 Subject: [PATCH 84/94] update docs headers --- mkdocs.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 19d6a4fd..96728971 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,15 +35,15 @@ extra_css: nav: - - Home: index.md - - How to: + - Supervision: index.md + - Learn: - Detect and Annotate: how_to/detect_and_annotate.md - Save Detections: how_to/save_detections.md - Filter Detections: how_to/filter_detections.md - Detect Small Objects: how_to/detect_small_objects.md - Track Objects on Video: how_to/track_objects.md - - API: + - Reference - Code API: - Detection and Segmentation: - Core: detection/core.md - Annotators: detection/annotators.md @@ -79,7 +79,7 @@ nav: - Contributing: contributing.md - Code of Conduct: code_of_conduct.md - License: license.md - - Changelog: + - Release Notes: - Changelog: changelog.md - Deprecated: deprecated.md From 13b46dda67f646b334c3a66535d620ef5e6dc64e Mon Sep 17 00:00:00 2001 From: LinasKo Date: Wed, 5 Jun 2024 17:44:25 +0200 Subject: [PATCH 85/94] Update "New" tags in docs, fix typo in one heading --- docs/cookbooks.md | 1 - docs/detection/annotators.md | 1 - docs/detection/tools/line_zone.md | 1 + docs/detection/tools/save_detections.md | 1 - docs/detection/utils.md | 1 - docs/how_to/detect_and_annotate.md | 1 - docs/how_to/save_detections.md | 1 - docs/trackers.md | 1 - docs/utils/image.md | 3 +-- docs/utils/iterables.md | 1 - 10 files changed, 2 insertions(+), 10 deletions(-) diff --git a/docs/cookbooks.md b/docs/cookbooks.md index dd963edb..6f87958f 100644 --- a/docs/cookbooks.md +++ b/docs/cookbooks.md @@ -1,7 +1,6 @@ --- template: cookbooks.html comments: true -status: new hide: - navigation - toc diff --git a/docs/detection/annotators.md b/docs/detection/annotators.md index 958f2a74..4d912cae 100644 --- a/docs/detection/annotators.md +++ b/docs/detection/annotators.md @@ -1,6 +1,5 @@ --- comments: true -status: new --- # Annotators diff --git a/docs/detection/tools/line_zone.md b/docs/detection/tools/line_zone.md index 8d13822c..22e9c0a8 100644 --- a/docs/detection/tools/line_zone.md +++ b/docs/detection/tools/line_zone.md @@ -1,5 +1,6 @@ --- comments: true +status: new ---
diff --git a/docs/detection/tools/save_detections.md b/docs/detection/tools/save_detections.md index a82ce5df..a24cee57 100644 --- a/docs/detection/tools/save_detections.md +++ b/docs/detection/tools/save_detections.md @@ -1,6 +1,5 @@ --- comments: true -status: new --- # Save Detections diff --git a/docs/detection/utils.md b/docs/detection/utils.md index 369746a3..ea98c868 100644 --- a/docs/detection/utils.md +++ b/docs/detection/utils.md @@ -1,6 +1,5 @@ --- comments: true -status: new --- # Detection Utils diff --git a/docs/how_to/detect_and_annotate.md b/docs/how_to/detect_and_annotate.md index a9a4405e..52e3174b 100644 --- a/docs/how_to/detect_and_annotate.md +++ b/docs/how_to/detect_and_annotate.md @@ -1,6 +1,5 @@ --- comments: true -status: new --- # Detect and Annotate diff --git a/docs/how_to/save_detections.md b/docs/how_to/save_detections.md index 94de6c61..05d5faad 100644 --- a/docs/how_to/save_detections.md +++ b/docs/how_to/save_detections.md @@ -1,6 +1,5 @@ --- comments: true -status: new --- # Save Detections diff --git a/docs/trackers.md b/docs/trackers.md index 47f70061..cb44441f 100644 --- a/docs/trackers.md +++ b/docs/trackers.md @@ -1,6 +1,5 @@ --- comments: true -status: new --- # ByteTrack diff --git a/docs/utils/image.md b/docs/utils/image.md index 8f170d35..8e39136a 100644 --- a/docs/utils/image.md +++ b/docs/utils/image.md @@ -1,6 +1,5 @@ --- comments: true -status: new --- # Image Utils @@ -12,7 +11,7 @@ status: new :::supervision.utils.image.crop_image :::supervision.utils.image.scale_image diff --git a/docs/utils/iterables.md b/docs/utils/iterables.md index b65cd954..5ae92dc9 100644 --- a/docs/utils/iterables.md +++ b/docs/utils/iterables.md @@ -1,6 +1,5 @@ --- comments: true -status: new --- # Iterables Utils From 2d5de66a9baaca93a13c858a624b2fcbe3462f8c Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Wed, 5 Jun 2024 17:53:38 +0200 Subject: [PATCH 86/94] code snippets updates --- supervision/detection/utils.py | 38 +++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py index f39a8e03..38a1bba8 100644 --- a/supervision/detection/utils.py +++ b/supervision/detection/utils.py @@ -155,6 +155,25 @@ def clip_boxes(xyxy: np.ndarray, resolution_wh: Tuple[int, int]) -> np.ndarray: np.ndarray: A numpy array of shape `(N, 4)` where each row corresponds to a bounding box with coordinates clipped to fit within the frame resolution. + + Examples: + ```python + import numpy as np + import supervision as sv + + xyxy = np.array([ + [10, 20, 300, 200], + [15, 25, 350, 450], + [-10, -20, 30, 40] + ]) + + sv.clip_boxes(xyxy=xyxy, resolution_wh=(320, 240)) + # array([ + # [ 10, 20, 300, 200], + # [ 15, 25, 320, 240], + # [ 0, 0, 30, 40] + # ]) + ``` """ result = np.copy(xyxy) width, height = resolution_wh @@ -181,6 +200,23 @@ def pad_boxes(xyxy: np.ndarray, px: int, py: Optional[int] = None) -> np.ndarray np.ndarray: A numpy array of shape `(N, 4)` where each row corresponds to a bounding box with coordinates padded according to the provided padding values. + + Examples: + ```python + import numpy as np + import supervision as sv + + xyxy = np.array([ + [10, 20, 30, 40], + [15, 25, 35, 45] + ]) + + sv.pad_boxes(xyxy=xyxy, px=5, py=10) + # array([ + # [ 5, 10, 35, 50], + # [10, 15, 40, 55] + # ]) + ``` """ if py is None: py = px @@ -553,7 +589,7 @@ def scale_boxes( [30, 30, 40, 40] ]) - scaled_bb = sv.scale_boxes(xyxy=xyxy, factor=1.5) + sv.scale_boxes(xyxy=xyxy, factor=1.5) # array([ # [ 7.5, 7.5, 22.5, 22.5], # [27.5, 27.5, 42.5, 42.5] From 0200fd307bef332cd406abc99ad6fa86de3f75f8 Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Wed, 5 Jun 2024 18:00:54 +0200 Subject: [PATCH 87/94] mark `InferenceSlicer` with new status --- docs/detection/tools/inference_slicer.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/detection/tools/inference_slicer.md b/docs/detection/tools/inference_slicer.md index 5d5d08bc..7a5d3e57 100644 --- a/docs/detection/tools/inference_slicer.md +++ b/docs/detection/tools/inference_slicer.md @@ -1,5 +1,6 @@ --- comments: true +status: new --- # InferenceSlicer From 560760993aaa49fa2b438b9c8bbab0ed1a787d42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Jun 2024 01:31:57 +0000 Subject: [PATCH 88/94] :arrow_up: Bump tox from 4.15.0 to 4.15.1 Bumps [tox](https://github.com/tox-dev/tox) from 4.15.0 to 4.15.1. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/main/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/4.15.0...4.15.1) --- updated-dependencies: - dependency-name: tox dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4d1b52ae..b68ced36 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3948,13 +3948,13 @@ files = [ [[package]] name = "tox" -version = "4.15.0" +version = "4.15.1" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" files = [ - {file = "tox-4.15.0-py3-none-any.whl", hash = "sha256:300055f335d855b2ab1b12c5802de7f62a36d4fd53f30bd2835f6a201dda46ea"}, - {file = "tox-4.15.0.tar.gz", hash = "sha256:7a0beeef166fbe566f54f795b4906c31b428eddafc0102ac00d20998dd1933f6"}, + {file = "tox-4.15.1-py3-none-any.whl", hash = "sha256:f00a5dc4222b358e69694e47e3da0227ac41253509bca9f45aa8f012053e8d9d"}, + {file = "tox-4.15.1.tar.gz", hash = "sha256:53a092527d65e873e39213ebd4bd027a64623320b6b0326136384213f95b7076"}, ] [package.dependencies] From 0739e24b2552d4aba1d2a7a88ea143c8f6046049 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Jun 2024 01:33:50 +0000 Subject: [PATCH 89/94] :arrow_up: Bump ruff from 0.4.7 to 0.4.8 Bumps [ruff](https://github.com/astral-sh/ruff) from 0.4.7 to 0.4.8. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.4.7...v0.4.8) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4d1b52ae..cdb69a75 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3662,28 +3662,28 @@ files = [ [[package]] name = "ruff" -version = "0.4.7" +version = "0.4.8" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.4.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e089371c67892a73b6bb1525608e89a2aca1b77b5440acf7a71dda5dac958f9e"}, - {file = "ruff-0.4.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:10f973d521d910e5f9c72ab27e409e839089f955be8a4c8826601a6323a89753"}, - {file = "ruff-0.4.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59c3d110970001dfa494bcd95478e62286c751126dfb15c3c46e7915fc49694f"}, - {file = "ruff-0.4.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa9773c6c00f4958f73b317bc0fd125295110c3776089f6ef318f4b775f0abe4"}, - {file = "ruff-0.4.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07fc80bbb61e42b3b23b10fda6a2a0f5a067f810180a3760c5ef1b456c21b9db"}, - {file = "ruff-0.4.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fa4dafe3fe66d90e2e2b63fa1591dd6e3f090ca2128daa0be33db894e6c18648"}, - {file = "ruff-0.4.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7c0083febdec17571455903b184a10026603a1de078428ba155e7ce9358c5f6"}, - {file = "ruff-0.4.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad1b20e66a44057c326168437d680a2166c177c939346b19c0d6b08a62a37589"}, - {file = "ruff-0.4.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbf5d818553add7511c38b05532d94a407f499d1a76ebb0cad0374e32bc67202"}, - {file = "ruff-0.4.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:50e9651578b629baec3d1513b2534de0ac7ed7753e1382272b8d609997e27e83"}, - {file = "ruff-0.4.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8874a9df7766cb956b218a0a239e0a5d23d9e843e4da1e113ae1d27ee420877a"}, - {file = "ruff-0.4.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b9de9a6e49f7d529decd09381c0860c3f82fa0b0ea00ea78409b785d2308a567"}, - {file = "ruff-0.4.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:13a1768b0691619822ae6d446132dbdfd568b700ecd3652b20d4e8bc1e498f78"}, - {file = "ruff-0.4.7-py3-none-win32.whl", hash = "sha256:769e5a51df61e07e887b81e6f039e7ed3573316ab7dd9f635c5afaa310e4030e"}, - {file = "ruff-0.4.7-py3-none-win_amd64.whl", hash = "sha256:9e3ab684ad403a9ed1226894c32c3ab9c2e0718440f6f50c7c5829932bc9e054"}, - {file = "ruff-0.4.7-py3-none-win_arm64.whl", hash = "sha256:10f2204b9a613988e3484194c2c9e96a22079206b22b787605c255f130db5ed7"}, - {file = "ruff-0.4.7.tar.gz", hash = "sha256:2331d2b051dc77a289a653fcc6a42cce357087c5975738157cd966590b18b5e1"}, + {file = "ruff-0.4.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7663a6d78f6adb0eab270fa9cf1ff2d28618ca3a652b60f2a234d92b9ec89066"}, + {file = "ruff-0.4.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eeceb78da8afb6de0ddada93112869852d04f1cd0f6b80fe464fd4e35c330913"}, + {file = "ruff-0.4.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aad360893e92486662ef3be0a339c5ca3c1b109e0134fcd37d534d4be9fb8de3"}, + {file = "ruff-0.4.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:284c2e3f3396fb05f5f803c9fffb53ebbe09a3ebe7dda2929ed8d73ded736deb"}, + {file = "ruff-0.4.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7354f921e3fbe04d2a62d46707e569f9315e1a613307f7311a935743c51a764"}, + {file = "ruff-0.4.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:72584676164e15a68a15778fd1b17c28a519e7a0622161eb2debdcdabdc71883"}, + {file = "ruff-0.4.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9678d5c9b43315f323af2233a04d747409d1e3aa6789620083a82d1066a35199"}, + {file = "ruff-0.4.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704977a658131651a22b5ebeb28b717ef42ac6ee3b11e91dc87b633b5d83142b"}, + {file = "ruff-0.4.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05f8d6f0c3cce5026cecd83b7a143dcad503045857bc49662f736437380ad45"}, + {file = "ruff-0.4.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ea874950daca5697309d976c9afba830d3bf0ed66887481d6bca1673fc5b66a"}, + {file = "ruff-0.4.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fc95aac2943ddf360376be9aa3107c8cf9640083940a8c5bd824be692d2216dc"}, + {file = "ruff-0.4.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:384154a1c3f4bf537bac69f33720957ee49ac8d484bfc91720cc94172026ceed"}, + {file = "ruff-0.4.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e9d5ce97cacc99878aa0d084c626a15cd21e6b3d53fd6f9112b7fc485918e1fa"}, + {file = "ruff-0.4.8-py3-none-win32.whl", hash = "sha256:6d795d7639212c2dfd01991259460101c22aabf420d9b943f153ab9d9706e6a9"}, + {file = "ruff-0.4.8-py3-none-win_amd64.whl", hash = "sha256:e14a3a095d07560a9d6769a72f781d73259655919d9b396c650fc98a8157555d"}, + {file = "ruff-0.4.8-py3-none-win_arm64.whl", hash = "sha256:14019a06dbe29b608f6b7cbcec300e3170a8d86efaddb7b23405cb7f7dcaf780"}, + {file = "ruff-0.4.8.tar.gz", hash = "sha256:16d717b1d57b2e2fd68bd0bf80fb43931b79d05a7131aa477d66fc40fbd86268"}, ] [[package]] From bcc1bc1ff2915f33b16580969f0478292fdbe2ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Jun 2024 23:39:07 +0000 Subject: [PATCH 90/94] :arrow_up: Bump tornado from 6.4 to 6.4.1 Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.4 to 6.4.1. - [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst) - [Commits](https://github.com/tornadoweb/tornado/compare/v6.4.0...v6.4.1) --- updated-dependencies: - dependency-name: tornado dependency-type: indirect ... Signed-off-by: dependabot[bot] --- poetry.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/poetry.lock b/poetry.lock index aa6da1e6..3fd66d46 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3928,22 +3928,22 @@ files = [ [[package]] name = "tornado" -version = "6.4" +version = "6.4.1" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." optional = false -python-versions = ">= 3.8" +python-versions = ">=3.8" files = [ - {file = "tornado-6.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:02ccefc7d8211e5a7f9e8bc3f9e5b0ad6262ba2fbb683a6443ecc804e5224ce0"}, - {file = "tornado-6.4-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:27787de946a9cffd63ce5814c33f734c627a87072ec7eed71f7fc4417bb16263"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7894c581ecdcf91666a0912f18ce5e757213999e183ebfc2c3fdbf4d5bd764e"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e43bc2e5370a6a8e413e1e1cd0c91bedc5bd62a74a532371042a18ef19e10579"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0251554cdd50b4b44362f73ad5ba7126fc5b2c2895cc62b14a1c2d7ea32f212"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fd03192e287fbd0899dd8f81c6fb9cbbc69194d2074b38f384cb6fa72b80e9c2"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:88b84956273fbd73420e6d4b8d5ccbe913c65d31351b4c004ae362eba06e1f78"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:71ddfc23a0e03ef2df1c1397d859868d158c8276a0603b96cf86892bff58149f"}, - {file = "tornado-6.4-cp38-abi3-win32.whl", hash = "sha256:6f8a6c77900f5ae93d8b4ae1196472d0ccc2775cc1dfdc9e7727889145c45052"}, - {file = "tornado-6.4-cp38-abi3-win_amd64.whl", hash = "sha256:10aeaa8006333433da48dec9fe417877f8bcc21f48dda8d661ae79da357b2a63"}, - {file = "tornado-6.4.tar.gz", hash = "sha256:72291fa6e6bc84e626589f1c29d90a5a6d593ef5ae68052ee2ef000dfd273dee"}, + {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:163b0aafc8e23d8cdc3c9dfb24c5368af84a81e3364745ccb4427669bf84aec8"}, + {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6d5ce3437e18a2b66fbadb183c1d3364fb03f2be71299e7d10dbeeb69f4b2a14"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e20b9113cd7293f164dc46fffb13535266e713cdb87bd2d15ddb336e96cfc4"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ae50a504a740365267b2a8d1a90c9fbc86b780a39170feca9bcc1787ff80842"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613bf4ddf5c7a95509218b149b555621497a6cc0d46ac341b30bd9ec19eac7f3"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25486eb223babe3eed4b8aecbac33b37e3dd6d776bc730ca14e1bf93888b979f"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:454db8a7ecfcf2ff6042dde58404164d969b6f5d58b926da15e6b23817950fc4"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a02a08cc7a9314b006f653ce40483b9b3c12cda222d6a46d4ac63bb6c9057698"}, + {file = "tornado-6.4.1-cp38-abi3-win32.whl", hash = "sha256:d9a566c40b89757c9aa8e6f032bcdb8ca8795d7c1a9762910c722b1635c9de4d"}, + {file = "tornado-6.4.1-cp38-abi3-win_amd64.whl", hash = "sha256:b24b8982ed444378d7f21d563f4180a2de31ced9d8d84443907a0a64da2072e7"}, + {file = "tornado-6.4.1.tar.gz", hash = "sha256:92d3ab53183d8c50f8204a51e6f91d18a15d5ef261e84d452800d4ff6fc504e9"}, ] [[package]] From 99957716042637b9c3e35d908f3275b33b60b483 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Jun 2024 01:07:22 +0000 Subject: [PATCH 91/94] :arrow_up: Bump mkdocs-material from 9.5.25 to 9.5.26 Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.5.25 to 9.5.26. - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.25...9.5.26) --- updated-dependencies: - dependency-name: mkdocs-material dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index aa6da1e6..9a1efca7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2199,13 +2199,13 @@ pygments = ">2.12.0" [[package]] name = "mkdocs-material" -version = "9.5.25" +version = "9.5.26" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.25-py3-none-any.whl", hash = "sha256:68fdab047a0b9bfbefe79ce267e8a7daaf5128bcf7867065fcd201ee335fece1"}, - {file = "mkdocs_material-9.5.25.tar.gz", hash = "sha256:d0662561efb725b712207e0ee01f035ca15633f29a64628e24f01ec99d7078f4"}, + {file = "mkdocs_material-9.5.26-py3-none-any.whl", hash = "sha256:5d01fb0aa1c7946a1e3ae8689aa2b11a030621ecb54894e35aabb74c21016312"}, + {file = "mkdocs_material-9.5.26.tar.gz", hash = "sha256:56aeb91d94cffa43b6296fa4fbf0eb7c840136e563eecfd12c2d9e92e50ba326"}, ] [package.dependencies] From 165c77d830e818e9bc5cd2f4966e099f83217064 Mon Sep 17 00:00:00 2001 From: mqasim41 Date: Fri, 7 Jun 2024 18:03:12 +0500 Subject: [PATCH 92/94] Fix Grammatical Mistake In CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0003f1aa..d32ab0cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,7 +64,7 @@ To run the pre-commit tool, follow these steps: 3. Run the command `pre-commit run --all-files`. This will execute the pre-commit hooks configured for this project against the modified files. If any issues are found, the pre-commit tool will provide feedback on how to resolve them. Make the necessary changes and re-run the pre-commit command until all issues are resolved. -4. You can also install pre-commit as a git hook by execute `pre-commit install`. Every time you made `git commit` pre-commit run automatically for you. +4. You can also install pre-commit as a git hook by executing `pre-commit install`. Every time you do a `git commit` pre-commit run automatically for you. ### Docstrings From 6f5b477ac0c1af44134623d06ed21c4fd5c9dfb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Jun 2024 01:16:42 +0000 Subject: [PATCH 93/94] :arrow_up: Bump notebook from 7.2.0 to 7.2.1 Bumps [notebook](https://github.com/jupyter/notebook) from 7.2.0 to 7.2.1. - [Release notes](https://github.com/jupyter/notebook/releases) - [Changelog](https://github.com/jupyter/notebook/blob/@jupyter-notebook/tree@7.2.1/CHANGELOG.md) - [Commits](https://github.com/jupyter/notebook/compare/@jupyter-notebook/tree@7.2.0...@jupyter-notebook/tree@7.2.1) --- updated-dependencies: - dependency-name: notebook dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index ef7bde4e..ecad075d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2485,13 +2485,13 @@ setuptools = "*" [[package]] name = "notebook" -version = "7.2.0" +version = "7.2.1" description = "Jupyter Notebook - A web-based notebook environment for interactive computing" optional = false python-versions = ">=3.8" files = [ - {file = "notebook-7.2.0-py3-none-any.whl", hash = "sha256:b4752d7407d6c8872fc505df0f00d3cae46e8efb033b822adacbaa3f1f3ce8f5"}, - {file = "notebook-7.2.0.tar.gz", hash = "sha256:34a2ba4b08ad5d19ec930db7484fb79746a1784be9e1a5f8218f9af8656a141f"}, + {file = "notebook-7.2.1-py3-none-any.whl", hash = "sha256:f45489a3995746f2195a137e0773e2130960b51c9ac3ce257dbc2705aab3a6ca"}, + {file = "notebook-7.2.1.tar.gz", hash = "sha256:4287b6da59740b32173d01d641f763d292f49c30e7a51b89c46ba8473126341e"}, ] [package.dependencies] From 68c2504d61a6546fb4be7ccb02a35ddee6eb3636 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 Jun 2024 17:50:26 +0000 Subject: [PATCH 94/94] =?UTF-8?q?chore(pre=5Fcommit):=20=E2=AC=86=20pre=5F?= =?UTF-8?q?commit=20autoupdate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.7 → v0.4.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.7...v0.4.8) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2acdf342..fb85a294 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.7 + rev: v0.4.8 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix]