Compare commits
224
Commits
0.26.0
...
0.27.0.post1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc54fa2e41 | ||
|
|
27e5fa3e9a | ||
|
|
eba1d64d91 | ||
|
|
f50ddca3a1 | ||
|
|
5b129a0850 | ||
|
|
fd4845a5cf | ||
|
|
8c104311d3 | ||
|
|
650ca0ae2f | ||
|
|
bc521bc2d0 | ||
|
|
ee1400a40c | ||
|
|
7f3dcaedbd | ||
|
|
0ba57b2f57 | ||
|
|
ae6aa369ae | ||
|
|
e36fbe061d | ||
|
|
224c7569a1 | ||
|
|
c9761515f3 | ||
|
|
d72089bb46 | ||
|
|
219161f1f9 | ||
|
|
99e772c466 | ||
|
|
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 | ||
|
|
b02ec93b30 | ||
|
|
89ee57f519 | ||
|
|
1c8398942a | ||
|
|
3e7e4caf3f | ||
|
|
a3ea8cdb1f | ||
|
|
32dca290ef | ||
|
|
03f623920a | ||
|
|
98476dfacf | ||
|
|
35ff87b08d | ||
|
|
03eed3fd64 | ||
|
|
272d9ed5bf | ||
|
|
371f8ecc5b | ||
|
|
171687f3ab | ||
|
|
421ad21c98 | ||
|
|
50358e96b6 | ||
|
|
86e10db19e | ||
|
|
deb1c9c4f4 | ||
|
|
6679b1f5a9 | ||
|
|
c7de70ed20 | ||
|
|
2cdb70eb91 | ||
|
|
2ef029ef4b | ||
|
|
dce96d462e | ||
|
|
3fcbef9482 | ||
|
|
c8b966be1a | ||
|
|
72621dce32 | ||
|
|
098d5e3eb1 | ||
|
|
8c49465d49 | ||
|
|
4ad36db3bb | ||
|
|
a933b93234 | ||
|
|
cbb58a8c6f | ||
|
|
8fc22f0281 | ||
|
|
4b7b347aea | ||
|
|
5c6745291b | ||
|
|
83d490dadc | ||
|
|
005cdbd37a | ||
|
|
ade2668ae3 | ||
|
|
46e5965680 | ||
|
|
aab3160cd7 | ||
|
|
13ab215fdf | ||
|
|
a08cda30fb | ||
|
|
652b0a190f | ||
|
|
6f87e2639e | ||
|
|
e1393691a4 | ||
|
|
90f8582e4f | ||
|
|
0c9fea4d9f | ||
|
|
cdca2e6b51 | ||
|
|
8407d5a692 | ||
|
|
eaf6cdd477 | ||
|
|
f447b6fa95 | ||
|
|
8c6ac2dd29 | ||
|
|
22b6d3d464 | ||
|
|
dda3900943 | ||
|
|
ac0c367e81 | ||
|
|
6f2490cc23 | ||
|
|
4a9d06eb50 | ||
|
|
21e311a319 | ||
|
|
71a14812fe | ||
|
|
35fdcb0a1a | ||
|
|
6d26a9766c | ||
|
|
1cfaef9405 | ||
|
|
063e2092c8 | ||
|
|
cb215d26f3 | ||
|
|
8bbe80e683 | ||
|
|
9a0483446a | ||
|
|
f4561f9ba7 | ||
|
|
beaf89cb22 | ||
|
|
96b58c2860 | ||
|
|
8873da4a30 | ||
|
|
1362850986 | ||
|
|
98a4391677 | ||
|
|
036a956473 | ||
|
|
b381fa198f | ||
|
|
8ca2e9fe8d | ||
|
|
bc1d73a307 | ||
|
|
9679cd5c4e | ||
|
|
00817df9e2 | ||
|
|
ec189bf388 | ||
|
|
58bdce5707 | ||
|
|
8262b6e64b | ||
|
|
72fe0bf47f | ||
|
|
137078602b | ||
|
|
8fa21dd165 | ||
|
|
da70389546 | ||
|
|
cc1525707a | ||
|
|
b3aee43c3b | ||
|
|
932c1aa91e | ||
|
|
76ee05420d | ||
|
|
a470bc2976 | ||
|
|
cf725dd4db | ||
|
|
7ecfc9f475 | ||
|
|
317ac0810e | ||
|
|
cb6018ef04 | ||
|
|
580fcdde4c | ||
|
|
ad4f44644c | ||
|
|
0c8a9d34a6 | ||
|
|
d067f32678 | ||
|
|
d251677774 | ||
|
|
b1780fbae5 | ||
|
|
e8cb962fcc | ||
|
|
1e84351d60 | ||
|
|
baa0402d5a | ||
|
|
c54d16c57e | ||
|
|
1adb75520f | ||
|
|
db51a9ff98 | ||
|
|
f044398c17 | ||
|
|
704652a0d2 | ||
|
|
6e3001194d | ||
|
|
d7774381cc | ||
|
|
1c8ec0a287 | ||
|
|
118e3a496c | ||
|
|
a139fb0b49 | ||
|
|
afdbb0a75c | ||
|
|
001a4254f8 | ||
|
|
2e72e70893 | ||
|
|
2907c40ecc | ||
|
|
2c67cee720 | ||
|
|
0ddbcb2010 | ||
|
|
40d4c6f554 | ||
|
|
bf88636487 | ||
|
|
a444b3bde8 | ||
|
|
4009f9d886 | ||
|
|
3f926c618a | ||
|
|
71d6670e7d | ||
|
|
08226e9199 | ||
|
|
535cb2a91b | ||
|
|
8e845caeeb | ||
|
|
9e2a535044 | ||
|
|
f737782ae8 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
# These owners will be the default owners for everything in
|
||||
# the repo. They will be requested for review when someone
|
||||
# opens a pull request.
|
||||
* @SkalskiP @onuralpszr
|
||||
* @SkalskiP @soumik12345
|
||||
|
||||
@@ -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@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
|
||||
@@ -3,9 +3,12 @@ name: Publish Supervision Pre-Releases to PyPI
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "[0-9]+.[0-9]+[0-9]+.[0-9]+a[0-9]"
|
||||
- "[0-9]+.[0-9]+[0-9]+.[0-9]+b[0-9]"
|
||||
- "[0-9]+.[0-9]+[0-9]+.[0-9]+rc[0-9]"
|
||||
- "[0-9]+.[0-9]+.[0-9]+a[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+b[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9].+a[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9].+b[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9].+rc[0-9]+"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {} # Explicitly remove all permissions by default
|
||||
@@ -29,7 +32,7 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
@@ -42,6 +45,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
|
||||
|
||||
@@ -3,7 +3,8 @@ name: Publish Supervision Releases to PyPI
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "[0-9]+.[0-9]+[0-9]+.[0-9]"
|
||||
- "[0-9]+.[0-9]+.[0-9]"
|
||||
- "[0-9]+.[0-9]+.[0-9].post[0-9]+"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {} # Explicitly remove all permissions by default
|
||||
@@ -27,7 +28,7 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
@@ -40,6 +41,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@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
|
||||
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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: 🧪 Docs Test WorkFlow 📚
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
@@ -24,7 +26,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🐍 Install uv and set Python ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: 🔧 Pytest/Test Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
@@ -19,16 +21,18 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
|
||||
enable-cache: true # Recommended: caches the uv download/builds
|
||||
|
||||
- name: 🚀 Install Packages
|
||||
run: uv pip install -r pyproject.toml --group dev --group docs --extra metrics
|
||||
# CHANGED: 'uv sync' enforces the versions in uv.lock
|
||||
run: uv sync --group dev --group docs --extra metrics
|
||||
|
||||
- name: 🧪 Run the Import test
|
||||
# 'uv run' executes inside the environment created by 'uv sync'
|
||||
run: uv run python -c "import supervision; from supervision import assets; from supervision import metrics; print(supervision.__version__)"
|
||||
|
||||
- name: 🧪 Run the Test
|
||||
|
||||
@@ -32,7 +32,7 @@ repos:
|
||||
additional_dependencies: ["bandit[toml]"]
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.3
|
||||
rev: v0.12.5
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
|
||||
+87
-7
@@ -1,5 +1,85 @@
|
||||
# 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`.
|
||||
|
||||
- Fixed [1895](https://github.com/roboflow/supervision/pull/1895): `ID=0` bug in [`sv.MeanAveragePrecision`](https://supervision.roboflow.com/0.26.1/metrics/mean_average_precision/#supervision.metrics.mean_average_precision.MeanAveragePrecision) where objects were getting `0.0` mAP despite perfect IoU matches due to a bug in annotation ID assignment.
|
||||
|
||||
- Fixed [1898](https://github.com/roboflow/supervision/pull/1898): Issue where [`sv.MeanAveragePrecision`](https://supervision.roboflow.com/0.26.1/metrics/mean_average_precision/#supervision.metrics.mean_average_precision.MeanAveragePrecision) could return negative values when certain object size categories have no data.
|
||||
|
||||
- Fixed [1901](https://github.com/roboflow/supervision/pull/1901): `match_metric` support for [`sv.Detections.with_nms`](https://supervision.roboflow.com/0.26.1/metrics/mean_average_precision/#supervision.detection.core.Detections.with_nms).
|
||||
|
||||
- Fixed [1906](https://github.com/roboflow/supervision/pull/1906): `border_thickness` parameter usage for [`sv.PercentageBarAnnotator`](https://supervision.roboflow.com/0.26.1/metrics/mean_average_precision/#supervision.annotators.core.PercentageBarAnnotator).
|
||||
|
||||
### 0.26.0 <small>Jul 16, 2025</small>
|
||||
|
||||
!!! failure "Removed"
|
||||
@@ -153,7 +233,7 @@
|
||||
|
||||
- Changed [#1786](https://github.com/roboflow/supervision/pull/1786): Significantly improved the speed of HSV color mapping in [`sv.HeatMapAnnotator`](https://supervision.roboflow.com/0.26.0/detection/annotators/#supervision.annotators.core.HeatMapAnnotator), achieving approximately 28x faster performance on 1920x1080 frames.
|
||||
|
||||
- Fix [#1834](https://github.com/roboflow/supervision/pull/1834): Supervision’s [`sv.MeanAveragePrecision`](https://supervision.roboflow.com/0.26.0/metrics/mean_average_precision/#supervision.metrics.mean_average_precision.MeanAveragePrecision) is now fully aligned with [pycocotools](https://github.com/ppwwyyxx/cocoapi), the official COCO evaluation tool, ensuring accurate and standardized metrics. This update enabled us to launch a new version of the [Computer Vision Model Leaderboard](https://leaderboard.roboflow.com/).
|
||||
- Fixed [#1834](https://github.com/roboflow/supervision/pull/1834): Supervision’s [`sv.MeanAveragePrecision`](https://supervision.roboflow.com/0.26.0/metrics/mean_average_precision/#supervision.metrics.mean_average_precision.MeanAveragePrecision) is now fully aligned with [pycocotools](https://github.com/ppwwyyxx/cocoapi), the official COCO evaluation tool, ensuring accurate and standardized metrics. This update enabled us to launch a new version of the [Computer Vision Model Leaderboard](https://leaderboard.roboflow.com/).
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
@@ -173,7 +253,7 @@
|
||||
# Average Precision (AP) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] = 0.629
|
||||
```
|
||||
|
||||
- Fix [#1767](https://github.com/roboflow/supervision/pull/1767): Fixed losing `sv.Detections.data` when detections filtering.
|
||||
- Fixed [#1767](https://github.com/roboflow/supervision/pull/1767): Fixed losing `sv.Detections.data` when detections filtering.
|
||||
|
||||
### 0.25.0 <small>Nov 12, 2024</small>
|
||||
|
||||
@@ -557,9 +637,9 @@ detections = sv.Detections.from_sam(sam_result=sam_result)
|
||||
|
||||
- Changed [#1434](https://github.com/roboflow/supervision/pull/1434): [`InferenceSlicer`](https://supervision.roboflow.com/0.23.0/detection/tools/inference_slicer/) now features an `overlap_wh` parameter, making it easier to compute slice sizes when handling overlapping slices.
|
||||
|
||||
- Fix [#1448](https://github.com/roboflow/supervision/pull/1448): Various annotator type issues have been resolved, supporting expanded error handling.
|
||||
- Fixed [#1448](https://github.com/roboflow/supervision/pull/1448): Various annotator type issues have been resolved, supporting expanded error handling.
|
||||
|
||||
- Fix [#1348](https://github.com/roboflow/supervision/pull/1348): Introduced a new method for [seeking to a specific video frame](https://supervision.roboflow.com/0.23.0/utils/video/#supervision.utils.video.get_video_frames_generator), addressing cases where traditional seek methods were failing. It can be enabled with `iterative_seek=True`.
|
||||
- Fixed [#1348](https://github.com/roboflow/supervision/pull/1348): Introduced a new method for [seeking to a specific video frame](https://supervision.roboflow.com/0.23.0/utils/video/#supervision.utils.video.get_video_frames_generator), addressing cases where traditional seek methods were failing. It can be enabled with `iterative_seek=True`.
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
@@ -572,7 +652,7 @@ for frame in sv.get_video_frames_generator(
|
||||
...
|
||||
```
|
||||
|
||||
- Fix [#1424](https://github.com/roboflow/supervision/pull/1424): `plot_image` function now clearly indicates that the size is in inches.
|
||||
- Fixed [#1424](https://github.com/roboflow/supervision/pull/1424): `plot_image` function now clearly indicates that the size is in inches.
|
||||
|
||||
!!! failure "Removed"
|
||||
|
||||
@@ -1285,7 +1365,7 @@ array([
|
||||
|
||||
### 0.11.1 <small>June 29, 2023</small>
|
||||
|
||||
- Fix [#165](https://github.com/roboflow/supervision/pull/165): [`as_folder_structure`](/0.11.1/dataset/core/#supervision.dataset.core.ClassificationDataset.as_folder_structure) fails to save [`sv.ClassificationDataset`](/0.11.1/dataset/core/#classificationdataset) when it is result of inference.
|
||||
- Fixed [#165](https://github.com/roboflow/supervision/pull/165): [`as_folder_structure`](/0.11.1/dataset/core/#supervision.dataset.core.ClassificationDataset.as_folder_structure) fails to save [`sv.ClassificationDataset`](/0.11.1/dataset/core/#classificationdataset) when it is result of inference.
|
||||
|
||||
### 0.11.0 <small>June 28, 2023</small>
|
||||
|
||||
@@ -1331,7 +1411,7 @@ array([
|
||||
|
||||
- Added [#162](https://github.com/roboflow/supervision/pull/162): additional `start` and `end` arguments to [`sv.get_video_frames_generator`](/0.11.0/utils/video/#get_video_frames_generator) allowing to generate frames only for a selected part of the video.
|
||||
|
||||
- Fix [#157](https://github.com/roboflow/supervision/pull/157): incorrect loading of YOLO dataset class names from `data.yaml`.
|
||||
- Fixed [#157](https://github.com/roboflow/supervision/pull/157): incorrect loading of YOLO dataset class names from `data.yaml`.
|
||||
|
||||
### 0.10.0 <small>June 14, 2023</small>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,3 +58,9 @@ status: new
|
||||
</div>
|
||||
|
||||
:::supervision.detection.utils.converters.polygon_to_xyxy
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.detection.utils.converters.xyxy_to_mask">xyxy_to_mask</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.detection.utils.converters.xyxy_to_mask
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# VLMs Utils
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.detection.utils.vlms.edit_distance">edit_distance</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.detection.utils.vlms.edit_distance
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.detection.utils.vlms.fuzzy_match_index">fuzzy_match_index</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.detection.utils.vlms.fuzzy_match_index
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||

|
||||
|
||||
@@ -331,12 +331,9 @@ for i in range(16):
|
||||
annotated_image = label_annotator.annotate(annotated_image, annotations, labels)
|
||||
annotated_images.append(annotated_image)
|
||||
|
||||
grid = sv.create_tiles(
|
||||
sv.plot_images_grid(
|
||||
annotated_images,
|
||||
grid_size=(4, 4),
|
||||
single_tile_size=(400, 400),
|
||||
tile_padding_color=sv.Color.WHITE,
|
||||
tile_margin_color=sv.Color.WHITE
|
||||
)
|
||||
```
|
||||
|
||||
@@ -398,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
|
||||
@@ -345,7 +344,7 @@ Supervision is versatile and compatible with various models. Check this [link](/
|
||||
|
||||
We will define a `callback` function, which will process each frame of the video by obtaining model predictions and then annotating the frame based on these predictions.
|
||||
|
||||
Let's immediately visualize the results with our [`EdgeAnnotator`](/latest/keypoint/annotators/#supervision.keypoint.annotators.EdgeAnnotator) and [`VertexAnnotator`](https://supervision.roboflow.com/latest/keypoint/annotators/#supervision.keypoint.annotators.VertexAnnotator).
|
||||
Let's immediately visualize the results with our [`EdgeAnnotator`](/latest/keypoint/annotators/#supervision.key_points.annotators.EdgeAnnotator) and [`VertexAnnotator`](https://supervision.roboflow.com/latest/keypoint/annotators/#supervision.key_points.annotators.VertexAnnotator).
|
||||
|
||||
=== "Ultralytics"
|
||||
|
||||
@@ -408,7 +407,7 @@ Let's immediately visualize the results with our [`EdgeAnnotator`](/latest/keypo
|
||||
|
||||
### Convert to Detections
|
||||
|
||||
Keypoint tracking is currently supported via the conversion of `KeyPoints` to `Detections`. This is achieved with the [`KeyPoints.as_detections()`](/latest/keypoint/core/#supervision.keypoint.core.KeyPoints.as_detections) function.
|
||||
Keypoint tracking is currently supported via the conversion of `KeyPoints` to `Detections`. This is achieved with the [`KeyPoints.as_detections()`](/latest/keypoint/core/#supervision.key_points.core.KeyPoints.as_detections) function.
|
||||
|
||||
Let's convert to detections and visualize the results with our [`BoxAnnotator`](/latest/detection/annotators/#supervision.annotators.core.BoxAnnotator).
|
||||
|
||||
|
||||
@@ -78,19 +78,19 @@ comments: true
|
||||
</div>
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.keypoint.annotators.VertexAnnotator">VertexAnnotator</a></h2>
|
||||
<h2><a href="#supervision.key_points.annotators.VertexAnnotator">VertexAnnotator</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.keypoint.annotators.VertexAnnotator
|
||||
:::supervision.key_points.annotators.VertexAnnotator
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.keypoint.annotators.EdgeAnnotator">EdgeAnnotator</a></h2>
|
||||
<h2><a href="#supervision.key_points.annotators.EdgeAnnotator">EdgeAnnotator</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.keypoint.annotators.EdgeAnnotator
|
||||
:::supervision.key_points.annotators.EdgeAnnotator
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.keypoint.annotators.VertexLabelAnnotator">VertexLabelAnnotator</a></h2>
|
||||
<h2><a href="#supervision.key_points.annotators.VertexLabelAnnotator">VertexLabelAnnotator</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.keypoint.annotators.VertexLabelAnnotator
|
||||
:::supervision.key_points.annotators.VertexLabelAnnotator
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Keypoint Detection
|
||||
|
||||
:::supervision.keypoint.core.KeyPoints
|
||||
:::supervision.key_points.core.KeyPoints
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Mean Average Precision
|
||||
|
||||
+15
-2
@@ -1,5 +1,6 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Image Utils
|
||||
@@ -29,10 +30,22 @@ comments: true
|
||||
:::supervision.utils.image.letterbox_image
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.utils.image.overlay_image">overlay_image</a></h2>
|
||||
<h2><a href="#supervision.utils.image.tint_image">tint_image</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.utils.image.overlay_image
|
||||
:::supervision.utils.image.tint_image
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.utils.image.grayscale_image">grayscale_image</a></h2>
|
||||
</div>
|
||||
|
||||
:::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>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
supervision
|
||||
ultralytics
|
||||
inference
|
||||
pytube
|
||||
# https://github.com/pytube/pytube/issues/2044
|
||||
# pytube
|
||||
pytubefix
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import os
|
||||
|
||||
from pytube import YouTube
|
||||
from pytubefix import YouTube
|
||||
|
||||
|
||||
def main(url: str, output_path: str | None, file_name: str | None) -> None:
|
||||
|
||||
+2
-1
@@ -26,7 +26,7 @@ extra:
|
||||
|
||||
extra_css:
|
||||
- stylesheets/extra.css
|
||||
- stylesheets/cookbooks-card.css
|
||||
- stylesheets/cookbooks_card.css
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
@@ -47,6 +47,7 @@ nav:
|
||||
- Boxes: detection/utils/boxes.md
|
||||
- Masks: detection/utils/masks.md
|
||||
- Polygons: detection/utils/polygons.md
|
||||
- VLMs: detection/utils/vlms.md
|
||||
- Keypoint Detection:
|
||||
- Core: keypoint/core.md
|
||||
- Annotators: keypoint/annotators.md
|
||||
|
||||
+8
-6
@@ -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.26.0"
|
||||
version = "0.27.0.post1"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
authors = [
|
||||
@@ -46,7 +46,8 @@ dependencies = [
|
||||
"pillow>=9.4",
|
||||
"requests>=2.26.0",
|
||||
"tqdm>=4.62.3",
|
||||
"opencv-python>=4.5.5.64"
|
||||
"opencv-python>=4.5.5.64",
|
||||
"pyDeprecate>=0.4.0"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -67,12 +68,13 @@ 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-python>=1.10.9",
|
||||
"mkdocstrings>=0.25.2,<0.31.0",
|
||||
"mkdocstrings-python>=1.10.9, <2.0.0", # todo: breaking changes in 2.x
|
||||
"mike>=2.0.0",
|
||||
"mkdocs-jupyter>=0.24.3",
|
||||
"mkdocs-git-committers-plugin-2>=2.4.1; python_version >= '3.9' and python_version < '4'",
|
||||
@@ -81,7 +83,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]
|
||||
|
||||
+15
-4
@@ -65,6 +65,7 @@ from supervision.detection.utils.converters import (
|
||||
polygon_to_xyxy,
|
||||
xcycwh_to_xyxy,
|
||||
xywh_to_xyxy,
|
||||
xyxy_to_mask,
|
||||
xyxy_to_polygons,
|
||||
xyxy_to_xcycarh,
|
||||
xyxy_to_xywh,
|
||||
@@ -86,12 +87,14 @@ 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 (
|
||||
approximate_polygon,
|
||||
filter_polygons_by_area,
|
||||
)
|
||||
from supervision.detection.utils.vlms import edit_distance, fuzzy_match_index
|
||||
from supervision.detection.vlm import LMM, VLM
|
||||
from supervision.draw.color import Color, ColorPalette
|
||||
from supervision.draw.utils import (
|
||||
@@ -107,24 +110,26 @@ from supervision.draw.utils import (
|
||||
)
|
||||
from supervision.geometry.core import Point, Position, Rect
|
||||
from supervision.geometry.utils import get_polygon_center
|
||||
from supervision.keypoint.annotators import (
|
||||
from supervision.key_points.annotators import (
|
||||
EdgeAnnotator,
|
||||
VertexAnnotator,
|
||||
VertexLabelAnnotator,
|
||||
)
|
||||
from supervision.keypoint.core import KeyPoints
|
||||
from supervision.key_points.core import KeyPoints
|
||||
from supervision.metrics.detection import ConfusionMatrix, MeanAveragePrecision
|
||||
from supervision.tracker.byte_tracker.core import ByteTrack
|
||||
from supervision.utils.conversion import cv2_to_pillow, pillow_to_cv2
|
||||
from supervision.utils.file import list_files_with_extensions
|
||||
from supervision.utils.image import (
|
||||
ImageSink,
|
||||
create_tiles,
|
||||
crop_image,
|
||||
get_image_resolution_wh,
|
||||
grayscale_image,
|
||||
letterbox_image,
|
||||
overlay_image,
|
||||
resize_image,
|
||||
scale_image,
|
||||
tint_image,
|
||||
)
|
||||
from supervision.utils.notebook import plot_image, plot_images_grid
|
||||
from supervision.utils.video import (
|
||||
@@ -205,7 +210,6 @@ __all__ = [
|
||||
"clip_boxes",
|
||||
"contains_holes",
|
||||
"contains_multiple_segments",
|
||||
"create_tiles",
|
||||
"crop_image",
|
||||
"cv2_to_pillow",
|
||||
"draw_filled_polygon",
|
||||
@@ -215,10 +219,15 @@ __all__ = [
|
||||
"draw_polygon",
|
||||
"draw_rectangle",
|
||||
"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",
|
||||
"letterbox_image",
|
||||
"list_files_with_extensions",
|
||||
"mask_iou_batch",
|
||||
@@ -242,8 +251,10 @@ __all__ = [
|
||||
"rle_to_mask",
|
||||
"scale_boxes",
|
||||
"scale_image",
|
||||
"tint_image",
|
||||
"xcycwh_to_xyxy",
|
||||
"xywh_to_xyxy",
|
||||
"xyxy_to_mask",
|
||||
"xyxy_to_polygons",
|
||||
"xyxy_to_xcycarh",
|
||||
"xyxy_to_xywh",
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TypeVar
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from supervision.detection.core import Detections
|
||||
|
||||
ImageType = TypeVar("ImageType", np.ndarray, Image.Image)
|
||||
"""
|
||||
An image of type `np.ndarray` or `PIL.Image.Image`.
|
||||
|
||||
Unlike a `Union`, ensures the type remains consistent. If a function
|
||||
takes an `ImageType` argument and returns an `ImageType`, when you
|
||||
pass an `np.ndarray`, you will get an `np.ndarray` back.
|
||||
"""
|
||||
from supervision.draw.base import ImageType
|
||||
|
||||
|
||||
class BaseAnnotator(ABC):
|
||||
|
||||
+208
-53
@@ -7,8 +7,9 @@ import cv2
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from scipy.interpolate import splev, splprep
|
||||
|
||||
from supervision.annotators.base import BaseAnnotator, ImageType
|
||||
from supervision.annotators.base import BaseAnnotator
|
||||
from supervision.annotators.utils import (
|
||||
PENDING_TRACK_ID,
|
||||
ColorLookup,
|
||||
@@ -28,12 +29,13 @@ from supervision.detection.utils.converters import (
|
||||
polygon_to_mask,
|
||||
xyxy_to_polygons,
|
||||
)
|
||||
from supervision.draw.base import ImageType
|
||||
from supervision.draw.color import Color, ColorPalette
|
||||
from supervision.draw.utils import draw_polygon, draw_rounded_rectangle, draw_text
|
||||
from supervision.geometry.core import Point, Position, Rect
|
||||
from supervision.utils.conversion import (
|
||||
ensure_cv2_image_for_annotation,
|
||||
ensure_pil_image_for_annotation,
|
||||
ensure_cv2_image_for_class_method,
|
||||
ensure_pil_image_for_class_method,
|
||||
)
|
||||
from supervision.utils.image import (
|
||||
crop_image,
|
||||
@@ -51,25 +53,28 @@ class _BaseLabelAnnotator(BaseAnnotator):
|
||||
|
||||
Attributes:
|
||||
color (Union[Color, ColorPalette]): The color to use for the label background.
|
||||
color_lookup (ColorLookup): The method used to determine the color of the label.
|
||||
text_color (Union[Color, ColorPalette]): The color to use for the label text.
|
||||
text_padding (int): The padding around the label text, in pixels.
|
||||
text_anchor (Position): The position of the text relative to the detection
|
||||
bounding box.
|
||||
color_lookup (ColorLookup): The method used to determine the color of the label.
|
||||
bounding box.
|
||||
text_offset (Tuple[int, int]): A tuple of 2D coordinates `(x, y)` to
|
||||
offset the text position from the anchor point, in pixels.
|
||||
border_radius (int): The radius of the label background corners, in pixels.
|
||||
smart_position (bool): Whether to intelligently adjust the label position to
|
||||
avoid overlapping with other elements.
|
||||
avoid overlapping with other elements.
|
||||
max_line_length (Optional[int]): Maximum number of characters per line before
|
||||
wrapping the text. None means no wrapping.
|
||||
wrapping the text. None means no wrapping.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
color: Color | ColorPalette = ColorPalette.DEFAULT,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
text_color: Color | ColorPalette = Color.WHITE,
|
||||
text_padding: int = 10,
|
||||
text_position: Position = Position.TOP_LEFT,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
text_offset: tuple[int, int] = (0, 0),
|
||||
border_radius: int = 0,
|
||||
smart_position: bool = False,
|
||||
max_line_length: int | None = None,
|
||||
@@ -79,27 +84,29 @@ class _BaseLabelAnnotator(BaseAnnotator):
|
||||
|
||||
Args:
|
||||
color (Union[Color, ColorPalette], optional): The color to use for the label
|
||||
background.
|
||||
background.
|
||||
color_lookup (ColorLookup, optional): The method used to determine the color
|
||||
of the label
|
||||
text_color (Union[Color, ColorPalette], optional): The color to use for the
|
||||
label text.
|
||||
label text.
|
||||
text_padding (int, optional): The padding around the label text, in pixels.
|
||||
text_position (Position, optional): The position of the text relative to the
|
||||
detection bounding box.
|
||||
color_lookup (ColorLookup, optional): The method used to determine the color
|
||||
of the label
|
||||
detection bounding box.
|
||||
text_offset (Tuple[int, int], optional): A tuple of 2D coordinates
|
||||
`(x, y)` to offset the text position from the anchor point, in pixels.
|
||||
border_radius (int, optional): The radius of the label background corners,
|
||||
in pixels.
|
||||
in pixels.
|
||||
smart_position (bool, optional): Whether to intelligently adjust the label
|
||||
position to avoid overlapping with other elements.
|
||||
position to avoid overlapping with other elements.
|
||||
max_line_length (Optional[int], optional): Maximum number of characters per
|
||||
line before wrapping the text. None means no wrapping.
|
||||
|
||||
line before wrapping the text. None means no wrapping.
|
||||
"""
|
||||
self.color: Color | ColorPalette = color
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
self.text_color: Color | ColorPalette = text_color
|
||||
self.text_padding: int = text_padding
|
||||
self.text_anchor: Position = text_position
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
self.text_offset: tuple[int, int] = text_offset
|
||||
self.border_radius: int = border_radius
|
||||
self.smart_position = smart_position
|
||||
self.max_line_length: int | None = max_line_length
|
||||
@@ -171,7 +178,7 @@ class BoxAnnotator(BaseAnnotator):
|
||||
self.thickness: int = thickness
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -254,7 +261,7 @@ class OrientedBoxAnnotator(BaseAnnotator):
|
||||
self.thickness: int = thickness
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -343,7 +350,7 @@ class MaskAnnotator(BaseAnnotator):
|
||||
self.opacity = opacity
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -433,7 +440,7 @@ class PolygonAnnotator(BaseAnnotator):
|
||||
self.thickness: int = thickness
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -520,7 +527,7 @@ class ColorAnnotator(BaseAnnotator):
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
self.opacity = opacity
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -616,7 +623,7 @@ class HaloAnnotator(BaseAnnotator):
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
self.kernel_size: int = kernel_size
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -716,7 +723,7 @@ class EllipseAnnotator(BaseAnnotator):
|
||||
self.end_angle: int = end_angle
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -808,7 +815,7 @@ class BoxCornerAnnotator(BaseAnnotator):
|
||||
self.corner_length: int = corner_length
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -897,7 +904,7 @@ class CircleAnnotator(BaseAnnotator):
|
||||
self.thickness: int = thickness
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -996,7 +1003,7 @@ class DotAnnotator(BaseAnnotator):
|
||||
self.outline_thickness = outline_thickness
|
||||
self.outline_color: Color | ColorPalette = outline_color
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -1076,37 +1083,100 @@ class LabelAnnotator(_BaseLabelAnnotator):
|
||||
def __init__(
|
||||
self,
|
||||
color: Color | ColorPalette = ColorPalette.DEFAULT,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
text_color: Color | ColorPalette = Color.WHITE,
|
||||
text_scale: float = 0.5,
|
||||
text_thickness: int = 1,
|
||||
text_padding: int = 10,
|
||||
text_position: Position = Position.TOP_LEFT,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
text_offset: tuple[int, int] = (0, 0),
|
||||
border_radius: int = 0,
|
||||
smart_position: bool = False,
|
||||
max_line_length: int | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
color (Union[Color, ColorPalette]): The color or color palette to use for
|
||||
annotating the text background.
|
||||
color_lookup (ColorLookup): Strategy for mapping colors to annotations.
|
||||
Options are `INDEX`, `CLASS`, `TRACK`.
|
||||
text_color (Union[Color, ColorPalette]): The color or color palette to use
|
||||
for the text.
|
||||
text_scale (float): Font scale for the text.
|
||||
text_thickness (int): Thickness of the text characters.
|
||||
text_padding (int): Padding around the text within its background box.
|
||||
text_position (Position): Position of the text relative to the detection.
|
||||
Possible values are defined in the `Position` enum.
|
||||
text_offset (Tuple[int, int]): A tuple of 2D coordinates `(x, y)` to
|
||||
offset the text position from the anchor point, in pixels.
|
||||
border_radius (int): The radius to apply round edges. If the selected
|
||||
value is higher than the lower dimension, width or height, is clipped.
|
||||
smart_position (bool): Spread out the labels to avoid overlapping.
|
||||
max_line_length (Optional[int]): Maximum number of characters per line
|
||||
before wrapping the text. None means no wrapping.
|
||||
"""
|
||||
self.text_scale: float = text_scale
|
||||
self.text_thickness: int = text_thickness
|
||||
super().__init__(
|
||||
color=color,
|
||||
color_lookup=color_lookup,
|
||||
text_color=text_color,
|
||||
text_padding=text_padding,
|
||||
text_position=text_position,
|
||||
color_lookup=color_lookup,
|
||||
text_offset=text_offset,
|
||||
border_radius=border_radius,
|
||||
smart_position=smart_position,
|
||||
max_line_length=max_line_length,
|
||||
)
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType, # Ensure scene is initially a NumPy array here
|
||||
scene: ImageType,
|
||||
detections: Detections,
|
||||
labels: list[str] | None = None,
|
||||
custom_color_lookup: np.ndarray | None = None,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Annotates the given scene with labels based on the provided detections.
|
||||
|
||||
Args:
|
||||
scene (ImageType): The image where labels will be drawn.
|
||||
`ImageType` is a flexible type, accepting either `numpy.ndarray`
|
||||
or `PIL.Image.Image`.
|
||||
detections (Detections): Object detections to annotate.
|
||||
labels (Optional[List[str]]): Custom labels for each detection.
|
||||
custom_color_lookup (Optional[np.ndarray]): Custom color lookup array.
|
||||
Allows to override the default color mapping strategy.
|
||||
|
||||
Returns:
|
||||
The annotated image, matching the type of `scene` (`numpy.ndarray`
|
||||
or `PIL.Image.Image`)
|
||||
|
||||
Example:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
detections = sv.Detections(...)
|
||||
|
||||
labels = [
|
||||
f"{class_name} {confidence:.2f}"
|
||||
for class_name, confidence
|
||||
in zip(detections['class_name'], detections.confidence)
|
||||
]
|
||||
|
||||
label_annotator = sv.LabelAnnotator(text_position=sv.Position.CENTER)
|
||||
annotated_frame = label_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
detections=detections,
|
||||
labels=labels
|
||||
)
|
||||
```
|
||||
|
||||

|
||||
"""
|
||||
assert isinstance(scene, np.ndarray)
|
||||
validate_labels(labels, detections)
|
||||
|
||||
@@ -1144,7 +1214,12 @@ class LabelAnnotator(_BaseLabelAnnotator):
|
||||
anchor=self.text_anchor
|
||||
).astype(int)
|
||||
|
||||
for label, center_coords in zip(labels, anchors_coordinates):
|
||||
for label, center_coordinates in zip(labels, anchors_coordinates):
|
||||
center_coordinates = (
|
||||
center_coordinates[0] + self.text_offset[0],
|
||||
center_coordinates[1] + self.text_offset[1],
|
||||
)
|
||||
|
||||
wrapped_lines = wrap_text(label, self.max_line_length)
|
||||
line_heights = []
|
||||
line_widths = []
|
||||
@@ -1170,7 +1245,7 @@ class LabelAnnotator(_BaseLabelAnnotator):
|
||||
height_padded = total_height + 2 * self.text_padding
|
||||
|
||||
text_background_xyxy = resolve_text_background_xyxy(
|
||||
center_coordinates=tuple(center_coords),
|
||||
center_coordinates=center_coordinates,
|
||||
text_wh=(width_padded, height_padded),
|
||||
position=self.text_anchor,
|
||||
)
|
||||
@@ -1317,31 +1392,54 @@ class RichLabelAnnotator(_BaseLabelAnnotator):
|
||||
def __init__(
|
||||
self,
|
||||
color: Color | ColorPalette = ColorPalette.DEFAULT,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
text_color: Color | ColorPalette = Color.WHITE,
|
||||
font_path: str | None = None,
|
||||
font_size: int = 10,
|
||||
text_padding: int = 10,
|
||||
text_position: Position = Position.TOP_LEFT,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
text_offset: tuple[int, int] = (0, 0),
|
||||
border_radius: int = 0,
|
||||
smart_position: bool = False,
|
||||
max_line_length: int | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
color (Union[Color, ColorPalette]): The color or color palette to use for
|
||||
annotating the text background.
|
||||
color_lookup (ColorLookup): Strategy for mapping colors to annotations.
|
||||
Options are `INDEX`, `CLASS`, `TRACK`.
|
||||
text_color (Union[Color, ColorPalette]): The color to use for the text.
|
||||
font_path (Optional[str]): Path to the font file (e.g., ".ttf" or ".otf")
|
||||
to use for rendering text. If `None`, the default PIL font will be used.
|
||||
font_size (int): Font size for the text.
|
||||
text_padding (int): Padding around the text within its background box.
|
||||
text_position (Position): Position of the text relative to the detection.
|
||||
Possible values are defined in the `Position` enum.
|
||||
text_offset (Tuple[int, int]): A tuple of 2D coordinates `(x, y)` to
|
||||
offset the text position from the anchor point, in pixels.
|
||||
border_radius (int): The radius to apply round edges. If the selected
|
||||
value is higher than the lower dimension, width or height, is clipped.
|
||||
smart_position (bool): Spread out the labels to avoid overlapping.
|
||||
max_line_length (Optional[int]): Maximum number of characters per line
|
||||
before wrapping the text. None means no wrapping.
|
||||
"""
|
||||
self.font_path = font_path
|
||||
self.font_size = font_size
|
||||
self.font = self._load_font(font_size, font_path)
|
||||
super().__init__(
|
||||
color=color,
|
||||
color_lookup=color_lookup,
|
||||
text_color=text_color,
|
||||
text_padding=text_padding,
|
||||
text_position=text_position,
|
||||
color_lookup=color_lookup,
|
||||
text_offset=text_offset,
|
||||
border_radius=border_radius,
|
||||
smart_position=smart_position,
|
||||
max_line_length=max_line_length,
|
||||
)
|
||||
|
||||
@ensure_pil_image_for_annotation
|
||||
@ensure_pil_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -1349,6 +1447,44 @@ class RichLabelAnnotator(_BaseLabelAnnotator):
|
||||
labels: list[str] | None = None,
|
||||
custom_color_lookup: np.ndarray | None = None,
|
||||
) -> ImageType:
|
||||
"""
|
||||
Annotates the given scene with labels based on the provided
|
||||
detections, with support for Unicode characters.
|
||||
|
||||
Args:
|
||||
scene (ImageType): The image where labels will be drawn.
|
||||
`ImageType` is a flexible type, accepting either `numpy.ndarray`
|
||||
or `PIL.Image.Image`.
|
||||
detections (Detections): Object detections to annotate.
|
||||
labels (Optional[List[str]]): Custom labels for each detection.
|
||||
custom_color_lookup (Optional[np.ndarray]): Custom color lookup array.
|
||||
Allows to override the default color mapping strategy.
|
||||
|
||||
Returns:
|
||||
The annotated image, matching the type of `scene` (`numpy.ndarray`
|
||||
or `PIL.Image.Image`)
|
||||
|
||||
Example:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
detections = sv.Detections(...)
|
||||
|
||||
labels = [
|
||||
f"{class_name} {confidence:.2f}"
|
||||
for class_name, confidence
|
||||
in zip(detections['class_name'], detections.confidence)
|
||||
]
|
||||
|
||||
rich_label_annotator = sv.RichLabelAnnotator(font_path="path/to/font.ttf")
|
||||
annotated_frame = label_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
detections=detections,
|
||||
labels=labels
|
||||
)
|
||||
```
|
||||
"""
|
||||
assert isinstance(scene, Image.Image)
|
||||
validate_labels(labels, detections)
|
||||
|
||||
@@ -1386,7 +1522,12 @@ class RichLabelAnnotator(_BaseLabelAnnotator):
|
||||
anchor=self.text_anchor
|
||||
).astype(int)
|
||||
|
||||
for label, center_coords in zip(labels, anchor_coordinates):
|
||||
for label, center_coordinates in zip(labels, anchor_coordinates):
|
||||
center_coordinates = (
|
||||
center_coordinates[0] + self.text_offset[0],
|
||||
center_coordinates[1] + self.text_offset[1],
|
||||
)
|
||||
|
||||
wrapped_lines = wrap_text(label, self.max_line_length)
|
||||
|
||||
# Calculate the total text height and maximum width
|
||||
@@ -1409,7 +1550,7 @@ class RichLabelAnnotator(_BaseLabelAnnotator):
|
||||
height_padded = int(total_height + 2 * self.text_padding)
|
||||
|
||||
text_background_xyxy = resolve_text_background_xyxy(
|
||||
center_coordinates=tuple(center_coords),
|
||||
center_coordinates=center_coordinates,
|
||||
text_wh=(width_padded, height_padded),
|
||||
position=self.text_anchor,
|
||||
)
|
||||
@@ -1525,7 +1666,7 @@ class IconAnnotator(BaseAnnotator):
|
||||
self.position = icon_position
|
||||
self.offset_xy = offset_xy
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self, scene: ImageType, detections: Detections, icon_path: str | list[str]
|
||||
) -> ImageType:
|
||||
@@ -1614,7 +1755,7 @@ class BlurAnnotator(BaseAnnotator):
|
||||
"""
|
||||
self.kernel_size: int = kernel_size
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -1681,6 +1822,7 @@ class TraceAnnotator(BaseAnnotator):
|
||||
position: Position = Position.CENTER,
|
||||
trace_length: int = 30,
|
||||
thickness: int = 2,
|
||||
smooth: bool = False,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
):
|
||||
"""
|
||||
@@ -1692,15 +1834,17 @@ class TraceAnnotator(BaseAnnotator):
|
||||
trace_length (int): The maximum length of the trace in terms of historical
|
||||
points. Defaults to `30`.
|
||||
thickness (int): The thickness of the trace lines. Defaults to `2`.
|
||||
smooth (bool): Smooth the trace lines.
|
||||
color_lookup (ColorLookup): Strategy for mapping colors to annotations.
|
||||
Options are `INDEX`, `CLASS`, `TRACK`.
|
||||
"""
|
||||
self.color: Color | ColorPalette = color
|
||||
self.trace = Trace(max_size=trace_length, anchor=position)
|
||||
self.thickness = thickness
|
||||
self.smooth = smooth
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -1769,10 +1913,18 @@ class TraceAnnotator(BaseAnnotator):
|
||||
else custom_color_lookup,
|
||||
)
|
||||
xy = self.trace.get(tracker_id=tracker_id)
|
||||
spline_points = xy.astype(np.int32)
|
||||
|
||||
if len(xy) > 3 and self.smooth:
|
||||
x, y = xy[:, 0], xy[:, 1]
|
||||
tck, u = splprep([x, y], s=20)
|
||||
x_new, y_new = splev(np.linspace(0, 1, 100), tck)
|
||||
spline_points = np.stack([x_new, y_new], axis=1).astype(np.int32)
|
||||
|
||||
if len(xy) > 1:
|
||||
scene = cv2.polylines(
|
||||
scene,
|
||||
[xy.astype(np.int32)],
|
||||
[spline_points],
|
||||
False,
|
||||
color=color.as_bgr(),
|
||||
thickness=self.thickness,
|
||||
@@ -1814,7 +1966,7 @@ class HeatMapAnnotator(BaseAnnotator):
|
||||
self.low_hue = low_hue
|
||||
self.heat_mask: npt.NDArray[np.float32] | None = None
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(self, scene: ImageType, detections: Detections) -> ImageType:
|
||||
"""
|
||||
Annotates the scene with a heatmap based on the provided detections.
|
||||
@@ -1896,7 +2048,7 @@ class PixelateAnnotator(BaseAnnotator):
|
||||
"""
|
||||
self.pixel_size: int = pixel_size
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -1993,7 +2145,7 @@ class TriangleAnnotator(BaseAnnotator):
|
||||
self.outline_thickness: int = outline_thickness
|
||||
self.outline_color: Color | ColorPalette = outline_color
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -2105,7 +2257,7 @@ class RoundBoxAnnotator(BaseAnnotator):
|
||||
raise ValueError("roundness attribute must be float between (0, 1.0]")
|
||||
self.roundness: float = roundness
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -2239,10 +2391,13 @@ class PercentageBarAnnotator(BaseAnnotator):
|
||||
self.position: Position = position
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
if border_thickness is None:
|
||||
self.border_thickness = int(0.15 * self.height)
|
||||
self.border_thickness = (
|
||||
border_thickness
|
||||
if border_thickness is not None
|
||||
else int(0.15 * self.height)
|
||||
)
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -2423,7 +2578,7 @@ class CropAnnotator(BaseAnnotator):
|
||||
self.border_thickness: int = border_thickness
|
||||
self.border_color_lookup: ColorLookup = border_color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
@@ -2572,7 +2727,7 @@ class BackgroundOverlayAnnotator(BaseAnnotator):
|
||||
self.opacity = opacity
|
||||
self.force_box = force_box
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(self, scene: ImageType, detections: Detections) -> ImageType:
|
||||
"""
|
||||
Applies a colored overlay to the scene outside of the detected regions.
|
||||
@@ -2670,7 +2825,7 @@ class ComparisonAnnotator:
|
||||
self.label_scale = label_scale
|
||||
self.text_thickness = int(self.label_scale + 1.2)
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self, scene: ImageType, detections_1: Detections, detections_2: Detections
|
||||
) -> ImageType:
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -142,35 +143,45 @@ def resolve_color(
|
||||
detection_idx=detection_idx,
|
||||
color_lookup=color_lookup,
|
||||
)
|
||||
if color_lookup == ColorLookup.TRACK and idx == PENDING_TRACK_ID:
|
||||
if (
|
||||
isinstance(color_lookup, ColorLookup)
|
||||
and color_lookup == ColorLookup.TRACK
|
||||
and idx == PENDING_TRACK_ID
|
||||
):
|
||||
return PENDING_TRACK_COLOR
|
||||
return get_color_by_index(color=color, idx=idx)
|
||||
|
||||
|
||||
def wrap_text(text: str, max_line_length=None) -> list[str]:
|
||||
def wrap_text(text: Any, max_line_length=None) -> list[str]:
|
||||
"""
|
||||
Wraps text to the specified maximum line length, respecting existing newlines.
|
||||
Uses the textwrap library for robust text wrapping.
|
||||
Wrap `text` to the specified maximum line length, respecting existing
|
||||
newlines. Falls back to str() if `text` is not already a string.
|
||||
|
||||
Args:
|
||||
text (str): The text to wrap.
|
||||
text (Any): The text (or object) to wrap.
|
||||
max_line_length (int | None): Maximum width for each wrapped line.
|
||||
|
||||
Returns:
|
||||
List[str]: A list of text lines after wrapping.
|
||||
list[str]: Wrapped lines.
|
||||
"""
|
||||
|
||||
if not text:
|
||||
return [""]
|
||||
|
||||
if not isinstance(text, str):
|
||||
text = str(text)
|
||||
|
||||
if max_line_length is None:
|
||||
return text.splitlines() or [""]
|
||||
|
||||
if max_line_length <= 0:
|
||||
raise ValueError("max_line_length must be a positive integer")
|
||||
|
||||
paragraphs = text.split("\n")
|
||||
all_lines = []
|
||||
all_lines: list[str] = []
|
||||
|
||||
for paragraph in paragraphs:
|
||||
if not paragraph:
|
||||
# Keep empty lines
|
||||
if paragraph == "":
|
||||
all_lines.append("")
|
||||
continue
|
||||
|
||||
@@ -182,12 +193,9 @@ def wrap_text(text: str, max_line_length=None) -> list[str]:
|
||||
drop_whitespace=True,
|
||||
)
|
||||
|
||||
if wrapped:
|
||||
all_lines.extend(wrapped)
|
||||
else:
|
||||
all_lines.append("")
|
||||
all_lines.extend(wrapped or [""])
|
||||
|
||||
return all_lines if all_lines else [""]
|
||||
return all_lines or [""]
|
||||
|
||||
|
||||
def validate_labels(labels: list[str] | None, detections: Detections):
|
||||
|
||||
+230
-24
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import warnings
|
||||
from functools import reduce
|
||||
from typing import Any
|
||||
|
||||
@@ -40,16 +40,18 @@ from supervision.detection.utils.masks import calculate_masks_centroids
|
||||
from supervision.detection.vlm import (
|
||||
LMM,
|
||||
VLM,
|
||||
from_deepseek_vl_2,
|
||||
from_florence_2,
|
||||
from_google_gemini_2_0,
|
||||
from_google_gemini_2_5,
|
||||
from_moondream,
|
||||
from_paligemma,
|
||||
from_qwen_2_5_vl,
|
||||
from_qwen_3_vl,
|
||||
validate_vlm_parameters,
|
||||
)
|
||||
from supervision.geometry.core import Position
|
||||
from supervision.utils.internal import deprecated, get_instance_variables
|
||||
from supervision.utils.internal import get_instance_variables
|
||||
from supervision.validators import validate_detections_fields
|
||||
|
||||
|
||||
@@ -295,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:
|
||||
@@ -809,10 +817,6 @@ class Detections:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@deprecated(
|
||||
"`Detections.from_lmm` property is deprecated and will be removed in "
|
||||
"`supervision-0.31.0`. Use Detections.from_vlm instead."
|
||||
)
|
||||
def from_lmm(cls, lmm: LMM | str, result: str | dict, **kwargs: Any) -> Detections:
|
||||
"""
|
||||
!!! deprecated "Deprecated"
|
||||
@@ -830,6 +834,7 @@ class Detections:
|
||||
| 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` | |
|
||||
| DeepSeek-VL2 | `DEEPSEEK_VL_2` | detection | `resolution_wh` | `classes` |
|
||||
|
||||
Args:
|
||||
lmm (Union[LMM, str]): The type of LMM (Large Multimodal Model) to use.
|
||||
@@ -943,6 +948,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
|
||||
@@ -1117,19 +1152,68 @@ class Detections:
|
||||
# array([[1752.28, 818.82, 2165.72, 1229.14],
|
||||
# [1908.01, 1346.67, 2585.99, 2024.11]])
|
||||
```
|
||||
|
||||
!!! example "DeepSeek-VL2"
|
||||
|
||||
|
||||
??? tip "Prompt engineering"
|
||||
|
||||
To get the best results from DeepSeek-VL2, use optimized prompts that leverage
|
||||
its object detection and visual grounding capabilities effectively.
|
||||
|
||||
**For general object detection, use the following user prompt:**
|
||||
|
||||
```
|
||||
<image>\\n<|ref|>The giraffe at the front<|/ref|>
|
||||
```
|
||||
|
||||
**For visual grounding, use the following user prompt:**
|
||||
|
||||
```
|
||||
<image>\\n<|grounding|>Detect the giraffes
|
||||
```
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
import supervision as sv
|
||||
|
||||
deepseek_vl2_result = "<|ref|>The giraffe at the back<|/ref|><|det|>[[580, 270, 999, 904]]<|/det|><|ref|>The giraffe at the front<|/ref|><|det|>[[26, 31, 632, 998]]<|/det|><|end▁of▁sentence|>"
|
||||
|
||||
detections = sv.Detections.from_vlm(
|
||||
vlm=sv.VLM.DEEPSEEK_VL_2, result=deepseek_vl2_result, resolution_wh=image.size
|
||||
)
|
||||
|
||||
detections.xyxy
|
||||
# array([[ 420, 293, 724, 982],
|
||||
# [ 18, 33, 458, 1084]])
|
||||
|
||||
detections.class_id
|
||||
# array([0, 1])
|
||||
|
||||
detections.data
|
||||
# {'class_name': array(['The giraffe at the back', 'The giraffe at the front'], dtype='<U24')}
|
||||
```
|
||||
""" # noqa: E501
|
||||
|
||||
warnings.warn(
|
||||
"`Detections.from_lmm` is deprecated since `supervision-0.26.0` "
|
||||
"and will be removed in `supervision-0.30.0`. "
|
||||
"Use `Detections.from_vlm` instead.",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# filler logic mapping old from_lmm to new from_vlm
|
||||
lmm_to_vlm = {
|
||||
LMM.PALIGEMMA: VLM.PALIGEMMA,
|
||||
LMM.FLORENCE_2: VLM.FLORENCE_2,
|
||||
LMM.QWEN_2_5_VL: VLM.QWEN_2_5_VL,
|
||||
LMM.DEEPSEEK_VL_2: VLM.DEEPSEEK_VL_2,
|
||||
LMM.GOOGLE_GEMINI_2_0: VLM.GOOGLE_GEMINI_2_0,
|
||||
LMM.GOOGLE_GEMINI_2_5: VLM.GOOGLE_GEMINI_2_5,
|
||||
}
|
||||
|
||||
# (this works even if the LMM enum is wrapped by @deprecated)
|
||||
if isinstance(lmm, Enum) and lmm.__class__.__name__ == "LMM":
|
||||
if isinstance(lmm, LMM):
|
||||
vlm = lmm_to_vlm[lmm]
|
||||
|
||||
elif isinstance(lmm, str):
|
||||
@@ -1161,9 +1245,11 @@ 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` | |
|
||||
| DeepSeek-VL2 | `DEEPSEEK_VL_2` | detection | `resolution_wh` | `classes` |
|
||||
|
||||
Args:
|
||||
vlm (Union[VLM, str]): The type of VLM (Vision Language Model) to use.
|
||||
@@ -1277,6 +1363,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
|
||||
@@ -1452,6 +1568,47 @@ class Detections:
|
||||
# [1908.01, 1346.67, 2585.99, 2024.11]])
|
||||
```
|
||||
|
||||
!!! example "DeepSeek-VL2"
|
||||
|
||||
|
||||
??? tip "Prompt engineering"
|
||||
|
||||
To get the best results from DeepSeek-VL2, use optimized prompts that leverage
|
||||
its object detection and visual grounding capabilities effectively.
|
||||
|
||||
**For general object detection, use the following user prompt:**
|
||||
|
||||
```
|
||||
<image>\\n<|ref|>The giraffe at the front<|/ref|>
|
||||
```
|
||||
|
||||
**For visual grounding, use the following user prompt:**
|
||||
|
||||
```
|
||||
<image>\\n<|grounding|>Detect the giraffes
|
||||
```
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
import supervision as sv
|
||||
|
||||
deepseek_vl2_result = "<|ref|>The giraffe at the back<|/ref|><|det|>[[580, 270, 999, 904]]<|/det|><|ref|>The giraffe at the front<|/ref|><|det|>[[26, 31, 632, 998]]<|/det|><|end▁of▁sentence|>"
|
||||
|
||||
detections = sv.Detections.from_vlm(
|
||||
vlm=sv.VLM.DEEPSEEK_VL_2, result=deepseek_vl2_result, resolution_wh=image.size
|
||||
)
|
||||
|
||||
detections.xyxy
|
||||
# array([[ 420, 293, 724, 982],
|
||||
# [ 18, 33, 458, 1084]])
|
||||
|
||||
detections.class_id
|
||||
# array([0, 1])
|
||||
|
||||
detections.data
|
||||
# {'class_name': array(['The giraffe at the back', 'The giraffe at the front'], dtype='<U24')}
|
||||
```
|
||||
|
||||
""" # noqa: E501
|
||||
|
||||
vlm = validate_vlm_parameters(vlm, result, kwargs)
|
||||
@@ -1464,6 +1621,18 @@ 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}
|
||||
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)
|
||||
data = {CLASS_NAME_DATA_FIELD: class_name}
|
||||
return cls(xyxy=xyxy, class_id=class_id, data=data)
|
||||
|
||||
if vlm == VLM.FLORENCE_2:
|
||||
@@ -1922,6 +2091,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,
|
||||
@@ -1939,8 +2145,8 @@ class Detections:
|
||||
class_agnostic (bool): Whether to perform class-agnostic
|
||||
non-maximum suppression. If True, the class_id of each detection
|
||||
will be ignored. Defaults to False.
|
||||
overlap_metric (OverlapMetric): Metric used for measuring overlap between
|
||||
detections in slices.
|
||||
overlap_metric (OverlapMetric): Metric used to compute the degree of
|
||||
overlap between pairs of masks or boxes (e.g., IoU, IoS).
|
||||
|
||||
Returns:
|
||||
Detections: A new Detections object containing the subset of detections
|
||||
@@ -2003,8 +2209,8 @@ class Detections:
|
||||
class_agnostic (bool): Whether to perform class-agnostic
|
||||
non-maximum merging. If True, the class_id of each detection
|
||||
will be ignored. Defaults to False.
|
||||
overlap_metric (OverlapMetric): Metric used for measuring overlap between
|
||||
detections in slices.
|
||||
overlap_metric (OverlapMetric): Metric used to compute the degree of
|
||||
overlap between pairs of masks or boxes (e.g., IoU, IoS).
|
||||
|
||||
Returns:
|
||||
Detections: A new Detections object containing the subset of detections
|
||||
|
||||
@@ -9,13 +9,11 @@ import numpy as np
|
||||
from supervision.config import ORIENTED_BOX_COORDINATES
|
||||
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
|
||||
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.
|
||||
match_metric (str): Metric used for matching detections in slices.
|
||||
"IOU" or "IOS". Defaults "IOU".
|
||||
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,
|
||||
match_metric: str = "IOU",
|
||||
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.match_metric = match_metric
|
||||
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, match_metric=self.match_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, match_metric=self.match_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}."
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from deprecate import deprecated
|
||||
|
||||
from supervision.detection.utils.iou_and_nms import box_iou_batch
|
||||
|
||||
@@ -14,8 +15,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
|
||||
@@ -94,25 +95,35 @@ def pad_boxes(xyxy: np.ndarray, px: int, py: int | None = None) -> np.ndarray:
|
||||
return result
|
||||
|
||||
|
||||
@deprecated(
|
||||
target=None,
|
||||
deprecated_in="0.27.0",
|
||||
remove_in="0.31.0",
|
||||
args_mapping={"normalized_xyxy": "xyxy"},
|
||||
)
|
||||
def denormalize_boxes(
|
||||
normalized_xyxy: np.ndarray,
|
||||
xyxy: np.ndarray,
|
||||
resolution_wh: tuple[int, int],
|
||||
normalization_factor: float = 1.0,
|
||||
normalized_xyxy: np.ndarray = None,
|
||||
) -> 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 +131,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
|
||||
|
||||
|
||||
@@ -229,6 +229,70 @@ def mask_to_xyxy(masks: np.ndarray) -> np.ndarray:
|
||||
return xyxy
|
||||
|
||||
|
||||
def xyxy_to_mask(boxes: np.ndarray, resolution_wh: tuple[int, int]) -> np.ndarray:
|
||||
"""
|
||||
Converts a 2D `np.ndarray` of bounding boxes into a 3D `np.ndarray` of bool masks.
|
||||
|
||||
Parameters:
|
||||
boxes (np.ndarray): A 2D `np.ndarray` of shape `(N, 4)`
|
||||
containing bounding boxes `(x_min, y_min, x_max, y_max)`
|
||||
resolution_wh (Tuple[int, int]): A tuple `(width, height)` specifying
|
||||
the resolution of the output masks
|
||||
|
||||
Returns:
|
||||
np.ndarray: A 3D `np.ndarray` of shape `(N, height, width)`
|
||||
containing 2D bool masks for each bounding box
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
boxes = np.array([[0, 0, 2, 2]])
|
||||
|
||||
sv.xyxy_to_mask(boxes, (5, 5))
|
||||
# array([
|
||||
# [[ True, True, True, False, False],
|
||||
# [ True, True, True, False, False],
|
||||
# [ True, True, True, False, False],
|
||||
# [False, False, False, False, False],
|
||||
# [False, False, False, False, False]]
|
||||
# ])
|
||||
|
||||
boxes = np.array([[0, 0, 1, 1], [3, 3, 4, 4]])
|
||||
|
||||
sv.xyxy_to_mask(boxes, (5, 5))
|
||||
# array([
|
||||
# [[ True, True, False, False, False],
|
||||
# [ True, True, False, False, False],
|
||||
# [False, False, False, False, False],
|
||||
# [False, False, False, False, False],
|
||||
# [False, False, False, False, False]],
|
||||
#
|
||||
# [[False, False, False, False, False],
|
||||
# [False, False, False, False, False],
|
||||
# [False, False, False, False, False],
|
||||
# [False, False, False, True, True],
|
||||
# [False, False, False, True, True]]
|
||||
# ])
|
||||
```
|
||||
"""
|
||||
width, height = resolution_wh
|
||||
n = boxes.shape[0]
|
||||
masks = np.zeros((n, height, width), dtype=bool)
|
||||
|
||||
for i, (x_min, y_min, x_max, y_max) in enumerate(boxes):
|
||||
x_min = max(0, int(x_min))
|
||||
y_min = max(0, int(y_min))
|
||||
x_max = min(width - 1, int(x_max))
|
||||
y_max = min(height - 1, int(y_max))
|
||||
|
||||
if x_max >= x_min and y_max >= y_min:
|
||||
masks[i, y_min : y_max + 1, x_min : x_max + 1] = True
|
||||
|
||||
return masks
|
||||
|
||||
|
||||
def mask_to_polygons(mask: np.ndarray) -> list[np.ndarray]:
|
||||
"""
|
||||
Converts a binary mask to a list of polygons.
|
||||
|
||||
@@ -271,7 +271,6 @@ def merge_metadata(metadata_list: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"{type(value)}, {type(other_value)}."
|
||||
)
|
||||
else:
|
||||
print("hm")
|
||||
if merged_metadata[key] != value:
|
||||
raise ValueError(f"Conflicting metadata for key: '{key}'.")
|
||||
|
||||
|
||||
@@ -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,90 +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 for matching detections in slices.
|
||||
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
|
||||
@@ -185,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:
|
||||
@@ -381,7 +406,8 @@ def _mask_iou_batch_split(
|
||||
Args:
|
||||
masks_true (np.ndarray): 3D `np.ndarray` representing ground-truth masks.
|
||||
masks_detection (np.ndarray): 3D `np.ndarray` representing detection masks.
|
||||
overlap_metric (OverlapMetric): Metric used for matching detections in slices.
|
||||
overlap_metric (OverlapMetric): Metric used to compute the degree of overlap
|
||||
between pairs of masks (e.g., IoU, IoS).
|
||||
|
||||
Returns:
|
||||
np.ndarray: Pairwise IoU of masks from `masks_true` and `masks_detection`.
|
||||
@@ -433,7 +459,8 @@ def mask_iou_batch(
|
||||
Args:
|
||||
masks_true (np.ndarray): 3D `np.ndarray` representing ground-truth masks.
|
||||
masks_detection (np.ndarray): 3D `np.ndarray` representing detection masks.
|
||||
overlap_metric (OverlapMetric): Metric used for matching detections in slices.
|
||||
overlap_metric (OverlapMetric): Metric used to compute the degree of overlap
|
||||
between pairs of masks (e.g., IoU, IoS).
|
||||
memory_limit (int): memory limit in MB, default is 1024 * 5 MB (5GB).
|
||||
|
||||
Returns:
|
||||
@@ -492,7 +519,8 @@ def mask_non_max_suppression(
|
||||
dimensions of each mask.
|
||||
iou_threshold (float): The intersection-over-union threshold
|
||||
to use for non-maximum suppression.
|
||||
overlap_metric (OverlapMetric): Metric used for matching detections in slices.
|
||||
overlap_metric (OverlapMetric): Metric used to compute the degree of overlap
|
||||
between pairs of masks (e.g., IoU, IoS).
|
||||
mask_dimension (int): The dimension to which the masks should be
|
||||
resized before computing IOU values. Defaults to 640.
|
||||
|
||||
@@ -543,7 +571,8 @@ def box_non_max_suppression(
|
||||
or `(x_min, y_min, x_max, y_max, score, class)`.
|
||||
iou_threshold (float): The intersection-over-union threshold
|
||||
to use for non-maximum suppression.
|
||||
overlap_metric (OverlapMetric): Metric used for matching detections in slices.
|
||||
overlap_metric (OverlapMetric): Metric used to compute the degree of overlap
|
||||
between pairs of boxes (e.g., IoU, IoS).
|
||||
|
||||
Returns:
|
||||
np.ndarray: A boolean array indicating which predictions to keep after n
|
||||
@@ -603,7 +632,8 @@ def _group_overlapping_masks(
|
||||
the predictions.
|
||||
iou_threshold (float): The intersection-over-union threshold
|
||||
to use for non-maximum suppression. Defaults to 0.5.
|
||||
overlap_metric (OverlapMetric): Metric used for matching detections in slices.
|
||||
overlap_metric (OverlapMetric): Metric used to compute the degree of overlap
|
||||
between pairs of masks (e.g., IoU, IoS).
|
||||
|
||||
Returns:
|
||||
list[list[int]]: Groups of prediction indices be merged.
|
||||
@@ -664,7 +694,8 @@ def mask_non_max_merge(
|
||||
to use for non-maximum suppression.
|
||||
mask_dimension (int): The dimension to which the masks should be
|
||||
resized before computing IOU values. Defaults to 640.
|
||||
overlap_metric (OverlapMetric): Metric used for matching detections in slices.
|
||||
overlap_metric (OverlapMetric): Metric used to compute the degree of overlap
|
||||
between pairs of masks (e.g., IoU, IoS).
|
||||
|
||||
Returns:
|
||||
np.ndarray: A boolean array indicating which predictions to keep after
|
||||
@@ -717,7 +748,8 @@ def _group_overlapping_boxes(
|
||||
and the confidence scores.
|
||||
iou_threshold (float): The intersection-over-union threshold
|
||||
to use for non-maximum suppression. Defaults to 0.5.
|
||||
overlap_metric (OverlapMetric): Metric used for matching detections in slices.
|
||||
overlap_metric (OverlapMetric): Metric used to compute the degree of overlap
|
||||
between pairs of boxes (e.g., IoU, IoS).
|
||||
|
||||
Returns:
|
||||
list[list[int]]: Groups of prediction indices be merged.
|
||||
@@ -765,7 +797,8 @@ def box_non_max_merge(
|
||||
detections of different classes to be merged.
|
||||
iou_threshold (float): The intersection-over-union threshold
|
||||
to use for non-maximum suppression. Defaults to 0.5.
|
||||
overlap_metric (OverlapMetric): Metric used for matching detections in slices.
|
||||
overlap_metric (OverlapMetric): Metric used to compute the degree of overlap
|
||||
between pairs of boxes (e.g., IoU, IoS).
|
||||
|
||||
Returns:
|
||||
list[list[int]]: Groups of prediction indices be merged.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def edit_distance(string_1: str, string_2: str, case_sensitive: bool = True) -> int:
|
||||
"""
|
||||
Calculates the minimum number of single-character edits required
|
||||
to transform one string into another. Allowed operations are insertion,
|
||||
deletion, and substitution.
|
||||
|
||||
Args:
|
||||
string_1 (str): The source string to be transformed.
|
||||
string_2 (str): The target string to transform into.
|
||||
case_sensitive (bool, optional): Whether comparison should be case-sensitive.
|
||||
Defaults to True.
|
||||
|
||||
Returns:
|
||||
int: The minimum number of edits required to convert `string_1`
|
||||
into `string_2`.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
sv.edit_distance("hello", "hello")
|
||||
# 0
|
||||
|
||||
sv.edit_distance("Test", "test", case_sensitive=True)
|
||||
# 1
|
||||
|
||||
sv.edit_distance("abc", "xyz")
|
||||
# 3
|
||||
|
||||
sv.edit_distance("hello", "")
|
||||
# 5
|
||||
|
||||
sv.edit_distance("", "")
|
||||
# 0
|
||||
|
||||
sv.edit_distance("hello world", "helloworld")
|
||||
# 1
|
||||
```
|
||||
"""
|
||||
if not case_sensitive:
|
||||
string_1 = string_1.lower()
|
||||
string_2 = string_2.lower()
|
||||
|
||||
if len(string_1) < len(string_2):
|
||||
string_1, string_2 = string_2, string_1
|
||||
|
||||
prev_row = list(range(len(string_2) + 1))
|
||||
curr_row = [0] * (len(string_2) + 1)
|
||||
|
||||
for i in range(1, len(string_1) + 1):
|
||||
curr_row[0] = i
|
||||
for j in range(1, len(string_2) + 1):
|
||||
if string_1[i - 1] == string_2[j - 1]:
|
||||
substitution_cost = 0
|
||||
else:
|
||||
substitution_cost = 1
|
||||
curr_row[j] = min(
|
||||
prev_row[j] + 1,
|
||||
curr_row[j - 1] + 1,
|
||||
prev_row[j - 1] + substitution_cost,
|
||||
)
|
||||
prev_row, curr_row = curr_row, prev_row
|
||||
|
||||
return prev_row[len(string_2)]
|
||||
|
||||
|
||||
def fuzzy_match_index(
|
||||
candidates: list[str],
|
||||
query: str,
|
||||
threshold: int,
|
||||
case_sensitive: bool = True,
|
||||
) -> int | None:
|
||||
"""
|
||||
Searches for the first string in `candidates` whose edit distance
|
||||
to `query` is less than or equal to `threshold`.
|
||||
|
||||
Args:
|
||||
candidates (list[str]): List of strings to search.
|
||||
query (str): String to compare against the candidates.
|
||||
threshold (int): Maximum allowed edit distance for a match.
|
||||
case_sensitive (bool, optional): Whether matching should be case-sensitive.
|
||||
|
||||
Returns:
|
||||
Optional[int]: Index of the first matching string in candidates,
|
||||
or None if no match is found.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
fuzzy_match_index(["cat", "dog", "rat"], "dat", threshold=1)
|
||||
# 0
|
||||
|
||||
fuzzy_match_index(["alpha", "beta", "gamma"], "bata", threshold=1)
|
||||
# 1
|
||||
|
||||
fuzzy_match_index(["one", "two", "three"], "ten", threshold=2)
|
||||
# None
|
||||
```
|
||||
"""
|
||||
for idx, candidate in enumerate(candidates):
|
||||
if edit_distance(candidate, query, case_sensitive=case_sensitive) <= threshold:
|
||||
return idx
|
||||
return None
|
||||
+228
-51
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import warnings
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
@@ -12,22 +14,22 @@ from PIL import Image
|
||||
|
||||
from supervision.detection.utils.boxes import denormalize_boxes
|
||||
from supervision.detection.utils.converters import polygon_to_mask, polygon_to_xyxy
|
||||
from supervision.utils.internal import deprecated
|
||||
from supervision.validators import validate_resolution
|
||||
|
||||
|
||||
@deprecated(
|
||||
"`LMM` enum is deprecated and will be removed in "
|
||||
"`supervision-0.31.0`. Use VLM instead."
|
||||
)
|
||||
class LMM(Enum):
|
||||
"""
|
||||
Enum specifying supported Large Multimodal Models (LMMs).
|
||||
|
||||
.. deprecated:: 0.27.0
|
||||
`LMM` is deprecated and will be removed in `supervision-0.31.0`.
|
||||
Use `VLM` instead.
|
||||
|
||||
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,8 @@ 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"
|
||||
MOONDREAM = "moondream"
|
||||
@@ -46,6 +50,12 @@ class LMM(Enum):
|
||||
|
||||
@classmethod
|
||||
def from_value(cls, value: LMM | str) -> LMM:
|
||||
warnings.warn(
|
||||
"`LMM` is deprecated since `supervision-0.27.0` and will be removed in "
|
||||
"`supervision-0.31.0`. Use `VLM` instead.",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
if isinstance(value, cls):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
@@ -68,6 +78,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.
|
||||
@@ -76,6 +87,8 @@ 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"
|
||||
MOONDREAM = "moondream"
|
||||
@@ -104,6 +117,8 @@ 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,
|
||||
VLM.MOONDREAM: dict,
|
||||
@@ -113,6 +128,8 @@ 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"],
|
||||
VLM.MOONDREAM: ["resolution_wh"],
|
||||
@@ -122,6 +139,8 @@ 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"],
|
||||
VLM.MOONDREAM: ["resolution_wh"],
|
||||
@@ -230,6 +249,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],
|
||||
@@ -237,7 +301,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
|
||||
@@ -248,38 +312,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 = []
|
||||
@@ -291,7 +369,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)
|
||||
@@ -310,6 +388,109 @@ 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]:
|
||||
"""
|
||||
Parse bounding boxes from deepseek-vl2-formatted text, scale them to the specified
|
||||
resolution, and optionally filter by classes.
|
||||
|
||||
The DeepSeek-VL2 output typically contains pairs of <|ref|> ... <|/ref|> labels
|
||||
and <|det|> ... <|/det|> bounding box definitions. Each <|det|> section may
|
||||
contain one or more bounding boxes in the form [[x1, y1, x2, y2], [x1, y1, x2, y2], ...]
|
||||
(scaled to 0..999). For example:
|
||||
|
||||
```
|
||||
<|ref|>The giraffe at the back<|/ref|><|det|>[[580, 270, 999, 904]]<|/det|><|ref|>The giraffe at the front<|/ref|><|det|>[[26, 31, 632, 998]]<|/det|><|end▁of▁sentence|>
|
||||
```
|
||||
|
||||
Args:
|
||||
result: String containing deepseek-vl2-formatted locations and labels.
|
||||
resolution_wh: Tuple (width, height) to which we scale the box coordinates.
|
||||
classes: Optional list of valid class names. If provided, boxes and labels not
|
||||
in this list are filtered out.
|
||||
|
||||
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.
|
||||
""" # noqa: E501
|
||||
|
||||
width, height = resolution_wh
|
||||
label_segments = re.findall(r"<\|ref\|>(.*?)<\|/ref\|>", result, flags=re.S)
|
||||
detection_segments = re.findall(r"<\|det\|>(.*?)<\|/det\|>", result, flags=re.S)
|
||||
|
||||
if len(label_segments) != len(detection_segments):
|
||||
raise ValueError(
|
||||
f"Number of ref tags ({len(label_segments)}) "
|
||||
f"and det tags ({len(detection_segments)}) in the result must be equal."
|
||||
)
|
||||
|
||||
xyxy, class_name_list = [], []
|
||||
for label, detection_blob in zip(label_segments, detection_segments):
|
||||
current_class_name = label.strip()
|
||||
for box in re.findall(r"\[(.*?)\]", detection_blob):
|
||||
x1, y1, x2, y2 = map(float, box.strip("[]").split(","))
|
||||
xyxy.append(
|
||||
[
|
||||
(x1 / 999 * width),
|
||||
(y1 / 999 * height),
|
||||
(x2 / 999 * width),
|
||||
(y2 / 999 * height),
|
||||
]
|
||||
)
|
||||
class_name_list.append(current_class_name)
|
||||
|
||||
xyxy = np.array(xyxy, dtype=np.float32)
|
||||
class_name = np.array(class_name_list)
|
||||
|
||||
if classes is not None:
|
||||
mask = np.array([name in classes for name in class_name], dtype=bool)
|
||||
xyxy = xyxy[mask]
|
||||
class_name = class_name[mask]
|
||||
class_id = np.array([classes.index(name) for name in class_name])
|
||||
else:
|
||||
unique_classes = sorted(list(set(class_name)))
|
||||
class_to_id = {name: i for i, name in enumerate(unique_classes)}
|
||||
class_id = np.array([class_to_id[name] for name in class_name])
|
||||
|
||||
return xyxy, class_id, class_name
|
||||
|
||||
|
||||
def from_florence_2(
|
||||
result: dict, resolution_wh: tuple[int, int]
|
||||
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray | None, np.ndarray | None]:
|
||||
@@ -460,7 +641,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:
|
||||
@@ -468,18 +649,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
|
||||
|
||||
@@ -571,10 +750,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:
|
||||
@@ -657,7 +836,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.
|
||||
|
||||
@@ -695,7 +874,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"]):
|
||||
@@ -706,14 +885,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),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from typing import TypeVar
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
ImageType = TypeVar("ImageType", np.ndarray, Image.Image)
|
||||
"""
|
||||
An image of type `np.ndarray` or `PIL.Image.Image`.
|
||||
|
||||
Unlike a `Union`, ensures the type remains consistent. If a function
|
||||
takes an `ImageType` argument and returns an `ImageType`, when you
|
||||
pass an `np.ndarray`, you will get an `np.ndarray` back.
|
||||
"""
|
||||
+32
-10
@@ -346,28 +346,50 @@ def draw_image(
|
||||
|
||||
def calculate_optimal_text_scale(resolution_wh: tuple[int, int]) -> float:
|
||||
"""
|
||||
Calculate font scale based on the resolution of an image.
|
||||
Calculate optimal font scale based on image resolution. Adjusts font scale
|
||||
proportionally to the smallest dimension of the given image resolution for
|
||||
consistent readability.
|
||||
|
||||
Parameters:
|
||||
resolution_wh (Tuple[int, int]): A tuple representing the width and height
|
||||
of the image.
|
||||
Args:
|
||||
resolution_wh (tuple[int, int]): (width, height) of the image in pixels
|
||||
|
||||
Returns:
|
||||
float: The calculated font scale factor.
|
||||
float: recommended font scale factor
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
sv.calculate_optimal_text_scale((1920, 1080))
|
||||
# 1.08
|
||||
sv.calculate_optimal_text_scale((640, 480))
|
||||
# 0.48
|
||||
```
|
||||
"""
|
||||
return min(resolution_wh) * 1e-3
|
||||
|
||||
|
||||
def calculate_optimal_line_thickness(resolution_wh: tuple[int, int]) -> int:
|
||||
"""
|
||||
Calculate line thickness based on the resolution of an image.
|
||||
Calculate optimal line thickness based on image resolution. Adjusts the line
|
||||
thickness for readability depending on the smallest dimension of the provided
|
||||
image resolution.
|
||||
|
||||
Parameters:
|
||||
resolution_wh (Tuple[int, int]): A tuple representing the width and height
|
||||
of the image.
|
||||
Args:
|
||||
resolution_wh (tuple[int, int]): (width, height) of the image in pixels
|
||||
|
||||
Returns:
|
||||
int: The calculated line thickness in pixels.
|
||||
int: recommended line thickness in pixels
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
sv.calculate_optimal_line_thickness((1920, 1080))
|
||||
# 4
|
||||
sv.calculate_optimal_line_thickness((640, 480))
|
||||
# 2
|
||||
```
|
||||
"""
|
||||
if min(resolution_wh) < 1080:
|
||||
return 2
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from logging import warn
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from supervision.detection.utils.boxes import pad_boxes, spread_out_boxes
|
||||
from supervision.draw.base import ImageType
|
||||
from supervision.draw.color import Color
|
||||
from supervision.draw.utils import draw_rounded_rectangle
|
||||
from supervision.geometry.core import Rect
|
||||
from supervision.key_points.core import KeyPoints
|
||||
from supervision.key_points.skeletons import SKELETONS_BY_VERTEX_COUNT
|
||||
from supervision.utils.conversion import ensure_cv2_image_for_class_method
|
||||
|
||||
|
||||
class BaseKeyPointAnnotator(ABC):
|
||||
@abstractmethod
|
||||
def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
|
||||
pass
|
||||
|
||||
|
||||
class VertexAnnotator(BaseKeyPointAnnotator):
|
||||
"""
|
||||
A class that specializes in drawing skeleton vertices on images. It uses
|
||||
specified key points to determine the locations where the vertices should be
|
||||
drawn.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
color: Color = Color.ROBOFLOW,
|
||||
radius: int = 4,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
color (Color): The color to use for annotating key points.
|
||||
radius (int): The radius of the circles used to represent the key
|
||||
points.
|
||||
"""
|
||||
self.color = color
|
||||
self.radius = radius
|
||||
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
|
||||
"""
|
||||
Annotates the given scene with skeleton vertices based on the provided key
|
||||
points. It draws circles at each key point location.
|
||||
|
||||
Args:
|
||||
scene (ImageType): The image where skeleton vertices will be drawn.
|
||||
`ImageType` is a flexible type, accepting either `numpy.ndarray` or
|
||||
`PIL.Image.Image`.
|
||||
key_points (KeyPoints): A collection of key points where each key point
|
||||
consists of x and y coordinates.
|
||||
|
||||
Returns:
|
||||
The annotated image, matching the type of `scene` (`numpy.ndarray`
|
||||
or `PIL.Image.Image`)
|
||||
|
||||
Example:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
key_points = sv.KeyPoints(...)
|
||||
|
||||
vertex_annotator = sv.VertexAnnotator(
|
||||
color=sv.Color.GREEN,
|
||||
radius=10
|
||||
)
|
||||
annotated_frame = vertex_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
key_points=key_points
|
||||
)
|
||||
```
|
||||
|
||||

|
||||
"""
|
||||
assert isinstance(scene, np.ndarray)
|
||||
if len(key_points) == 0:
|
||||
return scene
|
||||
|
||||
for xy in key_points.xy:
|
||||
for x, y in xy:
|
||||
cv2.circle(
|
||||
img=scene,
|
||||
center=(int(x), int(y)),
|
||||
radius=self.radius,
|
||||
color=self.color.as_bgr(),
|
||||
thickness=-1,
|
||||
)
|
||||
|
||||
return scene
|
||||
|
||||
|
||||
class EdgeAnnotator(BaseKeyPointAnnotator):
|
||||
"""
|
||||
A class that specializes in drawing skeleton edges on images using specified key
|
||||
points. It connects key points with lines to form the skeleton structure.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
color: Color = Color.ROBOFLOW,
|
||||
thickness: int = 2,
|
||||
edges: list[tuple[int, int]] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
color (Color): The color to use for the edges.
|
||||
thickness (int): The thickness of the edges.
|
||||
edges (Optional[List[Tuple[int, int]]]): The edges to draw.
|
||||
If set to `None`, will attempt to select automatically.
|
||||
"""
|
||||
self.color = color
|
||||
self.thickness = thickness
|
||||
self.edges = edges
|
||||
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
|
||||
"""
|
||||
Annotates the given scene by drawing lines between specified key points to form
|
||||
edges.
|
||||
|
||||
Args:
|
||||
scene (ImageType): The image where skeleton edges will be drawn. `ImageType`
|
||||
is a flexible type, accepting either `numpy.ndarray` or
|
||||
`PIL.Image.Image`.
|
||||
key_points (KeyPoints): A collection of key points where each key point
|
||||
consists of x and y coordinates.
|
||||
|
||||
Returns:
|
||||
Returns:
|
||||
The annotated image, matching the type of `scene` (`numpy.ndarray`
|
||||
or `PIL.Image.Image`)
|
||||
|
||||
Example:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
key_points = sv.KeyPoints(...)
|
||||
|
||||
edge_annotator = sv.EdgeAnnotator(
|
||||
color=sv.Color.GREEN,
|
||||
thickness=5
|
||||
)
|
||||
annotated_frame = edge_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
key_points=key_points
|
||||
)
|
||||
```
|
||||
|
||||

|
||||
"""
|
||||
assert isinstance(scene, np.ndarray)
|
||||
if len(key_points) == 0:
|
||||
return scene
|
||||
|
||||
for xy in key_points.xy:
|
||||
edges = self.edges
|
||||
if not edges:
|
||||
edges = SKELETONS_BY_VERTEX_COUNT.get(len(xy))
|
||||
if not edges:
|
||||
warn(f"No skeleton found with {len(xy)} vertices")
|
||||
return scene
|
||||
|
||||
for class_a, class_b in edges:
|
||||
xy_a = xy[class_a - 1]
|
||||
xy_b = xy[class_b - 1]
|
||||
missing_a = np.allclose(xy_a, 0)
|
||||
missing_b = np.allclose(xy_b, 0)
|
||||
if missing_a or missing_b:
|
||||
continue
|
||||
|
||||
cv2.line(
|
||||
img=scene,
|
||||
pt1=(int(xy_a[0]), int(xy_a[1])),
|
||||
pt2=(int(xy_b[0]), int(xy_b[1])),
|
||||
color=self.color.as_bgr(),
|
||||
thickness=self.thickness,
|
||||
)
|
||||
|
||||
return scene
|
||||
|
||||
|
||||
class VertexLabelAnnotator:
|
||||
"""
|
||||
A class that draws labels of skeleton vertices on images. It uses specified key
|
||||
points to determine the locations where the vertices should be drawn.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
color: Color | list[Color] = Color.ROBOFLOW,
|
||||
text_color: Color | list[Color] = Color.WHITE,
|
||||
text_scale: float = 0.5,
|
||||
text_thickness: int = 1,
|
||||
text_padding: int = 10,
|
||||
border_radius: int = 0,
|
||||
smart_position: bool = False,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
color (Union[Color, List[Color]]): The color to use for each
|
||||
keypoint label. If a list is provided, the colors will be used in order
|
||||
for each keypoint.
|
||||
text_color (Union[Color, List[Color]]): The color to use
|
||||
for the labels. If a list is provided, the colors will be used in order
|
||||
for each keypoint.
|
||||
text_scale (float): The scale of the text.
|
||||
text_thickness (int): The thickness of the text.
|
||||
text_padding (int): The padding around the text.
|
||||
border_radius (int): The radius of the rounded corners of the
|
||||
boxes. Set to a high value to produce circles.
|
||||
smart_position (bool): Spread out the labels to avoid overlap.
|
||||
"""
|
||||
self.border_radius: int = border_radius
|
||||
self.color: Color | list[Color] = color
|
||||
self.text_color: Color | list[Color] = text_color
|
||||
self.text_scale: float = text_scale
|
||||
self.text_thickness: int = text_thickness
|
||||
self.text_padding: int = text_padding
|
||||
self.smart_position = smart_position
|
||||
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
key_points: KeyPoints,
|
||||
labels: list[str] | None = None,
|
||||
) -> ImageType:
|
||||
"""
|
||||
A class that draws labels of skeleton vertices on images. It uses specified key
|
||||
points to determine the locations where the vertices should be drawn.
|
||||
|
||||
Args:
|
||||
scene (ImageType): The image where vertex labels will be drawn. `ImageType`
|
||||
is a flexible type, accepting either `numpy.ndarray` or
|
||||
`PIL.Image.Image`.
|
||||
key_points (KeyPoints): A collection of key points where each key point
|
||||
consists of x and y coordinates.
|
||||
labels (Optional[List[str]]): A list of labels to be displayed on the
|
||||
annotated image. If not provided, keypoint indices will be used.
|
||||
|
||||
Returns:
|
||||
The annotated image, matching the type of `scene` (`numpy.ndarray`
|
||||
or `PIL.Image.Image`)
|
||||
|
||||
Example:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
key_points = sv.KeyPoints(...)
|
||||
|
||||
vertex_label_annotator = sv.VertexLabelAnnotator(
|
||||
color=sv.Color.GREEN,
|
||||
text_color=sv.Color.BLACK,
|
||||
border_radius=5
|
||||
)
|
||||
annotated_frame = vertex_label_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
key_points=key_points
|
||||
)
|
||||
```
|
||||
|
||||

|
||||
|
||||
!!! tip
|
||||
|
||||
`VertexLabelAnnotator` allows to customize the color of each keypoint label
|
||||
values.
|
||||
|
||||
Example:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
key_points = sv.KeyPoints(...)
|
||||
|
||||
LABELS = [
|
||||
"nose", "left eye", "right eye", "left ear",
|
||||
"right ear", "left shoulder", "right shoulder", "left elbow",
|
||||
"right elbow", "left wrist", "right wrist", "left hip",
|
||||
"right hip", "left knee", "right knee", "left ankle",
|
||||
"right ankle"
|
||||
]
|
||||
|
||||
COLORS = [
|
||||
"#FF6347", "#FF6347", "#FF6347", "#FF6347",
|
||||
"#FF6347", "#FF1493", "#00FF00", "#FF1493",
|
||||
"#00FF00", "#FF1493", "#00FF00", "#FFD700",
|
||||
"#00BFFF", "#FFD700", "#00BFFF", "#FFD700",
|
||||
"#00BFFF"
|
||||
]
|
||||
COLORS = [sv.Color.from_hex(color_hex=c) for c in COLORS]
|
||||
|
||||
vertex_label_annotator = sv.VertexLabelAnnotator(
|
||||
color=COLORS,
|
||||
text_color=sv.Color.BLACK,
|
||||
border_radius=5
|
||||
)
|
||||
annotated_frame = vertex_label_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
key_points=key_points,
|
||||
labels=labels
|
||||
)
|
||||
```
|
||||

|
||||
"""
|
||||
assert isinstance(scene, np.ndarray)
|
||||
font = cv2.FONT_HERSHEY_SIMPLEX
|
||||
|
||||
skeletons_count, points_count, _ = key_points.xy.shape
|
||||
if skeletons_count == 0:
|
||||
return scene
|
||||
|
||||
anchors = key_points.xy.reshape(points_count * skeletons_count, 2).astype(int)
|
||||
mask = np.all(anchors != 0, axis=1)
|
||||
|
||||
if not np.any(mask):
|
||||
return scene
|
||||
|
||||
colors = self.preprocess_and_validate_colors(
|
||||
colors=self.color,
|
||||
points_count=points_count,
|
||||
skeletons_count=skeletons_count,
|
||||
)
|
||||
|
||||
text_colors = self.preprocess_and_validate_colors(
|
||||
colors=self.text_color,
|
||||
points_count=points_count,
|
||||
skeletons_count=skeletons_count,
|
||||
)
|
||||
|
||||
labels = self.preprocess_and_validate_labels(
|
||||
labels=labels, points_count=points_count, skeletons_count=skeletons_count
|
||||
)
|
||||
|
||||
anchors = anchors[mask]
|
||||
colors = colors[mask]
|
||||
text_colors = text_colors[mask]
|
||||
labels = labels[mask]
|
||||
|
||||
xyxy = np.array(
|
||||
[
|
||||
self.get_text_bounding_box(
|
||||
text=label,
|
||||
font=font,
|
||||
text_scale=self.text_scale,
|
||||
text_thickness=self.text_thickness,
|
||||
center_coordinates=tuple(anchor),
|
||||
)
|
||||
for anchor, label in zip(anchors, labels)
|
||||
]
|
||||
)
|
||||
xyxy_padded = pad_boxes(xyxy=xyxy, px=self.text_padding)
|
||||
|
||||
if self.smart_position:
|
||||
xyxy_padded = spread_out_boxes(xyxy_padded)
|
||||
xyxy = pad_boxes(xyxy=xyxy_padded, px=-self.text_padding)
|
||||
|
||||
for text, color, text_color, box, box_padded in zip(
|
||||
labels, colors, text_colors, xyxy, xyxy_padded
|
||||
):
|
||||
draw_rounded_rectangle(
|
||||
scene=scene,
|
||||
rect=Rect.from_xyxy(box_padded),
|
||||
color=color,
|
||||
border_radius=self.border_radius,
|
||||
)
|
||||
cv2.putText(
|
||||
img=scene,
|
||||
text=text,
|
||||
org=(box[0], box[3]),
|
||||
fontFace=font,
|
||||
fontScale=self.text_scale,
|
||||
color=text_color.as_bgr(),
|
||||
thickness=self.text_thickness,
|
||||
lineType=cv2.LINE_AA,
|
||||
)
|
||||
|
||||
return scene
|
||||
|
||||
@staticmethod
|
||||
def get_text_bounding_box(
|
||||
text: str,
|
||||
font: int,
|
||||
text_scale: float,
|
||||
text_thickness: int,
|
||||
center_coordinates: tuple[int, int],
|
||||
) -> tuple[int, int, int, int]:
|
||||
text_w, text_h = cv2.getTextSize(
|
||||
text=text,
|
||||
fontFace=font,
|
||||
fontScale=text_scale,
|
||||
thickness=text_thickness,
|
||||
)[0]
|
||||
center_x, center_y = center_coordinates
|
||||
return (
|
||||
center_x - text_w // 2,
|
||||
center_y - text_h // 2,
|
||||
center_x + text_w // 2,
|
||||
center_y + text_h // 2,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def preprocess_and_validate_labels(
|
||||
labels: list[str] | None, points_count: int, skeletons_count: int
|
||||
) -> np.ndarray:
|
||||
if labels and len(labels) != points_count:
|
||||
raise ValueError(
|
||||
f"Number of labels ({len(labels)}) must match number of key points "
|
||||
f"({points_count})."
|
||||
)
|
||||
if labels is None:
|
||||
labels = [str(i) for i in range(points_count)]
|
||||
|
||||
return np.array(labels * skeletons_count)
|
||||
|
||||
@staticmethod
|
||||
def preprocess_and_validate_colors(
|
||||
colors: Color | list[Color] | None,
|
||||
points_count: int,
|
||||
skeletons_count: int,
|
||||
) -> np.ndarray:
|
||||
if isinstance(colors, list) and len(colors) != points_count:
|
||||
raise ValueError(
|
||||
f"Number of colors ({len(colors)}) must match number of key points "
|
||||
f"({points_count})."
|
||||
)
|
||||
return (
|
||||
np.array(colors * skeletons_count)
|
||||
if isinstance(colors, list)
|
||||
else np.array([colors] * points_count * skeletons_count)
|
||||
)
|
||||
@@ -0,0 +1,839 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from supervision.config import CLASS_NAME_DATA_FIELD
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.detection.utils.internal import get_data_item, is_data_equal
|
||||
from supervision.validators import validate_key_points_fields
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyPoints:
|
||||
"""
|
||||
The `sv.KeyPoints` class in the Supervision library standardizes results from
|
||||
various keypoint detection and pose estimation models into a consistent format. This
|
||||
class simplifies data manipulation and filtering, providing a uniform API for
|
||||
integration with Supervision [keypoints annotators](/latest/keypoint/annotators).
|
||||
|
||||
=== "Ultralytics"
|
||||
|
||||
Use [`sv.KeyPoints.from_ultralytics`](/latest/keypoint/core/#supervision.key_points.core.KeyPoints.from_ultralytics)
|
||||
method, which accepts [YOLOv8-pose](https://docs.ultralytics.com/models/yolov8/), [YOLO11-pose](https://docs.ultralytics.com/models/yolo11/)
|
||||
[pose](https://docs.ultralytics.com/tasks/pose/) result.
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
model = YOLO('yolo11s-pose.pt')
|
||||
|
||||
result = model(image)[0]
|
||||
key_points = sv.KeyPoints.from_ultralytics(result)
|
||||
```
|
||||
|
||||
=== "Inference"
|
||||
|
||||
Use [`sv.KeyPoints.from_inference`](/latest/keypoint/core/#supervision.key_points.core.KeyPoints.from_inference)
|
||||
method, which accepts [Inference](https://inference.roboflow.com/) pose result.
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from inference import get_model
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
model = get_model(model_id=<POSE_MODEL_ID>, api_key=<ROBOFLOW_API_KEY>)
|
||||
|
||||
result = model.infer(image)[0]
|
||||
key_points = sv.KeyPoints.from_inference(result)
|
||||
```
|
||||
|
||||
=== "MediaPipe"
|
||||
|
||||
Use [`sv.KeyPoints.from_mediapipe`](/latest/keypoint/core/#supervision.key_points.core.KeyPoints.from_mediapipe)
|
||||
method, which accepts [MediaPipe](https://github.com/google-ai-edge/mediapipe)
|
||||
pose result.
|
||||
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import mediapipe as mp
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
image_height, image_width, _ = image.shape
|
||||
mediapipe_image = mp.Image(
|
||||
image_format=mp.ImageFormat.SRGB,
|
||||
data=cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
|
||||
|
||||
options = mp.tasks.vision.PoseLandmarkerOptions(
|
||||
base_options=mp.tasks.BaseOptions(
|
||||
model_asset_path="pose_landmarker_heavy.task"
|
||||
),
|
||||
running_mode=mp.tasks.vision.RunningMode.IMAGE,
|
||||
num_poses=2)
|
||||
|
||||
PoseLandmarker = mp.tasks.vision.PoseLandmarker
|
||||
with PoseLandmarker.create_from_options(options) as landmarker:
|
||||
pose_landmarker_result = landmarker.detect(mediapipe_image)
|
||||
|
||||
key_points = sv.KeyPoints.from_mediapipe(
|
||||
pose_landmarker_result, (image_width, image_height))
|
||||
```
|
||||
|
||||
=== "Transformers"
|
||||
|
||||
Use [`sv.KeyPoints.from_transformers`](/latest/keypoint/core/#supervision.key_points.core.KeyPoints.from_transformers)
|
||||
method, which accepts [ViTPose](https://huggingface.co/docs/transformers/en/model_doc/vitpose) result.
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
import requests
|
||||
import supervision as sv
|
||||
import torch
|
||||
from transformers import (
|
||||
AutoProcessor,
|
||||
RTDetrForObjectDetection,
|
||||
VitPoseForPoseEstimation,
|
||||
)
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
image = Image.open(<SOURCE_IMAGE_PATH>)
|
||||
|
||||
DETECTION_MODEL_ID = "PekingU/rtdetr_r50vd_coco_o365"
|
||||
|
||||
detection_processor = AutoProcessor.from_pretrained(DETECTION_MODEL_ID, use_fast=True)
|
||||
detection_model = RTDetrForObjectDetection.from_pretrained(DETECTION_MODEL_ID, device_map=DEVICE)
|
||||
|
||||
inputs = detection_processor(images=frame, return_tensors="pt").to(DEVICE)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = detection_model(**inputs)
|
||||
|
||||
target_size = torch.tensor([(frame.height, frame.width)])
|
||||
results = detection_processor.post_process_object_detection(
|
||||
outputs, target_sizes=target_size, threshold=0.3)
|
||||
|
||||
detections = sv.Detections.from_transformers(results[0])
|
||||
boxes = sv.xyxy_to_xywh(detections[detections.class_id == 0].xyxy)
|
||||
|
||||
POSE_ESTIMATION_MODEL_ID = "usyd-community/vitpose-base-simple"
|
||||
|
||||
pose_estimation_processor = AutoProcessor.from_pretrained(POSE_ESTIMATION_MODEL_ID)
|
||||
pose_estimation_model = VitPoseForPoseEstimation.from_pretrained(
|
||||
POSE_ESTIMATION_MODEL_ID, device_map=DEVICE)
|
||||
|
||||
inputs = pose_estimation_processor(frame, boxes=[boxes], return_tensors="pt").to(DEVICE)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = pose_estimation_model(**inputs)
|
||||
|
||||
results = pose_estimation_processor.post_process_pose_estimation(outputs, boxes=[boxes])
|
||||
key_point = sv.KeyPoints.from_transformers(results[0])
|
||||
```
|
||||
|
||||
Attributes:
|
||||
xy (np.ndarray): An array of shape `(n, m, 2)` containing
|
||||
`n` detected objects, each composed of `m` equally-sized
|
||||
sets of key points, where each point is `[x, y]`.
|
||||
class_id (Optional[np.ndarray]): An array of shape
|
||||
`(n,)` containing the class ids of the detected objects.
|
||||
confidence (Optional[np.ndarray]): An array of shape
|
||||
`(n, m)` containing the confidence scores of each keypoint.
|
||||
data (Dict[str, Union[np.ndarray, List]]): A dictionary containing additional
|
||||
data where each key is a string representing the data type, and the value
|
||||
is either a NumPy array or a list of corresponding data of length `n`
|
||||
(one entry per detected object).
|
||||
""" # noqa: E501 // docs
|
||||
|
||||
xy: npt.NDArray[np.float32]
|
||||
class_id: npt.NDArray[np.int_] | None = None
|
||||
confidence: npt.NDArray[np.float32] | None = None
|
||||
data: dict[str, npt.NDArray[Any] | list] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_key_points_fields(
|
||||
xy=self.xy,
|
||||
confidence=self.confidence,
|
||||
class_id=self.class_id,
|
||||
data=self.data,
|
||||
)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""
|
||||
Returns the number of keypoints in the `sv.KeyPoints` object.
|
||||
"""
|
||||
return len(self.xy)
|
||||
|
||||
def __iter__(
|
||||
self,
|
||||
) -> Iterator[
|
||||
tuple[
|
||||
np.ndarray,
|
||||
np.ndarray | None,
|
||||
float | None,
|
||||
int | None,
|
||||
int | None,
|
||||
dict[str, np.ndarray | list],
|
||||
]
|
||||
]:
|
||||
"""
|
||||
Iterates over the Keypoint object and yield a tuple of
|
||||
`(xy, confidence, class_id, data)` for each object detection.
|
||||
"""
|
||||
for i in range(len(self.xy)):
|
||||
yield (
|
||||
self.xy[i],
|
||||
self.confidence[i] if self.confidence is not None else None,
|
||||
self.class_id[i] if self.class_id is not None else None,
|
||||
get_data_item(self.data, i),
|
||||
)
|
||||
|
||||
def __eq__(self, other: KeyPoints) -> bool:
|
||||
return all(
|
||||
[
|
||||
np.array_equal(self.xy, other.xy),
|
||||
np.array_equal(self.class_id, other.class_id),
|
||||
np.array_equal(self.confidence, other.confidence),
|
||||
is_data_equal(self.data, other.data),
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_inference(cls, inference_result: dict | Any) -> KeyPoints:
|
||||
"""
|
||||
Create a `sv.KeyPoints` object from the [Roboflow](https://roboflow.com/)
|
||||
API inference result or the [Inference](https://inference.roboflow.com/)
|
||||
package results.
|
||||
|
||||
Args:
|
||||
inference_result (dict, any): The result from the
|
||||
Roboflow API or Inference package containing predictions with keypoints.
|
||||
|
||||
Returns:
|
||||
A `sv.KeyPoints` object containing the keypoint coordinates, class IDs,
|
||||
and class names, and confidences of each keypoint.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from inference import get_model
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
model = get_model(model_id=<POSE_MODEL_ID>, api_key=<ROBOFLOW_API_KEY>)
|
||||
|
||||
result = model.infer(image)[0]
|
||||
key_points = sv.KeyPoints.from_inference(result)
|
||||
```
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from inference_sdk import InferenceHTTPClient
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
client = InferenceHTTPClient(
|
||||
api_url="https://detect.roboflow.com",
|
||||
api_key=<ROBOFLOW_API_KEY>
|
||||
)
|
||||
|
||||
result = client.infer(image, model_id=<POSE_MODEL_ID>)
|
||||
key_points = sv.KeyPoints.from_inference(result)
|
||||
```
|
||||
"""
|
||||
if isinstance(inference_result, list):
|
||||
raise ValueError(
|
||||
"from_inference() operates on a single result at a time."
|
||||
"You can retrieve it like so: inference_result = model.infer(image)[0]"
|
||||
)
|
||||
|
||||
if hasattr(inference_result, "dict"):
|
||||
inference_result = inference_result.dict(exclude_none=True, by_alias=True)
|
||||
elif hasattr(inference_result, "json"):
|
||||
inference_result = inference_result.json()
|
||||
if not inference_result.get("predictions"):
|
||||
return cls.empty()
|
||||
|
||||
xy = []
|
||||
confidence = []
|
||||
class_id = []
|
||||
class_names = []
|
||||
|
||||
for prediction in inference_result["predictions"]:
|
||||
prediction_xy = []
|
||||
prediction_confidence = []
|
||||
for keypoint in prediction["keypoints"]:
|
||||
prediction_xy.append([keypoint["x"], keypoint["y"]])
|
||||
prediction_confidence.append(keypoint["confidence"])
|
||||
xy.append(prediction_xy)
|
||||
confidence.append(prediction_confidence)
|
||||
|
||||
class_id.append(prediction["class_id"])
|
||||
class_names.append(prediction["class"])
|
||||
|
||||
data = {CLASS_NAME_DATA_FIELD: np.array(class_names)}
|
||||
|
||||
return cls(
|
||||
xy=np.array(xy, dtype=np.float32),
|
||||
confidence=np.array(confidence, dtype=np.float32),
|
||||
class_id=np.array(class_id, dtype=int),
|
||||
data=data,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_mediapipe(
|
||||
cls, mediapipe_results, resolution_wh: tuple[int, int]
|
||||
) -> KeyPoints:
|
||||
"""
|
||||
Creates a `sv.KeyPoints` instance from a
|
||||
[MediaPipe](https://github.com/google-ai-edge/mediapipe)
|
||||
pose landmark detection inference result.
|
||||
|
||||
Args:
|
||||
mediapipe_results (Union[PoseLandmarkerResult, FaceLandmarkerResult, SolutionOutputs]):
|
||||
The output results from Mediapipe. It support pose and face landmarks
|
||||
from `PoseLandmaker`, `FaceLandmarker` and the legacy ones
|
||||
from `Pose` and `FaceMesh`.
|
||||
resolution_wh (Tuple[int, int]): A tuple of the form `(width, height)`
|
||||
representing the resolution of the frame.
|
||||
|
||||
Returns:
|
||||
A `sv.KeyPoints` object containing the keypoint coordinates and
|
||||
confidences of each keypoint.
|
||||
|
||||
!!! tip
|
||||
Before you start, download model bundles from the
|
||||
[MediaPipe website](https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker/index#models).
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import mediapipe as mp
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
image_height, image_width, _ = image.shape
|
||||
mediapipe_image = mp.Image(
|
||||
image_format=mp.ImageFormat.SRGB,
|
||||
data=cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
|
||||
|
||||
options = mp.tasks.vision.PoseLandmarkerOptions(
|
||||
base_options=mp.tasks.BaseOptions(
|
||||
model_asset_path="pose_landmarker_heavy.task"
|
||||
),
|
||||
running_mode=mp.tasks.vision.RunningMode.IMAGE,
|
||||
num_poses=2)
|
||||
|
||||
PoseLandmarker = mp.tasks.vision.PoseLandmarker
|
||||
with PoseLandmarker.create_from_options(options) as landmarker:
|
||||
pose_landmarker_result = landmarker.detect(mediapipe_image)
|
||||
|
||||
key_points = sv.KeyPoints.from_mediapipe(
|
||||
pose_landmarker_result, (image_width, image_height))
|
||||
```
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import mediapipe as mp
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
image_height, image_width, _ = image.shape
|
||||
mediapipe_image = mp.Image(
|
||||
image_format=mp.ImageFormat.SRGB,
|
||||
data=cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
|
||||
|
||||
options = mp.tasks.vision.FaceLandmarkerOptions(
|
||||
base_options=mp.tasks.BaseOptions(
|
||||
model_asset_path="face_landmarker.task"
|
||||
),
|
||||
output_face_blendshapes=True,
|
||||
output_facial_transformation_matrixes=True,
|
||||
num_faces=2)
|
||||
|
||||
FaceLandmarker = mp.tasks.vision.FaceLandmarker
|
||||
with FaceLandmarker.create_from_options(options) as landmarker:
|
||||
face_landmarker_result = landmarker.detect(mediapipe_image)
|
||||
|
||||
key_points = sv.KeyPoints.from_mediapipe(
|
||||
face_landmarker_result, (image_width, image_height))
|
||||
```
|
||||
|
||||
""" # noqa: E501 // docs
|
||||
if hasattr(mediapipe_results, "pose_landmarks"):
|
||||
results = mediapipe_results.pose_landmarks
|
||||
if not isinstance(mediapipe_results.pose_landmarks, list):
|
||||
if mediapipe_results.pose_landmarks is None:
|
||||
results = []
|
||||
else:
|
||||
results = [
|
||||
[
|
||||
landmark
|
||||
for landmark in mediapipe_results.pose_landmarks.landmark
|
||||
]
|
||||
]
|
||||
elif hasattr(mediapipe_results, "face_landmarks"):
|
||||
results = mediapipe_results.face_landmarks
|
||||
elif hasattr(mediapipe_results, "multi_face_landmarks"):
|
||||
if mediapipe_results.multi_face_landmarks is None:
|
||||
results = []
|
||||
else:
|
||||
results = [
|
||||
face_landmark.landmark
|
||||
for face_landmark in mediapipe_results.multi_face_landmarks
|
||||
]
|
||||
|
||||
if len(results) == 0:
|
||||
return cls.empty()
|
||||
|
||||
xy = []
|
||||
confidence = []
|
||||
for pose in results:
|
||||
prediction_xy = []
|
||||
prediction_confidence = []
|
||||
for landmark in pose:
|
||||
keypoint_xy = [
|
||||
landmark.x * resolution_wh[0],
|
||||
landmark.y * resolution_wh[1],
|
||||
]
|
||||
prediction_xy.append(keypoint_xy)
|
||||
prediction_confidence.append(landmark.visibility)
|
||||
|
||||
xy.append(prediction_xy)
|
||||
confidence.append(prediction_confidence)
|
||||
|
||||
return cls(
|
||||
xy=np.array(xy, dtype=np.float32),
|
||||
confidence=np.array(confidence, dtype=np.float32),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_ultralytics(cls, ultralytics_results) -> KeyPoints:
|
||||
"""
|
||||
Creates a `sv.KeyPoints` instance from a
|
||||
[YOLOv8](https://github.com/ultralytics/ultralytics) pose inference result.
|
||||
|
||||
Args:
|
||||
ultralytics_results (ultralytics.engine.results.Keypoints):
|
||||
The output Results instance from YOLOv8
|
||||
|
||||
Returns:
|
||||
A `sv.KeyPoints` object containing the keypoint coordinates, class IDs,
|
||||
and class names, and confidences of each keypoint.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
model = YOLO('yolov8s-pose.pt')
|
||||
|
||||
result = model(image)[0]
|
||||
key_points = sv.KeyPoints.from_ultralytics(result)
|
||||
```
|
||||
"""
|
||||
if ultralytics_results.keypoints.xy.numel() == 0:
|
||||
return cls.empty()
|
||||
|
||||
xy = ultralytics_results.keypoints.xy.cpu().numpy()
|
||||
class_id = ultralytics_results.boxes.cls.cpu().numpy().astype(int)
|
||||
class_names = np.array([ultralytics_results.names[i] for i in class_id])
|
||||
|
||||
confidence = ultralytics_results.keypoints.conf.cpu().numpy()
|
||||
data = {CLASS_NAME_DATA_FIELD: class_names}
|
||||
return cls(xy, class_id, confidence, data)
|
||||
|
||||
@classmethod
|
||||
def from_yolo_nas(cls, yolo_nas_results) -> KeyPoints:
|
||||
"""
|
||||
Create a `sv.KeyPoints` instance from a [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS-POSE.md)
|
||||
pose inference results.
|
||||
|
||||
Args:
|
||||
yolo_nas_results (ImagePoseEstimationPrediction): The output object from
|
||||
YOLO NAS.
|
||||
|
||||
Returns:
|
||||
A `sv.KeyPoints` object containing the keypoint coordinates, class IDs,
|
||||
and class names, and confidences of each keypoint.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import torch
|
||||
import supervision as sv
|
||||
import super_gradients
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
model = super_gradients.training.models.get(
|
||||
"yolo_nas_pose_s", pretrained_weights="coco_pose").to(device)
|
||||
|
||||
results = model.predict(image, conf=0.1)
|
||||
key_points = sv.KeyPoints.from_yolo_nas(results)
|
||||
```
|
||||
"""
|
||||
if len(yolo_nas_results.prediction.poses) == 0:
|
||||
return cls.empty()
|
||||
|
||||
xy = yolo_nas_results.prediction.poses[:, :, :2]
|
||||
confidence = yolo_nas_results.prediction.poses[:, :, 2]
|
||||
|
||||
# yolo_nas_results treats params differently.
|
||||
# prediction.labels may not exist, whereas class_names might be None
|
||||
if hasattr(yolo_nas_results.prediction, "labels"):
|
||||
class_id = yolo_nas_results.prediction.labels # np.array[int]
|
||||
else:
|
||||
class_id = None
|
||||
|
||||
data = {}
|
||||
if class_id is not None and yolo_nas_results.class_names is not None:
|
||||
class_names = []
|
||||
for c_id in class_id:
|
||||
name = yolo_nas_results.class_names[c_id] # tuple[str]
|
||||
class_names.append(name)
|
||||
data[CLASS_NAME_DATA_FIELD] = class_names
|
||||
|
||||
return cls(
|
||||
xy=xy,
|
||||
confidence=confidence,
|
||||
class_id=class_id,
|
||||
data=data,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_detectron2(cls, detectron2_results: Any) -> KeyPoints:
|
||||
"""
|
||||
Create a `sv.KeyPoints` object from the
|
||||
[Detectron2](https://github.com/facebookresearch/detectron2) inference result.
|
||||
|
||||
Args:
|
||||
detectron2_results (Any): The output of a
|
||||
Detectron2 model containing instances with prediction data.
|
||||
|
||||
Returns:
|
||||
A `sv.KeyPoints` object containing the keypoint coordinates, class IDs,
|
||||
and class names, and confidences of each keypoint.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from detectron2.engine import DefaultPredictor
|
||||
from detectron2.config import get_cfg
|
||||
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
cfg = get_cfg()
|
||||
cfg.merge_from_file(<CONFIG_PATH>)
|
||||
cfg.MODEL.WEIGHTS = <WEIGHTS_PATH>
|
||||
predictor = DefaultPredictor(cfg)
|
||||
|
||||
result = predictor(image)
|
||||
keypoints = sv.KeyPoints.from_detectron2(result)
|
||||
```
|
||||
"""
|
||||
|
||||
if hasattr(detectron2_results["instances"], "pred_keypoints"):
|
||||
if detectron2_results["instances"].pred_keypoints.cpu().numpy().size == 0:
|
||||
return cls.empty()
|
||||
return cls(
|
||||
xy=detectron2_results["instances"]
|
||||
.pred_keypoints.cpu()
|
||||
.numpy()[:, :, :2],
|
||||
confidence=detectron2_results["instances"]
|
||||
.pred_keypoints.cpu()
|
||||
.numpy()[:, :, 2],
|
||||
class_id=detectron2_results["instances"]
|
||||
.pred_classes.cpu()
|
||||
.numpy()
|
||||
.astype(int),
|
||||
)
|
||||
else:
|
||||
return cls.empty()
|
||||
|
||||
@classmethod
|
||||
def from_transformers(cls, transformers_results: Any) -> KeyPoints:
|
||||
"""
|
||||
Create a `sv.KeyPoints` object from the
|
||||
[Transformers](https://github.com/huggingface/transformers) inference result.
|
||||
|
||||
Args:
|
||||
transformers_results (Any): The output of a
|
||||
Transformers model containing instances with prediction data.
|
||||
|
||||
Returns:
|
||||
A `sv.KeyPoints` object containing the keypoint coordinates, class IDs,
|
||||
and class names, and confidences of each keypoint.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from PIL import Image
|
||||
import requests
|
||||
import supervision as sv
|
||||
import torch
|
||||
from transformers import (
|
||||
AutoProcessor,
|
||||
RTDetrForObjectDetection,
|
||||
VitPoseForPoseEstimation,
|
||||
)
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
image = Image.open(<SOURCE_IMAGE_PATH>)
|
||||
|
||||
DETECTION_MODEL_ID = "PekingU/rtdetr_r50vd_coco_o365"
|
||||
|
||||
detection_processor = AutoProcessor.from_pretrained(DETECTION_MODEL_ID, use_fast=True)
|
||||
detection_model = RTDetrForObjectDetection.from_pretrained(DETECTION_MODEL_ID, device_map=device)
|
||||
|
||||
inputs = detection_processor(images=frame, return_tensors="pt").to(device)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = detection_model(**inputs)
|
||||
|
||||
target_size = torch.tensor([(frame.height, frame.width)])
|
||||
results = detection_processor.post_process_object_detection(
|
||||
outputs, target_sizes=target_size, threshold=0.3)
|
||||
|
||||
detections = sv.Detections.from_transformers(results[0])
|
||||
boxes = sv.xyxy_to_xywh(detections[detections.class_id == 0].xyxy)
|
||||
|
||||
POSE_ESTIMATION_MODEL_ID = "usyd-community/vitpose-base-simple"
|
||||
|
||||
pose_estimation_processor = AutoProcessor.from_pretrained(POSE_ESTIMATION_MODEL_ID)
|
||||
pose_estimation_model = VitPoseForPoseEstimation.from_pretrained(
|
||||
POSE_ESTIMATION_MODEL_ID, device_map=device)
|
||||
|
||||
inputs = pose_estimation_processor(frame, boxes=[boxes], return_tensors="pt").to(device)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = pose_estimation_model(**inputs)
|
||||
|
||||
results = pose_estimation_processor.post_process_pose_estimation(outputs, boxes=[boxes])
|
||||
key_point = sv.KeyPoints.from_transformers(results[0])
|
||||
```
|
||||
|
||||
""" # noqa: E501 // docs
|
||||
|
||||
if "keypoints" in transformers_results[0]:
|
||||
if transformers_results[0]["keypoints"].cpu().numpy().size == 0:
|
||||
return cls.empty()
|
||||
|
||||
result_data = [
|
||||
(
|
||||
result["keypoints"].cpu().numpy(),
|
||||
result["scores"].cpu().numpy(),
|
||||
)
|
||||
for result in transformers_results
|
||||
]
|
||||
|
||||
xy, scores = zip(*result_data)
|
||||
|
||||
return cls(
|
||||
xy=np.stack(xy).astype(np.float32),
|
||||
confidence=np.stack(scores).astype(np.float32),
|
||||
class_id=np.arange(len(xy)).astype(int),
|
||||
)
|
||||
else:
|
||||
return cls.empty()
|
||||
|
||||
def __getitem__(
|
||||
self, index: int | slice | list[int] | np.ndarray | tuple | str
|
||||
) -> KeyPoints | np.ndarray | list | None:
|
||||
if isinstance(index, str):
|
||||
return self.data.get(index)
|
||||
|
||||
if not isinstance(index, tuple):
|
||||
index = (index, slice(None))
|
||||
|
||||
i, j = index
|
||||
|
||||
if isinstance(i, int):
|
||||
i = [i]
|
||||
|
||||
if isinstance(i, list) and all(isinstance(x, bool) for x in i):
|
||||
i = np.array(i)
|
||||
if isinstance(j, list) and all(isinstance(x, bool) for x in j):
|
||||
j = np.array(j)
|
||||
|
||||
if isinstance(i, np.ndarray) and i.dtype == bool:
|
||||
i = np.flatnonzero(i)
|
||||
if isinstance(j, np.ndarray) and j.dtype == bool:
|
||||
j = np.flatnonzero(j)
|
||||
|
||||
if (
|
||||
isinstance(i, (list, np.ndarray))
|
||||
and isinstance(j, (list, np.ndarray))
|
||||
and not np.isscalar(i)
|
||||
and not np.isscalar(j)
|
||||
):
|
||||
i, j = np.ix_(i, j)
|
||||
|
||||
xy_selected = self.xy[i, j]
|
||||
|
||||
conf_selected = self.confidence[i, j] if self.confidence is not None else None
|
||||
|
||||
class_id_selected = self.class_id[i] if self.class_id is not None else None
|
||||
|
||||
data_selected = get_data_item(self.data, i)
|
||||
|
||||
if xy_selected.ndim == 1:
|
||||
xy_selected = xy_selected.reshape(1, 1, 2)
|
||||
if conf_selected is not None:
|
||||
conf_selected = conf_selected.reshape(1, 1)
|
||||
elif xy_selected.ndim == 2:
|
||||
if np.isscalar(index[0]) or (
|
||||
isinstance(index[0], np.ndarray) and index[0].ndim == 0
|
||||
):
|
||||
xy_selected = xy_selected[np.newaxis, ...]
|
||||
if conf_selected is not None:
|
||||
conf_selected = conf_selected[np.newaxis, ...]
|
||||
elif np.isscalar(index[1]) or (
|
||||
isinstance(index[1], np.ndarray) and index[1].ndim == 0
|
||||
):
|
||||
xy_selected = xy_selected[:, np.newaxis, :]
|
||||
if conf_selected is not None:
|
||||
conf_selected = conf_selected[:, np.newaxis]
|
||||
|
||||
return KeyPoints(
|
||||
xy=xy_selected,
|
||||
confidence=conf_selected,
|
||||
class_id=class_id_selected,
|
||||
data=data_selected,
|
||||
)
|
||||
|
||||
def __setitem__(self, key: str, value: np.ndarray | list):
|
||||
"""
|
||||
Set a value in the data dictionary of the `sv.KeyPoints` object.
|
||||
|
||||
Args:
|
||||
key (str): The key in the data dictionary to set.
|
||||
value (Union[np.ndarray, List]): The value to set for the key.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
model = YOLO('yolov8s.pt')
|
||||
|
||||
result = model(image)[0]
|
||||
key_points = sv.KeyPoints.from_ultralytics(result)
|
||||
|
||||
key_points['class_name'] = [
|
||||
model.model.names[class_id]
|
||||
for class_id
|
||||
in key_points.class_id
|
||||
]
|
||||
```
|
||||
"""
|
||||
if not isinstance(value, (np.ndarray, list)):
|
||||
raise TypeError("Value must be a np.ndarray or a list")
|
||||
|
||||
if isinstance(value, list):
|
||||
value = np.array(value)
|
||||
|
||||
self.data[key] = value
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> KeyPoints:
|
||||
"""
|
||||
Create an empty KeyPoints object with no key points.
|
||||
|
||||
Returns:
|
||||
An empty `sv.KeyPoints` object.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
key_points = sv.KeyPoints.empty()
|
||||
```
|
||||
"""
|
||||
return cls(xy=np.empty((0, 0, 2), dtype=np.float32))
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""
|
||||
Returns `True` if the `KeyPoints` object is considered empty.
|
||||
"""
|
||||
empty_key_points = KeyPoints.empty()
|
||||
empty_key_points.data = self.data
|
||||
return self == empty_key_points
|
||||
|
||||
def as_detections(
|
||||
self, selected_keypoint_indices: Iterable[int] | None = None
|
||||
) -> Detections:
|
||||
"""
|
||||
Convert a KeyPoints object to a Detections object. This
|
||||
approximates the bounding box of the detected object by
|
||||
taking the bounding box that fits all key points.
|
||||
|
||||
Arguments:
|
||||
selected_keypoint_indices (Optional[Iterable[int]]): The
|
||||
indices of the key points to include in the bounding box
|
||||
calculation. This helps focus on a subset of key points,
|
||||
e.g. when some are occluded. Captures all key points by default.
|
||||
|
||||
Returns:
|
||||
detections (Detections): The converted detections object.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
key_points = sv.KeyPoints.from_inference(...)
|
||||
detections = key_points.as_detections()
|
||||
```
|
||||
"""
|
||||
if self.is_empty():
|
||||
return Detections.empty()
|
||||
|
||||
detections_list = []
|
||||
for i, xy in enumerate(self.xy):
|
||||
if selected_keypoint_indices:
|
||||
xy = xy[selected_keypoint_indices]
|
||||
|
||||
# [0, 0] used by some frameworks to indicate missing keypoints
|
||||
xy = xy[~np.all(xy == 0, axis=1)]
|
||||
if len(xy) == 0:
|
||||
xyxy = np.array([[0, 0, 0, 0]], dtype=np.float32)
|
||||
else:
|
||||
x_min = xy[:, 0].min()
|
||||
x_max = xy[:, 0].max()
|
||||
y_min = xy[:, 1].min()
|
||||
y_max = xy[:, 1].max()
|
||||
xyxy = np.array([[x_min, y_min, x_max, y_max]], dtype=np.float32)
|
||||
|
||||
if self.confidence is None:
|
||||
confidence = None
|
||||
else:
|
||||
confidence = self.confidence[i]
|
||||
if selected_keypoint_indices:
|
||||
confidence = confidence[selected_keypoint_indices]
|
||||
confidence = np.array([confidence.mean()], dtype=np.float32)
|
||||
|
||||
detections_list.append(
|
||||
Detections(
|
||||
xyxy=xyxy,
|
||||
confidence=confidence,
|
||||
)
|
||||
)
|
||||
|
||||
detections = Detections.merge(detections_list)
|
||||
detections.class_id = self.class_id
|
||||
detections.data = self.data
|
||||
detections = detections[detections.area > 0]
|
||||
|
||||
return detections
|
||||
@@ -0,0 +1,15 @@
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"The 'supervision.keypoint' module is deprecated in `0.27.0` and will be removed "
|
||||
"in `0.30.0`. Please use 'supervision.key_points' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
from supervision.key_points.annotators import (
|
||||
EdgeAnnotator,
|
||||
VertexAnnotator,
|
||||
VertexLabelAnnotator,
|
||||
)
|
||||
from supervision.key_points.core import KeyPoints
|
||||
|
||||
@@ -1,443 +1,15 @@
|
||||
from __future__ import annotations
|
||||
import warnings
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from logging import warn
|
||||
warnings.warn(
|
||||
"The 'supervision.keypoint' module is deprecated in `0.27.0` and will be removed "
|
||||
"in `0.30.0`. Please use 'supervision.key_points' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from supervision.annotators.base import ImageType
|
||||
from supervision.detection.utils.boxes import pad_boxes, spread_out_boxes
|
||||
from supervision.draw.color import Color
|
||||
from supervision.draw.utils import draw_rounded_rectangle
|
||||
from supervision.geometry.core import Rect
|
||||
from supervision.keypoint.core import KeyPoints
|
||||
from supervision.keypoint.skeletons import SKELETONS_BY_VERTEX_COUNT
|
||||
from supervision.utils.conversion import ensure_cv2_image_for_annotation
|
||||
|
||||
|
||||
class BaseKeyPointAnnotator(ABC):
|
||||
@abstractmethod
|
||||
def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
|
||||
pass
|
||||
|
||||
|
||||
class VertexAnnotator(BaseKeyPointAnnotator):
|
||||
"""
|
||||
A class that specializes in drawing skeleton vertices on images. It uses
|
||||
specified key points to determine the locations where the vertices should be
|
||||
drawn.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
color: Color = Color.ROBOFLOW,
|
||||
radius: int = 4,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
color (Color): The color to use for annotating key points.
|
||||
radius (int): The radius of the circles used to represent the key
|
||||
points.
|
||||
"""
|
||||
self.color = color
|
||||
self.radius = radius
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
|
||||
"""
|
||||
Annotates the given scene with skeleton vertices based on the provided key
|
||||
points. It draws circles at each key point location.
|
||||
|
||||
Args:
|
||||
scene (ImageType): The image where skeleton vertices will be drawn.
|
||||
`ImageType` is a flexible type, accepting either `numpy.ndarray` or
|
||||
`PIL.Image.Image`.
|
||||
key_points (KeyPoints): A collection of key points where each key point
|
||||
consists of x and y coordinates.
|
||||
|
||||
Returns:
|
||||
The annotated image, matching the type of `scene` (`numpy.ndarray`
|
||||
or `PIL.Image.Image`)
|
||||
|
||||
Example:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
key_points = sv.KeyPoints(...)
|
||||
|
||||
vertex_annotator = sv.VertexAnnotator(
|
||||
color=sv.Color.GREEN,
|
||||
radius=10
|
||||
)
|
||||
annotated_frame = vertex_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
key_points=key_points
|
||||
)
|
||||
```
|
||||
|
||||

|
||||
"""
|
||||
assert isinstance(scene, np.ndarray)
|
||||
if len(key_points) == 0:
|
||||
return scene
|
||||
|
||||
for xy in key_points.xy:
|
||||
for x, y in xy:
|
||||
cv2.circle(
|
||||
img=scene,
|
||||
center=(int(x), int(y)),
|
||||
radius=self.radius,
|
||||
color=self.color.as_bgr(),
|
||||
thickness=-1,
|
||||
)
|
||||
|
||||
return scene
|
||||
|
||||
|
||||
class EdgeAnnotator(BaseKeyPointAnnotator):
|
||||
"""
|
||||
A class that specializes in drawing skeleton edges on images using specified key
|
||||
points. It connects key points with lines to form the skeleton structure.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
color: Color = Color.ROBOFLOW,
|
||||
thickness: int = 2,
|
||||
edges: list[tuple[int, int]] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
color (Color): The color to use for the edges.
|
||||
thickness (int): The thickness of the edges.
|
||||
edges (Optional[List[Tuple[int, int]]]): The edges to draw.
|
||||
If set to `None`, will attempt to select automatically.
|
||||
"""
|
||||
self.color = color
|
||||
self.thickness = thickness
|
||||
self.edges = edges
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
|
||||
"""
|
||||
Annotates the given scene by drawing lines between specified key points to form
|
||||
edges.
|
||||
|
||||
Args:
|
||||
scene (ImageType): The image where skeleton edges will be drawn. `ImageType`
|
||||
is a flexible type, accepting either `numpy.ndarray` or
|
||||
`PIL.Image.Image`.
|
||||
key_points (KeyPoints): A collection of key points where each key point
|
||||
consists of x and y coordinates.
|
||||
|
||||
Returns:
|
||||
Returns:
|
||||
The annotated image, matching the type of `scene` (`numpy.ndarray`
|
||||
or `PIL.Image.Image`)
|
||||
|
||||
Example:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
key_points = sv.KeyPoints(...)
|
||||
|
||||
edge_annotator = sv.EdgeAnnotator(
|
||||
color=sv.Color.GREEN,
|
||||
thickness=5
|
||||
)
|
||||
annotated_frame = edge_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
key_points=key_points
|
||||
)
|
||||
```
|
||||
|
||||

|
||||
"""
|
||||
assert isinstance(scene, np.ndarray)
|
||||
if len(key_points) == 0:
|
||||
return scene
|
||||
|
||||
for xy in key_points.xy:
|
||||
edges = self.edges
|
||||
if not edges:
|
||||
edges = SKELETONS_BY_VERTEX_COUNT.get(len(xy))
|
||||
if not edges:
|
||||
warn(f"No skeleton found with {len(xy)} vertices")
|
||||
return scene
|
||||
|
||||
for class_a, class_b in edges:
|
||||
xy_a = xy[class_a - 1]
|
||||
xy_b = xy[class_b - 1]
|
||||
missing_a = np.allclose(xy_a, 0)
|
||||
missing_b = np.allclose(xy_b, 0)
|
||||
if missing_a or missing_b:
|
||||
continue
|
||||
|
||||
cv2.line(
|
||||
img=scene,
|
||||
pt1=(int(xy_a[0]), int(xy_a[1])),
|
||||
pt2=(int(xy_b[0]), int(xy_b[1])),
|
||||
color=self.color.as_bgr(),
|
||||
thickness=self.thickness,
|
||||
)
|
||||
|
||||
return scene
|
||||
|
||||
|
||||
class VertexLabelAnnotator:
|
||||
"""
|
||||
A class that draws labels of skeleton vertices on images. It uses specified key
|
||||
points to determine the locations where the vertices should be drawn.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
color: Color | list[Color] = Color.ROBOFLOW,
|
||||
text_color: Color | list[Color] = Color.WHITE,
|
||||
text_scale: float = 0.5,
|
||||
text_thickness: int = 1,
|
||||
text_padding: int = 10,
|
||||
border_radius: int = 0,
|
||||
smart_position: bool = False,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
color (Union[Color, List[Color]]): The color to use for each
|
||||
keypoint label. If a list is provided, the colors will be used in order
|
||||
for each keypoint.
|
||||
text_color (Union[Color, List[Color]]): The color to use
|
||||
for the labels. If a list is provided, the colors will be used in order
|
||||
for each keypoint.
|
||||
text_scale (float): The scale of the text.
|
||||
text_thickness (int): The thickness of the text.
|
||||
text_padding (int): The padding around the text.
|
||||
border_radius (int): The radius of the rounded corners of the
|
||||
boxes. Set to a high value to produce circles.
|
||||
smart_position (bool): Spread out the labels to avoid overlap.
|
||||
"""
|
||||
self.border_radius: int = border_radius
|
||||
self.color: Color | list[Color] = color
|
||||
self.text_color: Color | list[Color] = text_color
|
||||
self.text_scale: float = text_scale
|
||||
self.text_thickness: int = text_thickness
|
||||
self.text_padding: int = text_padding
|
||||
self.smart_position = smart_position
|
||||
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
key_points: KeyPoints,
|
||||
labels: list[str] | None = None,
|
||||
) -> ImageType:
|
||||
"""
|
||||
A class that draws labels of skeleton vertices on images. It uses specified key
|
||||
points to determine the locations where the vertices should be drawn.
|
||||
|
||||
Args:
|
||||
scene (ImageType): The image where vertex labels will be drawn. `ImageType`
|
||||
is a flexible type, accepting either `numpy.ndarray` or
|
||||
`PIL.Image.Image`.
|
||||
key_points (KeyPoints): A collection of key points where each key point
|
||||
consists of x and y coordinates.
|
||||
labels (Optional[List[str]]): A list of labels to be displayed on the
|
||||
annotated image. If not provided, keypoint indices will be used.
|
||||
|
||||
Returns:
|
||||
The annotated image, matching the type of `scene` (`numpy.ndarray`
|
||||
or `PIL.Image.Image`)
|
||||
|
||||
Example:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
key_points = sv.KeyPoints(...)
|
||||
|
||||
vertex_label_annotator = sv.VertexLabelAnnotator(
|
||||
color=sv.Color.GREEN,
|
||||
text_color=sv.Color.BLACK,
|
||||
border_radius=5
|
||||
)
|
||||
annotated_frame = vertex_label_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
key_points=key_points
|
||||
)
|
||||
```
|
||||
|
||||

|
||||
|
||||
!!! tip
|
||||
|
||||
`VertexLabelAnnotator` allows to customize the color of each keypoint label
|
||||
values.
|
||||
|
||||
Example:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
key_points = sv.KeyPoints(...)
|
||||
|
||||
LABELS = [
|
||||
"nose", "left eye", "right eye", "left ear",
|
||||
"right ear", "left shoulder", "right shoulder", "left elbow",
|
||||
"right elbow", "left wrist", "right wrist", "left hip",
|
||||
"right hip", "left knee", "right knee", "left ankle",
|
||||
"right ankle"
|
||||
]
|
||||
|
||||
COLORS = [
|
||||
"#FF6347", "#FF6347", "#FF6347", "#FF6347",
|
||||
"#FF6347", "#FF1493", "#00FF00", "#FF1493",
|
||||
"#00FF00", "#FF1493", "#00FF00", "#FFD700",
|
||||
"#00BFFF", "#FFD700", "#00BFFF", "#FFD700",
|
||||
"#00BFFF"
|
||||
]
|
||||
COLORS = [sv.Color.from_hex(color_hex=c) for c in COLORS]
|
||||
|
||||
vertex_label_annotator = sv.VertexLabelAnnotator(
|
||||
color=COLORS,
|
||||
text_color=sv.Color.BLACK,
|
||||
border_radius=5
|
||||
)
|
||||
annotated_frame = vertex_label_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
key_points=key_points,
|
||||
labels=labels
|
||||
)
|
||||
```
|
||||

|
||||
"""
|
||||
assert isinstance(scene, np.ndarray)
|
||||
font = cv2.FONT_HERSHEY_SIMPLEX
|
||||
|
||||
skeletons_count, points_count, _ = key_points.xy.shape
|
||||
if skeletons_count == 0:
|
||||
return scene
|
||||
|
||||
anchors = key_points.xy.reshape(points_count * skeletons_count, 2).astype(int)
|
||||
mask = np.all(anchors != 0, axis=1)
|
||||
|
||||
if not np.any(mask):
|
||||
return scene
|
||||
|
||||
colors = self.preprocess_and_validate_colors(
|
||||
colors=self.color,
|
||||
points_count=points_count,
|
||||
skeletons_count=skeletons_count,
|
||||
)
|
||||
|
||||
text_colors = self.preprocess_and_validate_colors(
|
||||
colors=self.text_color,
|
||||
points_count=points_count,
|
||||
skeletons_count=skeletons_count,
|
||||
)
|
||||
|
||||
labels = self.preprocess_and_validate_labels(
|
||||
labels=labels, points_count=points_count, skeletons_count=skeletons_count
|
||||
)
|
||||
|
||||
anchors = anchors[mask]
|
||||
colors = colors[mask]
|
||||
text_colors = text_colors[mask]
|
||||
labels = labels[mask]
|
||||
|
||||
xyxy = np.array(
|
||||
[
|
||||
self.get_text_bounding_box(
|
||||
text=label,
|
||||
font=font,
|
||||
text_scale=self.text_scale,
|
||||
text_thickness=self.text_thickness,
|
||||
center_coordinates=tuple(anchor),
|
||||
)
|
||||
for anchor, label in zip(anchors, labels)
|
||||
]
|
||||
)
|
||||
xyxy_padded = pad_boxes(xyxy=xyxy, px=self.text_padding)
|
||||
|
||||
if self.smart_position:
|
||||
xyxy_padded = spread_out_boxes(xyxy_padded)
|
||||
xyxy = pad_boxes(xyxy=xyxy_padded, px=-self.text_padding)
|
||||
|
||||
for text, color, text_color, box, box_padded in zip(
|
||||
labels, colors, text_colors, xyxy, xyxy_padded
|
||||
):
|
||||
draw_rounded_rectangle(
|
||||
scene=scene,
|
||||
rect=Rect.from_xyxy(box_padded),
|
||||
color=color,
|
||||
border_radius=self.border_radius,
|
||||
)
|
||||
cv2.putText(
|
||||
img=scene,
|
||||
text=text,
|
||||
org=(box[0], box[3]),
|
||||
fontFace=font,
|
||||
fontScale=self.text_scale,
|
||||
color=text_color.as_bgr(),
|
||||
thickness=self.text_thickness,
|
||||
lineType=cv2.LINE_AA,
|
||||
)
|
||||
|
||||
return scene
|
||||
|
||||
@staticmethod
|
||||
def get_text_bounding_box(
|
||||
text: str,
|
||||
font: int,
|
||||
text_scale: float,
|
||||
text_thickness: int,
|
||||
center_coordinates: tuple[int, int],
|
||||
) -> tuple[int, int, int, int]:
|
||||
text_w, text_h = cv2.getTextSize(
|
||||
text=text,
|
||||
fontFace=font,
|
||||
fontScale=text_scale,
|
||||
thickness=text_thickness,
|
||||
)[0]
|
||||
center_x, center_y = center_coordinates
|
||||
return (
|
||||
center_x - text_w // 2,
|
||||
center_y - text_h // 2,
|
||||
center_x + text_w // 2,
|
||||
center_y + text_h // 2,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def preprocess_and_validate_labels(
|
||||
labels: list[str] | None, points_count: int, skeletons_count: int
|
||||
) -> np.ndarray:
|
||||
if labels and len(labels) != points_count:
|
||||
raise ValueError(
|
||||
f"Number of labels ({len(labels)}) must match number of key points "
|
||||
f"({points_count})."
|
||||
)
|
||||
if labels is None:
|
||||
labels = [str(i) for i in range(points_count)]
|
||||
|
||||
return np.array(labels * skeletons_count)
|
||||
|
||||
@staticmethod
|
||||
def preprocess_and_validate_colors(
|
||||
colors: Color | list[Color] | None,
|
||||
points_count: int,
|
||||
skeletons_count: int,
|
||||
) -> np.ndarray:
|
||||
if isinstance(colors, list) and len(colors) != points_count:
|
||||
raise ValueError(
|
||||
f"Number of colors ({len(colors)}) must match number of key points "
|
||||
f"({points_count})."
|
||||
)
|
||||
return (
|
||||
np.array(colors * skeletons_count)
|
||||
if isinstance(colors, list)
|
||||
else np.array([colors] * points_count * skeletons_count)
|
||||
)
|
||||
from supervision.key_points.annotators import ( # noqa: F401, E402
|
||||
BaseKeyPointAnnotator,
|
||||
EdgeAnnotator,
|
||||
VertexAnnotator,
|
||||
VertexLabelAnnotator,
|
||||
)
|
||||
|
||||
@@ -1,775 +1,10 @@
|
||||
from __future__ import annotations
|
||||
import warnings
|
||||
|
||||
from collections.abc import Iterable, Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
warnings.warn(
|
||||
"The 'supervision.keypoint' module is deprecated in `0.27.0` and will be removed "
|
||||
"in `0.30.0`. Please use 'supervision.key_points' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from supervision.config import CLASS_NAME_DATA_FIELD
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.detection.utils.internal import get_data_item, is_data_equal
|
||||
from supervision.validators import validate_keypoints_fields
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyPoints:
|
||||
"""
|
||||
The `sv.KeyPoints` class in the Supervision library standardizes results from
|
||||
various keypoint detection and pose estimation models into a consistent format. This
|
||||
class simplifies data manipulation and filtering, providing a uniform API for
|
||||
integration with Supervision [keypoints annotators](/latest/keypoint/annotators).
|
||||
|
||||
=== "Ultralytics"
|
||||
|
||||
Use [`sv.KeyPoints.from_ultralytics`](/latest/keypoint/core/#supervision.keypoint.core.KeyPoints.from_ultralytics)
|
||||
method, which accepts [YOLOv8-pose](https://docs.ultralytics.com/models/yolov8/), [YOLO11-pose](https://docs.ultralytics.com/models/yolo11/)
|
||||
[pose](https://docs.ultralytics.com/tasks/pose/) result.
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
model = YOLO('yolo11s-pose.pt')
|
||||
|
||||
result = model(image)[0]
|
||||
key_points = sv.KeyPoints.from_ultralytics(result)
|
||||
```
|
||||
|
||||
=== "Inference"
|
||||
|
||||
Use [`sv.KeyPoints.from_inference`](/latest/keypoint/core/#supervision.keypoint.core.KeyPoints.from_inference)
|
||||
method, which accepts [Inference](https://inference.roboflow.com/) pose result.
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from inference import get_model
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
model = get_model(model_id=<POSE_MODEL_ID>, api_key=<ROBOFLOW_API_KEY>)
|
||||
|
||||
result = model.infer(image)[0]
|
||||
key_points = sv.KeyPoints.from_inference(result)
|
||||
```
|
||||
|
||||
=== "MediaPipe"
|
||||
|
||||
Use [`sv.KeyPoints.from_mediapipe`](/latest/keypoint/core/#supervision.keypoint.core.KeyPoints.from_mediapipe)
|
||||
method, which accepts [MediaPipe](https://github.com/google-ai-edge/mediapipe)
|
||||
pose result.
|
||||
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import mediapipe as mp
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
image_height, image_width, _ = image.shape
|
||||
mediapipe_image = mp.Image(
|
||||
image_format=mp.ImageFormat.SRGB,
|
||||
data=cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
|
||||
|
||||
options = mp.tasks.vision.PoseLandmarkerOptions(
|
||||
base_options=mp.tasks.BaseOptions(
|
||||
model_asset_path="pose_landmarker_heavy.task"
|
||||
),
|
||||
running_mode=mp.tasks.vision.RunningMode.IMAGE,
|
||||
num_poses=2)
|
||||
|
||||
PoseLandmarker = mp.tasks.vision.PoseLandmarker
|
||||
with PoseLandmarker.create_from_options(options) as landmarker:
|
||||
pose_landmarker_result = landmarker.detect(mediapipe_image)
|
||||
|
||||
key_points = sv.KeyPoints.from_mediapipe(
|
||||
pose_landmarker_result, (image_width, image_height))
|
||||
```
|
||||
|
||||
Attributes:
|
||||
xy (np.ndarray): An array of shape `(n, m, 2)` containing
|
||||
`n` detected objects, each composed of `m` equally-sized
|
||||
sets of keypoints, where each point is `[x, y]`.
|
||||
class_id (Optional[np.ndarray]): An array of shape
|
||||
`(n,)` containing the class ids of the detected objects.
|
||||
confidence (Optional[np.ndarray]): An array of shape
|
||||
`(n, m)` containing the confidence scores of each keypoint.
|
||||
data (Dict[str, Union[np.ndarray, List]]): A dictionary containing additional
|
||||
data where each key is a string representing the data type, and the value
|
||||
is either a NumPy array or a list of corresponding data of length `n`
|
||||
(one entry per detected object).
|
||||
""" # noqa: E501 // docs
|
||||
|
||||
xy: npt.NDArray[np.float32]
|
||||
class_id: npt.NDArray[np.int_] | None = None
|
||||
confidence: npt.NDArray[np.float32] | None = None
|
||||
data: dict[str, npt.NDArray[Any] | list] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_keypoints_fields(
|
||||
xy=self.xy,
|
||||
confidence=self.confidence,
|
||||
class_id=self.class_id,
|
||||
data=self.data,
|
||||
)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""
|
||||
Returns the number of keypoints in the `sv.KeyPoints` object.
|
||||
"""
|
||||
return len(self.xy)
|
||||
|
||||
def __iter__(
|
||||
self,
|
||||
) -> Iterator[
|
||||
tuple[
|
||||
np.ndarray,
|
||||
np.ndarray | None,
|
||||
float | None,
|
||||
int | None,
|
||||
int | None,
|
||||
dict[str, np.ndarray | list],
|
||||
]
|
||||
]:
|
||||
"""
|
||||
Iterates over the Keypoint object and yield a tuple of
|
||||
`(xy, confidence, class_id, data)` for each object detection.
|
||||
"""
|
||||
for i in range(len(self.xy)):
|
||||
yield (
|
||||
self.xy[i],
|
||||
self.confidence[i] if self.confidence is not None else None,
|
||||
self.class_id[i] if self.class_id is not None else None,
|
||||
get_data_item(self.data, i),
|
||||
)
|
||||
|
||||
def __eq__(self, other: KeyPoints) -> bool:
|
||||
return all(
|
||||
[
|
||||
np.array_equal(self.xy, other.xy),
|
||||
np.array_equal(self.class_id, other.class_id),
|
||||
np.array_equal(self.confidence, other.confidence),
|
||||
is_data_equal(self.data, other.data),
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_inference(cls, inference_result: dict | Any) -> KeyPoints:
|
||||
"""
|
||||
Create a `sv.KeyPoints` object from the [Roboflow](https://roboflow.com/)
|
||||
API inference result or the [Inference](https://inference.roboflow.com/)
|
||||
package results.
|
||||
|
||||
Args:
|
||||
inference_result (dict, any): The result from the
|
||||
Roboflow API or Inference package containing predictions with keypoints.
|
||||
|
||||
Returns:
|
||||
A `sv.KeyPoints` object containing the keypoint coordinates, class IDs,
|
||||
and class names, and confidences of each keypoint.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from inference import get_model
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
model = get_model(model_id=<POSE_MODEL_ID>, api_key=<ROBOFLOW_API_KEY>)
|
||||
|
||||
result = model.infer(image)[0]
|
||||
key_points = sv.KeyPoints.from_inference(result)
|
||||
```
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from inference_sdk import InferenceHTTPClient
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
client = InferenceHTTPClient(
|
||||
api_url="https://detect.roboflow.com",
|
||||
api_key=<ROBOFLOW_API_KEY>
|
||||
)
|
||||
|
||||
result = client.infer(image, model_id=<POSE_MODEL_ID>)
|
||||
key_points = sv.KeyPoints.from_inference(result)
|
||||
```
|
||||
"""
|
||||
if isinstance(inference_result, list):
|
||||
raise ValueError(
|
||||
"from_inference() operates on a single result at a time."
|
||||
"You can retrieve it like so: inference_result = model.infer(image)[0]"
|
||||
)
|
||||
|
||||
if hasattr(inference_result, "dict"):
|
||||
inference_result = inference_result.dict(exclude_none=True, by_alias=True)
|
||||
elif hasattr(inference_result, "json"):
|
||||
inference_result = inference_result.json()
|
||||
if not inference_result.get("predictions"):
|
||||
return cls.empty()
|
||||
|
||||
xy = []
|
||||
confidence = []
|
||||
class_id = []
|
||||
class_names = []
|
||||
|
||||
for prediction in inference_result["predictions"]:
|
||||
prediction_xy = []
|
||||
prediction_confidence = []
|
||||
for keypoint in prediction["keypoints"]:
|
||||
prediction_xy.append([keypoint["x"], keypoint["y"]])
|
||||
prediction_confidence.append(keypoint["confidence"])
|
||||
xy.append(prediction_xy)
|
||||
confidence.append(prediction_confidence)
|
||||
|
||||
class_id.append(prediction["class_id"])
|
||||
class_names.append(prediction["class"])
|
||||
|
||||
data = {CLASS_NAME_DATA_FIELD: np.array(class_names)}
|
||||
|
||||
return cls(
|
||||
xy=np.array(xy, dtype=np.float32),
|
||||
confidence=np.array(confidence, dtype=np.float32),
|
||||
class_id=np.array(class_id, dtype=int),
|
||||
data=data,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_mediapipe(
|
||||
cls, mediapipe_results, resolution_wh: tuple[int, int]
|
||||
) -> KeyPoints:
|
||||
"""
|
||||
Creates a `sv.KeyPoints` instance from a
|
||||
[MediaPipe](https://github.com/google-ai-edge/mediapipe)
|
||||
pose landmark detection inference result.
|
||||
|
||||
Args:
|
||||
mediapipe_results (Union[PoseLandmarkerResult, FaceLandmarkerResult, SolutionOutputs]):
|
||||
The output results from Mediapipe. It support pose and face landmarks
|
||||
from `PoseLandmaker`, `FaceLandmarker` and the legacy ones
|
||||
from `Pose` and `FaceMesh`.
|
||||
resolution_wh (Tuple[int, int]): A tuple of the form `(width, height)`
|
||||
representing the resolution of the frame.
|
||||
|
||||
Returns:
|
||||
A `sv.KeyPoints` object containing the keypoint coordinates and
|
||||
confidences of each keypoint.
|
||||
|
||||
!!! tip
|
||||
Before you start, download model bundles from the
|
||||
[MediaPipe website](https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker/index#models).
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import mediapipe as mp
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
image_height, image_width, _ = image.shape
|
||||
mediapipe_image = mp.Image(
|
||||
image_format=mp.ImageFormat.SRGB,
|
||||
data=cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
|
||||
|
||||
options = mp.tasks.vision.PoseLandmarkerOptions(
|
||||
base_options=mp.tasks.BaseOptions(
|
||||
model_asset_path="pose_landmarker_heavy.task"
|
||||
),
|
||||
running_mode=mp.tasks.vision.RunningMode.IMAGE,
|
||||
num_poses=2)
|
||||
|
||||
PoseLandmarker = mp.tasks.vision.PoseLandmarker
|
||||
with PoseLandmarker.create_from_options(options) as landmarker:
|
||||
pose_landmarker_result = landmarker.detect(mediapipe_image)
|
||||
|
||||
key_points = sv.KeyPoints.from_mediapipe(
|
||||
pose_landmarker_result, (image_width, image_height))
|
||||
```
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import mediapipe as mp
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
image_height, image_width, _ = image.shape
|
||||
mediapipe_image = mp.Image(
|
||||
image_format=mp.ImageFormat.SRGB,
|
||||
data=cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
|
||||
|
||||
options = mp.tasks.vision.FaceLandmarkerOptions(
|
||||
base_options=mp.tasks.BaseOptions(
|
||||
model_asset_path="face_landmarker.task"
|
||||
),
|
||||
output_face_blendshapes=True,
|
||||
output_facial_transformation_matrixes=True,
|
||||
num_faces=2)
|
||||
|
||||
FaceLandmarker = mp.tasks.vision.FaceLandmarker
|
||||
with FaceLandmarker.create_from_options(options) as landmarker:
|
||||
face_landmarker_result = landmarker.detect(mediapipe_image)
|
||||
|
||||
key_points = sv.KeyPoints.from_mediapipe(
|
||||
face_landmarker_result, (image_width, image_height))
|
||||
```
|
||||
|
||||
""" # noqa: E501 // docs
|
||||
if hasattr(mediapipe_results, "pose_landmarks"):
|
||||
results = mediapipe_results.pose_landmarks
|
||||
if not isinstance(mediapipe_results.pose_landmarks, list):
|
||||
if mediapipe_results.pose_landmarks is None:
|
||||
results = []
|
||||
else:
|
||||
results = [
|
||||
[
|
||||
landmark
|
||||
for landmark in mediapipe_results.pose_landmarks.landmark
|
||||
]
|
||||
]
|
||||
elif hasattr(mediapipe_results, "face_landmarks"):
|
||||
results = mediapipe_results.face_landmarks
|
||||
elif hasattr(mediapipe_results, "multi_face_landmarks"):
|
||||
if mediapipe_results.multi_face_landmarks is None:
|
||||
results = []
|
||||
else:
|
||||
results = [
|
||||
face_landmark.landmark
|
||||
for face_landmark in mediapipe_results.multi_face_landmarks
|
||||
]
|
||||
|
||||
if len(results) == 0:
|
||||
return cls.empty()
|
||||
|
||||
xy = []
|
||||
confidence = []
|
||||
for pose in results:
|
||||
prediction_xy = []
|
||||
prediction_confidence = []
|
||||
for landmark in pose:
|
||||
keypoint_xy = [
|
||||
landmark.x * resolution_wh[0],
|
||||
landmark.y * resolution_wh[1],
|
||||
]
|
||||
prediction_xy.append(keypoint_xy)
|
||||
prediction_confidence.append(landmark.visibility)
|
||||
|
||||
xy.append(prediction_xy)
|
||||
confidence.append(prediction_confidence)
|
||||
|
||||
return cls(
|
||||
xy=np.array(xy, dtype=np.float32),
|
||||
confidence=np.array(confidence, dtype=np.float32),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_ultralytics(cls, ultralytics_results) -> KeyPoints:
|
||||
"""
|
||||
Creates a `sv.KeyPoints` instance from a
|
||||
[YOLOv8](https://github.com/ultralytics/ultralytics) pose inference result.
|
||||
|
||||
Args:
|
||||
ultralytics_results (ultralytics.engine.results.Keypoints):
|
||||
The output Results instance from YOLOv8
|
||||
|
||||
Returns:
|
||||
A `sv.KeyPoints` object containing the keypoint coordinates, class IDs,
|
||||
and class names, and confidences of each keypoint.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
model = YOLO('yolov8s-pose.pt')
|
||||
|
||||
result = model(image)[0]
|
||||
key_points = sv.KeyPoints.from_ultralytics(result)
|
||||
```
|
||||
"""
|
||||
if ultralytics_results.keypoints.xy.numel() == 0:
|
||||
return cls.empty()
|
||||
|
||||
xy = ultralytics_results.keypoints.xy.cpu().numpy()
|
||||
class_id = ultralytics_results.boxes.cls.cpu().numpy().astype(int)
|
||||
class_names = np.array([ultralytics_results.names[i] for i in class_id])
|
||||
|
||||
confidence = ultralytics_results.keypoints.conf.cpu().numpy()
|
||||
data = {CLASS_NAME_DATA_FIELD: class_names}
|
||||
return cls(xy, class_id, confidence, data)
|
||||
|
||||
@classmethod
|
||||
def from_yolo_nas(cls, yolo_nas_results) -> KeyPoints:
|
||||
"""
|
||||
Create a `sv.KeyPoints` instance from a [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS-POSE.md)
|
||||
pose inference results.
|
||||
|
||||
Args:
|
||||
yolo_nas_results (ImagePoseEstimationPrediction): The output object from
|
||||
YOLO NAS.
|
||||
|
||||
Returns:
|
||||
A `sv.KeyPoints` object containing the keypoint coordinates, class IDs,
|
||||
and class names, and confidences of each keypoint.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import torch
|
||||
import supervision as sv
|
||||
import super_gradients
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
model = super_gradients.training.models.get(
|
||||
"yolo_nas_pose_s", pretrained_weights="coco_pose").to(device)
|
||||
|
||||
results = model.predict(image, conf=0.1)
|
||||
key_points = sv.KeyPoints.from_yolo_nas(results)
|
||||
```
|
||||
"""
|
||||
if len(yolo_nas_results.prediction.poses) == 0:
|
||||
return cls.empty()
|
||||
|
||||
xy = yolo_nas_results.prediction.poses[:, :, :2]
|
||||
confidence = yolo_nas_results.prediction.poses[:, :, 2]
|
||||
|
||||
# yolo_nas_results treats params differently.
|
||||
# prediction.labels may not exist, whereas class_names might be None
|
||||
if hasattr(yolo_nas_results.prediction, "labels"):
|
||||
class_id = yolo_nas_results.prediction.labels # np.array[int]
|
||||
else:
|
||||
class_id = None
|
||||
|
||||
data = {}
|
||||
if class_id is not None and yolo_nas_results.class_names is not None:
|
||||
class_names = []
|
||||
for c_id in class_id:
|
||||
name = yolo_nas_results.class_names[c_id] # tuple[str]
|
||||
class_names.append(name)
|
||||
data[CLASS_NAME_DATA_FIELD] = class_names
|
||||
|
||||
return cls(
|
||||
xy=xy,
|
||||
confidence=confidence,
|
||||
class_id=class_id,
|
||||
data=data,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_detectron2(cls, detectron2_results: Any) -> KeyPoints:
|
||||
"""
|
||||
Create a `sv.KeyPoints` object from the
|
||||
[Detectron2](https://github.com/facebookresearch/detectron2) inference result.
|
||||
|
||||
Args:
|
||||
detectron2_results (Any): The output of a
|
||||
Detectron2 model containing instances with prediction data.
|
||||
|
||||
Returns:
|
||||
A `sv.KeyPoints` object containing the keypoint coordinates, class IDs,
|
||||
and class names, and confidences of each keypoint.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from detectron2.engine import DefaultPredictor
|
||||
from detectron2.config import get_cfg
|
||||
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
cfg = get_cfg()
|
||||
cfg.merge_from_file(<CONFIG_PATH>)
|
||||
cfg.MODEL.WEIGHTS = <WEIGHTS_PATH>
|
||||
predictor = DefaultPredictor(cfg)
|
||||
|
||||
result = predictor(image)
|
||||
keypoints = sv.KeyPoints.from_detectron2(result)
|
||||
```
|
||||
"""
|
||||
|
||||
if hasattr(detectron2_results["instances"], "pred_keypoints"):
|
||||
if detectron2_results["instances"].pred_keypoints.cpu().numpy().size == 0:
|
||||
return cls.empty()
|
||||
return cls(
|
||||
xy=detectron2_results["instances"]
|
||||
.pred_keypoints.cpu()
|
||||
.numpy()[:, :, :2],
|
||||
confidence=detectron2_results["instances"]
|
||||
.pred_keypoints.cpu()
|
||||
.numpy()[:, :, 2],
|
||||
class_id=detectron2_results["instances"]
|
||||
.pred_classes.cpu()
|
||||
.numpy()
|
||||
.astype(int),
|
||||
)
|
||||
else:
|
||||
return cls.empty()
|
||||
|
||||
@classmethod
|
||||
def from_transformers(cls, transfomers_results: Any) -> KeyPoints:
|
||||
"""
|
||||
Create a `sv.KeyPoints` object from the
|
||||
[Transformers](https://github.com/huggingface/transformers) inference result.
|
||||
|
||||
Args:
|
||||
transfomers_results (Any): The output of a
|
||||
Transformers model containing instances with prediction data.
|
||||
|
||||
Returns:
|
||||
A `sv.KeyPoints` object containing the keypoint coordinates, class IDs,
|
||||
and class names, and confidences of each keypoint.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from PIL import Image
|
||||
import requests
|
||||
import supervision as sv
|
||||
import torch
|
||||
from transformers import (
|
||||
AutoProcessor,
|
||||
RTDetrForObjectDetection,
|
||||
VitPoseForPoseEstimation,
|
||||
)
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
image = Image.open(<SOURCE_IMAGE_PATH>)
|
||||
|
||||
DETECTION_MODEL_ID = "PekingU/rtdetr_r50vd_coco_o365"
|
||||
|
||||
detection_processor = AutoProcessor.from_pretrained(DETECTION_MODEL_ID, use_fast=True)
|
||||
detection_model = RTDetrForObjectDetection.from_pretrained(DETECTION_MODEL_ID, device_map=DEVICE)
|
||||
|
||||
inputs = detection_processor(images=frame, return_tensors="pt").to(DEVICE)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = detection_model(**inputs)
|
||||
|
||||
target_size = torch.tensor([(frame.height, frame.width)])
|
||||
results = detection_processor.post_process_object_detection(
|
||||
outputs, target_sizes=target_size, threshold=0.3)
|
||||
|
||||
detections = sv.Detections.from_transformers(results[0])
|
||||
boxes = sv.xyxy_to_xywh(detections[detections.class_id == 0].xyxy)
|
||||
|
||||
POSE_ESTIMATION_MODEL_ID = "usyd-community/vitpose-base-simple"
|
||||
|
||||
pose_estimation_processor = AutoProcessor.from_pretrained(POSE_ESTIMATION_MODEL_ID)
|
||||
pose_estimation_model = VitPoseForPoseEstimation.from_pretrained(
|
||||
POSE_ESTIMATION_MODEL_ID, device_map=DEVICE)
|
||||
|
||||
inputs = pose_estimation_processor(frame, boxes=[boxes], return_tensors="pt").to(DEVICE)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = pose_estimation_model(**inputs)
|
||||
|
||||
results = pose_estimation_processor.post_process_pose_estimation(outputs, boxes=[boxes])
|
||||
key_point = sv.KeyPoints.from_transformers(results[0])
|
||||
```
|
||||
|
||||
""" # noqa: E501 // docs
|
||||
|
||||
if "keypoints" in transfomers_results[0]:
|
||||
if transfomers_results[0]["keypoints"].cpu().numpy().size == 0:
|
||||
return cls.empty()
|
||||
|
||||
result_data = [
|
||||
(
|
||||
result["keypoints"].cpu().numpy(),
|
||||
result["scores"].cpu().numpy(),
|
||||
)
|
||||
for result in transfomers_results
|
||||
]
|
||||
|
||||
xy, scores = zip(*result_data)
|
||||
|
||||
return cls(
|
||||
xy=np.stack(xy).astype(np.float32),
|
||||
confidence=np.stack(scores).astype(np.float32),
|
||||
class_id=np.arange(len(xy)).astype(int),
|
||||
)
|
||||
else:
|
||||
return cls.empty()
|
||||
|
||||
def __getitem__(
|
||||
self, index: int | slice | list[int] | np.ndarray | str
|
||||
) -> KeyPoints | list | np.ndarray | None:
|
||||
"""
|
||||
Get a subset of the `sv.KeyPoints` object or access an item from its data field.
|
||||
|
||||
When provided with an integer, slice, list of integers, or a numpy array, this
|
||||
method returns a new `sv.KeyPoints` object that represents a subset of the
|
||||
original `sv.KeyPoints`. When provided with a string, it accesses the
|
||||
corresponding item in the data dictionary.
|
||||
|
||||
Args:
|
||||
index (Union[int, slice, List[int], np.ndarray, str]): The index, indices,
|
||||
or key to access a subset of the `sv.KeyPoints` or an item from the
|
||||
data.
|
||||
|
||||
Returns:
|
||||
A subset of the `sv.KeyPoints` object or an item from the data field.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
key_points = sv.KeyPoints()
|
||||
|
||||
# access the first keypoint using an integer index
|
||||
key_points[0]
|
||||
|
||||
# access the first 10 keypoints using index slice
|
||||
key_points[0:10]
|
||||
|
||||
# access selected keypoints using a list of indices
|
||||
key_points[[0, 2, 4]]
|
||||
|
||||
# access keypoints with selected class_id
|
||||
key_points[key_points.class_id == 0]
|
||||
|
||||
# access keypoints with confidence greater than 0.5
|
||||
key_points[key_points.confidence > 0.5]
|
||||
```
|
||||
"""
|
||||
if isinstance(index, str):
|
||||
return self.data.get(index)
|
||||
if isinstance(index, int):
|
||||
index = [index]
|
||||
return KeyPoints(
|
||||
xy=self.xy[index],
|
||||
confidence=self.confidence[index] if self.confidence is not None else None,
|
||||
class_id=self.class_id[index] if self.class_id is not None else None,
|
||||
data=get_data_item(self.data, index),
|
||||
)
|
||||
|
||||
def __setitem__(self, key: str, value: np.ndarray | list):
|
||||
"""
|
||||
Set a value in the data dictionary of the `sv.KeyPoints` object.
|
||||
|
||||
Args:
|
||||
key (str): The key in the data dictionary to set.
|
||||
value (Union[np.ndarray, List]): The value to set for the key.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
model = YOLO('yolov8s.pt')
|
||||
|
||||
result = model(image)[0]
|
||||
keypoints = sv.KeyPoints.from_ultralytics(result)
|
||||
|
||||
keypoints['class_name'] = [
|
||||
model.model.names[class_id]
|
||||
for class_id
|
||||
in keypoints.class_id
|
||||
]
|
||||
```
|
||||
"""
|
||||
if not isinstance(value, (np.ndarray, list)):
|
||||
raise TypeError("Value must be a np.ndarray or a list")
|
||||
|
||||
if isinstance(value, list):
|
||||
value = np.array(value)
|
||||
|
||||
self.data[key] = value
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> KeyPoints:
|
||||
"""
|
||||
Create an empty Keypoints object with no keypoints.
|
||||
|
||||
Returns:
|
||||
An empty `sv.KeyPoints` object.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
key_points = sv.KeyPoints.empty()
|
||||
```
|
||||
"""
|
||||
return cls(xy=np.empty((0, 0, 2), dtype=np.float32))
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""
|
||||
Returns `True` if the `KeyPoints` object is considered empty.
|
||||
"""
|
||||
empty_keypoints = KeyPoints.empty()
|
||||
empty_keypoints.data = self.data
|
||||
return self == empty_keypoints
|
||||
|
||||
def as_detections(
|
||||
self, selected_keypoint_indices: Iterable[int] | None = None
|
||||
) -> Detections:
|
||||
"""
|
||||
Convert a KeyPoints object to a Detections object. This
|
||||
approximates the bounding box of the detected object by
|
||||
taking the bounding box that fits all keypoints.
|
||||
|
||||
Arguments:
|
||||
selected_keypoint_indices (Optional[Iterable[int]]): The
|
||||
indices of the keypoints to include in the bounding box
|
||||
calculation. This helps focus on a subset of keypoints,
|
||||
e.g. when some are occluded. Captures all keypoints by default.
|
||||
|
||||
Returns:
|
||||
detections (Detections): The converted detections object.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
keypoints = sv.KeyPoints.from_inference(...)
|
||||
detections = keypoints.as_detections()
|
||||
```
|
||||
"""
|
||||
if self.is_empty():
|
||||
return Detections.empty()
|
||||
|
||||
detections_list = []
|
||||
for i, xy in enumerate(self.xy):
|
||||
if selected_keypoint_indices:
|
||||
xy = xy[selected_keypoint_indices]
|
||||
|
||||
# [0, 0] used by some frameworks to indicate missing keypoints
|
||||
xy = xy[~np.all(xy == 0, axis=1)]
|
||||
if len(xy) == 0:
|
||||
xyxy = np.array([[0, 0, 0, 0]], dtype=np.float32)
|
||||
else:
|
||||
x_min = xy[:, 0].min()
|
||||
x_max = xy[:, 0].max()
|
||||
y_min = xy[:, 1].min()
|
||||
y_max = xy[:, 1].max()
|
||||
xyxy = np.array([[x_min, y_min, x_max, y_max]], dtype=np.float32)
|
||||
|
||||
if self.confidence is None:
|
||||
confidence = None
|
||||
else:
|
||||
confidence = self.confidence[i]
|
||||
if selected_keypoint_indices:
|
||||
confidence = confidence[selected_keypoint_indices]
|
||||
confidence = np.array([confidence.mean()], dtype=np.float32)
|
||||
|
||||
detections_list.append(
|
||||
Detections(
|
||||
xyxy=xyxy,
|
||||
confidence=confidence,
|
||||
)
|
||||
)
|
||||
|
||||
detections = Detections.merge(detections_list)
|
||||
detections.class_id = self.class_id
|
||||
detections.data = self.data
|
||||
detections = detections[detections.area > 0]
|
||||
|
||||
return detections
|
||||
from supervision.key_points.core import KeyPoints # noqa: F401, E402
|
||||
|
||||
@@ -6,11 +6,11 @@ from dataclasses import dataclass
|
||||
import matplotlib
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
from deprecate import deprecated
|
||||
|
||||
from supervision.dataset.core import DetectionDataset
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.detection.utils.iou_and_nms import box_iou_batch
|
||||
from supervision.utils.internal import deprecated
|
||||
|
||||
|
||||
def detections_to_tensor(
|
||||
@@ -495,10 +495,9 @@ class ConfusionMatrix:
|
||||
|
||||
|
||||
@deprecated(
|
||||
"`MeanAveragePrecision` is deprecated and will be removed in "
|
||||
"`supervision-0.31.0`. Use "
|
||||
"`supervision.metrics.mean_average_precision.MeanAveragePrecision` instead. The "
|
||||
"deprecated implementation provides results that are inconsistent with pycocotools."
|
||||
target=None,
|
||||
deprecated_in="0.27.0",
|
||||
remove_in="0.31.0",
|
||||
)
|
||||
@dataclass(frozen=True)
|
||||
class MeanAveragePrecision:
|
||||
|
||||
@@ -57,7 +57,11 @@ class MeanAveragePrecisionResult:
|
||||
|
||||
@property
|
||||
def map50_95(self) -> float:
|
||||
return self.mAP_scores.mean()
|
||||
valid_scores = self.mAP_scores[self.mAP_scores > -1]
|
||||
if len(valid_scores) > 0:
|
||||
return valid_scores.mean()
|
||||
else:
|
||||
return -1
|
||||
|
||||
@property
|
||||
def map50(self) -> float:
|
||||
@@ -98,12 +102,12 @@ class MeanAveragePrecisionResult:
|
||||
f"maxDets=100 ] = {self.map50:.3f}\n"
|
||||
f"Average Precision (AP) @[ IoU=0.75 | area= all | "
|
||||
f"maxDets=100 ] = {self.map75:.3f}\n"
|
||||
f"Average Precision (AP) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] "
|
||||
f"= {self.small_objects.map50_95:.3f}\n"
|
||||
f"Average Precision (AP) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] \
|
||||
= {self.medium_objects.map50_95:.3f}\n"
|
||||
f"Average Precision (AP) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] \
|
||||
= {self.large_objects.map50_95:.3f}"
|
||||
f"Average Precision (AP) @[ IoU=0.50:0.95 | area= small | "
|
||||
f"maxDets=100 ] = {self.small_objects.map50_95:.3f}\n"
|
||||
f"Average Precision (AP) @[ IoU=0.50:0.95 | area=medium | "
|
||||
f"maxDets=100 ] = {self.medium_objects.map50_95:.3f}\n"
|
||||
f"Average Precision (AP) @[ IoU=0.50:0.95 | area= large | "
|
||||
f"maxDets=100 ] = {self.large_objects.map50_95:.3f}"
|
||||
)
|
||||
|
||||
def to_pandas(self) -> pd.DataFrame:
|
||||
@@ -421,6 +425,11 @@ class EvaluationDataset:
|
||||
if not isinstance(predictions, list):
|
||||
raise ValueError("results must be a list")
|
||||
|
||||
# Handle empty predictions
|
||||
if len(predictions) == 0:
|
||||
predictions_dataset.dataset["annotations"] = []
|
||||
return predictions_dataset
|
||||
|
||||
ids = [pred["image_id"] for pred in predictions]
|
||||
|
||||
# Make sure the image ids from predictions exist in the current dataset
|
||||
@@ -909,6 +918,35 @@ class COCOEvaluator:
|
||||
np.array(score_at_recall)
|
||||
)
|
||||
|
||||
self.results = {
|
||||
"params": self.params,
|
||||
"counts": [
|
||||
num_iou_thresholds,
|
||||
num_recall_thresholds,
|
||||
num_categories,
|
||||
num_area_ranges,
|
||||
num_max_detections,
|
||||
],
|
||||
"date": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"precision": precision,
|
||||
"recall": recall,
|
||||
"scores": scores,
|
||||
}
|
||||
|
||||
# Helper function to compute average precision while handling -1 sentinel values
|
||||
def compute_average_precision(precision_slice):
|
||||
"""Compute average precision while handling -1 sentinel values."""
|
||||
masked = np.ma.masked_equal(precision_slice, -1)
|
||||
if masked.count() == 0:
|
||||
# All values are -1 (no data)
|
||||
return np.full(num_iou_thresholds, -1), np.full(
|
||||
(num_categories, num_iou_thresholds), -1
|
||||
)
|
||||
else:
|
||||
mAP_scores = np.ma.filled(masked.mean(axis=(1, 2)), -1)
|
||||
ap_per_class = np.ma.filled(masked.mean(axis=1), -1).transpose(1, 0)
|
||||
return mAP_scores, ap_per_class
|
||||
|
||||
# Average precision over all sizes, 100 max detections
|
||||
area_range_idx = list(ObjectSize).index(ObjectSize.ALL)
|
||||
max_100_dets_idx = self.params.max_dets.index(100)
|
||||
@@ -917,10 +955,9 @@ class COCOEvaluator:
|
||||
:, :, :, area_range_idx, max_100_dets_idx
|
||||
]
|
||||
# mAP over thresholds (dimension=num_thresholds)
|
||||
mAP_scores_all_sizes = average_precision_all_sizes.mean(axis=(1, 2))
|
||||
# AP per class
|
||||
ap_per_class_all_sizes = average_precision_all_sizes.mean(axis=1).transpose(
|
||||
1, 0
|
||||
# Use masked array to exclude -1 values when computing mean
|
||||
mAP_scores_all_sizes, ap_per_class_all_sizes = compute_average_precision(
|
||||
average_precision_all_sizes
|
||||
)
|
||||
|
||||
# Average precision for SMALL objects and 100 max detections
|
||||
@@ -928,24 +965,27 @@ class COCOEvaluator:
|
||||
average_precision_small = precision[
|
||||
:, :, :, small_area_range_idx, max_100_dets_idx
|
||||
]
|
||||
mAP_scores_small = average_precision_small.mean(axis=(1, 2))
|
||||
ap_per_class_small = average_precision_small.mean(axis=1).transpose(1, 0)
|
||||
mAP_scores_small, ap_per_class_small = compute_average_precision(
|
||||
average_precision_small
|
||||
)
|
||||
|
||||
# Average precision for MEDIUM objects and 100 max detections
|
||||
medium_area_range_idx = list(ObjectSize).index(ObjectSize.MEDIUM)
|
||||
average_precision_medium = precision[
|
||||
:, :, :, medium_area_range_idx, max_100_dets_idx
|
||||
]
|
||||
mAP_scores_medium = average_precision_medium.mean(axis=(1, 2))
|
||||
ap_per_class_medium = average_precision_medium.mean(axis=1).transpose(1, 0)
|
||||
mAP_scores_medium, ap_per_class_medium = compute_average_precision(
|
||||
average_precision_medium
|
||||
)
|
||||
|
||||
# Average precision for LARGE objects and 100 max detections
|
||||
large_area_range_idx = list(ObjectSize).index(ObjectSize.LARGE)
|
||||
average_precision_large = precision[
|
||||
:, :, :, large_area_range_idx, max_100_dets_idx
|
||||
]
|
||||
mAP_scores_large = average_precision_large.mean(axis=(1, 2))
|
||||
ap_per_class_large = average_precision_large.mean(axis=1).transpose(1, 0)
|
||||
mAP_scores_large, ap_per_class_large = compute_average_precision(
|
||||
average_precision_large
|
||||
)
|
||||
|
||||
self.results = {
|
||||
"params": self.params,
|
||||
@@ -1221,7 +1261,7 @@ class MeanAveragePrecision(Metric):
|
||||
for image_id, image_targets in enumerate(targets):
|
||||
if self._image_indices is not None:
|
||||
image_id = self._image_indices[image_id]
|
||||
for target in image_targets:
|
||||
for target_idx, target in enumerate(image_targets):
|
||||
xyxy = target[0] # or xyxy = prediction[0]; xyxy[2:4] -= xyxy[0:2]
|
||||
xywh = [xyxy[0], xyxy[1], xyxy[2] - xyxy[0], xyxy[3] - xyxy[1]]
|
||||
# Get "area" and "iscrowd" (default 0) from data
|
||||
@@ -1231,13 +1271,20 @@ class MeanAveragePrecision(Metric):
|
||||
category_id = self._class_mapping[target[3].item()]
|
||||
else:
|
||||
category_id = target[3].item()
|
||||
|
||||
# Use area from data if available (e.g., COCO datasets)
|
||||
# Otherwise use Detections.area property
|
||||
area = data.get("area") if data else None
|
||||
if area is None:
|
||||
area = image_targets.area[target_idx]
|
||||
|
||||
dict_annotation = {
|
||||
"area": data.get("area", 0),
|
||||
"area": area,
|
||||
"iscrowd": data.get("iscrowd", 0),
|
||||
"image_id": image_id,
|
||||
"bbox": xywh,
|
||||
"category_id": category_id,
|
||||
"id": len(annotations), # incrementally increase the id
|
||||
"id": len(annotations) + 1, # Start IDs from 1 (0 means no match)
|
||||
}
|
||||
annotations.append(dict_annotation)
|
||||
# Category list
|
||||
|
||||
@@ -2,12 +2,13 @@ from functools import wraps
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from deprecate import deprecated
|
||||
from PIL import Image
|
||||
|
||||
from supervision.annotators.base import ImageType
|
||||
from supervision.draw.base import ImageType
|
||||
|
||||
|
||||
def ensure_cv2_image_for_annotation(annotate_func):
|
||||
def ensure_cv2_image_for_class_method(annotate_func):
|
||||
"""
|
||||
Decorates `BaseAnnotator.annotate` implementations, converts scene to
|
||||
an image type used internally by the annotators, converts back when annotation
|
||||
@@ -32,7 +33,16 @@ def ensure_cv2_image_for_annotation(annotate_func):
|
||||
return wrapper
|
||||
|
||||
|
||||
def ensure_cv2_image_for_processing(image_processing_fun):
|
||||
@deprecated(
|
||||
target=ensure_cv2_image_for_class_method,
|
||||
deprecated_in="0.27.0",
|
||||
remove_in="0.31.0",
|
||||
)
|
||||
def ensure_cv2_image_for_annotation(annotate_func):
|
||||
pass
|
||||
|
||||
|
||||
def ensure_cv2_image_for_standalone_function(image_processing_fun):
|
||||
"""
|
||||
Decorates image processing functions that accept np.ndarray, converting `image` to
|
||||
np.ndarray, converts back when processing is complete.
|
||||
@@ -55,7 +65,7 @@ def ensure_cv2_image_for_processing(image_processing_fun):
|
||||
return wrapper
|
||||
|
||||
|
||||
def ensure_pil_image_for_annotation(annotate_func):
|
||||
def ensure_pil_image_for_class_method(annotate_func):
|
||||
"""
|
||||
Decorates image processing functions that accept np.ndarray, converting `image` to
|
||||
PIL image, converts back when processing is complete.
|
||||
@@ -79,6 +89,24 @@ def ensure_pil_image_for_annotation(annotate_func):
|
||||
return wrapper
|
||||
|
||||
|
||||
@deprecated(
|
||||
target=ensure_pil_image_for_class_method,
|
||||
deprecated_in="0.27.0",
|
||||
remove_in="0.31.0",
|
||||
)
|
||||
def ensure_pil_image_for_annotation(annotate_func):
|
||||
pass
|
||||
|
||||
|
||||
@deprecated(
|
||||
target=ensure_cv2_image_for_standalone_function,
|
||||
deprecated_in="0.27.0",
|
||||
remove_in="0.31.0",
|
||||
)
|
||||
def ensure_cv2_image_for_processing(image_processing_fun):
|
||||
pass
|
||||
|
||||
|
||||
def images_to_cv2(images: list[ImageType]) -> list[np.ndarray]:
|
||||
"""
|
||||
Converts images provided either as Pillow images or OpenCV
|
||||
|
||||
+315
-103
@@ -11,6 +11,8 @@ from typing import Literal
|
||||
import cv2
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from deprecate import deprecated
|
||||
from PIL import Image
|
||||
|
||||
from supervision.annotators.base import ImageType
|
||||
from supervision.draw.color import Color, unify_to_bgr
|
||||
@@ -18,7 +20,7 @@ from supervision.draw.utils import calculate_optimal_text_scale, draw_text
|
||||
from supervision.geometry.core import Point
|
||||
from supervision.utils.conversion import (
|
||||
cv2_to_pillow,
|
||||
ensure_cv2_image_for_processing,
|
||||
ensure_cv2_image_for_standalone_function,
|
||||
images_to_cv2,
|
||||
)
|
||||
from supervision.utils.iterables import create_batches, fill
|
||||
@@ -28,92 +30,92 @@ RelativePosition = Literal["top", "bottom"]
|
||||
MAX_COLUMNS_FOR_SINGLE_ROW_GRID = 3
|
||||
|
||||
|
||||
@ensure_cv2_image_for_processing
|
||||
def crop_image(
|
||||
image: ImageType,
|
||||
xyxy: npt.NDArray[int] | list[int] | tuple[int, int, int, int],
|
||||
) -> ImageType:
|
||||
"""
|
||||
Crops the given image based on the given bounding box.
|
||||
Crop image based on bounding box coordinates.
|
||||
|
||||
Args:
|
||||
image (ImageType): The image to be cropped. `ImageType` is a flexible type,
|
||||
accepting either `numpy.ndarray` or `PIL.Image.Image`.
|
||||
xyxy (Union[np.ndarray, List[int], Tuple[int, int, int, int]]): A bounding box
|
||||
coordinates in the format `(x_min, y_min, x_max, y_max)`, accepted as either
|
||||
a `numpy.ndarray`, a `list`, or a `tuple`.
|
||||
image (`numpy.ndarray` or `PIL.Image.Image`): The image to crop.
|
||||
xyxy (`numpy.array`, `list[int]`, or `tuple[int, int, int, int]`):
|
||||
Bounding box coordinates in `(x_min, y_min, x_max, y_max)` format.
|
||||
|
||||
Returns:
|
||||
(ImageType): The cropped image. The type is determined by the input type and
|
||||
may be either a `numpy.ndarray` or `PIL.Image.Image`.
|
||||
|
||||
=== "OpenCV"
|
||||
(`numpy.ndarray` or `PIL.Image.Image`): Cropped image matching input
|
||||
type.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
image = cv2.imread("source.png")
|
||||
image.shape
|
||||
# (1080, 1920, 3)
|
||||
|
||||
xyxy = [200, 400, 600, 800]
|
||||
xyxy = (400, 400, 800, 800)
|
||||
cropped_image = sv.crop_image(image=image, xyxy=xyxy)
|
||||
cropped_image.shape
|
||||
# (400, 400, 3)
|
||||
```
|
||||
|
||||
=== "Pillow"
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
import supervision as sv
|
||||
|
||||
image = Image.open(<SOURCE_IMAGE_PATH>)
|
||||
image = Image.open("source.png")
|
||||
image.size
|
||||
# (1920, 1080)
|
||||
|
||||
xyxy = [200, 400, 600, 800]
|
||||
xyxy = (400, 400, 800, 800)
|
||||
cropped_image = sv.crop_image(image=image, xyxy=xyxy)
|
||||
cropped_image.size
|
||||
# (400, 400)
|
||||
```
|
||||
|
||||
{ align=center width="800" }
|
||||
{ align=center width="1000" }
|
||||
""" # 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_processing
|
||||
@ensure_cv2_image_for_standalone_function
|
||||
def scale_image(image: ImageType, scale_factor: float) -> ImageType:
|
||||
"""
|
||||
Scales the given image based on the given scale factor.
|
||||
Scale image by given factor. Scale factor > 1.0 zooms in, < 1.0 zooms out.
|
||||
|
||||
Args:
|
||||
image (ImageType): The image to be scaled. `ImageType` is a flexible type,
|
||||
accepting either `numpy.ndarray` or `PIL.Image.Image`.
|
||||
scale_factor (float): The factor by which the image will be scaled. Scale
|
||||
factor > `1.0` zooms in, < `1.0` zooms out.
|
||||
image (`numpy.ndarray` or `PIL.Image.Image`): The image to scale.
|
||||
scale_factor (`float`): Factor by which to scale the image.
|
||||
|
||||
Returns:
|
||||
(ImageType): The scaled image. The type is determined by the input type and
|
||||
may be either a `numpy.ndarray` or `PIL.Image.Image`.
|
||||
(`numpy.ndarray` or `PIL.Image.Image`): Scaled image matching input
|
||||
type.
|
||||
|
||||
Raises:
|
||||
ValueError: If the scale factor is non-positive.
|
||||
|
||||
=== "OpenCV"
|
||||
ValueError: If scale factor is non-positive.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
image = cv2.imread("source.png")
|
||||
image.shape
|
||||
# (1080, 1920, 3)
|
||||
|
||||
@@ -122,13 +124,11 @@ def scale_image(image: ImageType, scale_factor: float) -> ImageType:
|
||||
# (540, 960, 3)
|
||||
```
|
||||
|
||||
=== "Pillow"
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
import supervision as sv
|
||||
|
||||
image = Image.open(<SOURCE_IMAGE_PATH>)
|
||||
image = Image.open("source.png")
|
||||
image.size
|
||||
# (1920, 1080)
|
||||
|
||||
@@ -136,7 +136,9 @@ def scale_image(image: ImageType, scale_factor: float) -> ImageType:
|
||||
scaled_image.size
|
||||
# (960, 540)
|
||||
```
|
||||
"""
|
||||
|
||||
{ align=center width="1000" }
|
||||
""" # noqa E501 // docs
|
||||
if scale_factor <= 0:
|
||||
raise ValueError("Scale factor must be positive.")
|
||||
|
||||
@@ -146,35 +148,31 @@ def scale_image(image: ImageType, scale_factor: float) -> ImageType:
|
||||
return cv2.resize(image, (width_new, height_new), interpolation=cv2.INTER_LINEAR)
|
||||
|
||||
|
||||
@ensure_cv2_image_for_processing
|
||||
@ensure_cv2_image_for_standalone_function
|
||||
def resize_image(
|
||||
image: ImageType,
|
||||
resolution_wh: tuple[int, int],
|
||||
keep_aspect_ratio: bool = False,
|
||||
) -> ImageType:
|
||||
"""
|
||||
Resizes the given image to a specified resolution. Can maintain the original aspect
|
||||
ratio or resize directly to the desired dimensions.
|
||||
Resize image to specified resolution. Can optionally maintain aspect ratio.
|
||||
|
||||
Args:
|
||||
image (ImageType): The image to be resized. `ImageType` is a flexible type,
|
||||
accepting either `numpy.ndarray` or `PIL.Image.Image`.
|
||||
resolution_wh (Tuple[int, int]): The target resolution as
|
||||
`(width, height)`.
|
||||
keep_aspect_ratio (bool): Flag to maintain the image's original
|
||||
aspect ratio. Defaults to `False`.
|
||||
image (`numpy.ndarray` or `PIL.Image.Image`): The image to resize.
|
||||
resolution_wh (`tuple[int, int]`): Target resolution as `(width, height)`.
|
||||
keep_aspect_ratio (`bool`): Flag to maintain original aspect ratio.
|
||||
Defaults to `False`.
|
||||
|
||||
Returns:
|
||||
(ImageType): The resized image. The type is determined by the input type and
|
||||
may be either a `numpy.ndarray` or `PIL.Image.Image`.
|
||||
|
||||
=== "OpenCV"
|
||||
(`numpy.ndarray` or `PIL.Image.Image`): Resized image matching input
|
||||
type.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
image = cv2.imread("source.png")
|
||||
image.shape
|
||||
# (1080, 1920, 3)
|
||||
|
||||
@@ -185,13 +183,11 @@ def resize_image(
|
||||
# (562, 1000, 3)
|
||||
```
|
||||
|
||||
=== "Pillow"
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
import supervision as sv
|
||||
|
||||
image = Image.open(<SOURCE_IMAGE_PATH>)
|
||||
image = Image.open("source.png")
|
||||
image.size
|
||||
# (1920, 1080)
|
||||
|
||||
@@ -202,7 +198,7 @@ def resize_image(
|
||||
# (1000, 562)
|
||||
```
|
||||
|
||||
{ align=center width="800" }
|
||||
{ align=center width="1000" }
|
||||
""" # noqa E501 // docs
|
||||
if keep_aspect_ratio:
|
||||
image_ratio = image.shape[1] / image.shape[0]
|
||||
@@ -219,59 +215,58 @@ def resize_image(
|
||||
return cv2.resize(image, (width_new, height_new), interpolation=cv2.INTER_LINEAR)
|
||||
|
||||
|
||||
@ensure_cv2_image_for_processing
|
||||
@ensure_cv2_image_for_standalone_function
|
||||
def letterbox_image(
|
||||
image: ImageType,
|
||||
resolution_wh: tuple[int, int],
|
||||
color: tuple[int, int, int] | Color = Color.BLACK,
|
||||
) -> ImageType:
|
||||
"""
|
||||
Resizes and pads an image to a specified resolution with a given color, maintaining
|
||||
the original aspect ratio.
|
||||
Resize image and pad with color to achieve desired resolution while
|
||||
maintaining aspect ratio.
|
||||
|
||||
Args:
|
||||
image (ImageType): The image to be resized. `ImageType` is a flexible type,
|
||||
accepting either `numpy.ndarray` or `PIL.Image.Image`.
|
||||
resolution_wh (Tuple[int, int]): The target resolution as
|
||||
`(width, height)`.
|
||||
color (Union[Tuple[int, int, int], Color]): The color to pad with. If tuple
|
||||
provided it should be in BGR format.
|
||||
image (`numpy.ndarray` or `PIL.Image.Image`): The image to resize and pad.
|
||||
resolution_wh (`tuple[int, int]`): Target resolution as `(width, height)`.
|
||||
color (`tuple[int, int, int]` or `Color`): Padding color. If tuple, should
|
||||
be in BGR format. Defaults to `Color.BLACK`.
|
||||
|
||||
Returns:
|
||||
(ImageType): The resized image. The type is determined by the input type and
|
||||
may be either a `numpy.ndarray` or `PIL.Image.Image`.
|
||||
|
||||
=== "OpenCV"
|
||||
(`numpy.ndarray` or `PIL.Image.Image`): Letterboxed image matching input
|
||||
type.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
image = cv2.imread("source.png")
|
||||
image.shape
|
||||
# (1080, 1920, 3)
|
||||
|
||||
letterboxed_image = sv.letterbox_image(image=image, resolution_wh=(1000, 1000))
|
||||
letterboxed_image = sv.letterbox_image(
|
||||
image=image, resolution_wh=(1000, 1000)
|
||||
)
|
||||
letterboxed_image.shape
|
||||
# (1000, 1000, 3)
|
||||
```
|
||||
|
||||
=== "Pillow"
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
import supervision as sv
|
||||
|
||||
image = Image.open(<SOURCE_IMAGE_PATH>)
|
||||
image = Image.open("source.png")
|
||||
image.size
|
||||
# (1920, 1080)
|
||||
|
||||
letterboxed_image = sv.letterbox_image(image=image, resolution_wh=(1000, 1000))
|
||||
letterboxed_image = sv.letterbox_image(
|
||||
image=image, resolution_wh=(1000, 1000)
|
||||
)
|
||||
letterboxed_image.size
|
||||
# (1000, 1000)
|
||||
```
|
||||
|
||||
{ align=center width="800" }
|
||||
{ align=center width="1000" }
|
||||
""" # noqa E501 // docs
|
||||
assert isinstance(image, np.ndarray)
|
||||
color = unify_to_bgr(color=color)
|
||||
@@ -302,37 +297,60 @@ def letterbox_image(
|
||||
return image_with_borders
|
||||
|
||||
|
||||
@deprecated(
|
||||
target=None,
|
||||
deprecated_in="0.27.0",
|
||||
remove_in="0.31.0",
|
||||
)
|
||||
def overlay_image(
|
||||
image: npt.NDArray[np.uint8],
|
||||
overlay: npt.NDArray[np.uint8],
|
||||
anchor: tuple[int, int],
|
||||
) -> npt.NDArray[np.uint8]:
|
||||
"""
|
||||
Places an image onto a scene at a given anchor point, handling cases where
|
||||
the image's position is partially or completely outside the scene's bounds.
|
||||
Overlay image onto scene at specified anchor point. Handles cases where
|
||||
overlay position is partially or completely outside scene bounds.
|
||||
|
||||
Args:
|
||||
image (np.ndarray): The background scene onto which the image is placed.
|
||||
overlay (np.ndarray): The image to be placed onto the scene.
|
||||
anchor (Tuple[int, int]): The `(x, y)` coordinates in the scene where the
|
||||
top-left corner of the image will be placed.
|
||||
image (`numpy.array`): Background scene with shape `(height, width, 3)`.
|
||||
overlay (`numpy.array`): Image to overlay with shape
|
||||
`(height, width, 3)` or `(height, width, 4)`.
|
||||
anchor (`tuple[int, int]`): Coordinates `(x, y)` where top-left corner
|
||||
of overlay will be placed.
|
||||
|
||||
Returns:
|
||||
(np.ndarray): The result image with overlay.
|
||||
(`numpy.array`): Scene with overlay applied, shape `(height, width, 3)`.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
```
|
||||
import cv2
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
image = cv2.imread("source.png")
|
||||
overlay = np.zeros((400, 400, 3), dtype=np.uint8)
|
||||
result_image = sv.overlay_image(image=image, overlay=overlay, anchor=(200, 400))
|
||||
overlay[:] = (0, 255, 0) # Green overlay
|
||||
|
||||
result_image = sv.overlay_image(
|
||||
image=image, overlay=overlay, anchor=(200, 400)
|
||||
)
|
||||
cv2.imwrite("target.png", result_image)
|
||||
```
|
||||
|
||||
{ align=center width="800" }
|
||||
""" # noqa E501 // docs
|
||||
```
|
||||
import cv2
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread("source.png")
|
||||
overlay = cv2.imread("overlay.png", cv2.IMREAD_UNCHANGED)
|
||||
|
||||
result_image = sv.overlay_image(
|
||||
image=image, overlay=overlay, anchor=(100, 100)
|
||||
)
|
||||
cv2.imwrite("target.png", result_image)
|
||||
```
|
||||
"""
|
||||
scene_height, scene_width = image.shape[:2]
|
||||
image_height, image_width = overlay.shape[:2]
|
||||
anchor_x, anchor_y = anchor
|
||||
@@ -371,6 +389,158 @@ def overlay_image(
|
||||
return image
|
||||
|
||||
|
||||
@ensure_cv2_image_for_standalone_function
|
||||
def tint_image(
|
||||
image: ImageType,
|
||||
color: Color = Color.BLACK,
|
||||
opacity: float = 0.5,
|
||||
) -> ImageType:
|
||||
"""
|
||||
Tint image with solid color overlay at specified opacity.
|
||||
|
||||
Args:
|
||||
image (`numpy.ndarray` or `PIL.Image.Image`): The image to tint.
|
||||
color (`Color`): Overlay tint color. Defaults to `Color.BLACK`.
|
||||
opacity (`float`): Blend ratio between overlay and image (0.0-1.0).
|
||||
Defaults to `0.5`.
|
||||
|
||||
Returns:
|
||||
(`numpy.ndarray` or `PIL.Image.Image`): Tinted image matching input
|
||||
type.
|
||||
|
||||
Raises:
|
||||
ValueError: If opacity is outside range [0.0, 1.0].
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread("source.png")
|
||||
tinted_image = sv.tint_image(
|
||||
image=image, color=sv.Color.ROBOFLOW, opacity=0.5
|
||||
)
|
||||
cv2.imwrite("target.png", tinted_image)
|
||||
```
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
import supervision as sv
|
||||
|
||||
image = Image.open("source.png")
|
||||
tinted_image = sv.tint_image(
|
||||
image=image, color=sv.Color.ROBOFLOW, opacity=0.5
|
||||
)
|
||||
tinted_image.save("target.png")
|
||||
```
|
||||
|
||||
{ align=center width="1000" }
|
||||
""" # noqa E501 // docs
|
||||
if not 0.0 <= opacity <= 1.0:
|
||||
raise ValueError("opacity must be between 0.0 and 1.0")
|
||||
|
||||
overlay = np.full_like(image, fill_value=color.as_bgr(), dtype=image.dtype)
|
||||
cv2.addWeighted(
|
||||
src1=overlay, alpha=opacity, src2=image, beta=1 - opacity, gamma=0, dst=image
|
||||
)
|
||||
return image
|
||||
|
||||
|
||||
@ensure_cv2_image_for_standalone_function
|
||||
def grayscale_image(image: ImageType) -> ImageType:
|
||||
"""
|
||||
Convert image to 3-channel grayscale. Luminance channel is broadcast to
|
||||
all three channels for compatibility with color-based drawing helpers.
|
||||
|
||||
Args:
|
||||
image (`numpy.ndarray` or `PIL.Image.Image`): The image to convert to
|
||||
grayscale.
|
||||
|
||||
Returns:
|
||||
(`numpy.ndarray` or `PIL.Image.Image`): 3-channel grayscale image
|
||||
matching input type.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread("source.png")
|
||||
grayscale_image = sv.grayscale_image(image=image)
|
||||
cv2.imwrite("target.png", grayscale_image)
|
||||
```
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
import supervision as sv
|
||||
|
||||
image = Image.open("source.png")
|
||||
grayscale_image = sv.grayscale_image(image=image)
|
||||
grayscale_image.save("target.png")
|
||||
```
|
||||
|
||||
{ align=center width="1000" }
|
||||
""" # noqa E501 // docs
|
||||
grayscaled = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
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,
|
||||
@@ -379,27 +549,64 @@ class ImageSink:
|
||||
image_name_pattern: str = "image_{:05d}.png",
|
||||
):
|
||||
"""
|
||||
Initialize a context manager for saving images.
|
||||
Initialize context manager for saving images to directory.
|
||||
|
||||
Args:
|
||||
target_dir_path (str): The target directory where images will be saved.
|
||||
overwrite (bool): Whether to overwrite the existing directory.
|
||||
Defaults to False.
|
||||
image_name_pattern (str): The image file name pattern.
|
||||
Defaults to "image_{:05d}.png".
|
||||
target_dir_path (`str`): Target directory path where images will be
|
||||
saved.
|
||||
overwrite (`bool`): Whether to overwrite existing directory.
|
||||
Defaults to `False`.
|
||||
image_name_pattern (`str`): File name pattern for saved images.
|
||||
Defaults to `"image_{:05d}.png"`.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
frames_generator = sv.get_video_frames_generator(<SOURCE_VIDEO_PATH>, stride=2)
|
||||
frames_generator = sv.get_video_frames_generator(
|
||||
"source.mp4", stride=2
|
||||
)
|
||||
|
||||
with sv.ImageSink(target_dir_path=<TARGET_CROPS_DIRECTORY>) as sink:
|
||||
with sv.ImageSink(target_dir_path="output_frames") as sink:
|
||||
for image in frames_generator:
|
||||
sink.save_image(image=image)
|
||||
```
|
||||
""" # noqa E501 // docs
|
||||
|
||||
# Directory structure:
|
||||
# output_frames/
|
||||
# ├── image_00000.png
|
||||
# ├── image_00001.png
|
||||
# ├── image_00002.png
|
||||
# └── image_00003.png
|
||||
```
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread("source.png")
|
||||
crop_boxes = [
|
||||
( 0, 0, 400, 400),
|
||||
(400, 0, 800, 400),
|
||||
( 0, 400, 400, 800),
|
||||
(400, 400, 800, 800)
|
||||
]
|
||||
|
||||
with sv.ImageSink(
|
||||
target_dir_path="image_crops",
|
||||
overwrite=True
|
||||
) as sink:
|
||||
for i, xyxy in enumerate(crop_boxes):
|
||||
crop = sv.crop_image(image=image, xyxy=xyxy)
|
||||
sink.save_image(image=crop, image_name=f"crop_{i}.png")
|
||||
|
||||
# Directory structure:
|
||||
# image_crops/
|
||||
# ├── crop_0.png
|
||||
# ├── crop_1.png
|
||||
# ├── crop_2.png
|
||||
# └── crop_3.png
|
||||
```
|
||||
"""
|
||||
self.target_dir_path = target_dir_path
|
||||
self.overwrite = overwrite
|
||||
self.image_name_pattern = image_name_pattern
|
||||
@@ -417,14 +624,14 @@ class ImageSink:
|
||||
|
||||
def save_image(self, image: np.ndarray, image_name: str | None = None):
|
||||
"""
|
||||
Save a given image in the target directory.
|
||||
Save image to target directory with optional custom filename.
|
||||
|
||||
Args:
|
||||
image (np.ndarray): The image to be saved. The image must be in BGR color
|
||||
format.
|
||||
image_name (Optional[str]): The name to use for the saved image.
|
||||
If not provided, a name will be
|
||||
generated using the `image_name_pattern`.
|
||||
image (`numpy.array`): Image to save with shape `(height, width, 3)`
|
||||
in BGR format.
|
||||
image_name (`str` or `None`): Custom filename for saved image. If
|
||||
`None`, generates name using `image_name_pattern`. Defaults to
|
||||
`None`.
|
||||
"""
|
||||
if image_name is None:
|
||||
image_name = self.image_name_pattern.format(self.image_count)
|
||||
@@ -437,6 +644,11 @@ class ImageSink:
|
||||
pass
|
||||
|
||||
|
||||
@deprecated(
|
||||
target=None,
|
||||
deprecated_in="0.27.0",
|
||||
remove_in="0.31.0",
|
||||
)
|
||||
def create_tiles(
|
||||
images: list[ImageType],
|
||||
grid_size: tuple[int | None, int | None] | None = None,
|
||||
|
||||
@@ -113,30 +113,6 @@ def deprecated_parameter(
|
||||
return decorator
|
||||
|
||||
|
||||
def deprecated(reason: str):
|
||||
def decorator(cls_or_func):
|
||||
if inspect.isclass(cls_or_func):
|
||||
original_init = cls_or_func.__init__
|
||||
|
||||
@functools.wraps(original_init)
|
||||
def new_init(self, *args, **kwargs):
|
||||
warn_deprecated(f"{cls_or_func.__name__} is deprecated: {reason}")
|
||||
original_init(self, *args, **kwargs)
|
||||
|
||||
cls_or_func.__init__ = new_init
|
||||
return cls_or_func
|
||||
else:
|
||||
|
||||
@functools.wraps(cls_or_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
warn_deprecated(f"{cls_or_func.__name__} is deprecated: {reason}")
|
||||
return cls_or_func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import cv2
|
||||
import matplotlib.pyplot as plt
|
||||
from PIL import Image
|
||||
|
||||
from supervision.annotators.base import ImageType
|
||||
from supervision.draw.base import ImageType
|
||||
from supervision.utils.conversion import pillow_to_cv2
|
||||
|
||||
|
||||
|
||||
+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:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from deprecate import deprecated
|
||||
|
||||
|
||||
def validate_xyxy(xyxy: Any) -> None:
|
||||
@@ -53,7 +54,7 @@ def validate_confidence(confidence: Any, n: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
def validate_keypoint_confidence(confidence: Any, n: int, m: int) -> None:
|
||||
def validate_key_point_confidence(confidence: Any, n: int, m: int) -> None:
|
||||
expected_shape = f"({n, m})"
|
||||
actual_shape = str(getattr(confidence, "shape", None))
|
||||
|
||||
@@ -66,6 +67,15 @@ def validate_keypoint_confidence(confidence: Any, n: int, m: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
@deprecated(
|
||||
target=validate_key_point_confidence,
|
||||
deprecated_in="0.27.0",
|
||||
remove_in="0.31.0",
|
||||
)
|
||||
def validate_keypoint_confidence(confidence: Any, n: int, m: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def validate_tracker_id(tracker_id: Any, n: int) -> None:
|
||||
expected_shape = f"({n},)"
|
||||
actual_shape = str(getattr(tracker_id, "shape", None))
|
||||
@@ -126,7 +136,7 @@ def validate_detections_fields(
|
||||
validate_data(data, n)
|
||||
|
||||
|
||||
def validate_keypoints_fields(
|
||||
def validate_key_points_fields(
|
||||
xy: Any,
|
||||
class_id: Any,
|
||||
confidence: Any,
|
||||
@@ -136,10 +146,21 @@ def validate_keypoints_fields(
|
||||
m = len(xy[0]) if len(xy) > 0 else 0
|
||||
validate_xy(xy, n, m)
|
||||
validate_class_id(class_id, n)
|
||||
validate_keypoint_confidence(confidence, n, m)
|
||||
validate_key_point_confidence(confidence, n, m)
|
||||
validate_data(data, n)
|
||||
|
||||
|
||||
@deprecated(
|
||||
target=validate_key_points_fields,
|
||||
deprecated_in="0.27.0",
|
||||
remove_in="0.31.0",
|
||||
)
|
||||
def validate_keypoints_fields(
|
||||
xy: Any, class_id: Any, confidence: Any, data: dict[str, Any]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def validate_resolution(resolution: Any) -> tuple[int, int]:
|
||||
if not (isinstance(resolution, tuple) and len(resolution) == 2):
|
||||
raise ValueError(
|
||||
|
||||
@@ -5,7 +5,7 @@ from contextlib import ExitStack as DoesNotRaise
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.annotators.utils import ColorLookup, resolve_color_idx
|
||||
from supervision.annotators.utils import ColorLookup, resolve_color_idx, wrap_text
|
||||
from supervision.detection.core import Detections
|
||||
from test.test_utils import mock_detections
|
||||
|
||||
@@ -97,7 +97,7 @@ from test.test_utils import mock_detections
|
||||
def test_resolve_color_idx(
|
||||
detections: Detections,
|
||||
detection_idx: int,
|
||||
color_lookup: ColorLookup,
|
||||
color_lookup: ColorLookup | np.ndarray,
|
||||
expected_result: int | None,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
@@ -108,3 +108,67 @@ def test_resolve_color_idx(
|
||||
color_lookup=color_lookup,
|
||||
)
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text, max_line_length, expected_result, exception",
|
||||
[
|
||||
(None, None, [""], DoesNotRaise()), # text is None
|
||||
("", None, [""], DoesNotRaise()), # empty string
|
||||
(" \t ", 3, [""], DoesNotRaise()), # whitespace-only (spaces + tab)
|
||||
(12345, None, ["12345"], DoesNotRaise()), # plain integer
|
||||
(-6789, None, ["-6789"], DoesNotRaise()), # negative integer
|
||||
(np.int64(1000), None, ["1000"], DoesNotRaise()), # NumPy int64
|
||||
([1, 2, 3], None, ["[1, 2, 3]"], DoesNotRaise()), # list to string
|
||||
(
|
||||
"When you play the game of thrones, you win or you die.\nFear cuts deeper than swords.\nA mind needs books as a sword needs a whetstone.", # noqa: E501
|
||||
None,
|
||||
[
|
||||
"When you play the game of thrones, you win or you die.",
|
||||
"Fear cuts deeper than swords.",
|
||||
"A mind needs books as a sword needs a whetstone.",
|
||||
],
|
||||
DoesNotRaise(),
|
||||
), # Game-of-Thrones quotes, multiline
|
||||
("\n", None, [""], DoesNotRaise()), # single newline
|
||||
(
|
||||
"valarmorghulisvalardoharis",
|
||||
6,
|
||||
["valarm", "orghul", "isvala", "rdohar", "is"],
|
||||
DoesNotRaise(),
|
||||
), # long Valyrian phrase, wrapped
|
||||
(
|
||||
"Winter is coming\nFire and blood",
|
||||
10,
|
||||
[
|
||||
"Winter is",
|
||||
"coming",
|
||||
"Fire and",
|
||||
"blood",
|
||||
],
|
||||
DoesNotRaise(),
|
||||
), # mix of short/long with newline
|
||||
(
|
||||
"What is dead may never die",
|
||||
0,
|
||||
None,
|
||||
pytest.raises(ValueError),
|
||||
), # width 0 - invalid
|
||||
(
|
||||
"A Lannister always pays his debts",
|
||||
-1,
|
||||
None,
|
||||
pytest.raises(ValueError),
|
||||
), # width -1 - invalid
|
||||
(None, 10, [""], DoesNotRaise()), # text None, width set
|
||||
],
|
||||
)
|
||||
def test_wrap_text(
|
||||
text: object,
|
||||
max_line_length: int | None,
|
||||
expected_result: list[str],
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
with exception:
|
||||
result = wrap_text(text=text, max_line_length=max_line_length)
|
||||
assert result == expected_result
|
||||
|
||||
+151
-4
@@ -6,7 +6,10 @@ from contextlib import nullcontext as does_not_raise
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.config import CLASS_NAME_DATA_FIELD
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.detection.vlm import (
|
||||
VLM,
|
||||
from_florence_2,
|
||||
from_google_gemini_2_0,
|
||||
from_google_gemini_2_5,
|
||||
@@ -317,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
|
||||
@@ -327,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
|
||||
@@ -339,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(
|
||||
@@ -1122,3 +1162,110 @@ def test_from_google_gemini_2_5(
|
||||
assert masks is not None
|
||||
assert masks.shape == expected_results[4].shape
|
||||
assert np.array_equal(masks, expected_results[4])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception, result, resolution_wh, classes, expected_detections",
|
||||
[
|
||||
(
|
||||
pytest.raises(ValueError),
|
||||
"",
|
||||
(100, 100),
|
||||
None,
|
||||
None,
|
||||
), # empty text
|
||||
(
|
||||
pytest.raises(ValueError),
|
||||
"random text",
|
||||
(100, 100),
|
||||
None,
|
||||
None,
|
||||
), # random text
|
||||
(
|
||||
does_not_raise(),
|
||||
"<|ref|>cat<|/ref|><|det|>[[100, 200, 300, 400]]<|/det|>",
|
||||
(1000, 1000),
|
||||
None,
|
||||
Detections(
|
||||
xyxy=np.array([[100.1, 200.2, 300.3, 400.4]]),
|
||||
class_id=np.array([0]),
|
||||
data={CLASS_NAME_DATA_FIELD: np.array(["cat"])},
|
||||
),
|
||||
), # single box, no classes
|
||||
(
|
||||
does_not_raise(),
|
||||
"<|ref|>cat<|/ref|><|det|>[[100, 200, 300, 400]]<|/det|>",
|
||||
(1000, 1000),
|
||||
["cat", "dog"],
|
||||
Detections(
|
||||
xyxy=np.array([[100.1, 200.2, 300.3, 400.4]]),
|
||||
class_id=np.array([0]),
|
||||
data={CLASS_NAME_DATA_FIELD: np.array(["cat"])},
|
||||
),
|
||||
), # single box, with classes
|
||||
(
|
||||
does_not_raise(),
|
||||
"<|ref|>person<|/ref|><|det|>[[100, 200, 300, 400]]<|/det|>",
|
||||
(1000, 1000),
|
||||
["cat", "dog"],
|
||||
Detections.empty(),
|
||||
), # single box, wrong class
|
||||
(
|
||||
does_not_raise(),
|
||||
(
|
||||
"<|ref|>cat<|/ref|><|det|>[[100, 200, 300, 400]]<|/det|>"
|
||||
"<|ref|>dog<|/ref|><|det|>[[500, 600, 700, 800]]<|/det|>"
|
||||
),
|
||||
(1000, 1000),
|
||||
["cat"],
|
||||
Detections(
|
||||
xyxy=np.array([[100.1, 200.2, 300.3, 400.4]]),
|
||||
class_id=np.array([0]),
|
||||
data={CLASS_NAME_DATA_FIELD: np.array(["cat"])},
|
||||
),
|
||||
), # multiple boxes, one class correct
|
||||
(
|
||||
pytest.raises(ValueError),
|
||||
"<|ref|>cat<|/ref|>",
|
||||
(100, 100),
|
||||
None,
|
||||
None,
|
||||
), # only ref
|
||||
(
|
||||
pytest.raises(ValueError),
|
||||
"<|det|>[[100, 200, 300, 400]]<|/det|>",
|
||||
(100, 100),
|
||||
None,
|
||||
None,
|
||||
), # only det
|
||||
],
|
||||
)
|
||||
def test_from_deepseek_vl_2(
|
||||
exception,
|
||||
result: str,
|
||||
resolution_wh: tuple[int, int],
|
||||
classes: list[str] | None,
|
||||
expected_detections: Detections,
|
||||
):
|
||||
with exception:
|
||||
detections = Detections.from_vlm(
|
||||
vlm=VLM.DEEPSEEK_VL_2,
|
||||
result=result,
|
||||
resolution_wh=resolution_wh,
|
||||
classes=classes,
|
||||
)
|
||||
|
||||
if expected_detections is None:
|
||||
return
|
||||
|
||||
assert len(detections) == len(expected_detections)
|
||||
|
||||
if len(detections) == 0:
|
||||
return
|
||||
|
||||
assert np.allclose(detections.xyxy, expected_detections.xyxy, atol=1e-1)
|
||||
assert np.array_equal(detections.class_id, expected_detections.class_id)
|
||||
assert np.array_equal(
|
||||
detections.data[CLASS_NAME_DATA_FIELD],
|
||||
expected_detections.data[CLASS_NAME_DATA_FIELD],
|
||||
)
|
||||
|
||||
@@ -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,6 +6,7 @@ import pytest
|
||||
from supervision.detection.utils.converters import (
|
||||
xcycwh_to_xyxy,
|
||||
xywh_to_xyxy,
|
||||
xyxy_to_mask,
|
||||
xyxy_to_xcycarh,
|
||||
xyxy_to_xywh,
|
||||
)
|
||||
@@ -129,3 +130,174 @@ def test_xyxy_to_xcycarh(xyxy: np.ndarray, expected_result: np.ndarray) -> None:
|
||||
def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None:
|
||||
result = xcycwh_to_xyxy(xcycwh)
|
||||
np.testing.assert_array_equal(result, expected_result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"boxes,resolution_wh,expected",
|
||||
[
|
||||
# 0) Empty input
|
||||
(
|
||||
np.array([], dtype=float).reshape(0, 4),
|
||||
(5, 4),
|
||||
np.array([], dtype=bool).reshape(0, 4, 5),
|
||||
),
|
||||
# 1) Single pixel box
|
||||
(
|
||||
np.array([[2, 1, 2, 1]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[False, False, False, False, False],
|
||||
[False, False, True, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 2) Horizontal line, inclusive bounds
|
||||
(
|
||||
np.array([[1, 2, 3, 2]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, True, True, True, False],
|
||||
[False, False, False, False, False],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 3) Vertical line, inclusive bounds
|
||||
(
|
||||
np.array([[3, 0, 3, 2]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[False, False, False, True, False],
|
||||
[False, False, False, True, False],
|
||||
[False, False, False, True, False],
|
||||
[False, False, False, False, False],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 4) Proper rectangle fill
|
||||
(
|
||||
np.array([[1, 1, 3, 2]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[False, False, False, False, False],
|
||||
[False, True, True, True, False],
|
||||
[False, True, True, True, False],
|
||||
[False, False, False, False, False],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 5) Negative coordinates clipped to [0, 0]
|
||||
(
|
||||
np.array([[-2, -1, 1, 1]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[True, True, False, False, False],
|
||||
[True, True, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 6) Overflow coordinates clipped to width-1 and height-1
|
||||
(
|
||||
np.array([[3, 2, 10, 10]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, True, True],
|
||||
[False, False, False, True, True],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 7) Invalid box where max < min after ints, mask stays empty
|
||||
(
|
||||
np.array([[3, 2, 1, 4]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 8) Fractional coordinates are floored by int conversion
|
||||
# (0.2,0.2)-(2.8,1.9) -> (0,0)-(2,1)
|
||||
(
|
||||
np.array([[0.2, 0.2, 2.8, 1.9]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[True, True, True, False, False],
|
||||
[True, True, True, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 9) Multiple boxes, separate masks
|
||||
(
|
||||
np.array([[0, 0, 1, 0], [2, 1, 4, 3]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
# Box 0: row 0, cols 0..1
|
||||
[
|
||||
[True, True, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
],
|
||||
# Box 1: rows 1..3, cols 2..4
|
||||
[
|
||||
[False, False, False, False, False],
|
||||
[False, False, True, True, True],
|
||||
[False, False, True, True, True],
|
||||
[False, False, True, True, True],
|
||||
],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_xyxy_to_mask(boxes: np.ndarray, resolution_wh, expected: np.ndarray) -> None:
|
||||
result = xyxy_to_mask(boxes, resolution_wh)
|
||||
assert result.dtype == np.bool_
|
||||
assert result.shape == expected.shape
|
||||
np.testing.assert_array_equal(result, expected)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import pytest
|
||||
|
||||
from supervision.detection.utils.vlms import edit_distance, fuzzy_match_index
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"string_1, string_2, case_sensitive, expected_result",
|
||||
[
|
||||
# identical strings, various cases
|
||||
("hello", "hello", True, 0),
|
||||
("hello", "hello", False, 0),
|
||||
# case sensitive vs insensitive
|
||||
("Test", "test", True, 1),
|
||||
("Test", "test", False, 0),
|
||||
("CASE", "case", True, 4),
|
||||
("CASE", "case", False, 0),
|
||||
# completely different
|
||||
("abc", "xyz", True, 3),
|
||||
("abc", "xyz", False, 3),
|
||||
# one string empty
|
||||
("hello", "", True, 5),
|
||||
("", "world", True, 5),
|
||||
# single character cases
|
||||
("a", "b", True, 1),
|
||||
("A", "a", True, 1),
|
||||
("A", "a", False, 0),
|
||||
# whitespaces
|
||||
("hello world", "helloworld", True, 1),
|
||||
("test", " test", True, 1),
|
||||
# unicode and emoji
|
||||
("😊", "😊", True, 0),
|
||||
("😊", "😢", True, 1),
|
||||
# long string vs empty
|
||||
("a" * 100, "", True, 100),
|
||||
("", "b" * 100, True, 100),
|
||||
# prefix/suffix
|
||||
("prefix", "prefixes", True, 2),
|
||||
("suffix", "asuffix", True, 1),
|
||||
# leading/trailing whitespace
|
||||
(" hello", "hello", True, 1),
|
||||
("hello", "hello ", True, 1),
|
||||
# long almost-equal string
|
||||
(
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
"The quick brown fox jumps over the lazy cog",
|
||||
True,
|
||||
1,
|
||||
),
|
||||
(
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
"The quick brown fox jumps over the lazy cog",
|
||||
False,
|
||||
1,
|
||||
),
|
||||
# both empty
|
||||
("", "", True, 0),
|
||||
("", "", False, 0),
|
||||
# mixed case with symbols
|
||||
("123ABC!", "123abc!", True, 3),
|
||||
("123ABC!", "123abc!", False, 0),
|
||||
],
|
||||
)
|
||||
def test_edit_distance(string_1, string_2, case_sensitive, expected_result):
|
||||
assert (
|
||||
edit_distance(string_1, string_2, case_sensitive=case_sensitive)
|
||||
== expected_result
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"candidates, query, threshold, case_sensitive, expected_result",
|
||||
[
|
||||
# exact match at index 0
|
||||
(["cat", "dog", "rat"], "cat", 0, True, 0),
|
||||
# match at index 2 within threshold
|
||||
(["cat", "dog", "rat"], "dat", 1, True, 0),
|
||||
# no match due to high threshold
|
||||
(["cat", "dog", "rat"], "bat", 0, True, None),
|
||||
# multiple possible matches, returns first
|
||||
(["apple", "apply", "appla"], "apple", 1, True, 0),
|
||||
# case-insensitive match
|
||||
(["Alpha", "beta", "Gamma"], "alpha", 0, False, 0),
|
||||
# case-sensitive: no match
|
||||
(["Alpha", "beta", "Gamma"], "alpha", 0, True, None),
|
||||
# threshold boundary
|
||||
(["alpha", "beta", "gamma"], "bata", 1, True, 1),
|
||||
# no match (all distances too high)
|
||||
(["one", "two", "three"], "ten", 1, True, None),
|
||||
# unicode/emoji match
|
||||
(["😊", "😢", "😁"], "😄", 1, True, 0),
|
||||
(["😊", "😢", "😁"], "😊", 0, True, 0),
|
||||
# empty candidates
|
||||
([], "any", 2, True, None),
|
||||
# empty query, non-empty candidates
|
||||
(["", "abc"], "", 0, True, 0),
|
||||
(["", "abc"], "", 1, True, 0),
|
||||
(["a", "b", "c"], "", 1, True, 0),
|
||||
# non-empty query, empty candidate
|
||||
(["", ""], "a", 1, True, 0),
|
||||
# all candidates require higher edit than threshold
|
||||
(["short", "words", "only"], "longerword", 2, True, None),
|
||||
# repeated candidates
|
||||
(["a", "a", "a"], "b", 1, True, 0),
|
||||
],
|
||||
)
|
||||
def test_fuzzy_match_index(
|
||||
candidates, query, threshold, case_sensitive, expected_result
|
||||
):
|
||||
assert (
|
||||
fuzzy_match_index(
|
||||
candidates=candidates,
|
||||
query=query,
|
||||
threshold=threshold,
|
||||
case_sensitive=case_sensitive,
|
||||
)
|
||||
== expected_result
|
||||
)
|
||||
@@ -0,0 +1,268 @@
|
||||
from contextlib import nullcontext as DoesNotRaise
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.key_points.core import KeyPoints
|
||||
from test.test_utils import mock_key_points
|
||||
|
||||
KEY_POINTS = mock_key_points(
|
||||
xy=[
|
||||
[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]],
|
||||
[[10, 11], [12, 13], [14, 15], [16, 17], [18, 19]],
|
||||
[[20, 21], [22, 23], [24, 25], [26, 27], [28, 29]],
|
||||
],
|
||||
confidence=[
|
||||
[0.8, 0.2, 0.6, 0.1, 0.5],
|
||||
[0.7, 0.9, 0.3, 0.4, 0.0],
|
||||
[0.1, 0.6, 0.8, 0.2, 0.7],
|
||||
],
|
||||
class_id=[0, 1, 2],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key_points, index, expected_result, exception",
|
||||
[
|
||||
(
|
||||
KeyPoints.empty(),
|
||||
slice(None),
|
||||
KeyPoints.empty(),
|
||||
DoesNotRaise(),
|
||||
), # slice all key points when key points object empty
|
||||
(
|
||||
KEY_POINTS,
|
||||
slice(None),
|
||||
KEY_POINTS,
|
||||
DoesNotRaise(),
|
||||
), # slice all key points when key points object nonempty
|
||||
(
|
||||
KEY_POINTS,
|
||||
slice(0, 1),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]]],
|
||||
confidence=[[0.8, 0.2, 0.6, 0.1, 0.5]],
|
||||
class_id=[0],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first skeleton by slice
|
||||
(
|
||||
KEY_POINTS,
|
||||
slice(0, 2),
|
||||
mock_key_points(
|
||||
xy=[
|
||||
[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]],
|
||||
[[10, 11], [12, 13], [14, 15], [16, 17], [18, 19]],
|
||||
],
|
||||
confidence=[
|
||||
[0.8, 0.2, 0.6, 0.1, 0.5],
|
||||
[0.7, 0.9, 0.3, 0.4, 0.0],
|
||||
],
|
||||
class_id=[0, 1],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first skeleton by slice
|
||||
(
|
||||
KEY_POINTS,
|
||||
0,
|
||||
mock_key_points(
|
||||
xy=[[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]]],
|
||||
confidence=[[0.8, 0.2, 0.6, 0.1, 0.5]],
|
||||
class_id=[0],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first skeleton by index
|
||||
(
|
||||
KEY_POINTS,
|
||||
-1,
|
||||
mock_key_points(
|
||||
xy=[[[20, 21], [22, 23], [24, 25], [26, 27], [28, 29]]],
|
||||
confidence=[[0.1, 0.6, 0.8, 0.2, 0.7]],
|
||||
class_id=[2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the last skeleton by index
|
||||
(
|
||||
KEY_POINTS,
|
||||
[0, 1],
|
||||
mock_key_points(
|
||||
xy=[
|
||||
[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]],
|
||||
[[10, 11], [12, 13], [14, 15], [16, 17], [18, 19]],
|
||||
],
|
||||
confidence=[
|
||||
[0.8, 0.2, 0.6, 0.1, 0.5],
|
||||
[0.7, 0.9, 0.3, 0.4, 0.0],
|
||||
],
|
||||
class_id=[0, 1],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first two skeletons by index; list
|
||||
(
|
||||
KEY_POINTS,
|
||||
np.array([0, 1]),
|
||||
mock_key_points(
|
||||
xy=[
|
||||
[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]],
|
||||
[[10, 11], [12, 13], [14, 15], [16, 17], [18, 19]],
|
||||
],
|
||||
confidence=[
|
||||
[0.8, 0.2, 0.6, 0.1, 0.5],
|
||||
[0.7, 0.9, 0.3, 0.4, 0.0],
|
||||
],
|
||||
class_id=[0, 1],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first two skeletons by index; np.array
|
||||
(
|
||||
KEY_POINTS,
|
||||
[True, True, False],
|
||||
mock_key_points(
|
||||
xy=[
|
||||
[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]],
|
||||
[[10, 11], [12, 13], [14, 15], [16, 17], [18, 19]],
|
||||
],
|
||||
confidence=[
|
||||
[0.8, 0.2, 0.6, 0.1, 0.5],
|
||||
[0.7, 0.9, 0.3, 0.4, 0.0],
|
||||
],
|
||||
class_id=[0, 1],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select only skeletons associated with positive filter; list
|
||||
(
|
||||
KEY_POINTS,
|
||||
np.array([True, True, False]),
|
||||
mock_key_points(
|
||||
xy=[
|
||||
[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]],
|
||||
[[10, 11], [12, 13], [14, 15], [16, 17], [18, 19]],
|
||||
],
|
||||
confidence=[
|
||||
[0.8, 0.2, 0.6, 0.1, 0.5],
|
||||
[0.7, 0.9, 0.3, 0.4, 0.0],
|
||||
],
|
||||
class_id=[0, 1],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select only skeletons associated with positive filter; list
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), slice(None)),
|
||||
KEY_POINTS,
|
||||
DoesNotRaise(),
|
||||
), # slice all anchors from all skeletons
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), slice(0, 1)),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1]], [[10, 11]], [[20, 21]]],
|
||||
confidence=[[0.8], [0.7], [0.1]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # slice the first anchor from every skeleton
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), slice(0, 2)),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1], [2, 3]], [[10, 11], [12, 13]], [[20, 21], [22, 23]]],
|
||||
confidence=[[0.8, 0.2], [0.7, 0.9], [0.1, 0.6]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # slice the first anchor two anchors from every skeleton
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), 0),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1]], [[10, 11]], [[20, 21]]],
|
||||
confidence=[[0.8], [0.7], [0.1]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first anchor from every skeleton by index
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), -1),
|
||||
mock_key_points(
|
||||
xy=[[[8, 9]], [[18, 19]], [[28, 29]]],
|
||||
confidence=[[0.5], [0.0], [0.7]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the last anchor from every skeleton by index
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), [0, 1]),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1], [2, 3]], [[10, 11], [12, 13]], [[20, 21], [22, 23]]],
|
||||
confidence=[[0.8, 0.2], [0.7, 0.9], [0.1, 0.6]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first two anchors from every skeleton by index; list
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), np.array([0, 1])),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1], [2, 3]], [[10, 11], [12, 13]], [[20, 21], [22, 23]]],
|
||||
confidence=[[0.8, 0.2], [0.7, 0.9], [0.1, 0.6]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first two anchors from every skeleton by index; np.array
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), [True, True, False, False, False]),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1], [2, 3]], [[10, 11], [12, 13]], [[20, 21], [22, 23]]],
|
||||
confidence=[[0.8, 0.2], [0.7, 0.9], [0.1, 0.6]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select only anchors associated with positive filter; list
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), np.array([True, True, False, False, False])),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1], [2, 3]], [[10, 11], [12, 13]], [[20, 21], [22, 23]]],
|
||||
confidence=[[0.8, 0.2], [0.7, 0.9], [0.1, 0.6]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select only anchors associated with positive filter; np.array
|
||||
(
|
||||
KEY_POINTS,
|
||||
(0, 0),
|
||||
mock_key_points(
|
||||
xy=[
|
||||
[[0, 1]],
|
||||
],
|
||||
confidence=[
|
||||
[0.8],
|
||||
],
|
||||
class_id=[0],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first anchor from the first skeleton by index
|
||||
(
|
||||
KEY_POINTS,
|
||||
(0, -1),
|
||||
mock_key_points(
|
||||
xy=[
|
||||
[[8, 9]],
|
||||
],
|
||||
confidence=[
|
||||
[0.5],
|
||||
],
|
||||
class_id=[0],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the last anchor from the first skeleton by index
|
||||
],
|
||||
)
|
||||
def test_key_points_getitem(key_points, index, expected_result, exception):
|
||||
with exception:
|
||||
result = key_points[index]
|
||||
assert result == expected_result
|
||||
@@ -417,31 +417,27 @@ def test_drop_extra_matches(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"recall, precision, expected_result, exception",
|
||||
"recall, precision, expected_result",
|
||||
[
|
||||
(
|
||||
np.array([1.0]),
|
||||
np.array([1.0]),
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # perfect recall and precision
|
||||
(
|
||||
np.array([0.0]),
|
||||
np.array([0.0]),
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # no recall and precision
|
||||
(
|
||||
np.array([0.0, 0.2, 0.2, 0.8, 0.8, 1.0]),
|
||||
np.array([0.7, 0.8, 0.4, 0.5, 0.1, 0.2]),
|
||||
0.5,
|
||||
DoesNotRaise(),
|
||||
),
|
||||
(
|
||||
np.array([0.0, 0.5, 0.5, 1.0]),
|
||||
np.array([0.75, 0.75, 0.75, 0.75]),
|
||||
0.75,
|
||||
DoesNotRaise(),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -449,10 +445,10 @@ def test_compute_average_precision(
|
||||
recall: np.ndarray,
|
||||
precision: np.ndarray,
|
||||
expected_result: float,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
with exception:
|
||||
result = MeanAveragePrecision.compute_average_precision(
|
||||
with DoesNotRaise():
|
||||
# FIX: Use .__func__ to support Python 3.9
|
||||
result = MeanAveragePrecision.compute_average_precision.__func__(
|
||||
recall=recall, precision=precision
|
||||
)
|
||||
assert_almost_equal(result, expected_result, tolerance=0.01)
|
||||
assert_almost_equal(result, expected_result, tolerance=0.01)
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
Tests for Mean Average Precision ID=0 bug fix
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.metrics.mean_average_precision import MeanAveragePrecision
|
||||
|
||||
|
||||
def test_single_perfect_detection():
|
||||
"""Test that single perfect detection gets 1.0 mAP (not 0.0 due to ID=0 bug)"""
|
||||
# Perfect detection (identical prediction and target)
|
||||
detection = Detections(
|
||||
xyxy=np.array([[10, 10, 50, 50]], dtype=np.float64),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.9]),
|
||||
)
|
||||
|
||||
metric = MeanAveragePrecision()
|
||||
metric.update([detection], [detection])
|
||||
result = metric.compute()
|
||||
|
||||
# Should be perfect 1.0 mAP, not 0.0 due to ID=0 bug
|
||||
assert abs(result.map50_95 - 1.0) < 1e-6
|
||||
|
||||
|
||||
def test_multiple_perfect_detections():
|
||||
"""Test that multiple perfect detections get 1.0 mAP"""
|
||||
# Multiple perfect detections in one image
|
||||
detections = Detections(
|
||||
xyxy=np.array(
|
||||
[[10, 10, 50, 50], [100, 100, 140, 140], [200, 200, 240, 240]],
|
||||
dtype=np.float64,
|
||||
),
|
||||
class_id=np.array([0, 0, 0]),
|
||||
confidence=np.array([0.9, 0.9, 0.9]),
|
||||
)
|
||||
|
||||
metric = MeanAveragePrecision()
|
||||
metric.update([detections], [detections])
|
||||
result = metric.compute()
|
||||
|
||||
# Should be perfect 1.0 mAP
|
||||
assert abs(result.map50_95 - 1.0) < 1e-6
|
||||
|
||||
|
||||
def test_batch_updates_perfect_detections():
|
||||
"""Test that batch updates with perfect detections get 1.0 mAP"""
|
||||
# Single perfect detection for multiple batch updates
|
||||
detection = Detections(
|
||||
xyxy=np.array([[10, 10, 50, 50]], dtype=np.float64),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.9]),
|
||||
)
|
||||
|
||||
metric = MeanAveragePrecision()
|
||||
# Add 3 batch updates
|
||||
metric.update([detection], [detection])
|
||||
metric.update([detection], [detection])
|
||||
metric.update([detection], [detection])
|
||||
result = metric.compute()
|
||||
|
||||
# Should be perfect 1.0 mAP across all batches
|
||||
assert abs(result.map50_95 - 1.0) < 1e-6
|
||||
|
||||
|
||||
def test_scenario_1_success_case_imperfect_match():
|
||||
"""Scenario 1: Success Case with imperfect match"""
|
||||
# Small object (class 0) - area = 30*30 = 900 < 1024
|
||||
small_perfect = Detections(
|
||||
xyxy=np.array([[10, 10, 40, 40]], dtype=np.float64),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.95]),
|
||||
data={"area": np.array([900])},
|
||||
)
|
||||
|
||||
# Medium object (class 1) - area = 50*50 = 2500 (between 1024 and 9216)
|
||||
medium_target = Detections(
|
||||
xyxy=np.array([[10, 10, 60, 60]], dtype=np.float64),
|
||||
class_id=np.array([1]),
|
||||
data={"area": np.array([2500])},
|
||||
)
|
||||
medium_pred = Detections(
|
||||
xyxy=np.array([[12, 12, 60, 60]], dtype=np.float64), # Slightly off
|
||||
class_id=np.array([1]),
|
||||
confidence=np.array([0.9]),
|
||||
data={"area": np.array([2304])}, # 48*48
|
||||
)
|
||||
|
||||
# Large objects (classes 0, 1, 2) - area = 100*100 = 10000 > 9216
|
||||
large_targets = Detections(
|
||||
xyxy=np.array(
|
||||
[[10, 10, 110, 110], [120, 120, 220, 220], [230, 230, 330, 330]],
|
||||
dtype=np.float64,
|
||||
),
|
||||
class_id=np.array([2, 0, 1]),
|
||||
data={"area": np.array([10000, 10000, 10000])},
|
||||
)
|
||||
large_preds = Detections(
|
||||
xyxy=np.array(
|
||||
[[10, 10, 110, 110], [120, 120, 220, 220], [230, 230, 330, 330]],
|
||||
dtype=np.float64,
|
||||
),
|
||||
class_id=np.array([2, 0, 1]),
|
||||
confidence=np.array([0.9, 0.9, 0.9]),
|
||||
data={"area": np.array([10000, 10000, 10000])},
|
||||
)
|
||||
|
||||
metric = MeanAveragePrecision()
|
||||
metric.update([small_perfect], [small_perfect])
|
||||
metric.update([medium_pred], [medium_target])
|
||||
metric.update([large_preds], [large_targets])
|
||||
result = metric.compute()
|
||||
|
||||
# Should be close to 0.9 (slightly less than perfect due to medium object)
|
||||
assert 0.85 < result.map50_95 < 0.98 # Adjusted upper bound
|
||||
assert result.medium_objects.map50_95 < 1.0 # Medium should be less than perfect
|
||||
|
||||
|
||||
def test_scenario_2_missed_detection():
|
||||
"""Scenario 2: GT Present, No Prediction (Missed Detection)"""
|
||||
# Small object - area = 30*30 = 900 < 1024
|
||||
small_detection = Detections(
|
||||
xyxy=np.array([[10, 10, 40, 40]], dtype=np.float64),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.95]),
|
||||
data={"area": np.array([900])},
|
||||
)
|
||||
|
||||
# Medium object - area = 50*50 = 2500 (between 1024 and 9216) - missed
|
||||
medium_target = Detections(
|
||||
xyxy=np.array([[10, 10, 60, 60]], dtype=np.float64),
|
||||
class_id=np.array([1]),
|
||||
data={"area": np.array([2500])},
|
||||
)
|
||||
no_medium_pred = Detections.empty()
|
||||
|
||||
# Large objects - area = 100*100 = 10000 > 9216
|
||||
large_detections = Detections(
|
||||
xyxy=np.array(
|
||||
[[10, 10, 110, 110], [120, 120, 220, 220], [230, 230, 330, 330]],
|
||||
dtype=np.float64,
|
||||
),
|
||||
class_id=np.array([2, 0, 1]),
|
||||
confidence=np.array([0.9, 0.9, 0.9]),
|
||||
data={"area": np.array([10000, 10000, 10000])},
|
||||
)
|
||||
|
||||
metric = MeanAveragePrecision()
|
||||
metric.update([small_detection], [small_detection])
|
||||
metric.update([no_medium_pred], [medium_target])
|
||||
metric.update([large_detections], [large_detections])
|
||||
result = metric.compute()
|
||||
|
||||
# Medium objects should have 0.0 mAP (missed detection)
|
||||
assert abs(result.medium_objects.map50_95 - 0.0) < 1e-6
|
||||
|
||||
|
||||
def test_scenario_3_false_positive():
|
||||
"""Scenario 3: No GT, Prediction Present (False Positive)"""
|
||||
# Small object - area = 30*30 = 900 < 1024
|
||||
small_detection = Detections(
|
||||
xyxy=np.array([[10, 10, 40, 40]], dtype=np.float64),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.95]),
|
||||
data={"area": np.array([900])},
|
||||
)
|
||||
|
||||
# Medium object - area = 50*50 = 2500 - false positive (no GT)
|
||||
medium_pred = Detections(
|
||||
xyxy=np.array([[12, 12, 62, 62]], dtype=np.float64),
|
||||
class_id=np.array([1]),
|
||||
confidence=np.array([0.9]),
|
||||
data={"area": np.array([2500])},
|
||||
)
|
||||
no_medium_target = Detections.empty()
|
||||
|
||||
# Large objects - area = 100*100 = 10000 > 9216
|
||||
large_detections = Detections(
|
||||
xyxy=np.array(
|
||||
[[10, 10, 110, 110], [120, 120, 220, 220], [230, 230, 330, 330]],
|
||||
dtype=np.float64,
|
||||
),
|
||||
class_id=np.array([2, 0, 1]),
|
||||
confidence=np.array([0.9, 0.9, 0.9]),
|
||||
data={"area": np.array([10000, 10000, 10000])},
|
||||
)
|
||||
|
||||
metric = MeanAveragePrecision()
|
||||
metric.update([small_detection], [small_detection])
|
||||
metric.update([medium_pred], [no_medium_target])
|
||||
metric.update([large_detections], [large_detections])
|
||||
result = metric.compute()
|
||||
|
||||
# Medium objects should have -1 mAP (false positive, matching pycocotools)
|
||||
assert result.medium_objects.map50_95 == -1
|
||||
|
||||
|
||||
def test_scenario_4_no_data():
|
||||
"""Scenario 4: No GT, No Prediction (Category has no data)"""
|
||||
# Small object - area = 30*30 = 900 < 1024
|
||||
small_detection = Detections(
|
||||
xyxy=np.array([[10, 10, 40, 40]], dtype=np.float64),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.95]),
|
||||
data={"area": np.array([900])},
|
||||
)
|
||||
|
||||
# Medium object - no data at all
|
||||
no_medium = Detections.empty()
|
||||
|
||||
# Large objects - area = 100*100 = 10000 > 9216 - only classes 0 and 2 (no class 1)
|
||||
large_targets = Detections(
|
||||
xyxy=np.array(
|
||||
[
|
||||
[10, 10, 110, 110],
|
||||
[120, 120, 220, 220],
|
||||
],
|
||||
dtype=np.float64,
|
||||
),
|
||||
class_id=np.array([2, 0]),
|
||||
data={"area": np.array([10000, 10000])},
|
||||
)
|
||||
large_preds = Detections(
|
||||
xyxy=np.array(
|
||||
[
|
||||
[10, 10, 110, 110],
|
||||
[120, 120, 220, 220],
|
||||
],
|
||||
dtype=np.float64,
|
||||
),
|
||||
class_id=np.array([2, 0]),
|
||||
confidence=np.array([0.9, 0.9]),
|
||||
data={"area": np.array([10000, 10000])},
|
||||
)
|
||||
|
||||
metric = MeanAveragePrecision()
|
||||
metric.update([small_detection], [small_detection])
|
||||
metric.update([no_medium], [no_medium])
|
||||
metric.update([large_preds], [large_targets])
|
||||
result = metric.compute()
|
||||
|
||||
# Should NOT have negative mAP values for overall
|
||||
assert result.map50_95 >= 0.0
|
||||
# Medium objects should have -1 mAP (no data, matching pycocotools)
|
||||
assert result.medium_objects.map50_95 == -1
|
||||
|
||||
|
||||
def test_scenario_5_only_one_class_present():
|
||||
"""Scenario 5: Only 1 of 3 Classes Present (Perfect Match)"""
|
||||
# Only class 0 objects with perfect matches
|
||||
detections_class_0 = [
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 40, 40]], dtype=np.float64),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.95]),
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[20, 20, 230, 130]], dtype=np.float64),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.9]),
|
||||
),
|
||||
]
|
||||
|
||||
metric = MeanAveragePrecision()
|
||||
for det in detections_class_0:
|
||||
metric.update([det], [det])
|
||||
|
||||
result = metric.compute()
|
||||
|
||||
# Should be 1.0 mAP (perfect match for the only class present)
|
||||
assert abs(result.map50_95 - 1.0) < 1e-6
|
||||
assert abs(result.map50 - 1.0) < 1e-6
|
||||
assert abs(result.map75 - 1.0) < 1e-6
|
||||
|
||||
|
||||
def test_mixed_classes_with_missing_detections():
|
||||
"""Test mixed scenario with some classes having no detections"""
|
||||
# Class 0: Perfect detection
|
||||
class_0_det = Detections(
|
||||
xyxy=np.array([[10, 10, 50, 50]], dtype=np.float64),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.9]),
|
||||
)
|
||||
|
||||
# Class 1: GT exists but no prediction
|
||||
class_1_target = Detections(
|
||||
xyxy=np.array([[60, 60, 100, 100]], dtype=np.float64),
|
||||
class_id=np.array([1]),
|
||||
)
|
||||
class_1_pred = Detections.empty()
|
||||
|
||||
# Class 2: Prediction exists but no GT (false positive)
|
||||
class_2_pred = Detections(
|
||||
xyxy=np.array([[110, 110, 150, 150]], dtype=np.float64),
|
||||
class_id=np.array([2]),
|
||||
confidence=np.array([0.8]),
|
||||
)
|
||||
class_2_target = Detections.empty()
|
||||
|
||||
metric = MeanAveragePrecision()
|
||||
metric.update([class_0_det], [class_0_det])
|
||||
metric.update([class_1_pred], [class_1_target])
|
||||
metric.update([class_2_pred], [class_2_target])
|
||||
result = metric.compute()
|
||||
|
||||
# Should not have negative mAP
|
||||
assert result.map50_95 >= 0.0
|
||||
# Should be less than 1.0 due to missed detection and false positive
|
||||
assert result.map50_95 < 1.0
|
||||
|
||||
|
||||
def test_empty_predictions_and_targets():
|
||||
"""Test completely empty predictions and targets"""
|
||||
metric = MeanAveragePrecision()
|
||||
metric.update([Detections.empty()], [Detections.empty()])
|
||||
result = metric.compute()
|
||||
|
||||
# Should return -1 for no data (matching pycocotools behavior)
|
||||
assert result.map50_95 == -1
|
||||
assert result.map50 == -1
|
||||
assert result.map75 == -1
|
||||
|
||||
# All object size categories should also be -1
|
||||
assert result.small_objects.map50_95 == -1
|
||||
assert result.medium_objects.map50_95 == -1
|
||||
assert result.large_objects.map50_95 == -1
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.metrics.mean_average_precision import MeanAveragePrecision
|
||||
|
||||
|
||||
class TestMeanAveragePrecisionArea:
|
||||
"""Test area calculation in MeanAveragePrecision."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"xyxy, expected_areas, expected_size_maps",
|
||||
[
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[10, 10, 40, 40], # Small: 900
|
||||
[100, 100, 200, 150], # Medium: 5000
|
||||
[300, 300, 500, 400], # Large: 20000
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
[900.0, 5000.0, 20000.0],
|
||||
{"small": True, "medium": True, "large": True},
|
||||
),
|
||||
(
|
||||
np.array([[0, 0, 10, 10]], dtype=np.float32), # Small: 100
|
||||
[100.0],
|
||||
{"small": True, "medium": False, "large": False},
|
||||
),
|
||||
(
|
||||
np.array([[0, 0, 50, 50]], dtype=np.float32), # Medium: 2500
|
||||
[2500.0],
|
||||
{"small": False, "medium": True, "large": False},
|
||||
),
|
||||
(
|
||||
np.array([[0, 0, 100, 100]], dtype=np.float32), # Large: 10000
|
||||
[10000.0],
|
||||
{"small": False, "medium": False, "large": True},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_area_calculation_and_size_specific_map(
|
||||
self, xyxy, expected_areas, expected_size_maps
|
||||
):
|
||||
"""Test area calculation and size-specific mAP functionality."""
|
||||
gt = Detections(
|
||||
xyxy=xyxy,
|
||||
class_id=np.arange(len(xyxy)),
|
||||
)
|
||||
pred = Detections(
|
||||
xyxy=gt.xyxy.copy(),
|
||||
class_id=gt.class_id.copy(),
|
||||
confidence=np.full(len(xyxy), 0.9),
|
||||
)
|
||||
|
||||
map_metric = MeanAveragePrecision()
|
||||
map_metric.update([pred], [gt])
|
||||
|
||||
# Test area calculation
|
||||
prepared_targets = map_metric._prepare_targets(map_metric._targets_list)
|
||||
areas = [ann["area"] for ann in prepared_targets["annotations"]]
|
||||
assert np.allclose(areas, expected_areas), (
|
||||
f"Expected {expected_areas}, got {areas}"
|
||||
)
|
||||
|
||||
# Test size-specific mAP
|
||||
result = map_metric.compute()
|
||||
|
||||
if expected_size_maps["small"]:
|
||||
assert result.small_objects.map50 > 0.9, (
|
||||
"Small objects should have high mAP"
|
||||
)
|
||||
else:
|
||||
assert result.small_objects.map50 == -1.0, (
|
||||
"Small objects should have no data"
|
||||
)
|
||||
|
||||
if expected_size_maps["medium"]:
|
||||
assert result.medium_objects.map50 > 0.9, (
|
||||
"Medium objects should have high mAP"
|
||||
)
|
||||
else:
|
||||
assert result.medium_objects.map50 == -1.0, (
|
||||
"Medium objects should have no data"
|
||||
)
|
||||
|
||||
if expected_size_maps["large"]:
|
||||
assert result.large_objects.map50 > 0.9, (
|
||||
"Large objects should have high mAP"
|
||||
)
|
||||
else:
|
||||
assert result.large_objects.map50 == -1.0, (
|
||||
"Large objects should have no data"
|
||||
)
|
||||
|
||||
def test_area_preserved_from_data(self):
|
||||
"""Test that area from data field is preserved (COCO case)."""
|
||||
gt = Detections(
|
||||
xyxy=np.array(
|
||||
[[100, 100, 200, 150]], dtype=np.float32
|
||||
), # Would calculate to 5000
|
||||
class_id=np.array([0]),
|
||||
)
|
||||
# Override with custom area
|
||||
gt.data = {"area": np.array([3000.0])}
|
||||
|
||||
pred = Detections(
|
||||
xyxy=gt.xyxy.copy(),
|
||||
class_id=gt.class_id.copy(),
|
||||
confidence=np.array([0.9]),
|
||||
)
|
||||
pred.data = {"area": np.array([3000.0])}
|
||||
|
||||
map_metric = MeanAveragePrecision()
|
||||
map_metric.update([pred], [gt])
|
||||
|
||||
prepared_targets = map_metric._prepare_targets(map_metric._targets_list)
|
||||
used_area = prepared_targets["annotations"][0]["area"]
|
||||
|
||||
assert np.allclose(used_area, 3000.0), (
|
||||
f"Should use provided area 3000.0, got {used_area}"
|
||||
)
|
||||
|
||||
# Verify it's different from what would be calculated
|
||||
calculated_area = (200 - 100) * (150 - 100) # 100 * 50 = 5000
|
||||
assert not np.allclose(used_area, calculated_area), (
|
||||
"Should use provided area, not calculated"
|
||||
)
|
||||
+50
-6
@@ -1,16 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.keypoint.core import KeyPoints
|
||||
from supervision.key_points.core import KeyPoints
|
||||
|
||||
|
||||
def mock_detections(
|
||||
xyxy: npt.NDArray[np.float32],
|
||||
xyxy: list[list[float]],
|
||||
mask: list[np.ndarray] | None = None,
|
||||
confidence: list[float] | None = None,
|
||||
class_id: list[int] | None = None,
|
||||
@@ -34,9 +34,9 @@ def mock_detections(
|
||||
)
|
||||
|
||||
|
||||
def mock_keypoints(
|
||||
xy: npt.NDArray[np.float32],
|
||||
confidence: list[float] | None = None,
|
||||
def mock_key_points(
|
||||
xy: list[list[list[float]]],
|
||||
confidence: list[list[float]] | None = None,
|
||||
class_id: list[int] | None = None,
|
||||
data: dict[str, list[Any]] | None = None,
|
||||
) -> KeyPoints:
|
||||
@@ -53,5 +53,49 @@ def mock_keypoints(
|
||||
)
|
||||
|
||||
|
||||
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}."
|
||||
|
||||
@@ -3,7 +3,7 @@ from PIL import Image, ImageChops
|
||||
|
||||
from supervision.utils.conversion import (
|
||||
cv2_to_pillow,
|
||||
ensure_cv2_image_for_processing,
|
||||
ensure_cv2_image_for_standalone_function,
|
||||
images_to_cv2,
|
||||
pillow_to_cv2,
|
||||
)
|
||||
@@ -16,7 +16,7 @@ def test_ensure_cv2_image_for_processing_when_pillow_image_submitted(
|
||||
param_a_value = 3
|
||||
param_b_value = "some"
|
||||
|
||||
@ensure_cv2_image_for_processing
|
||||
@ensure_cv2_image_for_standalone_function
|
||||
def my_custom_processing_function(
|
||||
image: np.ndarray,
|
||||
param_a: int,
|
||||
@@ -55,7 +55,7 @@ def test_ensure_cv2_image_for_processing_when_cv2_image_submitted(
|
||||
param_a_value = 3
|
||||
param_b_value = "some"
|
||||
|
||||
@ensure_cv2_image_for_processing
|
||||
@ensure_cv2_image_for_standalone_function
|
||||
def my_custom_processing_function(
|
||||
image: np.ndarray,
|
||||
param_a: int,
|
||||
|
||||
+60
-142
@@ -2,8 +2,12 @@ import numpy as np
|
||||
import pytest
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
from supervision import Color, Point
|
||||
from supervision.utils.image import create_tiles, 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:
|
||||
@@ -98,145 +102,59 @@ def test_letterbox_image_for_pillow_image() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_create_tiles_with_one_image(
|
||||
one_image: np.ndarray, single_image_tile: np.ndarray
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(images=[one_image], single_tile_size=(240, 240))
|
||||
|
||||
# # then
|
||||
assert np.allclose(result, single_image_tile, atol=5.0)
|
||||
@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
|
||||
|
||||
|
||||
def test_create_tiles_with_one_image_and_enforced_grid(
|
||||
one_image: np.ndarray, single_image_tile_enforced_grid: np.ndarray
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(
|
||||
images=[one_image],
|
||||
grid_size=(None, 3),
|
||||
single_tile_size=(240, 240),
|
||||
)
|
||||
|
||||
# then
|
||||
assert np.allclose(result, single_image_tile_enforced_grid, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_two_images(
|
||||
two_images: list[np.ndarray], two_images_tile: np.ndarray
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(images=two_images, single_tile_size=(240, 240))
|
||||
|
||||
# then
|
||||
assert np.allclose(result, two_images_tile, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_three_images(
|
||||
three_images: list[np.ndarray], three_images_tile: np.ndarray
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(images=three_images, single_tile_size=(240, 240))
|
||||
|
||||
# then
|
||||
assert np.allclose(result, three_images_tile, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_four_images(
|
||||
four_images: list[np.ndarray],
|
||||
four_images_tile: np.ndarray,
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(images=four_images, single_tile_size=(240, 240))
|
||||
|
||||
# then
|
||||
assert np.allclose(result, four_images_tile, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_all_images(
|
||||
all_images: list[np.ndarray],
|
||||
all_images_tile: np.ndarray,
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(images=all_images, single_tile_size=(240, 240))
|
||||
|
||||
# then
|
||||
assert np.allclose(result, all_images_tile, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_all_images_and_custom_grid(
|
||||
all_images: list[np.ndarray], all_images_tile_and_custom_grid: np.ndarray
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(
|
||||
images=all_images,
|
||||
grid_size=(3, 3),
|
||||
single_tile_size=(240, 240),
|
||||
)
|
||||
|
||||
# then
|
||||
assert np.allclose(result, all_images_tile_and_custom_grid, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_all_images_and_custom_colors(
|
||||
all_images: list[np.ndarray], all_images_tile_and_custom_colors: np.ndarray
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(
|
||||
images=all_images,
|
||||
tile_margin_color=(127, 127, 127),
|
||||
tile_padding_color=(224, 224, 224),
|
||||
single_tile_size=(240, 240),
|
||||
)
|
||||
|
||||
# then
|
||||
assert np.allclose(result, all_images_tile_and_custom_colors, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_all_images_and_titles(
|
||||
all_images: list[np.ndarray],
|
||||
all_images_tile_and_custom_colors_and_titles: np.ndarray,
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(
|
||||
images=all_images,
|
||||
titles=["Image 1", None, "Image 3", "Image 4"],
|
||||
single_tile_size=(240, 240),
|
||||
)
|
||||
|
||||
# then
|
||||
assert np.allclose(result, all_images_tile_and_custom_colors_and_titles, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_all_images_and_titles_with_custom_configs(
|
||||
all_images: list[np.ndarray],
|
||||
all_images_tile_and_titles_with_custom_configs: np.ndarray,
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(
|
||||
images=all_images,
|
||||
titles=["Image 1", None, "Image 3", "Image 4"],
|
||||
single_tile_size=(240, 240),
|
||||
titles_anchors=[
|
||||
Point(x=200, y=300),
|
||||
Point(x=300, y=400),
|
||||
None,
|
||||
Point(x=300, y=400),
|
||||
],
|
||||
titles_color=Color.RED,
|
||||
titles_scale=1.5,
|
||||
titles_thickness=3,
|
||||
titles_padding=20,
|
||||
titles_background_color=Color.BLACK,
|
||||
default_title_placement="bottom",
|
||||
)
|
||||
|
||||
# then
|
||||
assert np.allclose(result, all_images_tile_and_titles_with_custom_configs, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_all_images_and_custom_grid_to_small_to_fit_images(
|
||||
all_images: list[np.ndarray],
|
||||
) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_ = create_tiles(images=all_images, grid_size=(2, 2))
|
||||
@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