diff --git a/docs/detection/tools/save_detections.md b/docs/detection/tools/save_detections.md
index a82ce5df..a24cee57 100644
--- a/docs/detection/tools/save_detections.md
+++ b/docs/detection/tools/save_detections.md
@@ -1,6 +1,5 @@
---
comments: true
-status: new
---
# Save Detections
diff --git a/docs/detection/utils.md b/docs/detection/utils.md
index f9c9473b..ea98c868 100644
--- a/docs/detection/utils.md
+++ b/docs/detection/utils.md
@@ -1,6 +1,5 @@
---
comments: true
-status: new
---
# Detection Utils
@@ -17,18 +16,6 @@ status: new
:::supervision.detection.utils.mask_iou_batch
-
-
-:::supervision.detection.utils.box_non_max_suppression
-
-
-
-:::supervision.detection.utils.mask_non_max_suppression
-
diff --git a/docs/how_to/detect_and_annotate.md b/docs/how_to/detect_and_annotate.md
index a9a4405e..52e3174b 100644
--- a/docs/how_to/detect_and_annotate.md
+++ b/docs/how_to/detect_and_annotate.md
@@ -1,6 +1,5 @@
---
comments: true
-status: new
---
# Detect and Annotate
diff --git a/docs/how_to/save_detections.md b/docs/how_to/save_detections.md
index 94de6c61..05d5faad 100644
--- a/docs/how_to/save_detections.md
+++ b/docs/how_to/save_detections.md
@@ -1,6 +1,5 @@
---
comments: true
-status: new
---
# Save Detections
diff --git a/docs/trackers.md b/docs/trackers.md
index 47f70061..cb44441f 100644
--- a/docs/trackers.md
+++ b/docs/trackers.md
@@ -1,6 +1,5 @@
---
comments: true
-status: new
---
# ByteTrack
diff --git a/docs/utils/image.md b/docs/utils/image.md
index 8f170d35..8e39136a 100644
--- a/docs/utils/image.md
+++ b/docs/utils/image.md
@@ -1,6 +1,5 @@
---
comments: true
-status: new
---
# Image Utils
@@ -12,7 +11,7 @@ status: new
:::supervision.utils.image.crop_image
:::supervision.utils.image.scale_image
diff --git a/docs/utils/iterables.md b/docs/utils/iterables.md
index b65cd954..5ae92dc9 100644
--- a/docs/utils/iterables.md
+++ b/docs/utils/iterables.md
@@ -1,6 +1,5 @@
---
comments: true
-status: new
---
# Iterables Utils
diff --git a/mkdocs.yml b/mkdocs.yml
index f257238d..96728971 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -35,19 +35,20 @@ extra_css:
nav:
- - Home: index.md
- - How to:
+ - Supervision: index.md
+ - Learn:
- Detect and Annotate: how_to/detect_and_annotate.md
- Save Detections: how_to/save_detections.md
- Filter Detections: how_to/filter_detections.md
- Detect Small Objects: how_to/detect_small_objects.md
- Track Objects on Video: how_to/track_objects.md
- - API:
+ - Reference - Code API:
- Detection and Segmentation:
- Core: detection/core.md
- Annotators: detection/annotators.md
- Metrics: detection/metrics.md
+ - Double Detection Filter: detection/double_detection_filter.md
- Utils: detection/utils.md
- Keypoint Detection:
- Core: keypoint/core.md
@@ -78,7 +79,7 @@ nav:
- Contributing: contributing.md
- Code of Conduct: code_of_conduct.md
- License: license.md
- - Changelog:
+ - Release Notes:
- Changelog: changelog.md
- Deprecated: deprecated.md
diff --git a/poetry.lock b/poetry.lock
index 0a9276f8..ecad075d 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1253,21 +1253,21 @@ test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pa
[[package]]
name = "ipywidgets"
-version = "8.1.2"
+version = "8.1.3"
description = "Jupyter interactive widgets"
optional = false
python-versions = ">=3.7"
files = [
- {file = "ipywidgets-8.1.2-py3-none-any.whl", hash = "sha256:bbe43850d79fb5e906b14801d6c01402857996864d1e5b6fa62dd2ee35559f60"},
- {file = "ipywidgets-8.1.2.tar.gz", hash = "sha256:d0b9b41e49bae926a866e613a39b0f0097745d2b9f1f3dd406641b4a57ec42c9"},
+ {file = "ipywidgets-8.1.3-py3-none-any.whl", hash = "sha256:efafd18f7a142248f7cb0ba890a68b96abd4d6e88ddbda483c9130d12667eaf2"},
+ {file = "ipywidgets-8.1.3.tar.gz", hash = "sha256:f5f9eeaae082b1823ce9eac2575272952f40d748893972956dc09700a6392d9c"},
]
[package.dependencies]
comm = ">=0.1.3"
ipython = ">=6.1.0"
-jupyterlab-widgets = ">=3.0.10,<3.1.0"
+jupyterlab-widgets = ">=3.0.11,<3.1.0"
traitlets = ">=4.3.1"
-widgetsnbextension = ">=4.0.10,<4.1.0"
+widgetsnbextension = ">=4.0.11,<4.1.0"
[package.extras]
test = ["ipykernel", "jsonschema", "pytest (>=3.6.0)", "pytest-cov", "pytz"]
@@ -1638,13 +1638,13 @@ test = ["hatch", "ipykernel", "openapi-core (>=0.18.0,<0.19.0)", "openapi-spec-v
[[package]]
name = "jupyterlab-widgets"
-version = "3.0.10"
+version = "3.0.11"
description = "Jupyter interactive widgets for JupyterLab"
optional = false
python-versions = ">=3.7"
files = [
- {file = "jupyterlab_widgets-3.0.10-py3-none-any.whl", hash = "sha256:dd61f3ae7a5a7f80299e14585ce6cf3d6925a96c9103c978eda293197730cb64"},
- {file = "jupyterlab_widgets-3.0.10.tar.gz", hash = "sha256:04f2ac04976727e4f9d0fa91cdc2f1ab860f965e504c29dbd6a65c882c9d04c0"},
+ {file = "jupyterlab_widgets-3.0.11-py3-none-any.whl", hash = "sha256:78287fd86d20744ace330a61625024cf5521e1c012a352ddc0a3cdc2348becd0"},
+ {file = "jupyterlab_widgets-3.0.11.tar.gz", hash = "sha256:dd5ac679593c969af29c9bed054c24f26842baa51352114736756bc035deee27"},
]
[[package]]
@@ -2164,13 +2164,13 @@ requests = "*"
[[package]]
name = "mkdocs-git-revision-date-localized-plugin"
-version = "1.2.5"
+version = "1.2.6"
description = "Mkdocs plugin that enables displaying the localized date of the last git modification of a markdown file."
optional = false
python-versions = ">=3.8"
files = [
- {file = "mkdocs_git_revision_date_localized_plugin-1.2.5-py3-none-any.whl", hash = "sha256:d796a18b07cfcdb154c133e3ec099d2bb5f38389e4fd54d3eb516a8a736815b8"},
- {file = "mkdocs_git_revision_date_localized_plugin-1.2.5.tar.gz", hash = "sha256:0c439816d9d0dba48e027d9d074b2b9f1d7cd179f74ba46b51e4da7bb3dc4b9b"},
+ {file = "mkdocs_git_revision_date_localized_plugin-1.2.6-py3-none-any.whl", hash = "sha256:f015cb0f3894a39b33447b18e270ae391c4e25275cac5a626e80b243784e2692"},
+ {file = "mkdocs_git_revision_date_localized_plugin-1.2.6.tar.gz", hash = "sha256:e432942ce4ee8aa9b9f4493e993dee9d2cc08b3ea2b40a3d6b03ca0f2a4bcaa2"},
]
[package.dependencies]
@@ -2199,13 +2199,13 @@ pygments = ">2.12.0"
[[package]]
name = "mkdocs-material"
-version = "9.5.24"
+version = "9.5.26"
description = "Documentation that simply works"
optional = false
python-versions = ">=3.8"
files = [
- {file = "mkdocs_material-9.5.24-py3-none-any.whl", hash = "sha256:e12cd75954c535b61e716f359cf2a5056bf4514889d17161fdebd5df4b0153c6"},
- {file = "mkdocs_material-9.5.24.tar.gz", hash = "sha256:02d5aaba0ee755e707c3ef6e748f9acb7b3011187c0ea766db31af8905078a34"},
+ {file = "mkdocs_material-9.5.26-py3-none-any.whl", hash = "sha256:5d01fb0aa1c7946a1e3ae8689aa2b11a030621ecb54894e35aabb74c21016312"},
+ {file = "mkdocs_material-9.5.26.tar.gz", hash = "sha256:56aeb91d94cffa43b6296fa4fbf0eb7c840136e563eecfd12c2d9e92e50ba326"},
]
[package.dependencies]
@@ -2485,13 +2485,13 @@ setuptools = "*"
[[package]]
name = "notebook"
-version = "7.2.0"
+version = "7.2.1"
description = "Jupyter Notebook - A web-based notebook environment for interactive computing"
optional = false
python-versions = ">=3.8"
files = [
- {file = "notebook-7.2.0-py3-none-any.whl", hash = "sha256:b4752d7407d6c8872fc505df0f00d3cae46e8efb033b822adacbaa3f1f3ce8f5"},
- {file = "notebook-7.2.0.tar.gz", hash = "sha256:34a2ba4b08ad5d19ec930db7484fb79746a1784be9e1a5f8218f9af8656a141f"},
+ {file = "notebook-7.2.1-py3-none-any.whl", hash = "sha256:f45489a3995746f2195a137e0773e2130960b51c9ac3ce257dbc2705aab3a6ca"},
+ {file = "notebook-7.2.1.tar.gz", hash = "sha256:4287b6da59740b32173d01d641f763d292f49c30e7a51b89c46ba8473126341e"},
]
[package.dependencies]
@@ -3039,13 +3039,13 @@ tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
[[package]]
name = "pytest"
-version = "8.2.1"
+version = "8.2.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.8"
files = [
- {file = "pytest-8.2.1-py3-none-any.whl", hash = "sha256:faccc5d332b8c3719f40283d0d44aa5cf101cec36f88cde9ed8f2bc0538612b1"},
- {file = "pytest-8.2.1.tar.gz", hash = "sha256:5046e5b46d8e4cac199c373041f26be56fdb81eb4e67dc11d4e10811fc3408fd"},
+ {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"},
+ {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"},
]
[package.dependencies]
@@ -3461,13 +3461,13 @@ files = [
[[package]]
name = "requests"
-version = "2.32.2"
+version = "2.32.3"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.8"
files = [
- {file = "requests-2.32.2-py3-none-any.whl", hash = "sha256:fc06670dd0ed212426dfeb94fc1b983d917c4f9847c863f313c9dfaaffb7c23c"},
- {file = "requests-2.32.2.tar.gz", hash = "sha256:dd951ff5ecf3e3b3aa26b40703ba77495dab41da839ae72ef3c8e5d8e2433289"},
+ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
+ {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
]
[package.dependencies]
@@ -3662,28 +3662,28 @@ files = [
[[package]]
name = "ruff"
-version = "0.4.5"
+version = "0.4.8"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
- {file = "ruff-0.4.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8f58e615dec58b1a6b291769b559e12fdffb53cc4187160a2fc83250eaf54e96"},
- {file = "ruff-0.4.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:84dd157474e16e3a82745d2afa1016c17d27cb5d52b12e3d45d418bcc6d49264"},
- {file = "ruff-0.4.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25f483ad9d50b00e7fd577f6d0305aa18494c6af139bce7319c68a17180087f4"},
- {file = "ruff-0.4.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:63fde3bf6f3ad4e990357af1d30e8ba2730860a954ea9282c95fc0846f5f64af"},
- {file = "ruff-0.4.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78e3ba4620dee27f76bbcad97067766026c918ba0f2d035c2fc25cbdd04d9c97"},
- {file = "ruff-0.4.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:441dab55c568e38d02bbda68a926a3d0b54f5510095c9de7f95e47a39e0168aa"},
- {file = "ruff-0.4.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1169e47e9c4136c997f08f9857ae889d614c5035d87d38fda9b44b4338909cdf"},
- {file = "ruff-0.4.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:755ac9ac2598a941512fc36a9070a13c88d72ff874a9781493eb237ab02d75df"},
- {file = "ruff-0.4.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4b02a65985be2b34b170025a8b92449088ce61e33e69956ce4d316c0fe7cce0"},
- {file = "ruff-0.4.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:75a426506a183d9201e7e5664de3f6b414ad3850d7625764106f7b6d0486f0a1"},
- {file = "ruff-0.4.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6e1b139b45e2911419044237d90b60e472f57285950e1492c757dfc88259bb06"},
- {file = "ruff-0.4.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a6f29a8221d2e3d85ff0c7b4371c0e37b39c87732c969b4d90f3dad2e721c5b1"},
- {file = "ruff-0.4.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d6ef817124d72b54cc923f3444828ba24fa45c3164bc9e8f1813db2f3d3a8a11"},
- {file = "ruff-0.4.5-py3-none-win32.whl", hash = "sha256:aed8166c18b1a169a5d3ec28a49b43340949e400665555b51ee06f22813ef062"},
- {file = "ruff-0.4.5-py3-none-win_amd64.whl", hash = "sha256:b0b03c619d2b4350b4a27e34fd2ac64d0dabe1afbf43de57d0f9d8a05ecffa45"},
- {file = "ruff-0.4.5-py3-none-win_arm64.whl", hash = "sha256:9d15de3425f53161b3f5a5658d4522e4eee5ea002bf2ac7aa380743dd9ad5fba"},
- {file = "ruff-0.4.5.tar.gz", hash = "sha256:286eabd47e7d4d521d199cab84deca135557e6d1e0f0d01c29e757c3cb151b54"},
+ {file = "ruff-0.4.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7663a6d78f6adb0eab270fa9cf1ff2d28618ca3a652b60f2a234d92b9ec89066"},
+ {file = "ruff-0.4.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eeceb78da8afb6de0ddada93112869852d04f1cd0f6b80fe464fd4e35c330913"},
+ {file = "ruff-0.4.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aad360893e92486662ef3be0a339c5ca3c1b109e0134fcd37d534d4be9fb8de3"},
+ {file = "ruff-0.4.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:284c2e3f3396fb05f5f803c9fffb53ebbe09a3ebe7dda2929ed8d73ded736deb"},
+ {file = "ruff-0.4.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7354f921e3fbe04d2a62d46707e569f9315e1a613307f7311a935743c51a764"},
+ {file = "ruff-0.4.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:72584676164e15a68a15778fd1b17c28a519e7a0622161eb2debdcdabdc71883"},
+ {file = "ruff-0.4.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9678d5c9b43315f323af2233a04d747409d1e3aa6789620083a82d1066a35199"},
+ {file = "ruff-0.4.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704977a658131651a22b5ebeb28b717ef42ac6ee3b11e91dc87b633b5d83142b"},
+ {file = "ruff-0.4.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05f8d6f0c3cce5026cecd83b7a143dcad503045857bc49662f736437380ad45"},
+ {file = "ruff-0.4.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ea874950daca5697309d976c9afba830d3bf0ed66887481d6bca1673fc5b66a"},
+ {file = "ruff-0.4.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fc95aac2943ddf360376be9aa3107c8cf9640083940a8c5bd824be692d2216dc"},
+ {file = "ruff-0.4.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:384154a1c3f4bf537bac69f33720957ee49ac8d484bfc91720cc94172026ceed"},
+ {file = "ruff-0.4.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e9d5ce97cacc99878aa0d084c626a15cd21e6b3d53fd6f9112b7fc485918e1fa"},
+ {file = "ruff-0.4.8-py3-none-win32.whl", hash = "sha256:6d795d7639212c2dfd01991259460101c22aabf420d9b943f153ab9d9706e6a9"},
+ {file = "ruff-0.4.8-py3-none-win_amd64.whl", hash = "sha256:e14a3a095d07560a9d6769a72f781d73259655919d9b396c650fc98a8157555d"},
+ {file = "ruff-0.4.8-py3-none-win_arm64.whl", hash = "sha256:14019a06dbe29b608f6b7cbcec300e3170a8d86efaddb7b23405cb7f7dcaf780"},
+ {file = "ruff-0.4.8.tar.gz", hash = "sha256:16d717b1d57b2e2fd68bd0bf80fb43931b79d05a7131aa477d66fc40fbd86268"},
]
[[package]]
@@ -3928,33 +3928,33 @@ files = [
[[package]]
name = "tornado"
-version = "6.4"
+version = "6.4.1"
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
optional = false
-python-versions = ">= 3.8"
+python-versions = ">=3.8"
files = [
- {file = "tornado-6.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:02ccefc7d8211e5a7f9e8bc3f9e5b0ad6262ba2fbb683a6443ecc804e5224ce0"},
- {file = "tornado-6.4-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:27787de946a9cffd63ce5814c33f734c627a87072ec7eed71f7fc4417bb16263"},
- {file = "tornado-6.4-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7894c581ecdcf91666a0912f18ce5e757213999e183ebfc2c3fdbf4d5bd764e"},
- {file = "tornado-6.4-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e43bc2e5370a6a8e413e1e1cd0c91bedc5bd62a74a532371042a18ef19e10579"},
- {file = "tornado-6.4-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0251554cdd50b4b44362f73ad5ba7126fc5b2c2895cc62b14a1c2d7ea32f212"},
- {file = "tornado-6.4-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fd03192e287fbd0899dd8f81c6fb9cbbc69194d2074b38f384cb6fa72b80e9c2"},
- {file = "tornado-6.4-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:88b84956273fbd73420e6d4b8d5ccbe913c65d31351b4c004ae362eba06e1f78"},
- {file = "tornado-6.4-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:71ddfc23a0e03ef2df1c1397d859868d158c8276a0603b96cf86892bff58149f"},
- {file = "tornado-6.4-cp38-abi3-win32.whl", hash = "sha256:6f8a6c77900f5ae93d8b4ae1196472d0ccc2775cc1dfdc9e7727889145c45052"},
- {file = "tornado-6.4-cp38-abi3-win_amd64.whl", hash = "sha256:10aeaa8006333433da48dec9fe417877f8bcc21f48dda8d661ae79da357b2a63"},
- {file = "tornado-6.4.tar.gz", hash = "sha256:72291fa6e6bc84e626589f1c29d90a5a6d593ef5ae68052ee2ef000dfd273dee"},
+ {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:163b0aafc8e23d8cdc3c9dfb24c5368af84a81e3364745ccb4427669bf84aec8"},
+ {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6d5ce3437e18a2b66fbadb183c1d3364fb03f2be71299e7d10dbeeb69f4b2a14"},
+ {file = "tornado-6.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e20b9113cd7293f164dc46fffb13535266e713cdb87bd2d15ddb336e96cfc4"},
+ {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ae50a504a740365267b2a8d1a90c9fbc86b780a39170feca9bcc1787ff80842"},
+ {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613bf4ddf5c7a95509218b149b555621497a6cc0d46ac341b30bd9ec19eac7f3"},
+ {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25486eb223babe3eed4b8aecbac33b37e3dd6d776bc730ca14e1bf93888b979f"},
+ {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:454db8a7ecfcf2ff6042dde58404164d969b6f5d58b926da15e6b23817950fc4"},
+ {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a02a08cc7a9314b006f653ce40483b9b3c12cda222d6a46d4ac63bb6c9057698"},
+ {file = "tornado-6.4.1-cp38-abi3-win32.whl", hash = "sha256:d9a566c40b89757c9aa8e6f032bcdb8ca8795d7c1a9762910c722b1635c9de4d"},
+ {file = "tornado-6.4.1-cp38-abi3-win_amd64.whl", hash = "sha256:b24b8982ed444378d7f21d563f4180a2de31ced9d8d84443907a0a64da2072e7"},
+ {file = "tornado-6.4.1.tar.gz", hash = "sha256:92d3ab53183d8c50f8204a51e6f91d18a15d5ef261e84d452800d4ff6fc504e9"},
]
[[package]]
name = "tox"
-version = "4.15.0"
+version = "4.15.1"
description = "tox is a generic virtualenv management and test command line tool"
optional = false
python-versions = ">=3.8"
files = [
- {file = "tox-4.15.0-py3-none-any.whl", hash = "sha256:300055f335d855b2ab1b12c5802de7f62a36d4fd53f30bd2835f6a201dda46ea"},
- {file = "tox-4.15.0.tar.gz", hash = "sha256:7a0beeef166fbe566f54f795b4906c31b428eddafc0102ac00d20998dd1933f6"},
+ {file = "tox-4.15.1-py3-none-any.whl", hash = "sha256:f00a5dc4222b358e69694e47e3da0227ac41253509bca9f45aa8f012053e8d9d"},
+ {file = "tox-4.15.1.tar.gz", hash = "sha256:53a092527d65e873e39213ebd4bd027a64623320b6b0326136384213f95b7076"},
]
[package.dependencies]
@@ -4227,13 +4227,13 @@ test = ["pytest (>=6.0.0)", "setuptools (>=65)"]
[[package]]
name = "widgetsnbextension"
-version = "4.0.10"
+version = "4.0.11"
description = "Jupyter interactive widgets for Jupyter Notebook"
optional = false
python-versions = ">=3.7"
files = [
- {file = "widgetsnbextension-4.0.10-py3-none-any.whl", hash = "sha256:d37c3724ec32d8c48400a435ecfa7d3e259995201fbefa37163124a9fcb393cc"},
- {file = "widgetsnbextension-4.0.10.tar.gz", hash = "sha256:64196c5ff3b9a9183a8e699a4227fb0b7002f252c814098e66c4d1cd0644688f"},
+ {file = "widgetsnbextension-4.0.11-py3-none-any.whl", hash = "sha256:55d4d6949d100e0d08b94948a42efc3ed6dfdc0e9468b2c4b128c9a2ce3a7a36"},
+ {file = "widgetsnbextension-4.0.11.tar.gz", hash = "sha256:8b22a8f1910bfd188e596fe7fc05dcbd87e810c8a4ba010bdb3da86637398474"},
]
[[package]]
@@ -4258,4 +4258,4 @@ desktop = ["opencv-python"]
[metadata]
lock-version = "2.0"
python-versions = "^3.8"
-content-hash = "ad8402ec1767f9427ab38bad7dab54b302a30f9e08b6489fad224c8481745b37"
+content-hash = "e3d79f6c93041323b04c7b45e93bb3c4198b21889044004af8a0485a6145a207"
diff --git a/pyproject.toml b/pyproject.toml
index ff83f5fa..59d4176d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "supervision"
-version = "0.21.0rc5"
+version = "0.21.0"
description = "A set of easy-to-use utils that will come in handy in any Computer Vision project"
authors = ["Piotr Skalski
"]
maintainers = ["Piotr Skalski "]
@@ -42,7 +42,7 @@ pyyaml = ">=5.3"
defusedxml = "^0.7.1"
opencv-python = { version = ">=4.5.5.64", optional = true }
opencv-python-headless = ">=4.5.5.64"
-requests = { version = ">=2.26.0,<=2.32.2", optional = true }
+requests = { version = ">=2.26.0,<=2.32.3", optional = true }
tqdm = { version = ">=4.62.3,<=4.66.4", optional = true }
pillow = ">=9.4"
diff --git a/supervision/__init__.py b/supervision/__init__.py
index abe63390..4f28d49f 100644
--- a/supervision/__init__.py
+++ b/supervision/__init__.py
@@ -39,6 +39,13 @@ from supervision.dataset.utils import mask_to_rle, rle_to_mask
from supervision.detection.annotate import BoxAnnotator
from supervision.detection.core import Detections
from supervision.detection.line_zone import LineZone, LineZoneAnnotator
+from supervision.detection.lmm import LMM
+from supervision.detection.overlap_filter import (
+ OverlapFilter,
+ box_non_max_merge,
+ box_non_max_suppression,
+ mask_non_max_suppression,
+)
from supervision.detection.tools.csv_sink import CSVSink
from supervision.detection.tools.inference_slicer import InferenceSlicer
from supervision.detection.tools.json_sink import JSONSink
@@ -46,14 +53,12 @@ from supervision.detection.tools.polygon_zone import PolygonZone, PolygonZoneAnn
from supervision.detection.tools.smoother import DetectionsSmoother
from supervision.detection.utils import (
box_iou_batch,
- box_non_max_suppression,
calculate_masks_centroids,
clip_boxes,
contains_holes,
contains_multiple_segments,
filter_polygons_by_area,
mask_iou_batch,
- mask_non_max_suppression,
mask_to_polygons,
mask_to_xyxy,
move_boxes,
diff --git a/supervision/detection/core.py b/supervision/detection/core.py
index d939293f..37dde153 100644
--- a/supervision/detection/core.py
+++ b/supervision/detection/core.py
@@ -8,20 +8,24 @@ import numpy as np
from supervision.config import CLASS_NAME_DATA_FIELD, ORIENTED_BOX_COORDINATES
from supervision.detection.lmm import LMM, from_paligemma, validate_lmm_and_kwargs
-from supervision.detection.utils import (
+from supervision.detection.overlap_filter import (
+ box_non_max_merge,
box_non_max_suppression,
+ mask_non_max_suppression,
+)
+from supervision.detection.utils import (
+ box_iou_batch,
calculate_masks_centroids,
extract_ultralytics_masks,
get_data_item,
is_data_equal,
- mask_non_max_suppression,
mask_to_xyxy,
merge_data,
process_roboflow_result,
xywh_to_xyxy,
)
from supervision.geometry.core import Position
-from supervision.utils.internal import deprecated
+from supervision.utils.internal import deprecated, get_instance_variables
from supervision.validators import validate_detections_fields
@@ -874,6 +878,14 @@ class Detections:
class_id=np.array([], dtype=int),
)
+ def is_empty(self) -> bool:
+ """
+ Returns `True` if the `Detections` object is considered empty.
+ """
+ empty_detections = Detections.empty()
+ empty_detections.data = self.data
+ return self == empty_detections
+
@classmethod
def merge(cls, detections_list: List[Detections]) -> Detections:
"""
@@ -886,6 +898,10 @@ class Detections:
For example, if merging Detections with 3 and 4 detected objects, this method
will return a Detections with 7 objects (7 entries in `xyxy`, `mask`, etc).
+ !!! Note
+
+ When merging, empty `Detections` objects are ignored.
+
Args:
detections_list (List[Detections]): A list of Detections objects to merge.
@@ -924,6 +940,10 @@ class Detections:
array([0.1, 0.2, 0.3])
```
"""
+ detections_list = [
+ detections for detections in detections_list if not detections.is_empty()
+ ]
+
if len(detections_list) == 0:
return Detections.empty()
@@ -942,12 +962,13 @@ class Detections:
def stack_or_none(name: str):
if all(d.__getattribute__(name) is None for d in detections_list):
return None
- stack_list = [
- d.__getattribute__(name)
- for d in detections_list
- if d.__getattribute__(name) is not None
- ]
- return np.vstack(stack_list) if name == "mask" else np.hstack(stack_list)
+ if any(d.__getattribute__(name) is None for d in detections_list):
+ raise ValueError(f"All or none of the '{name}' fields must be None")
+ return (
+ np.vstack([d.__getattribute__(name) for d in detections_list])
+ if name == "mask"
+ else np.hstack([d.__getattribute__(name) for d in detections_list])
+ )
mask = stack_or_none("mask")
confidence = stack_or_none("confidence")
@@ -1197,3 +1218,195 @@ class Detections:
)
return self[indices]
+
+ def with_nmm(
+ self, threshold: float = 0.5, class_agnostic: bool = False
+ ) -> Detections:
+ """
+ Perform non-maximum merging on the current set of object detections.
+
+ Args:
+ threshold (float, optional): The intersection-over-union threshold
+ to use for non-maximum merging. Defaults to 0.5.
+ class_agnostic (bool, optional): Whether to perform class-agnostic
+ non-maximum merging. If True, the class_id of each detection
+ will be ignored. Defaults to False.
+
+ Returns:
+ Detections: A new Detections object containing the subset of detections
+ after non-maximum merging.
+
+ Raises:
+ AssertionError: If `confidence` is None or `class_id` is None and
+ class_agnostic is False.
+
+ { align=center width="800" }
+ """ # noqa: E501 // docs
+ if len(self) == 0:
+ return self
+
+ assert (
+ self.confidence is not None
+ ), "Detections confidence must be given for NMM to be executed."
+
+ if class_agnostic:
+ predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1)))
+ else:
+ assert self.class_id is not None, (
+ "Detections class_id must be given for NMM to be executed. If you"
+ " intended to perform class agnostic NMM set class_agnostic=True."
+ )
+ predictions = np.hstack(
+ (
+ self.xyxy,
+ self.confidence.reshape(-1, 1),
+ self.class_id.reshape(-1, 1),
+ )
+ )
+
+ merge_groups = box_non_max_merge(
+ predictions=predictions, iou_threshold=threshold
+ )
+
+ result = []
+ for merge_group in merge_groups:
+ unmerged_detections = [self[i] for i in merge_group]
+ merged_detections = merge_inner_detections_objects(
+ unmerged_detections, threshold
+ )
+ result.append(merged_detections)
+
+ return Detections.merge(result)
+
+
+def merge_inner_detection_object_pair(
+ detections_1: Detections, detections_2: Detections
+) -> Detections:
+ """
+ Merges two Detections object into a single Detections object.
+ Assumes each Detections contains exactly one object.
+
+ A `winning` detection is determined based on the confidence score of the two
+ input detections. This winning detection is then used to specify which
+ `class_id`, `tracker_id`, and `data` to include in the merged Detections object.
+
+ The resulting `confidence` of the merged object is calculated by the weighted
+ contribution of ea detection to the merged object.
+ The bounding boxes and masks of the two input detections are merged into a
+ single bounding box and mask, respectively.
+
+ Args:
+ detections_1 (Detections):
+ The first Detections object
+ detections_2 (Detections):
+ The second Detections object
+
+ Returns:
+ Detections: A new Detections object, with merged attributes.
+
+ Raises:
+ ValueError: If the input Detections objects do not have exactly 1 detected
+ object.
+
+ Example:
+ ```python
+ import cv2
+ import supervision as sv
+ from inference import get_model
+
+ image = cv2.imread()
+ model = get_model(model_id="yolov8s-640")
+
+ result = model.infer(image)[0]
+ detections = sv.Detections.from_inference(result)
+
+ merged_detections = merge_object_detection_pair(
+ detections[0], detections[1])
+ ```
+ """
+ if len(detections_1) != 1 or len(detections_2) != 1:
+ raise ValueError("Both Detections should have exactly 1 detected object.")
+
+ validate_fields_both_defined_or_none(detections_1, detections_2)
+
+ xyxy_1 = detections_1.xyxy[0]
+ xyxy_2 = detections_2.xyxy[0]
+ if detections_1.confidence is None and detections_2.confidence is None:
+ merged_confidence = None
+ else:
+ detection_1_area = (xyxy_1[2] - xyxy_1[0]) * (xyxy_1[3] - xyxy_1[1])
+ detections_2_area = (xyxy_2[2] - xyxy_2[0]) * (xyxy_2[3] - xyxy_2[1])
+ merged_confidence = (
+ detection_1_area * detections_1.confidence[0]
+ + detections_2_area * detections_2.confidence[0]
+ ) / (detection_1_area + detections_2_area)
+ merged_confidence = np.array([merged_confidence])
+
+ merged_x1, merged_y1 = np.minimum(xyxy_1[:2], xyxy_2[:2])
+ merged_x2, merged_y2 = np.maximum(xyxy_1[2:], xyxy_2[2:])
+ merged_xyxy = np.array([[merged_x1, merged_y1, merged_x2, merged_y2]])
+
+ if detections_1.mask is None and detections_2.mask is None:
+ merged_mask = None
+ else:
+ merged_mask = np.logical_or(detections_1.mask, detections_2.mask)
+
+ if detections_1.confidence is None and detections_2.confidence is None:
+ winning_detection = detections_1
+ elif detections_1.confidence[0] >= detections_2.confidence[0]:
+ winning_detection = detections_1
+ else:
+ winning_detection = detections_2
+
+ return Detections(
+ xyxy=merged_xyxy,
+ mask=merged_mask,
+ confidence=merged_confidence,
+ class_id=winning_detection.class_id,
+ tracker_id=winning_detection.tracker_id,
+ data=winning_detection.data,
+ )
+
+
+def merge_inner_detections_objects(
+ detections: List[Detections], threshold=0.5
+) -> Detections:
+ """
+ Given N detections each of length 1 (exactly one object inside), combine them into a
+ single detection object of length 1. The contained inner object will be the merged
+ result of all the input detections.
+
+ For example, this lets you merge N boxes into one big box, N masks into one mask,
+ etc.
+ """
+ detections_1 = detections[0]
+ for detections_2 in detections[1:]:
+ box_iou = box_iou_batch(detections_1.xyxy, detections_2.xyxy)[0]
+ if box_iou < threshold:
+ break
+ detections_1 = merge_inner_detection_object_pair(detections_1, detections_2)
+ return detections_1
+
+
+def validate_fields_both_defined_or_none(
+ detections_1: Detections, detections_2: Detections
+) -> None:
+ """
+ Verify that for each optional field in the Detections, both instances either have
+ the field set to None or both have it set to non-None values.
+
+ `data` field is ignored.
+
+ Raises:
+ ValueError: If one field is None and the other is not, for any of the fields.
+ """
+ attributes = get_instance_variables(detections_1)
+ for attribute in attributes:
+ value_1 = getattr(detections_1, attribute)
+ value_2 = getattr(detections_2, attribute)
+
+ if (value_1 is None) != (value_2 is None):
+ raise ValueError(
+ f"Field '{attribute}' should be consistently None or not None in both "
+ "Detections."
+ )
diff --git a/supervision/detection/line_zone.py b/supervision/detection/line_zone.py
index cd4400e5..4255a0ad 100644
--- a/supervision/detection/line_zone.py
+++ b/supervision/detection/line_zone.py
@@ -5,6 +5,7 @@ import cv2
import numpy as np
from supervision.detection.core import Detections
+from supervision.detection.utils import cross_product
from supervision.draw.color import Color
from supervision.draw.utils import draw_text
from supervision.geometry.core import Point, Position, Vector
@@ -83,6 +84,8 @@ class LineZone:
self.in_count: int = 0
self.out_count: int = 0
self.triggering_anchors = triggering_anchors
+ if not list(self.triggering_anchors):
+ raise ValueError("Triggering anchors cannot be empty.")
@staticmethod
def calculate_region_of_interest_limits(vector: Vector) -> Tuple[Vector, Vector]:
@@ -158,28 +161,23 @@ class LineZone:
]
)
+ cross_products_1 = cross_product(all_anchors, self.limits[0])
+ cross_products_2 = cross_product(all_anchors, self.limits[1])
+ in_limits = (cross_products_1 > 0) == (cross_products_2 > 0)
+ in_limits = np.all(in_limits, axis=0)
+
+ triggers = cross_product(all_anchors, self.vector) < 0
+ has_any_left_trigger = np.any(triggers, axis=0)
+ has_any_right_trigger = np.any(~triggers, axis=0)
+ is_uniformly_triggered = ~(has_any_left_trigger & has_any_right_trigger)
for i, tracker_id in enumerate(detections.tracker_id):
- box_anchors = [Point(x=x, y=y) for x, y in all_anchors[:, i, :]]
-
- in_limits = all(
- [
- self.is_point_in_limits(point=anchor, limits=self.limits)
- for anchor in box_anchors
- ]
- )
-
- if not in_limits:
+ if not in_limits[i]:
continue
- triggers = [
- self.vector.cross_product(point=anchor) < 0 for anchor in box_anchors
- ]
-
- if len(set(triggers)) == 2:
+ if not is_uniformly_triggered[i]:
continue
- tracker_state = triggers[0]
-
+ tracker_state = has_any_left_trigger[i]
if tracker_id not in self.tracker_state:
self.tracker_state[tracker_id] = tracker_state
continue
diff --git a/supervision/detection/lmm.py b/supervision/detection/lmm.py
index 0278fc00..5f61db0a 100644
--- a/supervision/detection/lmm.py
+++ b/supervision/detection/lmm.py
@@ -41,7 +41,7 @@ def from_paligemma(
) -> Tuple[np.ndarray, Optional[np.ndarray], np.ndarray]:
w, h = resolution_wh
pattern = re.compile(
- r"(?) ([\w\s]+)"
+ r"(?) ([\w\s\-]+)"
)
matches = pattern.findall(result)
matches = np.array(matches) if matches else np.empty((0, 5))
diff --git a/supervision/detection/overlap_filter.py b/supervision/detection/overlap_filter.py
new file mode 100644
index 00000000..ab4408d1
--- /dev/null
+++ b/supervision/detection/overlap_filter.py
@@ -0,0 +1,263 @@
+from enum import Enum
+from typing import List, Union
+
+import numpy as np
+import numpy.typing as npt
+
+from supervision.detection.utils import box_iou_batch, mask_iou_batch
+
+
+def resize_masks(masks: np.ndarray, max_dimension: int = 640) -> np.ndarray:
+ """
+ Resize all masks in the array to have a maximum dimension of max_dimension,
+ maintaining aspect ratio.
+
+ Args:
+ masks (np.ndarray): 3D array of binary masks with shape (N, H, W).
+ max_dimension (int): The maximum dimension for the resized masks.
+
+ Returns:
+ np.ndarray: Array of resized masks.
+ """
+ max_height = np.max(masks.shape[1])
+ max_width = np.max(masks.shape[2])
+ scale = min(max_dimension / max_height, max_dimension / max_width)
+
+ new_height = int(scale * max_height)
+ new_width = int(scale * max_width)
+
+ x = np.linspace(0, max_width - 1, new_width).astype(int)
+ y = np.linspace(0, max_height - 1, new_height).astype(int)
+ xv, yv = np.meshgrid(x, y)
+
+ resized_masks = masks[:, yv, xv]
+
+ resized_masks = resized_masks.reshape(masks.shape[0], new_height, new_width)
+ return resized_masks
+
+
+def mask_non_max_suppression(
+ predictions: np.ndarray,
+ masks: np.ndarray,
+ iou_threshold: float = 0.5,
+ mask_dimension: int = 640,
+) -> np.ndarray:
+ """
+ Perform Non-Maximum Suppression (NMS) on segmentation predictions.
+
+ Args:
+ predictions (np.ndarray): A 2D array of object detection predictions in
+ the format of `(x_min, y_min, x_max, y_max, score)`
+ or `(x_min, y_min, x_max, y_max, score, class)`. Shape: `(N, 5)` or
+ `(N, 6)`, where N is the number of predictions.
+ masks (np.ndarray): A 3D array of binary masks corresponding to the predictions.
+ Shape: `(N, H, W)`, where N is the number of predictions, and H, W are the
+ dimensions of each mask.
+ iou_threshold (float, optional): The intersection-over-union threshold
+ to use for non-maximum suppression.
+ mask_dimension (int, optional): The dimension to which the masks should be
+ resized before computing IOU values. Defaults to 640.
+
+ Returns:
+ np.ndarray: A boolean array indicating which predictions to keep after
+ non-maximum suppression.
+
+ Raises:
+ AssertionError: If `iou_threshold` is not within the closed
+ range from `0` to `1`.
+ """
+ assert 0 <= iou_threshold <= 1, (
+ "Value of `iou_threshold` must be in the closed range from 0 to 1, "
+ f"{iou_threshold} given."
+ )
+ rows, columns = predictions.shape
+
+ if columns == 5:
+ predictions = np.c_[predictions, np.zeros(rows)]
+
+ sort_index = predictions[:, 4].argsort()[::-1]
+ predictions = predictions[sort_index]
+ masks = masks[sort_index]
+ masks_resized = resize_masks(masks, mask_dimension)
+ ious = mask_iou_batch(masks_resized, masks_resized)
+ categories = predictions[:, 5]
+
+ keep = np.ones(rows, dtype=bool)
+ for i in range(rows):
+ if keep[i]:
+ condition = (ious[i] > iou_threshold) & (categories[i] == categories)
+ keep[i + 1 :] = np.where(condition[i + 1 :], False, keep[i + 1 :])
+
+ return keep[sort_index.argsort()]
+
+
+def box_non_max_suppression(
+ predictions: np.ndarray, iou_threshold: float = 0.5
+) -> np.ndarray:
+ """
+ Perform Non-Maximum Suppression (NMS) on object detection predictions.
+
+ Args:
+ predictions (np.ndarray): An array of object detection predictions in
+ the format of `(x_min, y_min, x_max, y_max, score)`
+ or `(x_min, y_min, x_max, y_max, score, class)`.
+ iou_threshold (float, optional): The intersection-over-union threshold
+ to use for non-maximum suppression.
+
+ Returns:
+ np.ndarray: A boolean array indicating which predictions to keep after n
+ on-maximum suppression.
+
+ Raises:
+ AssertionError: If `iou_threshold` is not within the
+ closed range from `0` to `1`.
+ """
+ assert 0 <= iou_threshold <= 1, (
+ "Value of `iou_threshold` must be in the closed range from 0 to 1, "
+ f"{iou_threshold} given."
+ )
+ rows, columns = predictions.shape
+
+ # add column #5 - category filled with zeros for agnostic nms
+ if columns == 5:
+ predictions = np.c_[predictions, np.zeros(rows)]
+
+ # sort predictions column #4 - score
+ sort_index = np.flip(predictions[:, 4].argsort())
+ predictions = predictions[sort_index]
+
+ boxes = predictions[:, :4]
+ categories = predictions[:, 5]
+ ious = box_iou_batch(boxes, boxes)
+ ious = ious - np.eye(rows)
+
+ keep = np.ones(rows, dtype=bool)
+
+ for index, (iou, category) in enumerate(zip(ious, categories)):
+ if not keep[index]:
+ continue
+
+ # drop detections with iou > iou_threshold and
+ # same category as current detections
+ condition = (iou > iou_threshold) & (categories == category)
+ keep = keep & ~condition
+
+ return keep[sort_index.argsort()]
+
+
+def group_overlapping_boxes(
+ predictions: npt.NDArray[np.float64], iou_threshold: float = 0.5
+) -> List[List[int]]:
+ """
+ Apply greedy version of non-maximum merging to avoid detecting too many
+ overlapping bounding boxes for a given object.
+
+ Args:
+ predictions (npt.NDArray[np.float64]): An array of shape `(n, 5)` containing
+ the bounding boxes coordinates in format `[x1, y1, x2, y2]`
+ and the confidence scores.
+ iou_threshold (float, optional): The intersection-over-union threshold
+ to use for non-maximum suppression. Defaults to 0.5.
+
+ Returns:
+ List[List[int]]: Groups of prediction indices be merged.
+ Each group may have 1 or more elements.
+ """
+ merge_groups: List[List[int]] = []
+
+ scores = predictions[:, 4]
+ order = scores.argsort()
+
+ while len(order) > 0:
+ idx = int(order[-1])
+
+ order = order[:-1]
+ if len(order) == 0:
+ merge_groups.append([idx])
+ break
+
+ merge_candidate = np.expand_dims(predictions[idx], axis=0)
+ ious = box_iou_batch(predictions[order][:, :4], merge_candidate[:, :4])
+ ious = ious.flatten()
+
+ above_threshold = ious >= iou_threshold
+ merge_group = [idx] + np.flip(order[above_threshold]).tolist()
+ merge_groups.append(merge_group)
+ order = order[~above_threshold]
+ return merge_groups
+
+
+def box_non_max_merge(
+ predictions: npt.NDArray[np.float64],
+ iou_threshold: float = 0.5,
+) -> List[List[int]]:
+ """
+ Apply greedy version of non-maximum merging per category to avoid detecting
+ too many overlapping bounding boxes for a given object.
+
+ Args:
+ predictions (npt.NDArray[np.float64]): An array of shape `(n, 5)` or `(n, 6)`
+ containing the bounding boxes coordinates in format `[x1, y1, x2, y2]`,
+ the confidence scores and class_ids. Omit class_id column to allow
+ detections of different classes to be merged.
+ iou_threshold (float, optional): The intersection-over-union threshold
+ to use for non-maximum suppression. Defaults to 0.5.
+
+ Returns:
+ List[List[int]]: Groups of prediction indices be merged.
+ Each group may have 1 or more elements.
+ """
+ if predictions.shape[1] == 5:
+ return group_overlapping_boxes(predictions, iou_threshold)
+
+ category_ids = predictions[:, 5]
+ merge_groups = []
+ for category_id in np.unique(category_ids):
+ curr_indices = np.where(category_ids == category_id)[0]
+ merge_class_groups = group_overlapping_boxes(
+ predictions[curr_indices], iou_threshold
+ )
+
+ for merge_class_group in merge_class_groups:
+ merge_groups.append(curr_indices[merge_class_group].tolist())
+
+ for merge_group in merge_groups:
+ if len(merge_group) == 0:
+ raise ValueError(
+ f"Empty group detected when non-max-merging "
+ f"detections: {merge_groups}"
+ )
+ return merge_groups
+
+
+class OverlapFilter(Enum):
+ """
+ Enum specifying the strategy for filtering overlapping detections.
+
+ Attributes:
+ NONE: Do not filter detections based on overlap.
+ NON_MAX_SUPPRESSION: Filter detections using non-max suppression. This means,
+ detections that overlap by more than a set threshold will be discarded,
+ except for the one with the highest confidence.
+ NON_MAX_MERGE: Merge detections with non-max merging. This means,
+ detections that overlap by more than a set threshold will be merged
+ into a single detection.
+ """
+
+ NONE = "none"
+ NON_MAX_SUPPRESSION = "non_max_suppression"
+ NON_MAX_MERGE = "non_max_merge"
+
+
+def validate_overlap_filter(
+ strategy: Union[OverlapFilter, str],
+) -> OverlapFilter:
+ if isinstance(strategy, str):
+ try:
+ strategy = OverlapFilter(strategy.lower())
+ except ValueError:
+ raise ValueError(
+ f"Invalid strategy value: {strategy}. Must be one of "
+ f"{[e.value for e in OverlapFilter]}"
+ )
+ return strategy
diff --git a/supervision/detection/tools/inference_slicer.py b/supervision/detection/tools/inference_slicer.py
index 82551434..134361bd 100644
--- a/supervision/detection/tools/inference_slicer.py
+++ b/supervision/detection/tools/inference_slicer.py
@@ -1,11 +1,14 @@
+import warnings
from concurrent.futures import ThreadPoolExecutor, as_completed
-from typing import Callable, Optional, Tuple
+from typing import Callable, Optional, Tuple, Union
import numpy as np
from supervision.detection.core import Detections
+from supervision.detection.overlap_filter import OverlapFilter, validate_overlap_filter
from supervision.detection.utils import move_boxes, move_masks
from supervision.utils.image import crop_image
+from supervision.utils.internal import SupervisionWarnings
def move_detections(
@@ -50,8 +53,10 @@ class InferenceSlicer:
`(width, height)`.
overlap_ratio_wh (Tuple[float, float]): Overlap ratio between consecutive
slices in the format `(width_ratio, height_ratio)`.
- iou_threshold (Optional[float]): Intersection over Union (IoU) threshold
- used for non-max suppression.
+ overlap_filter_strategy (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.
callback (Callable): A function that performs inference on a given image
slice and returns detections.
thread_workers (int): Number of threads for parallel execution.
@@ -68,12 +73,18 @@ class InferenceSlicer:
callback: Callable[[np.ndarray], Detections],
slice_wh: Tuple[int, int] = (320, 320),
overlap_ratio_wh: Tuple[float, float] = (0.2, 0.2),
- iou_threshold: Optional[float] = 0.5,
+ overlap_filter_strategy: Union[
+ OverlapFilter, str
+ ] = OverlapFilter.NON_MAX_SUPPRESSION,
+ iou_threshold: float = 0.5,
thread_workers: int = 1,
):
+ overlap_filter_strategy = validate_overlap_filter(overlap_filter_strategy)
+
self.slice_wh = slice_wh
self.overlap_ratio_wh = overlap_ratio_wh
self.iou_threshold = iou_threshold
+ self.overlap_filter_strategy = overlap_filter_strategy
self.callback = callback
self.thread_workers = thread_workers
@@ -104,7 +115,10 @@ class InferenceSlicer:
result = model(image_slice)[0]
return sv.Detections.from_ultralytics(result)
- slicer = sv.InferenceSlicer(callback = callback)
+ slicer = sv.InferenceSlicer(
+ callback=callback,
+ overlap_filter_strategy=sv.OverlapFilter.NON_MAX_SUPPRESSION,
+ )
detections = slicer(image)
```
@@ -124,9 +138,19 @@ class InferenceSlicer:
for future in as_completed(futures):
detections_list.append(future.result())
- return Detections.merge(detections_list=detections_list).with_nms(
- threshold=self.iou_threshold
- )
+ merged = Detections.merge(detections_list=detections_list)
+ if self.overlap_filter_strategy == OverlapFilter.NONE:
+ return merged
+ elif self.overlap_filter_strategy == OverlapFilter.NON_MAX_SUPPRESSION:
+ return merged.with_nms(threshold=self.iou_threshold)
+ elif self.overlap_filter_strategy == OverlapFilter.NON_MAX_MERGE:
+ return merged.with_nmm(threshold=self.iou_threshold)
+ else:
+ warnings.warn(
+ f"Invalid overlap filter strategy: {self.overlap_filter_strategy}",
+ category=SupervisionWarnings,
+ )
+ return merged
def _run_callback(self, image, offset) -> Detections:
"""
diff --git a/supervision/detection/tools/polygon_zone.py b/supervision/detection/tools/polygon_zone.py
index a1997212..f1c48f94 100644
--- a/supervision/detection/tools/polygon_zone.py
+++ b/supervision/detection/tools/polygon_zone.py
@@ -54,6 +54,8 @@ class PolygonZone:
self.polygon = polygon.astype(int)
self.triggering_anchors = triggering_anchors
+ if not list(self.triggering_anchors):
+ raise ValueError("Triggering anchors cannot be empty.")
self.current_count = 0
diff --git a/supervision/detection/utils.py b/supervision/detection/utils.py
index aac0d627..5b92aedd 100644
--- a/supervision/detection/utils.py
+++ b/supervision/detection/utils.py
@@ -6,6 +6,7 @@ import numpy as np
import numpy.typing as npt
from supervision.config import CLASS_NAME_DATA_FIELD
+from supervision.geometry.core import Vector
MIN_POLYGON_POINT_COUNT = 3
@@ -139,144 +140,6 @@ def mask_iou_batch(
return np.vstack(ious)
-def resize_masks(masks: np.ndarray, max_dimension: int = 640) -> np.ndarray:
- """
- Resize all masks in the array to have a maximum dimension of max_dimension,
- maintaining aspect ratio.
-
- Args:
- masks (np.ndarray): 3D array of binary masks with shape (N, H, W).
- max_dimension (int): The maximum dimension for the resized masks.
-
- Returns:
- np.ndarray: Array of resized masks.
- """
- max_height = np.max(masks.shape[1])
- max_width = np.max(masks.shape[2])
- scale = min(max_dimension / max_height, max_dimension / max_width)
-
- new_height = int(scale * max_height)
- new_width = int(scale * max_width)
-
- x = np.linspace(0, max_width - 1, new_width).astype(int)
- y = np.linspace(0, max_height - 1, new_height).astype(int)
- xv, yv = np.meshgrid(x, y)
-
- resized_masks = masks[:, yv, xv]
-
- resized_masks = resized_masks.reshape(masks.shape[0], new_height, new_width)
- return resized_masks
-
-
-def mask_non_max_suppression(
- predictions: np.ndarray,
- masks: np.ndarray,
- iou_threshold: float = 0.5,
- mask_dimension: int = 640,
-) -> np.ndarray:
- """
- Perform Non-Maximum Suppression (NMS) on segmentation predictions.
-
- Args:
- predictions (np.ndarray): A 2D array of object detection predictions in
- the format of `(x_min, y_min, x_max, y_max, score)`
- or `(x_min, y_min, x_max, y_max, score, class)`. Shape: `(N, 5)` or
- `(N, 6)`, where N is the number of predictions.
- masks (np.ndarray): A 3D array of binary masks corresponding to the predictions.
- Shape: `(N, H, W)`, where N is the number of predictions, and H, W are the
- dimensions of each mask.
- iou_threshold (float, optional): The intersection-over-union threshold
- to use for non-maximum suppression.
- mask_dimension (int, optional): The dimension to which the masks should be
- resized before computing IOU values. Defaults to 640.
-
- Returns:
- np.ndarray: A boolean array indicating which predictions to keep after
- non-maximum suppression.
-
- Raises:
- AssertionError: If `iou_threshold` is not within the closed
- range from `0` to `1`.
- """
- assert 0 <= iou_threshold <= 1, (
- "Value of `iou_threshold` must be in the closed range from 0 to 1, "
- f"{iou_threshold} given."
- )
- rows, columns = predictions.shape
-
- if columns == 5:
- predictions = np.c_[predictions, np.zeros(rows)]
-
- sort_index = predictions[:, 4].argsort()[::-1]
- predictions = predictions[sort_index]
- masks = masks[sort_index]
- masks_resized = resize_masks(masks, mask_dimension)
- ious = mask_iou_batch(masks_resized, masks_resized)
- categories = predictions[:, 5]
-
- keep = np.ones(rows, dtype=bool)
- for i in range(rows):
- if keep[i]:
- condition = (ious[i] > iou_threshold) & (categories[i] == categories)
- keep[i + 1 :] = np.where(condition[i + 1 :], False, keep[i + 1 :])
-
- return keep[sort_index.argsort()]
-
-
-def box_non_max_suppression(
- predictions: np.ndarray, iou_threshold: float = 0.5
-) -> np.ndarray:
- """
- Perform Non-Maximum Suppression (NMS) on object detection predictions.
-
- Args:
- predictions (np.ndarray): An array of object detection predictions in
- the format of `(x_min, y_min, x_max, y_max, score)`
- or `(x_min, y_min, x_max, y_max, score, class)`.
- iou_threshold (float, optional): The intersection-over-union threshold
- to use for non-maximum suppression.
-
- Returns:
- np.ndarray: A boolean array indicating which predictions to keep after n
- on-maximum suppression.
-
- Raises:
- AssertionError: If `iou_threshold` is not within the
- closed range from `0` to `1`.
- """
- assert 0 <= iou_threshold <= 1, (
- "Value of `iou_threshold` must be in the closed range from 0 to 1, "
- f"{iou_threshold} given."
- )
- rows, columns = predictions.shape
-
- # add column #5 - category filled with zeros for agnostic nms
- if columns == 5:
- predictions = np.c_[predictions, np.zeros(rows)]
-
- # sort predictions column #4 - score
- sort_index = np.flip(predictions[:, 4].argsort())
- predictions = predictions[sort_index]
-
- boxes = predictions[:, :4]
- categories = predictions[:, 5]
- ious = box_iou_batch(boxes, boxes)
- ious = ious - np.eye(rows)
-
- keep = np.ones(rows, dtype=bool)
-
- for index, (iou, category) in enumerate(zip(ious, categories)):
- if not keep[index]:
- continue
-
- # drop detections with iou > iou_threshold and
- # same category as current detections
- condition = (iou > iou_threshold) & (categories == category)
- keep = keep & ~condition
-
- return keep[sort_index.argsort()]
-
-
def clip_boxes(xyxy: np.ndarray, resolution_wh: Tuple[int, int]) -> np.ndarray:
"""
Clips bounding boxes coordinates to fit within the frame resolution.
@@ -292,6 +155,25 @@ def clip_boxes(xyxy: np.ndarray, resolution_wh: Tuple[int, int]) -> np.ndarray:
np.ndarray: A numpy array of shape `(N, 4)` where each row
corresponds to a bounding box with coordinates clipped to fit
within the frame resolution.
+
+ Examples:
+ ```python
+ import numpy as np
+ import supervision as sv
+
+ xyxy = np.array([
+ [10, 20, 300, 200],
+ [15, 25, 350, 450],
+ [-10, -20, 30, 40]
+ ])
+
+ sv.clip_boxes(xyxy=xyxy, resolution_wh=(320, 240))
+ # array([
+ # [ 10, 20, 300, 200],
+ # [ 15, 25, 320, 240],
+ # [ 0, 0, 30, 40]
+ # ])
+ ```
"""
result = np.copy(xyxy)
width, height = resolution_wh
@@ -318,6 +200,23 @@ def pad_boxes(xyxy: np.ndarray, px: int, py: Optional[int] = None) -> np.ndarray
np.ndarray: A numpy array of shape `(N, 4)` where each row corresponds to a
bounding box with coordinates padded according to the provided padding
values.
+
+ Examples:
+ ```python
+ import numpy as np
+ import supervision as sv
+
+ xyxy = np.array([
+ [10, 20, 30, 40],
+ [15, 25, 35, 45]
+ ])
+
+ sv.pad_boxes(xyxy=xyxy, px=5, py=10)
+ # array([
+ # [ 5, 10, 35, 50],
+ # [10, 15, 40, 55]
+ # ])
+ ```
"""
if py is None:
py = px
@@ -349,7 +248,7 @@ def mask_to_xyxy(masks: np.ndarray) -> np.ndarray:
`(x_min, y_min, x_max, y_max)` for each mask
"""
n = masks.shape[0]
- bboxes = np.zeros((n, 4), dtype=int)
+ xyxy = np.zeros((n, 4), dtype=int)
for i, mask in enumerate(masks):
rows, cols = np.where(mask)
@@ -357,9 +256,9 @@ def mask_to_xyxy(masks: np.ndarray) -> np.ndarray:
if len(rows) > 0 and len(cols) > 0:
x_min, x_max = np.min(cols), np.max(cols)
y_min, y_max = np.min(rows), np.max(rows)
- bboxes[i, :] = [x_min, y_min, x_max, y_max]
+ xyxy[i, :] = [x_min, y_min, x_max, y_max]
- return bboxes
+ return xyxy
def mask_to_polygons(mask: np.ndarray) -> List[np.ndarray]:
@@ -595,16 +494,18 @@ def process_roboflow_result(
return xyxy, confidence, class_id, masks, tracker_id, data
-def move_boxes(xyxy: np.ndarray, offset: np.ndarray) -> np.ndarray:
+def move_boxes(
+ xyxy: npt.NDArray[np.float64], offset: npt.NDArray[np.int32]
+) -> npt.NDArray[np.float64]:
"""
Parameters:
- xyxy (np.ndarray): An array of shape `(n, 4)` containing the bounding boxes
- coordinates in format `[x1, y1, x2, y2]`
+ xyxy (npt.NDArray[np.float64]): An array of shape `(n, 4)` containing the
+ bounding boxes coordinates in format `[x1, y1, x2, y2]`
offset (np.array): An array of shape `(2,)` containing offset values in format
is `[dx, dy]`.
Returns:
- np.ndarray: Repositioned bounding boxes.
+ npt.NDArray[np.float64]: Repositioned bounding boxes.
Examples:
```python
@@ -628,24 +529,25 @@ def move_boxes(xyxy: np.ndarray, offset: np.ndarray) -> np.ndarray:
def move_masks(
- masks: np.ndarray,
- offset: np.ndarray,
- resolution_wh: Tuple[int, int] = None,
-) -> np.ndarray:
+ masks: npt.NDArray[np.bool_],
+ offset: npt.NDArray[np.int32],
+ resolution_wh: Tuple[int, int],
+) -> npt.NDArray[np.bool_]:
"""
Offset the masks in an array by the specified (x, y) amount.
Args:
- masks (np.ndarray): A 3D array of binary masks corresponding to the predictions.
- Shape: `(N, H, W)`, where N is the number of predictions, and H, W are the
- dimensions of each mask.
- offset (np.ndarray): An array of shape `(2,)` containing non-negative int values
- `[dx, dy]`.
+ masks (npt.NDArray[np.bool_]): A 3D array of binary masks corresponding to the
+ predictions. Shape: `(N, H, W)`, where N is the number of predictions, and
+ H, W are the dimensions of each mask.
+ offset (npt.NDArray[np.int32]): An array of shape `(2,)` containing non-negative
+ int values `[dx, dy]`.
resolution_wh (Tuple[int, int]): The width and height of the desired mask
resolution.
Returns:
- (np.ndarray) repositioned masks, optionally padded to the specified shape.
+ (npt.NDArray[np.bool_]) repositioned masks, optionally padded to the specified
+ shape.
"""
if offset[0] < 0 or offset[1] < 0:
@@ -661,19 +563,21 @@ def move_masks(
return mask_array
-def scale_boxes(xyxy: np.ndarray, factor: float) -> np.ndarray:
+def scale_boxes(
+ xyxy: npt.NDArray[np.float64], factor: float
+) -> npt.NDArray[np.float64]:
"""
Scale the dimensions of bounding boxes.
Parameters:
- xyxy (np.ndarray): An array of shape `(n, 4)` containing the bounding boxes
- coordinates in format `[x1, y1, x2, y2]`
+ xyxy (npt.NDArray[np.float64]): An array of shape `(n, 4)` containing the
+ bounding boxes coordinates in format `[x1, y1, x2, y2]`
factor (float): A float value representing the factor by which the box
dimensions are scaled. A factor greater than 1 enlarges the boxes, while a
factor less than 1 shrinks them.
Returns:
- np.ndarray: Scaled bounding boxes.
+ npt.NDArray[np.float64]: Scaled bounding boxes.
Examples:
```python
@@ -685,7 +589,7 @@ def scale_boxes(xyxy: np.ndarray, factor: float) -> np.ndarray:
[30, 30, 40, 40]
])
- scaled_bb = sv.scale_boxes(xyxy=xyxy, factor=1.5)
+ sv.scale_boxes(xyxy=xyxy, factor=1.5)
# array([
# [ 7.5, 7.5, 22.5, 22.5],
# [27.5, 27.5, 42.5, 42.5]
@@ -743,19 +647,19 @@ def is_data_equal(data_a: Dict[str, np.ndarray], data_b: Dict[str, np.ndarray])
def merge_data(
- data_list: List[Dict[str, Union[np.ndarray, List]]],
-) -> Dict[str, Union[np.ndarray, List]]:
+ data_list: List[Dict[str, Union[npt.NDArray[np.generic], List]]],
+) -> Dict[str, Union[npt.NDArray[np.generic], List]]:
"""
Merges the data payloads of a list of Detections instances.
Args:
data_list: The data payloads of the Detections instances. Each data payload
is a dictionary with the same keys, and the values are either lists or
- np.ndarray.
+ npt.NDArray[np.generic].
Returns:
A single data payload containing the merged data, preserving the original data
- types (list or np.ndarray).
+ types (list or npt.NDArray[np.generic]).
Raises:
ValueError: If data values within a single object have different lengths or if
@@ -764,6 +668,10 @@ def merge_data(
if not data_list:
return {}
+ all_keys_sets = [set(data.keys()) for data in data_list]
+ if not all(keys_set == all_keys_sets[0] for keys_set in all_keys_sets):
+ raise ValueError("All data dictionaries must have the same keys to merge.")
+
for data in data_list:
lengths = [len(value) for value in data.values()]
if len(set(lengths)) > 1:
@@ -771,21 +679,7 @@ def merge_data(
"All data values within a single object must have equal length."
)
- keys_by_data = [set(data.keys()) for data in data_list]
- keys_by_data = [keys for keys in keys_by_data if len(keys) > 0]
- if not keys_by_data:
- return {}
-
- common_keys = set.intersection(*keys_by_data)
- all_keys = set.union(*keys_by_data)
- if common_keys != all_keys:
- raise ValueError(
- f"All sv.Detections.data dictionaries must have the same keys. Common "
- f"keys: {common_keys}, but some dictionaries have additional keys: "
- f"{all_keys.difference(common_keys)}."
- )
-
- merged_data = {key: [] for key in all_keys}
+ merged_data = {key: [] for key in all_keys_sets[0]}
for data in data_list:
for key in data:
merged_data[key].append(data[key])
@@ -966,3 +860,20 @@ def contains_multiple_segments(
mask_uint8, labels, connectivity=connectivity
)
return number_of_labels > 2
+
+
+def cross_product(anchors: np.ndarray, vector: Vector) -> np.ndarray:
+ """
+ Get array of cross products of each anchor with a vector.
+ Args:
+ anchors: Array of anchors of shape (number of anchors, detections, 2)
+ vector: Vector to calculate cross product with
+
+ Returns:
+ Array of cross products of shape (number of anchors, detections)
+ """
+ vector_at_zero = np.array(
+ [vector.end.x - vector.start.x, vector.end.y - vector.start.y]
+ )
+ vector_start = np.array([vector.start.x, vector.start.y])
+ return np.cross(vector_at_zero, anchors - vector_start)
diff --git a/supervision/utils/internal.py b/supervision/utils/internal.py
index 978a1448..072c03b7 100644
--- a/supervision/utils/internal.py
+++ b/supervision/utils/internal.py
@@ -1,7 +1,8 @@
import functools
+import inspect
import os
import warnings
-from typing import Callable
+from typing import Any, Callable, Set
class SupervisionWarnings(Warning):
@@ -141,3 +142,42 @@ class classproperty(property):
The result of calling the function stored in 'fget' with 'owner_cls'.
"""
return self.fget(owner_cls)
+
+
+def get_instance_variables(instance: Any, include_properties=False) -> Set[str]:
+ """
+ Get the public variables of a class instance.
+
+ Args:
+ instance (Any): The instance of a class
+ include_properties (bool): Whether to include properties in the result
+
+ Usage:
+ ```python
+ detections = Detections(xyxy=np.array([1,2,3,4]))
+ variables = get_class_variables(detections)
+ # ["xyxy", "mask", "confidence", ..., "data"]
+ ```
+ """
+ if isinstance(instance, type):
+ raise ValueError("Only class instances are supported, not classes.")
+
+ fields = set(
+ (
+ name
+ for name, val in inspect.getmembers(instance)
+ if not callable(val) and not name.startswith("_")
+ )
+ )
+
+ if not include_properties:
+ properties = set(
+ (
+ name
+ for name, val in inspect.getmembers(instance.__class__)
+ if isinstance(val, property)
+ )
+ )
+ fields -= properties
+
+ return fields
diff --git a/test/detection/test_core.py b/test/detection/test_core.py
index 12f3de28..300d6dfe 100644
--- a/test/detection/test_core.py
+++ b/test/detection/test_core.py
@@ -5,7 +5,7 @@ from typing import List, Optional, Union
import numpy as np
import pytest
-from supervision.detection.core import Detections
+from supervision.detection.core import Detections, merge_inner_detection_object_pair
from supervision.geometry.core import Position
PREDICTIONS = np.array(
@@ -245,7 +245,6 @@ def test_getitem(
TEST_DET_1_2,
DoesNotRaise(),
), # Fields with same keys
- # Fields and empty
(
[TEST_DET_1, Detections.empty()],
TEST_DET_1,
@@ -264,9 +263,9 @@ def test_getitem(
TEST_DET_1,
TEST_DET_NONE,
],
- TEST_DET_1,
- DoesNotRaise(),
- ), # Single detection and None fields (+ missing Dict keys)
+ None,
+ pytest.raises(ValueError),
+ ), # Empty detection, but not Detections.empty()
# Errors: Non-zero-length differently defined keys & data
(
[TEST_DET_1, TEST_DET_DIFFERENT_FIELDS],
@@ -278,6 +277,22 @@ def test_getitem(
None,
pytest.raises(ValueError),
), # Non-empty detections with different data keys
+ (
+ [
+ mock_detections(
+ xyxy=[[10, 10, 20, 20]],
+ class_id=[1],
+ mask=[np.zeros((4, 4), dtype=bool)],
+ ),
+ Detections.empty(),
+ ],
+ mock_detections(
+ xyxy=[[10, 10, 20, 20]],
+ class_id=[1],
+ mask=[np.zeros((4, 4), dtype=bool)],
+ ),
+ DoesNotRaise(),
+ ), # Segmentation + Empty
],
)
def test_merge(
@@ -421,3 +436,172 @@ def test_equal(
detections_a: Detections, detections_b: Detections, expected_result: bool
) -> None:
assert (detections_a == detections_b) == expected_result
+
+
+@pytest.mark.parametrize(
+ "detection_1, detection_2, expected_result, exception",
+ [
+ (
+ mock_detections(
+ xyxy=[[10, 10, 30, 30]],
+ ),
+ mock_detections(
+ xyxy=[[10, 10, 30, 30]],
+ ),
+ mock_detections(
+ xyxy=[[10, 10, 30, 30]],
+ ),
+ DoesNotRaise(),
+ ), # Merge with self
+ (
+ mock_detections(
+ xyxy=[[10, 10, 30, 30]],
+ ),
+ Detections.empty(),
+ None,
+ pytest.raises(ValueError),
+ ), # merge with empty: error
+ (
+ mock_detections(
+ xyxy=[[10, 10, 30, 30]],
+ ),
+ mock_detections(
+ xyxy=[[10, 10, 30, 30], [40, 40, 60, 60]],
+ ),
+ None,
+ pytest.raises(ValueError),
+ ), # merge with 2+ objects: error
+ (
+ mock_detections(
+ xyxy=[[10, 10, 30, 30]],
+ confidence=[0.1],
+ class_id=[1],
+ mask=[np.array([[1, 1, 0], [1, 1, 0], [0, 0, 0]], dtype=bool)],
+ tracker_id=[1],
+ data={"key_1": [1]},
+ ),
+ mock_detections(
+ xyxy=[[20, 20, 40, 40]],
+ confidence=[0.1],
+ class_id=[2],
+ mask=[np.array([[0, 0, 0], [0, 1, 1], [0, 1, 1]], dtype=bool)],
+ tracker_id=[2],
+ data={"key_2": [2]},
+ ),
+ mock_detections(
+ xyxy=[[10, 10, 40, 40]],
+ confidence=[0.1],
+ class_id=[1],
+ mask=[np.array([[1, 1, 0], [1, 1, 1], [0, 1, 1]], dtype=bool)],
+ tracker_id=[1],
+ data={"key_1": [1]},
+ ),
+ DoesNotRaise(),
+ ), # Same confidence - merge box & mask, tie-break to detection_1
+ (
+ mock_detections(
+ xyxy=[[0, 0, 20, 20]],
+ confidence=[0.1],
+ class_id=[1],
+ mask=[np.array([[1, 1, 0], [1, 1, 0], [0, 0, 0]], dtype=bool)],
+ tracker_id=[1],
+ data={"key_1": [1]},
+ ),
+ mock_detections(
+ xyxy=[[10, 10, 50, 50]],
+ confidence=[0.2],
+ class_id=[2],
+ mask=[np.array([[0, 0, 0], [0, 1, 1], [0, 1, 1]], dtype=bool)],
+ tracker_id=[2],
+ data={"key_2": [2]},
+ ),
+ mock_detections(
+ xyxy=[[0, 0, 50, 50]],
+ confidence=[(1 * 0.1 + 4 * 0.2) / 5],
+ class_id=[2],
+ mask=[np.array([[1, 1, 0], [1, 1, 1], [0, 1, 1]], dtype=bool)],
+ tracker_id=[2],
+ data={"key_2": [2]},
+ ),
+ DoesNotRaise(),
+ ), # Different confidence, different area
+ (
+ mock_detections(
+ xyxy=[[10, 10, 30, 30]],
+ confidence=None,
+ class_id=[1],
+ mask=[np.array([[1, 1, 0], [1, 1, 0], [0, 0, 0]], dtype=bool)],
+ tracker_id=[1],
+ data={"key_1": [1]},
+ ),
+ mock_detections(
+ xyxy=[[20, 20, 40, 40]],
+ confidence=None,
+ class_id=[2],
+ mask=[np.array([[0, 0, 0], [0, 1, 1], [0, 1, 1]], dtype=bool)],
+ tracker_id=[2],
+ data={"key_2": [2]},
+ ),
+ mock_detections(
+ xyxy=[[10, 10, 40, 40]],
+ confidence=None,
+ class_id=[1],
+ mask=[np.array([[1, 1, 0], [1, 1, 1], [0, 1, 1]], dtype=bool)],
+ tracker_id=[1],
+ data={"key_1": [1]},
+ ),
+ DoesNotRaise(),
+ ), # No confidence at all
+ (
+ mock_detections(
+ xyxy=[[0, 0, 20, 20]],
+ confidence=None,
+ ),
+ mock_detections(
+ xyxy=[[10, 10, 30, 30]],
+ confidence=[0.2],
+ ),
+ None,
+ pytest.raises(ValueError),
+ ), # confidence: None + [x]
+ (
+ mock_detections(
+ xyxy=[[0, 0, 20, 20]],
+ mask=[np.array([[1, 1, 0], [1, 1, 0], [0, 0, 0]], dtype=bool)],
+ ),
+ mock_detections(
+ xyxy=[[10, 10, 30, 30]],
+ mask=None,
+ ),
+ None,
+ pytest.raises(ValueError),
+ ), # mask: None + [x]
+ (
+ mock_detections(xyxy=[[0, 0, 20, 20]], tracker_id=[1]),
+ mock_detections(
+ xyxy=[[10, 10, 30, 30]],
+ tracker_id=None,
+ ),
+ None,
+ pytest.raises(ValueError),
+ ), # tracker_id: None + []
+ (
+ mock_detections(xyxy=[[0, 0, 20, 20]], class_id=[1]),
+ mock_detections(
+ xyxy=[[10, 10, 30, 30]],
+ class_id=None,
+ ),
+ None,
+ pytest.raises(ValueError),
+ ), # class_id: None + []
+ ],
+)
+def test_merge_inner_detection_object_pair(
+ detection_1: Detections,
+ detection_2: Detections,
+ expected_result: Optional[Detections],
+ exception: Exception,
+):
+ with exception:
+ result = merge_inner_detection_object_pair(detection_1, detection_2)
+ assert result == expected_result
diff --git a/test/detection/test_line_counter.py b/test/detection/test_line_counter.py
index 73780414..66118e97 100644
--- a/test/detection/test_line_counter.py
+++ b/test/detection/test_line_counter.py
@@ -1,10 +1,11 @@
from contextlib import ExitStack as DoesNotRaise
-from typing import Optional, Tuple
+from test.test_utils import mock_detections
+from typing import List, Optional, Tuple
import pytest
from supervision import LineZone
-from supervision.geometry.core import Point, Vector
+from supervision.geometry.core import Point, Position, Vector
@pytest.mark.parametrize(
@@ -70,3 +71,409 @@ def test_calculate_region_of_interest_limits(
with exception:
result = LineZone.calculate_region_of_interest_limits(vector=vector)
assert result == expected_result
+
+
+@pytest.mark.parametrize(
+ "vector, xyxy_sequence, expected_crossed_in, expected_crossed_out",
+ [
+ ( # Vertical line, simple crossing
+ Vector(Point(0, 0), Point(0, 10)),
+ [
+ [4, 4, 6, 6],
+ [4 - 10, 4, 6 - 10, 6],
+ [4, 4, 6, 6],
+ [4 - 10, 4, 6 - 10, 6],
+ ],
+ [False, False, True, False],
+ [False, True, False, True],
+ ),
+ ( # Vertical line reversed, simple crossing
+ Vector(Point(0, 10), Point(0, 0)),
+ [
+ [4, 4, 6, 6],
+ [4 - 10, 4, 6 - 10, 6],
+ [4, 4, 6, 6],
+ [4 - 10, 4, 6 - 10, 6],
+ ],
+ [False, True, False, True],
+ [False, False, True, False],
+ ),
+ ( # Horizontal line, simple crossing
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [4, 4, 6, 6],
+ [4, 4 - 10, 6, 6 - 10],
+ [4, 4, 6, 6],
+ [4, 4 - 10, 6, 6 - 10],
+ ],
+ [False, True, False, True],
+ [False, False, True, False],
+ ),
+ ( # Horizontal line reversed, simple crossing
+ Vector(Point(10, 0), Point(0, 0)),
+ [
+ [4, 4, 6, 6],
+ [4, 4 - 10, 6, 6 - 10],
+ [4, 4, 6, 6],
+ [4, 4 - 10, 6, 6 - 10],
+ ],
+ [False, False, True, False],
+ [False, True, False, True],
+ ),
+ ( # Diagonal line, simple crossing
+ Vector(Point(5, 0), Point(0, 5)),
+ [
+ [0, 0, 2, 2],
+ [0 + 10, 0 + 10, 2 + 10, 2 + 10],
+ [0, 0, 2, 2],
+ [0 + 10, 0 + 10, 2 + 10, 2 + 10],
+ ],
+ [False, True, False, True],
+ [False, False, True, False],
+ ),
+ ( # Crossing beside - right side
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [20, 4, 24, 6],
+ [20, 4 - 10, 24, 6 - 10],
+ [20, 4, 24, 6],
+ [20, 4 - 10, 24, 6 - 10],
+ ],
+ [False, False, False, False],
+ [False, False, False, False],
+ ),
+ ( # Horizontal line, simple crossing, far away
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [4, 1e32, 6, 1e32 + 2],
+ [4, -1e32, 6, -1e32 + 2],
+ [4, 1e32, 6, 1e32 + 2],
+ [4, -1e32, 6, -1e32 + 2],
+ ],
+ [False, True, False, True],
+ [False, False, True, False],
+ ),
+ ( # Crossing beside - left side
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [-20, 4, -24, 6],
+ [-20, 4 - 10, -24, 6 - 10],
+ [-20, 4, -24, 6],
+ [-20, 4 - 10, -24, 6 - 10],
+ ],
+ [False, False, False, False],
+ [False, False, False, False],
+ ),
+ ( # Move above
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [-4, 4, -2, 6],
+ [-4 + 20, 4, -2 + 20, 6],
+ [-4, 4, -2, 6],
+ [-4 + 20, 4, -2 + 20, 6],
+ ],
+ [False, False, False, False],
+ [False, False, False, False],
+ ),
+ ( # Move below
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [-4, -6, -2, -4],
+ [-4 + 20, -6, -2 + 20, -4],
+ [-4, -6, -2, -4],
+ [-4 + 20, -6, -2 + 20, -4],
+ ],
+ [False, False, False, False],
+ [False, False, False, False],
+ ),
+ ( # Move into line partway
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [4, 4, 6, 6],
+ [4 + 5, 4, 6 + 5, 6],
+ [4, 4, 6, 6],
+ [4 + 5, 4, 6 + 5, 6],
+ ],
+ [False, False, False, False],
+ [False, False, False, False],
+ ),
+ ( # V-shaped crossing from outside limits - not supported.
+ Vector(Point(0, 0), Point(10, 0)),
+ [[-3, 6, -1, 8], [4, -6, 6, -4], [11, 6, 13, 8]],
+ [False, False, False],
+ [False, False, False],
+ ),
+ ( # Diagonal movement, from within limits to outside - not supported
+ Vector(Point(0, 0), Point(10, 0)),
+ [[4, 1, 6, 3], [11, 1 - 20, 13, 3 - 20]],
+ [False, False],
+ [False, False],
+ ),
+ ( # Diagonal movement, from outside limits to within - not supported
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [11, 21, 13, 23],
+ [4, -3, 6, -1],
+ ],
+ [False, False],
+ [False, False],
+ ),
+ ( # Diagonal crossing, from outside to outside limits - not supported.
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [-4, 4, -2, 8],
+ [-4 + 16, -4, -2 + 16, -6],
+ [-4, 4, -2, 8],
+ [-4 + 16, -4, -2 + 16, -6],
+ ],
+ [False, False, False, False],
+ [False, False, False, False],
+ ),
+ ],
+)
+def test_line_zone_one_detection_default_anchors(
+ vector: Vector,
+ xyxy_sequence: List[List[float]],
+ expected_crossed_in: List[bool],
+ expected_crossed_out: List[bool],
+) -> None:
+ line_zone = LineZone(start=vector.start, end=vector.end)
+
+ crossed_in_list = []
+ crossed_out_list = []
+ for i, bbox in enumerate(xyxy_sequence):
+ detections = mock_detections(
+ xyxy=[bbox],
+ tracker_id=[0],
+ )
+ crossed_in, crossed_out = line_zone.trigger(detections)
+ crossed_in_list.append(crossed_in[0])
+ crossed_out_list.append(crossed_out[0])
+
+ assert (
+ crossed_in_list == expected_crossed_in
+ ), f"expected {expected_crossed_in}, got {crossed_in_list}"
+ assert (
+ crossed_out_list == expected_crossed_out
+ ), f"expected {expected_crossed_out}, got {crossed_out_list}"
+
+
+@pytest.mark.parametrize(
+ "vector, xyxy_sequence, triggering_anchors, expected_crossed_in, "
+ "expected_crossed_out",
+ [
+ ( # Scrape line, left side, corner anchors
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [-2, 4, 2, 6],
+ [-2, 4 - 10, 2, 6 - 10],
+ [-2, 4, 2, 6],
+ [-2, 4 - 10, 2, 6 - 10],
+ ],
+ [
+ Position.TOP_LEFT,
+ Position.BOTTOM_LEFT,
+ Position.TOP_RIGHT,
+ Position.BOTTOM_RIGHT,
+ ],
+ [False, False, False, False],
+ [False, False, False, False],
+ ),
+ ( # Scrape line, left side, right anchors
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [-2, 4, 2, 6],
+ [-2, 4 - 10, 2, 6 - 10],
+ [-2, 4, 2, 6],
+ [-2, 4 - 10, 2, 6 - 10],
+ ],
+ [Position.TOP_RIGHT, Position.BOTTOM_RIGHT],
+ [False, True, False, True],
+ [False, False, True, False],
+ ),
+ ( # Scrape line, left side, center anchor (along line point)
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [-2, 4, 2, 6],
+ [-2, 4 - 10, 2, 6 - 10],
+ [-2, 4, 2, 6],
+ [-2, 4 - 10, 2, 6 - 10],
+ ],
+ [Position.CENTER],
+ [False, True, False, True],
+ [False, False, True, False],
+ ),
+ ( # Scrape line, right side, corner anchors
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [8, 4, 12, 6],
+ [8, 4 - 10, 12, 6 - 10],
+ [8, 4, 12, 6],
+ [8, 4 - 10, 12, 6 - 10],
+ ],
+ [
+ Position.TOP_LEFT,
+ Position.BOTTOM_LEFT,
+ Position.TOP_RIGHT,
+ Position.BOTTOM_RIGHT,
+ ],
+ [False, False, False, False],
+ [False, False, False, False],
+ ),
+ ( # Scrape line, right side, left anchors
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [8, 4, 12, 6],
+ [8, 4 - 10, 12, 6 - 10],
+ [8, 4, 12, 6],
+ [8, 4 - 10, 12, 6 - 10],
+ ],
+ [Position.TOP_LEFT, Position.BOTTOM_LEFT],
+ [False, True, False, True],
+ [False, False, True, False],
+ ),
+ ( # Scrape line, right side, center anchor (along line point)
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [8, 4, 12, 6],
+ [8, 4 - 10, 12, 6 - 10],
+ [8, 4, 12, 6],
+ [8, 4 - 10, 12, 6 - 10],
+ ],
+ [Position.CENTER],
+ [False, True, False, True],
+ [False, False, True, False],
+ ),
+ ( # Simple crossing, one anchor
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [4, 4, 6, 6],
+ [4, 4 - 10, 6, 6 - 10],
+ [4, 4, 6, 6],
+ [4, 4 - 10, 6, 6 - 10],
+ ],
+ [Position.CENTER],
+ [False, True, False, True],
+ [False, False, True, False],
+ ),
+ ( # Simple crossing, all box anchors
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [4, 4, 6, 6],
+ [4, 4 - 10, 6, 6 - 10],
+ [4, 4, 6, 6],
+ [4, 4 - 10, 6, 6 - 10],
+ ],
+ [
+ Position.CENTER,
+ Position.CENTER_LEFT,
+ Position.CENTER_RIGHT,
+ Position.TOP_CENTER,
+ Position.TOP_LEFT,
+ Position.TOP_RIGHT,
+ Position.BOTTOM_LEFT,
+ Position.BOTTOM_CENTER,
+ Position.BOTTOM_RIGHT,
+ ],
+ [False, True, False, True],
+ [False, False, True, False],
+ ),
+ ],
+)
+def test_line_zone_one_detection(
+ vector: Vector,
+ xyxy_sequence: List[List[float]],
+ triggering_anchors: List[Position],
+ expected_crossed_in: List[bool],
+ expected_crossed_out: List[bool],
+) -> None:
+ line_zone = LineZone(
+ start=vector.start, end=vector.end, triggering_anchors=triggering_anchors
+ )
+
+ crossed_in_list = []
+ crossed_out_list = []
+ for i, bbox in enumerate(xyxy_sequence):
+ detections = mock_detections(
+ xyxy=[bbox],
+ tracker_id=[0],
+ )
+ crossed_in, crossed_out = line_zone.trigger(detections)
+ crossed_in_list.append(crossed_in[0])
+ crossed_out_list.append(crossed_out[0])
+
+ assert (
+ crossed_in_list == expected_crossed_in
+ ), f"expected {expected_crossed_in}, got {crossed_in_list}"
+ assert (
+ crossed_out_list == expected_crossed_out
+ ), f"expected {expected_crossed_out}, got {crossed_out_list}"
+
+
+@pytest.mark.parametrize(
+ "vector, xyxy_sequence, anchors, expected_crossed_in, "
+ "expected_crossed_out, exception",
+ [
+ ( # One stays, one crosses
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [[4, 4, 6, 6], [4, 4, 6, 6]],
+ [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
+ [[4, 4, 6, 6], [4, 4, 6, 6]],
+ [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
+ ],
+ [
+ Position.TOP_LEFT,
+ Position.TOP_RIGHT,
+ Position.BOTTOM_LEFT,
+ Position.BOTTOM_RIGHT,
+ ],
+ [[False, False], [False, True], [False, False], [False, True]],
+ [[False, False], [False, False], [False, True], [False, False]],
+ DoesNotRaise(),
+ ),
+ ( # Both cross at the same time
+ Vector(Point(0, 0), Point(10, 0)),
+ [
+ [[4, 4, 6, 6], [4, 4, 6, 6]],
+ [[4, 4 - 10, 6, 6 - 10], [4, 4 - 10, 6, 6 - 10]],
+ [[4, 4, 6, 6], [4, 4, 6, 6]],
+ [[4, 4 - 10, 6, 6 - 10], [4, 4 - 10, 6, 6 - 10]],
+ ],
+ [
+ Position.TOP_LEFT,
+ Position.TOP_RIGHT,
+ Position.BOTTOM_LEFT,
+ Position.BOTTOM_RIGHT,
+ ],
+ [[False, False], [True, True], [False, False], [True, True]],
+ [[False, False], [False, False], [True, True], [False, False]],
+ DoesNotRaise(),
+ ),
+ ],
+)
+def test_line_zone_multiple_detections(
+ vector: Vector,
+ xyxy_sequence: List[List[List[float]]],
+ anchors: List[Position],
+ expected_crossed_in: List[List[bool]],
+ expected_crossed_out: List[List[bool]],
+ exception: Exception,
+) -> None:
+ with exception:
+ line_zone = LineZone(
+ start=vector.start, end=vector.end, triggering_anchors=anchors
+ )
+ crossed_in_list = []
+ crossed_out_list = []
+ for bboxes in xyxy_sequence:
+ detections = mock_detections(
+ xyxy=bboxes,
+ tracker_id=[i for i in range(0, len(bboxes))],
+ )
+ crossed_in, crossed_out = line_zone.trigger(detections)
+ crossed_in_list.append(list(crossed_in))
+ crossed_out_list.append(list(crossed_out))
+
+ assert crossed_in_list == expected_crossed_in
+ assert crossed_out_list == expected_crossed_out
diff --git a/test/detection/test_lmm.py b/test/detection/test_lmm.py
index 129aa44b..4448d8db 100644
--- a/test/detection/test_lmm.py
+++ b/test/detection/test_lmm.py
@@ -76,7 +76,27 @@ from supervision.detection.lmm import from_paligemma
None,
np.array(["black cat"]).astype(np.dtype("U")),
),
- ), # correct response; no classes
+ ), # correct response; class name with space; no classes
+ (
+ " black-cat",
+ (1000, 1000),
+ None,
+ (
+ np.array([[250.0, 250.0, 750.0, 750.0]]),
+ None,
+ np.array(["black-cat"]).astype(np.dtype("U")),
+ ),
+ ), # correct response; class name with hyphen; no classes
+ (
+ " black_cat",
+ (1000, 1000),
+ None,
+ (
+ np.array([[250.0, 250.0, 750.0, 750.0]]),
+ None,
+ np.array(["black_cat"]).astype(np.dtype("U")),
+ ),
+ ), # correct response; class name with underscore; no classes
(
" cat ;",
(1000, 1000),
diff --git a/test/detection/test_overlap_filter.py b/test/detection/test_overlap_filter.py
new file mode 100644
index 00000000..f628c30f
--- /dev/null
+++ b/test/detection/test_overlap_filter.py
@@ -0,0 +1,449 @@
+from contextlib import ExitStack as DoesNotRaise
+from typing import List, Optional
+
+import numpy as np
+import pytest
+
+from supervision.detection.overlap_filter import (
+ box_non_max_suppression,
+ group_overlapping_boxes,
+ mask_non_max_suppression,
+)
+
+
+@pytest.mark.parametrize(
+ "predictions, iou_threshold, expected_result, exception",
+ [
+ (
+ np.empty(shape=(0, 5), dtype=float),
+ 0.5,
+ [],
+ DoesNotRaise(),
+ ),
+ (
+ np.array([[0, 0, 10, 10, 1.0]]),
+ 0.5,
+ [[0]],
+ DoesNotRaise(),
+ ),
+ (
+ np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]),
+ 0.5,
+ [[1, 0]],
+ DoesNotRaise(),
+ ), # High overlap, tie-break to second det
+ (
+ np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 0.99]]),
+ 0.5,
+ [[0, 1]],
+ DoesNotRaise(),
+ ), # High overlap, merge to high confidence
+ (
+ np.array([[0, 0, 10, 10, 0.99], [0, 0, 9, 9, 1.0]]),
+ 0.5,
+ [[1, 0]],
+ DoesNotRaise(),
+ ), # (test symmetry) High overlap, merge to high confidence
+ (
+ np.array([[0, 0, 10, 10, 0.90], [0, 0, 9, 9, 1.0]]),
+ 0.5,
+ [[1, 0]],
+ DoesNotRaise(),
+ ), # (test symmetry) High overlap, merge to high confidence
+ (
+ np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]),
+ 1.0,
+ [[1], [0]],
+ DoesNotRaise(),
+ ), # High IOU required
+ (
+ np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0]]),
+ 0.0,
+ [[1, 0]],
+ DoesNotRaise(),
+ ), # No IOU required
+ (
+ np.array([[0, 0, 10, 10, 1.0], [0, 0, 5, 5, 0.9]]),
+ 0.25,
+ [[0, 1]],
+ DoesNotRaise(),
+ ), # Below IOU requirement
+ (
+ np.array([[0, 0, 10, 10, 1.0], [0, 0, 5, 5, 0.9]]),
+ 0.26,
+ [[0], [1]],
+ DoesNotRaise(),
+ ), # Above IOU requirement
+ (
+ np.array([[0, 0, 10, 10, 1.0], [0, 0, 9, 9, 1.0], [0, 0, 8, 8, 1.0]]),
+ 0.5,
+ [[2, 1, 0]],
+ DoesNotRaise(),
+ ), # 3 boxes
+ (
+ np.array(
+ [
+ [0, 0, 10, 10, 1.0],
+ [0, 0, 9, 9, 1.0],
+ [5, 5, 10, 10, 1.0],
+ [6, 6, 10, 10, 1.0],
+ [9, 9, 10, 10, 1.0],
+ ]
+ ),
+ 0.5,
+ [[4], [3, 2], [1, 0]],
+ DoesNotRaise(),
+ ), # 5 boxes, 2 merges, 1 separate
+ (
+ np.array(
+ [
+ [0, 0, 2, 1, 1.0],
+ [1, 0, 3, 1, 1.0],
+ [2, 0, 4, 1, 1.0],
+ [3, 0, 5, 1, 1.0],
+ [4, 0, 6, 1, 1.0],
+ ]
+ ),
+ 0.33,
+ [[4, 3], [2, 1], [0]],
+ DoesNotRaise(),
+ ), # sequential merge, half overlap
+ (
+ np.array(
+ [
+ [0, 0, 2, 1, 0.9],
+ [1, 0, 3, 1, 0.9],
+ [2, 0, 4, 1, 1.0],
+ [3, 0, 5, 1, 0.9],
+ [4, 0, 6, 1, 0.9],
+ ]
+ ),
+ 0.33,
+ [[2, 3, 1], [4], [0]],
+ DoesNotRaise(),
+ ), # confidence
+ ],
+)
+def test_group_overlapping_boxes(
+ predictions: np.ndarray,
+ iou_threshold: float,
+ expected_result: List[List[int]],
+ exception: Exception,
+) -> None:
+ with exception:
+ result = group_overlapping_boxes(
+ predictions=predictions, iou_threshold=iou_threshold
+ )
+
+ assert result == expected_result
+
+
+@pytest.mark.parametrize(
+ "predictions, iou_threshold, expected_result, exception",
+ [
+ (
+ np.empty(shape=(0, 5)),
+ 0.5,
+ np.array([]),
+ DoesNotRaise(),
+ ), # single box with no category
+ (
+ np.array([[10.0, 10.0, 40.0, 40.0, 0.8]]),
+ 0.5,
+ np.array([True]),
+ DoesNotRaise(),
+ ), # single box with no category
+ (
+ np.array([[10.0, 10.0, 40.0, 40.0, 0.8, 0]]),
+ 0.5,
+ np.array([True]),
+ DoesNotRaise(),
+ ), # single box with category
+ (
+ np.array(
+ [
+ [10.0, 10.0, 40.0, 40.0, 0.8],
+ [15.0, 15.0, 40.0, 40.0, 0.9],
+ ]
+ ),
+ 0.5,
+ np.array([False, True]),
+ DoesNotRaise(),
+ ), # two boxes with no category
+ (
+ np.array(
+ [
+ [10.0, 10.0, 40.0, 40.0, 0.8, 0],
+ [15.0, 15.0, 40.0, 40.0, 0.9, 1],
+ ]
+ ),
+ 0.5,
+ np.array([True, True]),
+ DoesNotRaise(),
+ ), # two boxes with different category
+ (
+ np.array(
+ [
+ [10.0, 10.0, 40.0, 40.0, 0.8, 0],
+ [15.0, 15.0, 40.0, 40.0, 0.9, 0],
+ ]
+ ),
+ 0.5,
+ np.array([False, True]),
+ DoesNotRaise(),
+ ), # two boxes with same category
+ (
+ np.array(
+ [
+ [0.0, 0.0, 30.0, 40.0, 0.8],
+ [5.0, 5.0, 35.0, 45.0, 0.9],
+ [10.0, 10.0, 40.0, 50.0, 0.85],
+ ]
+ ),
+ 0.5,
+ np.array([False, True, False]),
+ DoesNotRaise(),
+ ), # three boxes with no category
+ (
+ np.array(
+ [
+ [0.0, 0.0, 30.0, 40.0, 0.8, 0],
+ [5.0, 5.0, 35.0, 45.0, 0.9, 1],
+ [10.0, 10.0, 40.0, 50.0, 0.85, 2],
+ ]
+ ),
+ 0.5,
+ np.array([True, True, True]),
+ DoesNotRaise(),
+ ), # three boxes with same category
+ (
+ np.array(
+ [
+ [0.0, 0.0, 30.0, 40.0, 0.8, 0],
+ [5.0, 5.0, 35.0, 45.0, 0.9, 0],
+ [10.0, 10.0, 40.0, 50.0, 0.85, 1],
+ ]
+ ),
+ 0.5,
+ np.array([False, True, True]),
+ DoesNotRaise(),
+ ), # three boxes with different category
+ ],
+)
+def test_box_non_max_suppression(
+ predictions: np.ndarray,
+ iou_threshold: float,
+ expected_result: Optional[np.ndarray],
+ exception: Exception,
+) -> None:
+ with exception:
+ result = box_non_max_suppression(
+ predictions=predictions, iou_threshold=iou_threshold
+ )
+ assert np.array_equal(result, expected_result)
+
+
+@pytest.mark.parametrize(
+ "predictions, masks, iou_threshold, expected_result, exception",
+ [
+ (
+ np.empty((0, 6)),
+ np.empty((0, 5, 5)),
+ 0.5,
+ np.array([]),
+ DoesNotRaise(),
+ ), # empty predictions and masks
+ (
+ np.array([[0, 0, 0, 0, 0.8]]),
+ np.array(
+ [
+ [
+ [False, False, False, False, False],
+ [False, True, True, True, False],
+ [False, True, True, True, False],
+ [False, True, True, True, False],
+ [False, False, False, False, False],
+ ]
+ ]
+ ),
+ 0.5,
+ np.array([True]),
+ DoesNotRaise(),
+ ), # single mask with no category
+ (
+ np.array([[0, 0, 0, 0, 0.8, 0]]),
+ np.array(
+ [
+ [
+ [False, False, False, False, False],
+ [False, True, True, True, False],
+ [False, True, True, True, False],
+ [False, True, True, True, False],
+ [False, False, False, False, False],
+ ]
+ ]
+ ),
+ 0.5,
+ np.array([True]),
+ DoesNotRaise(),
+ ), # single mask with category
+ (
+ np.array([[0, 0, 0, 0, 0.8], [0, 0, 0, 0, 0.9]]),
+ np.array(
+ [
+ [
+ [False, False, False, False, False],
+ [False, 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, True, True],
+ [False, False, False, True, True],
+ [False, False, False, False, False],
+ ],
+ ]
+ ),
+ 0.5,
+ np.array([True, True]),
+ DoesNotRaise(),
+ ), # two masks non-overlapping with no category
+ (
+ np.array([[0, 0, 0, 0, 0.8], [0, 0, 0, 0, 0.9]]),
+ np.array(
+ [
+ [
+ [False, False, False, False, False],
+ [False, 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, False, True, True, True],
+ [False, False, True, True, True],
+ [False, False, True, True, True],
+ [False, False, False, False, False],
+ ],
+ ]
+ ),
+ 0.4,
+ np.array([False, True]),
+ DoesNotRaise(),
+ ), # two masks partially overlapping with no category
+ (
+ np.array([[0, 0, 0, 0, 0.8, 0], [0, 0, 0, 0, 0.9, 1]]),
+ np.array(
+ [
+ [
+ [False, False, False, False, False],
+ [False, 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, False, True, True, True],
+ [False, False, True, True, True],
+ [False, False, True, True, True],
+ [False, False, False, False, False],
+ ],
+ ]
+ ),
+ 0.5,
+ np.array([True, True]),
+ DoesNotRaise(),
+ ), # two masks partially overlapping with different category
+ (
+ np.array(
+ [
+ [0, 0, 0, 0, 0.8],
+ [0, 0, 0, 0, 0.85],
+ [0, 0, 0, 0, 0.9],
+ ]
+ ),
+ np.array(
+ [
+ [
+ [False, False, False, False, False],
+ [False, 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, 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, True, True],
+ [False, False, False, True, True],
+ [False, False, False, False, False],
+ [False, False, False, False, False],
+ ],
+ ]
+ ),
+ 0.5,
+ np.array([False, True, True]),
+ DoesNotRaise(),
+ ), # three masks with no category
+ (
+ np.array(
+ [
+ [0, 0, 0, 0, 0.8, 0],
+ [0, 0, 0, 0, 0.85, 1],
+ [0, 0, 0, 0, 0.9, 2],
+ ]
+ ),
+ np.array(
+ [
+ [
+ [False, False, False, False, False],
+ [False, 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, True, True, False, False],
+ [False, True, True, False, False],
+ [False, True, True, False, False],
+ [False, False, False, False, False],
+ ],
+ [
+ [False, False, False, False, False],
+ [False, True, True, False, False],
+ [False, True, True, False, False],
+ [False, False, False, False, False],
+ [False, False, False, False, False],
+ ],
+ ]
+ ),
+ 0.5,
+ np.array([True, True, True]),
+ DoesNotRaise(),
+ ), # three masks with different category
+ ],
+)
+def test_mask_non_max_suppression(
+ predictions: np.ndarray,
+ masks: np.ndarray,
+ iou_threshold: float,
+ expected_result: Optional[np.ndarray],
+ exception: Exception,
+) -> None:
+ with exception:
+ result = mask_non_max_suppression(
+ predictions=predictions, masks=masks, iou_threshold=iou_threshold
+ )
+ assert np.array_equal(result, expected_result)
diff --git a/test/detection/test_polygonzone.py b/test/detection/test_polygonzone.py
index 1a86a45b..ed899615 100644
--- a/test/detection/test_polygonzone.py
+++ b/test/detection/test_polygonzone.py
@@ -92,3 +92,19 @@ def test_polygon_zone_trigger(
with exception:
in_zone = polygon_zone.trigger(detections)
assert np.all(in_zone == expected_results)
+
+
+@pytest.mark.parametrize(
+ "polygon, triggering_anchors, exception",
+ [
+ (POLYGON, [sv.Position.CENTER], DoesNotRaise()),
+ (
+ POLYGON,
+ [],
+ pytest.raises(ValueError),
+ ),
+ ],
+)
+def test_polygon_zone_initialization(polygon, triggering_anchors, exception):
+ with exception:
+ sv.PolygonZone(polygon, FRAME_RESOLUTION, triggering_anchors=triggering_anchors)
diff --git a/test/detection/test_utils.py b/test/detection/test_utils.py
index 6a2070da..20c818e6 100644
--- a/test/detection/test_utils.py
+++ b/test/detection/test_utils.py
@@ -7,14 +7,12 @@ import pytest
from supervision.config import CLASS_NAME_DATA_FIELD
from supervision.detection.utils import (
- box_non_max_suppression,
calculate_masks_centroids,
clip_boxes,
contains_holes,
contains_multiple_segments,
filter_polygons_by_area,
get_data_item,
- mask_non_max_suppression,
merge_data,
move_boxes,
process_roboflow_result,
@@ -25,317 +23,6 @@ TEST_MASK = np.zeros((1, 1000, 1000), dtype=bool)
TEST_MASK[:, 300:351, 200:251] = True
-@pytest.mark.parametrize(
- "predictions, iou_threshold, expected_result, exception",
- [
- (
- np.empty(shape=(0, 5)),
- 0.5,
- np.array([]),
- DoesNotRaise(),
- ), # single box with no category
- (
- np.array([[10.0, 10.0, 40.0, 40.0, 0.8]]),
- 0.5,
- np.array([True]),
- DoesNotRaise(),
- ), # single box with no category
- (
- np.array([[10.0, 10.0, 40.0, 40.0, 0.8, 0]]),
- 0.5,
- np.array([True]),
- DoesNotRaise(),
- ), # single box with category
- (
- np.array(
- [
- [10.0, 10.0, 40.0, 40.0, 0.8],
- [15.0, 15.0, 40.0, 40.0, 0.9],
- ]
- ),
- 0.5,
- np.array([False, True]),
- DoesNotRaise(),
- ), # two boxes with no category
- (
- np.array(
- [
- [10.0, 10.0, 40.0, 40.0, 0.8, 0],
- [15.0, 15.0, 40.0, 40.0, 0.9, 1],
- ]
- ),
- 0.5,
- np.array([True, True]),
- DoesNotRaise(),
- ), # two boxes with different category
- (
- np.array(
- [
- [10.0, 10.0, 40.0, 40.0, 0.8, 0],
- [15.0, 15.0, 40.0, 40.0, 0.9, 0],
- ]
- ),
- 0.5,
- np.array([False, True]),
- DoesNotRaise(),
- ), # two boxes with same category
- (
- np.array(
- [
- [0.0, 0.0, 30.0, 40.0, 0.8],
- [5.0, 5.0, 35.0, 45.0, 0.9],
- [10.0, 10.0, 40.0, 50.0, 0.85],
- ]
- ),
- 0.5,
- np.array([False, True, False]),
- DoesNotRaise(),
- ), # three boxes with no category
- (
- np.array(
- [
- [0.0, 0.0, 30.0, 40.0, 0.8, 0],
- [5.0, 5.0, 35.0, 45.0, 0.9, 1],
- [10.0, 10.0, 40.0, 50.0, 0.85, 2],
- ]
- ),
- 0.5,
- np.array([True, True, True]),
- DoesNotRaise(),
- ), # three boxes with same category
- (
- np.array(
- [
- [0.0, 0.0, 30.0, 40.0, 0.8, 0],
- [5.0, 5.0, 35.0, 45.0, 0.9, 0],
- [10.0, 10.0, 40.0, 50.0, 0.85, 1],
- ]
- ),
- 0.5,
- np.array([False, True, True]),
- DoesNotRaise(),
- ), # three boxes with different category
- ],
-)
-def test_box_non_max_suppression(
- predictions: np.ndarray,
- iou_threshold: float,
- expected_result: Optional[np.ndarray],
- exception: Exception,
-) -> None:
- with exception:
- result = box_non_max_suppression(
- predictions=predictions, iou_threshold=iou_threshold
- )
- assert np.array_equal(result, expected_result)
-
-
-@pytest.mark.parametrize(
- "predictions, masks, iou_threshold, expected_result, exception",
- [
- (
- np.empty((0, 6)),
- np.empty((0, 5, 5)),
- 0.5,
- np.array([]),
- DoesNotRaise(),
- ), # empty predictions and masks
- (
- np.array([[0, 0, 0, 0, 0.8]]),
- np.array(
- [
- [
- [False, False, False, False, False],
- [False, True, True, True, False],
- [False, True, True, True, False],
- [False, True, True, True, False],
- [False, False, False, False, False],
- ]
- ]
- ),
- 0.5,
- np.array([True]),
- DoesNotRaise(),
- ), # single mask with no category
- (
- np.array([[0, 0, 0, 0, 0.8, 0]]),
- np.array(
- [
- [
- [False, False, False, False, False],
- [False, True, True, True, False],
- [False, True, True, True, False],
- [False, True, True, True, False],
- [False, False, False, False, False],
- ]
- ]
- ),
- 0.5,
- np.array([True]),
- DoesNotRaise(),
- ), # single mask with category
- (
- np.array([[0, 0, 0, 0, 0.8], [0, 0, 0, 0, 0.9]]),
- np.array(
- [
- [
- [False, False, False, False, False],
- [False, 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, True, True],
- [False, False, False, True, True],
- [False, False, False, False, False],
- ],
- ]
- ),
- 0.5,
- np.array([True, True]),
- DoesNotRaise(),
- ), # two masks non-overlapping with no category
- (
- np.array([[0, 0, 0, 0, 0.8], [0, 0, 0, 0, 0.9]]),
- np.array(
- [
- [
- [False, False, False, False, False],
- [False, 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, False, True, True, True],
- [False, False, True, True, True],
- [False, False, True, True, True],
- [False, False, False, False, False],
- ],
- ]
- ),
- 0.4,
- np.array([False, True]),
- DoesNotRaise(),
- ), # two masks partially overlapping with no category
- (
- np.array([[0, 0, 0, 0, 0.8, 0], [0, 0, 0, 0, 0.9, 1]]),
- np.array(
- [
- [
- [False, False, False, False, False],
- [False, 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, False, True, True, True],
- [False, False, True, True, True],
- [False, False, True, True, True],
- [False, False, False, False, False],
- ],
- ]
- ),
- 0.5,
- np.array([True, True]),
- DoesNotRaise(),
- ), # two masks partially overlapping with different category
- (
- np.array(
- [
- [0, 0, 0, 0, 0.8],
- [0, 0, 0, 0, 0.85],
- [0, 0, 0, 0, 0.9],
- ]
- ),
- np.array(
- [
- [
- [False, False, False, False, False],
- [False, 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, 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, True, True],
- [False, False, False, True, True],
- [False, False, False, False, False],
- [False, False, False, False, False],
- ],
- ]
- ),
- 0.5,
- np.array([False, True, True]),
- DoesNotRaise(),
- ), # three masks with no category
- (
- np.array(
- [
- [0, 0, 0, 0, 0.8, 0],
- [0, 0, 0, 0, 0.85, 1],
- [0, 0, 0, 0, 0.9, 2],
- ]
- ),
- np.array(
- [
- [
- [False, False, False, False, False],
- [False, 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, True, True, False, False],
- [False, True, True, False, False],
- [False, True, True, False, False],
- [False, False, False, False, False],
- ],
- [
- [False, False, False, False, False],
- [False, True, True, False, False],
- [False, True, True, False, False],
- [False, False, False, False, False],
- [False, False, False, False, False],
- ],
- ]
- ),
- 0.5,
- np.array([True, True, True]),
- DoesNotRaise(),
- ), # three masks with different category
- ],
-)
-def test_mask_non_max_suppression(
- predictions: np.ndarray,
- masks: np.ndarray,
- iou_threshold: float,
- expected_result: Optional[np.ndarray],
- exception: Exception,
-) -> None:
- with exception:
- result = mask_non_max_suppression(
- predictions=predictions, masks=masks, iou_threshold=iou_threshold
- )
- assert np.array_equal(result, expected_result)
-
-
@pytest.mark.parametrize(
"xyxy, resolution_wh, expected_result",
[
@@ -1033,8 +720,8 @@ def test_calculate_masks_centroids(
), # two data dicts with the same field name and different length arrays values
(
[{}, {"test_1": [1, 2, 3]}],
- {"test_1": [1, 2, 3]},
- DoesNotRaise(),
+ None,
+ pytest.raises(ValueError),
), # two data dicts; one empty and one non-empty dict
(
[{"test_1": [], "test_2": []}, {"test_1": [1, 2, 3], "test_2": [1, 2, 3]}],
diff --git a/test/utils/test_internal.py b/test/utils/test_internal.py
new file mode 100644
index 00000000..eee614e6
--- /dev/null
+++ b/test/utils/test_internal.py
@@ -0,0 +1,193 @@
+from contextlib import ExitStack as DoesNotRaise
+from dataclasses import dataclass, field
+from typing import Any, Set
+
+import numpy as np
+import pytest
+
+from supervision.detection.core import Detections
+from supervision.utils.internal import get_instance_variables
+
+
+class MockClass:
+ def __init__(self):
+ self.public = 0
+ self._protected = 1
+ self.__private = 2
+
+ def public_method(self):
+ pass
+
+ def _protected_method(self):
+ pass
+
+ def __private_method(self):
+ pass
+
+ @property
+ def public_property(self):
+ return 0
+
+ @property
+ def _protected_property(self):
+ return 1
+
+ @property
+ def __private_property(self):
+ return 2
+
+
+@dataclass
+class MockDataclass:
+ public: int = 0
+ _protected: int = 1
+ __private: int = 2
+
+ public_field: int = field(default=0)
+ _protected_field: int = field(default=1)
+ __private_field: int = field(default=2)
+
+ public_field_with_factory: dict = field(default_factory=dict)
+ _protected_field_with_factory: dict = field(default_factory=dict)
+ __private_field_with_factory: dict = field(default_factory=dict)
+
+ def public_method(self):
+ pass
+
+ def _protected_method(self):
+ pass
+
+ def __private_method(self):
+ pass
+
+ @property
+ def public_property(self):
+ return 0
+
+ @property
+ def _protected_property(self):
+ return 1
+
+ @property
+ def __private_property(self):
+ return 2
+
+
+@pytest.mark.parametrize(
+ "input_instance, include_properties, expected, exception",
+ [
+ (
+ MockClass,
+ False,
+ None,
+ pytest.raises(ValueError),
+ ),
+ (
+ MockClass(),
+ False,
+ {"public"},
+ DoesNotRaise(),
+ ),
+ (
+ MockClass(),
+ True,
+ {"public", "public_property"},
+ DoesNotRaise(),
+ ),
+ (
+ MockDataclass(),
+ False,
+ {"public", "public_field", "public_field_with_factory"},
+ DoesNotRaise(),
+ ),
+ (
+ MockDataclass(),
+ True,
+ {"public", "public_field", "public_field_with_factory", "public_property"},
+ DoesNotRaise(),
+ ),
+ (
+ Detections,
+ False,
+ None,
+ pytest.raises(ValueError),
+ ),
+ (
+ Detections,
+ True,
+ None,
+ pytest.raises(ValueError),
+ ),
+ (
+ Detections.empty(),
+ False,
+ {"xyxy", "class_id", "confidence", "mask", "tracker_id", "data"},
+ DoesNotRaise(),
+ ),
+ (
+ Detections.empty(),
+ True,
+ {
+ "xyxy",
+ "class_id",
+ "confidence",
+ "mask",
+ "tracker_id",
+ "data",
+ "area",
+ "box_area",
+ },
+ DoesNotRaise(),
+ ),
+ (
+ Detections(xyxy=np.array([[1, 2, 3, 4]])),
+ False,
+ {
+ "xyxy",
+ "class_id",
+ "confidence",
+ "mask",
+ "tracker_id",
+ "data",
+ },
+ DoesNotRaise(),
+ ),
+ (
+ Detections(
+ xyxy=np.array([[1, 2, 3, 4], [5, 6, 7, 8]]),
+ class_id=np.array([1, 2]),
+ confidence=np.array([0.1, 0.2]),
+ mask=np.array([[[1]], [[2]]]),
+ tracker_id=np.array([1, 2]),
+ data={"key_1": [1, 2], "key_2": [3, 4]},
+ ),
+ False,
+ {
+ "xyxy",
+ "class_id",
+ "confidence",
+ "mask",
+ "tracker_id",
+ "data",
+ },
+ DoesNotRaise(),
+ ),
+ (
+ Detections.empty(),
+ False,
+ {"xyxy", "class_id", "confidence", "mask", "tracker_id", "data"},
+ DoesNotRaise(),
+ ),
+ ],
+)
+def test_get_instance_variables(
+ input_instance: Any,
+ include_properties: bool,
+ expected: Set[str],
+ exception: Exception,
+) -> None:
+ with exception:
+ result = get_instance_variables(
+ input_instance, include_properties=include_properties
+ )
+ assert result == expected