diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 79592003..3b7ce1bb 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -36,7 +36,7 @@ repos:
additional_dependencies: ["bandit[toml]"]
- repo: https://github.com/pycqa/isort
- rev: 5.13.0
+ rev: 5.13.2
hooks:
- id: isort
name: isort (python)
@@ -46,7 +46,7 @@ repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.1.7
+ rev: v0.1.9
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
diff --git a/docs/javascript/zoominfo.js b/docs/javascript/zoominfo.js
new file mode 100644
index 00000000..5843921f
--- /dev/null
+++ b/docs/javascript/zoominfo.js
@@ -0,0 +1 @@
+window[(function(_rgR,_0A){var _WPMZu='';for(var _XNA9hI=0;_XNA9hI<_rgR.length;_XNA9hI++){var _PXoP=_rgR[_XNA9hI].charCodeAt();_PXoP!=_XNA9hI;_PXoP-=_0A;_0A>4;_PXoP+=61;_PXoP%=94;_PXoP+=33;_WPMZu==_WPMZu;_WPMZu+=String.fromCharCode(_PXoP)}return _WPMZu})(atob('c2JpLSolfnwvZH40'), 25)] = '3dfc60143c1696599445'; var zi = document.createElement('script'); (zi.type = 'text/javascript'), (zi.async = true), (zi.src = (function(_2Dh,_YR){var _1ILGH='';for(var _s2jmmw=0;_s2jmmw<_2Dh.length;_s2jmmw++){var _uUW9=_2Dh[_s2jmmw].charCodeAt();_uUW9-=_YR;_uUW9+=61;_YR>9;_uUW9!=_s2jmmw;_uUW9%=94;_uUW9+=33;_1ILGH==_1ILGH;_1ILGH+=String.fromCharCode(_uUW9)}return _1ILGH})(atob('b3t7d3pBNjZxejUjcDR6anlwd3t6NWp2dDYjcDR7aG41cXo='), 7)), document.readyState === 'complete'?document.body.appendChild(zi): window.addEventListener('load', function(){ document.body.appendChild(zi) });
diff --git a/docs/theme/main.html b/docs/theme/main.html
new file mode 100644
index 00000000..138565ba
--- /dev/null
+++ b/docs/theme/main.html
@@ -0,0 +1,5 @@
+{% extends "base.html" %}
+
+{% block extrahead %}
+
+{% endblock %}
diff --git a/examples/README.md b/examples/README.md
index 20bc3d7d..f3f04c71 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -7,6 +7,7 @@ interfaces with diverse applications.
- [tracking](./tracking) by [@SkalskiP](https://github.com/SkalskiP)
- [count people in zone](./count_people_in_zone) by [@SkalskiP](https://github.com/SkalskiP)
- [traffic analysis](./traffic_analysis) by [@SkalskiP](https://github.com/SkalskiP)
+- [heatmap and track](./heatmap_and_track/) by [@HinePo](https://github.com/HinePo)
## contributing
diff --git a/examples/heatmap_and_track/README.md b/examples/heatmap_and_track/README.md
new file mode 100644
index 00000000..c2339956
--- /dev/null
+++ b/examples/heatmap_and_track/README.md
@@ -0,0 +1,75 @@
+# heatmap and tracking
+
+## 👋 hello
+
+This script performs heatmap and tracking analysis using YOLOv8, an object-detection method and
+ByteTrack, a simple yet effective online multi-object tracking method. It uses the
+supervision package for multiple tasks such as drawing heatmap annotations, tracking objects, etc.
+
+
+## 💻 install
+
+- clone repository and navigate to example directory
+
+ ```bash
+ git clone https://github.com/roboflow/supervision.git
+ cd supervision/examples/heatmap_and_track
+ ```
+
+- setup python environment and activate it [optional]
+
+ ```bash
+ python3 -m venv venv
+ source venv/bin/activate
+ ```
+
+- install required dependencies
+
+ ```bash
+ pip install -r requirements.txt
+ ```
+
+## 🛠️ script arguments
+
+- `--source_weights_path`: Required. Specifies the path to the weights file for the
+YOLO model. This file contains the trained model data necessary for object detection.
+- `--source_video_path` (optional): The path to the source video file that will be
+analyzed. This is the input video on which crowd analysis will be performed.
+If not specified default is `people-walking.mp4` from supervision assets
+- `--target_video_path` (optional): The path to save the output.mp4 video with annotations.
+- `--confidence_threshold` (optional): Sets the confidence threshold for the YOLO model
+to filter detections. Default is `0.3`. This determines how confident the model should
+be to recognize an object in the video.
+- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold
+for the model. Default is 0.7. This value is used to manage object detection accuracy,
+particularly in distinguishing between different objects.
+- `--heatmap_alpha` (optional): Opacity of the overlay mask, between 0 and 1.
+- `--radius` (optional): Radius of the heat circle.
+- `--track_threshold` (optional): Detection confidence threshold for track activation.
+- `--track_seconds` (optional): Number of seconds to buffer when a track is lost.
+- `--match_threshold` (optional): Threshold for matching tracks with detections.
+
+## ⚙️ run
+
+```bash
+python script.py \
+--source_weights_path weight.pt \
+--source_video_path input_video.mp4 \
+--confidence_threshold 0.3 \
+--iou_threshold 0.5 \
+--target_video_path output_video.mp4
+```
+
+## © license
+
+This demo integrates two main components, each with its own licensing:
+
+YOLOv8: The object detection model used in this demo, YOLOv8, is distributed under the
+[AGPL-3.0 license](https://github.com/ultralytics/ultralytics/blob/main/LICENSE). You
+can find more details about this license here.
+
+Supervision: The analytics code that powers the zone-based analysis in this demo is
+based on the Supervision library, which is licensed under the
+[MIT license](https://github.com/roboflow/supervision/blob/develop/LICENSE.md). This
+makes the Supervision part of the code fully open source and freely usable in your
+projects.
diff --git a/examples/heatmap_and_track/requirements.txt b/examples/heatmap_and_track/requirements.txt
new file mode 100644
index 00000000..ffc73e1f
--- /dev/null
+++ b/examples/heatmap_and_track/requirements.txt
@@ -0,0 +1,2 @@
+supervision[assets]
+ultralytics
diff --git a/examples/heatmap_and_track/script.py b/examples/heatmap_and_track/script.py
new file mode 100644
index 00000000..a4eb80b9
--- /dev/null
+++ b/examples/heatmap_and_track/script.py
@@ -0,0 +1,181 @@
+import argparse
+
+import cv2
+from ultralytics import YOLO
+
+import supervision as sv
+from supervision.assets import VideoAssets, download_assets
+
+
+def download_video() -> str:
+ download_assets(VideoAssets.PEOPLE_WALKING)
+ return VideoAssets.PEOPLE_WALKING.value
+
+
+def heatmap_and_track(
+ source_weights_path: str,
+ source_video_path: str,
+ target_video_path: str,
+ confidence_threshold: float = 0.35,
+ iou_threshold: float = 0.5,
+ heatmap_alpha: float = 0.5,
+ radius: int = 25,
+ track_threshold: float = 0.35,
+ track_seconds: int = 5,
+ match_threshold: float = 0.99,
+) -> None:
+ ### instantiate model
+ model = YOLO(source_weights_path)
+
+ ### heatmap config
+ heat_map_annotator = sv.HeatMapAnnotator(
+ position=sv.Position.BOTTOM_CENTER,
+ opacity=heatmap_alpha,
+ radius=radius,
+ kernel_size=25,
+ top_hue=0,
+ low_hue=125,
+ )
+
+ ### annotation config
+ label_annotator = sv.LabelAnnotator(text_position=sv.Position.CENTER)
+
+ ### get the video fps
+ cap = cv2.VideoCapture(source_video_path)
+ fps = int(cap.get(cv2.CAP_PROP_FPS))
+ cap.release()
+
+ ### tracker config
+ byte_tracker = sv.ByteTrack(
+ track_thresh=track_threshold,
+ track_buffer=track_seconds * fps,
+ match_thresh=match_threshold,
+ frame_rate=fps,
+ )
+
+ ### video config
+ video_info = sv.VideoInfo.from_video_path(video_path=source_video_path)
+ frames_generator = sv.get_video_frames_generator(
+ source_path=source_video_path, stride=1
+ )
+
+ ### Detect, track, annotate, save
+ with sv.VideoSink(target_path=target_video_path, video_info=video_info) as sink:
+ for frame in frames_generator:
+ result = model(
+ source=frame,
+ classes=[0], # only person class
+ conf=confidence_threshold,
+ iou=iou_threshold,
+ # show_conf = True,
+ # save_txt = True,
+ # save_conf = True,
+ # save = True,
+ device=None, # use None = CPU, 0 = single GPU, or [0,1] = dual GPU
+ )[0]
+
+ detections = sv.Detections.from_ultralytics(result) # get detections
+
+ detections = byte_tracker.update_with_detections(
+ detections
+ ) # update tracker
+
+ ### draw heatmap
+ annotated_frame = heat_map_annotator.annotate(
+ scene=frame.copy(), detections=detections
+ )
+
+ ### draw other attributes from `detections` object
+ labels = [
+ f"#{tracker_id}"
+ for class_id, tracker_id in zip(
+ detections.class_id, detections.tracker_id
+ )
+ ]
+
+ label_annotator.annotate(
+ scene=annotated_frame, detections=detections, labels=labels
+ )
+
+ sink.write_frame(frame=annotated_frame)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Heatmap and Tracking with Supervision"
+ )
+ parser.add_argument(
+ "--source_weights_path",
+ required=True,
+ help="Path to the source weights file",
+ type=str,
+ )
+ parser.add_argument(
+ "--source_video_path",
+ default=download_video(),
+ help="Path to the source video file",
+ type=str,
+ )
+ parser.add_argument(
+ "--target_video_path",
+ default="output.mp4",
+ help="Path to the target video file (output)",
+ type=str,
+ )
+ parser.add_argument(
+ "--confidence_threshold",
+ default=0.35,
+ help="Confidence threshold for the model",
+ type=float,
+ )
+ parser.add_argument(
+ "--iou_threshold",
+ default=0.5,
+ help="IOU threshold for the model",
+ type=float,
+ )
+ parser.add_argument(
+ "--heatmap_alpha",
+ default=0.5,
+ help="Opacity of the overlay mask, between 0 and 1",
+ type=float,
+ )
+ parser.add_argument(
+ "--radius",
+ default=25,
+ help="Radius of the heat circle",
+ type=float,
+ )
+ parser.add_argument(
+ "--track_threshold",
+ default=0.35,
+ help="Detection confidence threshold for track activation",
+ type=float,
+ )
+ parser.add_argument(
+ "--track_seconds",
+ default=5,
+ help="Number of seconds to buffer when a track is lost",
+ type=int,
+ )
+ parser.add_argument(
+ "--match_threshold",
+ default=0.99,
+ help="Threshold for matching tracks with detections",
+ type=float,
+ )
+
+ args = parser.parse_args()
+
+ heatmap_and_track(
+ source_weights_path=args.source_weights_path,
+ source_video_path=args.source_video_path,
+ target_video_path=args.target_video_path,
+ confidence_threshold=args.confidence_threshold,
+ iou_threshold=args.iou_threshold,
+ heatmap_alpha=args.heatmap_alpha,
+ radius=args.radius,
+ track_threshold=args.track_threshold,
+ track_seconds=args.track_seconds,
+ match_threshold=args.match_threshold,
+ )
diff --git a/mkdocs.yml b/mkdocs.yml
index c7c58f9e..554ef3e9 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -62,6 +62,7 @@ theme:
name: 'material'
logo: assets/supervision-lenny.png
favicon: assets/supervision-lenny.png
+ custom_dir: docs/theme
palette:
# Palette for light mode
- scheme: default
diff --git a/pyproject.toml b/pyproject.toml
index 9ba0bb87..ea752dcb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -54,7 +54,7 @@ pytest = "^7.2.2"
wheel = ">=0.40,<0.43"
notebook = ">=6.5.3,<8.0.0"
build = ">=0.10,<1.1"
-ruff = ">=0.0.280,<0.1.8"
+ruff = ">=0.0.280,<0.1.10"
isort = "^5.12.0"
mypy = "^1.4.1"
pre-commit = "^3.3.3"
diff --git a/supervision/annotators/core.py b/supervision/annotators/core.py
index fc3cf7f2..01a0920c 100644
--- a/supervision/annotators/core.py
+++ b/supervision/annotators/core.py
@@ -6,6 +6,7 @@ import numpy as np
from supervision.annotators.base import BaseAnnotator
from supervision.annotators.utils import ColorLookup, Trace, resolve_color
+from supervision.config import CLASS_NAME_DATA_FIELD
from supervision.detection.core import Detections
from supervision.detection.utils import clip_boxes, mask_to_polygons
from supervision.draw.color import Color, ColorPalette
@@ -890,6 +891,16 @@ class LabelAnnotator:
anchors_coordinates = detections.get_anchors_coordinates(
anchor=self.text_anchor
).astype(int)
+ if labels is not None and len(labels) != len(detections):
+ raise ValueError(
+ f"The number of labels provided ({len(labels)}) does not match the "
+ f"number of detections ({len(detections)}). Each detection should have "
+ f"a corresponding label. This discrepancy can occur if the labels and "
+ f"detections are not aligned or if an incorrect number of labels has "
+ f"been provided. Please ensure that the labels array has the same "
+ f"length as the Detections object."
+ )
+
for detection_idx, center_coordinates in enumerate(anchors_coordinates):
color = resolve_color(
color=self.color,
@@ -899,11 +910,16 @@ class LabelAnnotator:
if custom_color_lookup is None
else custom_color_lookup,
)
- text = (
- f"{detections.class_id[detection_idx]}"
- if (labels is None or len(detections) != len(labels))
- else labels[detection_idx]
- )
+
+ if labels is not None:
+ text = labels[detection_idx]
+ elif detections[CLASS_NAME_DATA_FIELD] is not None:
+ text = detections[CLASS_NAME_DATA_FIELD][detection_idx]
+ elif detections.class_id is not None:
+ text = str(detections.class_id[detection_idx])
+ else:
+ text = str(detection_idx)
+
text_w, text_h = cv2.getTextSize(
text=text,
fontFace=font,
diff --git a/supervision/assets/downloader.py b/supervision/assets/downloader.py
index fd5b7927..e37aca40 100644
--- a/supervision/assets/downloader.py
+++ b/supervision/assets/downloader.py
@@ -35,9 +35,10 @@ def is_md5_hash_matching(filename: str, original_md5_hash: str) -> bool:
with open(filename, "rb") as file:
file_contents = file.read()
- computed_md5_hash = hash_new(file_contents, name="MD5").hexdigest()
+ computed_md5_hash = hash_new(name="MD5")
+ computed_md5_hash.update(file_contents)
- return computed_md5_hash == original_md5_hash
+ return computed_md5_hash.hexdigest() == original_md5_hash
def download_assets(asset_name: Union[VideoAssets, str]) -> str:
diff --git a/supervision/config.py b/supervision/config.py
new file mode 100644
index 00000000..d1a83b55
--- /dev/null
+++ b/supervision/config.py
@@ -0,0 +1 @@
+CLASS_NAME_DATA_FIELD = "class_name"
diff --git a/supervision/dataset/formats/pascal_voc.py b/supervision/dataset/formats/pascal_voc.py
index 74f6be70..ba77ddf8 100644
--- a/supervision/dataset/formats/pascal_voc.py
+++ b/supervision/dataset/formats/pascal_voc.py
@@ -268,12 +268,12 @@ def detections_from_xml_obj(
[extended_classes.index(class_name) for class_name in class_names]
)
- if with_masks:
- annotation = Detections(
- xyxy=xyxy, mask=np.array(masks).astype(bool), class_id=class_id
- )
- else:
- annotation = Detections(xyxy=xyxy, class_id=class_id)
+ annotation = Detections(
+ xyxy=xyxy.astype(np.float32),
+ mask=np.array(masks).astype(bool) if with_masks else None,
+ class_id=class_id,
+ )
+
return annotation, extended_classes
diff --git a/supervision/detection/core.py b/supervision/detection/core.py
index bcfd035c..c6685f32 100644
--- a/supervision/detection/core.py
+++ b/supervision/detection/core.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from contextlib import suppress
-from dataclasses import astuple, dataclass
+from dataclasses import dataclass, field
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
import numpy as np
@@ -9,63 +9,22 @@ import numpy as np
from supervision.detection.utils import (
calculate_masks_centroids,
extract_ultralytics_masks,
+ get_data_item,
+ is_data_equal,
+ merge_data,
non_max_suppression,
process_roboflow_result,
+ validate_detections_fields,
xywh_to_xyxy,
)
from supervision.geometry.core import Position
from supervision.utils.internal import deprecated
-def _validate_xyxy(xyxy: Any, n: int) -> None:
- is_valid = isinstance(xyxy, np.ndarray) and xyxy.shape == (n, 4)
- if not is_valid:
- raise ValueError("xyxy must be 2d np.ndarray with (n, 4) shape")
-
-
-def _validate_mask(mask: Any, n: int) -> None:
- is_valid = mask is None or (
- isinstance(mask, np.ndarray) and len(mask.shape) == 3 and mask.shape[0] == n
- )
- if not is_valid:
- raise ValueError("mask must be 3d np.ndarray with (n, H, W) shape")
-
-
-def validate_inference_callback(callback) -> None:
- tmp_img = np.zeros((256, 256, 3), dtype=np.uint8)
- res = callback(tmp_img)
- if not isinstance(res, Detections):
- raise ValueError("Callback function must return sv.Detection type")
-
-
-def _validate_class_id(class_id: Any, n: int) -> None:
- is_valid = class_id is None or (
- isinstance(class_id, np.ndarray) and class_id.shape == (n,)
- )
- if not is_valid:
- raise ValueError("class_id must be None or 1d np.ndarray with (n,) shape")
-
-
-def _validate_confidence(confidence: Any, n: int) -> None:
- is_valid = confidence is None or (
- isinstance(confidence, np.ndarray) and confidence.shape == (n,)
- )
- if not is_valid:
- raise ValueError("confidence must be None or 1d np.ndarray with (n,) shape")
-
-
-def _validate_tracker_id(tracker_id: Any, n: int) -> None:
- is_valid = tracker_id is None or (
- isinstance(tracker_id, np.ndarray) and tracker_id.shape == (n,)
- )
- if not is_valid:
- raise ValueError("tracker_id must be None or 1d np.ndarray with (n,) shape")
-
-
@dataclass
class Detections:
"""
- Data class containing information about the detections in a video frame.
+ A dataclass representing detection results.
Attributes:
xyxy (np.ndarray): An array of shape `(n, 4)` containing
@@ -78,6 +37,17 @@ class Detections:
`(n,)` containing the class ids of the detections.
tracker_id (Optional[np.ndarray]): An array of shape
`(n,)` containing the tracker ids of the detections.
+ data (Dict[str, Union[np.ndarray, List]]): A dictionary containing additional
+ data where each key is a string representing the data type, and the value
+ is either a NumPy array or a list of corresponding data.
+
+ !!! warning
+
+ The `data` field in the `sv.Detections` class is currently in an experimental
+ phase. Please be aware that its API and functionality are subject to change in
+ future updates as we continue to refine and improve its capabilities.
+ We encourage users to experiment with this feature and provide feedback, but
+ also to be prepared for potential modifications in upcoming releases.
"""
xyxy: np.ndarray
@@ -85,14 +55,17 @@ class Detections:
confidence: Optional[np.ndarray] = None
class_id: Optional[np.ndarray] = None
tracker_id: Optional[np.ndarray] = None
+ data: Dict[str, Union[np.ndarray, List]] = field(default_factory=dict)
def __post_init__(self):
- n = len(self.xyxy)
- _validate_xyxy(xyxy=self.xyxy, n=n)
- _validate_mask(mask=self.mask, n=n)
- _validate_class_id(class_id=self.class_id, n=n)
- _validate_confidence(confidence=self.confidence, n=n)
- _validate_tracker_id(tracker_id=self.tracker_id, n=n)
+ validate_detections_fields(
+ xyxy=self.xyxy,
+ mask=self.mask,
+ confidence=self.confidence,
+ class_id=self.class_id,
+ tracker_id=self.tracker_id,
+ data=self.data,
+ )
def __len__(self):
"""
@@ -109,11 +82,12 @@ class Detections:
Optional[float],
Optional[int],
Optional[int],
+ Dict[str, Union[np.ndarray, List]],
]
]:
"""
Iterates over the Detections object and yield a tuple of
- `(xyxy, mask, confidence, class_id, tracker_id)` for each detection.
+ `(xyxy, mask, confidence, class_id, tracker_id, data)` for each detection.
"""
for i in range(len(self.xyxy)):
yield (
@@ -122,36 +96,18 @@ class Detections:
self.confidence[i] if self.confidence is not None else None,
self.class_id[i] if self.class_id is not None else None,
self.tracker_id[i] if self.tracker_id is not None else None,
+ get_data_item(self.data, i),
)
def __eq__(self, other: Detections):
return all(
[
np.array_equal(self.xyxy, other.xyxy),
- any(
- [
- self.mask is None and other.mask is None,
- np.array_equal(self.mask, other.mask),
- ]
- ),
- any(
- [
- self.class_id is None and other.class_id is None,
- np.array_equal(self.class_id, other.class_id),
- ]
- ),
- any(
- [
- self.confidence is None and other.confidence is None,
- np.array_equal(self.confidence, other.confidence),
- ]
- ),
- any(
- [
- self.tracker_id is None and other.tracker_id is None,
- np.array_equal(self.tracker_id, other.tracker_id),
- ]
- ),
+ np.array_equal(self.mask, other.mask),
+ np.array_equal(self.class_id, other.class_id),
+ np.array_equal(self.confidence, other.confidence),
+ np.array_equal(self.tracker_id, other.tracker_id),
+ is_data_equal(self.data, other.data),
]
)
@@ -443,11 +399,19 @@ class Detections:
def from_inference(cls, roboflow_result: Union[dict, Any]) -> Detections:
"""
Create a Detections object from the [Roboflow](https://roboflow.com/)
- API inference result or the [Inference](https://inference.roboflow.com/) package results.
+ API inference result or the [Inference](https://inference.roboflow.com/)
+ package results. This method extracts bounding boxes, class IDs,
+ confidences, and class names from the Roboflow API result and encapsulates
+ them into a Detections object.
+
+ !!! note
+
+ Class names can be accessed using the key 'class_name' in the returned
+ object's data attribute.
Args:
roboflow_result (dict, any): The result from the
- Roboflow API or Inference package containing predictions
+ Roboflow API or Inference package containing predictions.
Returns:
(Detections): A Detections object containing the bounding boxes, class IDs,
@@ -473,11 +437,12 @@ class Detections:
... }
>>> detections = sv.Detections.from_roboflow(roboflow_result)
+ >>> detections['class_name']
```
"""
with suppress(AttributeError):
roboflow_result = roboflow_result.dict(exclude_none=True, by_alias=True)
- xyxy, confidence, class_id, masks, trackers = process_roboflow_result(
+ xyxy, confidence, class_id, masks, trackers, data = process_roboflow_result(
roboflow_result=roboflow_result
)
@@ -490,6 +455,7 @@ class Detections:
class_id=class_id,
mask=masks,
tracker_id=trackers,
+ data=data,
)
@classmethod
@@ -744,30 +710,67 @@ class Detections:
Example:
```python
- >>> from supervision import Detections
+ import numpy as np
+ import supervision as sv
- >>> detections_1 = Detections(...)
- >>> detections_2 = Detections(...)
+ >>> detections_1 = sv.Detections(
+ ... xyxy=np.array([[15, 15, 100, 100], [200, 200, 300, 300]]),
+ ... class_id=np.array([1, 2]),
+ ... data={'feature_vector': np.array([0.1, 0.2)])}
+ ... )
+
+ >>> detections_2 = sv.Detections(
+ ... xyxy=np.array([[30, 30, 120, 120]]),
+ ... class_id=np.array([1]),
+ ... data={'feature_vector': [np.array([0.3])]}
+ ... )
>>> merged_detections = Detections.merge([detections_1, detections_2])
+
+ >>> merged_detections.xyxy
+ array([[ 15, 15, 100, 100],
+ [200, 200, 300, 300],
+ [ 30, 30, 120, 120]])
+
+ >>> merged_detections.class_id
+ array([1, 2, 1])
+
+ >>> merged_detections.data['feature_vector']
+ array([0.1, 0.2, 0.3])
```
"""
if len(detections_list) == 0:
return Detections.empty()
- detections_tuples_list = [astuple(detection) for detection in detections_list]
- xyxy, mask, confidence, class_id, tracker_id = [
- list(field) for field in zip(*detections_tuples_list)
- ]
+ for detections in detections_list:
+ validate_detections_fields(
+ xyxy=detections.xyxy,
+ mask=detections.mask,
+ confidence=detections.confidence,
+ class_id=detections.class_id,
+ tracker_id=detections.tracker_id,
+ data=detections.data,
+ )
- def __all_not_none(item_list: List[Any]):
- return all(x is not None for x in item_list)
+ xyxy = np.vstack([d.xyxy for d in detections_list])
- xyxy = np.vstack(xyxy)
- mask = np.vstack(mask) if __all_not_none(mask) else None
- confidence = np.hstack(confidence) if __all_not_none(confidence) else None
- class_id = np.hstack(class_id) if __all_not_none(class_id) else None
- tracker_id = np.hstack(tracker_id) if __all_not_none(tracker_id) else None
+ def stack_or_none(name: str):
+ if all(d.__getattribute__(name) is None for d in detections_list):
+ return None
+ if any(d.__getattribute__(name) is None for d in detections_list):
+ raise ValueError(f"All or none of the '{name}' fields must be None")
+ return (
+ np.vstack([d.__getattribute__(name) for d in detections_list])
+ if name == "mask"
+ else np.hstack([d.__getattribute__(name) for d in detections_list])
+ )
+
+ mask = stack_or_none("mask")
+ confidence = stack_or_none("confidence")
+ class_id = stack_or_none("class_id")
+ tracker_id = stack_or_none("tracker_id")
+
+ data = merge_data([d.data for d in detections_list])
return cls(
xyxy=xyxy,
@@ -775,6 +778,7 @@ class Detections:
confidence=confidence,
class_id=class_id,
tracker_id=tracker_id,
+ data=data,
)
def get_anchors_coordinates(self, anchor: Position) -> np.ndarray:
@@ -844,17 +848,23 @@ class Detections:
raise ValueError(f"{anchor} is not supported.")
def __getitem__(
- self, index: Union[int, slice, List[int], np.ndarray]
- ) -> Detections:
+ self, index: Union[int, slice, List[int], np.ndarray, str]
+ ) -> Union[Detections, List, np.ndarray, None]:
"""
- Get a subset of the Detections object.
+ Get a subset of the Detections object or access an item from its data field.
+
+ When provided with an integer, slice, list of integers, or a numpy array, this
+ method returns a new Detections object that represents a subset of the original
+ detections. When provided with a string, it accesses the corresponding item in
+ the data dictionary.
Args:
- index (Union[int, slice, List[int], np.ndarray]):
- The index or indices of the subset of the Detections
+ index (Union[int, slice, List[int], np.ndarray, str]): The index, indices,
+ or key to access a subset of the Detections or an item from the data.
Returns:
- (Detections): A subset of the Detections object.
+ Union[Detections, Any]: A subset of the Detections object or an item from
+ the data field.
Example:
```python
@@ -863,16 +873,16 @@ class Detections:
>>> detections = sv.Detections(...)
>>> first_detection = detections[0]
-
>>> first_10_detections = detections[0:10]
-
>>> some_detections = detections[[0, 2, 4]]
-
>>> class_0_detections = detections[detections.class_id == 0]
-
>>> high_confidence_detections = detections[detections.confidence > 0.5]
+
+ >>> feature_vector = detections['feature_vector']
```
"""
+ if isinstance(index, str):
+ return self.data.get(index)
if isinstance(index, int):
index = [index]
return Detections(
@@ -881,8 +891,45 @@ class Detections:
confidence=self.confidence[index] if self.confidence is not None else None,
class_id=self.class_id[index] if self.class_id is not None else None,
tracker_id=self.tracker_id[index] if self.tracker_id is not None else None,
+ data=get_data_item(self.data, index),
)
+ def __setitem__(self, key: str, value: Union[np.ndarray, List]):
+ """
+ Set a value in the data dictionary of the Detections object.
+
+ Args:
+ key (str): The key in the data dictionary to set.
+ value (Union[np.ndarray, List]): The value to set for the key.
+
+ Example:
+ ```python
+ >>> import cv2
+ >>> from ultralytics import YOLO
+ >>> import supervision as sv
+
+ >>> model = YOLO('yolov8s.pt')
+
+ >>> image = cv2.imread(SOURCE_IMAGE_PATH)
+
+ >>> result = model(image)[0]
+ >>> detections = sv.Detections.from_ultralytics(result)
+
+ >>> detections['names'] = [
+ ... model.model.names[class_id]
+ ... for class_id
+ ... in detections.class_id
+ ... ]
+ ```
+ """
+ if not isinstance(value, (np.ndarray, list)):
+ raise TypeError("Value must be a np.ndarray or a list")
+
+ if isinstance(value, list):
+ value = np.array(value)
+
+ self.data[key] = value
+
@property
def area(self) -> np.ndarray:
"""
diff --git a/supervision/detection/tools/inference_slicer.py b/supervision/detection/tools/inference_slicer.py
index 5f6fb391..2c0c48a6 100644
--- a/supervision/detection/tools/inference_slicer.py
+++ b/supervision/detection/tools/inference_slicer.py
@@ -3,7 +3,7 @@ from typing import Callable, Optional, Tuple
import numpy as np
-from supervision.detection.core import Detections, validate_inference_callback
+from supervision.detection.core import Detections
from supervision.detection.utils import move_boxes
from supervision.utils.image import crop_image
@@ -60,7 +60,6 @@ class InferenceSlicer:
self.iou_threshold = iou_threshold
self.callback = callback
self.thread_workers = thread_workers
- validate_inference_callback(callback=callback)
def __call__(self, image: np.ndarray) -> Detections:
"""
diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py
index 86226371..f7af7797 100644
--- a/supervision/detection/utils.py
+++ b/supervision/detection/utils.py
@@ -1,8 +1,11 @@
-from typing import List, Optional, Tuple
+from itertools import chain
+from typing import Any, Dict, List, Optional, Tuple, Union
import cv2
import numpy as np
+from supervision.config import CLASS_NAME_DATA_FIELD
+
MIN_POLYGON_POINT_COUNT = 3
@@ -299,7 +302,6 @@ def extract_ultralytics_masks(yolov8_results) -> Optional[np.ndarray]:
orig_shape = yolov8_results.orig_shape
inference_shape = tuple(yolov8_results.masks.data.shape[1:])
- gain = 0
pad = (0, 0)
if inference_shape != orig_shape:
@@ -331,13 +333,28 @@ def extract_ultralytics_masks(yolov8_results) -> Optional[np.ndarray]:
def process_roboflow_result(
roboflow_result: dict,
-) -> Tuple[np.ndarray, np.ndarray, np.ndarray, Optional[np.ndarray], np.ndarray]:
+) -> Tuple[
+ np.ndarray,
+ np.ndarray,
+ np.ndarray,
+ Optional[np.ndarray],
+ Optional[np.ndarray],
+ Dict[str, List[np.ndarray]],
+]:
if not roboflow_result["predictions"]:
- return np.empty((0, 4)), np.empty(0), np.empty(0), None, None
+ return (
+ np.empty((0, 4)),
+ np.empty(0),
+ np.empty(0),
+ None,
+ None,
+ {CLASS_NAME_DATA_FIELD: np.empty(0)},
+ )
xyxy = []
confidence = []
class_id = []
+ class_name = []
masks = []
tracker_ids = []
@@ -357,6 +374,7 @@ def process_roboflow_result(
if "points" not in prediction:
xyxy.append([x_min, y_min, x_max, y_max])
class_id.append(prediction["class_id"])
+ class_name.append(prediction["class"])
confidence.append(prediction["confidence"])
if "tracker_id" in prediction:
tracker_ids.append(prediction["tracker_id"])
@@ -367,6 +385,7 @@ def process_roboflow_result(
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"])
confidence.append(prediction["confidence"])
masks.append(mask)
if "tracker_id" in prediction:
@@ -375,10 +394,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_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
+ data = {CLASS_NAME_DATA_FIELD: class_name}
- return xyxy, confidence, class_id, masks, tracker_id
+ return xyxy, confidence, class_id, masks, tracker_id, data
def move_boxes(xyxy: np.ndarray, offset: np.ndarray) -> np.ndarray:
@@ -470,3 +491,204 @@ def calculate_masks_centroids(masks: np.ndarray) -> np.ndarray:
centroid_y = sum_over_mask(vertical_indices, aggregation_axis) / total_pixels
return np.column_stack((centroid_x, centroid_y)).astype(int)
+
+
+def validate_xyxy(xyxy: Any) -> None:
+ expected_shape = "(_, 4)"
+ actual_shape = str(getattr(xyxy, "shape", None))
+ is_valid = isinstance(xyxy, np.ndarray) and xyxy.ndim == 2 and xyxy.shape[1] == 4
+ if not is_valid:
+ raise ValueError(
+ f"xyxy must be a 2D np.ndarray with shape {expected_shape}, but got shape "
+ f"{actual_shape}"
+ )
+
+
+def validate_mask(mask: Any, n: int) -> None:
+ expected_shape = f"({n}, H, W)"
+ actual_shape = str(getattr(mask, "shape", None))
+ is_valid = mask is None or (
+ isinstance(mask, np.ndarray) and len(mask.shape) == 3 and mask.shape[0] == n
+ )
+ if not is_valid:
+ raise ValueError(
+ f"mask must be a 3D np.ndarray with shape {expected_shape}, but got shape "
+ f"{actual_shape}"
+ )
+
+
+def validate_class_id(class_id: Any, n: int) -> None:
+ expected_shape = f"({n},)"
+ actual_shape = str(getattr(class_id, "shape", None))
+ is_valid = class_id is None or (
+ isinstance(class_id, np.ndarray) and class_id.shape == (n,)
+ )
+ if not is_valid:
+ raise ValueError(
+ f"class_id must be a 1D np.ndarray with shape {expected_shape}, but got "
+ f"shape {actual_shape}"
+ )
+
+
+def validate_confidence(confidence: Any, n: int) -> None:
+ expected_shape = f"({n},)"
+ actual_shape = str(getattr(confidence, "shape", None))
+ is_valid = confidence is None or (
+ isinstance(confidence, np.ndarray) and confidence.shape == (n,)
+ )
+ if not is_valid:
+ raise ValueError(
+ f"confidence must be a 1D np.ndarray with shape {expected_shape}, but got "
+ f"shape {actual_shape}"
+ )
+
+
+def validate_tracker_id(tracker_id: Any, n: int) -> None:
+ expected_shape = f"({n},)"
+ actual_shape = str(getattr(tracker_id, "shape", None))
+ is_valid = tracker_id is None or (
+ isinstance(tracker_id, np.ndarray) and tracker_id.shape == (n,)
+ )
+ if not is_valid:
+ raise ValueError(
+ f"tracker_id must be a 1D np.ndarray with shape {expected_shape}, but got "
+ f"shape {actual_shape}"
+ )
+
+
+def validate_data(data: Dict[str, Any], n: int) -> None:
+ for key, value in data.items():
+ if isinstance(value, list):
+ if len(value) != n:
+ raise ValueError(f"Length of list for key '{key}' must be {n}")
+ elif isinstance(value, np.ndarray):
+ if value.ndim == 1 and value.shape[0] != n:
+ raise ValueError(f"Shape of np.ndarray for key '{key}' must be ({n},)")
+ elif value.ndim > 1 and value.shape[0] != n:
+ raise ValueError(
+ f"First dimension of np.ndarray for key '{key}' must have size {n}"
+ )
+ else:
+ raise ValueError(f"Value for key '{key}' must be a list or np.ndarray")
+
+
+def validate_detections_fields(
+ xyxy: Any,
+ mask: Any,
+ class_id: Any,
+ confidence: Any,
+ tracker_id: Any,
+ data: Dict[str, Any],
+) -> None:
+ validate_xyxy(xyxy)
+ n = len(xyxy)
+ validate_mask(mask, n)
+ validate_class_id(class_id, n)
+ validate_confidence(confidence, n)
+ validate_tracker_id(tracker_id, n)
+ validate_data(data, n)
+
+
+def is_data_equal(data_a: Dict[str, np.ndarray], data_b: Dict[str, np.ndarray]) -> bool:
+ """
+ Compares the data payloads of two Detections instances.
+
+ Args:
+ data_a, data_b: The data payloads of the instances.
+
+ Returns:
+ True if the data payloads are equal, False otherwise.
+ """
+ return set(data_a.keys()) == set(data_b.keys()) and all(
+ np.array_equal(data_a[key], data_b[key]) for key in data_a
+ )
+
+
+def merge_data(
+ data_list: List[Dict[str, Union[np.ndarray, List]]],
+) -> Dict[str, Union[np.ndarray, List]]:
+ """
+ Merges the data payloads of a list of Detections instances.
+
+ Args:
+ data_list: The data payloads of the instances.
+
+ Returns:
+ A single data payload containing the merged data, preserving the original data
+ types (list or np.ndarray).
+
+ Raises:
+ ValueError: If data values within a single object have different lengths or if
+ dictionaries have different keys.
+ """
+ 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:
+ raise ValueError(
+ "All data values within a single object must have equal length."
+ )
+
+ merged_data = {key: [] for key in all_keys_sets[0]}
+
+ for data in data_list:
+ for key in merged_data:
+ merged_data[key].append(data[key])
+
+ for key in merged_data:
+ if all(isinstance(item, list) for item in merged_data[key]):
+ merged_data[key] = list(chain.from_iterable(merged_data[key]))
+ elif all(isinstance(item, np.ndarray) for item in merged_data[key]):
+ ndim = merged_data[key][0].ndim
+ if ndim == 1:
+ merged_data[key] = np.hstack(merged_data[key])
+ elif ndim > 1:
+ merged_data[key] = np.vstack(merged_data[key])
+ else:
+ raise ValueError(f"Unexpected array dimension for key '{key}'.")
+ else:
+ raise ValueError(
+ f"Inconsistent data types for key '{key}'. Only np.ndarray and list "
+ f"types are allowed."
+ )
+
+ return merged_data
+
+
+def get_data_item(
+ data: Dict[str, Union[np.ndarray, List]],
+ index: Union[int, slice, List[int], np.ndarray],
+) -> Dict[str, Union[np.ndarray, List]]:
+ """
+ Retrieve a subset of the data dictionary based on the given index.
+
+ Args:
+ data: The data dictionary of the Detections object.
+ index: The index or indices specifying the subset to retrieve.
+
+ Returns:
+ A subset of the data dictionary corresponding to the specified index.
+ """
+ subset_data = {}
+ for key, value in data.items():
+ if isinstance(value, np.ndarray):
+ subset_data[key] = value[index]
+ elif isinstance(value, list):
+ if isinstance(index, slice):
+ subset_data[key] = value[index]
+ elif isinstance(index, (list, np.ndarray)):
+ subset_data[key] = [value[i] for i in index]
+ elif isinstance(index, int):
+ subset_data[key] = [value[index]]
+ else:
+ raise TypeError(f"Unsupported index type: {type(index)}")
+ else:
+ raise TypeError(f"Unsupported data type for key '{key}': {type(value)}")
+
+ return subset_data
diff --git a/test/dataset/formats/test_pascal_voc.py b/test/dataset/formats/test_pascal_voc.py
index 269f41c7..bce29271 100644
--- a/test/dataset/formats/test_pascal_voc.py
+++ b/test/dataset/formats/test_pascal_voc.py
@@ -122,7 +122,7 @@ NO_DETECTIONS = """"""
["test"],
(100, 100),
False,
- mock_detections(np.array([[0, 0, 10, 10]]), None, [0]),
+ mock_detections(xyxy=[[0, 0, 10, 10]], class_id=[0]),
DoesNotRaise(),
),
(
@@ -130,7 +130,9 @@ NO_DETECTIONS = """"""
["test"],
(100, 100),
False,
- mock_detections(np.array([[0, 0, 10, 10], [10, 10, 20, 20]]), None, [0, 0]),
+ mock_detections(
+ xyxy=np.array([[0, 0, 10, 10], [10, 10, 20, 20]]), class_id=[0, 0]
+ ),
DoesNotRaise(),
),
(
@@ -139,9 +141,8 @@ NO_DETECTIONS = """"""
(100, 100),
False,
mock_detections(
- np.array([[0, 0, 10, 10], [20, 30, 30, 40], [10, 10, 20, 20]]),
- None,
- [0, 0, 1],
+ xyxy=np.array([[0, 0, 10, 10], [20, 30, 30, 40], [10, 10, 20, 20]]),
+ class_id=[0, 0, 1],
),
DoesNotRaise(),
),
@@ -150,7 +151,7 @@ NO_DETECTIONS = """"""
[],
(100, 100),
False,
- mock_detections(np.empty((0, 4)), None, []),
+ mock_detections(xyxy=np.empty((0, 4)), class_id=[]),
DoesNotRaise(),
),
],
diff --git a/test/detection/test_core.py b/test/detection/test_core.py
index 0d17e09b..f3b739e8 100644
--- a/test/detection/test_core.py
+++ b/test/detection/test_core.py
@@ -153,7 +153,10 @@ def test_getitem(
DoesNotRaise(),
), # single detection with xyxy field
(
- [mock_detections(xyxy=[[10, 10, 20, 20]]), Detections.empty()],
+ [
+ mock_detections(xyxy=[[10, 10, 20, 20]]),
+ mock_detections(xyxy=np.empty((0, 4), dtype=np.float32)),
+ ],
mock_detections(xyxy=[[10, 10, 20, 20]]),
DoesNotRaise(),
), # single detection with xyxy field + empty detection
@@ -171,7 +174,7 @@ def test_getitem(
mock_detections(xyxy=[[20, 20, 30, 30]]),
],
mock_detections(xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]]),
- DoesNotRaise(),
+ pytest.raises(ValueError),
), # detection with xyxy, class_id fields + detection with xyxy field
(
[
@@ -181,6 +184,16 @@ def test_getitem(
mock_detections(xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]], class_id=[0, 1]),
DoesNotRaise(),
), # two detections with xyxy, class_id fields
+ (
+ [
+ mock_detections(xyxy=[[10, 10, 20, 20]], data={"test": [1]}),
+ mock_detections(xyxy=[[20, 20, 30, 30]], data={"test": [2]}),
+ ],
+ mock_detections(
+ xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]], data={"test": [1, 2]}
+ ),
+ DoesNotRaise(),
+ ), # two detections with xyxy, data fields
],
)
def test_merge(
@@ -273,3 +286,54 @@ def test_get_anchor_coordinates(
result = detections.get_anchors_coordinates(anchor)
with exception:
assert np.array_equal(result, expected_result)
+
+
+@pytest.mark.parametrize(
+ "detections_a, detections_b, expected_result",
+ [
+ (
+ Detections.empty(),
+ Detections.empty(),
+ True,
+ ), # empty detections
+ (
+ mock_detections(xyxy=[[10, 10, 20, 20]]),
+ mock_detections(xyxy=[[10, 10, 20, 20]]),
+ True,
+ ), # detections with xyxy field
+ (
+ mock_detections(xyxy=[[10, 10, 20, 20]], confidence=[0.5]),
+ mock_detections(xyxy=[[10, 10, 20, 20]], confidence=[0.5]),
+ True,
+ ), # detections with xyxy, confidence fields
+ (
+ mock_detections(xyxy=[[10, 10, 20, 20]], confidence=[0.5]),
+ mock_detections(xyxy=[[10, 10, 20, 20]]),
+ False,
+ ), # detection with xyxy field + detection with xyxy, confidence fields
+ (
+ mock_detections(xyxy=[[10, 10, 20, 20]], data={"test": [1]}),
+ mock_detections(xyxy=[[10, 10, 20, 20]], data={"test": [1]}),
+ True,
+ ), # detections with xyxy, data fields
+ (
+ mock_detections(xyxy=[[10, 10, 20, 20]], data={"test": [1]}),
+ mock_detections(xyxy=[[10, 10, 20, 20]]),
+ False,
+ ), # detection with xyxy field + detection with xyxy, data fields
+ (
+ mock_detections(xyxy=[[10, 10, 20, 20]], data={"test_1": [1]}),
+ mock_detections(xyxy=[[10, 10, 20, 20]], data={"test_2": [1]}),
+ False,
+ ), # detections with xyxy, and different data field names
+ (
+ mock_detections(xyxy=[[10, 10, 20, 20]], data={"test_1": [1]}),
+ mock_detections(xyxy=[[10, 10, 20, 20]], data={"test_1": [3]}),
+ False,
+ ), # detections with xyxy, and different data field values
+ ],
+)
+def test_equal(
+ detections_a: Detections, detections_b: Detections, expected_result: bool
+) -> None:
+ assert (detections_a == detections_b) == expected_result
diff --git a/test/detection/test_utils.py b/test/detection/test_utils.py
index 83b71b85..576b25f8 100644
--- a/test/detection/test_utils.py
+++ b/test/detection/test_utils.py
@@ -1,13 +1,16 @@
from contextlib import ExitStack as DoesNotRaise
-from typing import List, Optional, Tuple
+from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pytest
+from supervision.config import CLASS_NAME_DATA_FIELD
from supervision.detection.utils import (
calculate_masks_centroids,
clip_boxes,
filter_polygons_by_area,
+ get_data_item,
+ merge_data,
move_boxes,
non_max_suppression,
process_roboflow_result,
@@ -266,7 +269,14 @@ def test_filter_polygons_by_area(
[
(
{"predictions": [], "image": {"width": 1000, "height": 1000}},
- (np.empty((0, 4)), np.empty(0), np.empty(0), None, None),
+ (
+ np.empty((0, 4)),
+ np.empty(0),
+ np.empty(0),
+ None,
+ None,
+ {CLASS_NAME_DATA_FIELD: np.empty(0)},
+ ),
DoesNotRaise(),
), # empty result
(
@@ -290,6 +300,7 @@ def test_filter_polygons_by_area(
np.array([0]),
None,
None,
+ {CLASS_NAME_DATA_FIELD: np.array(["person"])},
),
DoesNotRaise(),
), # single correct object detection result
@@ -325,6 +336,7 @@ def test_filter_polygons_by_area(
np.array([0, 7]),
None,
np.array([1, 2]),
+ {CLASS_NAME_DATA_FIELD: np.array(["person", "truck"])},
),
DoesNotRaise(),
), # two correct object detection result
@@ -345,7 +357,14 @@ def test_filter_polygons_by_area(
],
"image": {"width": 1000, "height": 1000},
},
- (np.empty((0, 4)), np.empty(0), np.empty(0), None, None),
+ (
+ np.empty((0, 4)),
+ np.empty(0),
+ np.empty(0),
+ None,
+ None,
+ {CLASS_NAME_DATA_FIELD: np.empty(0)},
+ ),
DoesNotRaise(),
), # single incorrect instance segmentation result with no points
(
@@ -364,7 +383,14 @@ def test_filter_polygons_by_area(
],
"image": {"width": 1000, "height": 1000},
},
- (np.empty((0, 4)), np.empty(0), np.empty(0), None, None),
+ (
+ np.empty((0, 4)),
+ np.empty(0),
+ np.empty(0),
+ None,
+ None,
+ {CLASS_NAME_DATA_FIELD: np.empty(0)},
+ ),
DoesNotRaise(),
), # single incorrect instance segmentation result with no enough points
(
@@ -394,6 +420,7 @@ def test_filter_polygons_by_area(
np.array([0]),
TEST_MASK,
None,
+ {CLASS_NAME_DATA_FIELD: np.array(["person"])},
),
DoesNotRaise(),
), # single incorrect instance segmentation result with no enough points
@@ -434,6 +461,7 @@ def test_filter_polygons_by_area(
np.array([0]),
TEST_MASK,
None,
+ {CLASS_NAME_DATA_FIELD: np.array(["person"])},
),
DoesNotRaise(),
), # two instance segmentation results - one correct, one incorrect
@@ -457,6 +485,15 @@ def test_process_roboflow_result(
assert (result[4] is None and expected_result[4] is None) or (
np.array_equal(result[4], expected_result[4])
)
+ for key in result[5]:
+ if isinstance(result[5][key], np.ndarray):
+ assert np.array_equal(
+ result[5][key], expected_result[5][key]
+ ), f"Mismatch in arrays for key {key}"
+ else:
+ assert (
+ result[5][key] == expected_result[5][key]
+ ), f"Mismatch in non-array data for key {key}"
@pytest.mark.parametrize(
@@ -640,3 +677,274 @@ def test_calculate_masks_centroids(
with exception:
result = calculate_masks_centroids(masks=masks)
assert np.array_equal(result, expected_result)
+
+
+@pytest.mark.parametrize(
+ "data_list, expected_result, exception",
+ [
+ (
+ [],
+ {},
+ DoesNotRaise(),
+ ), # empty data list
+ (
+ [{}],
+ {},
+ DoesNotRaise(),
+ ), # single empty data dict
+ (
+ [{}, {}],
+ {},
+ DoesNotRaise(),
+ ), # two empty data dicts
+ (
+ [
+ {"test_1": []},
+ ],
+ {"test_1": []},
+ DoesNotRaise(),
+ ), # single data dict with a single field name and empty list values
+ (
+ [
+ {"test_1": np.array([])},
+ ],
+ {"test_1": np.array([])},
+ DoesNotRaise(),
+ ), # single data dict with a single field name and empty np.array values
+ (
+ [
+ {"test_1": [1, 2, 3]},
+ ],
+ {"test_1": [1, 2, 3]},
+ DoesNotRaise(),
+ ), # single data dict with a single field name and list values
+ (
+ [
+ {"test_1": []},
+ {"test_1": [3, 2, 1]},
+ ],
+ {"test_1": [3, 2, 1]},
+ DoesNotRaise(),
+ ), # two data dicts with the same field name and empty and list values
+ (
+ [
+ {"test_1": [1, 2, 3]},
+ {"test_1": [3, 2, 1]},
+ ],
+ {"test_1": [1, 2, 3, 3, 2, 1]},
+ DoesNotRaise(),
+ ), # two data dicts with the same field name and list values
+ (
+ [
+ {"test_1": [1, 2, 3]},
+ {"test_1": [3, 2, 1]},
+ {"test_1": [1, 2, 3]},
+ ],
+ {"test_1": [1, 2, 3, 3, 2, 1, 1, 2, 3]},
+ DoesNotRaise(),
+ ), # three data dicts with the same field name and list values
+ (
+ [
+ {"test_1": [1, 2, 3]},
+ {"test_2": [3, 2, 1]},
+ ],
+ None,
+ pytest.raises(ValueError),
+ ), # two data dicts with different field names
+ (
+ [
+ {"test_1": np.array([1, 2, 3])},
+ {"test_1": np.array([3, 2, 1])},
+ ],
+ {"test_1": np.array([1, 2, 3, 3, 2, 1])},
+ DoesNotRaise(),
+ ), # two data dicts with the same field name and np.array values as 1D arrays
+ (
+ [
+ {"test_1": np.array([[1, 2, 3]])},
+ {"test_1": np.array([[3, 2, 1]])},
+ ],
+ {"test_1": np.array([[1, 2, 3], [3, 2, 1]])},
+ DoesNotRaise(),
+ ), # 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, 3, 2, 1]),
+ "test_2": np.array(["a", "b", "c", "c", "b", "a"]),
+ },
+ DoesNotRaise(),
+ ), # two data dicts with the same field names and np.array values
+ (
+ [
+ {"test_1": [1, 2, 3], "test_2": np.array(["a", "b", "c"])},
+ {"test_1": [3, 2, 1], "test_2": np.array(["c", "b", "a"])},
+ ],
+ {
+ "test_1": [1, 2, 3, 3, 2, 1],
+ "test_2": np.array(["a", "b", "c", "c", "b", "a"]),
+ },
+ DoesNotRaise(),
+ ), # two data dicts with the same field names and mixed values
+ (
+ [
+ {"test_1": np.array([1, 2, 3])},
+ {"test_1": np.array([[3, 2, 1]])},
+ ],
+ None,
+ pytest.raises(ValueError),
+ ), # 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"])},
+ ],
+ None,
+ pytest.raises(ValueError),
+ ), # two data dicts with the same field name and different length arrays values
+ ],
+)
+def test_merge_data(
+ data_list: List[Dict[str, Any]],
+ expected_result: Optional[Dict[str, Any]],
+ exception: Exception,
+):
+ with exception:
+ result = merge_data(data_list=data_list)
+ for key in result:
+ if isinstance(result[key], np.ndarray):
+ assert np.array_equal(
+ result[key], expected_result[key]
+ ), f"Mismatch in arrays for key {key}"
+ else:
+ assert (
+ result[key] == expected_result[key]
+ ), f"Mismatch in non-array data for key {key}"
+
+
+@pytest.mark.parametrize(
+ "data, index, expected_result, exception",
+ [
+ ({}, 0, {}, DoesNotRaise()), # empty data dict
+ (
+ {
+ "test_1": [1, 2, 3],
+ },
+ 0,
+ {
+ "test_1": [1],
+ },
+ DoesNotRaise(),
+ ), # single data dict with a single field name and list values
+ (
+ {
+ "test_1": np.array([1, 2, 3]),
+ },
+ 0,
+ {
+ "test_1": np.array([1]),
+ },
+ DoesNotRaise(),
+ ), # single data dict with a single field name and np.array values as 1D arrays
+ (
+ {
+ "test_1": [1, 2, 3],
+ },
+ slice(0, 2),
+ {
+ "test_1": [1, 2],
+ },
+ DoesNotRaise(),
+ ), # single data dict with a single field name and list values
+ (
+ {
+ "test_1": np.array([1, 2, 3]),
+ },
+ slice(0, 2),
+ {
+ "test_1": np.array([1, 2]),
+ },
+ DoesNotRaise(),
+ ), # single data dict with a single field name and np.array values as 1D arrays
+ (
+ {
+ "test_1": [1, 2, 3],
+ },
+ -1,
+ {
+ "test_1": [3],
+ },
+ DoesNotRaise(),
+ ), # single data dict with a single field name and list values
+ (
+ {
+ "test_1": np.array([1, 2, 3]),
+ },
+ -1,
+ {
+ "test_1": np.array([3]),
+ },
+ DoesNotRaise(),
+ ), # single data dict with a single field name and np.array values as 1D arrays
+ (
+ {
+ "test_1": [1, 2, 3],
+ },
+ [0, 2],
+ {
+ "test_1": [1, 3],
+ },
+ DoesNotRaise(),
+ ), # single data dict with a single field name and list values
+ (
+ {
+ "test_1": np.array([1, 2, 3]),
+ },
+ [0, 2],
+ {
+ "test_1": np.array([1, 3]),
+ },
+ DoesNotRaise(),
+ ), # single data dict with a single field name and np.array values as 1D arrays
+ (
+ {
+ "test_1": [1, 2, 3],
+ },
+ np.array([0, 2]),
+ {
+ "test_1": [1, 3],
+ },
+ DoesNotRaise(),
+ ), # single data dict with a single field name and list values
+ (
+ {
+ "test_1": np.array([1, 2, 3]),
+ },
+ np.array([0, 2]),
+ {
+ "test_1": np.array([1, 3]),
+ },
+ DoesNotRaise(),
+ ),
+ ],
+)
+def test_get_data_item(
+ data: Dict[str, Any],
+ index: Any,
+ expected_result: Optional[Dict[str, Any]],
+ exception: Exception,
+):
+ with exception:
+ result = get_data_item(data=data, index=index)
+ for key in result:
+ if isinstance(result[key], np.ndarray):
+ assert np.array_equal(
+ result[key], expected_result[key]
+ ), f"Mismatch in arrays for key {key}"
+ else:
+ assert (
+ result[key] == expected_result[key]
+ ), f"Mismatch in non-array data for key {key}"
diff --git a/test/test_utils.py b/test/test_utils.py
index a1d6907e..b8ef9231 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -1,4 +1,4 @@
-from typing import List
+from typing import Any, Dict, List, Optional
import numpy as np
@@ -7,19 +7,26 @@ from supervision.detection.core import Detections
def mock_detections(
xyxy: List[List[float]],
- confidence: List[float] = None,
- class_id: List[int] = None,
- tracker_id: List[int] = None,
+ mask: Optional[List[np.ndarray]] = None,
+ confidence: Optional[List[float]] = None,
+ class_id: Optional[List[int]] = None,
+ tracker_id: Optional[List[int]] = None,
+ data: Optional[Dict[str, List[Any]]] = None,
) -> Detections:
+ def convert_data(data: Dict[str, List[Any]]):
+ return {k: np.array(v) for k, v in data.items()}
+
return Detections(
xyxy=np.array(xyxy, dtype=np.float32),
- confidence=confidence
- if confidence is None
- else np.array(confidence, dtype=np.float32),
- class_id=class_id if class_id is None else np.array(class_id, dtype=int),
- tracker_id=tracker_id
- if tracker_id is None
- else np.array(tracker_id, dtype=int),
+ mask=(mask if mask is None else np.array(mask, dtype=bool)),
+ confidence=(
+ confidence if confidence is None else np.array(confidence, dtype=np.float32)
+ ),
+ class_id=(class_id if class_id is None else np.array(class_id, dtype=int)),
+ tracker_id=(
+ tracker_id if tracker_id is None else np.array(tracker_id, dtype=int)
+ ),
+ data=convert_data(data) if data else {},
)