Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a61440ee0b | ||
|
|
7f7b25d7e7 | ||
|
|
72506888c0 | ||
|
|
c5cc24f22d | ||
|
|
47574ed29d | ||
|
|
6119f2017e | ||
|
|
bbc37b4b6a | ||
|
|
e12bfe263d | ||
|
|
373fbae1fa | ||
|
|
790ccace2c | ||
|
|
a99015ac2d | ||
|
|
52c772d778 | ||
|
|
5af13c0f8c | ||
|
|
e0edb28ce6 | ||
|
|
c35a35cefd | ||
|
|
9bd8f71f26 | ||
|
|
e29e3832fb | ||
|
|
a287e68c92 | ||
|
|
0422a66eaa | ||
|
|
31db2ded11 | ||
|
|
9ab9650763 | ||
|
|
353bd5336b | ||
|
|
7301156e46 | ||
|
|
e68c908077 | ||
|
|
2edcbdc487 | ||
|
|
25c2f5cf0d | ||
|
|
316cb6271d | ||
|
|
70e9af9200 | ||
|
|
38f89125b1 | ||
|
|
80d76e712a | ||
|
|
a296712000 | ||
|
|
12d7c369db | ||
|
|
c8223cbab2 | ||
|
|
2c522a9931 | ||
|
|
fc0e133ea9 | ||
|
|
15364ac9a3 | ||
|
|
e82b5b9d74 | ||
|
|
c8f5c741fd | ||
|
|
8d464aa1b5 | ||
|
|
ce28ab7fc7 | ||
|
|
fae1bc2661 | ||
|
|
4663fab4e4 | ||
|
|
db0892f3e9 | ||
|
|
d330bafaa7 | ||
|
|
8b91d733af | ||
|
|
97bf652d90 | ||
|
|
b0bbb74558 | ||
|
|
1d73468dc5 | ||
|
|
dfd2a60855 | ||
|
|
f563c5a634 | ||
|
|
35272b40d3 | ||
|
|
9320be5d5b | ||
|
|
d109fba9d8 | ||
|
|
42d92ba913 | ||
|
|
1e2dc8f88b | ||
|
|
f716b7fa3b | ||
|
|
f6d52b53db | ||
|
|
61cca76acc | ||
|
|
9ad850af46 | ||
|
|
eee6a85eaa | ||
|
|
bb87ab0a6e | ||
|
|
da7c5cd65a | ||
|
|
ceefe0bab2 | ||
|
|
78439e03cc | ||
|
|
b66416e074 | ||
|
|
6c7c49e051 | ||
|
|
b1b69ad972 | ||
|
|
9bf9685165 | ||
|
|
52be9dc818 | ||
|
|
c7e95483bf | ||
|
|
65f6e418a1 | ||
|
|
1fef946a19 | ||
|
|
4c3d51f186 | ||
|
|
9340cb962b | ||
|
|
6578b4ea52 | ||
|
|
e37b32f5ca | ||
|
|
0bd60874ca | ||
|
|
03f623920a | ||
|
|
98476dfacf | ||
|
|
35ff87b08d | ||
|
|
03eed3fd64 | ||
|
|
272d9ed5bf | ||
|
|
371f8ecc5b | ||
|
|
50358e96b6 | ||
|
|
86e10db19e | ||
|
|
652b0a190f | ||
|
|
6f87e2639e | ||
|
|
7ecfc9f475 |
@@ -16,7 +16,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Clear cache
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
console.log("Starting cache cleanup...")
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🐍 Install uv and set Python ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
@@ -42,6 +42,6 @@ jobs:
|
||||
uv run twine check --strict dist/*
|
||||
|
||||
- name: 🚀 Publish to PyPi
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
attestations: true
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
@@ -40,6 +40,6 @@ jobs:
|
||||
uv run twine check --strict dist/*
|
||||
|
||||
- name: 🚀 Publish to PyPi
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
attestations: true
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uv run twine check --strict dist/*
|
||||
|
||||
- name: 🚀 Publish to Test-PyPi
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
attestations: true
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🐍 Install uv and set Python ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
|
||||
@@ -1,5 +1,73 @@
|
||||
# Changelog
|
||||
|
||||
### 0.27.0 <small>Nov 16, 2025</small>
|
||||
|
||||
- Added [#2008](https://github.com/roboflow/supervision/pull/2008): [`sv.filter_segments_by_distance`](https://supervision.roboflow.com/0.27.0/detection/utils/masks/#supervision.detection.utils.masks.filter_segments_by_distance) to keep the largest connected component and nearby components within an absolute or relative distance threshold. Useful for cleaning segmentation predictions from models such as SAM, SAM2, YOLO segmentation, and RF-DETR segmentation.
|
||||
|
||||
- Added [#2006](https://github.com/roboflow/supervision/pull/2006): [`sv.xyxy_to_mask`](https://supervision.roboflow.com/0.27.0/detection/utils/converters/#supervision.detection.utils.converters.xyxy_to_mask) to convert bounding boxes into 2D boolean masks, where each mask corresponds to a single box.
|
||||
|
||||
- Added [#1943](https://github.com/roboflow/supervision/pull/1943): [`sv.tint_image`](https://supervision.roboflow.com/0.27.0/utils/image/#supervision.utils.image.tint_image) to apply a solid color overlay to an image at a given opacity. Works with both NumPy and PIL inputs.
|
||||
|
||||
- Added [#1943](https://github.com/roboflow/supervision/pull/1943): [`sv.grayscale_image`](https://supervision.roboflow.com/0.27.0/utils/image/#supervision.utils.image.tint_image) to convert an image to 3 channel grayscale for compatibility with color based drawing utilities.
|
||||
|
||||
- Added [#2014](https://github.com/roboflow/supervision/pull/2014): [`sv.get_image_resolution_wh`](https://supervision.roboflow.com/0.27.0/utils/image/#supervision.utils.image.get_image_resolution_wh) as a unified way to read image width and height from NumPy and PIL inputs.
|
||||
|
||||
- Added [#1912](https://github.com/roboflow/supervision/pull/1912): [`sv.edit_distance`](https://supervision.roboflow.com/0.27.0/detection/utils/vlms/#supervision.detection.utils.vlms.edit_distance) for Levenshtein distance between two strings. Supports insert, delete, and substitute operations.
|
||||
|
||||
- Added [#1912](https://github.com/roboflow/supervision/pull/1912): [`sv.fuzzy_match_index`](https://supervision.roboflow.com/0.27.0/detection/utils/vlms/#supervision.detection.utils.vlms.fuzzy_match_index) to find the first close match in a list using edit distance.
|
||||
|
||||
- Changed [#2015](https://github.com/roboflow/supervision/pull/2015): [`sv.Detections.from_vlm`](https://supervision.roboflow.com/0.27.0/detection/core/#supervision.detection.core.Detections.from_vlm) and legacy `from_lmm` now support Qwen3 VL via `vlm=sv.VLM.QWEN_3_VL`.
|
||||
|
||||
- Changed [#1884](https://github.com/roboflow/supervision/pull/1884): [`sv.Detections.from_vlm`](https://supervision.roboflow.com/0.27.0/detection/core/#supervision.detection.core.Detections.from_vlm) and legacy `from_lmm` now support DeepSeek VL 2 via `vlm=sv.VLM.DEEPSEEK_VL_2`.
|
||||
|
||||
- Changed [#2015](https://github.com/roboflow/supervision/pull/2015): [`sv.Detections.from_vlm`](https://supervision.roboflow.com/0.27.0/detection/core/#supervision.detection.core.Detections.from_vlm) now parses Qwen 2.5 VL outputs more robustly and handles incomplete or truncated JSON responses.
|
||||
|
||||
- Changed [#2014](https://github.com/roboflow/supervision/pull/2014): [`sv.InferenceSlicer`](https://supervision.roboflow.com/0.27.0/detection/tools/inference_slicer/#supervision.detection.tools.inference_slicer.InferenceSlicer) now uses a new offset generation logic that removes redundant tiles and aligns borders cleanly. This reduces the number of processed tiles and shortens inference time without hurting detection quality.
|
||||
|
||||
- Changed [#2016](https://github.com/roboflow/supervision/pull/2016): [`sv.Detections`](https://supervision.roboflow.com/0.27.0/detection/core/#supervision.detection.core.Detections) now includes a `box_aspect_ratio` property for vectorized aspect ratio computation, useful for filtering detections based on box shape.
|
||||
|
||||
- Changed [#2001](https://github.com/roboflow/supervision/pull/2001): Significantly improved the performance of [`sv.box_iou_batch`](https://supervision.roboflow.com/0.27.0/detection/utils/iou_and_nms/#supervision.detection.utils.iou_and_nms.box_iou_batch). On internal benchmarks, processing runs approximately 2x to 5x faster.
|
||||
|
||||
- Changed [#1997](https://github.com/roboflow/supervision/pull/1997): [`sv.process_video`](https://supervision.roboflow.com/0.27.0/utils/video/#supervision.utils.video.process_video) now uses a threaded reader, processor, and writer pipeline. This removes I/O stalls and improves throughput while keeping the callback single threaded and safe for stateful models.
|
||||
|
||||
- Changed: [`sv.denormalize_boxes`](https://supervision.roboflow.com/0.27.0/detection/utils/boxes/#supervision.detection.utils.boxes.denormalize_boxes) now supports batch conversion of bounding boxes. The function accepts arrays of shape `(N, 4)` and returns a batch of absolute pixel coordinates.
|
||||
|
||||
- Changed [#1917](https://github.com/roboflow/supervision/pull/1917): [`sv.LabelAnnotator`](https://supervision.roboflow.com/develop/0.27.0/annotators/#supervision.annotators.core.LabelAnnotator) and [`sv.RichLabelAnnotator`](https://supervision.roboflow.com/develop/0.27.0/annotators/#supervision.annotators.core.LabelAnnotator) now accept `text_offset=(x, y)` to shift the label relative to `text_position`. Works with smart label position and line wrapping.
|
||||
|
||||
!!! failure "Removed"
|
||||
Removed the deprecated `overlap_ratio_wh` argument from `sv.InferenceSlicer`. Use the pixel based `overlap_wh` argument to control slice overlap.
|
||||
|
||||
!!! info "Tip"
|
||||
Convert your old ratio based overlap to pixel based overlap by multiplying each ratio by the slice dimensions.
|
||||
|
||||
```python
|
||||
# before
|
||||
|
||||
slice_wh = (640, 640)
|
||||
overlap_ratio_wh = (0.25, 0.25)
|
||||
|
||||
slicer = sv.InferenceSlicer(
|
||||
callback=callback,
|
||||
slice_wh=slice_wh,
|
||||
overlap_ratio_wh=overlap_ratio_wh,
|
||||
overlap_filter=sv.OverlapFilter.NON_MAX_SUPPRESSION,
|
||||
)
|
||||
|
||||
# after
|
||||
|
||||
overlap_wh = (
|
||||
int(overlap_ratio_wh[0] * slice_wh[0]),
|
||||
int(overlap_ratio_wh[1] * slice_wh[1]),
|
||||
)
|
||||
|
||||
slicer = sv.InferenceSlicer(
|
||||
callback=callback,
|
||||
slice_wh=slice_wh,
|
||||
overlap_wh=overlap_wh,
|
||||
overlap_filter=sv.OverlapFilter.NON_MAX_SUPPRESSION,
|
||||
)
|
||||
```
|
||||
|
||||
### 0.26.1 <small>Jul 22, 2025</small>
|
||||
|
||||
- Fixed [1894](https://github.com/roboflow/supervision/pull/1894): Error in [`sv.MeanAveragePrecision`](https://supervision.roboflow.com/0.26.1/metrics/mean_average_precision/#supervision.metrics.mean_average_precision.MeanAveragePrecision) where the area used for size-specific evaluation (small / medium / large) was always zero unless explicitly provided in `sv.Detections.data`.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Annotators
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Detections
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Boxes Utils
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# IoU and NMS Utils
|
||||
|
||||
@@ -22,3 +22,9 @@ status: new
|
||||
</div>
|
||||
|
||||
:::supervision.detection.utils.masks.contains_multiple_segments
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.detection.utils.masks.filter_segments_by_distance">filter_segments_by_distance</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.detection.utils.masks.filter_segments_by_distance
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Polygons Utils
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||

|
||||
|
||||
@@ -395,7 +395,7 @@ increase the size and diversity of training datasets, leading to improved model
|
||||
performance and generalization.
|
||||
|
||||
```bash
|
||||
pip install augmentation
|
||||
pip install albumentations
|
||||
```
|
||||
|
||||
Albumentations provides a flexible and powerful API for image augmentation. The core of
|
||||
|
||||
@@ -234,7 +234,7 @@ with
|
||||
model = get_model(model_id="yolov8n-640")
|
||||
frames_generator = sv.get_video_frames_generator(<SOURCE_VIDEO_PATH>)
|
||||
|
||||
with sv.JSONSink(<TARGET_CSV_PATH>) as sink:
|
||||
with sv.JSONSink(<TARGET_JSON_PATH>) as sink:
|
||||
for frame_index, frame in enumerate(frames_generator):
|
||||
|
||||
results = model.infer(image)[0]
|
||||
@@ -250,7 +250,7 @@ with
|
||||
model = YOLO("yolov8n.pt")
|
||||
frames_generator = sv.get_video_frames_generator(<SOURCE_VIDEO_PATH>)
|
||||
|
||||
with sv.JSONSink(<TARGET_CSV_PATH>) as sink:
|
||||
with sv.JSONSink(<TARGET_JSON_PATH>) as sink:
|
||||
for frame_index, frame in enumerate(frames_generator):
|
||||
|
||||
results = model(frame)[0]
|
||||
@@ -268,7 +268,7 @@ with
|
||||
model = DetrForObjectDetection.from_pretrained("facebook/detr-resnet-50")
|
||||
frames_generator = sv.get_video_frames_generator(<SOURCE_VIDEO_PATH>)
|
||||
|
||||
with sv.JSONSink(<TARGET_CSV_PATH>) as sink:
|
||||
with sv.JSONSink(<TARGET_JSON_PATH>) as sink:
|
||||
for frame_index, frame in enumerate(frames_generator):
|
||||
|
||||
frame = sv.cv2_to_pillow(frame)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Track Objects
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Keypoint Detection
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Mean Average Precision
|
||||
|
||||
@@ -41,6 +41,12 @@ status: new
|
||||
|
||||
:::supervision.utils.image.grayscale_image
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.utils.image.get_image_resolution_wh">get_image_resolution_wh</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.utils.image.get_image_resolution_wh
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.utils.image.ImageSink">ImageSink</a></h2>
|
||||
</div>
|
||||
|
||||
+5
-4
@@ -2,7 +2,7 @@
|
||||
name = "supervision"
|
||||
description = "A set of easy-to-use utils that will come in handy in any Computer Vision project"
|
||||
license = { text = "MIT" }
|
||||
version = "0.27.0rc2"
|
||||
version = "0.27.0"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
authors = [
|
||||
@@ -67,11 +67,12 @@ dev = [
|
||||
"ipywidgets>=8.1.1",
|
||||
"jupytext>=1.16.1",
|
||||
"nbconvert>=7.14.2",
|
||||
"docutils!=0.21"
|
||||
"docutils!=0.21",
|
||||
"pre-commit>=3.8.0"
|
||||
]
|
||||
docs = [
|
||||
"mkdocs-material[imaging]>=9.5.5",
|
||||
"mkdocstrings>=0.25.2,<0.30.0",
|
||||
"mkdocstrings>=0.25.2,<0.31.0",
|
||||
"mkdocstrings-python>=1.10.9",
|
||||
"mike>=2.0.0",
|
||||
"mkdocs-jupyter>=0.24.3",
|
||||
@@ -81,7 +82,7 @@ docs = [
|
||||
build = [
|
||||
"twine>=5.1.1,<7.0.0",
|
||||
"wheel>=0.40,<0.46",
|
||||
"build>=0.10,<1.3"
|
||||
"build>=0.10,<1.4"
|
||||
]
|
||||
|
||||
[tool.bandit]
|
||||
|
||||
@@ -87,6 +87,7 @@ from supervision.detection.utils.masks import (
|
||||
calculate_masks_centroids,
|
||||
contains_holes,
|
||||
contains_multiple_segments,
|
||||
filter_segments_by_distance,
|
||||
move_masks,
|
||||
)
|
||||
from supervision.detection.utils.polygons import (
|
||||
@@ -122,6 +123,7 @@ from supervision.utils.file import list_files_with_extensions
|
||||
from supervision.utils.image import (
|
||||
ImageSink,
|
||||
crop_image,
|
||||
get_image_resolution_wh,
|
||||
grayscale_image,
|
||||
letterbox_image,
|
||||
overlay_image,
|
||||
@@ -219,8 +221,10 @@ __all__ = [
|
||||
"draw_text",
|
||||
"edit_distance",
|
||||
"filter_polygons_by_area",
|
||||
"filter_segments_by_distance",
|
||||
"fuzzy_match_index",
|
||||
"get_coco_class_index_mapping",
|
||||
"get_image_resolution_wh",
|
||||
"get_polygon_center",
|
||||
"get_video_frames_generator",
|
||||
"grayscale_image",
|
||||
|
||||
+125
-13
@@ -47,6 +47,7 @@ from supervision.detection.vlm import (
|
||||
from_moondream,
|
||||
from_paligemma,
|
||||
from_qwen_2_5_vl,
|
||||
from_qwen_3_vl,
|
||||
validate_vlm_parameters,
|
||||
)
|
||||
from supervision.geometry.core import Position
|
||||
@@ -296,18 +297,24 @@ class Detections:
|
||||
class_id=np.arange(len(ultralytics_results)),
|
||||
)
|
||||
|
||||
class_id = ultralytics_results.boxes.cls.cpu().numpy().astype(int)
|
||||
class_names = np.array([ultralytics_results.names[i] for i in class_id])
|
||||
return cls(
|
||||
xyxy=ultralytics_results.boxes.xyxy.cpu().numpy(),
|
||||
confidence=ultralytics_results.boxes.conf.cpu().numpy(),
|
||||
class_id=class_id,
|
||||
mask=extract_ultralytics_masks(ultralytics_results),
|
||||
tracker_id=ultralytics_results.boxes.id.int().cpu().numpy()
|
||||
if ultralytics_results.boxes.id is not None
|
||||
else None,
|
||||
data={CLASS_NAME_DATA_FIELD: class_names},
|
||||
)
|
||||
if (
|
||||
hasattr(ultralytics_results, "boxes")
|
||||
and ultralytics_results.boxes is not None
|
||||
):
|
||||
class_id = ultralytics_results.boxes.cls.cpu().numpy().astype(int)
|
||||
class_names = np.array([ultralytics_results.names[i] for i in class_id])
|
||||
return cls(
|
||||
xyxy=ultralytics_results.boxes.xyxy.cpu().numpy(),
|
||||
confidence=ultralytics_results.boxes.conf.cpu().numpy(),
|
||||
class_id=class_id,
|
||||
mask=extract_ultralytics_masks(ultralytics_results),
|
||||
tracker_id=ultralytics_results.boxes.id.int().cpu().numpy()
|
||||
if ultralytics_results.boxes.id is not None
|
||||
else None,
|
||||
data={CLASS_NAME_DATA_FIELD: class_names},
|
||||
)
|
||||
|
||||
return cls.empty()
|
||||
|
||||
@classmethod
|
||||
def from_yolo_nas(cls, yolo_nas_results) -> Detections:
|
||||
@@ -945,6 +952,36 @@ class Detections:
|
||||
# array([0, 1])
|
||||
```
|
||||
|
||||
!!! example "Qwen3-VL"
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
qwen_3_vl_result = \"\"\"```json
|
||||
[
|
||||
{"bbox_2d": [139, 768, 315, 954], "label": "cat"},
|
||||
{"bbox_2d": [366, 679, 536, 849], "label": "dog"}
|
||||
]
|
||||
```\"\"\"
|
||||
detections = sv.Detections.from_lmm(
|
||||
sv.LMM.QWEN_3_VL,
|
||||
qwen_3_vl_result,
|
||||
resolution_wh=(1000, 1000),
|
||||
classes=['cat', 'dog'],
|
||||
)
|
||||
detections.xyxy
|
||||
# array([[139., 768., 315., 954.], [366., 679., 536., 849.]])
|
||||
|
||||
detections.class_id
|
||||
# array([0, 1])
|
||||
|
||||
detections.data
|
||||
# {'class_name': array(['cat', 'dog'], dtype='<U10')}
|
||||
|
||||
detections.class_id
|
||||
# array([0, 1])
|
||||
```
|
||||
|
||||
!!! example "Gemini 2.0"
|
||||
```python
|
||||
import supervision as sv
|
||||
@@ -1205,6 +1242,7 @@ class Detections:
|
||||
| PaliGemma | `PALIGEMMA` | detection | `resolution_wh` | `classes` |
|
||||
| PaliGemma 2 | `PALIGEMMA` | detection | `resolution_wh` | `classes` |
|
||||
| Qwen2.5-VL | `QWEN_2_5_VL` | detection | `resolution_wh`, `input_wh` | `classes` |
|
||||
| Qwen3-VL | `QWEN_3_VL` | detection | `resolution_wh`, | `classes` |
|
||||
| Google Gemini 2.0 | `GOOGLE_GEMINI_2_0` | detection | `resolution_wh` | `classes` |
|
||||
| Google Gemini 2.5 | `GOOGLE_GEMINI_2_5` | detection, segmentation | `resolution_wh` | `classes` |
|
||||
| Moondream | `MOONDREAM` | detection | `resolution_wh` | |
|
||||
@@ -1322,6 +1360,36 @@ class Detections:
|
||||
# array([0, 1])
|
||||
```
|
||||
|
||||
!!! example "Qwen3-VL"
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
qwen_3_vl_result = \"\"\"```json
|
||||
[
|
||||
{"bbox_2d": [139, 768, 315, 954], "label": "cat"},
|
||||
{"bbox_2d": [366, 679, 536, 849], "label": "dog"}
|
||||
]
|
||||
```\"\"\"
|
||||
detections = sv.Detections.from_vlm(
|
||||
sv.VLM.QWEN_3_VL,
|
||||
qwen_3_vl_result,
|
||||
resolution_wh=(1000, 1000),
|
||||
classes=['cat', 'dog'],
|
||||
)
|
||||
detections.xyxy
|
||||
# array([[139., 768., 315., 954.], [366., 679., 536., 849.]])
|
||||
|
||||
detections.class_id
|
||||
# array([0, 1])
|
||||
|
||||
detections.data
|
||||
# {'class_name': array(['cat', 'dog'], dtype='<U10')}
|
||||
|
||||
detections.class_id
|
||||
# array([0, 1])
|
||||
```
|
||||
|
||||
!!! example "Gemini 2.0"
|
||||
```python
|
||||
import supervision as sv
|
||||
@@ -1550,7 +1618,14 @@ class Detections:
|
||||
if vlm == VLM.QWEN_2_5_VL:
|
||||
xyxy, class_id, class_name = from_qwen_2_5_vl(result, **kwargs)
|
||||
data = {CLASS_NAME_DATA_FIELD: class_name}
|
||||
return cls(xyxy=xyxy, class_id=class_id, data=data)
|
||||
confidence = np.ones(len(xyxy), dtype=float)
|
||||
return cls(xyxy=xyxy, class_id=class_id, confidence=confidence, data=data)
|
||||
|
||||
if vlm == VLM.QWEN_3_VL:
|
||||
xyxy, class_id, class_name = from_qwen_3_vl(result, **kwargs)
|
||||
data = {CLASS_NAME_DATA_FIELD: class_name}
|
||||
confidence = np.ones(len(xyxy), dtype=float)
|
||||
return cls(xyxy=xyxy, class_id=class_id, confidence=confidence, data=data)
|
||||
|
||||
if vlm == VLM.DEEPSEEK_VL_2:
|
||||
xyxy, class_id, class_name = from_deepseek_vl_2(result, **kwargs)
|
||||
@@ -2013,6 +2088,43 @@ class Detections:
|
||||
"""
|
||||
return (self.xyxy[:, 3] - self.xyxy[:, 1]) * (self.xyxy[:, 2] - self.xyxy[:, 0])
|
||||
|
||||
@property
|
||||
def box_aspect_ratio(self) -> np.ndarray:
|
||||
"""
|
||||
Compute the aspect ratio (width divided by height) for each bounding box.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Array of shape `(N,)` containing aspect ratios, where `N` is the
|
||||
number of boxes (width / height for each box).
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
xyxy = np.array([
|
||||
[10, 10, 50, 50],
|
||||
[60, 10, 180, 50],
|
||||
[10, 60, 50, 180],
|
||||
])
|
||||
|
||||
detections = sv.Detections(xyxy=xyxy)
|
||||
|
||||
detections.box_aspect_ratio
|
||||
# array([1.0, 3.0, 0.33333333])
|
||||
|
||||
ar = detections.box_aspect_ratio
|
||||
detections[(ar < 2.0) & (ar > 0.5)].xyxy
|
||||
# array([[10., 10., 50., 50.]])
|
||||
```
|
||||
"""
|
||||
widths = self.xyxy[:, 2] - self.xyxy[:, 0]
|
||||
heights = self.xyxy[:, 3] - self.xyxy[:, 1]
|
||||
|
||||
aspect_ratios = np.full_like(widths, np.nan, dtype=np.float64)
|
||||
np.divide(widths, heights, out=aspect_ratios, where=heights != 0)
|
||||
return aspect_ratios
|
||||
|
||||
def with_nms(
|
||||
self,
|
||||
threshold: float = 0.5,
|
||||
|
||||
@@ -11,11 +11,9 @@ from supervision.detection.core import Detections
|
||||
from supervision.detection.utils.boxes import move_boxes, move_oriented_boxes
|
||||
from supervision.detection.utils.iou_and_nms import OverlapFilter, OverlapMetric
|
||||
from supervision.detection.utils.masks import move_masks
|
||||
from supervision.utils.image import crop_image
|
||||
from supervision.utils.internal import (
|
||||
SupervisionWarnings,
|
||||
warn_deprecated,
|
||||
)
|
||||
from supervision.draw.base import ImageType
|
||||
from supervision.utils.image import crop_image, get_image_resolution_wh
|
||||
from supervision.utils.internal import SupervisionWarnings
|
||||
|
||||
|
||||
def move_detections(
|
||||
@@ -53,111 +51,106 @@ def move_detections(
|
||||
|
||||
class InferenceSlicer:
|
||||
"""
|
||||
InferenceSlicer performs slicing-based inference for small target detection. This
|
||||
method, often referred to as
|
||||
[Slicing Adaptive Inference (SAHI)](https://ieeexplore.ieee.org/document/9897990),
|
||||
involves dividing a larger image into smaller slices, performing inference on each
|
||||
slice, and then merging the detections.
|
||||
Perform tiled inference on large images by slicing them into overlapping patches.
|
||||
|
||||
This class divides an input image into overlapping slices of configurable size
|
||||
and overlap, runs inference on each slice through a user-provided callback, and
|
||||
merges the resulting detections. The slicing process allows efficient processing
|
||||
of large images with limited resources while preserving detection accuracy via
|
||||
configurable overlap and post-processing of overlaps. Uses multi-threading for
|
||||
parallel slice inference.
|
||||
|
||||
Args:
|
||||
slice_wh (Tuple[int, int]): Dimensions of each slice measured in pixels. The
|
||||
tuple should be in the format `(width, height)`.
|
||||
overlap_ratio_wh (Optional[Tuple[float, float]]): [⚠️ Deprecated: please set
|
||||
to `None` and use `overlap_wh`] A tuple representing the
|
||||
desired overlap ratio for width and height between consecutive slices.
|
||||
Each value should be in the range [0, 1), where 0 means no overlap and
|
||||
a value close to 1 means high overlap.
|
||||
overlap_wh (Optional[Tuple[int, int]]): A tuple representing the desired
|
||||
overlap for width and height between consecutive slices measured in pixels.
|
||||
Each value should be greater than or equal to 0. Takes precedence over
|
||||
`overlap_ratio_wh`.
|
||||
overlap_filter (Union[OverlapFilter, str]): Strategy for
|
||||
filtering or merging overlapping detections in slices.
|
||||
iou_threshold (float): Intersection over Union (IoU) threshold
|
||||
used when filtering by overlap.
|
||||
overlap_metric (Union[OverlapMetric, str]): Metric used for matching detections
|
||||
in slices.
|
||||
callback (Callable): A function that performs inference on a given image
|
||||
slice and returns detections.
|
||||
thread_workers (int): Number of threads for parallel execution.
|
||||
callback (Callable[[ImageType], Detections]): Inference function that takes
|
||||
a sliced image and returns a `Detections` object.
|
||||
slice_wh (int or tuple[int, int]): Size of each slice `(width, height)`.
|
||||
If int, both width and height are set to this value.
|
||||
overlap_wh (int or tuple[int, int]): Overlap size `(width, height)` between
|
||||
slices. If int, both width and height are set to this value.
|
||||
overlap_filter (OverlapFilter or str): Strategy to merge overlapping
|
||||
detections (`NON_MAX_SUPPRESSION`, `NON_MAX_MERGE`, or `NONE`).
|
||||
iou_threshold (float): IOU threshold used in merging overlap filtering.
|
||||
overlap_metric (OverlapMetric or str): Metric to compute overlap
|
||||
(`IOU` or `IOS`).
|
||||
thread_workers (int): Number of threads for concurrent slice inference.
|
||||
|
||||
Note:
|
||||
The class ensures that slices do not exceed the boundaries of the original
|
||||
image. As a result, the final slices in the row and column dimensions might be
|
||||
smaller than the specified slice dimensions if the image's width or height is
|
||||
not a multiple of the slice's width or height minus the overlap.
|
||||
Raises:
|
||||
ValueError: If `slice_wh` or `overlap_wh` are invalid or inconsistent.
|
||||
|
||||
Example:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from rfdetr import RFDETRMedium
|
||||
|
||||
model = RFDETRMedium()
|
||||
|
||||
def callback(tile):
|
||||
return model.predict(tile)
|
||||
|
||||
slicer = sv.InferenceSlicer(callback, slice_wh=640, overlap_wh=100)
|
||||
|
||||
image = cv2.imread("example.png")
|
||||
detections = slicer(image)
|
||||
```
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
from PIL import Image
|
||||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO("yolo11m.pt")
|
||||
|
||||
def callback(tile):
|
||||
results = model(tile)[0]
|
||||
return sv.Detections.from_ultralytics(results)
|
||||
|
||||
slicer = sv.InferenceSlicer(callback, slice_wh=640, overlap_wh=100)
|
||||
|
||||
image = Image.open("example.png")
|
||||
detections = slicer(image)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
callback: Callable[[np.ndarray], Detections],
|
||||
slice_wh: tuple[int, int] = (320, 320),
|
||||
overlap_ratio_wh: tuple[float, float] | None = (0.2, 0.2),
|
||||
overlap_wh: tuple[int, int] | None = None,
|
||||
callback: Callable[[ImageType], Detections],
|
||||
slice_wh: int | tuple[int, int] = 640,
|
||||
overlap_wh: int | tuple[int, int] = 100,
|
||||
overlap_filter: OverlapFilter | str = OverlapFilter.NON_MAX_SUPPRESSION,
|
||||
iou_threshold: float = 0.5,
|
||||
overlap_metric: OverlapMetric | str = OverlapMetric.IOU,
|
||||
thread_workers: int = 1,
|
||||
):
|
||||
if overlap_ratio_wh is not None:
|
||||
warn_deprecated(
|
||||
"`overlap_ratio_wh` in `InferenceSlicer.__init__` is deprecated and "
|
||||
"will be removed in `supervision-0.27.0`. Please manually set it to "
|
||||
"`None` and use `overlap_wh` instead."
|
||||
)
|
||||
slice_wh_norm = self._normalize_slice_wh(slice_wh)
|
||||
overlap_wh_norm = self._normalize_overlap_wh(overlap_wh)
|
||||
|
||||
self._validate_overlap(overlap_ratio_wh, overlap_wh)
|
||||
self.overlap_ratio_wh = overlap_ratio_wh
|
||||
self.overlap_wh = overlap_wh
|
||||
self._validate_overlap(slice_wh=slice_wh_norm, overlap_wh=overlap_wh_norm)
|
||||
|
||||
self.slice_wh = slice_wh
|
||||
self.slice_wh = slice_wh_norm
|
||||
self.overlap_wh = overlap_wh_norm
|
||||
self.iou_threshold = iou_threshold
|
||||
self.overlap_metric = OverlapMetric.from_value(overlap_metric)
|
||||
self.overlap_filter = OverlapFilter.from_value(overlap_filter)
|
||||
self.callback = callback
|
||||
self.thread_workers = thread_workers
|
||||
|
||||
def __call__(self, image: np.ndarray) -> Detections:
|
||||
def __call__(self, image: ImageType) -> Detections:
|
||||
"""
|
||||
Performs slicing-based inference on the provided image using the specified
|
||||
callback.
|
||||
Perform tiled inference on the full image and return merged detections.
|
||||
|
||||
Args:
|
||||
image (np.ndarray): The input image on which inference needs to be
|
||||
performed. The image should be in the format
|
||||
`(height, width, channels)`.
|
||||
image (ImageType): The full image to run inference on.
|
||||
|
||||
Returns:
|
||||
Detections: A collection of detections for the entire image after merging
|
||||
results from all slices and applying NMS.
|
||||
|
||||
Example:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
image = cv2.imread(SOURCE_IMAGE_PATH)
|
||||
model = YOLO(...)
|
||||
|
||||
def callback(image_slice: np.ndarray) -> sv.Detections:
|
||||
result = model(image_slice)[0]
|
||||
return sv.Detections.from_ultralytics(result)
|
||||
|
||||
slicer = sv.InferenceSlicer(
|
||||
callback=callback,
|
||||
overlap_filter_strategy=sv.OverlapFilter.NON_MAX_SUPPRESSION,
|
||||
)
|
||||
|
||||
detections = slicer(image)
|
||||
```
|
||||
Detections: Merged detections across all slices.
|
||||
"""
|
||||
detections_list = []
|
||||
resolution_wh = (image.shape[1], image.shape[0])
|
||||
detections_list: list[Detections] = []
|
||||
resolution_wh = get_image_resolution_wh(image)
|
||||
|
||||
offsets = self._generate_offset(
|
||||
resolution_wh=resolution_wh,
|
||||
slice_wh=self.slice_wh,
|
||||
overlap_ratio_wh=self.overlap_ratio_wh,
|
||||
overlap_wh=self.overlap_wh,
|
||||
)
|
||||
|
||||
@@ -171,129 +164,178 @@ class InferenceSlicer:
|
||||
merged = Detections.merge(detections_list=detections_list)
|
||||
if self.overlap_filter == OverlapFilter.NONE:
|
||||
return merged
|
||||
elif self.overlap_filter == OverlapFilter.NON_MAX_SUPPRESSION:
|
||||
if self.overlap_filter == OverlapFilter.NON_MAX_SUPPRESSION:
|
||||
return merged.with_nms(
|
||||
threshold=self.iou_threshold, overlap_metric=self.overlap_metric
|
||||
threshold=self.iou_threshold,
|
||||
overlap_metric=self.overlap_metric,
|
||||
)
|
||||
elif self.overlap_filter == OverlapFilter.NON_MAX_MERGE:
|
||||
if self.overlap_filter == OverlapFilter.NON_MAX_MERGE:
|
||||
return merged.with_nmm(
|
||||
threshold=self.iou_threshold, overlap_metric=self.overlap_metric
|
||||
threshold=self.iou_threshold,
|
||||
overlap_metric=self.overlap_metric,
|
||||
)
|
||||
else:
|
||||
warnings.warn(
|
||||
f"Invalid overlap filter strategy: {self.overlap_filter}",
|
||||
category=SupervisionWarnings,
|
||||
)
|
||||
return merged
|
||||
|
||||
def _run_callback(self, image, offset) -> Detections:
|
||||
warnings.warn(
|
||||
f"Invalid overlap filter strategy: {self.overlap_filter}",
|
||||
category=SupervisionWarnings,
|
||||
)
|
||||
return merged
|
||||
|
||||
def _run_callback(self, image: ImageType, offset: np.ndarray) -> Detections:
|
||||
"""
|
||||
Run the provided callback on a slice of an image.
|
||||
Run detection callback on a sliced portion of the image and adjust coordinates.
|
||||
|
||||
Args:
|
||||
image (np.ndarray): The input image on which inference needs to run
|
||||
offset (np.ndarray): An array of shape `(4,)` containing coordinates
|
||||
for the slice.
|
||||
image (ImageType): The full image.
|
||||
offset (numpy.ndarray): Coordinates `(x_min, y_min, x_max, y_max)` defining
|
||||
the slice region.
|
||||
|
||||
Returns:
|
||||
Detections: A collection of detections for the slice.
|
||||
Detections: Detections adjusted to the full image coordinate system.
|
||||
"""
|
||||
image_slice = crop_image(image=image, xyxy=offset)
|
||||
image_slice: ImageType = crop_image(image=image, xyxy=offset)
|
||||
detections = self.callback(image_slice)
|
||||
resolution_wh = (image.shape[1], image.shape[0])
|
||||
resolution_wh = get_image_resolution_wh(image)
|
||||
|
||||
detections = move_detections(
|
||||
detections=detections, offset=offset[:2], resolution_wh=resolution_wh
|
||||
detections=detections,
|
||||
offset=offset[:2],
|
||||
resolution_wh=resolution_wh,
|
||||
)
|
||||
return detections
|
||||
|
||||
@staticmethod
|
||||
def _normalize_slice_wh(
|
||||
slice_wh: int | tuple[int, int],
|
||||
) -> tuple[int, int]:
|
||||
if isinstance(slice_wh, int):
|
||||
if slice_wh <= 0:
|
||||
raise ValueError(
|
||||
f"`slice_wh` must be a positive integer. Received: {slice_wh}"
|
||||
)
|
||||
return slice_wh, slice_wh
|
||||
|
||||
if isinstance(slice_wh, tuple) and len(slice_wh) == 2:
|
||||
width, height = slice_wh
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError(
|
||||
f"`slice_wh` values must be positive. Received: {slice_wh}"
|
||||
)
|
||||
return width, height
|
||||
|
||||
raise ValueError(
|
||||
"`slice_wh` must be an int or a tuple of two positive integers "
|
||||
"(slice_w, slice_h). "
|
||||
f"Received: {slice_wh}"
|
||||
)
|
||||
|
||||
return detections
|
||||
@staticmethod
|
||||
def _normalize_overlap_wh(
|
||||
overlap_wh: int | tuple[int, int],
|
||||
) -> tuple[int, int]:
|
||||
if isinstance(overlap_wh, int):
|
||||
if overlap_wh < 0:
|
||||
raise ValueError(
|
||||
"`overlap_wh` must be a non negative integer. "
|
||||
f"Received: {overlap_wh}"
|
||||
)
|
||||
return overlap_wh, overlap_wh
|
||||
|
||||
if isinstance(overlap_wh, tuple) and len(overlap_wh) == 2:
|
||||
overlap_w, overlap_h = overlap_wh
|
||||
if overlap_w < 0 or overlap_h < 0:
|
||||
raise ValueError(
|
||||
f"`overlap_wh` values must be non negative. Received: {overlap_wh}"
|
||||
)
|
||||
return overlap_w, overlap_h
|
||||
|
||||
raise ValueError(
|
||||
"`overlap_wh` must be an int or a tuple of two non negative integers "
|
||||
"(overlap_w, overlap_h). "
|
||||
f"Received: {overlap_wh}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _generate_offset(
|
||||
resolution_wh: tuple[int, int],
|
||||
slice_wh: tuple[int, int],
|
||||
overlap_ratio_wh: tuple[float, float] | None,
|
||||
overlap_wh: tuple[int, int] | None,
|
||||
overlap_wh: tuple[int, int],
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Generate offset coordinates for slicing an image based on the given resolution,
|
||||
slice dimensions, and overlap ratios.
|
||||
Generate bounding boxes defining the coordinates of image slices with overlap.
|
||||
|
||||
Args:
|
||||
resolution_wh (Tuple[int, int]): A tuple representing the width and height
|
||||
of the image to be sliced.
|
||||
slice_wh (Tuple[int, int]): Dimensions of each slice measured in pixels. The
|
||||
tuple should be in the format `(width, height)`.
|
||||
overlap_ratio_wh (Optional[Tuple[float, float]]): A tuple representing the
|
||||
desired overlap ratio for width and height between consecutive slices.
|
||||
Each value should be in the range [0, 1), where 0 means no overlap and
|
||||
a value close to 1 means high overlap.
|
||||
overlap_wh (Optional[Tuple[int, int]]): A tuple representing the desired
|
||||
overlap for width and height between consecutive slices measured in
|
||||
pixels. Each value should be greater than or equal to 0.
|
||||
resolution_wh (tuple[int, int]): Image resolution `(width, height)`.
|
||||
slice_wh (tuple[int, int]): Size of each slice `(width, height)`.
|
||||
overlap_wh (tuple[int, int]): Overlap size between slices `(width, height)`.
|
||||
|
||||
Returns:
|
||||
np.ndarray: An array of shape `(n, 4)` containing coordinates for each
|
||||
slice in the format `[xmin, ymin, xmax, ymax]`.
|
||||
|
||||
Note:
|
||||
The function ensures that slices do not exceed the boundaries of the
|
||||
original image. As a result, the final slices in the row and column
|
||||
dimensions might be smaller than the specified slice dimensions if the
|
||||
image's width or height is not a multiple of the slice's width or
|
||||
height minus the overlap.
|
||||
numpy.ndarray: Array of shape `(num_slices, 4)` with each row as
|
||||
`(x_min, y_min, x_max, y_max)` coordinates for a slice.
|
||||
"""
|
||||
slice_width, slice_height = slice_wh
|
||||
image_width, image_height = resolution_wh
|
||||
overlap_width = (
|
||||
overlap_wh[0]
|
||||
if overlap_wh is not None
|
||||
else int(overlap_ratio_wh[0] * slice_width)
|
||||
overlap_width, overlap_height = overlap_wh
|
||||
|
||||
stride_x = slice_width - overlap_width
|
||||
stride_y = slice_height - overlap_height
|
||||
|
||||
def _compute_axis_starts(
|
||||
image_size: int,
|
||||
slice_size: int,
|
||||
stride: int,
|
||||
) -> list[int]:
|
||||
if image_size <= slice_size:
|
||||
return [0]
|
||||
|
||||
if stride == slice_size:
|
||||
return np.arange(0, image_size, stride).tolist()
|
||||
|
||||
last_start = image_size - slice_size
|
||||
starts = np.arange(0, last_start, stride).tolist()
|
||||
if not starts or starts[-1] != last_start:
|
||||
starts.append(last_start)
|
||||
return starts
|
||||
|
||||
x_starts = _compute_axis_starts(
|
||||
image_size=image_width,
|
||||
slice_size=slice_width,
|
||||
stride=stride_x,
|
||||
)
|
||||
overlap_height = (
|
||||
overlap_wh[1]
|
||||
if overlap_wh is not None
|
||||
else int(overlap_ratio_wh[1] * slice_height)
|
||||
y_starts = _compute_axis_starts(
|
||||
image_size=image_height,
|
||||
slice_size=slice_height,
|
||||
stride=stride_y,
|
||||
)
|
||||
|
||||
width_stride = slice_width - overlap_width
|
||||
height_stride = slice_height - overlap_height
|
||||
x_min, y_min = np.meshgrid(x_starts, y_starts)
|
||||
x_max = np.clip(x_min + slice_width, 0, image_width)
|
||||
y_max = np.clip(y_min + slice_height, 0, image_height)
|
||||
|
||||
ws = np.arange(0, image_width, width_stride)
|
||||
hs = np.arange(0, image_height, height_stride)
|
||||
|
||||
xmin, ymin = np.meshgrid(ws, hs)
|
||||
xmax = np.clip(xmin + slice_width, 0, image_width)
|
||||
ymax = np.clip(ymin + slice_height, 0, image_height)
|
||||
|
||||
offsets = np.stack([xmin, ymin, xmax, ymax], axis=-1).reshape(-1, 4)
|
||||
offsets = np.stack(
|
||||
[x_min, y_min, x_max, y_max],
|
||||
axis=-1,
|
||||
).reshape(-1, 4)
|
||||
|
||||
return offsets
|
||||
|
||||
@staticmethod
|
||||
def _validate_overlap(
|
||||
overlap_ratio_wh: tuple[float, float] | None,
|
||||
overlap_wh: tuple[int, int] | None,
|
||||
slice_wh: tuple[int, int],
|
||||
overlap_wh: tuple[int, int],
|
||||
) -> None:
|
||||
if overlap_ratio_wh is not None and overlap_wh is not None:
|
||||
overlap_w, overlap_h = overlap_wh
|
||||
slice_w, slice_h = slice_wh
|
||||
|
||||
if overlap_w < 0 or overlap_h < 0:
|
||||
raise ValueError(
|
||||
"Both `overlap_ratio_wh` and `overlap_wh` cannot be provided. "
|
||||
"Please provide only one of them."
|
||||
)
|
||||
if overlap_ratio_wh is None and overlap_wh is None:
|
||||
raise ValueError(
|
||||
"Either `overlap_ratio_wh` or `overlap_wh` must be provided. "
|
||||
"Please provide one of them."
|
||||
"Overlap values must be greater than or equal to 0. "
|
||||
f"Received: {overlap_wh}"
|
||||
)
|
||||
|
||||
if overlap_ratio_wh is not None:
|
||||
if not (0 <= overlap_ratio_wh[0] < 1 and 0 <= overlap_ratio_wh[1] < 1):
|
||||
raise ValueError(
|
||||
"Overlap ratios must be in the range [0, 1). "
|
||||
f"Received: {overlap_ratio_wh}"
|
||||
)
|
||||
if overlap_wh is not None:
|
||||
if not (overlap_wh[0] >= 0 and overlap_wh[1] >= 0):
|
||||
raise ValueError(
|
||||
"Overlap values must be greater than or equal to 0. "
|
||||
f"Received: {overlap_wh}"
|
||||
)
|
||||
if overlap_w >= slice_w or overlap_h >= slice_h:
|
||||
raise ValueError(
|
||||
"`overlap_wh` must be smaller than `slice_wh` in both dimensions "
|
||||
f"to keep a positive stride. Received overlap_wh={overlap_wh}, "
|
||||
f"slice_wh={slice_wh}."
|
||||
)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from supervision.detection.utils.iou_and_nms import box_iou_batch
|
||||
|
||||
__all__ = ["box_iou_batch"]
|
||||
|
||||
@@ -14,8 +14,8 @@ def clip_boxes(xyxy: np.ndarray, resolution_wh: tuple[int, int]) -> np.ndarray:
|
||||
xyxy (np.ndarray): A numpy array of shape `(N, 4)` where each
|
||||
row corresponds to a bounding box in
|
||||
the format `(x_min, y_min, x_max, y_max)`.
|
||||
resolution_wh (Tuple[int, int]): A tuple of the form `(width, height)`
|
||||
representing the resolution of the frame.
|
||||
resolution_wh (Tuple[int, int]): A tuple of the form
|
||||
`(width, height)` representing the resolution of the frame.
|
||||
|
||||
Returns:
|
||||
np.ndarray: A numpy array of shape `(N, 4)` where each row
|
||||
@@ -95,24 +95,27 @@ def pad_boxes(xyxy: np.ndarray, px: int, py: int | None = None) -> np.ndarray:
|
||||
|
||||
|
||||
def denormalize_boxes(
|
||||
normalized_xyxy: np.ndarray,
|
||||
xyxy: np.ndarray,
|
||||
resolution_wh: tuple[int, int],
|
||||
normalization_factor: float = 1.0,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Converts normalized bounding box coordinates to absolute pixel values.
|
||||
Convert normalized bounding box coordinates to absolute pixel coordinates.
|
||||
|
||||
Multiplies each bounding box coordinate by image size and divides by
|
||||
`normalization_factor`, mapping values from normalized `[0, normalization_factor]`
|
||||
to absolute pixel values for a given resolution.
|
||||
|
||||
Args:
|
||||
normalized_xyxy (np.ndarray): A numpy array of shape `(N, 4)` where each row
|
||||
contains normalized coordinates in the format `(x_min, y_min, x_max, y_max)`,
|
||||
with values between 0 and `normalization_factor`.
|
||||
resolution_wh (Tuple[int, int]): A tuple `(width, height)` representing the
|
||||
target image resolution.
|
||||
normalization_factor (float, optional): The normalization range of the input
|
||||
coordinates. Defaults to 1.0.
|
||||
xyxy (`numpy.ndarray`): Normalized bounding boxes of shape `(N, 4)`,
|
||||
where each row is `(x_min, y_min, x_max, y_max)`, values in
|
||||
`[0, normalization_factor]`.
|
||||
resolution_wh (`tuple[int, int]`): Target image resolution as `(width, height)`.
|
||||
normalization_factor (`float`): Maximum value of input coordinate range.
|
||||
Defaults to `1.0`.
|
||||
|
||||
Returns:
|
||||
np.ndarray: An array of shape `(N, 4)` with absolute coordinates in
|
||||
(`numpy.ndarray`): Array of shape `(N, 4)` with absolute coordinates in
|
||||
`(x_min, y_min, x_max, y_max)` format.
|
||||
|
||||
Examples:
|
||||
@@ -120,35 +123,39 @@ def denormalize_boxes(
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
# Default normalization (0-1)
|
||||
normalized_xyxy = np.array([
|
||||
xyxy = np.array([
|
||||
[0.1, 0.2, 0.5, 0.6],
|
||||
[0.3, 0.4, 0.7, 0.8]
|
||||
[0.3, 0.4, 0.7, 0.8],
|
||||
[0.2, 0.1, 0.6, 0.5]
|
||||
])
|
||||
resolution_wh = (100, 200)
|
||||
sv.denormalize_boxes(normalized_xyxy, resolution_wh)
|
||||
# array([
|
||||
# [ 10., 40., 50., 120.],
|
||||
# [ 30., 80., 70., 160.]
|
||||
# ])
|
||||
|
||||
# Custom normalization (0-100)
|
||||
normalized_xyxy = np.array([
|
||||
[10., 20., 50., 60.],
|
||||
[30., 40., 70., 80.]
|
||||
])
|
||||
sv.denormalize_boxes(normalized_xyxy, resolution_wh, normalization_factor=100.0)
|
||||
sv.denormalize_boxes(xyxy, (1280, 720))
|
||||
# array([
|
||||
# [ 10., 40., 50., 120.],
|
||||
# [ 30., 80., 70., 160.]
|
||||
# [128., 144., 640., 432.],
|
||||
# [384., 288., 896., 576.],
|
||||
# [256., 72., 768., 360.]
|
||||
# ])
|
||||
```
|
||||
""" # noqa E501 // docs
|
||||
width, height = resolution_wh
|
||||
result = normalized_xyxy.copy()
|
||||
|
||||
result[[0, 2]] = (result[[0, 2]] * width) / normalization_factor
|
||||
result[[1, 3]] = (result[[1, 3]] * height) / normalization_factor
|
||||
```
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
xyxy = np.array([
|
||||
[256., 128., 768., 640.]
|
||||
])
|
||||
|
||||
sv.denormalize_boxes(xyxy, (1280, 720), normalization_factor=1024.0)
|
||||
# array([
|
||||
# [320., 90., 960., 450.]
|
||||
# ])
|
||||
```
|
||||
"""
|
||||
width, height = resolution_wh
|
||||
result = xyxy.copy()
|
||||
|
||||
result[:, [0, 2]] = (result[:, [0, 2]] * width) / normalization_factor
|
||||
result[:, [1, 3]] = (result[:, [1, 3]] * height) / normalization_factor
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ class OverlapMetric(Enum):
|
||||
IOS = "IOS"
|
||||
|
||||
@classmethod
|
||||
def list(cls):
|
||||
def list(cls) -> list[str]:
|
||||
return list(map(lambda c: c.value, cls))
|
||||
|
||||
@classmethod
|
||||
@@ -72,7 +72,7 @@ class OverlapMetric(Enum):
|
||||
if isinstance(value, cls):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
value = value.lower()
|
||||
value = value.upper()
|
||||
try:
|
||||
return cls(value)
|
||||
except ValueError:
|
||||
@@ -86,91 +86,107 @@ class OverlapMetric(Enum):
|
||||
def box_iou(
|
||||
box_true: list[float] | np.ndarray,
|
||||
box_detection: list[float] | np.ndarray,
|
||||
overlap_metric: OverlapMetric | str = OverlapMetric.IOU,
|
||||
) -> float:
|
||||
r"""
|
||||
Compute the Intersection over Union (IoU) between two bounding boxes.
|
||||
"""
|
||||
Compute overlap metric between two bounding boxes.
|
||||
|
||||
\[
|
||||
\text{IoU} = \frac{|\text{box}_{\text{true}} \cap \text{box}_{\text{detection}}|}{|\text{box}_{\text{true}} \cup \text{box}_{\text{detection}}|}
|
||||
\]
|
||||
|
||||
Note:
|
||||
Use `box_iou` when computing IoU between two individual boxes.
|
||||
For comparing multiple boxes (arrays of boxes), use `box_iou_batch` for better
|
||||
performance.
|
||||
Supports standard IOU (intersection-over-union) and IOS
|
||||
(intersection-over-smaller-area) metrics. Returns the overlap value in range
|
||||
`[0, 1]`.
|
||||
|
||||
Args:
|
||||
box_true (Union[List[float], np.ndarray]): A single bounding box represented as
|
||||
[x_min, y_min, x_max, y_max].
|
||||
box_detection (Union[List[float], np.ndarray]):
|
||||
A single bounding box represented as [x_min, y_min, x_max, y_max].
|
||||
box_true (`list[float]` or `numpy.array`): Ground truth box in format
|
||||
`(x_min, y_min, x_max, y_max)`.
|
||||
box_detection (`list[float]` or `numpy.array`): Detected box in format
|
||||
`(x_min, y_min, x_max, y_max)`.
|
||||
overlap_metric (`OverlapMetric` or `str`): Overlap type.
|
||||
Use `OverlapMetric.IOU` for IOU or
|
||||
`OverlapMetric.IOS` for IOS. Defaults to `OverlapMetric.IOU`.
|
||||
|
||||
Returns:
|
||||
IoU (float): IoU score between the two boxes. Ranges from 0.0 (no overlap)
|
||||
to 1.0 (perfect overlap).
|
||||
(`float`): Overlap value between boxes in `[0, 1]`.
|
||||
|
||||
Raises:
|
||||
ValueError: If `overlap_metric` is not IOU or IOS.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import numpy as np
|
||||
```
|
||||
import supervision as sv
|
||||
|
||||
box_true = np.array([100, 100, 200, 200])
|
||||
box_detection = np.array([150, 150, 250, 250])
|
||||
box_true = [100, 100, 200, 200]
|
||||
box_detection = [150, 150, 250, 250]
|
||||
|
||||
sv.box_iou(box_true=box_true, box_detection=box_detection)
|
||||
# 0.14285814285714285
|
||||
sv.box_iou(box_true, box_detection, overlap_metric=sv.OverlapMetric.IOU)
|
||||
# 0.14285714285714285
|
||||
|
||||
sv.box_iou(box_true, box_detection, overlap_metric=sv.OverlapMetric.IOS)
|
||||
# 0.25
|
||||
```
|
||||
""" # noqa: E501
|
||||
box_true = np.array(box_true)
|
||||
box_detection = np.array(box_detection)
|
||||
"""
|
||||
overlap_metric = OverlapMetric.from_value(overlap_metric)
|
||||
x_min_true, y_min_true, x_max_true, y_max_true = np.array(box_true)
|
||||
x_min_det, y_min_det, x_max_det, y_max_det = np.array(box_detection)
|
||||
|
||||
inter_x1 = max(box_true[0], box_detection[0])
|
||||
inter_y1 = max(box_true[1], box_detection[1])
|
||||
inter_x2 = min(box_true[2], box_detection[2])
|
||||
inter_y2 = min(box_true[3], box_detection[3])
|
||||
x_min_inter = max(x_min_true, x_min_det)
|
||||
y_min_inter = max(y_min_true, y_min_det)
|
||||
x_max_inter = min(x_max_true, x_max_det)
|
||||
y_max_inter = min(y_max_true, y_max_det)
|
||||
|
||||
inter_w = max(0, inter_x2 - inter_x1)
|
||||
inter_h = max(0, inter_y2 - inter_y1)
|
||||
inter_w = max(0.0, x_max_inter - x_min_inter)
|
||||
inter_h = max(0.0, y_max_inter - y_min_inter)
|
||||
|
||||
inter_area = inter_w * inter_h
|
||||
area_inter = inter_w * inter_h
|
||||
|
||||
area_true = (box_true[2] - box_true[0]) * (box_true[3] - box_true[1])
|
||||
area_detection = (box_detection[2] - box_detection[0]) * (
|
||||
box_detection[3] - box_detection[1]
|
||||
)
|
||||
area_true = (x_max_true - x_min_true) * (y_max_true - y_min_true)
|
||||
area_det = (x_max_det - x_min_det) * (y_max_det - y_min_det)
|
||||
|
||||
union_area = area_true + area_detection - inter_area
|
||||
if overlap_metric == OverlapMetric.IOU:
|
||||
area_norm = area_true + area_det - area_inter
|
||||
elif overlap_metric == OverlapMetric.IOS:
|
||||
area_norm = min(area_true, area_det)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"overlap_metric {overlap_metric} is not supported, "
|
||||
"only 'IOU' and 'IOS' are supported"
|
||||
)
|
||||
|
||||
return inter_area / union_area + 1e-6
|
||||
if area_norm <= 0.0:
|
||||
return 0.0
|
||||
|
||||
return float(area_inter / area_norm)
|
||||
|
||||
|
||||
def box_iou_batch(
|
||||
boxes_true: np.ndarray,
|
||||
boxes_detection: np.ndarray,
|
||||
overlap_metric: OverlapMetric = OverlapMetric.IOU,
|
||||
overlap_metric: OverlapMetric | str = OverlapMetric.IOU,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Compute Intersection over Union (IoU) of two sets of bounding boxes -
|
||||
`boxes_true` and `boxes_detection`. Both sets
|
||||
of boxes are expected to be in `(x_min, y_min, x_max, y_max)` format.
|
||||
Compute pairwise overlap scores between batches of bounding boxes.
|
||||
|
||||
Note:
|
||||
Use `box_iou` when computing IoU between two individual boxes.
|
||||
For comparing multiple boxes (arrays of boxes), use `box_iou_batch` for better
|
||||
performance.
|
||||
Supports standard IOU (intersection-over-union) and IOS
|
||||
(intersection-over-smaller-area) metrics for all `boxes_true` and
|
||||
`boxes_detection` pairs. Returns a matrix of overlap values in range
|
||||
`[0, 1]`, matching each box from the first batch to each from the second.
|
||||
|
||||
Args:
|
||||
boxes_true (np.ndarray): 2D `np.ndarray` representing ground-truth boxes.
|
||||
`shape = (N, 4)` where `N` is number of true objects.
|
||||
boxes_detection (np.ndarray): 2D `np.ndarray` representing detection boxes.
|
||||
`shape = (M, 4)` where `M` is number of detected objects.
|
||||
overlap_metric (OverlapMetric): Metric used to compute the degree of overlap
|
||||
between pairs of boxes (e.g., IoU, IoS).
|
||||
boxes_true (`numpy.array`): Array of reference boxes in
|
||||
shape `(N, 4)` as `(x_min, y_min, x_max, y_max)`.
|
||||
boxes_detection (`numpy.array`): Array of detected boxes in
|
||||
shape `(M, 4)` as `(x_min, y_min, x_max, y_max)`.
|
||||
overlap_metric (`OverlapMetric` or `str`): Overlap type.
|
||||
Use `OverlapMetric.IOU` for intersection-over-union,
|
||||
`OverlapMetric.IOS` for intersection-over-smaller-area.
|
||||
Defaults to `OverlapMetric.IOU`.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Pairwise IoU of boxes from `boxes_true` and `boxes_detection`.
|
||||
`shape = (N, M)` where `N` is number of true objects and
|
||||
`M` is number of detected objects.
|
||||
(`numpy.array`): Overlap matrix of shape `(N, M)`, where entry
|
||||
`[i, j]` is the overlap between `boxes_true[i]` and
|
||||
`boxes_detection[j]`.
|
||||
|
||||
Raises:
|
||||
ValueError: If `overlap_metric` is not IOU or IOS.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@@ -186,49 +202,57 @@ def box_iou_batch(
|
||||
[320, 320, 420, 420]
|
||||
])
|
||||
|
||||
sv.box_iou_batch(boxes_true=boxes_true, boxes_detection=boxes_detection)
|
||||
# array([
|
||||
# [0.14285714, 0. ],
|
||||
# [0. , 0.47058824]
|
||||
# ])
|
||||
sv.box_iou_batch(boxes_true, boxes_detection, overlap_metric=OverlapMetric.IOU)
|
||||
# array([[0.14285715, 0. ],
|
||||
# [0. , 0.47058824]])
|
||||
|
||||
sv.box_iou_batch(boxes_true, boxes_detection, overlap_metric=OverlapMetric.IOS)
|
||||
# array([[0.25, 0. ],
|
||||
# [0. , 0.64]])
|
||||
```
|
||||
"""
|
||||
overlap_metric = OverlapMetric.from_value(overlap_metric)
|
||||
x_min_true, y_min_true, x_max_true, y_max_true = boxes_true.T
|
||||
x_min_det, y_min_det, x_max_det, y_max_det = boxes_detection.T
|
||||
count_true, count_det = boxes_true.shape[0], boxes_detection.shape[0]
|
||||
|
||||
def box_area(box):
|
||||
return (box[2] - box[0]) * (box[3] - box[1])
|
||||
if count_true == 0 or count_det == 0:
|
||||
return np.empty((count_true, count_det), dtype=np.float32)
|
||||
|
||||
area_true = box_area(boxes_true.T)
|
||||
area_detection = box_area(boxes_detection.T)
|
||||
x_min_inter = np.empty((count_true, count_det), dtype=np.float32)
|
||||
x_max_inter = np.empty_like(x_min_inter)
|
||||
y_min_inter = np.empty_like(x_min_inter)
|
||||
y_max_inter = np.empty_like(x_min_inter)
|
||||
|
||||
top_left = np.maximum(boxes_true[:, None, :2], boxes_detection[:, :2])
|
||||
bottom_right = np.minimum(boxes_true[:, None, 2:], boxes_detection[:, 2:])
|
||||
np.maximum(x_min_true[:, None], x_min_det[None, :], out=x_min_inter)
|
||||
np.minimum(x_max_true[:, None], x_max_det[None, :], out=x_max_inter)
|
||||
np.maximum(y_min_true[:, None], y_min_det[None, :], out=y_min_inter)
|
||||
np.minimum(y_max_true[:, None], y_max_det[None, :], out=y_max_inter)
|
||||
|
||||
area_inter = np.prod(np.clip(bottom_right - top_left, a_min=0, a_max=None), 2)
|
||||
# we reuse x_max_inter and y_max_inter to store inter_w and inter_h
|
||||
np.subtract(x_max_inter, x_min_inter, out=x_max_inter) # inter_w
|
||||
np.subtract(y_max_inter, y_min_inter, out=y_max_inter) # inter_h
|
||||
np.clip(x_max_inter, 0.0, None, out=x_max_inter)
|
||||
np.clip(y_max_inter, 0.0, None, out=y_max_inter)
|
||||
|
||||
area_inter = x_max_inter * y_max_inter # inter_w * inter_h
|
||||
|
||||
area_true = (x_max_true - x_min_true) * (y_max_true - y_min_true)
|
||||
area_det = (x_max_det - x_min_det) * (y_max_det - y_min_det)
|
||||
|
||||
if overlap_metric == OverlapMetric.IOU:
|
||||
union_area = area_true[:, None] + area_detection - area_inter
|
||||
ious = np.divide(
|
||||
area_inter,
|
||||
union_area,
|
||||
out=np.zeros_like(area_inter, dtype=float),
|
||||
where=union_area != 0,
|
||||
)
|
||||
area_norm = area_true[:, None] + area_det[None, :] - area_inter
|
||||
elif overlap_metric == OverlapMetric.IOS:
|
||||
small_area = np.minimum(area_true[:, None], area_detection)
|
||||
ious = np.divide(
|
||||
area_inter,
|
||||
small_area,
|
||||
out=np.zeros_like(area_inter, dtype=float),
|
||||
where=small_area != 0,
|
||||
)
|
||||
area_norm = np.minimum(area_true[:, None], area_det[None, :])
|
||||
else:
|
||||
raise ValueError(
|
||||
f"overlap_metric {overlap_metric} is not supported, "
|
||||
"only 'IOU' and 'IOS' are supported"
|
||||
)
|
||||
|
||||
ious = np.nan_to_num(ious)
|
||||
return ious
|
||||
out = np.zeros_like(area_inter, dtype=np.float32)
|
||||
np.divide(area_inter, area_norm, out=out, where=area_norm > 0)
|
||||
return out
|
||||
|
||||
|
||||
def _jaccard(box_a: list[float], box_b: list[float], is_crowd: bool) -> float:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
@@ -260,3 +262,139 @@ def resize_masks(masks: np.ndarray, max_dimension: int = 640) -> np.ndarray:
|
||||
resized_masks = masks[:, yv, xv]
|
||||
|
||||
return resized_masks.reshape(masks.shape[0], new_height, new_width)
|
||||
|
||||
|
||||
def filter_segments_by_distance(
|
||||
mask: npt.NDArray[np.bool_],
|
||||
absolute_distance: float | None = 100.0,
|
||||
relative_distance: float | None = None,
|
||||
connectivity: int = 8,
|
||||
mode: Literal["edge", "centroid"] = "edge",
|
||||
) -> npt.NDArray[np.bool_]:
|
||||
"""
|
||||
Keep the largest connected component and any other components within a distance
|
||||
threshold.
|
||||
|
||||
Distance can be absolute in pixels or relative to the image diagonal.
|
||||
|
||||
Args:
|
||||
mask: Boolean mask HxW.
|
||||
absolute_distance: Max allowed distance in pixels to the main component.
|
||||
Ignored if `relative_distance` is provided.
|
||||
relative_distance: Fraction of the diagonal. If set, threshold = fraction * sqrt(H^2 + W^2).
|
||||
connectivity: Defines which neighboring pixels are considered connected.
|
||||
- 4-connectedness: Only orthogonal neighbors.
|
||||
```
|
||||
[ ][X][ ]
|
||||
[X][O][X]
|
||||
[ ][X][ ]
|
||||
```
|
||||
- 8-connectedness: Includes diagonal neighbors.
|
||||
```
|
||||
[X][X][X]
|
||||
[X][O][X]
|
||||
[X][X][X]
|
||||
```
|
||||
Default is 8.
|
||||
mode: Defines how distance between components is measured.
|
||||
- "edge": Uses distance between nearest edges (via distance transform).
|
||||
- "centroid": Uses distance between component centroids.
|
||||
|
||||
Returns:
|
||||
Boolean mask after filtering.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
mask = np.array([
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
], dtype=bool)
|
||||
|
||||
sv.filter_segments_by_distance(
|
||||
mask,
|
||||
absolute_distance=2,
|
||||
mode="edge",
|
||||
connectivity=8
|
||||
).astype(int)
|
||||
|
||||
# np.array([
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# ], dtype=bool)
|
||||
|
||||
# The nearby 2×2 block at columns 6–7 is kept because its edge distance
|
||||
# is within 2 pixels. The distant block at columns 9-10 is removed.
|
||||
```
|
||||
""" # noqa E501 // docs
|
||||
if mask.dtype != bool:
|
||||
raise TypeError("mask must be boolean")
|
||||
|
||||
height, width = mask.shape
|
||||
if not np.any(mask):
|
||||
return mask.copy()
|
||||
|
||||
image = mask.astype(np.uint8)
|
||||
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
|
||||
image, connectivity=connectivity
|
||||
)
|
||||
|
||||
if num_labels <= 1:
|
||||
return mask.copy()
|
||||
|
||||
areas = stats[1:, cv2.CC_STAT_AREA]
|
||||
main_label = 1 + int(np.argmax(areas))
|
||||
|
||||
if relative_distance is not None:
|
||||
diagonal = float(np.hypot(height, width))
|
||||
threshold = float(relative_distance) * diagonal
|
||||
else:
|
||||
threshold = float(absolute_distance)
|
||||
|
||||
keep_labels = np.zeros(num_labels, dtype=bool)
|
||||
keep_labels[main_label] = True
|
||||
|
||||
if mode == "centroid":
|
||||
differences = centroids[1:] - centroids[main_label]
|
||||
distances = np.sqrt(np.sum(differences**2, axis=1))
|
||||
nearby = 1 + np.where(distances <= threshold)[0]
|
||||
keep_labels[nearby] = True
|
||||
elif mode == "edge":
|
||||
main_mask = (labels == main_label).astype(np.uint8)
|
||||
inverse = 1 - main_mask
|
||||
distance_transform = cv2.distanceTransform(inverse, cv2.DIST_L2, 3)
|
||||
for label in range(1, num_labels):
|
||||
if label == main_label:
|
||||
continue
|
||||
component = labels == label
|
||||
if not np.any(component):
|
||||
continue
|
||||
min_distance = float(distance_transform[component].min())
|
||||
if min_distance <= threshold:
|
||||
keep_labels[label] = True
|
||||
else:
|
||||
raise ValueError("mode must be 'edge' or 'centroid'")
|
||||
|
||||
return keep_labels[labels]
|
||||
|
||||
+139
-46
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
@@ -27,7 +28,8 @@ class LMM(Enum):
|
||||
Attributes:
|
||||
PALIGEMMA: Google's PaliGemma vision-language model.
|
||||
FLORENCE_2: Microsoft's Florence-2 vision-language model.
|
||||
QWEN_2_5_VL: Qwen2.5-VL open vision-language model from Alibaba.
|
||||
QWEN_2_5_VL: Qwen2.5-VL open vision-language model from Alibaba.\
|
||||
QWEN_3_VL: Qwen3-VL open vision-language model from Alibaba.
|
||||
GOOGLE_GEMINI_2_0: Google Gemini 2.0 vision-language model.
|
||||
GOOGLE_GEMINI_2_5: Google Gemini 2.5 vision-language model.
|
||||
MOONDREAM: The Moondream vision-language model.
|
||||
@@ -36,6 +38,7 @@ class LMM(Enum):
|
||||
PALIGEMMA = "paligemma"
|
||||
FLORENCE_2 = "florence_2"
|
||||
QWEN_2_5_VL = "qwen_2_5_vl"
|
||||
QWEN_3_VL = "qwen_3_vl"
|
||||
DEEPSEEK_VL_2 = "deepseek_vl_2"
|
||||
GOOGLE_GEMINI_2_0 = "gemini_2_0"
|
||||
GOOGLE_GEMINI_2_5 = "gemini_2_5"
|
||||
@@ -69,6 +72,7 @@ class VLM(Enum):
|
||||
PALIGEMMA: Google's PaliGemma vision-language model.
|
||||
FLORENCE_2: Microsoft's Florence-2 vision-language model.
|
||||
QWEN_2_5_VL: Qwen2.5-VL open vision-language model from Alibaba.
|
||||
QWEN_3_VL: Qwen3-VL open vision-language model from Alibaba.
|
||||
GOOGLE_GEMINI_2_0: Google Gemini 2.0 vision-language model.
|
||||
GOOGLE_GEMINI_2_5: Google Gemini 2.5 vision-language model.
|
||||
MOONDREAM: The Moondream vision-language model.
|
||||
@@ -77,6 +81,7 @@ class VLM(Enum):
|
||||
PALIGEMMA = "paligemma"
|
||||
FLORENCE_2 = "florence_2"
|
||||
QWEN_2_5_VL = "qwen_2_5_vl"
|
||||
QWEN_3_VL = "qwen_3_vl"
|
||||
DEEPSEEK_VL_2 = "deepseek_vl_2"
|
||||
GOOGLE_GEMINI_2_0 = "gemini_2_0"
|
||||
GOOGLE_GEMINI_2_5 = "gemini_2_5"
|
||||
@@ -106,6 +111,7 @@ RESULT_TYPES: dict[VLM, type] = {
|
||||
VLM.PALIGEMMA: str,
|
||||
VLM.FLORENCE_2: dict,
|
||||
VLM.QWEN_2_5_VL: str,
|
||||
VLM.QWEN_3_VL: str,
|
||||
VLM.DEEPSEEK_VL_2: str,
|
||||
VLM.GOOGLE_GEMINI_2_0: str,
|
||||
VLM.GOOGLE_GEMINI_2_5: str,
|
||||
@@ -116,6 +122,7 @@ REQUIRED_ARGUMENTS: dict[VLM, list[str]] = {
|
||||
VLM.PALIGEMMA: ["resolution_wh"],
|
||||
VLM.FLORENCE_2: ["resolution_wh"],
|
||||
VLM.QWEN_2_5_VL: ["input_wh", "resolution_wh"],
|
||||
VLM.QWEN_3_VL: ["resolution_wh"],
|
||||
VLM.DEEPSEEK_VL_2: ["resolution_wh"],
|
||||
VLM.GOOGLE_GEMINI_2_0: ["resolution_wh"],
|
||||
VLM.GOOGLE_GEMINI_2_5: ["resolution_wh"],
|
||||
@@ -126,6 +133,7 @@ ALLOWED_ARGUMENTS: dict[VLM, list[str]] = {
|
||||
VLM.PALIGEMMA: ["resolution_wh", "classes"],
|
||||
VLM.FLORENCE_2: ["resolution_wh"],
|
||||
VLM.QWEN_2_5_VL: ["input_wh", "resolution_wh", "classes"],
|
||||
VLM.QWEN_3_VL: ["resolution_wh", "classes"],
|
||||
VLM.DEEPSEEK_VL_2: ["resolution_wh", "classes"],
|
||||
VLM.GOOGLE_GEMINI_2_0: ["resolution_wh", "classes"],
|
||||
VLM.GOOGLE_GEMINI_2_5: ["resolution_wh", "classes"],
|
||||
@@ -235,6 +243,51 @@ def from_paligemma(
|
||||
return xyxy, class_id, class_name
|
||||
|
||||
|
||||
def recover_truncated_qwen_2_5_vl_response(text: str) -> Any | None:
|
||||
"""
|
||||
Attempt to recover and parse a truncated or malformed JSON snippet from Qwen-2.5-VL
|
||||
output.
|
||||
|
||||
This utility extracts a JSON-like portion from a string that may be truncated or
|
||||
malformed, cleans trailing commas, and attempts to parse it into a Python object.
|
||||
|
||||
Args:
|
||||
text (str): Raw text containing the JSON snippet possibly truncated or
|
||||
incomplete.
|
||||
|
||||
Returns:
|
||||
Parsed Python object (usually list) if recovery and parsing succeed;
|
||||
otherwise `None`.
|
||||
"""
|
||||
try:
|
||||
first_bracket = text.find("[")
|
||||
if first_bracket == -1:
|
||||
return None
|
||||
snippet = text[first_bracket:]
|
||||
|
||||
last_brace = snippet.rfind("}")
|
||||
if last_brace == -1:
|
||||
return None
|
||||
|
||||
snippet = snippet[: last_brace + 1]
|
||||
|
||||
prefix_end = snippet.find("[")
|
||||
if prefix_end == -1:
|
||||
return None
|
||||
|
||||
prefix = snippet[: prefix_end + 1]
|
||||
body = snippet[prefix_end + 1 :].rstrip()
|
||||
|
||||
if body.endswith(","):
|
||||
body = body[:-1].rstrip()
|
||||
|
||||
repaired = prefix + body + "]"
|
||||
|
||||
return json.loads(repaired)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def from_qwen_2_5_vl(
|
||||
result: str,
|
||||
input_wh: tuple[int, int],
|
||||
@@ -242,7 +295,7 @@ def from_qwen_2_5_vl(
|
||||
classes: list[str] | None = None,
|
||||
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray]:
|
||||
"""
|
||||
Parse and scale bounding boxes from Qwen-2.5-VL style JSON output.
|
||||
Parse and rescale bounding boxes and class labels from Qwen-2.5-VL JSON output.
|
||||
|
||||
The JSON is expected to be enclosed in triple backticks with the format:
|
||||
```json
|
||||
@@ -253,38 +306,52 @@ def from_qwen_2_5_vl(
|
||||
```
|
||||
|
||||
Args:
|
||||
result: String containing the JSON snippet enclosed by triple backticks.
|
||||
input_wh: (input_width, input_height) describing the original bounding box
|
||||
scale.
|
||||
resolution_wh: (output_width, output_height) to which we rescale the boxes.
|
||||
classes: Optional list of valid class names. If provided, returned boxes/labels
|
||||
are filtered to only those classes found here.
|
||||
result (str): String containing Qwen-2.5-VL JSON bounding box and label data.
|
||||
input_wh (tuple[int, int]): Width and height of the coordinate space where boxes
|
||||
are normalized.
|
||||
resolution_wh (tuple[int, int]): Target width and height to scale bounding
|
||||
boxes.
|
||||
classes (list[str] or None): Optional list of valid class names to filter
|
||||
results. If provided, only boxes with labels in this list are returned.
|
||||
|
||||
Returns:
|
||||
xyxy (np.ndarray): An array of shape `(n, 4)` containing
|
||||
the bounding boxes coordinates in format `[x1, y1, x2, y2]`
|
||||
class_id (Optional[np.ndarray]): An array of shape `(n,)` containing
|
||||
the class indices for each bounding box (or None if `classes` is not
|
||||
provided)
|
||||
class_name (np.ndarray): An array of shape `(n,)` containing
|
||||
the class labels for each bounding box
|
||||
xyxy (np.ndarray): Array of shape `(N, 4)` with rescaled bounding boxes in
|
||||
`(x_min, y_min, x_max, y_max)` format.
|
||||
class_id (np.ndarray or None): Array of shape `(N,)` with indices of classes,
|
||||
or `None` if no filtering applied.
|
||||
class_name (np.ndarray): Array of shape `(N,)` with class names as strings.
|
||||
"""
|
||||
|
||||
in_w, in_h = validate_resolution(input_wh)
|
||||
out_w, out_h = validate_resolution(resolution_wh)
|
||||
|
||||
pattern = re.compile(r"```json\s*(.*?)\s*```", re.DOTALL)
|
||||
text = result.strip()
|
||||
text = re.sub(r"^```(json)?", "", text, flags=re.IGNORECASE).strip()
|
||||
text = re.sub(r"```$", "", text).strip()
|
||||
|
||||
match = pattern.search(result)
|
||||
if not match:
|
||||
return np.empty((0, 4)), None, np.empty((0,), dtype=str)
|
||||
|
||||
json_snippet = match.group(1)
|
||||
start = text.find("[")
|
||||
end = text.rfind("]")
|
||||
if start != -1 and end != -1 and end > start:
|
||||
text = text[start : end + 1].strip()
|
||||
|
||||
try:
|
||||
data = json.loads(json_snippet)
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return np.empty((0, 4)), None, np.empty((0,), dtype=str)
|
||||
repaired = recover_truncated_qwen_2_5_vl_response(text)
|
||||
if repaired is not None:
|
||||
data = repaired
|
||||
else:
|
||||
try:
|
||||
data = ast.literal_eval(text)
|
||||
except (ValueError, SyntaxError, TypeError):
|
||||
return (
|
||||
np.empty((0, 4)),
|
||||
np.empty((0,), dtype=int),
|
||||
np.empty((0,), dtype=str),
|
||||
)
|
||||
|
||||
if not isinstance(data, list):
|
||||
return (np.empty((0, 4)), np.empty((0,), dtype=int), np.empty((0,), dtype=str))
|
||||
|
||||
boxes_list = []
|
||||
labels_list = []
|
||||
@@ -296,7 +363,7 @@ def from_qwen_2_5_vl(
|
||||
labels_list.append(item["label"])
|
||||
|
||||
if not boxes_list:
|
||||
return np.empty((0, 4)), None, np.empty((0,), dtype=str)
|
||||
return (np.empty((0, 4)), np.empty((0,), dtype=int), np.empty((0,), dtype=str))
|
||||
|
||||
xyxy = np.array(boxes_list, dtype=float)
|
||||
class_name = np.array(labels_list, dtype=str)
|
||||
@@ -315,6 +382,36 @@ def from_qwen_2_5_vl(
|
||||
return xyxy, class_id, class_name
|
||||
|
||||
|
||||
def from_qwen_3_vl(
|
||||
result: str,
|
||||
resolution_wh: tuple[int, int],
|
||||
classes: list[str] | None = None,
|
||||
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray]:
|
||||
"""
|
||||
Parse and scale bounding boxes from Qwen-3-VL style JSON output.
|
||||
|
||||
Args:
|
||||
result (str): String containing the Qwen-3-VL JSON output.
|
||||
resolution_wh (tuple[int, int]): Target resolution `(width, height)` to
|
||||
scale bounding boxes.
|
||||
classes (list[str] or None): Optional list of valid classes to filter
|
||||
results.
|
||||
|
||||
Returns:
|
||||
xyxy (np.ndarray): Array of bounding boxes with shape `(N, 4)` in
|
||||
`(x_min, y_min, x_max, y_max)` format scaled to `resolution_wh`.
|
||||
class_id (np.ndarray or None): Array of class indices for each box, or
|
||||
None if no filtering by classes.
|
||||
class_name (np.ndarray): Array of class names as strings.
|
||||
"""
|
||||
return from_qwen_2_5_vl(
|
||||
result=result,
|
||||
input_wh=(1000, 1000),
|
||||
resolution_wh=resolution_wh,
|
||||
classes=classes,
|
||||
)
|
||||
|
||||
|
||||
def from_deepseek_vl_2(
|
||||
result: str, resolution_wh: tuple[int, int], classes: list[str] | None = None
|
||||
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray]:
|
||||
@@ -538,7 +635,7 @@ def from_google_gemini_2_0(
|
||||
return np.empty((0, 4)), None, np.empty((0,), dtype=str)
|
||||
|
||||
labels = []
|
||||
boxes_list = []
|
||||
xyxy = []
|
||||
|
||||
for item in data:
|
||||
if "box_2d" not in item or "label" not in item:
|
||||
@@ -546,18 +643,16 @@ def from_google_gemini_2_0(
|
||||
labels.append(item["label"])
|
||||
box = item["box_2d"]
|
||||
# Gemini bbox order is [y_min, x_min, y_max, x_max]
|
||||
boxes_list.append(
|
||||
denormalize_boxes(
|
||||
np.array([box[1], box[0], box[3], box[2]]).astype(np.float64),
|
||||
resolution_wh=(w, h),
|
||||
normalization_factor=1000,
|
||||
)
|
||||
)
|
||||
xyxy.append([box[1], box[0], box[3], box[2]])
|
||||
|
||||
if not boxes_list:
|
||||
if len(xyxy) == 0:
|
||||
return np.empty((0, 4)), None, np.empty((0,), dtype=str)
|
||||
|
||||
xyxy = np.array(boxes_list)
|
||||
xyxy = denormalize_boxes(
|
||||
np.array(xyxy, dtype=np.float64),
|
||||
resolution_wh=(w, h),
|
||||
normalization_factor=1000,
|
||||
)
|
||||
class_name = np.array(labels)
|
||||
class_id = None
|
||||
|
||||
@@ -649,10 +744,10 @@ def from_google_gemini_2_5(
|
||||
box = item["box_2d"]
|
||||
# Gemini bbox order is [y_min, x_min, y_max, x_max]
|
||||
absolute_bbox = denormalize_boxes(
|
||||
np.array([box[1], box[0], box[3], box[2]]).astype(np.float64),
|
||||
np.array([[box[1], box[0], box[3], box[2]]]).astype(np.float64),
|
||||
resolution_wh=(w, h),
|
||||
normalization_factor=1000,
|
||||
)
|
||||
)[0]
|
||||
boxes_list.append(absolute_bbox)
|
||||
|
||||
if "mask" in item:
|
||||
@@ -735,7 +830,7 @@ def from_google_gemini_2_5(
|
||||
def from_moondream(
|
||||
result: dict,
|
||||
resolution_wh: tuple[int, int],
|
||||
) -> tuple[np.ndarray]:
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Parse and scale bounding boxes from moondream JSON output.
|
||||
|
||||
@@ -773,7 +868,7 @@ def from_moondream(
|
||||
if "objects" not in result or not isinstance(result["objects"], list):
|
||||
return np.empty((0, 4), dtype=float)
|
||||
|
||||
denormalize_xyxy = []
|
||||
xyxy = []
|
||||
|
||||
for item in result["objects"]:
|
||||
if not all(k in item for k in ["x_min", "y_min", "x_max", "y_max"]):
|
||||
@@ -784,14 +879,12 @@ def from_moondream(
|
||||
x_max = item["x_max"]
|
||||
y_max = item["y_max"]
|
||||
|
||||
denormalize_xyxy.append(
|
||||
denormalize_boxes(
|
||||
np.array([x_min, y_min, x_max, y_max]).astype(np.float64),
|
||||
resolution_wh=(w, h),
|
||||
)
|
||||
)
|
||||
xyxy.append([x_min, y_min, x_max, y_max])
|
||||
|
||||
if not denormalize_xyxy:
|
||||
if len(xyxy) == 0:
|
||||
return np.empty((0, 4))
|
||||
|
||||
return np.array(denormalize_xyxy, dtype=float)
|
||||
return denormalize_boxes(
|
||||
np.array(xyxy).astype(np.float64),
|
||||
resolution_wh=(w, h),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import shutil
|
||||
import cv2
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from PIL import Image
|
||||
|
||||
from supervision.annotators.base import ImageType
|
||||
from supervision.draw.color import Color, unify_to_bgr
|
||||
@@ -15,7 +16,6 @@ from supervision.utils.conversion import (
|
||||
from supervision.utils.internal import deprecated
|
||||
|
||||
|
||||
@ensure_cv2_image_for_standalone_function
|
||||
def crop_image(
|
||||
image: ImageType,
|
||||
xyxy: npt.NDArray[int] | list[int] | tuple[int, int, int, int],
|
||||
@@ -65,9 +65,19 @@ def crop_image(
|
||||
""" # noqa E501 // docs
|
||||
if isinstance(xyxy, (list, tuple)):
|
||||
xyxy = np.array(xyxy)
|
||||
|
||||
xyxy = np.round(xyxy).astype(int)
|
||||
x_min, y_min, x_max, y_max = xyxy.flatten()
|
||||
return image[y_min:y_max, x_min:x_max]
|
||||
|
||||
if isinstance(image, np.ndarray):
|
||||
return image[y_min:y_max, x_min:x_max]
|
||||
|
||||
if isinstance(image, Image.Image):
|
||||
return image.crop((x_min, y_min, x_max, y_max))
|
||||
|
||||
raise TypeError(
|
||||
f"`image` must be a numpy.ndarray or PIL.Image.Image. Received {type(image)}"
|
||||
)
|
||||
|
||||
|
||||
@ensure_cv2_image_for_standalone_function
|
||||
@@ -460,6 +470,62 @@ def grayscale_image(image: ImageType) -> ImageType:
|
||||
return cv2.cvtColor(grayscaled, cv2.COLOR_GRAY2BGR)
|
||||
|
||||
|
||||
def get_image_resolution_wh(image: ImageType) -> tuple[int, int]:
|
||||
"""
|
||||
Get image width and height as a tuple `(width, height)` for various image formats.
|
||||
|
||||
Supports both `numpy.ndarray` images (with shape `(H, W, ...)`) and
|
||||
`PIL.Image.Image` inputs.
|
||||
|
||||
Args:
|
||||
image (`numpy.ndarray` or `PIL.Image.Image`): Input image.
|
||||
|
||||
Returns:
|
||||
(`tuple[int, int]`): Image resolution as `(width, height)`.
|
||||
|
||||
Raises:
|
||||
ValueError: If a `numpy.ndarray` image has fewer than 2 dimensions.
|
||||
TypeError: If `image` is not a supported type (`numpy.ndarray` or
|
||||
`PIL.Image.Image`).
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread("example.png")
|
||||
sv.get_image_resolution_wh(image)
|
||||
# (1920, 1080)
|
||||
```
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
import supervision as sv
|
||||
|
||||
image = Image.open("example.png")
|
||||
sv.get_image_resolution_wh(image)
|
||||
# (1920, 1080)
|
||||
```
|
||||
"""
|
||||
if isinstance(image, np.ndarray):
|
||||
if image.ndim < 2:
|
||||
raise ValueError(
|
||||
"NumPy image must have at least 2 dimensions (H, W, ...). "
|
||||
f"Received shape: {image.shape}"
|
||||
)
|
||||
height, width = image.shape[:2]
|
||||
return int(width), int(height)
|
||||
|
||||
if isinstance(image, Image.Image):
|
||||
width, height = image.size
|
||||
return int(width), int(height)
|
||||
|
||||
raise TypeError(
|
||||
"`image` must be a numpy.ndarray or PIL.Image.Image. "
|
||||
f"Received type: {type(image)}"
|
||||
)
|
||||
|
||||
|
||||
class ImageSink:
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
+104
-39
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Callable, Generator
|
||||
from dataclasses import dataclass
|
||||
from queue import Queue
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -196,63 +198,126 @@ def process_video(
|
||||
source_path: str,
|
||||
target_path: str,
|
||||
callback: Callable[[np.ndarray, int], np.ndarray],
|
||||
*,
|
||||
max_frames: int | None = None,
|
||||
prefetch: int = 32,
|
||||
writer_buffer: int = 32,
|
||||
show_progress: bool = False,
|
||||
progress_message: str = "Processing video",
|
||||
) -> None:
|
||||
"""
|
||||
Process a video file by applying a callback function on each frame
|
||||
and saving the result to a target video file.
|
||||
Process video frames asynchronously using a threaded pipeline.
|
||||
|
||||
This function orchestrates a three-stage pipeline to optimize video processing
|
||||
throughput:
|
||||
|
||||
1. Reader thread: Continuously reads frames from the source video file and
|
||||
enqueues them into a bounded queue (`frame_read_queue`). The queue size is
|
||||
limited by the `prefetch` parameter to control memory usage.
|
||||
2. Main thread (Processor): Dequeues frames from `frame_read_queue`, applies the
|
||||
user-defined `callback` function to process each frame, then enqueues the
|
||||
processed frames into another bounded queue (`frame_write_queue`) for writing.
|
||||
The processing happens in the main thread, simplifying use of stateful objects
|
||||
without synchronization.
|
||||
3. Writer thread: Dequeues processed frames from `frame_write_queue` and writes
|
||||
them sequentially to the output video file.
|
||||
|
||||
Args:
|
||||
source_path (str): The path to the source video file.
|
||||
target_path (str): The path to the target video file.
|
||||
callback (Callable[[np.ndarray, int], np.ndarray]): A function that takes in
|
||||
a numpy ndarray representation of a video frame and an
|
||||
int index of the frame and returns a processed numpy ndarray
|
||||
representation of the frame.
|
||||
max_frames (Optional[int]): The maximum number of frames to process.
|
||||
show_progress (bool): Whether to show a progress bar.
|
||||
progress_message (str): The message to display in the progress bar.
|
||||
source_path (str): Path to the input video file.
|
||||
target_path (str): Path where the processed video will be saved.
|
||||
callback (Callable[[numpy.ndarray, int], numpy.ndarray]): Function called for
|
||||
each frame, accepting the frame as a numpy array and its zero-based index,
|
||||
returning the processed frame.
|
||||
max_frames (int | None): Optional maximum number of frames to process.
|
||||
If None, the entire video is processed (default).
|
||||
prefetch (int): Maximum number of frames buffered by the reader thread.
|
||||
Controls memory use; default is 32.
|
||||
writer_buffer (int): Maximum number of frames buffered before writing.
|
||||
Controls output buffer size; default is 32.
|
||||
show_progress (bool): Whether to display a tqdm progress bar during processing.
|
||||
Default is False.
|
||||
progress_message (str): Description shown in the progress bar.
|
||||
|
||||
Examples:
|
||||
Returns:
|
||||
None
|
||||
|
||||
Example:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from rfdetr import RFDETRMedium
|
||||
|
||||
def callback(scene: np.ndarray, index: int) -> np.ndarray:
|
||||
...
|
||||
model = RFDETRMedium()
|
||||
|
||||
def callback(frame, frame_index):
|
||||
return model.predict(frame)
|
||||
|
||||
process_video(
|
||||
source_path=<SOURCE_VIDEO_PATH>,
|
||||
target_path=<TARGET_VIDEO_PATH>,
|
||||
callback=callback
|
||||
source_path="source.mp4",
|
||||
target_path="target.mp4",
|
||||
callback=frame_callback,
|
||||
)
|
||||
```
|
||||
"""
|
||||
source_video_info = VideoInfo.from_video_path(video_path=source_path)
|
||||
video_frames_generator = get_video_frames_generator(
|
||||
source_path=source_path, end=max_frames
|
||||
video_info = VideoInfo.from_video_path(video_path=source_path)
|
||||
total_frames = (
|
||||
min(video_info.total_frames, max_frames)
|
||||
if max_frames is not None
|
||||
else video_info.total_frames
|
||||
)
|
||||
with VideoSink(target_path=target_path, video_info=source_video_info) as sink:
|
||||
total_frames = (
|
||||
min(source_video_info.total_frames, max_frames)
|
||||
if max_frames is not None
|
||||
else source_video_info.total_frames
|
||||
|
||||
frame_read_queue: Queue[tuple[int, np.ndarray] | None] = Queue(maxsize=prefetch)
|
||||
frame_write_queue: Queue[np.ndarray | None] = Queue(maxsize=writer_buffer)
|
||||
|
||||
def reader_thread() -> None:
|
||||
frame_generator = get_video_frames_generator(
|
||||
source_path=source_path,
|
||||
end=max_frames,
|
||||
)
|
||||
for index, frame in enumerate(
|
||||
tqdm(
|
||||
video_frames_generator,
|
||||
total=total_frames,
|
||||
disable=not show_progress,
|
||||
desc=progress_message,
|
||||
)
|
||||
):
|
||||
result_frame = callback(frame, index)
|
||||
sink.write_frame(frame=result_frame)
|
||||
else:
|
||||
for index, frame in enumerate(video_frames_generator):
|
||||
result_frame = callback(frame, index)
|
||||
sink.write_frame(frame=result_frame)
|
||||
for frame_index, frame in enumerate(frame_generator):
|
||||
frame_read_queue.put((frame_index, frame))
|
||||
frame_read_queue.put(None)
|
||||
|
||||
def writer_thread(video_sink: VideoSink) -> None:
|
||||
while True:
|
||||
frame = frame_write_queue.get()
|
||||
if frame is None:
|
||||
break
|
||||
video_sink.write_frame(frame=frame)
|
||||
|
||||
reader_worker = threading.Thread(target=reader_thread, daemon=True)
|
||||
with VideoSink(target_path=target_path, video_info=video_info) as video_sink:
|
||||
writer_worker = threading.Thread(
|
||||
target=writer_thread,
|
||||
args=(video_sink,),
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
reader_worker.start()
|
||||
writer_worker.start()
|
||||
|
||||
progress_bar = tqdm(
|
||||
total=total_frames,
|
||||
disable=not show_progress,
|
||||
desc=progress_message,
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
read_item = frame_read_queue.get()
|
||||
if read_item is None:
|
||||
break
|
||||
|
||||
frame_index, frame = read_item
|
||||
processed_frame = callback(frame, frame_index)
|
||||
|
||||
frame_write_queue.put(processed_frame)
|
||||
progress_bar.update(1)
|
||||
finally:
|
||||
frame_write_queue.put(None)
|
||||
reader_worker.join()
|
||||
writer_worker.join()
|
||||
progress_bar.close()
|
||||
|
||||
|
||||
class FPSMonitor:
|
||||
|
||||
@@ -320,6 +320,43 @@ def test_from_paligemma(
|
||||
np.array(["dog"], dtype=str),
|
||||
),
|
||||
), # out-of-bounds box
|
||||
(
|
||||
does_not_raise(),
|
||||
"""[
|
||||
{'bbox_2d': [10, 20, 110, 120], 'label': 'cat'}
|
||||
]""",
|
||||
(640, 640),
|
||||
(1280, 720),
|
||||
None,
|
||||
(
|
||||
np.array([[20.0, 22.5, 220.0, 135.0]]),
|
||||
None,
|
||||
np.array(["cat"], dtype=str),
|
||||
),
|
||||
), # python-style list, single quotes, no fences
|
||||
(
|
||||
does_not_raise(),
|
||||
"""```json
|
||||
[
|
||||
{"bbox_2d": [0, 0, 64, 64], "label": "dog"},
|
||||
{"bbox_2d": [10, 20, 110, 120], "label": "cat"},
|
||||
{"bbox_2d": [30, 40, 130, 140], "label":
|
||||
""",
|
||||
(640, 640),
|
||||
(640, 640),
|
||||
None,
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[0.0, 0.0, 64.0, 64.0],
|
||||
[10.0, 20.0, 110.0, 120.0],
|
||||
],
|
||||
dtype=float,
|
||||
),
|
||||
None,
|
||||
np.array(["dog", "cat"], dtype=str),
|
||||
),
|
||||
), # truncated response, last object unfinished, previous ones recovered
|
||||
(
|
||||
pytest.raises(ValueError),
|
||||
"""```json
|
||||
@@ -330,8 +367,8 @@ def test_from_paligemma(
|
||||
(0, 640),
|
||||
(1280, 720),
|
||||
None,
|
||||
None, # won't be compared because we expect an exception
|
||||
), # zero input width -> ValueError
|
||||
None, # invalid input_wh
|
||||
),
|
||||
(
|
||||
pytest.raises(ValueError),
|
||||
"""```json
|
||||
@@ -342,8 +379,8 @@ def test_from_paligemma(
|
||||
(640, 640),
|
||||
(1280, -100),
|
||||
None,
|
||||
None,
|
||||
), # negative resolution height -> ValueError
|
||||
None, # invalid resolution_wh
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_from_qwen_2_5_vl(
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack as DoesNotRaise
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.detection.tools.inference_slicer import InferenceSlicer
|
||||
from supervision.detection.utils.iou_and_nms import OverlapFilter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -20,54 +17,10 @@ def mock_callback():
|
||||
return callback
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"slice_wh, overlap_ratio_wh, overlap_wh, expected_overlap, exception",
|
||||
[
|
||||
# Valid case: explicit overlap_wh in pixels
|
||||
((128, 128), None, (26, 26), (26, 26), DoesNotRaise()),
|
||||
# Valid case: overlap_wh in pixels
|
||||
((128, 128), None, (20, 20), (20, 20), DoesNotRaise()),
|
||||
# Invalid case: negative overlap_wh, should raise ValueError
|
||||
((128, 128), None, (-10, 20), None, pytest.raises(ValueError)),
|
||||
# Invalid case: no overlaps defined
|
||||
((128, 128), None, None, None, pytest.raises(ValueError)),
|
||||
# Valid case: overlap_wh = 50 pixels
|
||||
((256, 256), None, (50, 50), (50, 50), DoesNotRaise()),
|
||||
# Valid case: overlap_wh = 60 pixels
|
||||
((200, 200), None, (60, 60), (60, 60), DoesNotRaise()),
|
||||
# Valid case: small overlap_wh values
|
||||
((100, 100), None, (0.1, 0.1), (0.1, 0.1), DoesNotRaise()),
|
||||
# Invalid case: negative overlap_wh values
|
||||
((128, 128), None, (-10, -10), None, pytest.raises(ValueError)),
|
||||
# Invalid case: overlap_wh greater than slice size
|
||||
((128, 128), None, (150, 150), (150, 150), DoesNotRaise()),
|
||||
# Valid case: zero overlap
|
||||
((128, 128), None, (0, 0), (0, 0), DoesNotRaise()),
|
||||
],
|
||||
)
|
||||
def test_inference_slicer_overlap(
|
||||
mock_callback,
|
||||
slice_wh: tuple[int, int],
|
||||
overlap_ratio_wh: tuple[float, float] | None,
|
||||
overlap_wh: tuple[int, int] | None,
|
||||
expected_overlap: tuple[int, int] | None,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
with exception:
|
||||
slicer = InferenceSlicer(
|
||||
callback=mock_callback,
|
||||
slice_wh=slice_wh,
|
||||
overlap_ratio_wh=overlap_ratio_wh,
|
||||
overlap_wh=overlap_wh,
|
||||
overlap_filter=OverlapFilter.NONE,
|
||||
)
|
||||
assert slicer.overlap_wh == expected_overlap
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resolution_wh, slice_wh, overlap_wh, expected_offsets",
|
||||
[
|
||||
# Case 1: No overlap, exact slices fit within image dimensions
|
||||
# Case 1: Square image, square slices, no overlap
|
||||
(
|
||||
(256, 256),
|
||||
(128, 128),
|
||||
@@ -81,7 +34,7 @@ def test_inference_slicer_overlap(
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 2: Overlap of 64 pixels in both directions
|
||||
# Case 2: Square image, square slices, non-zero overlap
|
||||
(
|
||||
(256, 256),
|
||||
(128, 128),
|
||||
@@ -91,96 +44,154 @@ def test_inference_slicer_overlap(
|
||||
[0, 0, 128, 128],
|
||||
[64, 0, 192, 128],
|
||||
[128, 0, 256, 128],
|
||||
[192, 0, 256, 128],
|
||||
[0, 64, 128, 192],
|
||||
[64, 64, 192, 192],
|
||||
[128, 64, 256, 192],
|
||||
[192, 64, 256, 192],
|
||||
[0, 128, 128, 256],
|
||||
[64, 128, 192, 256],
|
||||
[128, 128, 256, 256],
|
||||
[192, 128, 256, 256],
|
||||
[0, 192, 128, 256],
|
||||
[64, 192, 192, 256],
|
||||
[128, 192, 256, 256],
|
||||
[192, 192, 256, 256],
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 3: Image not perfectly divisible by slice size (no overlap)
|
||||
# Case 3: Rectangle image (horizontal), square slices, no overlap
|
||||
(
|
||||
(300, 300),
|
||||
(128, 128),
|
||||
(192, 128),
|
||||
(64, 64),
|
||||
(0, 0),
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 128, 128],
|
||||
[128, 0, 256, 128],
|
||||
[256, 0, 300, 128],
|
||||
[0, 128, 128, 256],
|
||||
[128, 128, 256, 256],
|
||||
[256, 128, 300, 256],
|
||||
[0, 256, 128, 300],
|
||||
[128, 256, 256, 300],
|
||||
[256, 256, 300, 300],
|
||||
[0, 0, 64, 64],
|
||||
[64, 0, 128, 64],
|
||||
[128, 0, 192, 64],
|
||||
[0, 64, 64, 128],
|
||||
[64, 64, 128, 128],
|
||||
[128, 64, 192, 128],
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 4: Overlap of 32 pixels, image not perfectly divisible by slice size
|
||||
# Case 4: Rectangle image (horizontal), square slices, non-zero overlap
|
||||
(
|
||||
(300, 300),
|
||||
(128, 128),
|
||||
(192, 128),
|
||||
(64, 64),
|
||||
(32, 32),
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 128, 128],
|
||||
[96, 0, 224, 128],
|
||||
[192, 0, 300, 128],
|
||||
[288, 0, 300, 128],
|
||||
[0, 96, 128, 224],
|
||||
[96, 96, 224, 224],
|
||||
[192, 96, 300, 224],
|
||||
[288, 96, 300, 224],
|
||||
[0, 192, 128, 300],
|
||||
[96, 192, 224, 300],
|
||||
[192, 192, 300, 300],
|
||||
[288, 192, 300, 300],
|
||||
[0, 288, 128, 300],
|
||||
[96, 288, 224, 300],
|
||||
[192, 288, 300, 300],
|
||||
[288, 288, 300, 300],
|
||||
[0, 0, 64, 64],
|
||||
[32, 0, 96, 64],
|
||||
[64, 0, 128, 64],
|
||||
[96, 0, 160, 64],
|
||||
[128, 0, 192, 64],
|
||||
[0, 32, 64, 96],
|
||||
[32, 32, 96, 96],
|
||||
[64, 32, 128, 96],
|
||||
[96, 32, 160, 96],
|
||||
[128, 32, 192, 96],
|
||||
[0, 64, 64, 128],
|
||||
[32, 64, 96, 128],
|
||||
[64, 64, 128, 128],
|
||||
[96, 64, 160, 128],
|
||||
[128, 64, 192, 128],
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 5: Image smaller than slice size (no overlap)
|
||||
# Case 5: Rectangle image (vertical), square slices, no overlap
|
||||
(
|
||||
(100, 100),
|
||||
(128, 128),
|
||||
(128, 192),
|
||||
(64, 64),
|
||||
(0, 0),
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 100, 100],
|
||||
[0, 0, 64, 64],
|
||||
[64, 0, 128, 64],
|
||||
[0, 64, 64, 128],
|
||||
[64, 64, 128, 128],
|
||||
[0, 128, 64, 192],
|
||||
[64, 128, 128, 192],
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 6: Rectangle image (vertical), square slices, non-zero overlap
|
||||
(
|
||||
(128, 192),
|
||||
(64, 64),
|
||||
(32, 32),
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 64, 64],
|
||||
[32, 0, 96, 64],
|
||||
[64, 0, 128, 64],
|
||||
[0, 32, 64, 96],
|
||||
[32, 32, 96, 96],
|
||||
[64, 32, 128, 96],
|
||||
[0, 64, 64, 128],
|
||||
[32, 64, 96, 128],
|
||||
[64, 64, 128, 128],
|
||||
[0, 96, 64, 160],
|
||||
[32, 96, 96, 160],
|
||||
[64, 96, 128, 160],
|
||||
[0, 128, 64, 192],
|
||||
[32, 128, 96, 192],
|
||||
[64, 128, 128, 192],
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 7: Square image, rectangular slices (horizontal), no overlap
|
||||
(
|
||||
(160, 160),
|
||||
(80, 40),
|
||||
(0, 0),
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 80, 40],
|
||||
[80, 0, 160, 40],
|
||||
[0, 40, 80, 80],
|
||||
[80, 40, 160, 80],
|
||||
[0, 80, 80, 120],
|
||||
[80, 80, 160, 120],
|
||||
[0, 120, 80, 160],
|
||||
[80, 120, 160, 160],
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 8: Square image, rectangular slices (vertical), non-zero overlap
|
||||
(
|
||||
(160, 160),
|
||||
(40, 80),
|
||||
(10, 20),
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 40, 80],
|
||||
[30, 0, 70, 80],
|
||||
[60, 0, 100, 80],
|
||||
[90, 0, 130, 80],
|
||||
[120, 0, 160, 80],
|
||||
[0, 60, 40, 140],
|
||||
[30, 60, 70, 140],
|
||||
[60, 60, 100, 140],
|
||||
[90, 60, 130, 140],
|
||||
[120, 60, 160, 140],
|
||||
[0, 80, 40, 160],
|
||||
[30, 80, 70, 160],
|
||||
[60, 80, 100, 160],
|
||||
[90, 80, 130, 160],
|
||||
[120, 80, 160, 160],
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 6: Overlap_wh is greater than the slice size
|
||||
((256, 256), (128, 128), (150, 150), np.array([]).reshape(0, 4)),
|
||||
],
|
||||
)
|
||||
def test_generate_offset(
|
||||
resolution_wh: tuple[int, int],
|
||||
slice_wh: tuple[int, int],
|
||||
overlap_wh: tuple[int, int] | None,
|
||||
overlap_wh: tuple[int, int],
|
||||
expected_offsets: np.ndarray,
|
||||
) -> None:
|
||||
offsets = InferenceSlicer._generate_offset(
|
||||
resolution_wh=resolution_wh,
|
||||
slice_wh=slice_wh,
|
||||
overlap_ratio_wh=None,
|
||||
overlap_wh=overlap_wh,
|
||||
)
|
||||
|
||||
# Verify that the generated offsets match the expected offsets
|
||||
assert np.array_equal(offsets, expected_offsets), (
|
||||
f"Expected {expected_offsets}, got {offsets}"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,12 @@ from contextlib import ExitStack as DoesNotRaise
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.detection.utils.boxes import clip_boxes, move_boxes, scale_boxes
|
||||
from supervision.detection.utils.boxes import (
|
||||
clip_boxes,
|
||||
denormalize_boxes,
|
||||
move_boxes,
|
||||
scale_boxes,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -142,3 +147,88 @@ def test_scale_boxes(
|
||||
with exception:
|
||||
result = scale_boxes(xyxy=xyxy, factor=factor)
|
||||
assert np.array_equal(result, expected_result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"xyxy, resolution_wh, normalization_factor, expected_result, exception",
|
||||
[
|
||||
(
|
||||
np.empty(shape=(0, 4)),
|
||||
(1280, 720),
|
||||
1.0,
|
||||
np.empty(shape=(0, 4)),
|
||||
DoesNotRaise(),
|
||||
), # empty array
|
||||
(
|
||||
np.array([[0.1, 0.2, 0.5, 0.6]]),
|
||||
(1280, 720),
|
||||
1.0,
|
||||
np.array([[128.0, 144.0, 640.0, 432.0]]),
|
||||
DoesNotRaise(),
|
||||
), # single box with default normalization
|
||||
(
|
||||
np.array([[0.1, 0.2, 0.5, 0.6], [0.3, 0.4, 0.7, 0.8]]),
|
||||
(1280, 720),
|
||||
1.0,
|
||||
np.array([[128.0, 144.0, 640.0, 432.0], [384.0, 288.0, 896.0, 576.0]]),
|
||||
DoesNotRaise(),
|
||||
), # two boxes with default normalization
|
||||
(
|
||||
np.array(
|
||||
[[0.1, 0.2, 0.5, 0.6], [0.3, 0.4, 0.7, 0.8], [0.2, 0.1, 0.6, 0.5]]
|
||||
),
|
||||
(1280, 720),
|
||||
1.0,
|
||||
np.array(
|
||||
[
|
||||
[128.0, 144.0, 640.0, 432.0],
|
||||
[384.0, 288.0, 896.0, 576.0],
|
||||
[256.0, 72.0, 768.0, 360.0],
|
||||
]
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # three boxes - regression test for issue #1959
|
||||
(
|
||||
np.array([[10.0, 20.0, 50.0, 60.0]]),
|
||||
(100, 200),
|
||||
100.0,
|
||||
np.array([[10.0, 40.0, 50.0, 120.0]]),
|
||||
DoesNotRaise(),
|
||||
), # single box with custom normalization factor
|
||||
(
|
||||
np.array([[10.0, 20.0, 50.0, 60.0], [30.0, 40.0, 70.0, 80.0]]),
|
||||
(100, 200),
|
||||
100.0,
|
||||
np.array([[10.0, 40.0, 50.0, 120.0], [30.0, 80.0, 70.0, 160.0]]),
|
||||
DoesNotRaise(),
|
||||
), # two boxes with custom normalization factor
|
||||
(
|
||||
np.array([[0.0, 0.0, 1.0, 1.0]]),
|
||||
(1920, 1080),
|
||||
1.0,
|
||||
np.array([[0.0, 0.0, 1920.0, 1080.0]]),
|
||||
DoesNotRaise(),
|
||||
), # full frame box
|
||||
(
|
||||
np.array([[0.5, 0.5, 0.5, 0.5]]),
|
||||
(640, 480),
|
||||
1.0,
|
||||
np.array([[320.0, 240.0, 320.0, 240.0]]),
|
||||
DoesNotRaise(),
|
||||
), # zero-area box (point)
|
||||
],
|
||||
)
|
||||
def test_denormalize_boxes(
|
||||
xyxy: np.ndarray,
|
||||
resolution_wh: tuple[int, int],
|
||||
normalization_factor: float,
|
||||
expected_result: np.ndarray,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
with exception:
|
||||
result = denormalize_boxes(
|
||||
xyxy=xyxy,
|
||||
resolution_wh=resolution_wh,
|
||||
normalization_factor=normalization_factor,
|
||||
)
|
||||
assert np.allclose(result, expected_result)
|
||||
|
||||
@@ -6,11 +6,15 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.detection.utils.iou_and_nms import (
|
||||
OverlapMetric,
|
||||
_group_overlapping_boxes,
|
||||
box_iou,
|
||||
box_iou_batch,
|
||||
box_non_max_suppression,
|
||||
mask_non_max_merge,
|
||||
mask_non_max_suppression,
|
||||
)
|
||||
from test.test_utils import random_boxes
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -631,3 +635,497 @@ def test_mask_non_max_merge(
|
||||
sorted_result = sorted([sorted(group) for group in result])
|
||||
sorted_expected_result = sorted([sorted(group) for group in expected_result])
|
||||
assert sorted_result == sorted_expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"box_true, box_detection, overlap_metric, expected_overlap, exception",
|
||||
[
|
||||
(
|
||||
[100.0, 100.0, 200.0, 200.0],
|
||||
[150.0, 150.0, 250.0, 250.0],
|
||||
OverlapMetric.IOU,
|
||||
0.14285714285714285,
|
||||
DoesNotRaise(),
|
||||
), # partial overlap, IOU
|
||||
(
|
||||
[100.0, 100.0, 200.0, 200.0],
|
||||
[150.0, 150.0, 250.0, 250.0],
|
||||
OverlapMetric.IOS,
|
||||
0.25,
|
||||
DoesNotRaise(),
|
||||
), # partial overlap, IOS
|
||||
(
|
||||
np.array([0.0, 0.0, 10.0, 10.0], dtype=np.float32),
|
||||
np.array([0.0, 0.0, 10.0, 10.0], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # identical boxes, both boxes are arrays, IOU
|
||||
(
|
||||
np.array([0.0, 0.0, 10.0, 10.0], dtype=np.float32),
|
||||
np.array([0.0, 0.0, 10.0, 10.0], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # identical boxes, both boxes are arrays, IOS
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
"iou",
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # identical boxes, both boxes are arrays, IOU as lowercase string
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
"ios",
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # identical boxes, both boxes are arrays, IOS as lowercase string
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
"IOU",
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # identical boxes, both boxes are arrays, IOU as uppercase string
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
"IOU",
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # identical boxes, both boxes are arrays, IOS as uppercase string
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[20.0, 20.0, 30.0, 30.0],
|
||||
OverlapMetric.IOU,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # no overlap, IOU
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[20.0, 20.0, 30.0, 30.0],
|
||||
OverlapMetric.IOS,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # no overlap, IOS
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[10.0, 0.0, 20.0, 10.0],
|
||||
OverlapMetric.IOU,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # boxes touch at edge, zero intersection, IOU
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[10.0, 0.0, 20.0, 10.0],
|
||||
OverlapMetric.IOS,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # boxes touch at edge, zero intersection, IOU
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[2.0, 2.0, 8.0, 8.0],
|
||||
OverlapMetric.IOU,
|
||||
0.36,
|
||||
DoesNotRaise(),
|
||||
), # one box inside another, IOU
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[2.0, 2.0, 8.0, 8.0],
|
||||
OverlapMetric.IOS,
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # one box inside another, IOS
|
||||
(
|
||||
[0.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
OverlapMetric.IOU,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # degenerate true box with zero area, IOU
|
||||
(
|
||||
[0.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
OverlapMetric.IOS,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # degenerate true box with zero area, IOS
|
||||
(
|
||||
[0.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 0.0],
|
||||
OverlapMetric.IOU,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # both boxes fully degenerate, IOU
|
||||
(
|
||||
[0.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 0.0],
|
||||
OverlapMetric.IOS,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # both boxes fully degenerate, IOS
|
||||
(
|
||||
[-5.0, 0.0, 5.0, 10.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
OverlapMetric.IOU,
|
||||
1.0 / 3.0,
|
||||
DoesNotRaise(),
|
||||
), # negative x_min, overlapping boxes, IOU is 1/3
|
||||
(
|
||||
[-5.0, 0.0, 5.0, 10.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
OverlapMetric.IOS,
|
||||
0.5,
|
||||
DoesNotRaise(),
|
||||
), # negative x_min, overlapping boxes, IOS is 0.5
|
||||
(
|
||||
[0.0, 0.0, 1.0, 1.0],
|
||||
[0.5, 0.5, 1.5, 1.5],
|
||||
OverlapMetric.IOU,
|
||||
0.14285714285714285,
|
||||
DoesNotRaise(),
|
||||
), # partial overlap with fractional coordinates, IOU
|
||||
(
|
||||
[0.0, 0.0, 1.0, 1.0],
|
||||
[0.5, 0.5, 1.5, 1.5],
|
||||
OverlapMetric.IOS,
|
||||
0.25,
|
||||
DoesNotRaise(),
|
||||
), # partial overlap with fractional coordinates, IOS
|
||||
],
|
||||
)
|
||||
def test_box_iou(
|
||||
box_true: list[float] | np.ndarray,
|
||||
box_detection: list[float] | np.ndarray,
|
||||
overlap_metric: str | OverlapMetric,
|
||||
expected_overlap: float,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
with exception:
|
||||
result = box_iou(
|
||||
box_true=box_true,
|
||||
box_detection=box_detection,
|
||||
overlap_metric=overlap_metric,
|
||||
)
|
||||
assert result == pytest.approx(expected_overlap, rel=1e-6, abs=1e-12)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"boxes_true, boxes_detection, overlap_metric, expected_overlap, exception",
|
||||
[
|
||||
# both inputs empty
|
||||
(
|
||||
np.empty((0, 4), dtype=np.float32),
|
||||
np.empty((0, 4), dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.empty((0, 0), dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# one true box, no detections
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.empty((0, 4), dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.empty((1, 0), dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# no true boxes, one detection
|
||||
(
|
||||
np.empty((0, 4), dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.empty((0, 1), dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 partial overlap, IOU
|
||||
(
|
||||
np.array([[100.0, 100.0, 200.0, 200.0]], dtype=np.float32),
|
||||
np.array([[150.0, 150.0, 250.0, 250.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[0.14285715]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 partial overlap, IOS
|
||||
(
|
||||
np.array([[100.0, 100.0, 200.0, 200.0]], dtype=np.float32),
|
||||
np.array([[150.0, 150.0, 250.0, 250.0]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[0.25]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 identical boxes, IOU as lowercase string
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
"iou",
|
||||
np.array([[1.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 identical boxes, IOS as lowercase string
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
"ios",
|
||||
np.array([[1.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 identical boxes, IOU as uppercase string
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
"IOU",
|
||||
np.array([[1.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 identical boxes, IOS as uppercase string
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
"IOS",
|
||||
np.array([[1.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 no overlap, IOU
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[20.0, 20.0, 30.0, 30.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 no overlap, IOS
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[20.0, 20.0, 30.0, 30.0]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 touching at edge, zero intersection, IOU
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[10.0, 0.0, 20.0, 10.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 touching at edge, zero intersection, IOS
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[10.0, 0.0, 20.0, 10.0]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 box inside another, IOU
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[2.0, 2.0, 8.0, 8.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[0.36]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 box inside another, IOS
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[2.0, 2.0, 8.0, 8.0]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[1.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 degenerate true box, IOU
|
||||
(
|
||||
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 degenerate true box, IOS
|
||||
(
|
||||
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 both boxes degenerate, IOU
|
||||
(
|
||||
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 both boxes degenerate, IOS
|
||||
(
|
||||
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 negative coordinate, partial overlap, IOU
|
||||
(
|
||||
np.array([[-5.0, 0.0, 5.0, 10.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[1.0 / 3.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 negative coordinate, partial overlap, IOS
|
||||
(
|
||||
np.array([[-5.0, 0.0, 5.0, 10.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[0.5]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 fractional coordinates, partial overlap, IOU
|
||||
(
|
||||
np.array([[0.0, 0.0, 1.0, 1.0]], dtype=np.float32),
|
||||
np.array([[0.5, 0.5, 1.5, 1.5]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[0.14285715]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 fractional coordinates, partial overlap, IOS
|
||||
(
|
||||
np.array([[0.0, 0.0, 1.0, 1.0]], dtype=np.float32),
|
||||
np.array([[0.5, 0.5, 1.5, 1.5]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[0.25]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# true batch case, 2x2, IOU
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[10.0, 10.0, 20.0, 20.0],
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
np.array(
|
||||
[
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[5.0, 5.0, 15.0, 15.0],
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
OverlapMetric.IOU,
|
||||
np.array(
|
||||
[
|
||||
[1.0, 0.14285715],
|
||||
[0.0, 0.14285715],
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# true batch case, 2x2, IOS
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[10.0, 10.0, 20.0, 20.0],
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
np.array(
|
||||
[
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[5.0, 5.0, 15.0, 15.0],
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
OverlapMetric.IOS,
|
||||
np.array(
|
||||
[
|
||||
[1.0, 0.25],
|
||||
[0.0, 0.25],
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# invalid overlap_metric
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
"invalid",
|
||||
None,
|
||||
pytest.raises(ValueError),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_box_iou_batch(
|
||||
boxes_true: np.ndarray,
|
||||
boxes_detection: np.ndarray,
|
||||
overlap_metric: str | OverlapMetric,
|
||||
expected_overlap: np.ndarray | None,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
with exception:
|
||||
result = box_iou_batch(
|
||||
boxes_true=boxes_true,
|
||||
boxes_detection=boxes_detection,
|
||||
overlap_metric=overlap_metric,
|
||||
)
|
||||
|
||||
assert isinstance(result, np.ndarray)
|
||||
assert result.shape == expected_overlap.shape
|
||||
assert np.allclose(
|
||||
result,
|
||||
expected_overlap,
|
||||
rtol=1e-6,
|
||||
atol=1e-12,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"num_true, num_det",
|
||||
[
|
||||
(5, 5),
|
||||
(5, 10),
|
||||
(10, 5),
|
||||
(10, 10),
|
||||
(20, 30),
|
||||
(30, 20),
|
||||
(50, 50),
|
||||
(100, 100),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"overlap_metric",
|
||||
[OverlapMetric.IOU, OverlapMetric.IOS],
|
||||
)
|
||||
def test_box_iou_batch_symmetric_large(
|
||||
num_true: int,
|
||||
num_det: int,
|
||||
overlap_metric: OverlapMetric,
|
||||
) -> None:
|
||||
boxes_true = random_boxes(num_true)
|
||||
boxes_det = random_boxes(num_det)
|
||||
|
||||
result_ab = box_iou_batch(
|
||||
boxes_true=boxes_true,
|
||||
boxes_detection=boxes_det,
|
||||
overlap_metric=overlap_metric,
|
||||
)
|
||||
result_ba = box_iou_batch(
|
||||
boxes_true=boxes_det,
|
||||
boxes_detection=boxes_true,
|
||||
overlap_metric=overlap_metric,
|
||||
)
|
||||
|
||||
assert result_ab.shape == (num_true, num_det)
|
||||
assert result_ba.shape == (num_det, num_true)
|
||||
assert np.allclose(
|
||||
result_ab,
|
||||
result_ba.T,
|
||||
rtol=1e-6,
|
||||
atol=1e-12,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from supervision.detection.utils.masks import (
|
||||
calculate_masks_centroids,
|
||||
contains_holes,
|
||||
contains_multiple_segments,
|
||||
filter_segments_by_distance,
|
||||
move_masks,
|
||||
)
|
||||
|
||||
@@ -500,3 +501,228 @@ def test_contains_multiple_segments(
|
||||
with exception:
|
||||
result = contains_multiple_segments(mask=mask, connectivity=connectivity)
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mask, connectivity, mode, absolute_distance, relative_distance, expected_result, exception", # noqa: E501
|
||||
[
|
||||
# single component, unchanged
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
8,
|
||||
"edge",
|
||||
2.0,
|
||||
None,
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# two components, edge distance 2, kept with abs=1
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 1],
|
||||
[0, 1, 1, 1, 0, 1],
|
||||
[0, 1, 1, 1, 0, 1],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
8,
|
||||
"edge",
|
||||
2.0,
|
||||
None,
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 1],
|
||||
[0, 1, 1, 1, 0, 1],
|
||||
[0, 1, 1, 1, 0, 1],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# centroid mode, far centroids, dropped with small relative threshold
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 1, 1, 1],
|
||||
[0, 0, 0, 1, 1, 1],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
8,
|
||||
"centroid",
|
||||
None,
|
||||
0.3, # diagonal ~8.49, threshold ~2.55, centroid gap ~4.24
|
||||
np.array(
|
||||
[
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# centroid mode, larger relative threshold, kept
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 1, 1, 1],
|
||||
[0, 0, 0, 1, 1, 1],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
8,
|
||||
"centroid",
|
||||
None,
|
||||
0.6, # diagonal ~8.49, threshold ~5.09, centroid gap ~4.24
|
||||
np.array(
|
||||
[
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 1, 1, 1],
|
||||
[0, 0, 0, 1, 1, 1],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# empty mask
|
||||
(
|
||||
np.zeros((4, 4), dtype=bool),
|
||||
4,
|
||||
"edge",
|
||||
2.0,
|
||||
None,
|
||||
np.zeros((4, 4), dtype=bool),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# full mask
|
||||
(
|
||||
np.ones((4, 4), dtype=bool),
|
||||
8,
|
||||
"centroid",
|
||||
None,
|
||||
0.2,
|
||||
np.ones((4, 4), dtype=bool),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# two components, pixel distance = 2, kept with abs=2
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 1, 1, 1],
|
||||
[0, 1, 1, 1, 0, 1, 1, 1],
|
||||
[0, 1, 1, 1, 0, 1, 1, 1],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
8,
|
||||
"edge",
|
||||
2.0, # was 1.0
|
||||
None,
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 1, 1, 1],
|
||||
[0, 1, 1, 1, 0, 1, 1, 1],
|
||||
[0, 1, 1, 1, 0, 1, 1, 1],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# two components, pixel distance = 3, dropped with abs=2
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0, 0, 1, 1],
|
||||
[0, 1, 1, 1, 0, 0, 0, 1, 1],
|
||||
[0, 1, 1, 1, 0, 0, 0, 1, 1],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
8,
|
||||
"edge",
|
||||
2.0, # keep threshold below 3 so the right blob is removed
|
||||
None,
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_filter_segments_by_distance_sweep(
|
||||
mask: npt.NDArray,
|
||||
connectivity: int,
|
||||
mode: str,
|
||||
absolute_distance: float | None,
|
||||
relative_distance: float | None,
|
||||
expected_result: npt.NDArray | None,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
with exception:
|
||||
result = filter_segments_by_distance(
|
||||
mask=mask,
|
||||
connectivity=connectivity,
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
absolute_distance=absolute_distance,
|
||||
relative_distance=relative_distance,
|
||||
)
|
||||
assert np.array_equal(result, expected_result)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
@@ -52,5 +53,49 @@ def mock_key_points(
|
||||
)
|
||||
|
||||
|
||||
def random_boxes(
|
||||
count: int,
|
||||
image_size: tuple[int, int] = (1920, 1080),
|
||||
min_box_size: int = 20,
|
||||
max_box_size: int = 200,
|
||||
seed: int | None = None,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Generate random bounding boxes within given image dimensions and size constraints.
|
||||
|
||||
Creates `count` bounding boxes randomly positioned and sized, ensuring each
|
||||
stays within image bounds and has width and height in the specified range.
|
||||
|
||||
Args:
|
||||
count (`int`): Number of random bounding boxes to generate.
|
||||
image_size (`tuple[int, int]`): Image size as `(width, height)`.
|
||||
min_box_size (`int`): Minimum side length (pixels) for generated boxes.
|
||||
max_box_size (`int`): Maximum side length (pixels) for generated boxes.
|
||||
seed (`int` or `None`): Optional random seed for reproducibility.
|
||||
|
||||
Returns:
|
||||
(`numpy.ndarray`): Array of shape `(count, 4)` with bounding boxes as
|
||||
`(x_min, y_min, x_max, y_max)`.
|
||||
"""
|
||||
if seed is not None:
|
||||
random.seed(seed)
|
||||
|
||||
img_w, img_h = image_size
|
||||
out = np.zeros((count, 4), dtype=np.float32)
|
||||
|
||||
for i in range(count):
|
||||
w = random.uniform(min_box_size, max_box_size)
|
||||
h = random.uniform(min_box_size, max_box_size)
|
||||
|
||||
x_min = random.uniform(0, img_w - w)
|
||||
y_min = random.uniform(0, img_h - h)
|
||||
x_max = x_min + w
|
||||
y_max = y_min + h
|
||||
|
||||
out[i] = (x_min, y_min, x_max, y_max)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def assert_almost_equal(actual, expected, tolerance=1e-5):
|
||||
assert abs(actual - expected) < tolerance, f"Expected {expected}, but got {actual}."
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
from supervision.utils.image import letterbox_image, resize_image
|
||||
from supervision.utils.image import (
|
||||
crop_image,
|
||||
get_image_resolution_wh,
|
||||
letterbox_image,
|
||||
resize_image,
|
||||
)
|
||||
|
||||
|
||||
def test_resize_image_for_opencv_image() -> None:
|
||||
@@ -94,3 +100,61 @@ def test_letterbox_image_for_pillow_image() -> None:
|
||||
assert difference.getbbox() is None, (
|
||||
"Expected padding to be added top and bottom with padding added top and bottom"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"image, xyxy, expected_size",
|
||||
[
|
||||
# NumPy RGB
|
||||
(
|
||||
np.zeros((4, 6, 3), dtype=np.uint8),
|
||||
(2, 1, 5, 3),
|
||||
(3, 2), # width = 5-2, height = 3-1
|
||||
),
|
||||
# NumPy grayscale
|
||||
(
|
||||
np.zeros((5, 5), dtype=np.uint8),
|
||||
(1, 1, 4, 4),
|
||||
(3, 3),
|
||||
),
|
||||
# Pillow RGB
|
||||
(
|
||||
Image.new("RGB", (6, 4), color=0),
|
||||
(2, 1, 5, 3),
|
||||
(3, 2),
|
||||
),
|
||||
# Pillow grayscale
|
||||
(
|
||||
Image.new("L", (5, 5), color=0),
|
||||
(1, 1, 4, 4),
|
||||
(3, 3),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_crop_image(image, xyxy, expected_size):
|
||||
cropped = crop_image(image=image, xyxy=xyxy)
|
||||
if isinstance(image, np.ndarray):
|
||||
assert isinstance(cropped, np.ndarray)
|
||||
assert cropped.shape[1] == expected_size[0] # width
|
||||
assert cropped.shape[0] == expected_size[1] # height
|
||||
else:
|
||||
assert isinstance(cropped, Image.Image)
|
||||
assert cropped.size == expected_size
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"image, expected",
|
||||
[
|
||||
# NumPy RGB
|
||||
(np.zeros((4, 6, 3), dtype=np.uint8), (6, 4)),
|
||||
# NumPy grayscale
|
||||
(np.zeros((10, 20), dtype=np.uint8), (20, 10)),
|
||||
# Pillow RGB
|
||||
(Image.new("RGB", (6, 4), color=0), (6, 4)),
|
||||
# Pillow grayscale
|
||||
(Image.new("L", (20, 10), color=0), (20, 10)),
|
||||
],
|
||||
)
|
||||
def test_get_image_resolution_wh(image, expected):
|
||||
resolution = get_image_resolution_wh(image)
|
||||
assert resolution == expected
|
||||
|
||||
@@ -145,6 +145,7 @@ class MockDataclass:
|
||||
"metadata",
|
||||
"area",
|
||||
"box_area",
|
||||
"box_aspect_ratio",
|
||||
},
|
||||
DoesNotRaise(),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user