Merge branch 'develop' into feature/remove-false-line-counts

This commit is contained in:
LinasKo 2024-11-06 14:14:50 +02:00
commit ee3aa9f08d
45 changed files with 2744 additions and 747 deletions

37
.github/workflows/poetry-test.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: 🔧 Poetry Check and Installation Test Workflow
on:
push:
paths:
- 'poetry.lock'
- 'pyproject.toml'
pull_request:
paths:
- 'poetry.lock'
- 'pyproject.toml'
workflow_dispatch:
jobs:
poetry-tests:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
runs-on: ${{ matrix.os }}
steps:
- name: 📥 Checkout the repository
uses: actions/checkout@v4
- name: 🐍 Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: 📦 Install the base dependencies
run: python -m pip install --upgrade poetry
- name: 🔍 Check the correctness of the project config
run: poetry check
- name: 🚀 Do Install the package Test
run: poetry install

View File

@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- name: 🛎️ Checkout
uses: actions/checkout@v4

View File

@ -1,18 +0,0 @@
name: Welcome WorkFlow
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
jobs:
build:
name: 👋 Welcome
runs-on: ubuntu-latest
steps:
- uses: actions/first-interaction@v1.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: "Hello there, thank you for opening an Issue ! 🙏🏻 The team was notified and they will get back to you asap."
pr-message: "Hello there, thank you for opening an PR ! 🙏🏻 The team was notified and they will get back to you asap."

View File

@ -7,7 +7,7 @@ ci:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: test/.*\.py
@ -32,7 +32,7 @@ repos:
additional_dependencies: ["bandit[toml]"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.8
rev: v0.7.2
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]

View File

@ -34,8 +34,6 @@
**We write your reusable computer vision tools.** Whether you need to load your dataset from your hard drive, draw detections on an image or video, or count how many detections are in a zone. You can count on us! 🤝
[![supervision-hackfest](https://github.com/roboflow/supervision/assets/26109316/c05cc954-b9a6-4ed5-9a52-d0b4b619ff65)](https://github.com/orgs/roboflow/projects)
## 💻 install
Pip install the supervision package in a

View File

@ -1,5 +1,144 @@
# CHANGELOG
### 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)
```python
import supervision as sv
from supervision.metrics import F1Score
predictions = sv.Detections(...)
targets = sv.Detections(...)
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)
```
- Added new cookbook: [Small Object Detection with SAHI](https://supervision.roboflow.com/0.24.0/notebooks/small-object-detection-with-sahi/). This cookbook provides a detailed guide on using [`InferenceSlicer`](https://supervision.roboflow.com/0.24.0/detection/tools/inference_slicer/) for small object detection. [#1483](https://github.com/roboflow/supervision/pull/1483)
- Added an [Embedded Workflow](https://roboflow.com/workflows), which allows you to [preview annotators](https://supervision.roboflow.com/0.24.0/detection/annotators/). [#1533](https://github.com/roboflow/supervision/pull/1533)
- Enhanced [`LineZoneAnnotator`](https://supervision.roboflow.com/0.24.0/detection/tools/line_zone/#supervision.detection.line_zone.LineZoneAnnotator), allowing the labels to align with the line, even when it's not horizontal. Also, you can now disable text background, and choose to draw labels off-center which minimizes overlaps for multiple [`LineZone`](https://supervision.roboflow.com/0.24.0/detection/tools/line_zone/#supervision.detection.line_zone.LineZone) labels. [#854](https://github.com/roboflow/supervision/pull/854)
```python
import supervision as sv
import cv2
image = cv2.imread("<SOURCE_IMAGE_PATH>")
line_zone = sv.LineZone(
start=sv.Point(0, 100),
end=sv.Point(50, 200)
)
line_zone_annotator = sv.LineZoneAnnotator(
text_orient_to_line=True,
display_text_box=False,
text_centered=False
)
annotated_frame = line_zone_annotator.annotate(
frame=image.copy(), line_counter=line_zone
)
sv.plot_image(frame)
```
- Added per-class counting capabilities to [`LineZone`](https://supervision.roboflow.com/0.24.0/detection/tools/line_zone/#supervision.detection.line_zone.LineZone) and introduced [`LineZoneAnnotatorMulticlass`](https://supervision.roboflow.com/0.24.0/detection/tools/line_zone/#supervision.detection.line_zone.LineZoneAnnotatorMulticlass) for visualizing the counts per class. This feature allows tracking of individual classes crossing a line, enhancing the flexibility of use cases like traffic monitoring or crowd analysis. [#1555](https://github.com/roboflow/supervision/pull/1555)
```python
import supervision as sv
import cv2
image = cv2.imread("<SOURCE_IMAGE_PATH>")
line_zone = sv.LineZone(
start=sv.Point(0, 100),
end=sv.Point(50, 200)
)
line_zone_annotator = sv.LineZoneAnnotatorMulticlass()
frame = line_zone_annotator.annotate(
frame=frame, line_zones=[line_zone]
)
sv.plot_image(frame)
```
- Added [`from_easyocr`](https://supervision.roboflow.com/0.24.0/detection/core/#supervision.detection.core.Detections.from_easyocr), allowing integration of OCR results into the supervision framework. [EasyOCR](https://github.com/JaidedAI/EasyOCR) is an open-source optical character recognition (OCR) library that can read text from images. [#1515](https://github.com/roboflow/supervision/pull/1515)
```python
import supervision as sv
import easyocr
import cv2
image = cv2.imread("<SOURCE_IMAGE_PATH>")
reader = easyocr.Reader(["en"])
result = reader.readtext("<SOURCE_IMAGE_PATH>", paragraph=True)
detections = sv.Detections.from_easyocr(result)
box_annotator = sv.BoxAnnotator(color_lookup=sv.ColorLookup.INDEX)
label_annotator = sv.LabelAnnotator(color_lookup=sv.ColorLookup.INDEX)
annotated_image = image.copy()
annotated_image = box_annotator.annotate(scene=annotated_image, detections=detections)
annotated_image = label_annotator.annotate(scene=annotated_image, detections=detections)
sv.plot_image(annotated_image)
```
- Added [`oriented_box_iou_batch`](https://supervision.roboflow.com/0.24.0/detection/utils/#supervision.detection.utils.oriented_box_iou_batch) function to `detection.utils`. This function computes Intersection over Union (IoU) for oriented or rotated bounding boxes (OBB). [#1502](https://github.com/roboflow/supervision/pull/1502)
```python
import numpy as np
boxes_true = np.array([[[1, 0], [0, 1], [3, 4], [4, 3]]])
boxes_detection = np.array([[[1, 1], [2, 0], [4, 2], [3, 3]]])
ious = sv.oriented_box_iou_batch(boxes_true, boxes_detection)
print("IoU between true and detected boxes:", ious)
```
- Extended [`PolygonZoneAnnotator`](https://supervision.roboflow.com/0.24.0/detection/tools/polygon_zone/#supervision.detection.tools.polygon_zone.PolygonZoneAnnotator) to allow setting opacity when drawing zones, providing enhanced visualization by filling the zone with adjustable transparency. [#1527](https://github.com/roboflow/supervision/pull/1527)
```python
import cv2
from ncnn.model_zoo import get_model
import supervision as sv
image = cv2.imread("<SOURCE_IMAGE_PATH>")
model = get_model(
"yolov8s",
target_size=640,
prob_threshold=0.5,
nms_threshold=0.45,
num_threads=4,
use_gpu=True,
)
result = model(image)
detections = sv.Detections.from_ncnn(result)
```
!!! failure "Removed"
The `frame_resolution_wh` parameter in [`PolygonZone`](https://supervision.roboflow.com/0.24.0/detection/tools/polygon_zone/#supervision.detection.tools.polygon_zone.PolygonZone) has been removed.
!!! failure "Removed"
Supervision installation methods `"headless"` and `"desktop"` were removed, as they are no longer needed. `pip install supervision[headless]` will install the base library and harmlessly warn of non-existent extras.
- Supervision now depends on `opencv-python` rather than `opencv-python-headless`. [#1530](https://github.com/roboflow/supervision/pull/1530)
- Fixed the COCO 101 point Average Precision algorithm to correctly interpolate precision, providing a more precise calculation of average precision without averaging out intermediate values. [#1500](https://github.com/roboflow/supervision/pull/1500)
- Resolved miscellaneous issues highlighted when building documentation. This mostly includes whitespace adjustments and type inconsistencies. Updated documentation for clarity and fixed formatting issues. Added explicit version for `mkdocstrings-python`. [#1549](https://github.com/roboflow/supervision/pull/1549)
- Enabled and fixed Ruff rules for code formatting, including changes like avoiding unnecessary iterable allocations and using Optional for default mutable arguments. [#1526](https://github.com/roboflow/supervision/pull/1526)
### 0.23.0 <small>Aug 28, 2024</small>
- Added [#930](https://github.com/roboflow/supervision/pull/930): `IconAnnotator`, a [new annotator](https://supervision.roboflow.com/0.23.0/detection/annotators/#supervision.annotators.core.IconAnnotator) that allows drawing icons on each detection. Useful if you want to draw a specific icon for each class.
@ -117,15 +256,19 @@ for frame in sv.get_video_frames_generator(
- Fix [#1424](https://github.com/roboflow/supervision/pull/1424): `plot_image` function now clearly indicates that the size is in inches.
!!! failure "Removed"
The `track_buffer`, `track_thresh`, and `match_thresh` parameters in [`ByteTrack`](trackers.md/#supervision.tracker.byte_tracker.core.ByteTrack) are deprecated and were removed as of `supervision-0.23.0`. Use `lost_track_buffer,` `track_activation_threshold`, and `minimum_matching_threshold` instead.
!!! failure "Removed"
The `triggering_position` parameter in [`sv.PolygonZone`](detection/tools/polygon_zone.md/#supervision.detection.tools.polygon_zone.PolygonZone) was removed as of `supervision-0.23.0`. Use `triggering_anchors` instead.
!!! failure "Deprecated"
`overlap_filter_strategy` in `InferenceSlicer.__init__` is deprecated and will be removed in `supervision-0.27.0`. Use `overlap_strategy` instead.
!!! failure "Deprecated"
`overlap_ratio_wh` in `InferenceSlicer.__init__` is deprecated and will be removed in `supervision-0.27.0`. Use `overlap_wh` instead.
### 0.22.0 <small>Jul 12, 2024</small>
@ -133,9 +276,11 @@ for frame in sv.get_video_frames_generator(
- Added [#1326](https://github.com/roboflow/supervision/pull/1326): [`sv.DetectionsDataset`](https://supervision.roboflow.com/0.22.0/datasets/core/#supervision.dataset.core.DetectionDataset) and [`sv.ClassificationDataset`](https://supervision.roboflow.com/0.22.0/datasets/core/#supervision.dataset.core.ClassificationDataset) allowing to load the images into memory only when necessary (lazy loading).
!!! failure "Deprecated"
Constructing `DetectionDataset` with parameter `images` as `Dict[str, np.ndarray]` is deprecated and will be removed in `supervision-0.26.0`. Please pass a list of paths `List[str]` instead.
!!! failure "Deprecated"
The `DetectionDataset.images` property is deprecated and will be removed in `supervision-0.26.0`. Please loop over images with `for path, image, annotation in dataset:`, as that does not require loading all images into memory.
```python
@ -192,7 +337,7 @@ annotated_frame = mask_annotator.annotate(scene=image.copy(), detections=detecti
```
- Added [#1277](https://github.com/roboflow/supervision/pull/1277): if you provide a font that supports symbols of a language, [`sv.RichLabelAnnotator`](https://supervision.roboflow.com/0.22.0/detection/annotators/#supervision.annotators.core.LabelAnnotator.annotate) will draw them on your images.
- Various other annotators have been revised to ensure proper in-place functionality when used with `numpy` arrays. Additionally, we fixed a bug where `sv.ColorAnnotator` was filling boxes with solid color when used in-place.
- Various other annotators have been revised to ensure proper in-place functionality when used with `numpy` arrays. Additionally, we fixed a bug where `sv.ColorAnnotator` was filling boxes with solid color when used in-place.
```python
import cv2
@ -230,9 +375,11 @@ annotated_image = obb_annotator.annotate(scene=image.copy(), detections=detectio
- Fixed [#1312](https://github.com/roboflow/supervision/pull/1312): Fixed [`CropAnnotator`](https://supervision.roboflow.com/0.22.0/detection/annotators/#supervision.annotators.core.TraceAnnotator.annotate).
!!! failure "Removed"
`BoxAnnotator` was removed, however `BoundingBoxAnnotator` has been renamed to `BoxAnnotator`. Use a combination of [`BoxAnnotator`](https://supervision.roboflow.com/0.22.0/detection/annotators/#supervision.annotators.core.BoxAnnotator) and [`LabelAnnotator`](https://supervision.roboflow.com/0.22.0/detection/annotators/#supervision.annotators.core.LabelAnnotator) to simulate old `BoundingBox` behavior.
!!! failure "Deprecated"
The name `BoundingBoxAnnotator` has been deprecated and will be removed in `supervision-0.26.0`. It has been renamed to [`BoxAnnotator`](https://supervision.roboflow.com/0.22.0/detection/annotators/#supervision.annotators.core.BoxAnnotator).
- Added [#975](https://github.com/roboflow/supervision/pull/975) 📝 New Cookbooks: serialize detections into [json](https://github.com/roboflow/supervision/blob/de896189b83a1f9434c0a37dd9192ee00d2a1283/docs/notebooks/serialise-detections-to-json.ipynb) and [csv](https://github.com/roboflow/supervision/blob/de896189b83a1f9434c0a37dd9192ee00d2a1283/docs/notebooks/serialise-detections-to-csv.ipynb).
@ -242,27 +389,35 @@ annotated_image = obb_annotator.annotate(scene=image.copy(), detections=detectio
- Added [#1340](https://github.com/roboflow/supervision/pull/1340): Two new methods for converting between bounding box formats - [`xywh_to_xyxy`](https://supervision.roboflow.com/0.22.0/detection/utils/#supervision.detection.utils.xywh_to_xyxy) and [`xcycwh_to_xyxy`](https://supervision.roboflow.com/0.22.0/detection/utils/#supervision.detection.utils.xcycwh_to_xyxy)
!!! failure "Removed"
`from_roboflow` method has been removed due to deprecation. Use [from_inference](https://supervision.roboflow.com/0.22.0/detection/core/#supervision.detection.core.Detections.from_inference) instead.
!!! failure "Removed"
`Color.white()` has been removed due to deprecation. Use `color.WHITE` instead.
!!! failure "Removed"
`Color.black()` has been removed due to deprecation. Use `color.BLACK` instead.
!!! failure "Removed"
`Color.red()` has been removed due to deprecation. Use `color.RED` instead.
!!! failure "Removed"
`Color.green()` has been removed due to deprecation. Use `color.GREEN` instead.
!!! failure "Removed"
`Color.blue()` has been removed due to deprecation. Use `color.BLUE` instead.
!!! failure "Removed"
`ColorPalette.default()` has been removed due to deprecation. Use [ColorPalette.DEFAULT](https://supervision.roboflow.com/0.22.0/utils/draw/#supervision.draw.color.ColorPalette.DEFAULT) instead.
!!! failure "Removed"
`FPSMonitor.__call__` has been removed due to deprecation. Use the attribute [FPSMonitor.fps](https://supervision.roboflow.com/0.22.0/utils/video/#supervision.utils.video.FPSMonitor.fps) instead.
### 0.21.0 <small>Jun 5, 2024</small>
@ -371,6 +526,7 @@ annotated_image = edge_annotators.annotate(image.copy(), keypoints)
- Changed [#1109](https://github.com/roboflow/supervision/pull/1109): [`sv.PolygonZone`](/0.20.0/detection/tools/polygon_zone/#supervision.detection.tools.polygon_zone.PolygonZone) such that the `frame_resolution_wh` argument is no longer required to initialize `sv.PolygonZone`.
!!! failure "Deprecated"
The `frame_resolution_wh` parameter in `sv.PolygonZone` is deprecated and will be removed in `supervision-0.24.0`.
- Changed [#1084](https://github.com/roboflow/supervision/pull/1084): [`sv.get_polygon_center`](/0.20.0/utils/geometry/#supervision.geometry.core.utils.get_polygon_center) to calculate a more accurate polygon centroid.
@ -476,11 +632,13 @@ annotated_frame = crop_annotator.annotate(
- Changed [#787](https://github.com/roboflow/supervision/pull/787): [`sv.ByteTrack`](/0.19.0/trackers/#supervision.tracker.ByteTrack) input arguments and docstrings updated to improve readability and ease of use.
!!! failure "Deprecated"
The `track_buffer`, `track_thresh`, and `match_thresh` parameters in `sv.ByteTrack` are deprecated and will be removed in `supervision-0.23.0`. Use `lost_track_buffer,` `track_activation_threshold`, and `minimum_matching_threshold` instead.
- Changed [#910](https://github.com/roboflow/supervision/pull/910): [`sv.PolygonZone`](/0.19.0/detection/tools/polygon_zone/#supervision.detection.tools.polygon_zone.PolygonZone) to now accept a list of specific box anchors that must be in zone for a detection to be counted.
!!! failure "Deprecated"
The `triggering_position ` parameter in `sv.PolygonZone` is deprecated and will be removed in `supervision-0.23.0`. Use `triggering_anchors` instead.
- Changed [#875](https://github.com/roboflow/supervision/pull/875): annotators adding support for Pillow images. All supervision Annotators can now accept an image as either a numpy array or a Pillow Image. They automatically detect its type, draw annotations, and return the output in the same format as the input.
@ -544,6 +702,7 @@ ColorPalette(colors=[Color(r=68, g=1, b=84), Color(r=59, g=82, b=139), ...])
- Changed [#756](https://github.com/roboflow/supervision/pull/756): [`sv.Color`](/0.18.0/draw/color/#color)'s and [`sv.ColorPalette`](/0.18.0/draw/color/#colorpalette)'s method of accessing predefined colors, transitioning from a function-based approach (`sv.Color.red()`) to a more intuitive and conventional property-based method (`sv.Color.RED`).
!!! failure "Deprecated"
`sv.ColorPalette.default()` is deprecated and will be removed in `supervision-0.22.0`. Use `sv.ColorPalette.DEFAULT` instead.
- Changed [#769](https://github.com/roboflow/supervision/pull/769): [`sv.ColorPalette.DEFAULT`](/0.18.0/draw/color/#colorpalette) value, giving users a more extensive set of annotation colors.
@ -551,6 +710,7 @@ ColorPalette(colors=[Color(r=68, g=1, b=84), Color(r=59, g=82, b=139), ...])
- Changed [#677](https://github.com/roboflow/supervision/pull/677): `sv.Detections.from_roboflow` to [`sv.Detections.from_inference`](/0.18.0/detection/core/#supervision.detection.core.Detections.from_inference) streamlining its functionality to be compatible with both the both [inference](https://github.com/roboflow/inference) pip package and the Robloflow [hosted API](https://docs.roboflow.com/deploy/hosted-api).
!!! failure "Deprecated"
`Detections.from_roboflow()` is deprecated and will be removed in `supervision-0.22.0`. Use `Detections.from_inference` instead.
- Fixed [#735](https://github.com/roboflow/supervision/pull/735): [`sv.LineZone`](/0.18.0/detection/tools/line_zone/#linezone) functionality to accurately update the counter when an object crosses a line from any direction, including from the side. This enhancement enables more precise tracking and analytics, such as calculating individual in/out counts for each lane on the road.
@ -648,6 +808,7 @@ ColorPalette(colors=[Color(r=68, g=1, b=84), Color(r=59, g=82, b=139), ...])
- Fixed [#430](https://github.com/roboflow/supervision/pull/430): [`sv.ByteTrack`](/0.16.0/trackers/#supervision.tracker.byte_tracker.core.ByteTrack) to return `np.array([], dtype=int)` when `svDetections` is empty.
!!! failure "Deprecated"
`sv.Detections.from_yolov8` and `sv.Classifications.from_yolov8` as those are now replaced by [`sv.Detections.from_ultralytics`](/0.16.0/detection/core/#supervision.detection.core.Detections.from_ultralytics) and [`sv.Classifications.from_ultralytics`](/0.16.0/classification/core/#supervision.classification.core.Classifications.from_ultralytics).
### 0.15.0 <small>October 5, 2023</small>
@ -715,6 +876,7 @@ ColorPalette(colors=[Color(r=68, g=1, b=84), Color(r=59, g=82, b=139), ...])
- Added [#281](https://github.com/roboflow/supervision/pull/281): [`sv.Classifications.from_ultralytics`](/0.14.0/classification/core/#supervision.classification.core.Classifications.from_ultralytics) to enable seamless integration with [Ultralytics](https://github.com/ultralytics/ultralytics) framework. This will enable you to use supervision with all [models](https://docs.ultralytics.com/models/) that Ultralytics supports.
!!! failure "Deprecated"
[sv.Detections.from_yolov8](/0.14.0/detection/core/#supervision.detection.core.Detections.from_yolov8) and [sv.Classifications.from_yolov8](/0.14.0/classification/core/#supervision.classification.core.Classifications.from_yolov8) are now deprecated and will be removed with `supervision-0.16.0` release.
- Added [#341](https://github.com/roboflow/supervision/pull/341): First supervision usage example script showing how to detect and track objects on video using YOLOv8 + Supervision.
@ -752,6 +914,7 @@ ColorPalette(colors=[Color(r=68, g=1, b=84), Color(r=59, g=82, b=139), ...])
- Added [#222](https://github.com/roboflow/supervision/pull/222): [`sv.Detections.from_ultralytics`](/0.13.0/detection/core/#supervision.detection.core.Detections.from_ultralytics) to enable seamless integration with [Ultralytics](https://github.com/ultralytics/ultralytics) framework. This will enable you to use `supervision` with all [models](https://docs.ultralytics.com/models/) that Ultralytics supports.
!!! failure "Deprecated"
[`sv.Detections.from_yolov8`](/0.13.0/detection/core/#supervision.detection.core.Detections.from_yolov8) is now deprecated and will be removed with `supervision-0.15.0` release.
- Added [#191](https://github.com/roboflow/supervision/pull/191): [`sv.Detections.from_paddledet`](/0.13.0/detection/core/#supervision.detection.core.Detections.from_paddledet) to enable seamless integration with [PaddleDetection](https://github.com/PaddlePaddle/PaddleDetection) framework.
@ -761,6 +924,7 @@ ColorPalette(colors=[Color(r=68, g=1, b=84), Color(r=59, g=82, b=139), ...])
### 0.12.0 <small>July 24, 2023</small>
!!! failure "Python 3.7. Support Terminated"
With the `supervision-0.12.0` release, we are terminating official support for Python 3.7.
- Added [#177](https://github.com/roboflow/supervision/pull/177): initial support for object detection model benchmarking with [`sv.ConfusionMatrix`](/0.12.0/metrics/detection/#confusionmatrix).

View File

@ -7,8 +7,6 @@ status: deprecated
These features are phased out due to better alternatives or potential issues in future versions. Deprecated functionalities are supported for **five subsequent releases**, providing time for users to transition to updated methods.
- The `frame_resolution_wh ` parameter in [`sv.PolygonZone`](detection/tools/polygon_zone.md/#supervision.detection.tools.polygon_zone.PolygonZone) will be removed in `supervision-0.24.0`.
- Constructing [`DetectionDataset`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset) and [`ClassificationDataset`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.ClassificationDataset) with parameter `images` as `Dict[str, np.ndarray]` will be removed in `supervision-0.26.0`. Please pass a list of paths `List[str]` instead.
- The `DetectionDataset.images` property will be removed in `supervision-0.26.0`. Please loop over images with `for path, image, annotation in dataset:`, as that does not require loading all images into memory.
@ -21,10 +19,15 @@ These features are phased out due to better alternatives or potential issues in
# Removed
### 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.
- Supervision installation methods `"headless"` and `"desktop"` were removed, as they are no longer needed. `pip install supervision[headless]` will install the base library and harmlessly warn of non-existent extras.
### 0.23.0
- The `track_buffer`, `track_thresh`, and `match_thresh` parameters in [`ByteTrack`](trackers.md/#supervision.tracker.byte_tracker.core.ByteTrack) are deprecated and were removed as of `supervision-0.23.0`. Use `lost_track_buffer,` `track_activation_threshold`, and `minimum_matching_threshold` instead.
- The `triggering_position ` parameter in [`sv.PolygonZone`](detection/tools/polygon_zone.md/#supervision.detection.tools.polygon_zone.PolygonZone) was removed as of `supervision-0.23.0`. Use `triggering_anchors ` instead.
- The `triggering_position ` parameter in [`sv.PolygonZone`](detection/tools/polygon_zone.md/#supervision.detection.tools.polygon_zone.PolygonZone) was removed as of `supervision-0.23.0`. Use `triggering_anchors` instead.
### 0.22.0

View File

@ -5,9 +5,7 @@ status: new
# Annotators
Supervision provides a variety of annotators to annotate detections on images and videos. You can try them out below, with a [Workflow](https://roboflow.com/workflows) that runs [Microsoft's COCO](https://cocodataset.org/#home) dataset through a Instance Segmentation model and annotates the detections using supervision's annotators.
<div style="height: 400px; width: 100%; border-radius: 8px; overflow: hidden;"><iframe src="https://app.roboflow.com/workflows/embed/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3b3JrZmxvd0lkIjoiNDdtd2xuWW16S25VNWtOYUZjMG8iLCJ3b3Jrc3BhY2VJZCI6ImtyT1RBYm5jRmhvUU1DZExPbGU0IiwidXNlcklkIjoiRVJNUFBZY3FQMmZWWjB1NkRpNXZaYXJDdlZPMiIsImlhdCI6MTcyNjgzOTM2N30.gj2F6SnmmURAScJe4PTC1raUXsAK5mZyrUIGIJ44NhM" loading="lazy" title="Roboflow Workflow for Supervision Annotators" style="width: 100%; height: 100%; min-height: 400px; border: none;"></iframe></div>
Annotators accept detections and apply box or mask visualizations to the detections. Annotators have many available styles.
=== "Box"
@ -485,6 +483,13 @@ Supervision provides a variety of annotators to annotate detections on images an
</div>
<div class="md-typeset">
<h2>Try Supervision Annotators on your own image</h2>
Visualize annotators on images with COCO classes such as people, vehicles, animals, household items.
</div>
<div style="height: 400px; width: 100%; border-radius: 8px; overflow: hidden;"><iframe src="https://app.roboflow.com/workflows/embed/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3b3JrZmxvd0lkIjoiNDdtd2xuWW16S25VNWtOYUZjMG8iLCJ3b3Jrc3BhY2VJZCI6ImtyT1RBYm5jRmhvUU1DZExPbGU0IiwidXNlcklkIjoiRVJNUFBZY3FQMmZWWjB1NkRpNXZaYXJDdlZPMiIsImlhdCI6MTcyNjgzOTM2N30.gj2F6SnmmURAScJe4PTC1raUXsAK5mZyrUIGIJ44NhM?hideToolbar=true&hideHeader=true&defaultVisual=true" loading="lazy" title="Roboflow Workflow for Supervision Annotators" style="width: 100%; height: 100%; min-height: 400px; border: none;"></iframe></div>
<div class="md-typeset">
<h2><a href="#supervision.annotators.core.BoxAnnotator">BoxAnnotator</a></h2>
</div>

View File

@ -1,5 +1,6 @@
---
comments: true
status: new
---
<div class="md-typeset">

View File

@ -1,5 +1,6 @@
---
comments: true
status: new
---
<div class="md-typeset">

View File

@ -1,5 +1,6 @@
---
comments: true
status: new
---
# Detection Utils

View File

@ -1,6 +1,5 @@
---
comments: true
status: new
---
# Annotators

View File

@ -0,0 +1,20 @@
---
comments: true
status: new
---
# Common Values
This page contains supplementary values, types and enums that metrics use.
<div class="md-typeset">
<h2><a href="#supervision.metrics.core.MetricTarget">MetricTarget</a></h2>
</div>
:::supervision.metrics.core.MetricTarget
<div class="md-typeset">
<h2><a href="#supervision.metrics.core.AveragingMethod">AveragingMethod</a></h2>
</div>
:::supervision.metrics.core.AveragingMethod

18
docs/metrics/precision.md Normal file
View File

@ -0,0 +1,18 @@
---
comments: true
status: new
---
# Precision
<div class="md-typeset">
<h2><a href="#supervision.metrics.precision.Precision">Precision</a></h2>
</div>
:::supervision.metrics.precision.Precision
<div class="md-typeset">
<h2><a href="#supervision.metrics.precision.PrecisionResult">PrecisionResult</a></h2>
</div>
:::supervision.metrics.precision.PrecisionResult

18
docs/metrics/recall.md Normal file
View File

@ -0,0 +1,18 @@
---
comments: true
status: new
---
# Recall
<div class="md-typeset">
<h2><a href="#supervision.metrics.recall.Recall">Recall</a></h2>
</div>
:::supervision.metrics.recall.Recall
<div class="md-typeset">
<h2><a href="#supervision.metrics.recall.RecallResult">RecallResult</a></h2>
</div>
:::supervision.metrics.recall.RecallResult

View File

@ -15,7 +15,7 @@
"\n",
"This cookbook shows how to use [Slicing Aided Hyper Inference (SAHI) ](https://arxiv.org/abs/2202.06934) for small object detection with `supervision`.\n",
"\n",
"![\"Small Object Detection\"](https://raw.githubusercontent.com/ediardo/notebooks/main/sahi/animation.gif \"Small Object Detection\")\n",
"![\"Small Object Detection\"](https://media.roboflow.com/supervision/cookbooks/sahi/animation.gif \"Small Object Detection\")\n",
"\n",
"Click the Open in Colab button to run the cookbook on Google Colab.\n",
"\n",
@ -70,7 +70,7 @@
"\n",
"Detecting people (or their heads) is a common problem that has been addressed by many researchers in the past. In this project, we\u2019ll use an open-source public dataset and a fine-tuned model to perform inference on images.\n",
"\n",
"![Roboflow Universe](https://raw.githubusercontent.com/ediardo/notebooks/main/sahi/roboflow_universe.png \"Open source model for counting people's heads\")\n",
"![Roboflow Universe](https://media.roboflow.com/supervision/cookbooks/sahi/roboflow_universe.png \"Open source model for counting people's heads\")\n",
"\n",
"Some details about the project [\"people_counterv0 Computer Vision Project\"](https://universe.roboflow.com/sit-cx0ng/people_counterv0):\n",
"\n",
@ -782,9 +782,9 @@
"\n",
"| Example| Observations |\n",
"|----|----|\n",
"| ![Overlapping](https://github.com/ediardo/notebooks/blob/main/sahi/overlapping_1.png?raw=true \"Overlapping\") | False Negative, Incomplete bbox |\n",
"| ![Overlapping](https://raw.githubusercontent.com/ediardo/notebooks/main/sahi/overlapping_2.png \"Overlapping\")| Double detection, Incomplete bbox|\n",
"| ![Overlapping](https://raw.githubusercontent.com/ediardo/notebooks/main/sahi/overlapping_3.png \"Overlapping\")| Incomplete bounding box|\n",
"| ![Overlapping](https://media.roboflow.com/supervision/cookbooks/sahi/overlapping_1.png \"Overlapping\") | False Negative, Incomplete bbox |\n",
"| ![Overlapping](https://media.roboflow.com/supervision/cookbooks/sahi/overlapping_2.png \"Overlapping\")| Double detection, Incomplete bbox|\n",
"| ![Overlapping](https://media.roboflow.com/supervision/cookbooks/sahi/overlapping_3.png \"Overlapping\")| Incomplete bounding box|\n",
"\n",
"## Improving Object Detection Near Boundaries with Overlapping\n",
"\n",

View File

@ -1,6 +1,5 @@
---
comments: true
status: new
---
# Image Utils

View File

@ -1,6 +1,5 @@
---
comments: true
status: new
---
# Video Utils

View File

@ -66,7 +66,10 @@ nav:
- Utils: datasets/utils.md
- Metrics:
- mAP: metrics/mean_average_precision.md
- Precision: metrics/precision.md
- Recall: metrics/recall.md
- F1 Score: metrics/f1_score.md
- Common Values: metrics/common_values.md
- Legacy Metrics: detection/metrics.md
- Utils:
- Video: utils/video.md

916
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "supervision"
version = "0.24.0rc1"
version = "0.24.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 = [
@ -9,7 +9,7 @@ maintainers = [
]
readme = "README.md"
license = "MIT"
packages = [{ include = "supervision" }]
packages = [{ include = "supervision" }, { include = "supervision/py.typed" }]
homepage = "https://github.com/roboflow/supervision"
repository = "https://github.com/roboflow/supervision"
documentation = "https://supervision.roboflow.com/latest/"
@ -46,17 +46,32 @@ python = "^3.8"
numpy = [
{ version = ">=1.21.2,<1.23.3", python = "<=3.10" },
{ version = ">=1.23.3", python = ">3.10" },
{ version = ">=2.1.0", python = ">=3.13" },
]
scipy = [
{ version = "1.10.0", python = "<3.9" },
{ version = "^1.10.0", python = ">=3.9" },
{ version = ">=1.14.1", python = ">=3.13" },
]
# Matplotlib sub-dependency
# The 'contourpy' package is required by Matplotlib for contour plotting.
# We need to ensure compatibility with both Python 3.8 and Python 3.13.
#
# For Python 3.8 and above, we use version 1.0.7 or higher, as it is the lowest major version that supports Python 3.8.
# For Python 3.13 and above, we use version 1.3.0 or higher, as it is the first version that explicitly supports Python 3.13.
contourpy = [
{ version = ">=1.0.7", python = ">=3.8" },
{ version = ">=1.3.0", python = ">=3.13" },
]
matplotlib = ">=3.6.0"
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.5", optional = true }
tqdm = { version = ">=4.62.3,<=4.66.6", 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 }
@ -92,7 +107,7 @@ mike = "^2.0.0"
# For Documentation Development use Python 3.10 or above
# Use Latest mkdocs-jupyter min 0.24.6 for Jupyter Notebook Theme support
mkdocs-jupyter = "^0.24.3"
mkdocs-git-committers-plugin-2 = "^2.2.3"
mkdocs-git-committers-plugin-2 = "^2.4.1"
mkdocs-git-revision-date-localized-plugin = "^1.2.4"
[tool.poetry.group.typecheck]

View File

@ -55,7 +55,7 @@ def merge_class_lists(class_lists: List[List[str]]) -> List[str]:
for class_list in class_lists:
for class_name in class_list:
unique_classes.add(class_name.lower())
unique_classes.add(class_name)
return sorted(list(unique_classes))

View File

@ -32,8 +32,10 @@ from supervision.detection.utils import (
extract_ultralytics_masks,
get_data_item,
is_data_equal,
is_metadata_equal,
mask_to_xyxy,
merge_data,
merge_metadata,
process_roboflow_result,
xywh_to_xyxy,
)
@ -125,6 +127,9 @@ class Detections:
data (Dict[str, Union[np.ndarray, List]]): A dictionary containing additional
data where each key is a string representing the data type, and the value
is either a NumPy array or a list of corresponding data.
metadata (Dict[str, Any]): A dictionary containing collection-level metadata
that applies to the entire set of detections. This may include information such
as the video name, camera parameters, timestamp, or other global metadata.
""" # noqa: E501 // docs
xyxy: np.ndarray
@ -133,6 +138,7 @@ class Detections:
class_id: Optional[np.ndarray] = None
tracker_id: Optional[np.ndarray] = None
data: Dict[str, Union[np.ndarray, List]] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
validate_detections_fields(
@ -185,6 +191,7 @@ class Detections:
np.array_equal(self.confidence, other.confidence),
np.array_equal(self.tracker_id, other.tracker_id),
is_data_equal(self.data, other.data),
is_metadata_equal(self.metadata, other.metadata),
]
)
@ -985,6 +992,7 @@ class Detections:
"""
empty_detections = Detections.empty()
empty_detections.data = self.data
empty_detections.metadata = self.metadata
return self == empty_detections
@classmethod
@ -1078,6 +1086,9 @@ class Detections:
data = merge_data([d.data for d in detections_list])
metadata_list = [detections.metadata for detections in detections_list]
metadata = merge_metadata(metadata_list)
return cls(
xyxy=xyxy,
mask=mask,
@ -1085,6 +1096,7 @@ class Detections:
class_id=class_id,
tracker_id=tracker_id,
data=data,
metadata=metadata,
)
def get_anchors_coordinates(self, anchor: Position) -> np.ndarray:
@ -1198,6 +1210,7 @@ class Detections:
class_id=self.class_id[index] if self.class_id is not None else None,
tracker_id=self.tracker_id[index] if self.tracker_id is not None else None,
data=get_data_item(self.data, index),
metadata=self.metadata,
)
def __setitem__(self, key: str, value: Union[np.ndarray, List]):
@ -1459,6 +1472,8 @@ def merge_inner_detection_object_pair(
else:
winning_detection = detections_2
metadata = merge_metadata([detections_1.metadata, detections_2.metadata])
return Detections(
xyxy=merged_xyxy,
mask=merged_mask,
@ -1466,6 +1481,7 @@ def merge_inner_detection_object_pair(
class_id=winning_detection.class_id,
tracker_id=winning_detection.tracker_id,
data=winning_detection.data,
metadata=metadata,
)

View File

@ -771,6 +771,19 @@ class LineZoneAnnotatorMulticlass:
line_zones: List[LineZone],
line_zone_labels: Optional[List[str]] = None,
) -> np.ndarray:
"""
Draws a table with the number of objects of each class that crossed each line.
Attributes:
frame (np.ndarray): The image on which the table will be drawn.
line_zones (List[LineZone]): The line zones to be annotated.
line_zone_labels (Optional[List[str]]): The labels, one for each
line zone. If not provided, the default labels will be used.
Returns:
(np.ndarray): The image with the table drawn on it.
"""
if line_zone_labels is None:
line_zone_labels = [f"Line {i + 1}:" for i in range(len(line_zones))]
if len(line_zones) != len(line_zone_labels):

View File

@ -1,6 +1,5 @@
import warnings
from dataclasses import replace
from typing import Iterable, Optional, Tuple
from typing import Iterable, Optional
import cv2
import numpy as np
@ -12,13 +11,18 @@ from supervision.draw.color import Color
from supervision.draw.utils import draw_filled_polygon, draw_polygon, draw_text
from supervision.geometry.core import Position
from supervision.geometry.utils import get_polygon_center
from supervision.utils.internal import SupervisionWarnings
class PolygonZone:
"""
A class for defining a polygon-shaped zone within a frame for detecting objects.
!!! warning
LineZone uses the `tracker_id`. Read
[here](/latest/trackers/) to learn how to plug
tracking into your inference pipeline.
Attributes:
polygon (np.ndarray): A polygon represented by a numpy array of shape
`(N, 2)`, containing the `x`, `y` coordinates of the points.
@ -28,22 +32,35 @@ class PolygonZone:
(default: (sv.Position.BOTTOM_CENTER,)).
current_count (int): The current count of detected objects within the zone
mask (np.ndarray): The 2D bool mask for the polygon zone
Example:
```python
import supervision as sv
from ultralytics import YOLO
import numpy as np
import cv2
image = cv2.imread(<SOURCE_IMAGE_PATH>)
model = YOLO("yolo11s")
tracker = sv.ByteTrack()
polygon = np.array([[100, 200], [200, 100], [300, 200], [200, 300]])
polygon_zone = sv.PolygonZone(polygon=polygon)
result = model.infer(image)[0]
detections = sv.Detections.from_ultralytics(result)
detections = tracker.update_with_detections(detections)
is_detections_in_zone = polygon_zone.trigger(detections)
print(polygon_zone.current_count)
```
"""
def __init__(
self,
polygon: npt.NDArray[np.int64],
frame_resolution_wh: Optional[Tuple[int, int]] = None,
triggering_anchors: Iterable[Position] = (Position.BOTTOM_CENTER,),
):
if frame_resolution_wh is not None:
warnings.warn(
"The `frame_resolution_wh` parameter is no longer required and will be "
"dropped in version supervision-0.24.0. The mask resolution is now "
"calculated automatically based on the polygon coordinates.",
category=SupervisionWarnings,
)
self.polygon = polygon.astype(int)
self.triggering_anchors = triggering_anchors
if not list(self.triggering_anchors):
@ -99,7 +116,7 @@ class PolygonZoneAnnotator:
Attributes:
zone (PolygonZone): The polygon zone to be annotated
color (Color): The color to draw the polygon lines
color (Color): The color to draw the polygon lines, default is white
thickness (int): The thickness of the polygon lines, default is 2
text_color (Color): The color of the text on the polygon, default is black
text_scale (float): The scale of the text on the polygon, default is 0.5
@ -115,7 +132,7 @@ class PolygonZoneAnnotator:
def __init__(
self,
zone: PolygonZone,
color: Color,
color: Color = Color.WHITE,
thickness: int = 2,
text_color: Color = Color.BLACK,
text_scale: float = 0.5,

View File

@ -1,5 +1,5 @@
from itertools import chain
from typing import Dict, List, Optional, Tuple, Union
from typing import Any, Dict, List, Optional, Tuple, Union
import cv2
import numpy as np
@ -23,10 +23,9 @@ def polygon_to_mask(polygon: np.ndarray, resolution_wh: Tuple[int, int]) -> np.n
np.ndarray: The generated 2D mask, where the polygon is marked with
`1`'s and the rest is filled with `0`'s.
"""
width, height = resolution_wh
mask = np.zeros((height, width))
cv2.fillPoly(mask, [polygon], color=1)
width, height = map(int, resolution_wh)
mask = np.zeros((height, width), dtype=np.uint8)
cv2.fillPoly(mask, [polygon.astype(np.int32)], color=1)
return mask
@ -163,9 +162,9 @@ def oriented_box_iou_batch(
boxes_true = boxes_true.reshape(-1, 4, 2)
boxes_detection = boxes_detection.reshape(-1, 4, 2)
max_height = max(boxes_true[:, :, 0].max(), boxes_detection[:, :, 0].max()) + 1
max_height = int(max(boxes_true[:, :, 0].max(), boxes_detection[:, :, 0].max()) + 1)
# adding 1 because we are 0-indexed
max_width = max(boxes_true[:, :, 1].max(), boxes_detection[:, :, 1].max()) + 1
max_width = int(max(boxes_true[:, :, 1].max(), boxes_detection[:, :, 1].max()) + 1)
mask_true = np.zeros((boxes_true.shape[0], max_height, max_width))
for i, box_true in enumerate(boxes_true):
@ -808,12 +807,36 @@ def is_data_equal(data_a: Dict[str, np.ndarray], data_b: Dict[str, np.ndarray])
)
def is_metadata_equal(metadata_a: Dict[str, Any], metadata_b: Dict[str, Any]) -> bool:
"""
Compares the metadata payloads of two Detections instances.
Args:
metadata_a, metadata_b: The metadata payloads of the instances.
Returns:
True if the metadata payloads are equal, False otherwise.
"""
return set(metadata_a.keys()) == set(metadata_b.keys()) and all(
np.array_equal(metadata_a[key], metadata_b[key])
if (
isinstance(metadata_a[key], np.ndarray)
and isinstance(metadata_b[key], np.ndarray)
)
else metadata_a[key] == metadata_b[key]
for key in metadata_a
)
def merge_data(
data_list: List[Dict[str, Union[npt.NDArray[np.generic], List]]],
) -> Dict[str, Union[npt.NDArray[np.generic], List]]:
"""
Merges the data payloads of a list of Detections instances.
Warning: Assumes that empty detections were filtered-out before passing data to
this function.
Args:
data_list: The data payloads of the Detections instances. Each data payload
is a dictionary with the same keys, and the values are either lists or
@ -866,6 +889,45 @@ def merge_data(
return merged_data
def merge_metadata(metadata_list: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Merge metadata from a list of metadata dictionaries.
This function combines the metadata dictionaries. If a key appears in more than one
dictionary, the values must be identical for the merge to succeed.
Warning: Assumes that empty detections were filtered-out before passing metadata to
this function.
Args:
metadata_list (List[Dict[str, Any]]): A list of metadata dictionaries to merge.
Returns:
Dict[str, Any]: A single merged metadata dictionary.
Raises:
ValueError: If there are conflicting values for the same key or if
dictionaries have different keys.
"""
if not metadata_list:
return {}
all_keys_sets = [set(metadata.keys()) for metadata in metadata_list]
if not all(keys_set == all_keys_sets[0] for keys_set in all_keys_sets):
raise ValueError("All metadata dictionaries must have the same keys to merge.")
merged_metadata: Dict[str, Any] = {}
for metadata in metadata_list:
for key, value in metadata.items():
if key in merged_metadata:
if merged_metadata[key] != value:
raise ValueError(f"Conflicting metadata for key: '{key}'.")
else:
merged_metadata[key] = value
return merged_metadata
def get_data_item(
data: Dict[str, Union[np.ndarray, List]],
index: Union[int, slice, List[int], np.ndarray],

View File

@ -9,7 +9,11 @@ from supervision.geometry.core import Point, Rect
def draw_line(
scene: np.ndarray, start: Point, end: Point, color: Color, thickness: int = 2
scene: np.ndarray,
start: Point,
end: Point,
color: Color = Color.ROBOFLOW,
thickness: int = 2,
) -> np.ndarray:
"""
Draws a line on a given scene.
@ -18,7 +22,7 @@ def draw_line(
scene (np.ndarray): The scene on which the line will be drawn
start (Point): The starting point of the line
end (Point): The end point of the line
color (Color): The color of the line
color (Color): The color of the line, defaults to Color.ROBOFLOW
thickness (int): The thickness of the line
Returns:
@ -35,7 +39,7 @@ def draw_line(
def draw_rectangle(
scene: np.ndarray, rect: Rect, color: Color, thickness: int = 2
scene: np.ndarray, rect: Rect, color: Color = Color.ROBOFLOW, thickness: int = 2
) -> np.ndarray:
"""
Draws a rectangle on an image.
@ -60,7 +64,7 @@ def draw_rectangle(
def draw_filled_rectangle(
scene: np.ndarray, rect: Rect, color: Color, opacity: float = 1
scene: np.ndarray, rect: Rect, color: Color = Color.ROBOFLOW, opacity: float = 1
) -> np.ndarray:
"""
Draws a filled rectangle on an image.
@ -151,14 +155,17 @@ def draw_rounded_rectangle(
def draw_polygon(
scene: np.ndarray, polygon: np.ndarray, color: Color, thickness: int = 2
scene: np.ndarray,
polygon: np.ndarray,
color: Color = Color.ROBOFLOW,
thickness: int = 2,
) -> np.ndarray:
"""Draw a polygon on a scene.
Parameters:
scene (np.ndarray): The scene to draw the polygon on.
polygon (np.ndarray): The polygon to be drawn, given as a list of vertices.
color (Color): The color of the polygon.
color (Color): The color of the polygon. Defaults to Color.ROBOFLOW.
thickness (int): The thickness of the polygon lines, by default 2.
Returns:
@ -171,14 +178,17 @@ def draw_polygon(
def draw_filled_polygon(
scene: np.ndarray, polygon: np.ndarray, color: Color, opacity: float = 1
scene: np.ndarray,
polygon: np.ndarray,
color: Color = Color.ROBOFLOW,
opacity: float = 1,
) -> np.ndarray:
"""Draw a filled polygon on a scene.
Parameters:
scene (np.ndarray): The scene to draw the polygon on.
polygon (np.ndarray): The polygon to be drawn, given as a list of vertices.
color (Color): The color of the polygon.
color (Color): The color of the polygon. Defaults to Color.ROBOFLOW.
opacity (float): The opacity of polygon when drawn on the scene.
Returns:

View File

@ -16,6 +16,9 @@ def get_polygon_center(polygon: np.ndarray) -> Point:
Point: The center of the polygon, represented as a
Point object with x and y attributes.
Raises:
ValueError: If the polygon has no vertices.
Examples:
```python
import numpy as np
@ -30,6 +33,9 @@ def get_polygon_center(polygon: np.ndarray) -> Point:
# This is one of the 3 candidate algorithms considered for centroid calculation.
# For a more detailed discussion, see PR #1084 and commit eb33176
if len(polygon) == 0:
raise ValueError("Polygon must have at least one vertex.")
shift_polygon = np.roll(polygon, -1, axis=0)
signed_areas = np.cross(polygon, shift_polygon) / 2
if signed_areas.sum() == 0:

View File

@ -1,5 +1,4 @@
from supervision.metrics.core import (
CLASS_ID_NONE,
AveragingMethod,
Metric,
MetricTarget,
@ -9,6 +8,8 @@ from supervision.metrics.mean_average_precision import (
MeanAveragePrecision,
MeanAveragePrecisionResult,
)
from supervision.metrics.precision import Precision, PrecisionResult
from supervision.metrics.recall import Recall, RecallResult
from supervision.metrics.utils.object_size import (
ObjectSizeCategory,
get_detection_size_category,

View File

@ -4,9 +4,6 @@ from abc import ABC, abstractmethod
from enum import Enum
from typing import Any
CLASS_ID_NONE = -1
"""Used by metrics module as class ID, when none is present"""
class Metric(ABC):
"""
@ -40,9 +37,10 @@ class MetricTarget(Enum):
"""
Specifies what type of detection is used to compute the metric.
* BOXES: xyxy bounding boxes
* MASKS: Binary masks
* ORIENTED_BOUNDING_BOXES: Oriented bounding boxes (OBB)
Attributes:
BOXES: xyxy bounding boxes
MASKS: Binary masks
ORIENTED_BOUNDING_BOXES: Oriented bounding boxes (OBB)
"""
BOXES = "boxes"
@ -57,15 +55,16 @@ class AveragingMethod(Enum):
Suppose, before returning the final result, a metric is computed for each class.
How do you combine those to get the final number?
* MACRO: Calculate the metric for each class and average the results. The simplest
averaging method, but it does not take class imbalance into account.
* MICRO: Calculate the metric globally by counting the total true positives, false
positives, and false negatives. Micro averaging is useful when you want to give
more importance to classes with more samples. It's also more appropriate if you
have an imbalance in the number of instances per class.
* WEIGHTED: Calculate the metric for each class and average the results, weighted by
the number of true instances of each class. Use weighted averaging if you want
to take class imbalance into account.
Attributes:
MACRO: Calculate the metric for each class and average the results. The simplest
averaging method, but it does not take class imbalance into account.
MICRO: Calculate the metric globally by counting the total true positives, false
positives, and false negatives. Micro averaging is useful when you want to
give more importance to classes with more samples. It's also more
appropriate if you have an imbalance in the number of instances per class.
WEIGHTED: Calculate the metric for each class and average the results, weighted
by the number of true instances of each class. Use weighted averaging if
you want to take class imbalance into account.
"""
MACRO = "macro"

View File

@ -9,7 +9,11 @@ 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
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 AveragingMethod, Metric, MetricTarget
from supervision.metrics.utils.object_size import (
@ -23,23 +27,55 @@ if TYPE_CHECKING:
class F1Score(Metric):
"""
F1 Score is a metric used to evaluate object detection models. It is the harmonic
mean of precision and recall, calculated at different IoU thresholds.
In simple terms, F1 Score is a measure of a model's balance between precision and
recall (accuracy and completeness), calculated as:
`F1 = 2 * (precision * recall) / (precision + recall)`
Example:
```python
import supervision as sv
from supervision.metrics import F1Score
predictions = sv.Detections(...)
targets = sv.Detections(...)
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)
```
"""
def __init__(
self,
metric_target: MetricTarget = MetricTarget.BOXES,
averaging_method: AveragingMethod = AveragingMethod.WEIGHTED,
):
self._metric_target = metric_target
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
raise NotImplementedError(
"F1 score is not implemented for oriented bounding boxes."
)
"""
Initialize the F1Score metric.
Args:
metric_target (MetricTarget): The type of detection data to use.
averaging_method (AveragingMethod): The averaging method used to compute the
F1 scores. Determines how the F1 scores are aggregated across classes.
"""
self._metric_target = metric_target
self.averaging_method = averaging_method
self._predictions_list: List[Detections] = []
self._targets_list: List[Detections] = []
def reset(self) -> None:
"""
Reset the metric to its initial state, clearing all stored data.
"""
self._predictions_list = []
self._targets_list = []
@ -48,6 +84,16 @@ class F1Score(Metric):
predictions: Union[Detections, List[Detections]],
targets: Union[Detections, List[Detections]],
) -> F1Score:
"""
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:
(F1Score): The updated metric instance.
"""
if not isinstance(predictions, list):
predictions = [predictions]
if not isinstance(targets, list):
@ -65,6 +111,13 @@ class F1Score(Metric):
return self
def compute(self) -> F1ScoreResult:
"""
Calculate the F1 score metric based on the stored predictions and ground-truth
data, at different IoU thresholds.
Returns:
(F1ScoreResult): The F1 score metric result.
"""
result = self._compute(self._predictions_list, self._targets_list)
small_predictions, small_targets = self._filter_predictions_and_targets_by_size(
@ -112,8 +165,12 @@ class F1Score(Metric):
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 NotImplementedError(
raise ValueError(
"Unsupported metric target for IoU calculation"
)
@ -312,12 +369,22 @@ class F1Score(Metric):
return (
detections.mask
if detections.mask is not None
else np.empty((0, 0, 0), dtype=bool)
else self._make_empty_content()
)
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
if obb := detections.data.get(ORIENTED_BOX_COORDINATES):
return np.ndarray(obb, dtype=np.float32)
return np.empty((0, 8), dtype=np.float32)
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(
@ -373,7 +440,6 @@ class F1ScoreResult:
The results of the F1 score metric calculation.
Defaults to `0` if no detections or targets were provided.
Provides a custom `__str__` method for pretty printing.
Attributes:
metric_target (MetricTarget): the type of data used for the metric -

View File

@ -9,7 +9,11 @@ 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
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 (
@ -23,6 +27,27 @@ if TYPE_CHECKING:
class MeanAveragePrecision(Metric):
"""
Mean Average Precision (mAP) is a metric used to evaluate object detection models.
It is the average of the precision-recall curves at different IoU thresholds.
Example:
```python
import supervision as sv
from supervision.metrics import MeanAveragePrecision
predictions = sv.Detections(...)
targets = sv.Detections(...)
map_metric = MeanAveragePrecision()
map_result = map_metric.update(predictions, targets).compute()
print(map_result)
print(map_result.map50_95)
map_result.plot()
```
"""
def __init__(
self,
metric_target: MetricTarget = MetricTarget.BOXES,
@ -36,17 +61,15 @@ class MeanAveragePrecision(Metric):
class_agnostic (bool): Whether to treat all data as a single class.
"""
self._metric_target = metric_target
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
raise NotImplementedError(
"Mean Average Precision is not implemented for oriented bounding boxes."
)
self._class_agnostic = class_agnostic
self._predictions_list: List[Detections] = []
self._targets_list: List[Detections] = []
def reset(self) -> None:
"""
Reset the metric to its initial state, clearing all stored data.
"""
self._predictions_list = []
self._targets_list = []
@ -76,6 +99,15 @@ class MeanAveragePrecision(Metric):
f" targets ({len(targets)}) during the update must be the same."
)
if self._class_agnostic:
predictions = deepcopy(predictions)
targets = deepcopy(targets)
for prediction in predictions:
prediction.class_id[:] = -1
for target in targets:
target.class_id[:] = -1
self._predictions_list.extend(predictions)
self._targets_list.extend(targets)
@ -86,26 +118,10 @@ class MeanAveragePrecision(Metric):
) -> MeanAveragePrecisionResult:
"""
Calculate Mean Average Precision based on predicted and ground-truth
detections at different thresholds.
detections at different thresholds.
Returns:
(MeanAveragePrecisionResult): New instance of MeanAveragePrecision.
Example:
```python
import supervision as sv
from supervision.metrics import MeanAveragePrecision
predictions = sv.Detections(...)
targets = sv.Detections(...)
map_metric = MeanAveragePrecision()
map_result = map_metric.update(predictions, targets).compute()
print(map_result)
print(map_result.map50_95)
map_result.plot()
```
(MeanAveragePrecisionResult): The Mean Average Precision result.
"""
result = self._compute(self._predictions_list, self._targets_list)
@ -172,14 +188,19 @@ class MeanAveragePrecision(Metric):
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 NotImplementedError(
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,
@ -203,6 +224,7 @@ class MeanAveragePrecision(Metric):
return MeanAveragePrecisionResult(
metric_target=self._metric_target,
is_class_agnostic=self._class_agnostic,
mAP_scores=mAP_scores,
iou_thresholds=iou_thresholds,
matched_classes=unique_classes,
@ -230,7 +252,7 @@ class MeanAveragePrecision(Metric):
for r, p in zip(recall[::-1], precision[::-1]):
precision_levels[recall_levels <= r] = p
average_precision = (1 / 100 * precision_levels).sum()
average_precision = (1 / 101 * precision_levels).sum()
return average_precision
@staticmethod
@ -332,8 +354,9 @@ class MeanAveragePrecision(Metric):
else self._make_empty_content()
)
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
if obb := detections.data.get(ORIENTED_BOX_COORDINATES):
return np.ndarray(obb, dtype=np.float32)
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}")
@ -343,7 +366,7 @@ class MeanAveragePrecision(Metric):
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, 8), dtype=np.float32)
return np.empty((0, 4, 2), dtype=np.float32)
raise ValueError(f"Invalid metric target: {self._metric_target}")
def _filter_detections_by_size(
@ -383,6 +406,8 @@ class MeanAveragePrecisionResult:
Attributes:
metric_target (MetricTarget): the type of data used for the metric -
boxes, masks or oriented bounding boxes.
class_agnostic (bool): When computing class-agnostic results, class ID
is set to `-1`.
mAP_map50_95 (float): the mAP score at IoU thresholds from `0.5` to `0.95`.
mAP_map50 (float): the mAP score at IoU threshold of `0.5`.
mAP_map75 (float): the mAP score at IoU threshold of `0.75`.
@ -402,6 +427,7 @@ class MeanAveragePrecisionResult:
"""
metric_target: MetricTarget
is_class_agnostic: bool
@property
def map50_95(self) -> float:
@ -436,6 +462,7 @@ class MeanAveragePrecisionResult:
out_str = (
f"{self.__class__.__name__}:\n"
f"Metric target: {self.metric_target}\n"
f"Class agnostic: {self.is_class_agnostic}\n"
f"mAP @ 50:95: {self.map50_95:.4f}\n"
f"mAP @ 50: {self.map50:.4f}\n"
f"mAP @ 75: {self.map75:.4f}\n"

View File

@ -0,0 +1,616 @@
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 AveragingMethod, 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 Precision(Metric):
"""
Precision is a metric used to evaluate object detection models. It is the ratio of
true positive detections to the total number of predicted detections. We calculate
it at different IoU thresholds.
In simple terms, Precision is a measure of a model's accuracy, calculated as:
`Precision = TP / (TP + FP)`
Here, `TP` is the number of true positives (correct detections), and `FP` is the
number of false positive detections (detected, but incorrectly).
Example:
```python
import supervision as sv
from supervision.metrics import Precision
predictions = sv.Detections(...)
targets = sv.Detections(...)
precision_metric = Precision()
precision_result = precision_metric.update(predictions, targets).compute()
print(precision_result)
print(precision_result.precision_at_50)
print(precision_result.small_objects.precision_at_50)
```
"""
def __init__(
self,
metric_target: MetricTarget = MetricTarget.BOXES,
averaging_method: AveragingMethod = AveragingMethod.WEIGHTED,
):
"""
Initialize the Precision metric.
Args:
metric_target (MetricTarget): The type of detection data to use.
averaging_method (AveragingMethod): The averaging method used to compute the
precision. Determines how the precision is aggregated across classes.
"""
self._metric_target = metric_target
self.averaging_method = averaging_method
self._predictions_list: List[Detections] = []
self._targets_list: List[Detections] = []
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]],
) -> Precision:
"""
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:
(Precision): 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) -> PrecisionResult:
"""
Calculate the precision metric based on the stored predictions and ground-truth
data, at different IoU thresholds.
Returns:
(PrecisionResult): The precision 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]
) -> PrecisionResult:
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 PrecisionResult(
metric_target=self._metric_target,
averaging_method=self.averaging_method,
precision_scores=np.zeros(iou_thresholds.shape[0]),
precision_per_class=np.zeros((0, iou_thresholds.shape[0])),
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)]
precision_scores, precision_per_class, unique_classes = (
self._compute_precision_for_classes(*concatenated_stats)
)
return PrecisionResult(
metric_target=self._metric_target,
averaging_method=self.averaging_method,
precision_scores=precision_scores,
precision_per_class=precision_per_class,
iou_thresholds=iou_thresholds,
matched_classes=unique_classes,
small_objects=None,
medium_objects=None,
large_objects=None,
)
def _compute_precision_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)
# Shape: PxTh,P,C,C -> CxThx3
confusion_matrix = self._compute_confusion_matrix(
matches, prediction_class_ids, unique_classes, class_counts
)
# Shape: CxThx3 -> CxTh
precision_per_class = self._compute_precision(confusion_matrix)
# Shape: CxTh -> Th
if self.averaging_method == AveragingMethod.MACRO:
precision_scores = np.mean(precision_per_class, axis=0)
elif self.averaging_method == AveragingMethod.MICRO:
confusion_matrix_merged = confusion_matrix.sum(0)
precision_scores = self._compute_precision(confusion_matrix_merged)
elif self.averaging_method == AveragingMethod.WEIGHTED:
class_counts = class_counts.astype(np.float32)
precision_scores = np.average(
precision_per_class, axis=0, weights=class_counts
)
return precision_scores, precision_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,
) -> 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.
Arguments:
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.
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:
true_positives = sorted_matches[is_class].sum(0)
false_positives = (1 - sorted_matches[is_class]).sum(0)
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_precision(confusion_matrix: np.ndarray) -> np.ndarray:
"""
Broadcastable function, computing the precision 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 precision 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_positives = confusion_matrix[..., 1]
denominator = true_positives + false_positives
precision = np.where(denominator == 0, 0, true_positives / denominator)
return precision
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]]:
"""
Filter predictions and targets by object size category.
"""
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 PrecisionResult:
"""
The results of the precision 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
precision. Determines how the precision is aggregated across classes.
precision_at_50 (float): the precision at IoU threshold of `0.5`.
precision_at_75 (float): the precision at IoU threshold of `0.75`.
precision_scores (np.ndarray): the precision scores at each IoU threshold.
Shape: `(num_iou_thresholds,)`
precision_per_class (np.ndarray): the precision 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 `precision_per_class`.
small_objects (Optional[PrecisionResult]): the Precision metric results
for small objects.
medium_objects (Optional[PrecisionResult]): the Precision metric results
for medium objects.
large_objects (Optional[PrecisionResult]): the Precision metric results
for large objects.
"""
metric_target: MetricTarget
averaging_method: AveragingMethod
@property
def precision_at_50(self) -> float:
return self.precision_scores[0]
@property
def precision_at_75(self) -> float:
return self.precision_scores[5]
precision_scores: np.ndarray
precision_per_class: np.ndarray
iou_thresholds: np.ndarray
matched_classes: np.ndarray
small_objects: Optional[PrecisionResult]
medium_objects: Optional[PrecisionResult]
large_objects: Optional[PrecisionResult]
def __str__(self) -> str:
"""
Format as a pretty string.
Example:
```python
print(precision_result)
```
"""
out_str = (
f"{self.__class__.__name__}:\n"
f"Metric target: {self.metric_target}\n"
f"Averaging method: {self.averaging_method}\n"
f"P @ 50: {self.precision_at_50:.4f}\n"
f"P @ 75: {self.precision_at_75:.4f}\n"
f"P @ thresh: {self.precision_scores}\n"
f"IoU thresh: {self.iou_thresholds}\n"
f"Precision per class:\n"
)
if self.precision_per_class.size == 0:
out_str += " No results\n"
for class_id, precision_of_class in zip(
self.matched_classes, self.precision_per_class
):
out_str += f" {class_id}: {precision_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 = {
"P@50": self.precision_at_50,
"P@75": self.precision_at_75,
}
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 precision results.
"""
labels = ["Precision@50", "Precision@75"]
values = [self.precision_at_50, self.precision_at_75]
colors = [LEGACY_COLOR_PALETTE[0]] * 2
if self.small_objects is not None:
small_objects = self.small_objects
labels += ["Small: P@50", "Small: P@75"]
values += [small_objects.precision_at_50, small_objects.precision_at_75]
colors += [LEGACY_COLOR_PALETTE[3]] * 2
if self.medium_objects is not None:
medium_objects = self.medium_objects
labels += ["Medium: P@50", "Medium: P@75"]
values += [medium_objects.precision_at_50, medium_objects.precision_at_75]
colors += [LEGACY_COLOR_PALETTE[2]] * 2
if self.large_objects is not None:
large_objects = self.large_objects
labels += ["Large: P@50", "Large: P@75"]
values += [large_objects.precision_at_50, large_objects.precision_at_75]
colors += [LEGACY_COLOR_PALETTE[4]] * 2
plt.rcParams["font.family"] = "monospace"
_, ax = plt.subplots(figsize=(10, 6))
ax.set_ylim(0, 1)
ax.set_ylabel("Value", fontweight="bold")
title = (
f"Precision, by Object Size"
f"\n(target: {self.metric_target.value},"
f" averaging: {self.averaging_method.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()

View File

@ -0,0 +1,614 @@
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 AveragingMethod, 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 Recall(Metric):
"""
Recall is a metric used to evaluate object detection models. It is the ratio of
true positive detections to the total number of ground truth instances. We calculate
it at different IoU thresholds.
In simple terms, Recall is a measure of a model's completeness, calculated as:
`Recall = TP / (TP + FN)`
Here, `TP` is the number of true positives (correct detections), and `FN` is the
number of false negatives (missed detections).
Example:
```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()
print(recall_result)
print(recall_result.recall_at_50)
print(recall_result.small_objects.recall_at_50)
```
"""
def __init__(
self,
metric_target: MetricTarget = MetricTarget.BOXES,
averaging_method: AveragingMethod = AveragingMethod.WEIGHTED,
):
"""
Initialize the Recall metric.
Args:
metric_target (MetricTarget): The type of detection data to use.
averaging_method (AveragingMethod): The averaging method used to compute the
recall. Determines how the recall is aggregated across classes.
"""
self._metric_target = metric_target
self.averaging_method = averaging_method
self._predictions_list: List[Detections] = []
self._targets_list: List[Detections] = []
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]],
) -> Recall:
"""
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) -> RecallResult:
"""
Calculate the precision metric based on the stored predictions and ground-truth
data, at different IoU thresholds.
Returns:
(RecallResult): The precision 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]
) -> RecallResult:
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 RecallResult(
metric_target=self._metric_target,
averaging_method=self.averaging_method,
recall_scores=np.zeros(iou_thresholds.shape[0]),
recall_per_class=np.zeros((0, iou_thresholds.shape[0])),
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, recall_per_class, unique_classes = (
self._compute_recall_for_classes(*concatenated_stats)
)
return RecallResult(
metric_target=self._metric_target,
averaging_method=self.averaging_method,
recall_scores=recall_scores,
recall_per_class=recall_per_class,
iou_thresholds=iou_thresholds,
matched_classes=unique_classes,
small_objects=None,
medium_objects=None,
large_objects=None,
)
def _compute_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)
# Shape: PxTh,P,C,C -> CxThx3
confusion_matrix = self._compute_confusion_matrix(
matches, prediction_class_ids, unique_classes, class_counts
)
# Shape: CxThx3 -> CxTh
recall_per_class = self._compute_recall(confusion_matrix)
# Shape: CxTh -> Th
if self.averaging_method == AveragingMethod.MACRO:
recall_scores = np.mean(recall_per_class, axis=0)
elif self.averaging_method == AveragingMethod.MICRO:
confusion_matrix_merged = confusion_matrix.sum(0)
recall_scores = self._compute_recall(confusion_matrix_merged)
elif self.averaging_method == AveragingMethod.WEIGHTED:
class_counts = class_counts.astype(np.float32)
recall_scores = np.average(recall_per_class, axis=0, weights=class_counts)
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,
) -> 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.
Arguments:
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.
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:
true_positives = sorted_matches[is_class].sum(0)
false_positives = (1 - sorted_matches[is_class]).sum(0)
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]]:
"""
Filter predictions and targets by object size category.
"""
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 RecallResult:
"""
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.
"""
metric_target: MetricTarget
averaging_method: AveragingMethod
@property
def recall_at_50(self) -> float:
return self.recall_scores[0]
@property
def recall_at_75(self) -> float:
return self.recall_scores[5]
recall_scores: np.ndarray
recall_per_class: np.ndarray
iou_thresholds: np.ndarray
matched_classes: np.ndarray
small_objects: Optional[RecallResult]
medium_objects: Optional[RecallResult]
large_objects: Optional[RecallResult]
def __str__(self) -> str:
"""
Format as a pretty string.
Example:
```python
print(recall_result)
```
"""
out_str = (
f"{self.__class__.__name__}:\n"
f"Metric target: {self.metric_target}\n"
f"Averaging method: {self.averaging_method}\n"
f"R @ 50: {self.recall_at_50:.4f}\n"
f"R @ 75: {self.recall_at_75:.4f}\n"
f"R @ thresh: {self.recall_scores}\n"
f"IoU thresh: {self.iou_thresholds}\n"
f"Recall 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 = {
"R@50": self.recall_at_50,
"R@75": self.recall_at_75,
}
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 recall results.
"""
labels = ["Recall@50", "Recall@75"]
values = [self.recall_at_50, self.recall_at_75]
colors = [LEGACY_COLOR_PALETTE[0]] * 2
if self.small_objects is not None:
small_objects = self.small_objects
labels += ["Small: R@50", "Small: R@75"]
values += [small_objects.recall_at_50, small_objects.recall_at_75]
colors += [LEGACY_COLOR_PALETTE[3]] * 2
if self.medium_objects is not None:
medium_objects = self.medium_objects
labels += ["Medium: R@50", "Medium: R@75"]
values += [medium_objects.recall_at_50, medium_objects.recall_at_75]
colors += [LEGACY_COLOR_PALETTE[2]] * 2
if self.large_objects is not None:
large_objects = self.large_objects
labels += ["Large: R@50", "Large: R@75"]
values += [large_objects.recall_at_50, large_objects.recall_at_75]
colors += [LEGACY_COLOR_PALETTE[4]] * 2
plt.rcParams["font.family"] = "monospace"
_, ax = plt.subplots(figsize=(10, 6))
ax.set_ylim(0, 1)
ax.set_ylabel("Value", fontweight="bold")
title = (
f"Recall, by Object Size"
f"\n(target: {self.metric_target.value},"
f" averaging: {self.averaging_method.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()

0
supervision/py.typed Normal file
View File

View File

@ -1,63 +0,0 @@
from collections import OrderedDict
from enum import Enum
import numpy as np
class TrackState(Enum):
New = 0
Tracked = 1
Lost = 2
Removed = 3
class BaseTrack:
_count = 0
def __init__(self):
self.track_id = 0
self.is_activated = False
self.state = TrackState.New
self.history = OrderedDict()
self.features = []
self.curr_feature = None
self.score = 0
self.start_frame = 0
self.frame_id = 0
self.time_since_update = 0
# multi-camera
self.location = (np.inf, np.inf)
@property
def end_frame(self) -> int:
return self.frame_id
@staticmethod
def next_id() -> int:
BaseTrack._count += 1
return BaseTrack._count
@staticmethod
def reset_counter():
BaseTrack._count = 0
BaseTrack.track_id = 0
BaseTrack.start_frame = 0
BaseTrack.frame_id = 0
BaseTrack.time_since_update = 0
def activate(self, *args):
raise NotImplementedError
def predict(self):
raise NotImplementedError
def update(self, *args, **kwargs):
raise NotImplementedError
def mark_lost(self):
self.state = TrackState.Lost
def mark_removed(self):
self.state = TrackState.Removed

View File

@ -5,186 +5,9 @@ import numpy as np
from supervision.detection.core import Detections
from supervision.detection.utils import box_iou_batch
from supervision.tracker.byte_tracker import matching
from supervision.tracker.byte_tracker.basetrack import BaseTrack, TrackState
from supervision.tracker.byte_tracker.kalman_filter import KalmanFilter
class STrack(BaseTrack):
shared_kalman = KalmanFilter()
_external_count = 0
def __init__(self, tlwh, score, class_ids, minimum_consecutive_frames):
# wait activate
self._tlwh = np.asarray(tlwh, dtype=np.float32)
self.kalman_filter = None
self.mean, self.covariance = None, None
self.is_activated = False
self.score = score
self.class_ids = class_ids
self.tracklet_len = 0
self.external_track_id = -1
self.minimum_consecutive_frames = minimum_consecutive_frames
def predict(self):
mean_state = self.mean.copy()
if self.state != TrackState.Tracked:
mean_state[7] = 0
self.mean, self.covariance = self.kalman_filter.predict(
mean_state, self.covariance
)
@staticmethod
def multi_predict(stracks):
if len(stracks) > 0:
multi_mean = []
multi_covariance = []
for i, st in enumerate(stracks):
multi_mean.append(st.mean.copy())
multi_covariance.append(st.covariance)
if st.state != TrackState.Tracked:
multi_mean[i][7] = 0
multi_mean, multi_covariance = STrack.shared_kalman.multi_predict(
np.asarray(multi_mean), np.asarray(multi_covariance)
)
for i, (mean, cov) in enumerate(zip(multi_mean, multi_covariance)):
stracks[i].mean = mean
stracks[i].covariance = cov
def activate(self, kalman_filter, frame_id):
"""Start a new tracklet"""
self.kalman_filter = kalman_filter
self.internal_track_id = self.next_id()
self.mean, self.covariance = self.kalman_filter.initiate(
self.tlwh_to_xyah(self._tlwh)
)
self.tracklet_len = 0
self.state = TrackState.Tracked
if frame_id == 1:
self.is_activated = True
if self.minimum_consecutive_frames == 1:
self.external_track_id = self.next_external_id()
self.frame_id = frame_id
self.start_frame = frame_id
def re_activate(self, new_track, frame_id, new_id=False):
self.mean, self.covariance = self.kalman_filter.update(
self.mean, self.covariance, self.tlwh_to_xyah(new_track.tlwh)
)
self.tracklet_len = 0
self.state = TrackState.Tracked
self.frame_id = frame_id
if new_id:
self.internal_track_id = self.next_id()
self.score = new_track.score
def update(self, new_track, frame_id):
"""
Update a matched track
:type new_track: STrack
:type frame_id: int
:type update_feature: bool
:return:
"""
self.frame_id = frame_id
self.tracklet_len += 1
new_tlwh = new_track.tlwh
self.mean, self.covariance = self.kalman_filter.update(
self.mean, self.covariance, self.tlwh_to_xyah(new_tlwh)
)
self.state = TrackState.Tracked
if self.tracklet_len == self.minimum_consecutive_frames:
self.is_activated = True
if self.external_track_id == -1:
self.external_track_id = self.next_external_id()
self.score = new_track.score
@property
def tlwh(self):
"""Get current position in bounding box format `(top left x, top left y,
width, height)`.
"""
if self.mean is None:
return self._tlwh.copy()
ret = self.mean[:4].copy()
ret[2] *= ret[3]
ret[:2] -= ret[2:] / 2
return ret
@property
def tlbr(self):
"""Convert bounding box to format `(min x, min y, max x, max y)`, i.e.,
`(top left, bottom right)`.
"""
ret = self.tlwh.copy()
ret[2:] += ret[:2]
return ret
@staticmethod
def tlwh_to_xyah(tlwh):
"""Convert bounding box to format `(center x, center y, aspect ratio,
height)`, where the aspect ratio is `width / height`.
"""
ret = np.asarray(tlwh).copy()
ret[:2] += ret[2:] / 2
ret[2] /= ret[3]
return ret
def to_xyah(self):
return self.tlwh_to_xyah(self.tlwh)
@staticmethod
def next_external_id():
STrack._external_count += 1
return STrack._external_count
@staticmethod
def reset_external_counter():
STrack._external_count = 0
@staticmethod
def tlbr_to_tlwh(tlbr):
ret = np.asarray(tlbr).copy()
ret[2:] -= ret[:2]
return ret
@staticmethod
def tlwh_to_tlbr(tlwh):
ret = np.asarray(tlwh).copy()
ret[2:] += ret[:2]
return ret
def __repr__(self):
return "OT_{}_({}-{})".format(
self.internal_track_id, self.start_frame, self.end_frame
)
def detections2boxes(detections: Detections) -> np.ndarray:
"""
Convert Supervision Detections to numpy tensors for further computation.
Args:
detections (Detections): Detections/Targets in the format of sv.Detections.
Returns:
(np.ndarray): Detections as numpy tensors as in
`(x_min, y_min, x_max, y_max, confidence, class_id)` order.
"""
return np.hstack(
(
detections.xyxy,
detections.confidence[:, np.newaxis],
detections.class_id[:, np.newaxis],
)
)
from supervision.tracker.byte_tracker.single_object_track import STrack, TrackState
from supervision.tracker.byte_tracker.utils import IdCounter
class ByteTrack:
@ -230,11 +53,17 @@ class ByteTrack:
self.max_time_lost = int(frame_rate / 30.0 * lost_track_buffer)
self.minimum_consecutive_frames = minimum_consecutive_frames
self.kalman_filter = KalmanFilter()
self.shared_kalman = KalmanFilter()
self.tracked_tracks: List[STrack] = []
self.lost_tracks: List[STrack] = []
self.removed_tracks: List[STrack] = []
# Warning, possible bug: If you also set internal_id to start at 1,
# all traces will be connected across objects.
self.internal_id_counter = IdCounter()
self.external_id_counter = IdCounter(start_id=1)
def update_with_detections(self, detections: Detections) -> Detections:
"""
Updates the tracker with the provided detections and returns the updated
@ -274,8 +103,12 @@ class ByteTrack:
)
```
"""
tensors = detections2boxes(detections=detections)
tensors = np.hstack(
(
detections.xyxy,
detections.confidence[:, np.newaxis],
)
)
tracks = self.update_with_tensors(tensors=tensors)
if len(tracks) > 0:
@ -301,7 +134,7 @@ class ByteTrack:
return detections
def reset(self):
def reset(self) -> None:
"""
Resets the internal state of the ByteTrack tracker.
@ -311,11 +144,11 @@ class ByteTrack:
ensuring the tracker starts with a clean state for each new video.
"""
self.frame_id = 0
self.tracked_tracks: List[STrack] = []
self.lost_tracks: List[STrack] = []
self.removed_tracks: List[STrack] = []
BaseTrack.reset_counter()
STrack.reset_external_counter()
self.internal_id_counter.reset()
self.external_id_counter.reset()
self.tracked_tracks = []
self.lost_tracks = []
self.removed_tracks = []
def update_with_tensors(self, tensors: np.ndarray) -> List[STrack]:
"""
@ -333,7 +166,6 @@ class ByteTrack:
lost_stracks = []
removed_stracks = []
class_ids = tensors[:, 5]
scores = tensors[:, 4]
bboxes = tensors[:, :4]
@ -347,14 +179,18 @@ class ByteTrack:
scores_keep = scores[remain_inds]
scores_second = scores[inds_second]
class_ids_keep = class_ids[remain_inds]
class_ids_second = class_ids[inds_second]
if len(dets) > 0:
"""Detections"""
detections = [
STrack(STrack.tlbr_to_tlwh(tlbr), s, c, self.minimum_consecutive_frames)
for (tlbr, s, c) in zip(dets, scores_keep, class_ids_keep)
STrack(
STrack.tlbr_to_tlwh(tlbr),
score_keep,
self.minimum_consecutive_frames,
self.shared_kalman,
self.internal_id_counter,
self.external_id_counter,
)
for (tlbr, score_keep) in zip(dets, scores_keep)
]
else:
detections = []
@ -372,7 +208,7 @@ class ByteTrack:
""" Step 2: First association, with high score detection boxes"""
strack_pool = joint_tracks(tracked_stracks, self.lost_tracks)
# Predict the current location with KF
STrack.multi_predict(strack_pool)
STrack.multi_predict(strack_pool, self.shared_kalman)
dists = matching.iou_distance(strack_pool, detections)
dists = matching.fuse_score(dists, detections)
@ -387,7 +223,7 @@ class ByteTrack:
track.update(detections[idet], self.frame_id)
activated_starcks.append(track)
else:
track.re_activate(det, self.frame_id, new_id=False)
track.re_activate(det, self.frame_id)
refind_stracks.append(track)
""" Step 3: Second association, with low score detection boxes"""
@ -395,8 +231,15 @@ class ByteTrack:
if len(dets_second) > 0:
"""Detections"""
detections_second = [
STrack(STrack.tlbr_to_tlwh(tlbr), s, c, self.minimum_consecutive_frames)
for (tlbr, s, c) in zip(dets_second, scores_second, class_ids_second)
STrack(
STrack.tlbr_to_tlwh(tlbr),
score_second,
self.minimum_consecutive_frames,
self.shared_kalman,
self.internal_id_counter,
self.external_id_counter,
)
for (tlbr, score_second) in zip(dets_second, scores_second)
]
else:
detections_second = []
@ -416,13 +259,13 @@ class ByteTrack:
track.update(det, self.frame_id)
activated_starcks.append(track)
else:
track.re_activate(det, self.frame_id, new_id=False)
track.re_activate(det, self.frame_id)
refind_stracks.append(track)
for it in u_track:
track = r_tracked_stracks[it]
if not track.state == TrackState.Lost:
track.mark_lost()
track.state = TrackState.Lost
lost_stracks.append(track)
"""Deal with unconfirmed tracks, usually tracks with only one beginning frame"""
@ -438,7 +281,7 @@ class ByteTrack:
activated_starcks.append(unconfirmed[itracked])
for it in u_unconfirmed:
track = unconfirmed[it]
track.mark_removed()
track.state = TrackState.Removed
removed_stracks.append(track)
""" Step 4: Init new stracks"""
@ -450,8 +293,8 @@ class ByteTrack:
activated_starcks.append(track)
""" Step 5: Update state"""
for track in self.lost_tracks:
if self.frame_id - track.end_frame > self.max_time_lost:
track.mark_removed()
if self.frame_id - track.frame_id > self.max_time_lost:
track.state = TrackState.Removed
removed_stracks.append(track)
self.tracked_tracks = [
@ -497,7 +340,7 @@ def joint_tracks(
return result
def sub_tracks(track_list_a: List, track_list_b: List) -> List[int]:
def sub_tracks(track_list_a: List[STrack], track_list_b: List[STrack]) -> List[int]:
"""
Returns a list of tracks from track_list_a after removing any tracks
that share the same internal_track_id with tracks in track_list_b.
@ -518,7 +361,9 @@ def sub_tracks(track_list_a: List, track_list_b: List) -> List[int]:
return list(tracks.values())
def remove_duplicate_tracks(tracks_a: List, tracks_b: List) -> Tuple[List, List]:
def remove_duplicate_tracks(
tracks_a: List[STrack], tracks_b: List[STrack]
) -> Tuple[List[STrack], List[STrack]]:
pairwise_distance = matching.iou_distance(tracks_a, tracks_b)
matching_pairs = np.where(pairwise_distance < 0.15)

View File

@ -1,10 +1,15 @@
from typing import List, Tuple
from __future__ import annotations
from typing import TYPE_CHECKING, List, Tuple
import numpy as np
from scipy.optimize import linear_sum_assignment
from supervision.detection.utils import box_iou_batch
if TYPE_CHECKING:
from supervision.tracker.byte_tracker.core import STrack
def indices_to_matches(
cost_matrix: np.ndarray, indices: np.ndarray, thresh: float
@ -20,7 +25,7 @@ def indices_to_matches(
def linear_assignment(
cost_matrix: np.ndarray, thresh: float
) -> [np.ndarray, Tuple[int], Tuple[int, int]]:
) -> Tuple[np.ndarray, Tuple[int], Tuple[int, int]]:
if cost_matrix.size == 0:
return (
np.empty((0, 2), dtype=int),
@ -35,7 +40,7 @@ def linear_assignment(
return indices_to_matches(cost_matrix, indices, thresh)
def iou_distance(atracks: List, btracks: List) -> np.ndarray:
def iou_distance(atracks: List[STrack], btracks: List[STrack]) -> np.ndarray:
if (len(atracks) > 0 and isinstance(atracks[0], np.ndarray)) or (
len(btracks) > 0 and isinstance(btracks[0], np.ndarray)
):
@ -53,11 +58,11 @@ def iou_distance(atracks: List, btracks: List) -> np.ndarray:
return cost_matrix
def fuse_score(cost_matrix: np.ndarray, detections: List) -> np.ndarray:
def fuse_score(cost_matrix: np.ndarray, stracks: List[STrack]) -> np.ndarray:
if cost_matrix.size == 0:
return cost_matrix
iou_sim = 1 - cost_matrix
det_scores = np.array([det.score for det in detections])
det_scores = np.array([strack.score for strack in stracks])
det_scores = np.expand_dims(det_scores, axis=0).repeat(cost_matrix.shape[0], axis=0)
fuse_sim = iou_sim * det_scores
fuse_cost = 1 - fuse_sim

View File

@ -0,0 +1,178 @@
from __future__ import annotations
from enum import Enum
from typing import List
import numpy as np
import numpy.typing as npt
from supervision.tracker.byte_tracker.kalman_filter import KalmanFilter
from supervision.tracker.byte_tracker.utils import IdCounter
class TrackState(Enum):
New = 0
Tracked = 1
Lost = 2
Removed = 3
class STrack:
def __init__(
self,
tlwh: npt.NDArray[np.float32],
score: npt.NDArray[np.float32],
minimum_consecutive_frames: int,
shared_kalman: KalmanFilter,
internal_id_counter: IdCounter,
external_id_counter: IdCounter,
):
self.state = TrackState.New
self.is_activated = False
self.start_frame = 0
self.frame_id = 0
self._tlwh = np.asarray(tlwh, dtype=np.float32)
self.kalman_filter = None
self.shared_kalman = shared_kalman
self.mean, self.covariance = None, None
self.is_activated = False
self.score = score
self.tracklet_len = 0
self.minimum_consecutive_frames = minimum_consecutive_frames
self.internal_id_counter = internal_id_counter
self.external_id_counter = external_id_counter
self.internal_track_id = self.internal_id_counter.NO_ID
self.external_track_id = self.external_id_counter.NO_ID
def predict(self) -> None:
mean_state = self.mean.copy()
if self.state != TrackState.Tracked:
mean_state[7] = 0
self.mean, self.covariance = self.kalman_filter.predict(
mean_state, self.covariance
)
@staticmethod
def multi_predict(stracks: List[STrack], shared_kalman: KalmanFilter) -> None:
if len(stracks) > 0:
multi_mean = []
multi_covariance = []
for i, st in enumerate(stracks):
multi_mean.append(st.mean.copy())
multi_covariance.append(st.covariance)
if st.state != TrackState.Tracked:
multi_mean[i][7] = 0
multi_mean, multi_covariance = shared_kalman.multi_predict(
np.asarray(multi_mean), np.asarray(multi_covariance)
)
for i, (mean, cov) in enumerate(zip(multi_mean, multi_covariance)):
stracks[i].mean = mean
stracks[i].covariance = cov
def activate(self, kalman_filter: KalmanFilter, frame_id: int) -> None:
"""Start a new tracklet"""
self.kalman_filter = kalman_filter
self.internal_track_id = self.internal_id_counter.new_id()
self.mean, self.covariance = self.kalman_filter.initiate(
self.tlwh_to_xyah(self._tlwh)
)
self.tracklet_len = 0
self.state = TrackState.Tracked
if frame_id == 1:
self.is_activated = True
if self.minimum_consecutive_frames == 1:
self.external_track_id = self.external_id_counter.new_id()
self.frame_id = frame_id
self.start_frame = frame_id
def re_activate(self, new_track: STrack, frame_id: int) -> None:
self.mean, self.covariance = self.kalman_filter.update(
self.mean, self.covariance, self.tlwh_to_xyah(new_track.tlwh)
)
self.tracklet_len = 0
self.state = TrackState.Tracked
self.frame_id = frame_id
self.score = new_track.score
def update(self, new_track: STrack, frame_id: int) -> None:
"""
Update a matched track
:type new_track: STrack
:type frame_id: int
:type update_feature: bool
:return:
"""
self.frame_id = frame_id
self.tracklet_len += 1
new_tlwh = new_track.tlwh
self.mean, self.covariance = self.kalman_filter.update(
self.mean, self.covariance, self.tlwh_to_xyah(new_tlwh)
)
self.state = TrackState.Tracked
if self.tracklet_len == self.minimum_consecutive_frames:
self.is_activated = True
if self.external_track_id == self.external_id_counter.NO_ID:
self.external_track_id = self.external_id_counter.new_id()
self.score = new_track.score
@property
def tlwh(self) -> npt.NDArray[np.float32]:
"""Get current position in bounding box format `(top left x, top left y,
width, height)`.
"""
if self.mean is None:
return self._tlwh.copy()
ret = self.mean[:4].copy()
ret[2] *= ret[3]
ret[:2] -= ret[2:] / 2
return ret
@property
def tlbr(self) -> npt.NDArray[np.float32]:
"""Convert bounding box to format `(min x, min y, max x, max y)`, i.e.,
`(top left, bottom right)`.
"""
ret = self.tlwh.copy()
ret[2:] += ret[:2]
return ret
@staticmethod
def tlwh_to_xyah(tlwh) -> npt.NDArray[np.float32]:
"""Convert bounding box to format `(center x, center y, aspect ratio,
height)`, where the aspect ratio is `width / height`.
"""
ret = np.asarray(tlwh).copy()
ret[:2] += ret[2:] / 2
ret[2] /= ret[3]
return ret
def to_xyah(self) -> npt.NDArray[np.float32]:
return self.tlwh_to_xyah(self.tlwh)
@staticmethod
def tlbr_to_tlwh(tlbr) -> npt.NDArray[np.float32]:
ret = np.asarray(tlbr).copy()
ret[2:] -= ret[:2]
return ret
@staticmethod
def tlwh_to_tlbr(tlwh) -> npt.NDArray[np.float32]:
ret = np.asarray(tlwh).copy()
ret[2:] += ret[:2]
return ret
def __repr__(self) -> str:
return "OT_{}_({}-{})".format(
self.internal_track_id, self.start_frame, self.frame_id
)

View File

@ -0,0 +1,18 @@
class IdCounter:
def __init__(self, start_id: int = 0):
self.start_id = start_id
if self.start_id <= self.NO_ID:
raise ValueError(f"start_id must be greater than {self.NO_ID}")
self.reset()
def reset(self) -> None:
self._id = self.start_id
def new_id(self) -> int:
returned_id = self._id
self._id += 1
return returned_id
@property
def NO_ID(self) -> int:
return -1

View File

@ -65,8 +65,9 @@ class VideoSink:
Attributes:
target_path (str): The path to the output file where the video will be saved.
video_info (VideoInfo): Information about the video resolution, fps,
and total frame count.
video_info (Optional[VideoInfo]): Information about the output video resolution,
fps, and total frame count. If not provided, the information will be inferred
from the video path.
codec (str): FOURCC code for video format
Example:
@ -82,8 +83,16 @@ class VideoSink:
```
""" # noqa: E501 // docs
def __init__(self, target_path: str, video_info: VideoInfo, codec: str = "mp4v"):
def __init__(
self,
target_path: str,
video_info: Optional[VideoInfo] = None,
codec: str = "mp4v",
):
self.target_path = target_path
if video_info is None:
video_info = VideoInfo.from_video_path(target_path)
self.video_info = video_info
self.__codec = codec
self.__writer = None

0
test/tracker/__init__.py Normal file
View File

View File

@ -0,0 +1,40 @@
from typing import List
import numpy as np
import pytest
import supervision as sv
@pytest.mark.parametrize(
"detections, expected_results",
[
(
[
sv.Detections(
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]]),
class_id=np.array([1, 1]),
confidence=np.array([1, 1]),
),
sv.Detections(
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]]),
class_id=np.array([1, 1]),
confidence=np.array([1, 1]),
),
],
sv.Detections(
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]]),
class_id=np.array([1, 1]),
confidence=np.array([1, 1]),
tracker_id=np.array([1, 2]),
),
),
],
)
def test_byte_tracker(
detections: List[sv.Detections],
expected_results: sv.Detections,
) -> None:
byte_tracker = sv.ByteTrack()
tracked_detections = [byte_tracker.update_with_detections(d) for d in detections]
assert tracked_detections[-1] == expected_results

View File

@ -121,7 +121,15 @@ class MockDataclass:
(
Detections.empty(),
False,
{"xyxy", "class_id", "confidence", "mask", "tracker_id", "data"},
{
"xyxy",
"class_id",
"confidence",
"mask",
"tracker_id",
"data",
"metadata",
},
DoesNotRaise(),
),
(
@ -134,6 +142,7 @@ class MockDataclass:
"mask",
"tracker_id",
"data",
"metadata",
"area",
"box_area",
},
@ -149,6 +158,7 @@ class MockDataclass:
"mask",
"tracker_id",
"data",
"metadata",
},
DoesNotRaise(),
),
@ -169,13 +179,22 @@ class MockDataclass:
"mask",
"tracker_id",
"data",
"metadata",
},
DoesNotRaise(),
),
(
Detections.empty(),
False,
{"xyxy", "class_id", "confidence", "mask", "tracker_id", "data"},
{
"xyxy",
"class_id",
"confidence",
"mask",
"tracker_id",
"data",
"metadata",
},
DoesNotRaise(),
),
],

View File

@ -1,5 +1,5 @@
[tox]
envlist = py38,py39,py310,py311,py312
envlist = py38,py39,py310,py311,py312,py313
[testenv]
changedir = test