Compare commits

...
224 Commits
Author SHA1 Message Date
jirka dc54fa2e41 bump 0.27.0.post1 2026-02-06 21:54:15 +09:00
jirka 27e5fa3e9a Refactor deprecation handling for LMM and from_lmm, replacing @deprecated with warnings.warn 2026-02-06 21:53:46 +09:00
jirka eba1d64d91 resolve triggers for releasing 2026-02-06 19:53:07 +09:00
jirka f50ddca3a1 bump 0.27.0.post0 2026-02-06 17:10:56 +09:00
jirka 5b129a0850 Ensure compatibility with Python 3.9 2026-02-06 17:10:56 +09:00
jirka fd4845a5cf adjust deprecation version for from_lmm 2026-02-06 17:10:56 +09:00
jirka 8c104311d3 Enable caching and enforce dependency versions in uv workflow 2026-02-06 17:10:56 +09:00
jirka 650ca0ae2f Revive create_tiles implementation and all related functions 2026-02-06 17:10:56 +09:00
jirka bc521bc2d0 Update deprecation annotations to extend remove_in versions across multiple modules 2026-02-06 17:10:56 +09:00
Jirka Borovec ee1400a40c resolve docs build / pin mkdocstrings-python<2.0.0 (#2031) 2026-02-06 17:10:56 +09:00
jirka 7f3dcaedbd Refactor imports for consistency and update deprecated utility usage across modules 2026-02-06 17:10:56 +09:00
jirka 0ba57b2f57 Update deprecation annotations and refactor deprecated utility usage 2026-02-06 17:10:56 +09:00
jirka ae6aa369ae Update deprecation annotations and refactor deprecated utility usage 2026-02-06 17:10:56 +09:00
jirka e36fbe061d Add deprecation warnings for keypoint validation utilities in supervision 2026-02-06 17:10:56 +09:00
jirka 224c7569a1 Add deprecation warning for create_tiles in image utilities 2026-02-06 17:10:56 +09:00
jirka c9761515f3 Add deprecation warning for ensure_cv2_image_for_processing in conversion utilities 2026-02-06 17:10:56 +09:00
jirka d72089bb46 Add deprecation warning for denormalize_boxes in bounding box utilities 2026-02-06 17:10:56 +09:00
jirka 219161f1f9 Add deprecation warnings for annotation utilities and update dependencies 2026-02-06 17:10:56 +09:00
jirka 99e772c466 Introduce deprecation warnings for supervision.keypoint module, redirecting to supervision.key_points. 2026-02-06 17:10:56 +09:00
Piotr Skalski a61440ee0b Merge pull request #2005 from roboflow/develop
supervision-0.27.0 release
2025-11-16 19:05:40 +01:00
SkalskiP 7f7b25d7e7 bump version from 0.27.0rc6 to 0.27.0 2025-11-16 19:02:42 +01:00
SkalskiP 72506888c0 bump version from 0.27.0rc5 to 0.27.0rc6 2025-11-16 16:31:22 +01:00
Piotr Skalski c5cc24f22d Merge pull request #2017 from roboflow/0.27.0-changelog
supervision 0.27.0 changelog
2025-11-16 16:18:50 +01:00
SkalskiP 47574ed29d supervision 0.27.0 changelog 2025-11-16 16:15:10 +01:00
Piotr Skalski 6119f2017e Merge pull request #2016 from roboflow/feature/make_box_aspect_ratio_part_of_detections_api
add `box_aspect_ratio` property to `sv.Detections`
2025-11-16 13:39:49 +01:00
SkalskiP bbc37b4b6a fix failing tests 2025-11-16 13:34:51 +01:00
SkalskiP e12bfe263d add box_aspect_ratio property to sv.Detections 2025-11-16 13:27:29 +01:00
Piotr Skalski 373fbae1fa Merge pull request #2015 from roboflow/feature/support_for_qwen_3_vl
Qwen3-VL support
2025-11-16 01:02:29 +01:00
SkalskiP 790ccace2c Merge remote-tracking branch 'origin/feature/support_for_qwen_3_vl' into feature/support_for_qwen_3_vl 2025-11-15 22:54:34 +01:00
SkalskiP a99015ac2d more Qwen2.5-VL tests 2025-11-15 22:51:22 +01:00
pre-commit-ci[bot] 52c772d778 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-11-15 21:48:47 +00:00
SkalskiP 5af13c0f8c add Qwen3-VL prompting example 2025-11-15 22:48:20 +01:00
SkalskiP e0edb28ce6 Merge remote-tracking branch 'origin/feature/support_for_qwen_3_vl' into feature/support_for_qwen_3_vl 2025-11-15 22:27:20 +01:00
SkalskiP c35a35cefd plug Qwen3-VL into sv.Detections.from_vlm 2025-11-15 22:27:11 +01:00
pre-commit-ci[bot] 9bd8f71f26 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-11-15 21:19:57 +00:00
SkalskiP e29e3832fb plug Qwen3-VL into sv.Detections.from_vlm 2025-11-15 22:19:28 +01:00
SkalskiP a287e68c92 Merge remote-tracking branch 'origin/feature/support_for_qwen_3_vl' into feature/support_for_qwen_3_vl 2025-11-15 22:06:56 +01:00
SkalskiP 0422a66eaa plug Qwen3-VL into sv.Detections.from_vlm 2025-11-15 22:05:46 +01:00
pre-commit-ci[bot] 31db2ded11 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-11-15 20:40:07 +00:00
SkalskiP 9ab9650763 plug Qwen3-VL into sv.Detections.from_vlm 2025-11-15 21:39:23 +01:00
SkalskiP 353bd5336b Merge remote-tracking branch 'origin/feature/support_for_qwen_3_vl' into feature/support_for_qwen_3_vl 2025-11-15 21:37:05 +01:00
SkalskiP 7301156e46 plug Qwen3-VL into sv.Detections.from_vlm 2025-11-15 21:36:54 +01:00
pre-commit-ci[bot] e68c908077 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-11-15 20:29:38 +00:00
SkalskiP 2edcbdc487 initial commit with from_qwen_3_vl added, from_qwen_2_5_vl improved 2025-11-15 21:27:55 +01:00
Piotr Skalski 25c2f5cf0d Merge pull request #1997 from AnonymDevOSS/feat/threads-queues-video-process
feat: add threaded I/O pipeline for video processing
2025-11-15 02:40:10 +01:00
SkalskiP 316cb6271d make ruff happy 2025-11-15 02:33:11 +01:00
SkalskiP 70e9af9200 make ruff happy 2025-11-15 02:30:55 +01:00
pre-commit-ci[bot] 38f89125b1 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-11-15 01:10:49 +00:00
SkalskiP 80d76e712a style and docstring improvements 2025-11-15 02:10:04 +01:00
SkalskiP a296712000 improved InferenceSlicer docstring 2025-11-15 00:00:52 +01:00
Piotr Skalski 12d7c369db Merge pull request #2014 from roboflow/0.27.0-deprecations
remove 0.27.0 deprecations + InferenceSlicer improvements
2025-11-14 23:51:36 +01:00
pre-commit-ci[bot] c8223cbab2 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-11-14 22:15:39 +00:00
SkalskiP 2c522a9931 improved InferenceSlicer docstring 2025-11-14 23:14:34 +01:00
AnonymDevOSS fc0e133ea9 added example as it was delited by mistake 2025-11-14 21:53:14 +01:00
AnonymDevOSS 15364ac9a3 Rename process_video_threads to process_video and remove legacy implementation and redundant tests 2025-11-14 21:45:06 +01:00
pre-commit-ci[bot] e82b5b9d74 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-11-14 16:58:25 +00:00
SkalskiP c8f5c741fd reimplement crop_image to skip type casting when Pillow Image given
`get_image_resolution_wh` added
2025-11-14 17:55:07 +01:00
SkalskiP 8d464aa1b5 improve InferenceSlicer implementation lowering the count of generated boxes 2025-11-14 16:42:10 +01:00
SkalskiP ce28ab7fc7 fix uti tests for InferenceSlicer._generate_offset 2025-11-14 15:49:22 +01:00
SkalskiP fae1bc2661 bump version from 0.27.0rc4 to 0.27.0rc5 2025-11-14 14:52:57 +01:00
SkalskiP 4663fab4e4 update new docs page marks 2025-11-14 14:52:39 +01:00
SkalskiP db0892f3e9 remove unnecessary package-level import 2025-11-14 14:52:03 +01:00
Piotr Skalski d330bafaa7 Merge pull request #1926 from roboflow/dependabot/pip/develop/mkdocstrings-gte-0.25.2-and-lt-0.31.0
⬆️ Update mkdocstrings requirement from <0.30.0,>=0.25.2 to >=0.25.2,<0.31.0
2025-11-14 14:12:16 +01:00
Piotr Skalski 8b91d733af Merge pull request #1955 from roboflow/dependabot/github_actions/develop/pypa/gh-action-pypi-publish-1.13.0
⬆️ Bump pypa/gh-action-pypi-publish from 1.12.4 to 1.13.0
2025-11-14 14:11:57 +01:00
Piotr Skalski 97bf652d90 Merge pull request #1927 from roboflow/dependabot/pip/develop/build-gte-0.10-and-lt-1.4
⬆️ Update build requirement from <1.3,>=0.10 to >=0.10,<1.4
2025-11-14 14:11:24 +01:00
Piotr Skalski b0bbb74558 Merge pull request #1965 from roboflow/dependabot/github_actions/develop/actions/github-script-8.0.0
⬆️ Bump actions/github-script from 7.0.1 to 8.0.0
2025-11-14 13:50:13 +01:00
Piotr Skalski 1d73468dc5 Merge pull request #2012 from jackiehimel/fix/bugs-1959-2000
fix: correct numpy indexing in denormalize_boxes and add ultralytics validation
2025-11-14 13:25:26 +01:00
SkalskiP dfd2a60855 update denormalize_boxes docstring and examples; update from_google_gemini_2_0, from_google_gemini_2_5 and from_moondream to use latest version of denormalize_boxes 2025-11-14 13:12:39 +01:00
SkalskiP f563c5a634 update denormalize_boxes docstring and examples 2025-11-14 12:53:58 +01:00
SkalskiP 35272b40d3 remove ultralytics tests 2025-11-14 12:38:55 +01:00
Piotr Skalski 9320be5d5b Merge pull request #2013 from dominikWin/docs/how-to-json-path
docs: Update JSONSink example path
2025-11-14 12:28:25 +01:00
Piotr Skalski d109fba9d8 Merge pull request #2001 from AnonymDevOSS/feat/speed-up-box-iou-batch
feat: speed up box iou batch using 2d in-place ops instead of 3d
2025-11-14 12:26:25 +01:00
SkalskiP 42d92ba913 making ruff happy 2025-11-14 12:00:45 +01:00
pre-commit-ci[bot] 1e2dc8f88b fix(pre_commit): 🎨 auto format pre-commit hooks 2025-11-14 10:58:01 +00:00
SkalskiP f716b7fa3b improve docstrings, examples, variable naming conventions and unit tests 2025-11-14 11:57:31 +01:00
Dominik Winecki f6d52b53db docs: Update JSONSink example path 2025-11-13 13:48:16 -05:00
pre-commit-ci[bot] 61cca76acc fix(pre_commit): 🎨 auto format pre-commit hooks 2025-11-13 02:10:25 +00:00
jackiehimel 9ad850af46 fix: correct numpy indexing in denormalize_boxes and add ultralytics validation
- Fix denormalize_boxes numpy indexing bug that caused IndexError with 3+ boxes
- Add validation for missing boxes attribute in from_ultralytics
- Add comprehensive test coverage (11 new tests)

Fixes #1959
Fixes #2000
2025-11-12 20:58:25 -05:00
AnonymDevOSS eee6a85eaa Merge branch 'feat/speed-up-box-iou-batch' of github.com:AnonymDevOSS/supervision into feat/speed-up-box-iou-batch 2025-11-06 23:01:49 +01:00
AnonymDevOSS bb87ab0a6e fixed autoformat errors 2025-11-06 23:00:33 +01:00
pre-commit-ci[bot] da7c5cd65a fix(pre_commit): 🎨 auto format pre-commit hooks 2025-11-06 21:57:29 +00:00
AnonymDevOSS ceefe0bab2 feat/ speed up box iou - replaced original function; added tests 2025-11-06 22:52:25 +01:00
Piotr Skalski 78439e03cc Merge pull request #1999 from roboflow/dependabot/github_actions/develop/astral-sh/setup-uv-7.1.2
⬆️ Bump astral-sh/setup-uv from 6.4.3 to 7.1.2
2025-11-06 00:09:30 +01:00
Piotr Skalski b66416e074 Merge pull request #1993 from ernestlwt/fix/overlapmetric-to-upper
fix: Overlapmetric should check string using uppercase conversion
2025-11-06 00:07:20 +01:00
Piotr Skalski 6c7c49e051 Merge pull request #1974 from kshitijaucharmal/fix/add-pre-commit-in-pyproject
fix: added precommit under dev group
2025-11-05 23:58:32 +01:00
SkalskiP b1b69ad972 bump version from 0.27.0rc3 to 0.27.0rc4 2025-11-05 23:53:36 +01:00
Piotr Skalski 9bf9685165 Merge pull request #2007 from cdeil/patch-1
Fix Albumentations docs typo
2025-11-05 22:33:48 +01:00
SkalskiP 52be9dc818 fix filter_segments_by_distance docstring 2025-11-05 21:19:33 +01:00
Piotr Skalski c7e95483bf Merge pull request #2008 from roboflow/feature/filter_segments_by_distance
initial version of `filter_segments_by_distance`
2025-11-05 20:41:43 +01:00
SkalskiP 65f6e418a1 making flake8 happy 2025-11-05 20:34:22 +01:00
pre-commit-ci[bot] 1fef946a19 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-11-05 19:29:22 +00:00
SkalskiP 4c3d51f186 making flake8 happy 2025-11-05 20:28:55 +01:00
pre-commit-ci[bot] 9340cb962b fix(pre_commit): 🎨 auto format pre-commit hooks 2025-11-05 19:23:02 +00:00
SkalskiP 6578b4ea52 initial version of filter_segments_by_distance 2025-11-05 20:18:10 +01:00
Christoph Deil e37b32f5ca Fix Albumentations docs typo 2025-11-05 16:31:24 +01:00
SkalskiP 0bd60874ca bump version from 0.27.0rc2 to 0.27.0rc3 2025-11-05 13:39:13 +01:00
Piotr Skalski b02ec93b30 Merge pull request #2006 from roboflow/feature/xyxy_to_mask
Feature/xyxy to mask
2025-11-05 13:23:13 +01:00
pre-commit-ci[bot] 89ee57f519 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-11-05 12:14:45 +00:00
SkalskiP 1c8398942a docs and examples 2025-11-05 13:12:49 +01:00
pre-commit-ci[bot] 3e7e4caf3f fix(pre_commit): 🎨 auto format pre-commit hooks 2025-11-05 12:03:55 +00:00
SkalskiP a3ea8cdb1f bump version from 0.27.0rc1 to 0.27.0rc2 2025-11-05 13:01:30 +01:00
SkalskiP 32dca290ef xyxy_to_mask implementation + tests 2025-11-05 13:00:11 +01:00
AnonymDevOSS 03f623920a feat: tests for add threaded I/O pipeline for video processing 2025-10-28 14:05:52 +01:00
AnonymDevOSS 98476dfacf feat: add threaded I/O pipeline for video processing
Implements pipeline with bounded queues to overlap decode,
compute and encode. Reduces I/O stalls.
2025-10-28 14:05:37 +01:00
AnonymDevOSS 35ff87b08d feat: speed up box iou batch using 2d in-place ops instead of 3d
without creating intermediary (N, M, 2) arrays.
2025-10-28 13:51:28 +01:00
dependabot[bot] 03eed3fd64 ⬆️ Bump astral-sh/setup-uv from 6.4.3 to 7.1.2
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 6.4.3 to 7.1.2.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/e92bafb6253dcd438e0484186d7669ea7a8ca1cc...85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 7.1.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 00:05:24 +00:00
Ernest Lim 272d9ed5bf fix: Overlapmetric should check string using uppercase conversion 2025-10-16 16:12:28 +08:00
Kshitij Aucharmal 371f8ecc5b fix: added precommit under dev group 2025-10-03 19:46:46 +05:30
Piotr Skalski 171687f3ab Merge pull request #1968 from kshitijaucharmal/develop
Fixed: random print("hm") in merge_metadata
2025-09-25 17:22:01 +02:00
Kshitij Aucharmal 421ad21c98 Fixed: random print("hm") in merge_metadata 2025-09-24 23:25:12 +05:30
dependabot[bot] 50358e96b6 ⬆️ Bump actions/github-script from 7.0.1 to 8.0.0
Bumps [actions/github-script](https://github.com/actions/github-script) from 7.0.1 to 8.0.0.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/60a0d83039c74a4aee543508d2ffcb1c3799cdea...ed597411d8f924073f98dfc5c65a23a2325f34cd)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: 8.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-22 00:11:46 +00:00
dependabot[bot] 86e10db19e ⬆️ Bump pypa/gh-action-pypi-publish from 1.12.4 to 1.13.0
Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.12.4 to 1.13.0.
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/76f52bc884231f62b9a034ebfe128415bbaabdfc...ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-version: 1.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 00:07:58 +00:00
Piotr Skalski deb1c9c4f4 Merge pull request #1945 from roboflow/docs/refactor_0.27.0
docs/refactor 0.27.0 missing commits
2025-08-07 16:49:03 +02:00
pre-commit-ci[bot] 6679b1f5a9 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-08-07 14:45:36 +00:00
Piotr Skalski c7de70ed20 Merge pull request #1943 from roboflow/docs/refactor_0.27.0
docs/refactor 0.27.0
2025-08-07 13:46:36 +02:00
SkalskiP 2cdb70eb91 Merge remote-tracking branch 'origin/docs/refactor_0.27.0' into docs/refactor_0.27.0
# Conflicts:
#	supervision/utils/image.py
2025-08-07 13:32:56 +02:00
SkalskiP 2ef029ef4b visualize examples 2025-08-07 13:32:44 +02:00
pre-commit-ci[bot] dce96d462e fix(pre_commit): 🎨 auto format pre-commit hooks 2025-08-07 10:36:03 +00:00
SkalskiP 3fcbef9482 Merge remote-tracking branch 'origin/docs/refactor_0.27.0' into docs/refactor_0.27.0
# Conflicts:
#	supervision/utils/image.py
2025-08-07 12:35:35 +02:00
SkalskiP c8b966be1a align new utils with naming conventions 2025-08-07 12:35:09 +02:00
pre-commit-ci[bot] 72621dce32 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-08-07 10:30:22 +00:00
SkalskiP 098d5e3eb1 Merge remote-tracking branch 'origin/docs/refactor_0.27.0' into docs/refactor_0.27.0
# Conflicts:
#	supervision/utils/image.py
2025-08-07 12:29:54 +02:00
SkalskiP 8c49465d49 align new utils with naming conventions 2025-08-07 12:29:05 +02:00
pre-commit-ci[bot] 4ad36db3bb fix(pre_commit): 🎨 auto format pre-commit hooks 2025-08-07 09:53:22 +00:00
SkalskiP a933b93234 update image utils docs; deprecate overlay_image image 2025-08-07 11:51:59 +02:00
SkalskiP cbb58a8c6f tint_image and grayscale_image functions added 2025-08-07 10:01:50 +02:00
SkalskiP 8fc22f0281 move ImageType to a more suitable location 2025-08-07 07:54:50 +02:00
SkalskiP 4b7b347aea drop create tiles from public API 2025-08-07 07:45:55 +02:00
SkalskiP 5c6745291b after few tests drop support for pycon in docs examples 2025-08-07 07:41:52 +02:00
SkalskiP 83d490dadc initial support for pycon docs examples 2025-08-06 23:19:21 +02:00
SkalskiP 005cdbd37a bump version from 0.27.0rc0 to 0.27.0rc1 2025-08-06 12:27:16 +02:00
Piotr Skalski ade2668ae3 Merge pull request #1932 from roboflow/fix/numpy.int64-object-has-no-attribute-splitlines
fix: AttributeError: 'numpy.int64' object has no attribute 'splitlines'
2025-08-06 12:25:46 +02:00
pre-commit-ci[bot] 46e5965680 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-08-06 10:17:35 +00:00
SkalskiP aab3160cd7 make ruff happy 2025-08-06 12:17:04 +02:00
pre-commit-ci[bot] 13ab215fdf fix(pre_commit): 🎨 auto format pre-commit hooks 2025-08-06 10:05:47 +00:00
SkalskiP a08cda30fb ready for review 2025-08-06 12:02:51 +02:00
dependabot[bot] 652b0a190f ⬆️ Update build requirement from <1.3,>=0.10 to >=0.10,<1.4
Updates the requirements on [build](https://github.com/pypa/build) to permit the latest version.
- [Release notes](https://github.com/pypa/build/releases)
- [Changelog](https://github.com/pypa/build/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pypa/build/compare/0.10.0...1.3.0)

---
updated-dependencies:
- dependency-name: build
  dependency-version: 1.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-04 00:52:10 +00:00
dependabot[bot] 6f87e2639e ⬆️ Update mkdocstrings requirement
Updates the requirements on [mkdocstrings](https://github.com/mkdocstrings/mkdocstrings) to permit the latest version.
- [Release notes](https://github.com/mkdocstrings/mkdocstrings/releases)
- [Changelog](https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mkdocstrings/mkdocstrings/compare/0.25.2...0.30.0)

---
updated-dependencies:
- dependency-name: mkdocstrings
  dependency-version: 0.30.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-04 00:50:49 +00:00
SkalskiP e1393691a4 bump version from 0.26.1 to 0.27.0rc0 2025-07-31 17:49:06 +02:00
Piotr Skalski 90f8582e4f Merge pull request #1919 from Ashp116/feature/spline-tracer
FEATURE: Spline Annotations
2025-07-31 16:53:33 +02:00
Ashp116 0c9fea4d9f UPDATE: Change Smooth to bool 2025-07-30 18:24:14 -04:00
Ashp116 cdca2e6b51 UPDATE: Removed SplineAnnotator and added smooth flag for TraceAnnotator 2025-07-30 13:48:38 -04:00
pre-commit-ci[bot] 8407d5a692 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-30 07:15:02 +00:00
Ashp116 eaf6cdd477 Precommit 2025-07-30 03:14:00 -04:00
pre-commit-ci[bot] f447b6fa95 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-30 07:10:19 +00:00
Ashp116 8c6ac2dd29 UPDATE: Made smoothing and spline_order as hyperparameters 2025-07-30 02:53:58 -04:00
Ashp116 22b6d3d464 ADD: Added a spline annotator 2025-07-30 02:04:24 -04:00
Piotr Skalski dda3900943 Merge pull request #1917 from roboflow/feat/label-annotator-offset
add: support for an additional parameter `text_anchor_offset` in label annotators
2025-07-29 14:50:34 +02:00
Piotr Skalski ac0c367e81 Merge pull request #1914 from roboflow/dependabot/github_actions/develop/astral-sh/setup-uv-6.4.3
⬆️ Bump astral-sh/setup-uv from 6.4.1 to 6.4.3
2025-07-29 14:48:27 +02:00
SkalskiP 6f2490cc23 fix formatting 2025-07-29 14:43:38 +02:00
Piotr Skalski 4a9d06eb50 Merge pull request #1918 from roboflow/pre-commit-ci-update-config
chore(pre_commit): ⬆ pre_commit autoupdate
2025-07-29 14:40:38 +02:00
pre-commit-ci[bot] 21e311a319 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-29 12:40:11 +00:00
SkalskiP 71a14812fe LabelAnnotator and RichLabelAnnotator docs improvement 2025-07-29 14:39:41 +02:00
SkalskiP 35fdcb0a1a Merge branch 'develop' into feat/label-annotator-offset 2025-07-29 12:46:06 +02:00
Piotr Skalski 6d26a9766c Merge pull request #1915 from roboflow/feature/advanced_keypoints_indexing_and_slicing
advanced `sv.KeyPoints` filtering and slicing
2025-07-29 12:45:29 +02:00
SkalskiP 1cfaef9405 Merge branch 'develop' into feature/advanced_keypoints_indexing_and_slicing 2025-07-29 12:42:34 +02:00
pre-commit-ci[bot] 063e2092c8 chore(pre_commit): ⬆ pre_commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.12.4 → v0.12.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.4...v0.12.5)
2025-07-28 17:28:44 +00:00
soumik12345 cb215d26f3 add: support for an additional parameter text_anchor_offset in _BaseLabelAnnotator 2025-07-28 19:03:17 +05:30
Soumik Rakshit 8bbe80e683 Merge pull request #1916 from AlexBodner/fix/docs-vitpose
Corrected vitpose docstring
2025-07-28 18:38:22 +05:30
Alex Bodner 9a0483446a fixed docs 2025-07-28 09:53:44 -03:00
pre-commit-ci[bot] f4561f9ba7 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-28 06:29:08 +00:00
SkalskiP beaf89cb22 initial version of advanced key points filtering and slicing + some naming refactor 2025-07-28 08:24:43 +02:00
dependabot[bot] 96b58c2860 ⬆️ Bump astral-sh/setup-uv from 6.4.1 to 6.4.3
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 6.4.1 to 6.4.3.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/7edac99f961f18b581bbd960d59d049f04c0002f...e92bafb6253dcd438e0484186d7669ea7a8ca1cc)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 6.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-28 00:07:27 +00:00
Piotr Skalski 8873da4a30 Merge pull request #1902 from roboflow/dependabot/github_actions/develop/astral-sh/setup-uv-6.4.1
⬆️ Bump astral-sh/setup-uv from 6.3.1 to 6.4.1
2025-07-26 17:57:44 +02:00
Piotr Skalski 1362850986 Merge pull request #1904 from roboflow/pre-commit-ci-update-config
chore(pre_commit): ⬆ pre_commit autoupdate
2025-07-26 17:57:15 +02:00
Piotr Skalski 98a4391677 Merge pull request #1913 from roboflow/fix/pending_track_id
fix for #1911
2025-07-26 17:51:14 +02:00
SkalskiP 036a956473 fix for #1911 2025-07-26 17:46:04 +02:00
Piotr Skalski b381fa198f Merge pull request #1884 from roboflow/feat/from_deepseek_vl_2_support
feat: 🚀 from_vlm deepseek vl 2 support
2025-07-25 16:57:44 +02:00
pre-commit-ci[bot] 8ca2e9fe8d fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-25 14:44:15 +00:00
soumik12345 bc1d73a307 chore: address feedback 2025-07-25 20:13:28 +05:30
Piotr Skalski 9679cd5c4e Merge pull request #1912 from roboflow/feature/edit_distance_and_fuzzy_match
`edit_distance` and `fuzzy_match_index` that will allow to match faulty class name generated by VLMs with exact class names
2025-07-25 14:52:24 +02:00
pre-commit-ci[bot] 00817df9e2 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-25 11:41:30 +00:00
SkalskiP ec189bf388 new, more memory efficient edit_distance implementation 2025-07-25 13:41:03 +02:00
SkalskiP 58bdce5707 tests fixed 2025-07-24 15:13:46 +02:00
pre-commit-ci[bot] 8262b6e64b fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-24 13:06:48 +00:00
SkalskiP 72fe0bf47f initial version of edit_distance and fuzzy_match_index that will allow to match faulty class name generated by VLMs with exact class names 2025-07-24 15:05:19 +02:00
soumik12345 137078602b add: tests 2025-07-24 15:17:05 +05:30
soumik12345 8fa21dd165 update: from_deepseek_vl_2 2025-07-24 15:04:59 +05:30
soumik12345 da70389546 add: prompt engineering tip for deepseek-vl2 2025-07-24 14:23:24 +05:30
soumik12345 cc1525707a update: docstring for Detections.from_vlm 2025-07-24 13:11:06 +05:30
soumik12345 b3aee43c3b update: from_deepseek_vl_2 logic 2025-07-24 12:55:40 +05:30
Soumik Rakshit 932c1aa91e Merge pull request #1910 from rafaelpadilla/fix/import_box_iou_batch
exposing `box_iou_batch`  in `supervision/detection/utils__init__.py`
2025-07-23 22:00:55 +05:30
rcvsq 76ee05420d exposing box_iou_batch in supervision/detection/utils__init__.py 2025-07-23 14:58:09 +00:00
soumik12345 a470bc2976 chore: make pre-commit happy 2025-07-23 18:36:31 +05:30
Soumik Rakshit cf725dd4db Merge branch 'develop' into feat/from_deepseek_vl_2_support 2025-07-23 18:31:07 +05:30
Piotr Skalski 7ecfc9f475 Merge pull request #1908 from roboflow/develop
`supervision-0.26.1` release
2025-07-23 08:46:45 +02:00
Piotr Skalski 317ac0810e Merge pull request #1907 from roboflow/changelog/0.26.1
update: changelog for 0.26.1
2025-07-23 08:35:47 +02:00
SkalskiP cb6018ef04 final changes before supervision-0.26.1 2025-07-23 08:32:07 +02:00
pre-commit-ci[bot] 580fcdde4c fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-22 10:51:53 +00:00
soumik12345 ad4f44644c update: changelog for 0.26.1 2025-07-22 16:20:38 +05:30
Piotr Skalski 0c8a9d34a6 Merge pull request #1906 from roboflow/fix/border_thickness
fix: border thickness parameter usage for `PercentageBarAnnotator`
2025-07-22 12:27:26 +02:00
soumik12345 d067f32678 fix: border thickness parameter usage for PercentageBarAnnotator 2025-07-22 15:48:54 +05:30
Piotr Skalski d251677774 Merge pull request #1894 from roboflow/fix/mAP-area
fix: correct area handling in MeanAveragePrecision for size-specific mAP
2025-07-22 08:07:20 +02:00
pre-commit-ci[bot] b1780fbae5 chore(pre_commit): ⬆ pre_commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.12.3 → v0.12.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.3...v0.12.4)
2025-07-21 17:29:41 +00:00
SkalskiP e8cb962fcc bump version from 0.26.1 to 0.26.1rc0 2025-07-21 10:14:58 +02:00
dependabot[bot] 1e84351d60 ⬆️ Bump astral-sh/setup-uv from 6.3.1 to 6.4.1
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 6.3.1 to 6.4.1.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/bd01e18f51369d5a26f1651c3cb451d3417e3bba...7edac99f961f18b581bbd960d59d049f04c0002f)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 6.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-21 00:32:44 +00:00
Piotr Skalski baa0402d5a Merge pull request #1901 from roboflow/fix/inference-overlap-metric-fix
fix:  🐞 update inference_slicer.py for improved detection handling
2025-07-21 00:07:28 +02:00
pre-commit-ci[bot] c54d16c57e fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-20 22:02:42 +00:00
SkalskiP 1adb75520f bump version from 0.26.0 to 0.26.1; improve docstrings; unify naming; improve parsing 2025-07-21 00:02:14 +02:00
pre-commit-ci[bot] db51a9ff98 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-20 17:12:24 +00:00
Onuralp SEZER f044398c17 fix: 🐞 update inference_slicer.py for improved detection handling 2025-07-20 20:11:37 +03:00
pre-commit-ci[bot] 704652a0d2 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-18 15:19:38 +00:00
Balthasar 6e3001194d improve tests 2025-07-18 17:19:10 +02:00
Balthasar d7774381cc Merge branch 'develop' into fix/mAP-area 2025-07-18 12:11:05 +02:00
Soumik Rakshit 1c8ec0a287 Merge pull request #1898 from roboflow/fix/negative-mAP-values
fix: handle -1 sentinel values in mAP calculation to prevent negative results and mimic pycocotools behaviour
2025-07-18 15:32:21 +05:30
Balthasar 118e3a496c fix: line length 2025-07-18 10:09:53 +02:00
pre-commit-ci[bot] a139fb0b49 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-18 08:00:37 +00:00
Balthasar afdbb0a75c fix: keep it DRY 2025-07-18 09:41:06 +02:00
Balthasar 001a4254f8 fix: improve code, more fixes and add tests 2025-07-18 09:41:06 +02:00
Balthasar 2e72e70893 basic fix 2025-07-18 09:41:06 +02:00
pre-commit-ci[bot] 2907c40ecc fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-17 08:00:04 +00:00
Balthasar 2c67cee720 fix: tests and adjust comment 2025-07-17 09:57:55 +02:00
Balthasar 0ddbcb2010 fix: start id with 1 because 0 means no match 2025-07-17 09:49:21 +02:00
pre-commit-ci[bot] 40d4c6f554 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-16 18:07:52 +00:00
Balthasar bf88636487 Merge branch 'fix/mAP-area' of https://github.com/roboflow/supervision into fix/mAP-area 2025-07-16 20:07:30 +02:00
Balthasar a444b3bde8 fix: cleanup comment 2025-07-16 20:06:27 +02:00
pre-commit-ci[bot] 4009f9d886 fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-16 18:00:47 +00:00
Balthasar 3f926c618a fix: cleanup comments 2025-07-16 19:51:59 +02:00
Balthasar 71d6670e7d fix(metrics): update area calculation in MeanAveragePrecision to use area property as fallback if it's not COCO 2025-07-16 19:50:37 +02:00
Piotr Skalski 08226e9199 Merge pull request #1893 from roboflow/main
Merge pull request #1742 from roboflow/develop
2025-07-16 02:50:11 +02:00
Onuralp SEZER 535cb2a91b fix(vlm): 📝 correct formatting in docstring for from_deepseek_vl_2 function 2025-07-11 18:49:50 +03:00
pre-commit-ci[bot] 8e845caeeb fix(pre_commit): 🎨 auto format pre-commit hooks 2025-07-11 15:44:15 +00:00
Onuralp SEZER 9e2a535044 Merge branch 'develop' into feat/from_deepseek_vl_2_support 2025-07-11 18:43:02 +03:00
SkalskiP f737782ae8 initial commit 2025-04-07 14:56:58 +02:00
78 changed files with 5767 additions and 2202 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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...")
+1 -1
View File
@@ -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
+8 -5
View File
@@ -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
+4 -3
View File
@@ -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
+2 -2
View File
@@ -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
+3 -1
View File
@@ -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
+7 -3
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -1,6 +1,5 @@
---
comments: true
status: new
---
# Annotators
-1
View File
@@ -1,6 +1,5 @@
---
comments: true
status: new
---
# Detections
-1
View File
@@ -1,6 +1,5 @@
---
comments: true
status: new
---
# Boxes Utils
+6
View File
@@ -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
View File
@@ -1,6 +1,5 @@
---
comments: true
status: new
---
# IoU and NMS Utils
+6
View File
@@ -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
View File
@@ -1,6 +1,5 @@
---
comments: true
status: new
---
# Polygons Utils
+18
View File
@@ -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
View File
@@ -1,6 +1,5 @@
---
comments: true
status: new
---
![Corgi Example](https://media.roboflow.com/supervision/image-examples/how-to/benchmark-models/corgi-sorted-2.png)
+2 -5
View File
@@ -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
+3 -3
View File
@@ -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)
+2 -3
View File
@@ -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).
+6 -6
View File
@@ -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 -2
View File
@@ -1,8 +1,7 @@
---
comments: true
status: new
---
# Keypoint Detection
:::supervision.keypoint.core.KeyPoints
:::supervision.key_points.core.KeyPoints
-1
View File
@@ -1,6 +1,5 @@
---
comments: true
status: new
---
# Mean Average Precision
+15 -2
View File
@@ -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>
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -13
View File
@@ -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
View File
@@ -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
)
```
![label-annotator-example](https://media.roboflow.com/
supervision-annotator-examples/label-annotator-example-purple.png)
"""
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:
+22 -14
View File
@@ -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
View File
@@ -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
+210 -168
View File
@@ -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}."
)
+49 -34
View File
@@ -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
+64
View File
@@ -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.
-1
View File
@@ -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}'.")
+125 -92
View File
@@ -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.
+138
View File
@@ -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]
+105
View File
@@ -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
View File
@@ -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),
)
+13
View File
@@ -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
View File
@@ -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
View File
+443
View File
@@ -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
)
```
![vertex-annotator-example](https://media.roboflow.com/
supervision-annotator-examples/vertex-annotator-example.png)
"""
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
)
```
![edge-annotator-example](https://media.roboflow.com/
supervision-annotator-examples/edge-annotator-example.png)
"""
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
)
```
![vertex-label-annotator-example](https://media.roboflow.com/
supervision-annotator-examples/vertex-label-annotator-example.png)
!!! 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
)
```
![vertex-label-annotator-custom-example](https://media.roboflow.com/
supervision-annotator-examples/vertex-label-annotator-custom-example.png)
"""
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)
)
+839
View File
@@ -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
+15
View File
@@ -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
+13 -441
View File
@@ -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
)
```
![vertex-annotator-example](https://media.roboflow.com/
supervision-annotator-examples/vertex-annotator-example.png)
"""
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
)
```
![edge-annotator-example](https://media.roboflow.com/
supervision-annotator-examples/edge-annotator-example.png)
"""
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
)
```
![vertex-label-annotator-example](https://media.roboflow.com/
supervision-annotator-examples/vertex-label-annotator-example.png)
!!! 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
)
```
![vertex-label-annotator-custom-example](https://media.roboflow.com/
supervision-annotator-examples/vertex-label-annotator-custom-example.png)
"""
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,
)
+8 -773
View File
@@ -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
+4 -5
View File
@@ -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:
+67 -20
View File
@@ -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
+32 -4
View File
@@ -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
View File
@@ -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)
```
![crop_image](https://media.roboflow.com/supervision-docs/crop-image.png){ align=center width="800" }
![crop-image](https://media.roboflow.com/supervision-docs/supervision-docs-crop-image-2.png){ 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)
```
"""
![scale-image](https://media.roboflow.com/supervision-docs/supervision-docs-scale-image-2.png){ 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)
```
![resize_image](https://media.roboflow.com/supervision-docs/resize-image.png){ align=center width="800" }
![resize-image](https://media.roboflow.com/supervision-docs/supervision-docs-resize-image-2.png){ 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)
```
![letterbox_image](https://media.roboflow.com/supervision-docs/letterbox-image.png){ align=center width="800" }
![letterbox-image](https://media.roboflow.com/supervision-docs/supervision-docs-letterbox-image-2.png){ 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)
```
![overlay_image](https://media.roboflow.com/supervision-docs/overlay-image.png){ 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")
```
![tint-image](https://media.roboflow.com/supervision-docs/supervision-docs-tint-image-2.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")
```
![grayscale-image](https://media.roboflow.com/supervision-docs/supervision-docs-grayscale-image-2.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,
-24
View File
@@ -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")
+1 -1
View File
@@ -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
View File
@@ -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:
+24 -3
View File
@@ -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(
+66 -2
View File
@@ -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
View File
@@ -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],
)
+107 -96
View File
@@ -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}"
)
+91 -1
View File
@@ -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)
+172
View File
@@ -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)
+498
View File
@@ -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,
)
+226
View File
@@ -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)
+117
View File
@@ -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
)
View File
+268
View File
@@ -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
+5 -9
View File
@@ -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)
+328
View File
@@ -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
View File
@@ -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 -3
View File
@@ -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
View File
@@ -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
+1
View File
@@ -145,6 +145,7 @@ class MockDataclass:
"metadata",
"area",
"box_area",
"box_aspect_ratio",
},
DoesNotRaise(),
),