Compare commits

..
47 Commits
Author SHA1 Message Date
LinasKo eaeb51a8db Merge pull request #1655 from roboflow/develop
`supervision-0.25.0` release
2024-11-12 23:58:16 +02:00
LinasKo 8d562c16a6 Bump supervision to 0.25.0 2024-11-12 23:53:48 +02:00
LinasKo dd4ffc6d97 Merge pull request #1668 from roboflow/feat/changelog-0.25.0
Feat/changelog 0.25.0
2024-11-12 23:49:35 +02:00
LinasKo 2d50beeaa9 Add changelog 2024-11-12 23:46:29 +02:00
LinasKo 7b3d88602b 'New' tags for docs of 0.25.0 2024-11-12 13:08:08 +02:00
LinasKo 90168644de Update deprecated.md 2024-11-12 12:45:53 +02:00
LinasKo aae3c69ee3 Merge pull request #1660 from roboflow/fix/address-release-pr-comments
Address 0.25.0 release PR comments
2024-11-12 11:56:42 +02:00
LinasKo 3bb22abe0f Merge pull request #1625 from kshitijaucharmal/develop
Added smart positioning (non overlapping labels) to VertexLabelAnnotator
2024-11-12 11:54:56 +02:00
Onuralp SEZER d99565dcd1 Merge pull request #1666 from roboflow/pre-commit-ci-update-config
chore(pre_commit): ⬆ pre_commit autoupdate
2024-11-12 10:25:10 +03:00
LinasKo 3692fb65f9 Smart Lables: Increase minimum step size to 2 2024-11-11 20:07:51 +02:00
pre-commit-ci[bot] 1af9c7d629 chore(pre_commit): ⬆ pre_commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.7.2 → v0.7.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.2...v0.7.3)
2024-11-11 17:51:11 +00:00
LinasKo 1d9f0b1994 SmartLabels: Labels move proportianlly to IoU 2024-11-11 19:47:58 +02:00
Onuralp SEZER 77c71dacac Merge pull request #1664 from roboflow/dependabot/pip/wheel-0.45.0
⬆️ Bump wheel from 0.44.0 to 0.45.0
2024-11-11 16:41:51 +03:00
Onuralp SEZER cfb462ca0b Merge pull request #1665 from roboflow/dependabot/pip/ruff-0.7.3
⬆️ Bump ruff from 0.7.2 to 0.7.3
2024-11-11 16:41:35 +03:00
LinasKo c12c562e29 Label annotators: add docstring to _get_label_properties 2024-11-11 15:15:30 +02:00
LinasKo 10652efbc4 Label annotators: remove unused functions get_box_intersection, get_unit_vector 2024-11-11 15:07:22 +02:00
LinasKo 3532130b2d Label annotators: xyxy = label_properties[:, :4] moved inside if self.smart_position: 2024-11-11 15:05:57 +02:00
LinasKo 79b45a4ea8 Label, RichLabel VertexLabel annotators: rename 'smart_positions' arg to 'smart_position' 2024-11-11 15:03:52 +02:00
dependabot[bot] cc8be562b0 ⬆️ Bump ruff from 0.7.2 to 0.7.3
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.7.2 to 0.7.3.
- [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/0.7.2...0.7.3)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-11 01:06:12 +00:00
dependabot[bot] a1d8c69654 ⬆️ Bump wheel from 0.44.0 to 0.45.0
Bumps [wheel](https://github.com/pypa/wheel) from 0.44.0 to 0.45.0.
- [Release notes](https://github.com/pypa/wheel/releases)
- [Changelog](https://github.com/pypa/wheel/blob/main/docs/news.rst)
- [Commits](https://github.com/pypa/wheel/compare/0.44.0...0.45.0)

---
updated-dependencies:
- dependency-name: wheel
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-11 01:03:27 +00:00
LinasKo 4326b87191 Even better Discord shield (thanks Brad) 2024-11-08 16:51:21 +02:00
LinasKo 38dbf5d05d Remove TextProperties class, merge auxiliary functions, use numpy arrays for passing data 2024-11-08 16:42:04 +02:00
LinasKo 5e76f86ff9 3 changes to spread_put_boxes algo:
* Vectorized
* Using forces rather than discrete steps
* Move along secondary axis more
2024-11-08 15:47:37 +02:00
LinasKo 01c2912a64 Move FONT to global scope 2024-11-08 13:10:18 +02:00
LinasKo 5fedfe5220 Rename "spread_out" arg to "smart_positions" 2024-11-08 13:07:34 +02:00
LinasKo c43ca06609 Expand Detections.merge tests with metadata 2024-11-08 12:48:23 +02:00
LinasKo b72ad4656b Unit tests for merge_metadata, fixes involving numpy arrays 2024-11-08 11:52:24 +02:00
LinasKo e4cf743c1c Add explanations for small/medium/large in metric results 2024-11-08 01:36:56 +02:00
LinasKo 19677bede5 Merge branch 'develop' into fix/address-release-pr-comments 2024-11-08 01:34:27 +02:00
LinasKo a6e1f037d7 Merge pull request #1661 from roboflow/feat/mAR-metric
Mean Average Recall (mAR) metric
2024-11-08 01:33:34 +02:00
LinasKo 0bfcdeab6f Add mAR docstrings & examples 2024-11-08 01:01:52 +02:00
LinasKo 83d4386fa2 Implemented mAR metric 2024-11-08 00:09:55 +02:00
LinasKo 42bb4c2865 Add print results and plot examples to plot() 2024-11-07 21:16:45 +02:00
LinasKo 4ed05e76fd Add example images for metrics 2024-11-07 20:58:18 +02:00
LinasKo 2084f57d76 Split How To Track Keypoints into multiple sections 2024-11-07 20:26:29 +02:00
LinasKo 1b101b49ac Renamed crossing_acceptance_threshold to minimum_crossing_threshold 2024-11-07 18:32:58 +02:00
LinasKo abcb055815 Removed LineZone property docstrings; added 2 properties to Attributes 2024-11-07 18:28:34 +02:00
LinasKo c3d7dc0ee3 Move keypoint to detections converstion to sv.KeyPoints.
* Test colab: https://colab.research.google.com/drive/10PMuW0IyaksofqI70NLnB_-Fnr4oKVmk?usp=sharing
2024-11-07 17:59:02 +02:00
LinasKo c6c447c527 Imporved Discord shield-badge 2024-11-07 17:47:00 +02:00
LinasKo b66702c5ff Remove dead code, surplus function args 2024-11-07 17:32:09 +02:00
pre-commit-ci[bot] 920a2c0cda fix(pre_commit): 🎨 auto format pre-commit hooks 2024-11-07 15:27:36 +00:00
LinasKo 6728527f36 Smart annotators are now called via "spread_out" param.
* Alos, refactored RichLabelAnnotator so its structure matches LabelAnnotator
2024-11-07 17:23:23 +02:00
Onuralp SEZER b9b802e9ea Merge pull request #1659 from roboflow/dependabot/pip/tqdm-4.67.0
⬆️ Bump tqdm from 4.66.6 to 4.67.0
2024-11-07 18:04:52 +03:00
dependabot[bot] 11978410f0 ⬆️ Bump tqdm from 4.66.6 to 4.67.0
Bumps [tqdm](https://github.com/tqdm/tqdm) from 4.66.6 to 4.67.0.
- [Release notes](https://github.com/tqdm/tqdm/releases)
- [Commits](https://github.com/tqdm/tqdm/compare/v4.66.6...v4.67.0)

---
updated-dependencies:
- dependency-name: tqdm
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-07 00:11:34 +00:00
KshitijAucharmal ecf5b1334f Added smart positioning (non overlapping labels) to RichLabelAnnotator 2024-10-29 16:06:54 +05:30
KshitijAucharmal a2e2e3725a Added smart positioning (non overlapping labels) to LabelAnnotator 2024-10-28 19:10:37 +05:30
KshitijAucharmal ba559c2658 Added smart positioning (non overlapping labels) to VertexLabelAnnotator 2024-10-28 08:59:30 +05:30
32 changed files with 2205 additions and 394 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ repos:
additional_dependencies: ["bandit[toml]"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.2
rev: v0.7.3
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
+64 -64
View File
@@ -21,7 +21,7 @@
[![python-version](https://img.shields.io/pypi/pyversions/supervision)](https://badge.fury.io/py/supervision)
[![colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/roboflow/supervision/blob/main/demo.ipynb)
[![gradio](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Spaces-blue)](https://huggingface.co/spaces/Roboflow/Annotators)
[![discord](https://img.shields.io/discord/1159501506232451173)](https://discord.gg/GbfgXGJ8Bk)
[![discord](https://img.shields.io/discord/1159501506232451173?logo=discord&label=discord&labelColor=fff&color=5865f2&link=https%3A%2F%2Fdiscord.gg%2FGbfgXGJ8Bk)](https://discord.gg/GbfgXGJ8Bk)
[![built-with-material-for-mkdocs](https://img.shields.io/badge/Material_for_MkDocs-526CFE?logo=MaterialForMkDocs&logoColor=white)](https://squidfunk.github.io/mkdocs-material/)
<div align="center">
@@ -135,88 +135,88 @@ for path, image, annotation in ds:
- load
```python
dataset = sv.DetectionDataset.from_yolo(
images_directory_path=...,
annotations_directory_path=...,
data_yaml_path=...
)
```python
dataset = sv.DetectionDataset.from_yolo(
images_directory_path=...,
annotations_directory_path=...,
data_yaml_path=...
)
dataset = sv.DetectionDataset.from_pascal_voc(
images_directory_path=...,
annotations_directory_path=...
)
dataset = sv.DetectionDataset.from_pascal_voc(
images_directory_path=...,
annotations_directory_path=...
)
dataset = sv.DetectionDataset.from_coco(
images_directory_path=...,
annotations_path=...
)
```
dataset = sv.DetectionDataset.from_coco(
images_directory_path=...,
annotations_path=...
)
```
- split
```python
train_dataset, test_dataset = dataset.split(split_ratio=0.7)
test_dataset, valid_dataset = test_dataset.split(split_ratio=0.5)
```python
train_dataset, test_dataset = dataset.split(split_ratio=0.7)
test_dataset, valid_dataset = test_dataset.split(split_ratio=0.5)
len(train_dataset), len(test_dataset), len(valid_dataset)
# (700, 150, 150)
```
len(train_dataset), len(test_dataset), len(valid_dataset)
# (700, 150, 150)
```
- merge
```python
ds_1 = sv.DetectionDataset(...)
len(ds_1)
# 100
ds_1.classes
# ['dog', 'person']
```python
ds_1 = sv.DetectionDataset(...)
len(ds_1)
# 100
ds_1.classes
# ['dog', 'person']
ds_2 = sv.DetectionDataset(...)
len(ds_2)
# 200
ds_2.classes
# ['cat']
ds_2 = sv.DetectionDataset(...)
len(ds_2)
# 200
ds_2.classes
# ['cat']
ds_merged = sv.DetectionDataset.merge([ds_1, ds_2])
len(ds_merged)
# 300
ds_merged.classes
# ['cat', 'dog', 'person']
```
ds_merged = sv.DetectionDataset.merge([ds_1, ds_2])
len(ds_merged)
# 300
ds_merged.classes
# ['cat', 'dog', 'person']
```
- save
```python
dataset.as_yolo(
images_directory_path=...,
annotations_directory_path=...,
data_yaml_path=...
)
```python
dataset.as_yolo(
images_directory_path=...,
annotations_directory_path=...,
data_yaml_path=...
)
dataset.as_pascal_voc(
images_directory_path=...,
annotations_directory_path=...
)
dataset.as_pascal_voc(
images_directory_path=...,
annotations_directory_path=...
)
dataset.as_coco(
images_directory_path=...,
annotations_path=...
)
```
dataset.as_coco(
images_directory_path=...,
annotations_path=...
)
```
- convert
```python
sv.DetectionDataset.from_yolo(
images_directory_path=...,
annotations_directory_path=...,
data_yaml_path=...
).as_pascal_voc(
images_directory_path=...,
annotations_directory_path=...
)
```
```python
sv.DetectionDataset.from_yolo(
images_directory_path=...,
annotations_directory_path=...,
data_yaml_path=...
).as_pascal_voc(
images_directory_path=...,
annotations_directory_path=...
)
```
</details>
+2
View File
@@ -1,5 +1,6 @@
---
comments: true
status: new
---
# Assets
@@ -13,6 +14,7 @@ To install the Supervision assets utility, you can use `pip`. This utility is av
as an extra within the Supervision package.
!!! example "pip install"
```bash
pip install "supervision[assets]"
```
+144
View File
@@ -1,5 +1,149 @@
# CHANGELOG
### 0.25.0 <small>Nov 12, 2024</small>
- No removals or deprecations in this release!
- Essential update to the [`LineZone`](https://supervision.roboflow.com/0.25.0/detection/tools/line_zone/): when computing line crossings, detections that jitter might be counted twice (or more). This can now be solved with the `minimum_crossing_threshold` argument. If you set it to `2` or more, extra frames will be used to confirm the crossing, improving the accuracy significantly. ([#1540](https://github.com/roboflow/supervision/pull/1540))
- It is now possible to track objects detected as [`KeyPoints`](https://supervision.roboflow.com/0.25.0/keypoint/core/#supervision.keypoint.core.KeyPoints). See the complete step-by-step guide in the [Object Tracking Guide](https://supervision.roboflow.com/latest/how_to/track_objects/#keypoints). ([#1658](https://github.com/roboflow/supervision/pull/1658))
```python
import numpy as np
import supervision as sv
from ultralytics import YOLO
model = YOLO("yolov8m-pose.pt")
tracker = sv.ByteTrack()
trace_annotator = sv.TraceAnnotator()
def callback(frame: np.ndarray, _: int) -> np.ndarray:
results = model(frame)[0]
key_points = sv.KeyPoints.from_ultralytics(results)
detections = key_points.as_detections()
detections = tracker.update_with_detections(detections)
annotated_image = trace_annotator.annotate(frame.copy(), detections)
return annotated_image
sv.process_video(
source_path="input_video.mp4",
target_path="output_video.mp4",
callback=callback
)
```
- Added `is_empty` method to [`KeyPoints`](https://supervision.roboflow.com/0.25.0/keypoint/core/#supervision.keypoint.core.KeyPoints) to check if there are any keypoints in the object. ([#1658](https://github.com/roboflow/supervision/pull/1658))
- Added `as_detections` method to [`KeyPoints`](https://supervision.roboflow.com/0.25.0/keypoint/core/#supervision.keypoint.core.KeyPoints) that converts `KeyPoints` to `Detections`. ([#1658](https://github.com/roboflow/supervision/pull/1658))
- Added a new video to `supervision[assets]`. ([#1657](https://github.com/roboflow/supervision/pull/1657))
```python
from supervision.assets import download_assets, VideoAssets
path_to_video = download_assets(VideoAssets.SKIING)
```
- Supervision can now be used with [`Python 3.13`](https://docs.python.org/3/whatsnew/3.13.html). The most renowned update is the ability to run Python [without Global Interpreter Lock (GIL)](https://docs.python.org/3/whatsnew/3.13.html#whatsnew313-free-threaded-cpython). We expect support for this among our dependencies to be inconsistent, but if you do attempt it - let us know the results! ([#1595](https://github.com/roboflow/supervision/pull/1595))
- Added [`Mean Average Recall`](https://supervision.roboflow.com/latest/metrics/mean_average_recall/) mAR metric, which returns a recall score, averaged over IoU thresholds, detected object classes, and limits imposed on maximum considered detections. ([#1661](https://github.com/roboflow/supervision/pull/1661))
```python
import supervision as sv
from supervision.metrics import MeanAverageRecall
predictions = sv.Detections(...)
targets = sv.Detections(...)
map_metric = MeanAverageRecall()
map_result = map_metric.update(predictions, targets).compute()
map_result.plot()
```
- Added [`Precision`](https://supervision.roboflow.com/latest/metrics/precision/) and [`Recall`](https://supervision.roboflow.com/latest/metrics/recall/) metrics, providing a baseline for comparing model outputs to ground truth or another model ([#1609](https://github.com/roboflow/supervision/pull/1609))
```python
import supervision as sv
from supervision.metrics import Recall
predictions = sv.Detections(...)
targets = sv.Detections(...)
recall_metric = Recall()
recall_result = recall_metric.update(predictions, targets).compute()
recall_result.plot()
```
- All Metrics now support Oriented Bounding Boxes (OBB) ([#1593](https://github.com/roboflow/supervision/pull/1593))
```python
import supervision as sv
from supervision.metrics import F1_Score
predictions = sv.Detections(...)
targets = sv.Detections(...)
f1_metric = MeanAverageRecall(metric_target=sv.MetricTarget.ORIENTED_BOUNDING_BOXES)
f1_result = f1_metric.update(predictions, targets).compute()
```
- Introducing Smart Labels! When `smart_position` is set for [`LabelAnnotator`](https://supervision.roboflow.com/0.25.0/detection/annotators/#supervision.annotators.core.LabelAnnotator), [`RichLabelAnnotator`](https://supervision.roboflow.com/0.25.0/detection/annotators/#supervision.annotators.core.RichLabelAnnotator) or [`VertexLabelAnnotator`](https://supervision.roboflow.com/0.25.0/detection/annotators/#supervision.annotators.core.RichLabelAnnotator), the labels will move around to avoid overlapping others. ([#1625](https://github.com/roboflow/supervision/pull/1625))
```python
import supervision as sv
from ultralytics import YOLO
image = cv2.imread("image.jpg")
label_annotator = sv.LabelAnnotator(smart_position=True)
model = YOLO("yolo11m.pt")
results = model(image)[0]
detections = sv.Detections.from_ultralytics(results)
annotated_frame = label_annotator.annotate(first_frame.copy(), detections)
sv.plot_image(annotated_frame)
```
- Added the `metadata` variable to [`Detections`](https://supervision.roboflow.com/0.25.0/detection/core/#supervision.detection.core.Detections). It allows you to store custom data per-image, rather than per-detected-object as was possible with `data` variable. For example, `metadata` could be used to store the source video path, camera model or camera parameters. ([#1589](https://github.com/roboflow/supervision/pull/1589))
```python
import supervision as sv
from ultralytics import YOLO
model = YOLO("yolov8m")
result = model("image.png")[0]
detections = sv.Detections.from_ultralytics(result)
# Items in `data` must match length of detections
object_ids = [num for num in range(len(detections))]
detections.data["object_number"] = object_ids
# Items in `metadata` can be of any length.
detections.metadata["camera_model"] = "Luxonis OAK-D"
```
- Added a `py.typed` type hints metafile. It should provide a stronger signal to type annotators and IDEs that type support is available. ([#1586](https://github.com/roboflow/supervision/pull/1586))
- `ByteTrack` no longer requires `detections` to have a `class_id` ([#1637](https://github.com/roboflow/supervision/pull/1637))
- `draw_line`, `draw_rectangle`, `draw_filled_rectangle`, `draw_polygon`, `draw_filled_polygon` and `PolygonZoneAnnotator` now comes with a default color ([#1591](https://github.com/roboflow/supervision/pull/1591))
- Dataset classes are treated as case-sensitive when merging multiple datasets. ([#1643](https://github.com/roboflow/supervision/pull/1643))
- Expanded [metrics documentation](https://supervision.roboflow.com/0.25.0/metrics/f1_score/) with example plots and printed results ([#1660](https://github.com/roboflow/supervision/pull/1660))
- Added usage example for polygon zone ([#1608](https://github.com/roboflow/supervision/pull/1608))
- Small improvements to error handling in polygons: ([#1602](https://github.com/roboflow/supervision/pull/1602))
- Updated [`ByteTrack`](https://supervision.roboflow.com/0.25.0/trackers/#supervision.tracker.byte_tracker.core.ByteTrack), removing shared variables. Previously, multiple instances of `ByteTrack` would share some date, requiring liberal use of `tracker.reset()`. ([#1603](https://github.com/roboflow/supervision/pull/1603)), ([#1528](https://github.com/roboflow/supervision/pull/1528))
- Fixed a bug where `class_agnostic` setting in `MeanAveragePrecision` would not work. ([#1577](https://github.com/roboflow/supervision/pull/1577)) hacktoberfest
- Removed welcome workflow from our CI system. ([#1596](https://github.com/roboflow/supervision/pull/1596))
- Large refactor of `ByteTrack`: STrack moved to separate class, removed superfluous `BaseTrack` class, removed unused variables ([#1603](https://github.com/roboflow/supervision/pull/1603))
- Large refactor of `RichLabelAnnotator`, matching its contents with `LabelAnnotator`. ([#1625](https://github.com/roboflow/supervision/pull/1625))
### 0.24.0 <small>Oct 4, 2024</small>
- Added [F1 score](https://supervision.roboflow.com/0.24.0/metrics/f1_score/#supervision.metrics.f1_score.F1Score) as a new metric for detection and segmentation. [#1521](https://github.com/roboflow/supervision/pull/1521)
+4
View File
@@ -19,6 +19,10 @@ These features are phased out due to better alternatives or potential issues in
# Removed
### 0.25.0
No removals in this version!
### 0.24.0
- The `frame_resolution_wh ` parameter in [`sv.PolygonZone`](detection/tools/polygon_zone.md/#supervision.detection.tools.polygon_zone.PolygonZone) has been removed.
-1
View File
@@ -1,6 +1,5 @@
---
comments: true
status: new
---
# InferenceSlicer
-1
View File
@@ -1,6 +1,5 @@
---
comments: true
status: new
---
<div class="md-typeset">
+284 -43
View File
@@ -1,5 +1,6 @@
---
comments: true
status: new
---
# Track Objects
@@ -13,6 +14,8 @@ take you through the steps to perform inference using the YOLOv8 model via eithe
you'll discover how to track these objects efficiently and annotate your video content
for a deeper analysis.
## Object Detection & Segmentation
To make it easier for you to follow our tutorial download the video we will use as an
example. You can do this using
[`supervision[assets]`](/latest/assets/) extension.
@@ -21,14 +24,13 @@ example. You can do this using
from supervision.assets import download_assets, VideoAssets
download_assets(VideoAssets.PEOPLE_WALKING)
download_assets(VideoAssets.SKIING)
```
<video controls>
<source src="https://media.roboflow.com/supervision/video-examples/people-walking.mp4" type="video/mp4">
</video>
## Run Inference
### Run Inference
First, you'll need to obtain predictions from your object detection or segmentation
model. In this tutorial, we are using the YOLOv8 model as an example. However,
@@ -41,6 +43,10 @@ by obtaining model predictions and then annotating the frame based on these pred
This `callback` function will be essential in the subsequent steps of the tutorial, as
it will be modified to include tracking, labeling, and trace annotations.
!!! tip
Both object detection and segmentation models are supported. Try it with `yolov8n.pt` or `yolov8n-640-seg`!
=== "Ultralytics"
```{ .py }
@@ -89,7 +95,7 @@ it will be modified to include tracking, labeling, and trace annotations.
<source src="https://media.roboflow.com/supervision/video-examples/how-to/track-objects/run-inference.mp4" type="video/mp4">
</video>
## Tracking
### Tracking
After running inference and obtaining predictions, the next step is to track the
detected objects throughout the video. Utilizing Supervision’s
@@ -145,7 +151,7 @@ enabling the continuous following of the object's motion path across different f
)
```
## Annotate Video with Tracking IDs
### Annotate Video with Tracking IDs
Annotating the video with tracking IDs helps in distinguishing and following each object
distinctly. With the
@@ -227,7 +233,7 @@ offering a clear visual representation of each object's class and unique identif
<source src="https://media.roboflow.com/supervision/video-examples/how-to/track-objects/annotate-video-with-tracking-ids.mp4" type="video/mp4">
</video>
## Annotate Video with Traces
### Annotate Video with Traces
Adding traces to the video involves overlaying the historical paths of the detected
objects. This feature, powered by the
@@ -315,9 +321,96 @@ movement patterns and interactions between objects in the video.
<source src="https://media.roboflow.com/supervision/video-examples/how-to/track-objects/annotate-video-with-traces.mp4" type="video/mp4">
</video>
## Tracking Key Points
## Keypoints
Keypoint tracking is currently supported via the conversion of `KeyPoints` to `Detections`. This is achieved with the [`keypoints_to_detections`](/latest/utils/datatypes/#supervision.utils.datatypes.keypoints_to_detections) function. We'll use a different video as well as [`DetectionsSmoother`](/latest/detection/tools/smoother/) to stabilize the boxes.
Models aren't limited to object detection and segmentation. Keypoint detection allows for detailed analysis of body joints and connections, especially valuable for applications like human pose estimation. This section introduces keypoint tracking. We'll walk through the steps of annotating keypoints, converting them into bounding box detections compatible with `ByteTrack`, and applying detection smoothing for enhanced stability.
To make it easier for you to follow our tutorial, let's download the video we will use as an
example. You can do this using [`supervision[assets]`](/latest/assets/) extension.
```python
from supervision.assets import download_assets, VideoAssets
download_assets(VideoAssets.SKIING)
```
<video controls muted>
<source src="https://media.roboflow.com/supervision/video-examples/how-to/track-objects/skiing-hd.mp4" type="video/mp4">
</video>
### Keypoint Detection
First, you'll need to obtain predictions from your keypoint detection model. In this tutorial, we are using the YOLOv8 model as an example. However,
Supervision is versatile and compatible with various models. Check this [link](/latest/keypoint/core/) for guidance on how to plug in other models.
We will define a `callback` function, which will process each frame of the video by obtaining model predictions and then annotating the frame based on these predictions.
Let's immediately visualize the results with our [`EdgeAnnotator`](/latest/keypoint/annotators/#supervision.keypoint.annotators.EdgeAnnotator) and [`VertexAnnotator`](https://supervision.roboflow.com/latest/keypoint/annotators/#supervision.keypoint.annotators.VertexAnnotator).
=== "Ultralytics"
```{ .py hl_lines="5 10-11" }
import numpy as np
import supervision as sv
from ultralytics import YOLO
model = YOLO("yolov8m-pose.pt")
edge_annotator = sv.EdgeAnnotator()
vertex_annotator = sv.VertexAnnotator()
def callback(frame: np.ndarray, _: int) -> np.ndarray:
results = model(frame)[0]
key_points = sv.KeyPoints.from_ultralytics(results)
annotated_frame = edge_annotator.annotate(
frame.copy(), key_points=key_points)
return vertex_annotator.annotate(
annotated_frame, key_points=key_points)
sv.process_video(
source_path="skiing.mp4",
target_path="result.mp4",
callback=callback
)
```
=== "Inference"
```{ .py hl_lines="5-6 11-12" }
import numpy as np
import supervision as sv
from inference.models.utils import get_roboflow_model
model = get_roboflow_model(
model_id="yolov8m-pose-640", api_key=<ROBOFLOW API KEY>)
edge_annotator = sv.EdgeAnnotator()
vertex_annotator = sv.VertexAnnotator()
def callback(frame: np.ndarray, _: int) -> np.ndarray:
results = model.infer(frame)[0]
key_points = sv.KeyPoints.from_inference(results)
annotated_frame = edge_annotator.annotate(
frame.copy(), key_points=key_points)
return vertex_annotator.annotate(
annotated_frame, key_points=key_points)
sv.process_video(
source_path="skiing.mp4",
target_path="result.mp4",
callback=callback
)
```
<video controls>
<source src="https://media.roboflow.com/supervision/video-examples/how-to/track-objects/track-keypoints-only-keypoints.mp4" type="video/mp4">
</video>
### Convert to Detections
Keypoint tracking is currently supported via the conversion of `KeyPoints` to `Detections`. This is achieved with the [`KeyPoints.as_detections()`](/latest/keypoint/core/#supervision.keypoint.core.KeyPoints.as_detections) function.
Let's convert to detections and visualize the results with our [`BoxAnnotator`](/latest/detection/annotators/#supervision.annotators.core.BoxAnnotator).
!!! tip
@@ -325,35 +418,102 @@ Keypoint tracking is currently supported via the conversion of `KeyPoints` to `D
=== "Ultralytics"
```{ .py hl_lines="5 7 14-15 17 33" }
```{ .py hl_lines="8 13 19-20" }
import numpy as np
import supervision as sv
from ultralytics import YOLO
model = YOLO("yolov8m-pose.pt")
edge_annotator = sv.EdgeAnnotator()
vertex_annotator = sv.VertexAnnotator()
box_annotator = sv.BoxAnnotator()
def callback(frame: np.ndarray, _: int) -> np.ndarray:
results = model(frame)[0]
key_points = sv.KeyPoints.from_ultralytics(results)
detections = key_points.as_detections()
annotated_frame = edge_annotator.annotate(
frame.copy(), key_points=key_points)
annotated_frame = vertex_annotator.annotate(
annotated_frame, key_points=key_points)
return box_annotator.annotate(
annotated_frame, detections=detections)
sv.process_video(
source_path="skiing.mp4",
target_path="result.mp4",
callback=callback
)
```
=== "Inference"
```{ .py hl_lines="9 14 20-21" }
import numpy as np
import supervision as sv
from inference.models.utils import get_roboflow_model
model = get_roboflow_model(
model_id="yolov8m-pose-640", api_key=<ROBOFLOW API KEY>)
edge_annotator = sv.EdgeAnnotator()
vertex_annotator = sv.VertexAnnotator()
box_annotator = sv.BoxAnnotator()
def callback(frame: np.ndarray, _: int) -> np.ndarray:
results = model.infer(frame)[0]
key_points = sv.KeyPoints.from_inference(results)
detections = key_points.as_detections()
annotated_frame = edge_annotator.annotate(
frame.copy(), key_points=key_points)
annotated_frame = vertex_annotator.annotate(
annotated_frame, key_points=key_points)
return box_annotator.annotate(
annotated_frame, detections=detections)
sv.process_video(
source_path="skiing.mp4",
target_path="result.mp4",
callback=callback
)
```
<video controls>
<source src="https://media.roboflow.com/supervision/video-examples/how-to/track-objects/track-keypoints-converted-to-detections.mp4" type="video/mp4">
</video>
### Keypoint Tracking
Now that we have a `Detections` object, we can track it throughout the video. Utilizing Supervision’s [`sv.ByteTrack`](/latest/trackers/#supervision.tracker.byte_tracker.core.ByteTrack) functionality, each detected object is assigned a unique tracker ID, enabling the continuous following of the object's motion path across different frames. We shall visualize the result with `TraceAnnotator`.
=== "Ultralytics"
```{ .py hl_lines="10-11 17 25-26" }
import numpy as np
import supervision as sv
from ultralytics import YOLO
model = YOLO("yolov8m-pose.pt")
edge_annotator = sv.EdgeAnnotator()
vertex_annotator = sv.VertexAnnotator()
box_annotator = sv.BoxAnnotator()
tracker = sv.ByteTrack()
smoother = sv.DetectionsSmoother()
box_annotator = sv.BoundingBoxAnnotator()
label_annotator = sv.LabelAnnotator()
trace_annotator = sv.TraceAnnotator()
def callback(frame: np.ndarray, _: int) -> np.ndarray:
results = model(frame)[0]
keypoints = sv.KeyPoints.from_ultralytics(results)
detections = sv.keypoints_to_detections(keypoints)
key_points = sv.KeyPoints.from_ultralytics(results)
detections = key_points.as_detections()
detections = tracker.update_with_detections(detections)
detections = smoother.update_with_detections(detections)
labels = [
f"#{tracker_id} {results.names[class_id]}"
for class_id, tracker_id
in zip(detections.class_id, detections.tracker_id)
]
annotated_frame = edge_annotator.annotate(
frame.copy(), key_points=key_points)
annotated_frame = vertex_annotator.annotate(
annotated_frame, key_points=key_points)
annotated_frame = box_annotator.annotate(
frame.copy(), detections=detections)
annotated_frame = label_annotator.annotate(
annotated_frame, detections=detections, labels=labels)
annotated_frame, detections=detections)
return trace_annotator.annotate(
annotated_frame, detections=detections)
@@ -366,36 +526,32 @@ Keypoint tracking is currently supported via the conversion of `KeyPoints` to `D
=== "Inference"
```{ .py hl_lines="5-6 8 15-16 18 34" }
```{ .py hl_lines="11-12 18 26-27" }
import numpy as np
import supervision as sv
from inference.models.utils import get_roboflow_model
model = get_roboflow_model(
model_id="yolov8m-pose-640", api_key=<ROBOFLOW API KEY>)
edge_annotator = sv.EdgeAnnotator()
vertex_annotator = sv.VertexAnnotator()
box_annotator = sv.BoxAnnotator()
tracker = sv.ByteTrack()
smoother = sv.DetectionsSmoother()
box_annotator = sv.BoundingBoxAnnotator()
label_annotator = sv.LabelAnnotator()
trace_annotator = sv.TraceAnnotator()
def callback(frame: np.ndarray, _: int) -> np.ndarray:
results = model.infer(frame)[0]
keypoints = sv.KeyPoints.from_inference(results)
detections = sv.keypoints_to_detections(keypoints)
key_points = sv.KeyPoints.from_inference(results)
detections = key_points.as_detections()
detections = tracker.update_with_detections(detections)
detections = smoother.update_with_detections(detections)
labels = [
f"#{tracker_id} {results.names[class_id]}"
for class_id, tracker_id
in zip(detections.class_id, detections.tracker_id)
]
annotated_frame = edge_annotator.annotate(
frame.copy(), key_points=key_points)
annotated_frame = vertex_annotator.annotate(
annotated_frame, key_points=key_points)
annotated_frame = box_annotator.annotate(
frame.copy(), detections=detections)
annotated_frame = label_annotator.annotate(
annotated_frame, detections=detections, labels=labels)
annotated_frame, detections=detections)
return trace_annotator.annotate(
annotated_frame, detections=detections)
@@ -407,9 +563,94 @@ Keypoint tracking is currently supported via the conversion of `KeyPoints` to `D
```
<video controls>
<source src="https://media.roboflow.com/supervision/video-examples/how-to/track-objects/track-keypoints.mp4" type="video/mp4">
<source src="https://media.roboflow.com/supervision/video-examples/how-to/track-objects/track-keypoints-with-tracking.mp4" type="video/mp4">
</video>
This structured walkthrough should give a detailed pathway to annotate videos
effectively using Supervision’s various functionalities, including object tracking and
trace annotations.
### Bonus: Smoothing
We could stop here as we have successfully tracked the object detected by the keypoint model. However, we can further enhance the stability of the boxes by applying [`DetectionsSmoother`](/latest/detection/tools/smoother/). This tool helps in stabilizing the boxes by smoothing the bounding box coordinates across frames. It is very simple to use:
=== "Ultralytics"
```{ .py hl_lines="11 19" }
import numpy as np
import supervision as sv
from ultralytics import YOLO
model = YOLO("yolov8m-pose.pt")
edge_annotator = sv.EdgeAnnotator()
vertex_annotator = sv.VertexAnnotator()
box_annotator = sv.BoxAnnotator()
tracker = sv.ByteTrack()
smoother = sv.DetectionsSmoother()
trace_annotator = sv.TraceAnnotator()
def callback(frame: np.ndarray, _: int) -> np.ndarray:
results = model(frame)[0]
key_points = sv.KeyPoints.from_ultralytics(results)
detections = key_points.as_detections()
detections = tracker.update_with_detections(detections)
detections = smoother.update_with_detections(detections)
annotated_frame = edge_annotator.annotate(
frame.copy(), key_points=key_points)
annotated_frame = vertex_annotator.annotate(
annotated_frame, key_points=key_points)
annotated_frame = box_annotator.annotate(
annotated_frame, detections=detections)
return trace_annotator.annotate(
annotated_frame, detections=detections)
sv.process_video(
source_path="skiing.mp4",
target_path="result.mp4",
callback=callback
)
```
=== "Inference"
```{ .py hl_lines="12 20" }
import numpy as np
import supervision as sv
from inference.models.utils import get_roboflow_model
model = get_roboflow_model(
model_id="yolov8m-pose-640", api_key=<ROBOFLOW API KEY>)
edge_annotator = sv.EdgeAnnotator()
vertex_annotator = sv.VertexAnnotator()
box_annotator = sv.BoxAnnotator()
tracker = sv.ByteTrack()
smoother = sv.DetectionsSmoother()
trace_annotator = sv.TraceAnnotator()
def callback(frame: np.ndarray, _: int) -> np.ndarray:
results = model.infer(frame)[0]
key_points = sv.KeyPoints.from_inference(results)
detections = key_points.as_detections()
detections = tracker.update_with_detections(detections)
detections = smoother.update_with_detections(detections)
annotated_frame = edge_annotator.annotate(
frame.copy(), key_points=key_points)
annotated_frame = vertex_annotator.annotate(
annotated_frame, key_points=key_points)
annotated_frame = box_annotator.annotate(
annotated_frame, detections=detections)
return trace_annotator.annotate(
annotated_frame, detections=detections)
sv.process_video(
source_path="skiing.mp4",
target_path="result.mp4",
callback=callback
)
```
<video controls>
<source src="https://media.roboflow.com/supervision/video-examples/how-to/track-objects/track-keypoints-with-smoothing.mp4" type="video/mp4">
</video>
This structured walkthrough should give a detailed pathway to annotate videos effectively using Supervision’s various functionalities, including object tracking and trace annotations.
+1
View File
@@ -1,5 +1,6 @@
---
comments: true
status: new
---
# Annotators
+1
View File
@@ -1,5 +1,6 @@
---
comments: true
status: new
---
# Keypoint Detection
+18
View File
@@ -0,0 +1,18 @@
---
comments: true
status: new
---
# Mean Average Recall
<div class="md-typeset">
<h2><a href="#supervision.metrics.mean_average_recall.MeanAverageRecall">MeanAverageRecall</a></h2>
</div>
:::supervision.metrics.mean_average_recall.MeanAverageRecall
<div class="md-typeset">
<h2><a href="#supervision.metrics.mean_average_recall.MeanAverageRecallResult">MeanAverageRecallResult</a></h2>
</div>
:::supervision.metrics.mean_average_recall.MeanAverageRecallResult
+1
View File
@@ -1,5 +1,6 @@
---
comments: true
status: new
---
# ByteTrack
-12
View File
@@ -1,12 +0,0 @@
---
comments: true
status: new
---
# Data Types Utils
<div class="md-typeset">
<h2><a href="#supervision.utils.datatypes.keypoints_to_detections">keypoints_to_detections</a></h2>
</div>
:::supervision.utils.datatypes.keypoints_to_detections
+1 -1
View File
@@ -66,6 +66,7 @@ nav:
- Utils: datasets/utils.md
- Metrics:
- mAP: metrics/mean_average_precision.md
- mAR: metrics/mean_average_recall.md
- Precision: metrics/precision.md
- Recall: metrics/recall.md
- F1 Score: metrics/f1_score.md
@@ -79,7 +80,6 @@ nav:
- File: utils/file.md
- Draw: utils/draw.md
- Geometry: utils/geometry.md
- Datatypes: utils/datatypes.md
- Assets: assets.md
- Cookbooks: cookbooks.md
- Cheatsheet: https://roboflow.github.io/cheatsheet-supervision/
Generated
+27 -26
View File
@@ -4037,29 +4037,29 @@ files = [
[[package]]
name = "ruff"
version = "0.7.2"
version = "0.7.3"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.7.2-py3-none-linux_armv6l.whl", hash = "sha256:b73f873b5f52092e63ed540adefc3c36f1f803790ecf2590e1df8bf0a9f72cb8"},
{file = "ruff-0.7.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5b813ef26db1015953daf476202585512afd6a6862a02cde63f3bafb53d0b2d4"},
{file = "ruff-0.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:853277dbd9675810c6826dad7a428d52a11760744508340e66bf46f8be9701d9"},
{file = "ruff-0.7.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21aae53ab1490a52bf4e3bf520c10ce120987b047c494cacf4edad0ba0888da2"},
{file = "ruff-0.7.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ccc7e0fc6e0cb3168443eeadb6445285abaae75142ee22b2b72c27d790ab60ba"},
{file = "ruff-0.7.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd77877a4e43b3a98e5ef4715ba3862105e299af0c48942cc6d51ba3d97dc859"},
{file = "ruff-0.7.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e00163fb897d35523c70d71a46fbaa43bf7bf9af0f4534c53ea5b96b2e03397b"},
{file = "ruff-0.7.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3c54b538633482dc342e9b634d91168fe8cc56b30a4b4f99287f4e339103e88"},
{file = "ruff-0.7.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b792468e9804a204be221b14257566669d1db5c00d6bb335996e5cd7004ba80"},
{file = "ruff-0.7.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dba53ed84ac19ae4bfb4ea4bf0172550a2285fa27fbb13e3746f04c80f7fa088"},
{file = "ruff-0.7.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b19fafe261bf741bca2764c14cbb4ee1819b67adb63ebc2db6401dcd652e3748"},
{file = "ruff-0.7.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28bd8220f4d8f79d590db9e2f6a0674f75ddbc3847277dd44ac1f8d30684b828"},
{file = "ruff-0.7.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9fd67094e77efbea932e62b5d2483006154794040abb3a5072e659096415ae1e"},
{file = "ruff-0.7.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:576305393998b7bd6c46018f8104ea3a9cb3fa7908c21d8580e3274a3b04b691"},
{file = "ruff-0.7.2-py3-none-win32.whl", hash = "sha256:fa993cfc9f0ff11187e82de874dfc3611df80852540331bc85c75809c93253a8"},
{file = "ruff-0.7.2-py3-none-win_amd64.whl", hash = "sha256:dd8800cbe0254e06b8fec585e97554047fb82c894973f7ff18558eee33d1cb88"},
{file = "ruff-0.7.2-py3-none-win_arm64.whl", hash = "sha256:bb8368cd45bba3f57bb29cbb8d64b4a33f8415d0149d2655c5c8539452ce7760"},
{file = "ruff-0.7.2.tar.gz", hash = "sha256:2b14e77293380e475b4e3a7a368e14549288ed2931fce259a6f99978669e844f"},
{file = "ruff-0.7.3-py3-none-linux_armv6l.whl", hash = "sha256:34f2339dc22687ec7e7002792d1f50712bf84a13d5152e75712ac08be565d344"},
{file = "ruff-0.7.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fb397332a1879b9764a3455a0bb1087bda876c2db8aca3a3cbb67b3dbce8cda0"},
{file = "ruff-0.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37d0b619546103274e7f62643d14e1adcbccb242efda4e4bdb9544d7764782e9"},
{file = "ruff-0.7.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d59f0c3ee4d1a6787614e7135b72e21024875266101142a09a61439cb6e38a5"},
{file = "ruff-0.7.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44eb93c2499a169d49fafd07bc62ac89b1bc800b197e50ff4633aed212569299"},
{file = "ruff-0.7.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d0242ce53f3a576c35ee32d907475a8d569944c0407f91d207c8af5be5dae4e"},
{file = "ruff-0.7.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6b6224af8b5e09772c2ecb8dc9f3f344c1aa48201c7f07e7315367f6dd90ac29"},
{file = "ruff-0.7.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c50f95a82b94421c964fae4c27c0242890a20fe67d203d127e84fbb8013855f5"},
{file = "ruff-0.7.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f3eff9961b5d2644bcf1616c606e93baa2d6b349e8aa8b035f654df252c8c67"},
{file = "ruff-0.7.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8963cab06d130c4df2fd52c84e9f10d297826d2e8169ae0c798b6221be1d1d2"},
{file = "ruff-0.7.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:61b46049d6edc0e4317fb14b33bd693245281a3007288b68a3f5b74a22a0746d"},
{file = "ruff-0.7.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:10ebce7696afe4644e8c1a23b3cf8c0f2193a310c18387c06e583ae9ef284de2"},
{file = "ruff-0.7.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3f36d56326b3aef8eeee150b700e519880d1aab92f471eefdef656fd57492aa2"},
{file = "ruff-0.7.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5d024301109a0007b78d57ab0ba190087b43dce852e552734ebf0b0b85e4fb16"},
{file = "ruff-0.7.3-py3-none-win32.whl", hash = "sha256:4ba81a5f0c5478aa61674c5a2194de8b02652f17addf8dfc40c8937e6e7d79fc"},
{file = "ruff-0.7.3-py3-none-win_amd64.whl", hash = "sha256:588a9ff2fecf01025ed065fe28809cd5a53b43505f48b69a1ac7707b1b7e4088"},
{file = "ruff-0.7.3-py3-none-win_arm64.whl", hash = "sha256:1713e2c5545863cdbfe2cbce21f69ffaf37b813bfd1fb3b90dc9a6f1963f5a8c"},
{file = "ruff-0.7.3.tar.gz", hash = "sha256:e1d1ba2e40b6e71a61b063354d04be669ab0d39c352461f3d789cac68b54a313"},
]
[[package]]
@@ -4405,13 +4405,13 @@ test = ["devpi-process (>=1.0.2)", "pytest (>=8.3.3)", "pytest-mock (>=3.14)"]
[[package]]
name = "tqdm"
version = "4.66.6"
version = "4.67.0"
description = "Fast, Extensible Progress Meter"
optional = true
python-versions = ">=3.7"
files = [
{file = "tqdm-4.66.6-py3-none-any.whl", hash = "sha256:223e8b5359c2efc4b30555531f09e9f2f3589bcd7fdd389271191031b49b7a63"},
{file = "tqdm-4.66.6.tar.gz", hash = "sha256:4bdd694238bef1485ce839d67967ab50af8f9272aab687c0d7702a01da0be090"},
{file = "tqdm-4.67.0-py3-none-any.whl", hash = "sha256:0cd8af9d56911acab92182e88d763100d4788bdf421d251616040cc4d44863be"},
{file = "tqdm-4.67.0.tar.gz", hash = "sha256:fe5a6f95e6fe0b9755e9469b77b9c3cf850048224ecaa8293d7d2d31f97d869a"},
]
[package.dependencies]
@@ -4419,6 +4419,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""}
[package.extras]
dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"]
discord = ["requests"]
notebook = ["ipywidgets (>=6)"]
slack = ["slack-sdk"]
telegram = ["requests"]
@@ -4732,13 +4733,13 @@ test = ["websockets"]
[[package]]
name = "wheel"
version = "0.44.0"
version = "0.45.0"
description = "A built-package format for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "wheel-0.44.0-py3-none-any.whl", hash = "sha256:2376a90c98cc337d18623527a97c31797bd02bad0033d41547043a1cbfbe448f"},
{file = "wheel-0.44.0.tar.gz", hash = "sha256:a29c3f2817e95ab89aa4660681ad547c0e9547f20e75b0562fe7723c9a2a9d49"},
{file = "wheel-0.45.0-py3-none-any.whl", hash = "sha256:52f0baa5e6522155090a09c6bd95718cc46956d1b51d537ea5454249edb671c7"},
{file = "wheel-0.45.0.tar.gz", hash = "sha256:a57353941a3183b3d5365346b567a260a0602a0f8a635926a7dede41b94c674a"},
]
[package.extras]
@@ -4781,4 +4782,4 @@ metrics = ["pandas", "pandas-stubs"]
[metadata]
lock-version = "2.0"
python-versions = "^3.8"
content-hash = "8f7dad5406a294901e3f489cf0d09e8217a80597ba9cd82695822a3fb5c13034"
content-hash = "85f56a451ee3e0f2c00c8a39b1433d4fb54a239f14f4878e5ded30bc63729734"
+3 -3
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "supervision"
version = "0.25.0rc2"
version = "0.25.0"
description = "A set of easy-to-use utils that will come in handy in any Computer Vision project"
authors = ["Piotr Skalski <piotr.skalski92@gmail.com>"]
maintainers = [
@@ -71,7 +71,7 @@ pyyaml = ">=5.3"
defusedxml = "^0.7.1"
pillow = ">=9.4"
requests = { version = ">=2.26.0,<=2.32.3", optional = true }
tqdm = { version = ">=4.62.3,<=4.66.6", optional = true }
tqdm = { version = ">=4.62.3,<=4.67.0", optional = true }
# pandas: picked lowest major version that supports Python 3.8
pandas = { version = ">=2.0.0", optional = true }
pandas-stubs = { version = ">=2.0.0.230412", optional = true }
@@ -84,7 +84,7 @@ metrics = ["pandas", "pandas-stubs"]
[tool.poetry.group.dev.dependencies]
twine = "^5.1.1"
pytest = ">=7.2.2,<9.0.0"
wheel = ">=0.40,<0.45"
wheel = ">=0.40,<0.46"
build = ">=0.10,<1.3"
ruff = ">=0.1.0"
mypy = "^1.4.1"
-1
View File
@@ -100,7 +100,6 @@ from supervision.keypoint.core import KeyPoints
from supervision.metrics.detection import ConfusionMatrix, MeanAveragePrecision
from supervision.tracker.byte_tracker.core import ByteTrack
from supervision.utils.conversion import cv2_to_pillow, pillow_to_cv2
from supervision.utils.datatypes import keypoints_to_detections
from supervision.utils.file import list_files_with_extensions
from supervision.utils.image import (
ImageSink,
+253 -116
View File
@@ -16,7 +16,7 @@ from supervision.annotators.utils import (
)
from supervision.config import CLASS_NAME_DATA_FIELD, ORIENTED_BOX_COORDINATES
from supervision.detection.core import Detections
from supervision.detection.utils import clip_boxes, mask_to_polygons
from supervision.detection.utils import clip_boxes, mask_to_polygons, spread_out_boxes
from supervision.draw.color import Color, ColorPalette
from supervision.draw.utils import draw_polygon
from supervision.geometry.core import Position
@@ -32,6 +32,8 @@ from supervision.utils.image import (
)
from supervision.utils.internal import deprecated
CV2_FONT = cv2.FONT_HERSHEY_SIMPLEX
class BoxAnnotator(BaseAnnotator):
"""
@@ -1054,6 +1056,7 @@ class LabelAnnotator(BaseAnnotator):
text_position: Position = Position.TOP_LEFT,
color_lookup: ColorLookup = ColorLookup.CLASS,
border_radius: int = 0,
smart_position: bool = False,
):
"""
Args:
@@ -1070,6 +1073,7 @@ class LabelAnnotator(BaseAnnotator):
Options are `INDEX`, `CLASS`, `TRACK`.
border_radius (int): The radius to apply round edges. If the selected
value is higher than the lower dimension, width or height, is clipped.
smart_position (bool): Spread out the labels to avoid overlapping.
"""
self.border_radius: int = border_radius
self.color: Union[Color, ColorPalette] = color
@@ -1079,6 +1083,7 @@ class LabelAnnotator(BaseAnnotator):
self.text_padding: int = text_padding
self.text_anchor: Position = text_position
self.color_lookup: ColorLookup = color_lookup
self.smart_position = smart_position
@ensure_cv2_image_for_annotation
def annotate(
@@ -1128,11 +1133,29 @@ class LabelAnnotator(BaseAnnotator):
![label-annotator-example](https://media.roboflow.com/
supervision-annotator-examples/label-annotator-example-purple.png)
"""
assert isinstance(scene, np.ndarray)
font = cv2.FONT_HERSHEY_SIMPLEX
anchors_coordinates = detections.get_anchors_coordinates(
anchor=self.text_anchor
).astype(int)
self._validate_labels(labels, detections)
labels = self._get_labels_text(detections, labels)
label_properties = self._get_label_properties(detections, labels)
if self.smart_position:
xyxy = label_properties[:, :4]
xyxy = spread_out_boxes(xyxy)
label_properties[:, :4] = xyxy
self._draw_labels(
scene=scene,
labels=labels,
label_properties=label_properties,
detections=detections,
custom_color_lookup=custom_color_lookup,
)
return scene
def _validate_labels(self, labels: Optional[List[str]], detections: Detections):
if labels is not None and len(labels) != len(detections):
raise ValueError(
f"The number of labels ({len(labels)}) does not match the "
@@ -1140,72 +1163,121 @@ class LabelAnnotator(BaseAnnotator):
f"should have exactly 1 label."
)
for detection_idx, center_coordinates in enumerate(anchors_coordinates):
color = resolve_color(
color=self.color,
detections=detections,
detection_idx=detection_idx,
color_lookup=(
self.color_lookup
if custom_color_lookup is None
else custom_color_lookup
),
)
def _get_label_properties(
self,
detections: Detections,
labels: List[str],
) -> np.ndarray:
"""
Calculate the numerical properties required to draw the labels on the image.
text_color = resolve_color(
color=self.text_color,
detections=detections,
detection_idx=detection_idx,
color_lookup=(
self.color_lookup
if custom_color_lookup is None
else custom_color_lookup
),
)
Returns:
(np.ndarray): An array of label properties, containing columns:
`min_x`, `min_y`, `max_x`, `max_y`, `padded_text_height`.
"""
label_properties = []
anchors_coordinates = detections.get_anchors_coordinates(
anchor=self.text_anchor
).astype(int)
if labels is not None:
text = labels[detection_idx]
elif CLASS_NAME_DATA_FIELD in detections.data:
text = detections.data[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,
for label, center_coords in zip(labels, anchors_coordinates):
(text_w, text_h) = cv2.getTextSize(
text=label,
fontFace=CV2_FONT,
fontScale=self.text_scale,
thickness=self.text_thickness,
)[0]
text_w_padded = text_w + 2 * self.text_padding
text_h_padded = text_h + 2 * self.text_padding
width_padded = text_w + 2 * self.text_padding
height_padded = text_h + 2 * self.text_padding
text_background_xyxy = resolve_text_background_xyxy(
center_coordinates=tuple(center_coordinates),
text_wh=(text_w_padded, text_h_padded),
center_coordinates=tuple(center_coords),
text_wh=(width_padded, height_padded),
position=self.text_anchor,
)
text_x = text_background_xyxy[0] + self.text_padding
text_y = text_background_xyxy[1] + self.text_padding + text_h
label_properties.append(
[
*text_background_xyxy,
text_h,
]
)
return np.array(label_properties).reshape(-1, 5)
@staticmethod
def _get_labels_text(
detections: Detections, custom_labels: Optional[List[str]]
) -> List[str]:
if custom_labels is not None:
return custom_labels
labels = []
for idx in range(len(detections)):
if CLASS_NAME_DATA_FIELD in detections.data:
labels.append(detections.data[CLASS_NAME_DATA_FIELD][idx])
elif detections.class_id is not None:
labels.append(str(detections.class_id[idx]))
else:
labels.append(str(idx))
return labels
def _draw_labels(
self,
scene: np.ndarray,
labels: List[str],
label_properties: np.ndarray,
detections: Detections,
custom_color_lookup: Optional[np.ndarray],
) -> None:
assert len(labels) == len(label_properties) == len(detections), (
f"Number of label properties ({len(label_properties)}), "
f"labels ({len(labels)}) and detections ({len(detections)}) "
"do not match."
)
color_lookup = (
custom_color_lookup
if custom_color_lookup is not None
else self.color_lookup
)
for idx, label_property in enumerate(label_properties):
background_color = resolve_color(
color=self.color,
detections=detections,
detection_idx=idx,
color_lookup=color_lookup,
)
text_color = resolve_color(
color=self.text_color,
detections=detections,
detection_idx=idx,
color_lookup=color_lookup,
)
box_xyxy = label_property[:4]
text_height_padded = label_property[4]
self.draw_rounded_rectangle(
scene=scene,
xyxy=text_background_xyxy,
color=color.as_bgr(),
xyxy=box_xyxy,
color=background_color.as_bgr(),
border_radius=self.border_radius,
)
text_x = box_xyxy[0] + self.text_padding
text_y = box_xyxy[1] + self.text_padding + text_height_padded
cv2.putText(
img=scene,
text=text,
text=labels[idx],
org=(text_x, text_y),
fontFace=font,
fontFace=CV2_FONT,
fontScale=self.text_scale,
color=text_color.as_bgr(),
thickness=self.text_thickness,
lineType=cv2.LINE_AA,
)
return scene
@staticmethod
def draw_rounded_rectangle(
@@ -1266,6 +1338,7 @@ class RichLabelAnnotator(BaseAnnotator):
text_position: Position = Position.TOP_LEFT,
color_lookup: ColorLookup = ColorLookup.CLASS,
border_radius: int = 0,
smart_position: bool = False,
):
"""
Args:
@@ -1282,6 +1355,7 @@ class RichLabelAnnotator(BaseAnnotator):
Options are `INDEX`, `CLASS`, `TRACK`.
border_radius (int): The radius to apply round edges. If the selected
value is higher than the lower dimension, width or height, is clipped.
smart_position (bool): Spread out the labels to avoid overlapping.
"""
self.color = color
self.text_color = text_color
@@ -1289,14 +1363,8 @@ class RichLabelAnnotator(BaseAnnotator):
self.text_anchor = text_position
self.color_lookup = color_lookup
self.border_radius = border_radius
if font_path is not None:
try:
self.font = ImageFont.truetype(font_path, font_size)
except OSError:
print(f"Font path '{font_path}' not found. Using PIL's default font.")
self.font = self._load_default_font(font_size)
else:
self.font = self._load_default_font(font_size)
self.smart_position = smart_position
self.font = self._load_font(font_size, font_path)
@ensure_pil_image_for_annotation
def annotate(
@@ -1346,88 +1414,157 @@ class RichLabelAnnotator(BaseAnnotator):
"""
assert isinstance(scene, Image.Image)
self._validate_labels(labels, detections)
draw = ImageDraw.Draw(scene)
anchors_coordinates = detections.get_anchors_coordinates(
anchor=self.text_anchor
).astype(int)
labels = self._get_labels_text(detections, labels)
label_properties = self._get_label_properties(draw, detections, labels)
if self.smart_position:
xyxy = label_properties[:, :4]
xyxy = spread_out_boxes(xyxy)
label_properties[:, :4] = xyxy
self._draw_labels(
draw=draw,
labels=labels,
label_properties=label_properties,
detections=detections,
custom_color_lookup=custom_color_lookup,
)
return scene
def _validate_labels(self, labels: Optional[List[str]], detections: Detections):
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."
)
for detection_idx, center_coordinates in enumerate(anchors_coordinates):
color = resolve_color(
color=self.color,
detections=detections,
detection_idx=detection_idx,
color_lookup=(
self.color_lookup
if custom_color_lookup is None
else custom_color_lookup
),
f"The number of labels ({len(labels)}) does not match the "
f"number of detections ({len(detections)}). Each detection "
f"should have exactly 1 label."
)
text_color = resolve_color(
color=self.text_color,
detections=detections,
detection_idx=detection_idx,
color_lookup=(
self.color_lookup
if custom_color_lookup is None
else custom_color_lookup
),
def _get_label_properties(
self, draw, detections: Detections, labels: List[str]
) -> np.ndarray:
"""
Calculate the numerical properties required to draw the labels on the image.
Returns:
(np.ndarray): An array of label properties, containing columns:
`min_x`, `min_y`, `max_x`, `max_y`, `text_left_coordinate`,
`text_top_coordinate`. The first 4 values are already padded
with `text_padding`.
"""
label_properties = []
anchor_coordinates = detections.get_anchors_coordinates(
anchor=self.text_anchor
).astype(int)
for label, center_coords in zip(labels, anchor_coordinates):
text_left, text_top, text_right, text_bottom = draw.textbbox(
(0, 0), label, font=self.font
)
text_width = text_right - text_left
text_height = text_bottom - text_top
width_padded = text_width + 2 * self.text_padding
height_padded = text_height + 2 * self.text_padding
if labels is not None:
text = labels[detection_idx]
elif CLASS_NAME_DATA_FIELD in detections.data:
text = detections.data[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)
left, top, right, bottom = draw.textbbox((0, 0), text, font=self.font)
text_width = right - left
text_height = bottom - top
text_w_padded = text_width + 2 * self.text_padding
text_h_padded = text_height + 2 * self.text_padding
text_background_xyxy = resolve_text_background_xyxy(
center_coordinates=tuple(center_coordinates),
text_wh=(text_w_padded, text_h_padded),
center_coordinates=tuple(center_coords),
text_wh=(width_padded, height_padded),
position=self.text_anchor,
)
text_x = text_background_xyxy[0] + self.text_padding - left
text_y = text_background_xyxy[1] + self.text_padding - top
label_properties.append([*text_background_xyxy, text_left, text_top])
return np.array(label_properties).reshape(-1, 6)
@staticmethod
def _get_labels_text(
detections: Detections, custom_labels: Optional[List[str]]
) -> List[str]:
if custom_labels is not None:
return custom_labels
labels = []
for idx in range(len(detections)):
if CLASS_NAME_DATA_FIELD in detections.data:
labels.append(detections.data[CLASS_NAME_DATA_FIELD][idx])
elif detections.class_id is not None:
labels.append(str(detections.class_id[idx]))
else:
labels.append(str(idx))
return labels
def _draw_labels(
self,
draw,
labels: List[str],
label_properties: np.ndarray,
detections: Detections,
custom_color_lookup: Optional[np.ndarray],
) -> None:
assert len(labels) == len(label_properties) == len(detections), (
f"Number of label properties ({len(label_properties)}), "
f"labels ({len(labels)}) and detections ({len(detections)}) "
"do not match."
)
color_lookup = (
custom_color_lookup
if custom_color_lookup is not None
else self.color_lookup
)
for idx, label_property in enumerate(label_properties):
background_color = resolve_color(
color=self.color,
detections=detections,
detection_idx=idx,
color_lookup=color_lookup,
)
text_color = resolve_color(
color=self.text_color,
detections=detections,
detection_idx=idx,
color_lookup=color_lookup,
)
box_xyxy = label_property[:4]
text_left = label_property[4]
text_top = label_property[5]
label_x_position = box_xyxy[0] + self.text_padding - text_left
label_y_position = box_xyxy[1] + self.text_padding - text_top
draw.rounded_rectangle(
text_background_xyxy,
tuple(box_xyxy),
radius=self.border_radius,
fill=color.as_rgb(),
fill=background_color.as_rgb(),
outline=None,
)
draw.text(
xy=(text_x, text_y),
text=text,
xy=(label_x_position, label_y_position),
text=labels[idx],
font=self.font,
fill=text_color.as_rgb(),
)
return scene
@staticmethod
def _load_default_font(size):
"""
PIL either loads a font that accepts a size (e.g. on my machine)
or raises an error saying `load_default` does not accept arguments
(e.g. in Colab).
"""
def _load_font(font_size: int, font_path: Optional[str]):
def load_default_font(size):
try:
return ImageFont.load_default(size)
except TypeError:
return ImageFont.load_default()
if font_path is None:
return load_default_font(font_size)
try:
font = ImageFont.load_default(size)
except TypeError:
font = ImageFont.load_default()
return font
return ImageFont.truetype(font_path, font_size)
except OSError:
print(f"Font path '{font_path}' not found. Using PIL's default font.")
return load_default_font(font_size)
class IconAnnotator(BaseAnnotator):
+7 -19
View File
@@ -42,6 +42,10 @@ class LineZone:
to inside.
out_count (int): The number of objects that have crossed the line from inside
to outside.
in_count_per_class (Dict[int, int]): Number of objects of each class that have
crossed the line from outside to inside.
out_count_per_class (Dict[int, int]): Number of objects of each class that have
crossed the line from inside to outside.
Example:
```python
@@ -75,7 +79,7 @@ class LineZone:
Position.BOTTOM_LEFT,
Position.BOTTOM_RIGHT,
),
crossing_acceptance_threshold: int = 1,
minimum_crossing_threshold: int = 1,
):
"""
Args:
@@ -86,7 +90,7 @@ class LineZone:
to consider when deciding on whether the detection
has passed the line counter or not. By default, this
contains the four corners of the detection's bounding box
crossing_acceptance_threshold (int): Detection needs to be seen
minimum_crossing_threshold (int): Detection needs to be seen
on the other side of the line for this many frames to be
considered as having crossed the line. This is useful when
dealing with unstable bounding boxes or when detections
@@ -94,7 +98,7 @@ class LineZone:
"""
self.vector = Vector(start=start, end=end)
self.limits = self._calculate_region_of_interest_limits(vector=self.vector)
self.crossing_history_length = max(2, crossing_acceptance_threshold + 1)
self.crossing_history_length = max(2, minimum_crossing_threshold + 1)
self.crossing_state_history: Dict[int, Deque[bool]] = defaultdict(
lambda: deque(maxlen=self.crossing_history_length)
)
@@ -107,34 +111,18 @@ class LineZone:
@property
def in_count(self) -> int:
"""
Number of objects that have crossed the line from
outside to inside.
"""
return sum(self._in_count_per_class.values())
@property
def out_count(self) -> int:
"""
Number of objects that have crossed the line from
inside to outside.
"""
return sum(self._out_count_per_class.values())
@property
def in_count_per_class(self) -> Dict[int, int]:
"""
Number of objects of each class that have crossed
the line from outside to inside.
"""
return dict(self._in_count_per_class)
@property
def out_count_per_class(self) -> Dict[int, int]:
"""
Number of objects of each class that have crossed the line
from inside to outside.
"""
return dict(self._out_count_per_class)
def trigger(self, detections: Detections) -> Tuple[np.ndarray, np.ndarray]:
+75 -3
View File
@@ -919,11 +919,27 @@ def merge_metadata(metadata_list: List[Dict[str, Any]]) -> Dict[str, Any]:
merged_metadata: Dict[str, Any] = {}
for metadata in metadata_list:
for key, value in metadata.items():
if key in merged_metadata:
if key not in merged_metadata:
merged_metadata[key] = value
continue
other_value = merged_metadata[key]
if isinstance(value, np.ndarray) and isinstance(other_value, np.ndarray):
if not np.array_equal(merged_metadata[key], value):
raise ValueError(
f"Conflicting metadata for key: '{key}': "
"{type(value)}, {type(other_value)}."
)
elif isinstance(value, np.ndarray) or isinstance(other_value, np.ndarray):
# Since [] == np.array([]).
raise ValueError(
f"Conflicting metadata for key: '{key}': "
"{type(value)}, {type(other_value)}."
)
else:
print("hm")
if merged_metadata[key] != value:
raise ValueError(f"Conflicting metadata for key: '{key}'.")
else:
merged_metadata[key] = value
return merged_metadata
@@ -1101,3 +1117,59 @@ def cross_product(anchors: np.ndarray, vector: Vector) -> np.ndarray:
)
vector_start = np.array([vector.start.x, vector.start.y])
return np.cross(vector_at_zero, anchors - vector_start)
def spread_out_boxes(
xyxy: np.ndarray,
max_iterations: int = 100,
) -> np.ndarray:
"""
Spread out boxes that overlap with each other.
Args:
xyxy: Numpy array of shape (N, 4) where N is the number of boxes.
max_iterations: Maximum number of iterations to run the algorithm for.
"""
if len(xyxy) == 0:
return xyxy
xyxy_padded = pad_boxes(xyxy, px=1)
for _ in range(max_iterations):
# NxN
iou = box_iou_batch(xyxy_padded, xyxy_padded)
np.fill_diagonal(iou, 0)
if np.all(iou == 0):
break
overlap_mask = iou > 0
# Nx2
centers = (xyxy_padded[:, :2] + xyxy_padded[:, 2:]) / 2
# NxNx2
delta_centers = centers[:, np.newaxis, :] - centers[np.newaxis, :, :]
delta_centers *= overlap_mask[:, :, np.newaxis]
# Nx2
delta_sum = np.sum(delta_centers, axis=1)
delta_magnitude = np.linalg.norm(delta_sum, axis=1, keepdims=True)
direction_vectors = np.divide(
delta_sum,
delta_magnitude,
out=np.zeros_like(delta_sum),
where=delta_magnitude != 0,
)
force_vectors = np.sum(iou, axis=1)
force_vectors = force_vectors[:, np.newaxis] * direction_vectors
force_vectors *= 10
force_vectors[(force_vectors > 0) & (force_vectors < 2)] = 2
force_vectors[(force_vectors < 0) & (force_vectors > -2)] = -2
force_vectors = force_vectors.astype(int)
xyxy_padded[:, [0, 1]] += force_vectors
xyxy_padded[:, [2, 3]] += force_vectors
return pad_boxes(xyxy_padded, px=-1)
+9 -2
View File
@@ -5,10 +5,11 @@ from typing import List, Optional, Tuple, Union
import cv2
import numpy as np
from supervision import Rect, pad_boxes
from supervision.annotators.base import ImageType
from supervision.detection.utils import pad_boxes, spread_out_boxes
from supervision.draw.color import Color
from supervision.draw.utils import draw_rounded_rectangle
from supervision.geometry.core import Rect
from supervision.keypoint.core import KeyPoints
from supervision.keypoint.skeletons import SKELETONS_BY_VERTEX_COUNT
from supervision.utils.conversion import ensure_cv2_image_for_annotation
@@ -201,6 +202,7 @@ class VertexLabelAnnotator:
text_thickness: int = 1,
text_padding: int = 10,
border_radius: int = 0,
smart_position: bool = False,
):
"""
Args:
@@ -215,6 +217,7 @@ class VertexLabelAnnotator:
text_padding (int): The padding around the text.
border_radius (int): The radius of the rounded corners of the
boxes. Set to a high value to produce circles.
smart_position (bool): Spread out the labels to avoid overlap.
"""
self.border_radius: int = border_radius
self.color: Union[Color, List[Color]] = color
@@ -222,6 +225,7 @@ class VertexLabelAnnotator:
self.text_scale: float = text_scale
self.text_thickness: int = text_thickness
self.text_padding: int = text_padding
self.smart_position = smart_position
def annotate(
self,
@@ -356,9 +360,12 @@ class VertexLabelAnnotator:
for anchor, label in zip(anchors, labels)
]
)
xyxy_padded = pad_boxes(xyxy=xyxy, px=self.text_padding)
if self.smart_position:
xyxy_padded = spread_out_boxes(xyxy_padded)
xyxy = pad_boxes(xyxy=xyxy_padded, px=-self.text_padding)
for text, color, text_color, box, box_padded in zip(
labels, colors, text_colors, xyxy, xyxy_padded
):
+66 -1
View File
@@ -2,12 +2,13 @@ from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass, field
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union
import numpy as np
import numpy.typing as npt
from supervision.config import CLASS_NAME_DATA_FIELD
from supervision.detection.core import Detections
from supervision.detection.utils import get_data_item, is_data_equal
from supervision.validators import validate_keypoints_fields
@@ -620,3 +621,67 @@ class KeyPoints:
empty_keypoints = KeyPoints.empty()
empty_keypoints.data = self.data
return self == empty_keypoints
def as_detections(
self, selected_keypoint_indices: Optional[Iterable[int]] = None
) -> Detections:
"""
Convert a KeyPoints object to a Detections object. This
approximates the bounding box of the detected object by
taking the bounding box that fits all keypoints.
Arguments:
selected_keypoint_indices (Optional[Iterable[int]]): The
indices of the keypoints to include in the bounding box
calculation. This helps focus on a subset of keypoints,
e.g. when some are occluded. Captures all keypoints by default.
Returns:
detections (Detections): The converted detections object.
Example:
```python
keypoints = sv.KeyPoints.from_inference(...)
detections = keypoints.as_detections()
```
"""
if self.is_empty():
return Detections.empty()
detections_list = []
for i, xy in enumerate(self.xy):
if selected_keypoint_indices:
xy = xy[selected_keypoint_indices]
# [0, 0] used by some frameworks to indicate missing keypoints
xy = xy[~np.all(xy == 0, axis=1)]
if len(xy) == 0:
xyxy = np.array([[0, 0, 0, 0]], dtype=np.float32)
else:
x_min = xy[:, 0].min()
x_max = xy[:, 0].max()
y_min = xy[:, 1].min()
y_max = xy[:, 1].max()
xyxy = np.array([[x_min, y_min, x_max, y_max]], dtype=np.float32)
if self.confidence is None:
confidence = None
else:
confidence = self.confidence[i]
if selected_keypoint_indices:
confidence = confidence[selected_keypoint_indices]
confidence = np.array([confidence.mean()], dtype=np.float32)
detections_list.append(
Detections(
xyxy=xyxy,
confidence=confidence,
)
)
detections = Detections.merge(detections_list)
detections.class_id = self.class_id
detections.data = self.data
detections = detections[detections.area > 0]
return detections
+4
View File
@@ -8,6 +8,10 @@ from supervision.metrics.mean_average_precision import (
MeanAveragePrecision,
MeanAveragePrecisionResult,
)
from supervision.metrics.mean_average_recall import (
MeanAverageRecall,
MeanAverageRecallResult,
)
from supervision.metrics.precision import Precision, PrecisionResult
from supervision.metrics.recall import Recall, RecallResult
from supervision.metrics.utils.object_size import (
+42 -5
View File
@@ -47,10 +47,30 @@ class F1Score(Metric):
f1_metric = F1Score()
f1_result = f1_metric.update(predictions, targets).compute()
print(f1_result)
print(f1_result.f1_50)
print(f1_result.small_objects.f1_50)
# 0.7618
print(f1_result)
# F1ScoreResult:
# Metric target: MetricTarget.BOXES
# Averaging method: AveragingMethod.WEIGHTED
# F1 @ 50: 0.7618
# F1 @ 75: 0.7487
# F1 @ thresh: [0.76175 0.76068 0.76068]
# IoU thresh: [0.5 0.55 0.6 ...]
# F1 per class:
# 0: [0.70968 0.70968 0.70968 ...]
# ...
# Small objects: ...
# Medium objects: ...
# Large objects: ...
f1_result.plot()
```
![example_plot](\
https://media.roboflow.com/supervision-docs/metrics/f1_plot_example.png\
){ align=center width="800" }
"""
def __init__(
@@ -456,11 +476,11 @@ class F1ScoreResult:
matched_classes (np.ndarray): the class IDs of all matched classes.
Corresponds to the rows of `f1_per_class`.
small_objects (Optional[F1ScoreResult]): the F1 metric results
for small objects.
for small objects (area < 32²).
medium_objects (Optional[F1ScoreResult]): the F1 metric results
for medium objects.
for medium objects (32² ≤ area < 96²).
large_objects (Optional[F1ScoreResult]): the F1 metric results
for large objects.
for large objects (area ≥ 96²).
"""
metric_target: MetricTarget
@@ -490,6 +510,19 @@ class F1ScoreResult:
Example:
```python
print(f1_result)
# F1ScoreResult:
# Metric target: MetricTarget.BOXES
# Averaging method: AveragingMethod.WEIGHTED
# F1 @ 50: 0.7618
# F1 @ 75: 0.7487
# F1 @ thresh: [0.76175 0.76068 0.76068]
# IoU thresh: [0.5 0.55 0.6 ...]
# F1 per class:
# 0: [0.70968 0.70968 0.70968 ...]
# ...
# Small objects: ...
# Medium objects: ...
# Large objects: ...
```
"""
out_str = (
@@ -553,6 +586,10 @@ class F1ScoreResult:
def plot(self):
"""
Plot the F1 results.
![example_plot](\
https://media.roboflow.com/supervision-docs/metrics/f1_plot_example.png\
){ align=center width="800" }
"""
labels = ["F1@50", "F1@75"]
+43 -4
View File
@@ -42,10 +42,31 @@ class MeanAveragePrecision(Metric):
map_metric = MeanAveragePrecision()
map_result = map_metric.update(predictions, targets).compute()
print(map_result)
print(map_result.map50_95)
# 0.4674
print(map_result)
# MeanAveragePrecisionResult:
# Metric target: MetricTarget.BOXES
# Class agnostic: False
# mAP @ 50:95: 0.4674
# mAP @ 50: 0.5048
# mAP @ 75: 0.4796
# mAP scores: [0.50485 0.50377 0.50377 ...]
# IoU thresh: [0.5 0.55 0.6 ...]
# AP per class:
# 0: [0.67699 0.67699 0.67699 ...]
# ...
# Small objects: ...
# Medium objects: ...
# Large objects: ...
map_result.plot()
```
![example_plot](\
https://media.roboflow.com/supervision-docs/metrics/mAP_plot_example.png\
){ align=center width="800" }
"""
def __init__(
@@ -419,11 +440,11 @@ class MeanAveragePrecisionResult:
matched_classes (np.ndarray): the class IDs of all matched classes.
Corresponds to the rows of `ap_per_class`.
small_objects (Optional[MeanAveragePrecisionResult]): the mAP results
for small objects.
for small objects (area < 32²).
medium_objects (Optional[MeanAveragePrecisionResult]): the mAP results
for medium objects.
for medium objects (32² ≤ area < 96²).
large_objects (Optional[MeanAveragePrecisionResult]): the mAP results
for large objects.
for large objects (area ≥ 96²).
"""
metric_target: MetricTarget
@@ -456,6 +477,20 @@ class MeanAveragePrecisionResult:
Example:
```python
print(map_result)
# MeanAveragePrecisionResult:
# Metric target: MetricTarget.BOXES
# Class agnostic: False
# mAP @ 50:95: 0.4674
# mAP @ 50: 0.5048
# mAP @ 75: 0.4796
# mAP scores: [0.50485 0.50377 0.50377 ...]
# IoU thresh: [0.5 0.55 0.6 ...]
# AP per class:
# 0: [0.67699 0.67699 0.67699 ...]
# ...
# Small objects: ...
# Medium objects: ...
# Large objects: ...
```
"""
@@ -527,6 +562,10 @@ class MeanAveragePrecisionResult:
def plot(self):
"""
Plot the mAP results.
![example_plot](\
https://media.roboflow.com/supervision-docs/metrics/mAP_plot_example.png\
){ align=center width="800" }
"""
labels = ["mAP@50:95", "mAP@50", "mAP@75"]
+697
View File
@@ -0,0 +1,697 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional, Tuple, Union
import numpy as np
from matplotlib import pyplot as plt
from supervision.config import ORIENTED_BOX_COORDINATES
from supervision.detection.core import Detections
from supervision.detection.utils import (
box_iou_batch,
mask_iou_batch,
oriented_box_iou_batch,
)
from supervision.draw.color import LEGACY_COLOR_PALETTE
from supervision.metrics.core import Metric, MetricTarget
from supervision.metrics.utils.object_size import (
ObjectSizeCategory,
get_detection_size_category,
)
from supervision.metrics.utils.utils import ensure_pandas_installed
if TYPE_CHECKING:
import pandas as pd
class MeanAverageRecall(Metric):
"""
Mean Average Recall (mAR) measures how well the model detects
and retrieves relevant objects by averaging recall over multiple
IoU thresholds, classes and detection limits.
Intuitively, while Recall measures the ability to find all relevant
objects, mAR narrows down how many detections are considered for each
class. For example, mAR @ 100 considers the top 100 highest confidence
detections for each class. mAR @ 1 considers only the highest
confidence detection for each class.
Example:
```python
import supervision as sv
from supervision.metrics import MeanAverageRecall
predictions = sv.Detections(...)
targets = sv.Detections(...)
map_metric = MeanAverageRecall()
map_result = map_metric.update(predictions, targets).compute()
print(mar_results.mar_at_100)
# 0.5241
print(mar_results)
# MeanAverageRecallResult:
# Metric target: MetricTarget.BOXES
# mAR @ 1: 0.1362
# mAR @ 10: 0.4239
# mAR @ 100: 0.5241
# max detections: [1 10 100]
# IoU thresh: [0.5 0.55 0.6 ...]
# mAR per class:
# 0: [0.78571 0.78571 0.78571 ...]
# ...
# Small objects: ...
# Medium objects: ...
# Large objects: ...
mar_results.plot()
```
![example_plot](\
https://media.roboflow.com/supervision-docs/metrics/mAR_plot_example.png\
){ align=center width="800" }
"""
def __init__(
self,
metric_target: MetricTarget = MetricTarget.BOXES,
):
"""
Initialize the Mean Average Recall metric.
Args:
metric_target (MetricTarget): The type of detection data to use.
"""
self._metric_target = metric_target
self._predictions_list: List[Detections] = []
self._targets_list: List[Detections] = []
self.max_detections = np.array([1, 10, 100])
def reset(self) -> None:
"""
Reset the metric to its initial state, clearing all stored data.
"""
self._predictions_list = []
self._targets_list = []
def update(
self,
predictions: Union[Detections, List[Detections]],
targets: Union[Detections, List[Detections]],
) -> MeanAverageRecall:
"""
Add new predictions and targets to the metric, but do not compute the result.
Args:
predictions (Union[Detections, List[Detections]]): The predicted detections.
targets (Union[Detections, List[Detections]]): The target detections.
Returns:
(Recall): The updated metric instance.
"""
if not isinstance(predictions, list):
predictions = [predictions]
if not isinstance(targets, list):
targets = [targets]
if len(predictions) != len(targets):
raise ValueError(
f"The number of predictions ({len(predictions)}) and"
f" targets ({len(targets)}) during the update must be the same."
)
self._predictions_list.extend(predictions)
self._targets_list.extend(targets)
return self
def compute(self) -> MeanAverageRecallResult:
"""
Calculate the Mean Average Recall metric based on the stored predictions
and ground-truth, at different IoU thresholds and maximum detection counts.
Returns:
(MeanAverageRecallResult): The Mean Average Recall metric result.
"""
result = self._compute(self._predictions_list, self._targets_list)
small_predictions, small_targets = self._filter_predictions_and_targets_by_size(
self._predictions_list, self._targets_list, ObjectSizeCategory.SMALL
)
result.small_objects = self._compute(small_predictions, small_targets)
medium_predictions, medium_targets = (
self._filter_predictions_and_targets_by_size(
self._predictions_list, self._targets_list, ObjectSizeCategory.MEDIUM
)
)
result.medium_objects = self._compute(medium_predictions, medium_targets)
large_predictions, large_targets = self._filter_predictions_and_targets_by_size(
self._predictions_list, self._targets_list, ObjectSizeCategory.LARGE
)
result.large_objects = self._compute(large_predictions, large_targets)
return result
def _compute(
self, predictions_list: List[Detections], targets_list: List[Detections]
) -> MeanAverageRecallResult:
iou_thresholds = np.linspace(0.5, 0.95, 10)
stats = []
for predictions, targets in zip(predictions_list, targets_list):
prediction_contents = self._detections_content(predictions)
target_contents = self._detections_content(targets)
if len(targets) > 0:
if len(predictions) == 0:
stats.append(
(
np.zeros((0, iou_thresholds.size), dtype=bool),
np.zeros((0,), dtype=np.float32),
np.zeros((0,), dtype=int),
targets.class_id,
)
)
else:
if self._metric_target == MetricTarget.BOXES:
iou = box_iou_batch(target_contents, prediction_contents)
elif self._metric_target == MetricTarget.MASKS:
iou = mask_iou_batch(target_contents, prediction_contents)
elif self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
iou = oriented_box_iou_batch(
target_contents, prediction_contents
)
else:
raise ValueError(
"Unsupported metric target for IoU calculation"
)
matches = self._match_detection_batch(
predictions.class_id, targets.class_id, iou, iou_thresholds
)
stats.append(
(
matches,
predictions.confidence,
predictions.class_id,
targets.class_id,
)
)
if not stats:
return MeanAverageRecallResult(
metric_target=self._metric_target,
recall_scores=np.zeros(iou_thresholds.shape[0]),
recall_per_class=np.zeros((0, iou_thresholds.shape[0])),
max_detections=self.max_detections,
iou_thresholds=iou_thresholds,
matched_classes=np.array([], dtype=int),
small_objects=None,
medium_objects=None,
large_objects=None,
)
concatenated_stats = [np.concatenate(items, 0) for items in zip(*stats)]
recall_scores_per_k, recall_per_class, unique_classes = (
self._compute_average_recall_for_classes(*concatenated_stats)
)
return MeanAverageRecallResult(
metric_target=self._metric_target,
recall_scores=recall_scores_per_k,
recall_per_class=recall_per_class,
max_detections=self.max_detections,
iou_thresholds=iou_thresholds,
matched_classes=unique_classes,
small_objects=None,
medium_objects=None,
large_objects=None,
)
def _compute_average_recall_for_classes(
self,
matches: np.ndarray,
prediction_confidence: np.ndarray,
prediction_class_ids: np.ndarray,
true_class_ids: np.ndarray,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
sorted_indices = np.argsort(-prediction_confidence)
matches = matches[sorted_indices]
prediction_class_ids = prediction_class_ids[sorted_indices]
unique_classes, class_counts = np.unique(true_class_ids, return_counts=True)
recalls_at_k = []
for max_detections in self.max_detections:
# Shape: PxTh,P,C,C -> CxThx3
confusion_matrix = self._compute_confusion_matrix(
matches,
prediction_class_ids,
unique_classes,
class_counts,
max_detections=max_detections,
)
# Shape: CxThx3 -> CxTh
recall_per_class = self._compute_recall(confusion_matrix)
recalls_at_k.append(recall_per_class)
# Shape: KxCxTh -> KxC
recalls_at_k = np.array(recalls_at_k)
average_recall_per_class = np.mean(recalls_at_k, axis=2)
# Shape: KxC -> K
recall_scores = np.mean(average_recall_per_class, axis=1)
return recall_scores, recall_per_class, unique_classes
@staticmethod
def _match_detection_batch(
predictions_classes: np.ndarray,
target_classes: np.ndarray,
iou: np.ndarray,
iou_thresholds: np.ndarray,
) -> np.ndarray:
num_predictions, num_iou_levels = (
predictions_classes.shape[0],
iou_thresholds.shape[0],
)
correct = np.zeros((num_predictions, num_iou_levels), dtype=bool)
correct_class = target_classes[:, None] == predictions_classes
for i, iou_level in enumerate(iou_thresholds):
matched_indices = np.where((iou >= iou_level) & correct_class)
if matched_indices[0].shape[0]:
combined_indices = np.stack(matched_indices, axis=1)
iou_values = iou[matched_indices][:, None]
matches = np.hstack([combined_indices, iou_values])
if matched_indices[0].shape[0] > 1:
matches = matches[matches[:, 2].argsort()[::-1]]
matches = matches[np.unique(matches[:, 1], return_index=True)[1]]
matches = matches[np.unique(matches[:, 0], return_index=True)[1]]
correct[matches[:, 1].astype(int), i] = True
return correct
@staticmethod
def _compute_confusion_matrix(
sorted_matches: np.ndarray,
sorted_prediction_class_ids: np.ndarray,
unique_classes: np.ndarray,
class_counts: np.ndarray,
max_detections: Optional[int] = None,
) -> np.ndarray:
"""
Compute the confusion matrix for each class and IoU threshold.
Assumes the matches and prediction_class_ids are sorted by confidence
in descending order.
Args:
sorted_matches: np.ndarray, bool, shape (P, Th), that is True
if the prediction is a true positive at the given IoU threshold.
sorted_prediction_class_ids: np.ndarray, int, shape (P,), containing
the class id for each prediction.
unique_classes: np.ndarray, int, shape (C,), containing the unique
class ids.
class_counts: np.ndarray, int, shape (C,), containing the number
of true instances for each class.
max_detections: Optional[int], the maximum number of detections to
consider for each class. Extra detections are considered false
positives. By default, all detections are considered.
Returns:
np.ndarray, shape (C, Th, 3), containing the true positives, false
positives, and false negatives for each class and IoU threshold.
"""
num_thresholds = sorted_matches.shape[1]
num_classes = unique_classes.shape[0]
confusion_matrix = np.zeros((num_classes, num_thresholds, 3))
for class_idx, class_id in enumerate(unique_classes):
is_class = sorted_prediction_class_ids == class_id
num_true = class_counts[class_idx]
num_predictions = is_class.sum()
if num_predictions == 0:
true_positives = np.zeros(num_thresholds)
false_positives = np.zeros(num_thresholds)
false_negatives = np.full(num_thresholds, num_true)
elif num_true == 0:
true_positives = np.zeros(num_thresholds)
false_positives = np.full(num_thresholds, num_predictions)
false_negatives = np.zeros(num_thresholds)
else:
limited_matches = sorted_matches[is_class][slice(max_detections)]
true_positives = limited_matches.sum(0)
false_positives = (1 - limited_matches).sum(0)
false_negatives = num_true - true_positives
false_negatives = num_true - true_positives
confusion_matrix[class_idx] = np.stack(
[true_positives, false_positives, false_negatives], axis=1
)
return confusion_matrix
@staticmethod
def _compute_recall(confusion_matrix: np.ndarray) -> np.ndarray:
"""
Broadcastable function, computing the recall from the confusion matrix.
Arguments:
confusion_matrix: np.ndarray, shape (N, ..., 3), where the last dimension
contains the true positives, false positives, and false negatives.
Returns:
np.ndarray, shape (N, ...), containing the recall for each element.
"""
if not confusion_matrix.shape[-1] == 3:
raise ValueError(
f"Confusion matrix must have shape (..., 3), got "
f"{confusion_matrix.shape}"
)
true_positives = confusion_matrix[..., 0]
false_negatives = confusion_matrix[..., 2]
denominator = true_positives + false_negatives
recall = np.where(denominator == 0, 0, true_positives / denominator)
return recall
def _detections_content(self, detections: Detections) -> np.ndarray:
"""Return boxes, masks or oriented bounding boxes from detections."""
if self._metric_target == MetricTarget.BOXES:
return detections.xyxy
if self._metric_target == MetricTarget.MASKS:
return (
detections.mask
if detections.mask is not None
else self._make_empty_content()
)
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
obb = detections.data.get(ORIENTED_BOX_COORDINATES)
if obb is not None and len(obb) > 0:
return np.array(obb, dtype=np.float32)
return self._make_empty_content()
raise ValueError(f"Invalid metric target: {self._metric_target}")
def _make_empty_content(self) -> np.ndarray:
if self._metric_target == MetricTarget.BOXES:
return np.empty((0, 4), dtype=np.float32)
if self._metric_target == MetricTarget.MASKS:
return np.empty((0, 0, 0), dtype=bool)
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
return np.empty((0, 4, 2), dtype=np.float32)
raise ValueError(f"Invalid metric target: {self._metric_target}")
def _filter_detections_by_size(
self, detections: Detections, size_category: ObjectSizeCategory
) -> Detections:
"""Return a copy of detections with contents filtered by object size."""
new_detections = deepcopy(detections)
if detections.is_empty() or size_category == ObjectSizeCategory.ANY:
return new_detections
sizes = get_detection_size_category(new_detections, self._metric_target)
size_mask = sizes == size_category.value
new_detections.xyxy = new_detections.xyxy[size_mask]
if new_detections.mask is not None:
new_detections.mask = new_detections.mask[size_mask]
if new_detections.class_id is not None:
new_detections.class_id = new_detections.class_id[size_mask]
if new_detections.confidence is not None:
new_detections.confidence = new_detections.confidence[size_mask]
if new_detections.tracker_id is not None:
new_detections.tracker_id = new_detections.tracker_id[size_mask]
if new_detections.data is not None:
for key, value in new_detections.data.items():
new_detections.data[key] = np.array(value)[size_mask]
return new_detections
def _filter_predictions_and_targets_by_size(
self,
predictions_list: List[Detections],
targets_list: List[Detections],
size_category: ObjectSizeCategory,
) -> Tuple[List[Detections], List[Detections]]:
new_predictions_list = []
new_targets_list = []
for predictions, targets in zip(predictions_list, targets_list):
new_predictions_list.append(
self._filter_detections_by_size(predictions, size_category)
)
new_targets_list.append(
self._filter_detections_by_size(targets, size_category)
)
return new_predictions_list, new_targets_list
@dataclass
class MeanAverageRecallResult:
# """
# The results of the recall metric calculation.
# Defaults to `0` if no detections or targets were provided.
# Attributes:
# metric_target (MetricTarget): the type of data used for the metric -
# boxes, masks or oriented bounding boxes.
# averaging_method (AveragingMethod): the averaging method used to compute the
# recall. Determines how the recall is aggregated across classes.
# recall_at_50 (float): the recall at IoU threshold of `0.5`.
# recall_at_75 (float): the recall at IoU threshold of `0.75`.
# recall_scores (np.ndarray): the recall scores at each IoU threshold.
# Shape: `(num_iou_thresholds,)`
# recall_per_class (np.ndarray): the recall scores per class and IoU threshold.
# Shape: `(num_target_classes, num_iou_thresholds)`
# iou_thresholds (np.ndarray): the IoU thresholds used in the calculations.
# matched_classes (np.ndarray): the class IDs of all matched classes.
# Corresponds to the rows of `recall_per_class`.
# small_objects (Optional[RecallResult]): the Recall metric results
# for small objects.
# medium_objects (Optional[RecallResult]): the Recall metric results
# for medium objects.
# large_objects (Optional[RecallResult]): the Recall metric results
# for large objects.
# """
"""
The results of the Mean Average Recall metric calculation.
Defaults to `0` if no detections or targets were provided.
Attributes:
metric_target (MetricTarget): the type of data used for the metric -
boxes, masks or oriented bounding boxes.
mAR_at_1 (float): the Mean Average Recall, when considering only the top
highest confidence detection for each class.
mAR_at_10 (float): the Mean Average Recall, when considering top 10
highest confidence detections for each class.
mAR_at_100 (float): the Mean Average Recall, when considering top 100
highest confidence detections for each class.
recall_per_class (np.ndarray): the recall scores per class and IoU threshold.
Shape: `(num_target_classes, num_iou_thresholds)`
max_detections (np.ndarray): the array with maximum number of detections
considered.
iou_thresholds (np.ndarray): the IoU thresholds used in the calculations.
matched_classes (np.ndarray): the class IDs of all matched classes.
Corresponds to the rows of `recall_per_class`.
small_objects (Optional[MeanAverageRecallResult]): the Mean Average Recall
metric results for small objects (area < 32²).
medium_objects (Optional[MeanAverageRecallResult]): the Mean Average Recall
metric results for medium objects (32² ≤ area < 96²).
large_objects (Optional[MeanAverageRecallResult]): the Mean Average Recall
metric results for large objects (area ≥ 96²).
"""
metric_target: MetricTarget
@property
def mAR_at_1(self) -> float:
return self.recall_scores[0]
@property
def mAR_at_10(self) -> float:
return self.recall_scores[1]
@property
def mAR_at_100(self) -> float:
return self.recall_scores[2]
recall_scores: np.ndarray
recall_per_class: np.ndarray
max_detections: np.ndarray
iou_thresholds: np.ndarray
matched_classes: np.ndarray
small_objects: Optional[MeanAverageRecallResult]
medium_objects: Optional[MeanAverageRecallResult]
large_objects: Optional[MeanAverageRecallResult]
def __str__(self) -> str:
"""
Format as a pretty string.
Example:
```python
# MeanAverageRecallResult:
# Metric target: MetricTarget.BOXES
# mAR @ 1: 0.1362
# mAR @ 10: 0.4239
# mAR @ 100: 0.5241
# max detections: [1 10 100]
# IoU thresh: [0.5 0.55 0.6 ...]
# mAR per class:
# 0: [0.78571 0.78571 0.78571 ...]
# ...
# Small objects: ...
# Medium objects: ...
# Large objects: ...
```
"""
out_str = (
f"{self.__class__.__name__}:\n"
f"Metric target: {self.metric_target}\n"
f"mAR @ 1: {self.mAR_at_1:.4f}\n"
f"mAR @ 10: {self.mAR_at_10:.4f}\n"
f"mAR @ 100: {self.mAR_at_100:.4f}\n"
f"max detections: {self.max_detections}\n"
f"IoU thresh: {self.iou_thresholds}\n"
f"mAR per class:\n"
)
if self.recall_per_class.size == 0:
out_str += " No results\n"
for class_id, recall_of_class in zip(
self.matched_classes, self.recall_per_class
):
out_str += f" {class_id}: {recall_of_class}\n"
indent = " "
if self.small_objects is not None:
indented = indent + str(self.small_objects).replace("\n", f"\n{indent}")
out_str += f"\nSmall objects:\n{indented}"
if self.medium_objects is not None:
indented = indent + str(self.medium_objects).replace("\n", f"\n{indent}")
out_str += f"\nMedium objects:\n{indented}"
if self.large_objects is not None:
indented = indent + str(self.large_objects).replace("\n", f"\n{indent}")
out_str += f"\nLarge objects:\n{indented}"
return out_str
def to_pandas(self) -> "pd.DataFrame":
"""
Convert the result to a pandas DataFrame.
Returns:
(pd.DataFrame): The result as a DataFrame.
"""
ensure_pandas_installed()
import pandas as pd
pandas_data = {
"mAR @ 1": self.mAR_at_1,
"mAR @ 10": self.mAR_at_10,
"mAR @ 100": self.mAR_at_100,
}
if self.small_objects is not None:
small_objects_df = self.small_objects.to_pandas()
for key, value in small_objects_df.items():
pandas_data[f"small_objects_{key}"] = value
if self.medium_objects is not None:
medium_objects_df = self.medium_objects.to_pandas()
for key, value in medium_objects_df.items():
pandas_data[f"medium_objects_{key}"] = value
if self.large_objects is not None:
large_objects_df = self.large_objects.to_pandas()
for key, value in large_objects_df.items():
pandas_data[f"large_objects_{key}"] = value
return pd.DataFrame(pandas_data, index=[0])
def plot(self):
"""
Plot the Mean Average Recall results.
![example_plot](\
https://media.roboflow.com/supervision-docs/metrics/mAR_plot_example.png\
){ align=center width="800" }
"""
labels = ["mAR @ 1", "mAR @ 10", "mAR @ 100"]
values = [self.mAR_at_1, self.mAR_at_10, self.mAR_at_100]
colors = [LEGACY_COLOR_PALETTE[0]] * 3
if self.small_objects is not None:
small_objects = self.small_objects
labels += ["Small: mAR @ 1", "Small: mAR @ 10", "Small: mAR @ 100"]
values += [
small_objects.mAR_at_1,
small_objects.mAR_at_10,
small_objects.mAR_at_100,
]
colors += [LEGACY_COLOR_PALETTE[3]] * 3
if self.medium_objects is not None:
medium_objects = self.medium_objects
labels += ["Medium: mAR @ 1", "Medium: mAR @ 10", "Medium: mAR @ 100"]
values += [
medium_objects.mAR_at_1,
medium_objects.mAR_at_10,
medium_objects.mAR_at_100,
]
colors += [LEGACY_COLOR_PALETTE[2]] * 3
if self.large_objects is not None:
large_objects = self.large_objects
labels += ["Large: mAR @ 1", "Large: mAR @ 10", "Large: mAR @ 100"]
values += [
large_objects.mAR_at_1,
large_objects.mAR_at_10,
large_objects.mAR_at_100,
]
colors += [LEGACY_COLOR_PALETTE[4]] * 3
plt.rcParams["font.family"] = "monospace"
_, ax = plt.subplots(figsize=(10, 6))
ax.set_ylim(0, 1)
ax.set_ylabel("Value", fontweight="bold")
title = (
f"Mean Average Recall, by Object Size"
f"\n(target: {self.metric_target.value})"
)
ax.set_title(title, fontweight="bold")
x_positions = range(len(labels))
bars = ax.bar(x_positions, values, color=colors, align="center")
ax.set_xticks(x_positions)
ax.set_xticklabels(labels, rotation=45, ha="right")
for bar in bars:
y_value = bar.get_height()
ax.text(
bar.get_x() + bar.get_width() / 2,
y_value + 0.02,
f"{y_value:.2f}",
ha="center",
va="bottom",
)
plt.rcParams["font.family"] = "sans-serif"
plt.tight_layout()
plt.show()
+41 -4
View File
@@ -50,10 +50,30 @@ class Precision(Metric):
precision_metric = Precision()
precision_result = precision_metric.update(predictions, targets).compute()
print(precision_result)
print(precision_result.precision_at_50)
# 0.8099
print(precision_result)
# PrecisionResult:
# Metric target: MetricTarget.BOXES
# Averaging method: AveragingMethod.WEIGHTED
# P @ 50: 0.8099
# P @ 75: 0.7969
# P @ thresh: [0.80992 0.80905 0.80905 ...]
# IoU thresh: [0.5 0.55 0.6 ...]
# Precision per class:
# 0: [0.64706 0.64706 0.64706 ...]
# ...
# Small objects: ...
# Medium objects: ...
# Large objects: ...
print(precision_result.small_objects.precision_at_50)
```
![example_plot](\
https://media.roboflow.com/supervision-docs/metrics/precision_plot_example.png\
){ align=center width="800" }
"""
def __init__(
@@ -459,11 +479,11 @@ class PrecisionResult:
matched_classes (np.ndarray): the class IDs of all matched classes.
Corresponds to the rows of `precision_per_class`.
small_objects (Optional[PrecisionResult]): the Precision metric results
for small objects.
for small objects (area < 32²).
medium_objects (Optional[PrecisionResult]): the Precision metric results
for medium objects.
for medium objects (32² ≤ area < 96²).
large_objects (Optional[PrecisionResult]): the Precision metric results
for large objects.
for large objects (area ≥ 96²).
"""
metric_target: MetricTarget
@@ -493,6 +513,19 @@ class PrecisionResult:
Example:
```python
print(precision_result)
# PrecisionResult:
# Metric target: MetricTarget.BOXES
# Averaging method: AveragingMethod.WEIGHTED
# P @ 50: 0.8099
# P @ 75: 0.7969
# P @ thresh: [0.80992 0.80905 0.80905 ...]
# IoU thresh: [0.5 0.55 0.6 ...]
# Precision per class:
# 0: [0.64706 0.64706 0.64706 ...]
# ...
# Small objects: ...
# Medium objects: ...
# Large objects: ...
```
"""
out_str = (
@@ -558,6 +591,10 @@ class PrecisionResult:
def plot(self):
"""
Plot the precision results.
![example_plot](\
https://media.roboflow.com/supervision-docs/metrics/precision_plot_example.png\
){ align=center width="800" }
"""
labels = ["Precision@50", "Precision@75"]
+45 -7
View File
@@ -50,10 +50,31 @@ class Recall(Metric):
recall_metric = Recall()
recall_result = recall_metric.update(predictions, targets).compute()
print(recall_result)
print(recall_result.recall_at_50)
print(recall_result.small_objects.recall_at_50)
# 0.7615
print(recall_result)
# RecallResult:
# Metric target: MetricTarget.BOXES
# Averaging method: AveragingMethod.WEIGHTED
# R @ 50: 0.7615
# R @ 75: 0.7462
# R @ thresh: [0.76151 0.76011 0.76011 0.75732 ...]
# IoU thresh: [0.5 0.55 0.6 ...]
# Recall per class:
# 0: [0.78571 0.78571 0.78571 ...]
# ...
# Small objects: ...
# Medium objects: ...
# Large objects: ...
recall_result.plot()
```
![example_plot](\
https://media.roboflow.com/supervision-docs/metrics/recall_plot_example.png\
){ align=center width="800" }
"""
def __init__(
@@ -115,11 +136,11 @@ class Recall(Metric):
def compute(self) -> RecallResult:
"""
Calculate the precision metric based on the stored predictions and ground-truth
Calculate the recall metric based on the stored predictions and ground-truth
data, at different IoU thresholds.
Returns:
(RecallResult): The precision metric result.
(RecallResult): The recall metric result.
"""
result = self._compute(self._predictions_list, self._targets_list)
@@ -457,11 +478,11 @@ class RecallResult:
matched_classes (np.ndarray): the class IDs of all matched classes.
Corresponds to the rows of `recall_per_class`.
small_objects (Optional[RecallResult]): the Recall metric results
for small objects.
for small objects (area < 32²).
medium_objects (Optional[RecallResult]): the Recall metric results
for medium objects.
for medium objects (32² ≤ area < 96²).
large_objects (Optional[RecallResult]): the Recall metric results
for large objects.
for large objects (area ≥ 96²).
"""
metric_target: MetricTarget
@@ -491,6 +512,19 @@ class RecallResult:
Example:
```python
print(recall_result)
# RecallResult:
# Metric target: MetricTarget.BOXES
# Averaging method: AveragingMethod.WEIGHTED
# R @ 50: 0.7615
# R @ 75: 0.7462
# R @ thresh: [0.76151 0.76011 0.76011 0.75732 ...]
# IoU thresh: [0.5 0.55 0.6 ...]
# Recall per class:
# 0: [0.78571 0.78571 0.78571 ...]
# ...
# Small objects: ...
# Medium objects: ...
# Large objects: ...
```
"""
out_str = (
@@ -556,6 +590,10 @@ class RecallResult:
def plot(self):
"""
Plot the recall results.
![example_plot](\
https://media.roboflow.com/supervision-docs/metrics/recall_plot_example.png\
){ align=center width="800" }
"""
labels = ["Recall@50", "Recall@75"]
-72
View File
@@ -1,72 +0,0 @@
from typing import Iterable, Optional
import numpy as np
from supervision.detection.core import Detections
from supervision.keypoint.core import KeyPoints
def keypoints_to_detections(
keypoints: KeyPoints, selected_keypoint_indices: Optional[Iterable[int]] = None
) -> Detections:
"""
Convert a KeyPoints object to a Detections object. This
approximates the bounding box of the detected object by
taking the bounding box that fits all keypoints.
Arguments:
keypoints (KeyPoints): The keypoints to convert to detections.
selected_keypoint_indices (Optional[Iterable[int]]): The
indices of the keypoints to include in the bounding box
calculation. This helps focus on a subset of keypoints,
e.g. when some are occluded. Captures all keypoints by default.
Returns:
detections (Detections): The converted detections object.
Example:
```python
keypoints = sv.KeyPoints.from_inference(...)
detections = keypoints_to_detections(keypoints)
```
"""
if keypoints.is_empty():
return Detections.empty()
detections_list = []
for i, xy in enumerate(keypoints.xy):
if selected_keypoint_indices:
xy = xy[selected_keypoint_indices]
# [0, 0] used by some frameworks to indicate missing keypoints
xy = xy[~np.all(xy == 0, axis=1)]
if len(xy) == 0:
xyxy = np.array([[0, 0, 0, 0]], dtype=np.float32)
else:
x_min = xy[:, 0].min()
x_max = xy[:, 0].max()
y_min = xy[:, 1].min()
y_max = xy[:, 1].max()
xyxy = np.array([[x_min, y_min, x_max, y_max]], dtype=np.float32)
if keypoints.confidence is None:
confidence = None
else:
confidence = keypoints.confidence[i]
if selected_keypoint_indices:
confidence = confidence[selected_keypoint_indices]
confidence = np.array([confidence.mean()], dtype=np.float32)
detections_list.append(
Detections(
xyxy=xyxy,
confidence=confidence,
)
)
detections = Detections.merge(detections_list)
detections.class_id = keypoints.class_id
detections.data = keypoints.data
detections = detections[detections.area > 0]
return detections
+205 -2
View File
@@ -106,6 +106,26 @@ TEST_DET_DIFFERENT_DATA = Detections(
"never_seen_key": [9],
},
)
TEST_DET_WITH_METADATA = Detections(
xyxy=np.array([[10, 10, 20, 20]]),
class_id=np.array([1]),
metadata={"source": "camera1"},
)
TEST_DET_WITH_METADATA_2 = Detections(
xyxy=np.array([[30, 30, 40, 40]]),
class_id=np.array([2]),
metadata={"source": "camera1"},
)
TEST_DET_NO_METADATA = Detections(
xyxy=np.array([[10, 10, 20, 20]]),
class_id=np.array([1]),
)
TEST_DET_DIFFERENT_METADATA = Detections(
xyxy=np.array([[50, 50, 60, 60]]),
class_id=np.array([3]),
metadata={"source": "camera2"},
)
@pytest.mark.parametrize(
@@ -258,6 +278,11 @@ def test_getitem(
TEST_DET_1,
DoesNotRaise(),
), # Single detection and empty-array fields
(
[TEST_DET_ZERO_LENGTH, TEST_DET_ZERO_LENGTH],
TEST_DET_ZERO_LENGTH,
DoesNotRaise(),
), # Zero-length fields across all Detections
(
[
TEST_DET_1,
@@ -287,12 +312,190 @@ def test_getitem(
Detections.empty(),
],
mock_detections(
xyxy=[[10, 10, 20, 20]],
xyxy=np.array([[10, 10, 20, 20]]),
class_id=[1],
mask=[np.zeros((4, 4), dtype=bool)],
),
DoesNotRaise(),
), # Segmentation + Empty
# Metadata
(
[
Detections(
xyxy=np.array([[10, 10, 20, 20]]),
class_id=np.array([1]),
metadata={"source": "camera1"},
),
Detections.empty(),
],
Detections(
xyxy=np.array([[10, 10, 20, 20]]),
class_id=np.array([1]),
metadata={"source": "camera1"},
),
DoesNotRaise(),
), # Metadata merge with empty detections
(
[
Detections(
xyxy=np.array([[10, 10, 20, 20]]),
class_id=np.array([1]),
metadata={"source": "camera1"},
),
Detections(xyxy=np.array([[30, 30, 40, 40]]), class_id=np.array([2])),
],
None,
pytest.raises(ValueError),
), # Empty and non-empty metadata
(
[
Detections(
xyxy=np.array([[10, 10, 20, 20]]),
class_id=np.array([1]),
metadata={"source": "camera1"},
)
],
Detections(
xyxy=np.array([[10, 10, 20, 20]]),
class_id=np.array([1]),
metadata={"source": "camera1"},
),
DoesNotRaise(),
), # Single detection with metadata
(
[
Detections(
xyxy=np.array([[10, 10, 20, 20]]),
class_id=np.array([1]),
metadata={"source": "camera1"},
),
Detections(
xyxy=np.array([[30, 30, 40, 40]]),
class_id=np.array([2]),
metadata={"source": "camera1"},
),
],
Detections(
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]]),
class_id=np.array([1, 2]),
metadata={"source": "camera1"},
),
DoesNotRaise(),
), # Multiple metadata entries with identical values
(
[
Detections(
xyxy=np.array([[10, 10, 20, 20]]),
class_id=np.array([1]),
metadata={"source": "camera1"},
),
Detections(
xyxy=np.array([[50, 50, 60, 60]]),
class_id=np.array([3]),
metadata={"source": "camera2"},
),
],
None,
pytest.raises(ValueError),
), # Different metadata values
(
[
Detections(
xyxy=np.array([[10, 10, 20, 20]]),
metadata={"source": "camera1", "resolution": "1080p"},
),
Detections(
xyxy=np.array([[30, 30, 40, 40]]),
metadata={"source": "camera1", "resolution": "1080p"},
),
],
Detections(
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]]),
metadata={"source": "camera1", "resolution": "1080p"},
),
DoesNotRaise(),
), # Large metadata with multiple identical entries
(
[
Detections(
xyxy=np.array([[10, 10, 20, 20]]), metadata={"source": "camera1"}
),
Detections(
xyxy=np.array([[30, 30, 40, 40]]), metadata={"source": ["camera1"]}
),
],
None,
pytest.raises(ValueError),
), # Inconsistent types in metadata values
(
[
Detections(
xyxy=np.array([[10, 10, 20, 20]]), metadata={"source": "camera1"}
),
Detections(
xyxy=np.array([[30, 30, 40, 40]]), metadata={"location": "indoor"}
),
],
None,
pytest.raises(ValueError),
), # Metadata key mismatch
(
[
Detections(
xyxy=np.array([[10, 10, 20, 20]]),
metadata={
"source": "camera1",
"settings": {"resolution": "1080p", "fps": 30},
},
),
Detections(
xyxy=np.array([[30, 30, 40, 40]]),
metadata={
"source": "camera1",
"settings": {"resolution": "1080p", "fps": 30},
},
),
],
Detections(
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]]),
metadata={
"source": "camera1",
"settings": {"resolution": "1080p", "fps": 30},
},
),
DoesNotRaise(),
), # multi-field metadata
(
[
Detections(
xyxy=np.array([[10, 10, 20, 20]]),
metadata={"calibration_matrix": np.array([[1, 0], [0, 1]])},
),
Detections(
xyxy=np.array([[30, 30, 40, 40]]),
metadata={"calibration_matrix": np.array([[1, 0], [0, 1]])},
),
],
Detections(
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]]),
metadata={"calibration_matrix": np.array([[1, 0], [0, 1]])},
),
DoesNotRaise(),
), # Identical 2D numpy arrays in metadata
(
[
Detections(
xyxy=np.array([[10, 10, 20, 20]]),
metadata={"calibration_matrix": np.array([[1, 0], [0, 1]])},
),
Detections(
xyxy=np.array([[30, 30, 40, 40]]),
metadata={"calibration_matrix": np.array([[2, 0], [0, 2]])},
),
],
None,
pytest.raises(ValueError),
), # Mismatching 2D numpy arrays in metadata
],
)
def test_merge(
@@ -302,7 +505,7 @@ def test_merge(
) -> None:
with exception:
result = Detections.merge(detections_list=detections_list)
assert result == expected_result
assert result == expected_result, f"Expected: {expected_result}, Got: {result}"
@pytest.mark.parametrize(
+6 -6
View File
@@ -493,7 +493,7 @@ def test_line_zone_multiple_detections(
@pytest.mark.parametrize(
"vector, xyxy_sequence, triggering_anchors, crossing_acceptance_threshold, "
"vector, xyxy_sequence, triggering_anchors, minimum_crossing_threshold, "
"expected_crossed_in, expected_crossed_out",
[
( # Detection lingers around line, all crosses counted
@@ -578,7 +578,7 @@ def test_line_zone_one_detection_long_horizon(
vector: Vector,
xyxy_sequence: List[List[float]],
triggering_anchors: List[Position],
crossing_acceptance_threshold: int,
minimum_crossing_threshold: int,
expected_crossed_in: List[bool],
expected_crossed_out: List[bool],
) -> None:
@@ -586,7 +586,7 @@ def test_line_zone_one_detection_long_horizon(
start=vector.start,
end=vector.end,
triggering_anchors=triggering_anchors,
crossing_acceptance_threshold=crossing_acceptance_threshold,
minimum_crossing_threshold=minimum_crossing_threshold,
)
crossed_in_list = []
@@ -609,7 +609,7 @@ def test_line_zone_one_detection_long_horizon(
@pytest.mark.parametrize(
"vector, xyxy_sequence, anchors, crossing_acceptance_threshold, "
"vector, xyxy_sequence, anchors, minimum_crossing_threshold, "
"expected_crossed_in, expected_crossed_out, expected_count_in, "
"expected_count_out, exception",
[
@@ -743,7 +743,7 @@ def test_line_zone_long_horizon_disappearing_detections(
vector: Vector,
xyxy_sequence: List[List[Optional[List[float]]]],
anchors: List[Position],
crossing_acceptance_threshold: int,
minimum_crossing_threshold: int,
expected_crossed_in: List[List[bool]],
expected_crossed_out: List[List[bool]],
expected_count_in: List[int],
@@ -755,7 +755,7 @@ def test_line_zone_long_horizon_disappearing_detections(
start=vector.start,
end=vector.end,
triggering_anchors=anchors,
crossing_acceptance_threshold=crossing_acceptance_threshold,
minimum_crossing_threshold=minimum_crossing_threshold,
)
crossed_in_list = []
crossed_out_list = []
+161
View File
@@ -14,6 +14,7 @@ from supervision.detection.utils import (
filter_polygons_by_area,
get_data_item,
merge_data,
merge_metadata,
move_boxes,
process_roboflow_result,
scale_boxes,
@@ -1138,3 +1139,163 @@ def test_xywh_to_xyxy(xywh: np.ndarray, expected_result: np.ndarray) -> None:
def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None:
result = xcycwh_to_xyxy(xcycwh)
np.testing.assert_array_equal(result, expected_result)
@pytest.mark.parametrize(
"metadata_list, expected_result, exception",
[
# Identical metadata with a single key
([{"key1": "value1"}, {"key1": "value1"}], {"key1": "value1"}, DoesNotRaise()),
# Identical metadata with multiple keys
(
[
{"key1": "value1", "key2": "value2"},
{"key1": "value1", "key2": "value2"},
],
{"key1": "value1", "key2": "value2"},
DoesNotRaise(),
),
# Conflicting values for the same key
([{"key1": "value1"}, {"key1": "value2"}], None, pytest.raises(ValueError)),
# Different sets of keys across dictionaries
([{"key1": "value1"}, {"key2": "value2"}], None, pytest.raises(ValueError)),
# Empty metadata list
([], {}, DoesNotRaise()),
# Empty metadata dictionaries
([{}, {}], {}, DoesNotRaise()),
# Different declaration order for keys
(
[
{"key1": "value1", "key2": "value2"},
{"key2": "value2", "key1": "value1"},
],
{"key1": "value1", "key2": "value2"},
DoesNotRaise(),
),
# Nested metadata dictionaries
(
[{"key1": {"sub_key": "sub_value"}}, {"key1": {"sub_key": "sub_value"}}],
{"key1": {"sub_key": "sub_value"}},
DoesNotRaise(),
),
# Large metadata dictionaries with many keys
(
[
{f"key{i}": f"value{i}" for i in range(100)},
{f"key{i}": f"value{i}" for i in range(100)},
],
{f"key{i}": f"value{i}" for i in range(100)},
DoesNotRaise(),
),
# Mixed types in list metadata values
(
[{"key1": ["value1", 2, True]}, {"key1": ["value1", 2, True]}],
{"key1": ["value1", 2, True]},
DoesNotRaise(),
),
# Identical lists across metadata dictionaries
(
[{"key1": [1, 2, 3]}, {"key1": [1, 2, 3]}],
{"key1": [1, 2, 3]},
DoesNotRaise(),
),
# Identical numpy arrays across metadata dictionaries
(
[{"key1": np.array([1, 2, 3])}, {"key1": np.array([1, 2, 3])}],
{"key1": np.array([1, 2, 3])},
DoesNotRaise(),
),
# Identical numpy arrays across metadata dictionaries, different datatype
(
[
{"key1": np.array([1, 2, 3], dtype=np.int32)},
{"key1": np.array([1, 2, 3], dtype=np.int64)},
],
{"key1": np.array([1, 2, 3])},
DoesNotRaise(),
),
# Conflicting lists for the same key
([{"key1": [1, 2, 3]}, {"key1": [4, 5, 6]}], None, pytest.raises(ValueError)),
# Conflicting numpy arrays for the same key
(
[{"key1": np.array([1, 2, 3])}, {"key1": np.array([4, 5, 6])}],
None,
pytest.raises(ValueError),
),
# Mixed data types: list and numpy array for the same key
(
[{"key1": [1, 2, 3]}, {"key1": np.array([1, 2, 3])}],
None,
pytest.raises(ValueError),
),
# Empty lists and numpy arrays for the same key
([{"key1": []}, {"key1": np.array([])}], None, pytest.raises(ValueError)),
# Identical multi-dimensional lists across metadata dictionaries
(
[{"key1": [[1, 2], [3, 4]]}, {"key1": [[1, 2], [3, 4]]}],
{"key1": [[1, 2], [3, 4]]},
DoesNotRaise(),
),
# Identical multi-dimensional numpy arrays across metadata dictionaries
(
[
{"key1": np.arange(4).reshape(2, 2)},
{"key1": np.arange(4).reshape(2, 2)},
],
{"key1": np.arange(4).reshape(2, 2)},
DoesNotRaise(),
),
# Conflicting multi-dimensional lists for the same key
(
[{"key1": [[1, 2], [3, 4]]}, {"key1": [[5, 6], [7, 8]]}],
None,
pytest.raises(ValueError),
),
# Conflicting multi-dimensional numpy arrays for the same key
(
[
{"key1": np.arange(4).reshape(2, 2)},
{"key1": np.arange(4, 8).reshape(2, 2)},
],
None,
pytest.raises(ValueError),
),
# Mixed types with multi-dimensional list and array for the same key
(
[{"key1": [[1, 2], [3, 4]]}, {"key1": np.arange(4).reshape(2, 2)}],
None,
pytest.raises(ValueError),
),
# Identical higher-dimensional (3D) numpy arrays across
# metadata dictionaries
(
[
{"key1": np.arange(8).reshape(2, 2, 2)},
{"key1": np.arange(8).reshape(2, 2, 2)},
],
{"key1": np.arange(8).reshape(2, 2, 2)},
DoesNotRaise(),
),
# Differently-shaped higher-dimensional (3D) numpy arrays
# across metadata dictionaries
(
[
{"key1": np.arange(8).reshape(2, 2, 2)},
{"key1": np.arange(8).reshape(4, 1, 2)},
],
None,
pytest.raises(ValueError),
),
],
)
def test_merge_metadata(metadata_list, expected_result, exception):
with exception:
result = merge_metadata(metadata_list)
if expected_result is None:
assert result is None, f"Expected an error, but got a result {result}"
for key, value in result.items():
assert key in expected_result
if isinstance(value, np.ndarray):
np.testing.assert_array_equal(value, expected_result[key])
else:
assert value == expected_result[key]