Compare commits

...
188 Commits
Author SHA1 Message Date
SkalskiP 19b7ca0a3d bump version from 0.25.0rc1 to 0.25.0rc2 2024-11-07 14:15:05 +01:00
SkalskiP 01caa1f528 bump version from 0.24.0 to 0.25.0rc1 2024-11-07 14:05:19 +01:00
LinasKo 8e91865fd0 Merge pull request #1658 from roboflow/feat/keypoints-to-detections-and-keypoint-tracking
`keypoints_to_detections` and Keypoint tracking
2024-11-06 21:52:57 +02:00
LinasKo 89b911d5f9 Guide: New section in "Track Objects on Video", for keypoints 2024-11-06 21:27:37 +02:00
LinasKo 763be226a4 Fix: unaccounted for missing keypoints returned as [0, 0] 2024-11-06 21:00:31 +02:00
LinasKo c52223b4b9 Merge branch 'develop' into feat/keypoints-to-detections-and-keypoint-tracking 2024-11-06 20:04:53 +02:00
LinasKo a0e909669d Add keypoints_to_detections 2024-11-06 20:03:48 +02:00
LinasKo 698c085518 Add KeyPoints.is_empty() 2024-11-06 20:02:31 +02:00
LinasKo 6d7dba7572 Typo: LineZone in PolygonZone 2024-11-06 20:02:11 +02:00
LinasKo a18fb116a1 Merge pull request #1657 from roboflow/feat/new-asset-skiing
Add skiing video asset
2024-11-06 19:54:38 +02:00
LinasKo c54b4be4f0 Add skiing video asset 2024-11-06 19:49:36 +02:00
LinasKo fe2781cf85 Merge pull request #1540 from roboflow/feature/remove-false-line-counts
Extend LineZone to filter out miscounts
2024-11-06 15:53:31 +02:00
LinasKo e1af717c59 Add environment to publish.yml, publish-test.yml
* Similar issue: https://github.com/pypa/gh-action-pypi-publish/issues/217#issuecomment-1965733177
* Minor stylistic edits to the file
2024-11-06 15:37:37 +02:00
LinasKo 58c5aa981b Merge branch 'develop' into feature/remove-false-line-counts 2024-11-06 14:23:54 +02:00
LinasKo 17f482b1ba Merge pull request #1654 from roboflow/revert-1653-feat/infer-video-info-in-video-sink
Revert "VideoSink automatically infers video_info"
2024-11-06 14:23:28 +02:00
LinasKo b39ead2ef0 Revert "VideoSink automatically infers video_info" 2024-11-06 14:21:52 +02:00
LinasKo ee3aa9f08d Merge branch 'develop' into feature/remove-false-line-counts 2024-11-06 14:14:50 +02:00
LinasKo 01f0f9ebe7 Merge pull request #1653 from roboflow/feat/infer-video-info-in-video-sink
VideoSink automatically infers video_info
2024-11-06 14:14:16 +02:00
LinasKo 659ddec360 VideoSimk automatically infers video_info 2024-11-06 14:07:26 +02:00
Onuralp SEZER 8bb609eca0 Merge pull request #1652 from roboflow/dependabot/pip/mkdocs-material-9.5.44
⬆️ Bump mkdocs-material from 9.5.43 to 9.5.44
2024-11-06 07:40:05 +03:00
dependabot[bot] 62c2c6a668 ⬆️ Bump mkdocs-material from 9.5.43 to 9.5.44
Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.5.43 to 9.5.44.
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.43...9.5.44)

---
updated-dependencies:
- dependency-name: mkdocs-material
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-06 00:28:19 +00:00
LinasKo d1e11684e7 Fixed bugs, heavily refactored crossing confirmation in LineZone 2024-11-06 00:24:09 +02:00
Onuralp SEZER f7c47b6edb Merge pull request #1651 from roboflow/pre-commit-ci-update-config
chore(pre_commit): ⬆ pre_commit autoupdate
2024-11-04 21:11:43 +03:00
pre-commit-ci[bot] 5391671838 chore(pre_commit): ⬆ pre_commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.7.1 → v0.7.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.1...v0.7.2)
2024-11-04 18:02:20 +00:00
LinasKo 11355c5192 Merge pull request #1589 from souhhmm/feat/detection-metadata
Feat: Added detection metadata
2024-11-04 13:19:11 +02:00
pre-commit-ci[bot] 9ee1b5dc5a fix(pre_commit): 🎨 auto format pre-commit hooks 2024-11-04 10:41:58 +00:00
LinasKo d645bd816e Fix: Add metadata to public var retrieval test 2024-11-04 12:41:16 +02:00
LinasKo 5cce8a1599 Remoe Detections.empty metadata field 2024-11-04 12:23:56 +02:00
LinasKo d388857057 Merge pull request #1649 from roboflow/dependabot/pip/ruff-0.7.2
⬆️ Bump ruff from 0.7.1 to 0.7.2
2024-11-04 11:18:18 +02:00
dependabot[bot] 940243fc18 ⬆️ Bump ruff from 0.7.1 to 0.7.2
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.7.1 to 0.7.2.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.7.1...0.7.2)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-04 00:48:54 +00:00
Onuralp SEZER 48c63e1b17 Merge pull request #1645 from roboflow/docs/hacktoberfest_remove
docs: 📝 hacktoberfest banner removed
2024-11-02 11:39:28 +03:00
Onuralp SEZER 6d03253370 docs: 📝 hacktoberfest picture removed 2024-11-02 06:37:26 +03:00
LinasKo 5974054c6c Merge pull request #1643 from roboflow/fix/DetectionDataset/lower_case_removal
fix: 🐞 Remove lowercase conversion for unique class names in merge_class_lists function
2024-11-01 22:18:27 +02:00
Onuralp SEZER 62d869ca87 fix: 🐞 Remove lowercase conversion for unique class names in merge_class_lists function
Signed-off-by: Onuralp SEZER <thunderbirdtr@gmail.com>
2024-11-01 22:57:17 +03:00
LinasKo 1f610d820b Merge pull request #1603 from roboflow/feat/refactor-byte-track
ByteTrack: Remove BaseTrack, refactor, add types, remove dead code, moved out Shared Kalman
2024-11-01 21:09:25 +02:00
LinasKo 22d827fac4 Minor fix: show current NO_ID value of the ID counter when printing error 2024-11-01 20:41:38 +02:00
LinasKo 9eee76a6d1 Remove start_frame from ByteTrack 2024-11-01 20:36:08 +02:00
LinasKo d3210ed0cc Minor rename to avoid name shadowing 2024-11-01 20:36:00 +02:00
LinasKo cfd02c8c5c fix(pre_commit): 🎨 auto format pre-commit hooks 2024-11-01 20:34:58 +02:00
LinasKo fd1277f29a Add types, move STrack into a new file 2024-11-01 20:33:46 +02:00
LinasKo b380bd2df7 Correct but useless: Moved shared kalman to ByteTrack
* It's not stateful! All it keeps is the initial params - motion and update matrices
2024-11-01 20:31:04 +02:00
LinasKo 89be6f92d2 Get rid of BaseTrack completely
* 85%-90% of it was used in STrack
* 10% was used by ByteTrack
2024-11-01 20:31:04 +02:00
LinasKo 37cc16e7a8 Bugfix: remove reset start frame when tracker is reset 2024-11-01 20:30:21 +02:00
LinasKo e8983bf427 BaseTrack: Move trivial values used in STrack to STrack 2024-11-01 20:28:38 +02:00
LinasKo 1f5b1c7e01 BaseTrack: Move TrackState to STrack 2024-11-01 20:28:38 +02:00
LinasKo 589d4f9152 Merge pull request #1642 from roboflow/fix/tracker-regression-all-objects-connected
Fix regression where all uninitialized tracks are connected
2024-11-01 20:08:56 +02:00
LinasKo 4678dfc74a Fix regression where all uninitialized tracks are connected
* Apparently, external track ID must start with `1` and not `0`. Not sure where this coupling comes from.
2024-11-01 19:29:31 +02:00
LinasKo 2704a24ff6 Merge pull request #1637 from Kadermiyanyedi/remove-class-ids-from-bytetrack
feat(ByteTrack): Allow ByteTrack to track detection without class ids
2024-11-01 18:18:49 +02:00
LinasKo b8daa408bf Reset default color selection to CLASS for TraceAnnotator 2024-11-01 18:14:56 +02:00
LinasKo 46f7a221ae Merge pull request #1640 from roboflow/dependabot/pip/mkdocs-material-9.5.43
⬆️ Bump mkdocs-material from 9.5.42 to 9.5.43
2024-11-01 14:18:47 +02:00
dependabot[bot] 895bcfbddb ⬆️ Bump mkdocs-material from 9.5.42 to 9.5.43
Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.5.42 to 9.5.43.
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.42...9.5.43)

---
updated-dependencies:
- dependency-name: mkdocs-material
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-01 01:03:55 +00:00
Kader Miyanyedi ab99c3615d feat(ByteTrack): Remove unnecessary class id from bytetrack 2024-10-31 21:03:01 +03:00
LinasKo 37cacec704 Merge pull request #1638 from roboflow/fix/tracker-reset-fails
Bugfix: Tracker reset fails
2024-10-31 19:51:06 +02:00
LinasKo 42b4d439c8 Merge pull request #1639 from roboflow/revert-1618-allow-bytetrack-detection-without-class-ids
Revert "feat(ByteTrack): Allow ByteTrack to track detection without class ids"
2024-10-31 19:45:20 +02:00
LinasKo 7229409fab Revert "feat(ByteTrack): Allow ByteTrack to track detection without class ids" 2024-10-31 19:43:26 +02:00
LinasKo 353fa93305 Bugfix: Tracker reset fails 2024-10-31 19:41:13 +02:00
LinasKo ad4418a350 Merge pull request #1618 from Kadermiyanyedi/allow-bytetrack-detection-without-class-ids
feat(ByteTrack): Allow ByteTrack to track detection without class ids
2024-10-31 18:54:31 +02:00
LinasKo 3b4cd4276f Merge pull request #1593 from PrakharJain1509/develop
Added support by using oriented_box_iou_batch
2024-10-31 18:21:27 +02:00
LinasKo d1c07bc106 Add OBB support to precision and recall 2024-10-31 18:05:54 +02:00
LinasKo 6f3491f03c Fix issue in polygon_to_mask - expects a np.int32 datatype 2024-10-31 17:18:08 +02:00
LinasKo 37f0ad8b14 Add OBB support to F1 Score 2024-10-31 15:57:31 +02:00
LinasKo 02735848b5 Undo style, class agnostic, mAP regressions. Fix empty OBB object shape 2024-10-31 15:42:04 +02:00
LinasKo b760b96e4f Merge upstream 'develop' into develop 2024-10-31 15:33:23 +02:00
Onuralp SEZER 066099f42d Merge pull request #1628 from roboflow/dependabot/pip/tqdm-4.66.6
⬆️ Bump tqdm from 4.66.5 to 4.66.6
2024-10-29 04:47:27 +03:00
dependabot[bot] b26e8f5814 ⬆️ Bump tqdm from 4.66.5 to 4.66.6
Bumps [tqdm](https://github.com/tqdm/tqdm) from 4.66.5 to 4.66.6.
- [Release notes](https://github.com/tqdm/tqdm/releases)
- [Commits](https://github.com/tqdm/tqdm/compare/v4.66.5...v4.66.6)

---
updated-dependencies:
- dependency-name: tqdm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-29 00:12:10 +00:00
Onuralp SEZER b70269947d Merge pull request #1627 from roboflow/pre-commit-ci-update-config
chore(pre_commit): ⬆ pre_commit autoupdate
2024-10-28 23:40:57 +03:00
pre-commit-ci[bot] b4d93ce6c9 chore(pre_commit): ⬆ pre_commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.7.0 → v0.7.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.0...v0.7.1)
2024-10-28 18:02:23 +00:00
Onuralp SEZER 0e7f3cdfe0 Merge pull request #1620 from roboflow/dependabot/pip/ruff-0.7.1
⬆️ Bump ruff from 0.7.0 to 0.7.1
2024-10-25 23:13:02 +03:00
dependabot[bot] 40e16118f8 ⬆️ Bump ruff from 0.7.0 to 0.7.1
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.7.0 to 0.7.1.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.7.0...0.7.1)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-25 00:37:54 +00:00
Kader Miyanyedi dbd4aa7798 feat(ByteTrack): Allow ByteTrack to track detection without class ids 2024-10-23 22:04:56 +03:00
Onuralp SEZER 98aedd7383 Merge pull request #1617 from roboflow/dependabot/pip/mkdocs-git-revision-date-localized-plugin-1.3.0
⬆️ Bump mkdocs-git-revision-date-localized-plugin from 1.2.9 to 1.3.0
2024-10-23 05:06:38 +03:00
Onuralp SEZER d56c566b62 Merge pull request #1616 from roboflow/dependabot/pip/mypy-1.13.0
⬆️ Bump mypy from 1.12.1 to 1.13.0
2024-10-23 05:06:24 +03:00
Onuralp SEZER f176f46d3a Merge pull request #1615 from roboflow/dependabot/pip/tox-4.23.2
⬆️ Bump tox from 4.23.0 to 4.23.2
2024-10-23 05:06:08 +03:00
dependabot[bot] cf3c1e4e10 ⬆️ Bump mkdocs-git-revision-date-localized-plugin
Bumps [mkdocs-git-revision-date-localized-plugin](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin) from 1.2.9 to 1.3.0.
- [Release notes](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin/releases)
- [Commits](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin/compare/v1.2.9...v1.3.0)

---
updated-dependencies:
- dependency-name: mkdocs-git-revision-date-localized-plugin
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-23 00:37:54 +00:00
dependabot[bot] 6c7ee37225 ⬆️ Bump mypy from 1.12.1 to 1.13.0
Bumps [mypy](https://github.com/python/mypy) from 1.12.1 to 1.13.0.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.12.1...v1.13.0)

---
updated-dependencies:
- dependency-name: mypy
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-23 00:37:04 +00:00
dependabot[bot] abd149fa1a ⬆️ Bump tox from 4.23.0 to 4.23.2
Bumps [tox](https://github.com/tox-dev/tox) from 4.23.0 to 4.23.2.
- [Release notes](https://github.com/tox-dev/tox/releases)
- [Changelog](https://github.com/tox-dev/tox/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/tox/compare/4.23.0...4.23.2)

---
updated-dependencies:
- dependency-name: tox
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-23 00:36:13 +00:00
Onuralp SEZER 76975478dd Merge pull request #1614 from roboflow/pre-commit-ci-update-config
chore(pre_commit): ⬆ pre_commit autoupdate
2024-10-22 22:47:07 +03:00
Onuralp SEZER 145ed41305 Merge pull request #1612 from roboflow/dependabot/pip/mkdocs-material-9.5.42
⬆️ Bump mkdocs-material from 9.5.41 to 9.5.42
2024-10-22 22:46:51 +03:00
Onuralp SEZER 5c13d9c0dc Merge pull request #1611 from roboflow/dependabot/pip/mypy-1.12.1
⬆️ Bump mypy from 1.12.0 to 1.12.1
2024-10-22 22:46:34 +03:00
pre-commit-ci[bot] 40ef90c76e chore(pre_commit): ⬆ pre_commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.9 → v0.7.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.9...v0.7.0)
2024-10-21 17:55:36 +00:00
dependabot[bot] 2efe3030cb ⬆️ Bump mkdocs-material from 9.5.41 to 9.5.42
Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.5.41 to 9.5.42.
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.41...9.5.42)

---
updated-dependencies:
- dependency-name: mkdocs-material
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-21 00:52:04 +00:00
dependabot[bot] 74ae1eec87 ⬆️ Bump mypy from 1.12.0 to 1.12.1
Bumps [mypy](https://github.com/python/mypy) from 1.12.0 to 1.12.1.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.12.0...v1.12.1)

---
updated-dependencies:
- dependency-name: mypy
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-21 00:48:47 +00:00
LinasKo ea776b58a2 Merge pull request #1609 from roboflow/feat/metrics-precision-recall
Feat/metrics precision recall
2024-10-18 16:16:21 +03:00
LinasKo 3e8a88a8d4 Add Precision and Recall to metrics __init__ 2024-10-18 15:28:45 +03:00
LinasKo fbd96d10c4 Add new and missing docstrings, examples to metrics, new Common section 2024-10-18 15:16:01 +03:00
LinasKo c17de5c11b Add precision and recall metrics 2024-10-18 14:37:36 +03:00
LinasKo bda4003453 Merge pull request #1608 from roboflow/fix/polygon-zone-example
Add usage example in Polygon Zone docs
2024-10-18 12:50:33 +03:00
LinasKo eb3e1e6293 Add usage example in Polygon Zone docs 2024-10-18 12:45:38 +03:00
Onuralp SEZER a9214b2fdb Merge pull request #1605 from roboflow/dependabot/pip/ruff-0.7.0
⬆️ Bump ruff from 0.6.9 to 0.7.0
2024-10-18 09:10:34 +03:00
Onuralp SEZER 2c21e9ecc1 Merge pull request #1604 from roboflow/dependabot/pip/tox-4.23.0
⬆️ Bump tox from 4.22.0 to 4.23.0
2024-10-18 09:10:05 +03:00
dependabot[bot] 70dd0e1c1d ⬆️ Bump ruff from 0.6.9 to 0.7.0
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.6.9 to 0.7.0.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.6.9...0.7.0)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-18 00:21:04 +00:00
dependabot[bot] b2762dafaf ⬆️ Bump tox from 4.22.0 to 4.23.0
Bumps [tox](https://github.com/tox-dev/tox) from 4.22.0 to 4.23.0.
- [Release notes](https://github.com/tox-dev/tox/releases)
- [Changelog](https://github.com/tox-dev/tox/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/tox/compare/4.22.0...4.23.0)

---
updated-dependencies:
- dependency-name: tox
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-18 00:18:11 +00:00
LinasKo 251b952438 Merge pull request #1528 from roboflow/feature/make-BaseTrack-count-instance-var
let ByteTrack to maintain track ID per instance
2024-10-18 01:20:11 +03:00
LinasKo 4a7d9ceaa9 Tracker fix: add & rework mistakenly removed ID system
* External IDs were counted as the numbers grow very large very fast
* Reworked ID counting as a class, as state is necessary, and only STracks know if an external ID is required after the update (see conditions everywhere before self.external_id_counter.new_id() is called).
2024-10-18 01:01:43 +03:00
LinasKo e5215b967c typos, minor type updates 2024-10-17 23:52:05 +03:00
LinasKo f60d89f508 Merge pull request #1602 from ablazejuk/patch-1
Validate that polygon has at least 3 vertices in geometry utils.py
2024-10-17 23:15:33 +03:00
LinasKo 1b95d1dea2 Change get_polygon_center validation to fail on 0 points 2024-10-17 23:11:56 +03:00
pre-commit-ci[bot] f8ff332872 fix(pre_commit): 🎨 auto format pre-commit hooks 2024-10-17 17:01:07 +00:00
Andrey Blazejuk 5ac3999aeb Validate that polygon has at least 3 vertices in geometry utils.py 2024-10-17 13:58:37 -03:00
Onuralp SEZER 75a4ff1c64 Merge pull request #1601 from roboflow/dependabot/pip/types-requests-2.32.0.20241016
⬆️ Bump types-requests from 2.32.0.20240914 to 2.32.0.20241016
2024-10-17 04:25:29 +03:00
dependabot[bot] d50aa67ac0 ⬆️ Bump types-requests from 2.32.0.20240914 to 2.32.0.20241016
Bumps [types-requests](https://github.com/python/typeshed) from 2.32.0.20240914 to 2.32.0.20241016.
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-requests
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-17 00:38:46 +00:00
Soham 847f132aa0 Merge branch 'feat/detection-metadata' of https://github.com/souhhmm/supervision into feat/detection-metadata 2024-10-16 19:30:49 +05:30
Soham c6e701be1c fix: added check if not array 2024-10-16 19:30:28 +05:30
Soham 4ce033c4d7 fix: made required changes 2024-10-16 11:51:13 +05:30
Soham Kalburgi 57870817f5 Merge branch 'roboflow:develop' into feat/detection-metadata 2024-10-16 11:50:32 +05:30
Onuralp SEZER 3eb5c0b024 Merge pull request #1598 from roboflow/dependabot/pip/tox-4.22.0
⬆️ Bump tox from 4.21.2 to 4.22.0
2024-10-16 04:41:49 +03:00
Onuralp SEZER ed6fe98e24 Merge pull request #1599 from roboflow/dependabot/pip/mkdocs-material-9.5.41
⬆️ Bump mkdocs-material from 9.5.40 to 9.5.41
2024-10-16 04:41:33 +03:00
Onuralp SEZER 8d374a6282 Merge pull request #1597 from roboflow/dependabot/pip/mypy-1.12.0
⬆️ Bump mypy from 1.11.2 to 1.12.0
2024-10-16 04:41:19 +03:00
dependabot[bot] 5b09368a0e ⬆️ Bump mkdocs-material from 9.5.40 to 9.5.41
Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.5.40 to 9.5.41.
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.40...9.5.41)

---
updated-dependencies:
- dependency-name: mkdocs-material
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-16 00:21:15 +00:00
dependabot[bot] 0012052cdd ⬆️ Bump tox from 4.21.2 to 4.22.0
Bumps [tox](https://github.com/tox-dev/tox) from 4.21.2 to 4.22.0.
- [Release notes](https://github.com/tox-dev/tox/releases)
- [Changelog](https://github.com/tox-dev/tox/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/tox/compare/4.21.2...4.22.0)

---
updated-dependencies:
- dependency-name: tox
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-16 00:19:59 +00:00
prakharjain 85f400c162 Added support by using oriented_box_iou_batch 2024-10-15 22:55:41 +05:30
pre-commit-ci[bot] 865c4e4231 fix(pre_commit): 🎨 auto format pre-commit hooks 2024-10-15 17:15:18 +00:00
prakharjain e9d5f861a2 Added support by using oriented_box_iou_batch Final 2024-10-15 22:44:01 +05:30
dependabot[bot] a879c1d39a ⬆️ Bump mypy from 1.11.2 to 1.12.0
Bumps [mypy](https://github.com/python/mypy) from 1.11.2 to 1.12.0.
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.11.2...v1.12.0)

---
updated-dependencies:
- dependency-name: mypy
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-15 00:26:36 +00:00
LinasKo 213d59d952 Merge pull request #1595 from roboflow/support/py13
feat: 🚀 python3.13 support for poetry and library
2024-10-14 15:51:39 +03:00
Onuralp SEZER 62a4d45c6c feat: 🚀 python3.13 support for poetry and library
Signed-off-by: Onuralp SEZER <thunderbirdtr@gmail.com>
2024-10-14 14:49:50 +03:00
Onuralp SEZER 086091a1e2 Merge pull request #1596 from roboflow/fix/remove-welcome-workflow
Removed welcome workflow
2024-10-14 14:31:32 +03:00
LinasKo 302041d8aa Removed welcome workflow
* It doesn't work, and trip new contributors up
2024-10-14 13:56:52 +03:00
pre-commit-ci[bot] 9822406cde fix(pre_commit): 🎨 auto format pre-commit hooks 2024-10-13 07:45:06 +00:00
prakharjain 1d3f61af85 Added support by using oriented_box_iou_batch 2024-10-13 13:10:24 +05:30
Onuralp SEZER 9b35f8c3c4 PR #1591 - feat: 🚀 Add default color to PolygonZoneAnnotator, drawing functions 2024-10-12 19:07:33 +03:00
Onuralp SEZER c339315b31 feat: ✨ set default colors for polygon drawing functions and add documentations 2024-10-12 18:58:45 +03:00
LinasKo 8edf7bb941 Merge pull request #1584 from roboflow/python/version/3.13
ci: 👷 python3.13 ci tests and tox ver added
2024-10-11 18:44:08 +03:00
pre-commit-ci[bot] 7e7eb8bd5a fix(pre_commit): 🎨 auto format pre-commit hooks 2024-10-11 14:35:09 +00:00
DivyaVijay1234 b6ae599a21 feat: add default color 2024-10-11 20:04:40 +05:30
DivyaVijay1234 a6897d4117 feat: add default color 2024-10-11 20:01:42 +05:30
LinasKo d333d67912 Merge pull request #1586 from DemyCode/feat/adding-py-typed-for-type-hinters
feat: adding type hinter markers in package
2024-10-11 16:28:20 +03:00
pre-commit-ci[bot] 5242614b01 fix(pre_commit): 🎨 auto format pre-commit hooks 2024-10-11 11:38:25 +00:00
DivyaVijay1234 b86b660634 feat-Added Default color 2024-10-11 16:56:22 +05:30
Onuralp SEZER ef1f968289 Merge pull request #1588 from roboflow/joao/fix/sahi-cookbook-images-from-cdn
Replaces github hosted images to roboflow hosting
2024-10-11 14:24:35 +03:00
Onuralp SEZER 337b0e4a5b Merge pull request #1590 from roboflow/dependabot/pip/mkdocs-material-9.5.40
⬆️ Bump mkdocs-material from 9.5.39 to 9.5.40
2024-10-11 03:47:08 +03:00
dependabot[bot] 892185e4c6 ⬆️ Bump mkdocs-material from 9.5.39 to 9.5.40
Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.5.39 to 9.5.40.
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.39...9.5.40)

---
updated-dependencies:
- dependency-name: mkdocs-material
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-11 00:35:27 +00:00
Soham c7611e384b fix: update empty and is_empty methods 2024-10-11 01:44:47 +05:30
Soham 68a1052cfc fix: is_empty no longer compares metadata directly 2024-10-11 01:14:00 +05:30
pre-commit-ci[bot] bfe4f0f829 fix(pre_commit): 🎨 auto format pre-commit hooks 2024-10-10 19:06:43 +00:00
João dcdeea85d1 replaces github hosted to roboflow cdn images links 2024-10-10 14:58:57 -03:00
Soham 6b3735001b fix: merge_metadata for conflicting keys 2024-10-10 18:59:50 +05:30
Soham f3f5585f31 Merge branch 'feat/detection-metadata' of https://github.com/souhhmm/supervision into feat/detection-metadata 2024-10-10 17:46:33 +05:30
Soham a988b72822 fix: merge_metadata function 2024-10-10 17:44:52 +05:30
Soham Kalburgi 94230c51dc Merge branch 'roboflow:develop' into feat/detection-metadata 2024-10-10 16:07:09 +05:30
Soham b59f867bcb format with precommit 2024-10-10 16:02:20 +05:30
LinasKo 70bc4b9d88 Merge pull request #1577 from AHuzail/fix/class-agnostic
Bugfix: Class-agnostic mAP
2024-10-10 13:04:19 +03:00
LinasKo c5dfb47d5c Remove old unused code: CLASS_ID_NONE 2024-10-10 13:02:07 +03:00
LinasKo 2530c41ffa docs: fix datatype of class_agnostic in MeanAveragePrecisionResult 2024-10-10 13:00:07 +03:00
Soham 4c928eda2e feat: add detection metadata and update related methods 2024-10-10 15:22:55 +05:30
Ahmad Huzail Khan 05b17c483a Deleted _match_detection_batch_class_agnostic 2024-10-09 20:25:10 +00:00
pre-commit-ci[bot] 9659f7b2eb fix(pre_commit): 🎨 auto format pre-commit hooks 2024-10-09 20:21:05 +00:00
Ahmad Huzail Khan 5fc7c3aa53 only using _match_detection_batch 2024-10-09 20:19:26 +00:00
Ahmad Huzail Khan f1e32e7c07 Added deepcopy 2024-10-09 20:16:25 +00:00
pre-commit-ci[bot] 75b6384a9a fix(pre_commit): 🎨 auto format pre-commit hooks 2024-10-09 19:53:01 +00:00
Ahmad Huzail Khan 426e36dea1 Merge branch 'fix/class-agnostic' of https://github.com/AHuzail/supervision into fix/class-agnostic
modified:   supervision/metrics/mean_average_precision.py
2024-10-09 19:52:24 +00:00
Ahmad Huzail Khan 53b26d0c05 modified: supervision/metrics/mean_average_precision.py 2024-10-09 19:48:05 +00:00
pre-commit-ci[bot] 201a186539 fix(pre_commit): 🎨 auto format pre-commit hooks 2024-10-09 19:45:27 +00:00
Ahmad Huzail Khan 0f42a87ee8 modified: supervision/metrics/mean_average_precision.py 2024-10-09 19:44:50 +00:00
Ahmad Huzail Khan 62a3cfe480 modified: supervision/metrics/mean_average_precision.py 2024-10-09 19:33:56 +00:00
Ahmad Huzail Khan ef85b325de Merge branch 'fix/class-agnostic' of https://github.com/AHuzail/supervision into fix/class-agnostic 2024-10-09 19:32:27 +00:00
Ahmad Huzail Khan 16a4491b59 modified class MeanAveragePrecisionResult for class_agnostic 2024-10-09 19:29:41 +00:00
pre-commit-ci[bot] e433527942 fix(pre_commit): 🎨 auto format pre-commit hooks 2024-10-09 19:04:50 +00:00
Ahmad Huzail Khan 12c6b873e3 modified: supervision/metrics/mean_average_precision.py 2024-10-09 19:01:40 +00:00
Ahmad Huzail Khan baffd98314 removed comments 2024-10-09 18:54:31 +00:00
DemyCode a4f79b59f1 feat: adding type hinting to package 2024-10-09 16:48:54 +02:00
Onuralp SEZER 921e8f32d4 ci: 👷 python3.13 ci tests and tox ver added
Signed-off-by: Onuralp SEZER <thunderbirdtr@gmail.com>
2024-10-09 16:46:02 +03:00
LinasKo ebbc79aeae Merge branch 'develop' into fix/class-agnostic 2024-10-09 12:04:27 +03:00
LinasKo 70869c7337 Merge pull request #1581 from roboflow/fix/mAP-calculation-division-fix
Minor fix: mAP can be 1.01
2024-10-09 11:54:46 +03:00
LinasKo 3231f7f498 Minor fix: mAP can be 1.01
Fix division to avoid cases where 1.01 mAP is possible.

Discovered in: https://colab.research.google.com/drive/10EaQ4lJNXzcmub7doO2vGn2t1LgDdkcC?usp=sharing
2024-10-09 11:50:08 +03:00
Onuralp SEZER 22d73580db Merge pull request #1580 from roboflow/pre-commit-ci-update-config
chore(pre_commit): ⬆ pre_commit autoupdate
2024-10-07 23:57:48 +03:00
pre-commit-ci[bot] 7862f9cc70 chore(pre_commit): ⬆ pre_commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.6.0 → v5.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.6.0...v5.0.0)
- [github.com/astral-sh/ruff-pre-commit: v0.6.8 → v0.6.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.8...v0.6.9)
2024-10-07 18:27:48 +00:00
Onuralp SEZER 044d5039b2 Merge pull request #1579 from roboflow/dependabot/pip/build-1.2.2.post1
⬆️ Bump build from 1.2.2 to 1.2.2.post1
2024-10-07 17:41:27 +03:00
Onuralp SEZER c3e46b0f9c Merge pull request #1578 from roboflow/dependabot/pip/ruff-0.6.9
⬆️ Bump ruff from 0.6.8 to 0.6.9
2024-10-07 17:40:57 +03:00
dependabot[bot] 1523ee21af ⬆️ Bump build from 1.2.2 to 1.2.2.post1
Bumps [build](https://github.com/pypa/build) from 1.2.2 to 1.2.2.post1.
- [Release notes](https://github.com/pypa/build/releases)
- [Changelog](https://github.com/pypa/build/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pypa/build/compare/1.2.2...1.2.2.post1)

---
updated-dependencies:
- dependency-name: build
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-07 01:04:39 +00:00
dependabot[bot] c70988123d ⬆️ Bump ruff from 0.6.8 to 0.6.9
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.6.8 to 0.6.9.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.6.8...0.6.9)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-07 01:02:26 +00:00
pre-commit-ci[bot] e0db07a80f fix(pre_commit): 🎨 auto format pre-commit hooks 2024-10-05 21:14:43 +00:00
Huzail 6d30eef4a9 Added _match_detection_batch_class_agnostic 2024-10-06 01:37:48 +05:30
Huzail 124c7d9b90 Added class-agnostic 2024-10-05 21:36:21 +05:30
LinasKo 58a6fd046d Merge pull request #1575 from roboflow/main
Merge main into develop after 0.24.0 release
2024-10-04 23:23:23 +03:00
Grzegorz Klimaszewski edf2277ea0 fix typo in docstring 2024-10-02 10:19:45 +02:00
pre-commit-ci[bot] 3863a1f2e9 fix(pre_commit): 🎨 auto format pre-commit hooks 2024-10-02 08:10:03 +00:00
Grzegorz Klimaszewski 78fd2147f1 Merge branch 'develop' into feature/remove-false-line-counts 2024-10-02 10:08:04 +02:00
Grzegorz Klimaszewski 95f6ef0b22 Address CI linter 2024-09-23 17:49:05 +02:00
pre-commit-ci[bot] 0e26f1b22e fix(pre_commit): 🎨 auto format pre-commit hooks 2024-09-23 15:44:40 +00:00
Grzegorz Klimaszewski cc640dc890 Extend LineZone to filter out miscounts 2024-09-23 17:36:32 +02:00
Grzegorz Klimaszewski 266bf3f10c Reset _count 2024-09-19 13:49:43 +02:00
pre-commit-ci[bot] df9257b8fc fix(pre_commit): 🎨 auto format pre-commit hooks 2024-09-19 11:45:36 +00:00
Grzegorz Klimaszewski a0655aed08 fix test 2024-09-19 13:44:26 +02:00
pre-commit-ci[bot] 1e43de0a41 fix(pre_commit): 🎨 auto format pre-commit hooks 2024-09-19 11:41:21 +00:00
Grzegorz Klimaszewski 3da10edb47 Remove unused variable 2024-09-19 13:40:31 +02:00
Grzegorz Klimaszewski db2aa721f8 Let upper layer to handle track_id generation 2024-09-19 13:38:16 +02:00
Grzegorz Klimaszewski a05882add1 Make BaseTrack._count instance variable 2024-09-19 12:15:02 +02:00
44 changed files with 3186 additions and 792 deletions
+37
View File
@@ -0,0 +1,37 @@
name: 🔧 Poetry Check and Installation Test Workflow
on:
push:
paths:
- 'poetry.lock'
- 'pyproject.toml'
pull_request:
paths:
- 'poetry.lock'
- 'pyproject.toml'
workflow_dispatch:
jobs:
poetry-tests:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
runs-on: ${{ matrix.os }}
steps:
- name: 📥 Checkout the repository
uses: actions/checkout@v4
- name: 🐍 Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: 📦 Install the base dependencies
run: python -m pip install --upgrade poetry
- name: 🔍 Check the correctness of the project config
run: poetry check
- name: 🚀 Do Install the package Test
run: poetry install
+4 -3
View File
@@ -2,9 +2,9 @@ name: Publish Supervision Pre-Releases to PyPI and TestPyPI
on:
push:
tags:
- '[0-9]+.[0-9]+[0-9]+.[0-9]+a[0-9]'
- '[0-9]+.[0-9]+[0-9]+.[0-9]+b[0-9]'
- '[0-9]+.[0-9]+[0-9]+.[0-9]+rc[0-9]'
- "[0-9]+.[0-9]+[0-9]+.[0-9]+a[0-9]"
- "[0-9]+.[0-9]+[0-9]+.[0-9]+b[0-9]"
- "[0-9]+.[0-9]+[0-9]+.[0-9]+rc[0-9]"
workflow_dispatch:
@@ -12,6 +12,7 @@ jobs:
build-and-publish-pre-release-pypi:
name: Build and publish to PyPI
runs-on: ubuntu-latest
environment: test
permissions:
id-token: write
strategy:
+3 -2
View File
@@ -2,13 +2,14 @@ name: Publish Supervision Releases to PyPI and TestPyPI
on:
push:
tags:
- '[0-9]+.[0-9]+[0-9]+.[0-9]'
- "[0-9]+.[0-9]+[0-9]+.[0-9]"
workflow_dispatch:
jobs:
build-and-publish-pre-release:
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write
strategy:
@@ -24,7 +25,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- name: 🏗️ Build source and wheel distributions
- name: 🏗️ Build source and wheel distributions
run: |
python -m pip install --upgrade build twine
python -m build
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- name: 🛎️ Checkout
uses: actions/checkout@v4
-18
View File
@@ -1,18 +0,0 @@
name: Welcome WorkFlow
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
jobs:
build:
name: 👋 Welcome
runs-on: ubuntu-latest
steps:
- uses: actions/first-interaction@v1.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: "Hello there, thank you for opening an Issue ! 🙏🏻 The team was notified and they will get back to you asap."
pr-message: "Hello there, thank you for opening an PR ! 🙏🏻 The team was notified and they will get back to you asap."
+2 -2
View File
@@ -7,7 +7,7 @@ ci:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: test/.*\.py
@@ -32,7 +32,7 @@ repos:
additional_dependencies: ["bandit[toml]"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.8
rev: v0.7.2
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
-2
View File
@@ -34,8 +34,6 @@
**We write your reusable computer vision tools.** Whether you need to load your dataset from your hard drive, draw detections on an image or video, or count how many detections are in a zone. You can count on us! 🤝
[![supervision-hackfest](https://media.roboflow.com/supervision/supervision-hacktoberfest-banner-2024.png)](https://github.com/roboflow/supervision/issues?q=is%3Aissue+is%3Aopen+label%3Ahacktoberfest)
## 💻 install
Pip install the supervision package in a
+105 -1
View File
@@ -6,7 +6,7 @@ comments: true
Leverage Supervision's advanced capabilities for enhancing your video analysis by
seamlessly [tracking](/latest/trackers/) objects recognized by
a multitude of object detection and segmentation models. This comprehensive guide will
a multitude of object detection, segmentation and keypoint models. This comprehensive guide will
take you through the steps to perform inference using the YOLOv8 model via either the
[Inference](https://github.com/roboflow/inference) or
[Ultralytics](https://github.com/ultralytics/ultralytics) packages. Following this,
@@ -21,6 +21,7 @@ example. You can do this using
from supervision.assets import download_assets, VideoAssets
download_assets(VideoAssets.PEOPLE_WALKING)
download_assets(VideoAssets.SKIING)
```
<video controls>
@@ -41,6 +42,7 @@ This `callback` function will be essential in the subsequent steps of the tutori
it will be modified to include tracking, labeling, and trace annotations.
=== "Ultralytics"
```{ .py }
import numpy as np
import supervision as sv
@@ -62,6 +64,7 @@ it will be modified to include tracking, labeling, and trace annotations.
```
=== "Inference"
```{ .py }
import numpy as np
import supervision as sv
@@ -95,6 +98,7 @@ functionality, each detected object is assigned a unique tracker ID,
enabling the continuous following of the object's motion path across different frames.
=== "Ultralytics"
```{ .py hl_lines="6 12" }
import numpy as np
import supervision as sv
@@ -118,6 +122,7 @@ enabling the continuous following of the object's motion path across different f
```
=== "Inference"
```{ .py hl_lines="6 12" }
import numpy as np
import supervision as sv
@@ -149,6 +154,7 @@ in Supervision, we can overlay the tracker IDs and class labels on the detected
offering a clear visual representation of each object's class and unique identifier.
=== "Ultralytics"
```{ .py hl_lines="8 15-19 23-24" }
import numpy as np
import supervision as sv
@@ -183,6 +189,7 @@ offering a clear visual representation of each object's class and unique identif
```
=== "Inference"
```{ .py hl_lines="8 15-19 23-24" }
import numpy as np
import supervision as sv
@@ -229,6 +236,7 @@ allows for visualizing the trajectories of objects, helping in understanding the
movement patterns and interactions between objects in the video.
=== "Ultralytics"
```{ .py hl_lines="9 26-27" }
import numpy as np
import supervision as sv
@@ -266,6 +274,7 @@ movement patterns and interactions between objects in the video.
```
=== "Inference"
```{ .py hl_lines="9 26-27" }
import numpy as np
import supervision as sv
@@ -306,6 +315,101 @@ movement patterns and interactions between objects in the video.
<source src="https://media.roboflow.com/supervision/video-examples/how-to/track-objects/annotate-video-with-traces.mp4" type="video/mp4">
</video>
## Tracking Key Points
Keypoint tracking is currently supported via the conversion of `KeyPoints` to `Detections`. This is achieved with the [`keypoints_to_detections`](/latest/utils/datatypes/#supervision.utils.datatypes.keypoints_to_detections) function. We'll use a different video as well as [`DetectionsSmoother`](/latest/detection/tools/smoother/) to stabilize the boxes.
!!! tip
You may use the `selected_keypoint_indices` argument to specify a subset of keypoints to convert. This is useful when some keypoints could be occluded. For example: a person might swing their arm, causing the elbow to be occluded by the torso sometimes.
=== "Ultralytics"
```{ .py hl_lines="5 7 14-15 17 33" }
import numpy as np
import supervision as sv
from ultralytics import YOLO
model = YOLO("yolov8m-pose.pt")
tracker = sv.ByteTrack()
smoother = sv.DetectionsSmoother()
box_annotator = sv.BoundingBoxAnnotator()
label_annotator = sv.LabelAnnotator()
trace_annotator = sv.TraceAnnotator()
def callback(frame: np.ndarray, _: int) -> np.ndarray:
results = model(frame)[0]
keypoints = sv.KeyPoints.from_ultralytics(results)
detections = sv.keypoints_to_detections(keypoints)
detections = tracker.update_with_detections(detections)
detections = smoother.update_with_detections(detections)
labels = [
f"#{tracker_id} {results.names[class_id]}"
for class_id, tracker_id
in zip(detections.class_id, detections.tracker_id)
]
annotated_frame = box_annotator.annotate(
frame.copy(), detections=detections)
annotated_frame = label_annotator.annotate(
annotated_frame, detections=detections, labels=labels)
return trace_annotator.annotate(
annotated_frame, detections=detections)
sv.process_video(
source_path="skiing.mp4",
target_path="result.mp4",
callback=callback
)
```
=== "Inference"
```{ .py hl_lines="5-6 8 15-16 18 34" }
import numpy as np
import supervision as sv
from inference.models.utils import get_roboflow_model
model = get_roboflow_model(
model_id="yolov8m-pose-640", api_key=<ROBOFLOW API KEY>)
tracker = sv.ByteTrack()
smoother = sv.DetectionsSmoother()
box_annotator = sv.BoundingBoxAnnotator()
label_annotator = sv.LabelAnnotator()
trace_annotator = sv.TraceAnnotator()
def callback(frame: np.ndarray, _: int) -> np.ndarray:
results = model.infer(frame)[0]
keypoints = sv.KeyPoints.from_inference(results)
detections = sv.keypoints_to_detections(keypoints)
detections = tracker.update_with_detections(detections)
detections = smoother.update_with_detections(detections)
labels = [
f"#{tracker_id} {results.names[class_id]}"
for class_id, tracker_id
in zip(detections.class_id, detections.tracker_id)
]
annotated_frame = box_annotator.annotate(
frame.copy(), detections=detections)
annotated_frame = label_annotator.annotate(
annotated_frame, detections=detections, labels=labels)
return trace_annotator.annotate(
annotated_frame, detections=detections)
sv.process_video(
source_path="skiing.mp4",
target_path="result.mp4",
callback=callback
)
```
<video controls>
<source src="https://media.roboflow.com/supervision/video-examples/how-to/track-objects/track-keypoints.mp4" type="video/mp4">
</video>
This structured walkthrough should give a detailed pathway to annotate videos
effectively using Supervision’s various functionalities, including object tracking and
trace annotations.
+20
View File
@@ -0,0 +1,20 @@
---
comments: true
status: new
---
# Common Values
This page contains supplementary values, types and enums that metrics use.
<div class="md-typeset">
<h2><a href="#supervision.metrics.core.MetricTarget">MetricTarget</a></h2>
</div>
:::supervision.metrics.core.MetricTarget
<div class="md-typeset">
<h2><a href="#supervision.metrics.core.AveragingMethod">AveragingMethod</a></h2>
</div>
:::supervision.metrics.core.AveragingMethod
+18
View File
@@ -0,0 +1,18 @@
---
comments: true
status: new
---
# Precision
<div class="md-typeset">
<h2><a href="#supervision.metrics.precision.Precision">Precision</a></h2>
</div>
:::supervision.metrics.precision.Precision
<div class="md-typeset">
<h2><a href="#supervision.metrics.precision.PrecisionResult">PrecisionResult</a></h2>
</div>
:::supervision.metrics.precision.PrecisionResult
+18
View File
@@ -0,0 +1,18 @@
---
comments: true
status: new
---
# Recall
<div class="md-typeset">
<h2><a href="#supervision.metrics.recall.Recall">Recall</a></h2>
</div>
:::supervision.metrics.recall.Recall
<div class="md-typeset">
<h2><a href="#supervision.metrics.recall.RecallResult">RecallResult</a></h2>
</div>
:::supervision.metrics.recall.RecallResult
+5 -5
View File
@@ -15,7 +15,7 @@
"\n",
"This cookbook shows how to use [Slicing Aided Hyper Inference (SAHI) ](https://arxiv.org/abs/2202.06934) for small object detection with `supervision`.\n",
"\n",
"![\"Small Object Detection\"](https://raw.githubusercontent.com/ediardo/notebooks/main/sahi/animation.gif \"Small Object Detection\")\n",
"![\"Small Object Detection\"](https://media.roboflow.com/supervision/cookbooks/sahi/animation.gif \"Small Object Detection\")\n",
"\n",
"Click the Open in Colab button to run the cookbook on Google Colab.\n",
"\n",
@@ -70,7 +70,7 @@
"\n",
"Detecting people (or their heads) is a common problem that has been addressed by many researchers in the past. In this project, we\u2019ll use an open-source public dataset and a fine-tuned model to perform inference on images.\n",
"\n",
"![Roboflow Universe](https://raw.githubusercontent.com/ediardo/notebooks/main/sahi/roboflow_universe.png \"Open source model for counting people's heads\")\n",
"![Roboflow Universe](https://media.roboflow.com/supervision/cookbooks/sahi/roboflow_universe.png \"Open source model for counting people's heads\")\n",
"\n",
"Some details about the project [\"people_counterv0 Computer Vision Project\"](https://universe.roboflow.com/sit-cx0ng/people_counterv0):\n",
"\n",
@@ -782,9 +782,9 @@
"\n",
"| Example| Observations |\n",
"|----|----|\n",
"| ![Overlapping](https://github.com/ediardo/notebooks/blob/main/sahi/overlapping_1.png?raw=true \"Overlapping\") | False Negative, Incomplete bbox |\n",
"| ![Overlapping](https://raw.githubusercontent.com/ediardo/notebooks/main/sahi/overlapping_2.png \"Overlapping\")| Double detection, Incomplete bbox|\n",
"| ![Overlapping](https://raw.githubusercontent.com/ediardo/notebooks/main/sahi/overlapping_3.png \"Overlapping\")| Incomplete bounding box|\n",
"| ![Overlapping](https://media.roboflow.com/supervision/cookbooks/sahi/overlapping_1.png \"Overlapping\") | False Negative, Incomplete bbox |\n",
"| ![Overlapping](https://media.roboflow.com/supervision/cookbooks/sahi/overlapping_2.png \"Overlapping\")| Double detection, Incomplete bbox|\n",
"| ![Overlapping](https://media.roboflow.com/supervision/cookbooks/sahi/overlapping_3.png \"Overlapping\")| Incomplete bounding box|\n",
"\n",
"## Improving Object Detection Near Boundaries with Overlapping\n",
"\n",
+12
View File
@@ -0,0 +1,12 @@
---
comments: true
status: new
---
# Data Types Utils
<div class="md-typeset">
<h2><a href="#supervision.utils.datatypes.keypoints_to_detections">keypoints_to_detections</a></h2>
</div>
:::supervision.utils.datatypes.keypoints_to_detections
+4
View File
@@ -66,7 +66,10 @@ nav:
- Utils: datasets/utils.md
- Metrics:
- mAP: metrics/mean_average_precision.md
- Precision: metrics/precision.md
- Recall: metrics/recall.md
- F1 Score: metrics/f1_score.md
- Common Values: metrics/common_values.md
- Legacy Metrics: detection/metrics.md
- Utils:
- Video: utils/video.md
@@ -76,6 +79,7 @@ nav:
- File: utils/file.md
- Draw: utils/draw.md
- Geometry: utils/geometry.md
- Datatypes: utils/datatypes.md
- Assets: assets.md
- Cookbooks: cookbooks.md
- Cheatsheet: https://roboflow.github.io/cheatsheet-supervision/
Generated
+572 -336
View File
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "supervision"
version = "0.24.0"
version = "0.25.0rc2"
description = "A set of easy-to-use utils that will come in handy in any Computer Vision project"
authors = ["Piotr Skalski <piotr.skalski92@gmail.com>"]
maintainers = [
@@ -9,7 +9,7 @@ maintainers = [
]
readme = "README.md"
license = "MIT"
packages = [{ include = "supervision" }]
packages = [{ include = "supervision" }, { include = "supervision/py.typed" }]
homepage = "https://github.com/roboflow/supervision"
repository = "https://github.com/roboflow/supervision"
documentation = "https://supervision.roboflow.com/latest/"
@@ -46,17 +46,32 @@ python = "^3.8"
numpy = [
{ version = ">=1.21.2,<1.23.3", python = "<=3.10" },
{ version = ">=1.23.3", python = ">3.10" },
{ version = ">=2.1.0", python = ">=3.13" },
]
scipy = [
{ version = "1.10.0", python = "<3.9" },
{ version = "^1.10.0", python = ">=3.9" },
{ version = ">=1.14.1", python = ">=3.13" },
]
# Matplotlib sub-dependency
# The 'contourpy' package is required by Matplotlib for contour plotting.
# We need to ensure compatibility with both Python 3.8 and Python 3.13.
#
# For Python 3.8 and above, we use version 1.0.7 or higher, as it is the lowest major version that supports Python 3.8.
# For Python 3.13 and above, we use version 1.3.0 or higher, as it is the first version that explicitly supports Python 3.13.
contourpy = [
{ version = ">=1.0.7", python = ">=3.8" },
{ version = ">=1.3.0", python = ">=3.13" },
]
matplotlib = ">=3.6.0"
pyyaml = ">=5.3"
defusedxml = "^0.7.1"
pillow = ">=9.4"
requests = { version = ">=2.26.0,<=2.32.3", optional = true }
tqdm = { version = ">=4.62.3,<=4.66.5", optional = true }
tqdm = { version = ">=4.62.3,<=4.66.6", optional = true }
# pandas: picked lowest major version that supports Python 3.8
pandas = { version = ">=2.0.0", optional = true }
pandas-stubs = { version = ">=2.0.0.230412", optional = true }
+1
View File
@@ -100,6 +100,7 @@ from supervision.keypoint.core import KeyPoints
from supervision.metrics.detection import ConfusionMatrix, MeanAveragePrecision
from supervision.tracker.byte_tracker.core import ByteTrack
from supervision.utils.conversion import cv2_to_pillow, pillow_to_cv2
from supervision.utils.datatypes import keypoints_to_detections
from supervision.utils.file import list_files_with_extensions
from supervision.utils.image import (
ImageSink,
+6
View File
@@ -20,6 +20,7 @@ class VideoAssets(Enum):
| `PEOPLE_WALKING` | `people-walking.mp4` | [Link](https://media.roboflow.com/supervision/video-examples/people-walking.mp4) |
| `BEACH` | `beach-1.mp4` | [Link](https://media.roboflow.com/supervision/video-examples/beach-1.mp4) |
| `BASKETBALL` | `basketball-1.mp4` | [Link](https://media.roboflow.com/supervision/video-examples/basketball-1.mp4) |
| `SKIING` | `skiing.mp4` | [Link](https://media.roboflow.com/supervision/video-examples/skiing.mp4) |
""" # noqa: E501 // docs
VEHICLES = "vehicles.mp4"
@@ -31,6 +32,7 @@ class VideoAssets(Enum):
PEOPLE_WALKING = "people-walking.mp4"
BEACH = "beach-1.mp4"
BASKETBALL = "basketball-1.mp4"
SKIING = "skiing.mp4"
@classmethod
def list(cls):
@@ -74,4 +76,8 @@ VIDEO_ASSETS: Dict[str, Tuple[str, str]] = {
f"{BASE_VIDEO_URL}{VideoAssets.BASKETBALL.value}",
"60d94a3c7c47d16f09d342b088012ecc",
),
VideoAssets.SKIING.value: (
f"{BASE_VIDEO_URL}{VideoAssets.SKIING.value}",
"d30987cbab1bbc5934199cdd1b293119",
),
}
+1 -1
View File
@@ -55,7 +55,7 @@ def merge_class_lists(class_lists: List[List[str]]) -> List[str]:
for class_list in class_lists:
for class_name in class_list:
unique_classes.add(class_name.lower())
unique_classes.add(class_name)
return sorted(list(unique_classes))
+16
View File
@@ -32,8 +32,10 @@ from supervision.detection.utils import (
extract_ultralytics_masks,
get_data_item,
is_data_equal,
is_metadata_equal,
mask_to_xyxy,
merge_data,
merge_metadata,
process_roboflow_result,
xywh_to_xyxy,
)
@@ -125,6 +127,9 @@ class Detections:
data (Dict[str, Union[np.ndarray, List]]): A dictionary containing additional
data where each key is a string representing the data type, and the value
is either a NumPy array or a list of corresponding data.
metadata (Dict[str, Any]): A dictionary containing collection-level metadata
that applies to the entire set of detections. This may include information such
as the video name, camera parameters, timestamp, or other global metadata.
""" # noqa: E501 // docs
xyxy: np.ndarray
@@ -133,6 +138,7 @@ class Detections:
class_id: Optional[np.ndarray] = None
tracker_id: Optional[np.ndarray] = None
data: Dict[str, Union[np.ndarray, List]] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
validate_detections_fields(
@@ -185,6 +191,7 @@ class Detections:
np.array_equal(self.confidence, other.confidence),
np.array_equal(self.tracker_id, other.tracker_id),
is_data_equal(self.data, other.data),
is_metadata_equal(self.metadata, other.metadata),
]
)
@@ -985,6 +992,7 @@ class Detections:
"""
empty_detections = Detections.empty()
empty_detections.data = self.data
empty_detections.metadata = self.metadata
return self == empty_detections
@classmethod
@@ -1078,6 +1086,9 @@ class Detections:
data = merge_data([d.data for d in detections_list])
metadata_list = [detections.metadata for detections in detections_list]
metadata = merge_metadata(metadata_list)
return cls(
xyxy=xyxy,
mask=mask,
@@ -1085,6 +1096,7 @@ class Detections:
class_id=class_id,
tracker_id=tracker_id,
data=data,
metadata=metadata,
)
def get_anchors_coordinates(self, anchor: Position) -> np.ndarray:
@@ -1198,6 +1210,7 @@ class Detections:
class_id=self.class_id[index] if self.class_id is not None else None,
tracker_id=self.tracker_id[index] if self.tracker_id is not None else None,
data=get_data_item(self.data, index),
metadata=self.metadata,
)
def __setitem__(self, key: str, value: Union[np.ndarray, List]):
@@ -1459,6 +1472,8 @@ def merge_inner_detection_object_pair(
else:
winning_detection = detections_2
metadata = merge_metadata([detections_1.metadata, detections_2.metadata])
return Detections(
xyxy=merged_xyxy,
mask=merged_mask,
@@ -1466,6 +1481,7 @@ def merge_inner_detection_object_pair(
class_id=winning_detection.class_id,
tracker_id=winning_detection.tracker_id,
data=winning_detection.data,
metadata=metadata,
)
+143 -68
View File
@@ -1,11 +1,12 @@
import math
import warnings
from collections import Counter
from collections import Counter, defaultdict, deque
from functools import lru_cache
from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
from typing import Any, Deque, Dict, Iterable, List, Literal, Optional, Tuple
import cv2
import numpy as np
import numpy.typing as npt
from supervision.config import CLASS_NAME_DATA_FIELD
from supervision.detection.core import Detections
@@ -74,6 +75,7 @@ class LineZone:
Position.BOTTOM_LEFT,
Position.BOTTOM_RIGHT,
),
crossing_acceptance_threshold: int = 1,
):
"""
Args:
@@ -84,10 +86,18 @@ class LineZone:
to consider when deciding on whether the detection
has passed the line counter or not. By default, this
contains the four corners of the detection's bounding box
crossing_acceptance_threshold (int): Detection needs to be seen
on the other side of the line for this many frames to be
considered as having crossed the line. This is useful when
dealing with unstable bounding boxes or when detections
may linger on the line.
"""
self.vector = Vector(start=start, end=end)
self.limits = self.calculate_region_of_interest_limits(vector=self.vector)
self.tracker_state: Dict[str, bool] = {}
self.limits = self._calculate_region_of_interest_limits(vector=self.vector)
self.crossing_history_length = max(2, crossing_acceptance_threshold + 1)
self.crossing_state_history: Dict[int, Deque[bool]] = defaultdict(
lambda: deque(maxlen=self.crossing_history_length)
)
self._in_count_per_class: Counter = Counter()
self._out_count_per_class: Counter = Counter()
self.triggering_anchors = triggering_anchors
@@ -127,8 +137,82 @@ class LineZone:
"""
return dict(self._out_count_per_class)
def trigger(self, detections: Detections) -> Tuple[np.ndarray, np.ndarray]:
"""
Update the `in_count` and `out_count` based on the objects that cross the line.
Args:
detections (Detections): A list of detections for which to update the
counts.
Returns:
A tuple of two boolean NumPy arrays. The first array indicates which
detections have crossed the line from outside to inside. The second
array indicates which detections have crossed the line from inside to
outside.
"""
crossed_in = np.full(len(detections), False)
crossed_out = np.full(len(detections), False)
if len(detections) == 0:
return crossed_in, crossed_out
if detections.tracker_id is None:
warnings.warn(
"Line zone counting skipped. LineZone requires tracker_id. Refer to "
"https://supervision.roboflow.com/latest/trackers for more "
"information.",
category=SupervisionWarnings,
)
return crossed_in, crossed_out
self._update_class_id_to_name(detections)
in_limits, has_any_left_trigger, has_any_right_trigger = (
self._compute_anchor_sides(detections)
)
class_ids: List[Optional[int]] = (
list(detections.class_id)
if detections.class_id is not None
else [None] * len(detections)
)
for i, (class_id, tracker_id) in enumerate(
zip(class_ids, detections.tracker_id)
):
if not in_limits[i]:
continue
if has_any_left_trigger[i] and has_any_right_trigger[i]:
continue
tracker_state: bool = has_any_left_trigger[i]
crossing_history = self.crossing_state_history[tracker_id]
crossing_history.append(tracker_state)
if len(crossing_history) < self.crossing_history_length:
continue
# TODO: Account for incorrect class_id.
# Most likely this would involve indexing self.crossing_state_history
# with (tracker_id, class_id).
oldest_state = crossing_history[0]
if crossing_history.count(oldest_state) > 1:
continue
if tracker_state:
self._in_count_per_class[class_id] += 1
crossed_in[i] = True
else:
self._out_count_per_class[class_id] += 1
crossed_out[i] = True
return crossed_in, crossed_out
@staticmethod
def calculate_region_of_interest_limits(vector: Vector) -> Tuple[Vector, Vector]:
def _calculate_region_of_interest_limits(vector: Vector) -> Tuple[Vector, Vector]:
magnitude = vector.magnitude
if magnitude == 0:
@@ -159,40 +243,45 @@ class LineZone:
)
return start_region_limit, end_region_limit
@staticmethod
def is_point_in_limits(point: Point, limits: Tuple[Vector, Vector]) -> bool:
cross_product_1 = limits[0].cross_product(point)
cross_product_2 = limits[1].cross_product(point)
return (cross_product_1 > 0) == (cross_product_2 > 0)
def trigger(self, detections: Detections) -> Tuple[np.ndarray, np.ndarray]:
def _compute_anchor_sides(
self, detections: Detections
) -> Tuple[npt.NDArray[np.bool_], npt.NDArray[np.bool_], npt.NDArray[np.bool_]]:
"""
Update the `in_count` and `out_count` based on the objects that cross the line.
Find if detections' anchors are within the limit of the line
zone and which anchors are on its left and right side.
Assumes:
* At least 1 detection is provided
* Detections have `tracker_id`
The limit is defined as the region between the two lines,
perpendicular to the line zone, and passing through its start
and end points, as shown below:
Limits:
```
| IN ↑
| |
OUT o---LINE---o OUT
| |
↓ IN |
```
Args:
detections (Detections): A list of detections for which to update the
counts.
detections (Detections): The detections to check.
Returns:
A tuple of two boolean NumPy arrays. The first array indicates which
detections have crossed the line from outside to inside. The second
array indicates which detections have crossed the line from inside to
outside.
result (Tuple[np.ndarray, np.ndarray, np.ndarray]):
All 3 arrays are boolean arrays of shape (N, ) where N is the
number of detections. The first array, `in_limits`, indicates
if the detection's anchor is within the line zone limits.
The second array, `has_any_left_trigger`, indicates if the
detection's anchor is on the left side of the line zone.
The third array, `has_any_right_trigger`, indicates if the
detection's anchor is on the right side of the line zone.
"""
crossed_in = np.full(len(detections), False)
crossed_out = np.full(len(detections), False)
if len(detections) == 0:
return crossed_in, crossed_out
if detections.tracker_id is None:
warnings.warn(
"Line zone counting skipped. LineZone requires tracker_id. Refer to "
"https://supervision.roboflow.com/latest/trackers for more "
"information.",
category=SupervisionWarnings,
)
return crossed_in, crossed_out
assert len(detections) > 0
assert detections.tracker_id is not None
all_anchors = np.array(
[
@@ -203,52 +292,38 @@ class LineZone:
cross_products_1 = cross_product(all_anchors, self.limits[0])
cross_products_2 = cross_product(all_anchors, self.limits[1])
# Works because limit vectors are pointing in opposite directions
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)
class_ids = (
list(detections.class_id)
if detections.class_id is not None
else [None] * len(detections)
)
tracker_ids = list(detections.tracker_id)
return in_limits, has_any_left_trigger, has_any_right_trigger
if CLASS_NAME_DATA_FIELD in detections.data:
class_names = detections.data[CLASS_NAME_DATA_FIELD]
for class_id, class_name in zip(class_ids, class_names):
if class_id is None:
class_name = "No class"
self.class_id_to_name[class_id] = class_name
def _update_class_id_to_name(self, detections: Detections) -> None:
"""
Update the attribute keeping track of which class
IDs correspond to which class names.
for i, (class_ids, tracker_id) in enumerate(zip(class_ids, tracker_ids)):
if not in_limits[i]:
continue
Assumes that class_names are only provided when class_ids are.
"""
class_names = detections.data.get(CLASS_NAME_DATA_FIELD)
assert class_names is None or detections.class_id is not None
if not is_uniformly_triggered[i]:
continue
if detections.class_id is None:
return
tracker_state = has_any_left_trigger[i]
if tracker_id not in self.tracker_state:
self.tracker_state[tracker_id] = tracker_state
continue
if self.tracker_state.get(tracker_id) == tracker_state:
continue
self.tracker_state[tracker_id] = tracker_state
if tracker_state:
self._in_count_per_class[class_ids] += 1
crossed_in[i] = True
else:
self._out_count_per_class[class_ids] += 1
crossed_out[i] = True
return crossed_in, crossed_out
if class_names is None:
new_names = {class_id: str(class_id) for class_id in detections.class_id}
else:
new_names = {
class_id: class_name
for class_id, class_name in zip(detections.class_id, class_names)
}
self.class_id_to_name.update(new_names)
class LineZoneAnnotator:
+30 -2
View File
@@ -17,6 +17,12 @@ class PolygonZone:
"""
A class for defining a polygon-shaped zone within a frame for detecting objects.
!!! warning
PolygonZone uses the `tracker_id`. Read
[here](/latest/trackers/) to learn how to plug
tracking into your inference pipeline.
Attributes:
polygon (np.ndarray): A polygon represented by a numpy array of shape
`(N, 2)`, containing the `x`, `y` coordinates of the points.
@@ -26,6 +32,28 @@ class PolygonZone:
(default: (sv.Position.BOTTOM_CENTER,)).
current_count (int): The current count of detected objects within the zone
mask (np.ndarray): The 2D bool mask for the polygon zone
Example:
```python
import supervision as sv
from ultralytics import YOLO
import numpy as np
import cv2
image = cv2.imread(<SOURCE_IMAGE_PATH>)
model = YOLO("yolo11s")
tracker = sv.ByteTrack()
polygon = np.array([[100, 200], [200, 100], [300, 200], [200, 300]])
polygon_zone = sv.PolygonZone(polygon=polygon)
result = model.infer(image)[0]
detections = sv.Detections.from_ultralytics(result)
detections = tracker.update_with_detections(detections)
is_detections_in_zone = polygon_zone.trigger(detections)
print(polygon_zone.current_count)
```
"""
def __init__(
@@ -88,7 +116,7 @@ class PolygonZoneAnnotator:
Attributes:
zone (PolygonZone): The polygon zone to be annotated
color (Color): The color to draw the polygon lines
color (Color): The color to draw the polygon lines, default is white
thickness (int): The thickness of the polygon lines, default is 2
text_color (Color): The color of the text on the polygon, default is black
text_scale (float): The scale of the text on the polygon, default is 0.5
@@ -104,7 +132,7 @@ class PolygonZoneAnnotator:
def __init__(
self,
zone: PolygonZone,
color: Color,
color: Color = Color.WHITE,
thickness: int = 2,
text_color: Color = Color.BLACK,
text_scale: float = 0.5,
+69 -7
View File
@@ -1,5 +1,5 @@
from itertools import chain
from typing import Dict, List, Optional, Tuple, Union
from typing import Any, Dict, List, Optional, Tuple, Union
import cv2
import numpy as np
@@ -23,10 +23,9 @@ def polygon_to_mask(polygon: np.ndarray, resolution_wh: Tuple[int, int]) -> np.n
np.ndarray: The generated 2D mask, where the polygon is marked with
`1`'s and the rest is filled with `0`'s.
"""
width, height = resolution_wh
mask = np.zeros((height, width))
cv2.fillPoly(mask, [polygon], color=1)
width, height = map(int, resolution_wh)
mask = np.zeros((height, width), dtype=np.uint8)
cv2.fillPoly(mask, [polygon.astype(np.int32)], color=1)
return mask
@@ -163,9 +162,9 @@ def oriented_box_iou_batch(
boxes_true = boxes_true.reshape(-1, 4, 2)
boxes_detection = boxes_detection.reshape(-1, 4, 2)
max_height = max(boxes_true[:, :, 0].max(), boxes_detection[:, :, 0].max()) + 1
max_height = int(max(boxes_true[:, :, 0].max(), boxes_detection[:, :, 0].max()) + 1)
# adding 1 because we are 0-indexed
max_width = max(boxes_true[:, :, 1].max(), boxes_detection[:, :, 1].max()) + 1
max_width = int(max(boxes_true[:, :, 1].max(), boxes_detection[:, :, 1].max()) + 1)
mask_true = np.zeros((boxes_true.shape[0], max_height, max_width))
for i, box_true in enumerate(boxes_true):
@@ -808,12 +807,36 @@ def is_data_equal(data_a: Dict[str, np.ndarray], data_b: Dict[str, np.ndarray])
)
def is_metadata_equal(metadata_a: Dict[str, Any], metadata_b: Dict[str, Any]) -> bool:
"""
Compares the metadata payloads of two Detections instances.
Args:
metadata_a, metadata_b: The metadata payloads of the instances.
Returns:
True if the metadata payloads are equal, False otherwise.
"""
return set(metadata_a.keys()) == set(metadata_b.keys()) and all(
np.array_equal(metadata_a[key], metadata_b[key])
if (
isinstance(metadata_a[key], np.ndarray)
and isinstance(metadata_b[key], np.ndarray)
)
else metadata_a[key] == metadata_b[key]
for key in metadata_a
)
def merge_data(
data_list: List[Dict[str, Union[npt.NDArray[np.generic], List]]],
) -> Dict[str, Union[npt.NDArray[np.generic], List]]:
"""
Merges the data payloads of a list of Detections instances.
Warning: Assumes that empty detections were filtered-out before passing data to
this function.
Args:
data_list: The data payloads of the Detections instances. Each data payload
is a dictionary with the same keys, and the values are either lists or
@@ -866,6 +889,45 @@ def merge_data(
return merged_data
def merge_metadata(metadata_list: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Merge metadata from a list of metadata dictionaries.
This function combines the metadata dictionaries. If a key appears in more than one
dictionary, the values must be identical for the merge to succeed.
Warning: Assumes that empty detections were filtered-out before passing metadata to
this function.
Args:
metadata_list (List[Dict[str, Any]]): A list of metadata dictionaries to merge.
Returns:
Dict[str, Any]: A single merged metadata dictionary.
Raises:
ValueError: If there are conflicting values for the same key or if
dictionaries have different keys.
"""
if not metadata_list:
return {}
all_keys_sets = [set(metadata.keys()) for metadata in metadata_list]
if not all(keys_set == all_keys_sets[0] for keys_set in all_keys_sets):
raise ValueError("All metadata dictionaries must have the same keys to merge.")
merged_metadata: Dict[str, Any] = {}
for metadata in metadata_list:
for key, value in metadata.items():
if key in merged_metadata:
if merged_metadata[key] != value:
raise ValueError(f"Conflicting metadata for key: '{key}'.")
else:
merged_metadata[key] = value
return merged_metadata
def get_data_item(
data: Dict[str, Union[np.ndarray, List]],
index: Union[int, slice, List[int], np.ndarray],
+18 -8
View File
@@ -9,7 +9,11 @@ from supervision.geometry.core import Point, Rect
def draw_line(
scene: np.ndarray, start: Point, end: Point, color: Color, thickness: int = 2
scene: np.ndarray,
start: Point,
end: Point,
color: Color = Color.ROBOFLOW,
thickness: int = 2,
) -> np.ndarray:
"""
Draws a line on a given scene.
@@ -18,7 +22,7 @@ def draw_line(
scene (np.ndarray): The scene on which the line will be drawn
start (Point): The starting point of the line
end (Point): The end point of the line
color (Color): The color of the line
color (Color): The color of the line, defaults to Color.ROBOFLOW
thickness (int): The thickness of the line
Returns:
@@ -35,7 +39,7 @@ def draw_line(
def draw_rectangle(
scene: np.ndarray, rect: Rect, color: Color, thickness: int = 2
scene: np.ndarray, rect: Rect, color: Color = Color.ROBOFLOW, thickness: int = 2
) -> np.ndarray:
"""
Draws a rectangle on an image.
@@ -60,7 +64,7 @@ def draw_rectangle(
def draw_filled_rectangle(
scene: np.ndarray, rect: Rect, color: Color, opacity: float = 1
scene: np.ndarray, rect: Rect, color: Color = Color.ROBOFLOW, opacity: float = 1
) -> np.ndarray:
"""
Draws a filled rectangle on an image.
@@ -151,14 +155,17 @@ def draw_rounded_rectangle(
def draw_polygon(
scene: np.ndarray, polygon: np.ndarray, color: Color, thickness: int = 2
scene: np.ndarray,
polygon: np.ndarray,
color: Color = Color.ROBOFLOW,
thickness: int = 2,
) -> np.ndarray:
"""Draw a polygon on a scene.
Parameters:
scene (np.ndarray): The scene to draw the polygon on.
polygon (np.ndarray): The polygon to be drawn, given as a list of vertices.
color (Color): The color of the polygon.
color (Color): The color of the polygon. Defaults to Color.ROBOFLOW.
thickness (int): The thickness of the polygon lines, by default 2.
Returns:
@@ -171,14 +178,17 @@ def draw_polygon(
def draw_filled_polygon(
scene: np.ndarray, polygon: np.ndarray, color: Color, opacity: float = 1
scene: np.ndarray,
polygon: np.ndarray,
color: Color = Color.ROBOFLOW,
opacity: float = 1,
) -> np.ndarray:
"""Draw a filled polygon on a scene.
Parameters:
scene (np.ndarray): The scene to draw the polygon on.
polygon (np.ndarray): The polygon to be drawn, given as a list of vertices.
color (Color): The color of the polygon.
color (Color): The color of the polygon. Defaults to Color.ROBOFLOW.
opacity (float): The opacity of polygon when drawn on the scene.
Returns:
+6
View File
@@ -16,6 +16,9 @@ def get_polygon_center(polygon: np.ndarray) -> Point:
Point: The center of the polygon, represented as a
Point object with x and y attributes.
Raises:
ValueError: If the polygon has no vertices.
Examples:
```python
import numpy as np
@@ -30,6 +33,9 @@ def get_polygon_center(polygon: np.ndarray) -> Point:
# This is one of the 3 candidate algorithms considered for centroid calculation.
# For a more detailed discussion, see PR #1084 and commit eb33176
if len(polygon) == 0:
raise ValueError("Polygon must have at least one vertex.")
shift_polygon = np.roll(polygon, -1, axis=0)
signed_areas = np.cross(polygon, shift_polygon) / 2
if signed_areas.sum() == 0:
+8
View File
@@ -612,3 +612,11 @@ class KeyPoints:
```
"""
return cls(xy=np.empty((0, 0, 2), dtype=np.float32))
def is_empty(self) -> bool:
"""
Returns `True` if the `KeyPoints` object is considered empty.
"""
empty_keypoints = KeyPoints.empty()
empty_keypoints.data = self.data
return self == empty_keypoints
+2 -1
View File
@@ -1,5 +1,4 @@
from supervision.metrics.core import (
CLASS_ID_NONE,
AveragingMethod,
Metric,
MetricTarget,
@@ -9,6 +8,8 @@ from supervision.metrics.mean_average_precision import (
MeanAveragePrecision,
MeanAveragePrecisionResult,
)
from supervision.metrics.precision import Precision, PrecisionResult
from supervision.metrics.recall import Recall, RecallResult
from supervision.metrics.utils.object_size import (
ObjectSizeCategory,
get_detection_size_category,
+14 -15
View File
@@ -4,9 +4,6 @@ from abc import ABC, abstractmethod
from enum import Enum
from typing import Any
CLASS_ID_NONE = -1
"""Used by metrics module as class ID, when none is present"""
class Metric(ABC):
"""
@@ -40,9 +37,10 @@ class MetricTarget(Enum):
"""
Specifies what type of detection is used to compute the metric.
* BOXES: xyxy bounding boxes
* MASKS: Binary masks
* ORIENTED_BOUNDING_BOXES: Oriented bounding boxes (OBB)
Attributes:
BOXES: xyxy bounding boxes
MASKS: Binary masks
ORIENTED_BOUNDING_BOXES: Oriented bounding boxes (OBB)
"""
BOXES = "boxes"
@@ -57,15 +55,16 @@ class AveragingMethod(Enum):
Suppose, before returning the final result, a metric is computed for each class.
How do you combine those to get the final number?
* MACRO: Calculate the metric for each class and average the results. The simplest
averaging method, but it does not take class imbalance into account.
* MICRO: Calculate the metric globally by counting the total true positives, false
positives, and false negatives. Micro averaging is useful when you want to give
more importance to classes with more samples. It's also more appropriate if you
have an imbalance in the number of instances per class.
* WEIGHTED: Calculate the metric for each class and average the results, weighted by
the number of true instances of each class. Use weighted averaging if you want
to take class imbalance into account.
Attributes:
MACRO: Calculate the metric for each class and average the results. The simplest
averaging method, but it does not take class imbalance into account.
MICRO: Calculate the metric globally by counting the total true positives, false
positives, and false negatives. Micro averaging is useful when you want to
give more importance to classes with more samples. It's also more
appropriate if you have an imbalance in the number of instances per class.
WEIGHTED: Calculate the metric for each class and average the results, weighted
by the number of true instances of each class. Use weighted averaging if
you want to take class imbalance into account.
"""
MACRO = "macro"
+78 -12
View File
@@ -9,7 +9,11 @@ from matplotlib import pyplot as plt
from supervision.config import ORIENTED_BOX_COORDINATES
from supervision.detection.core import Detections
from supervision.detection.utils import box_iou_batch, mask_iou_batch
from supervision.detection.utils import (
box_iou_batch,
mask_iou_batch,
oriented_box_iou_batch,
)
from supervision.draw.color import LEGACY_COLOR_PALETTE
from supervision.metrics.core import AveragingMethod, Metric, MetricTarget
from supervision.metrics.utils.object_size import (
@@ -23,23 +27,55 @@ if TYPE_CHECKING:
class F1Score(Metric):
"""
F1 Score is a metric used to evaluate object detection models. It is the harmonic
mean of precision and recall, calculated at different IoU thresholds.
In simple terms, F1 Score is a measure of a model's balance between precision and
recall (accuracy and completeness), calculated as:
`F1 = 2 * (precision * recall) / (precision + recall)`
Example:
```python
import supervision as sv
from supervision.metrics import F1Score
predictions = sv.Detections(...)
targets = sv.Detections(...)
f1_metric = F1Score()
f1_result = f1_metric.update(predictions, targets).compute()
print(f1_result)
print(f1_result.f1_50)
print(f1_result.small_objects.f1_50)
```
"""
def __init__(
self,
metric_target: MetricTarget = MetricTarget.BOXES,
averaging_method: AveragingMethod = AveragingMethod.WEIGHTED,
):
self._metric_target = metric_target
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
raise NotImplementedError(
"F1 score is not implemented for oriented bounding boxes."
)
"""
Initialize the F1Score metric.
Args:
metric_target (MetricTarget): The type of detection data to use.
averaging_method (AveragingMethod): The averaging method used to compute the
F1 scores. Determines how the F1 scores are aggregated across classes.
"""
self._metric_target = metric_target
self.averaging_method = averaging_method
self._predictions_list: List[Detections] = []
self._targets_list: List[Detections] = []
def reset(self) -> None:
"""
Reset the metric to its initial state, clearing all stored data.
"""
self._predictions_list = []
self._targets_list = []
@@ -48,6 +84,16 @@ class F1Score(Metric):
predictions: Union[Detections, List[Detections]],
targets: Union[Detections, List[Detections]],
) -> F1Score:
"""
Add new predictions and targets to the metric, but do not compute the result.
Args:
predictions (Union[Detections, List[Detections]]): The predicted detections.
targets (Union[Detections, List[Detections]]): The target detections.
Returns:
(F1Score): The updated metric instance.
"""
if not isinstance(predictions, list):
predictions = [predictions]
if not isinstance(targets, list):
@@ -65,6 +111,13 @@ class F1Score(Metric):
return self
def compute(self) -> F1ScoreResult:
"""
Calculate the F1 score metric based on the stored predictions and ground-truth
data, at different IoU thresholds.
Returns:
(F1ScoreResult): The F1 score metric result.
"""
result = self._compute(self._predictions_list, self._targets_list)
small_predictions, small_targets = self._filter_predictions_and_targets_by_size(
@@ -112,8 +165,12 @@ class F1Score(Metric):
iou = box_iou_batch(target_contents, prediction_contents)
elif self._metric_target == MetricTarget.MASKS:
iou = mask_iou_batch(target_contents, prediction_contents)
elif self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
iou = oriented_box_iou_batch(
target_contents, prediction_contents
)
else:
raise NotImplementedError(
raise ValueError(
"Unsupported metric target for IoU calculation"
)
@@ -312,12 +369,22 @@ class F1Score(Metric):
return (
detections.mask
if detections.mask is not None
else np.empty((0, 0, 0), dtype=bool)
else self._make_empty_content()
)
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
if obb := detections.data.get(ORIENTED_BOX_COORDINATES):
return np.ndarray(obb, dtype=np.float32)
return np.empty((0, 8), dtype=np.float32)
obb = detections.data.get(ORIENTED_BOX_COORDINATES)
if obb is not None and len(obb) > 0:
return np.array(obb, dtype=np.float32)
return self._make_empty_content()
raise ValueError(f"Invalid metric target: {self._metric_target}")
def _make_empty_content(self) -> np.ndarray:
if self._metric_target == MetricTarget.BOXES:
return np.empty((0, 4), dtype=np.float32)
if self._metric_target == MetricTarget.MASKS:
return np.empty((0, 0, 0), dtype=bool)
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
return np.empty((0, 4, 2), dtype=np.float32)
raise ValueError(f"Invalid metric target: {self._metric_target}")
def _filter_detections_by_size(
@@ -373,7 +440,6 @@ class F1ScoreResult:
The results of the F1 score metric calculation.
Defaults to `0` if no detections or targets were provided.
Provides a custom `__str__` method for pretty printing.
Attributes:
metric_target (MetricTarget): the type of data used for the metric -
+56 -29
View File
@@ -9,7 +9,11 @@ from matplotlib import pyplot as plt
from supervision.config import ORIENTED_BOX_COORDINATES
from supervision.detection.core import Detections
from supervision.detection.utils import box_iou_batch, mask_iou_batch
from supervision.detection.utils import (
box_iou_batch,
mask_iou_batch,
oriented_box_iou_batch,
)
from supervision.draw.color import LEGACY_COLOR_PALETTE
from supervision.metrics.core import Metric, MetricTarget
from supervision.metrics.utils.object_size import (
@@ -23,6 +27,27 @@ if TYPE_CHECKING:
class MeanAveragePrecision(Metric):
"""
Mean Average Precision (mAP) is a metric used to evaluate object detection models.
It is the average of the precision-recall curves at different IoU thresholds.
Example:
```python
import supervision as sv
from supervision.metrics import MeanAveragePrecision
predictions = sv.Detections(...)
targets = sv.Detections(...)
map_metric = MeanAveragePrecision()
map_result = map_metric.update(predictions, targets).compute()
print(map_result)
print(map_result.map50_95)
map_result.plot()
```
"""
def __init__(
self,
metric_target: MetricTarget = MetricTarget.BOXES,
@@ -36,17 +61,15 @@ class MeanAveragePrecision(Metric):
class_agnostic (bool): Whether to treat all data as a single class.
"""
self._metric_target = metric_target
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
raise NotImplementedError(
"Mean Average Precision is not implemented for oriented bounding boxes."
)
self._class_agnostic = class_agnostic
self._predictions_list: List[Detections] = []
self._targets_list: List[Detections] = []
def reset(self) -> None:
"""
Reset the metric to its initial state, clearing all stored data.
"""
self._predictions_list = []
self._targets_list = []
@@ -76,6 +99,15 @@ class MeanAveragePrecision(Metric):
f" targets ({len(targets)}) during the update must be the same."
)
if self._class_agnostic:
predictions = deepcopy(predictions)
targets = deepcopy(targets)
for prediction in predictions:
prediction.class_id[:] = -1
for target in targets:
target.class_id[:] = -1
self._predictions_list.extend(predictions)
self._targets_list.extend(targets)
@@ -86,26 +118,10 @@ class MeanAveragePrecision(Metric):
) -> MeanAveragePrecisionResult:
"""
Calculate Mean Average Precision based on predicted and ground-truth
detections at different thresholds.
detections at different thresholds.
Returns:
(MeanAveragePrecisionResult): New instance of MeanAveragePrecision.
Example:
```python
import supervision as sv
from supervision.metrics import MeanAveragePrecision
predictions = sv.Detections(...)
targets = sv.Detections(...)
map_metric = MeanAveragePrecision()
map_result = map_metric.update(predictions, targets).compute()
print(map_result)
print(map_result.map50_95)
map_result.plot()
```
(MeanAveragePrecisionResult): The Mean Average Precision result.
"""
result = self._compute(self._predictions_list, self._targets_list)
@@ -172,14 +188,19 @@ class MeanAveragePrecision(Metric):
iou = box_iou_batch(target_contents, prediction_contents)
elif self._metric_target == MetricTarget.MASKS:
iou = mask_iou_batch(target_contents, prediction_contents)
elif self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
iou = oriented_box_iou_batch(
target_contents, prediction_contents
)
else:
raise NotImplementedError(
raise ValueError(
"Unsupported metric target for IoU calculation"
)
matches = self._match_detection_batch(
predictions.class_id, targets.class_id, iou, iou_thresholds
)
stats.append(
(
matches,
@@ -203,6 +224,7 @@ class MeanAveragePrecision(Metric):
return MeanAveragePrecisionResult(
metric_target=self._metric_target,
is_class_agnostic=self._class_agnostic,
mAP_scores=mAP_scores,
iou_thresholds=iou_thresholds,
matched_classes=unique_classes,
@@ -230,7 +252,7 @@ class MeanAveragePrecision(Metric):
for r, p in zip(recall[::-1], precision[::-1]):
precision_levels[recall_levels <= r] = p
average_precision = (1 / 100 * precision_levels).sum()
average_precision = (1 / 101 * precision_levels).sum()
return average_precision
@staticmethod
@@ -332,8 +354,9 @@ class MeanAveragePrecision(Metric):
else self._make_empty_content()
)
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
if obb := detections.data.get(ORIENTED_BOX_COORDINATES):
return np.ndarray(obb, dtype=np.float32)
obb = detections.data.get(ORIENTED_BOX_COORDINATES)
if obb is not None and len(obb) > 0:
return np.array(obb, dtype=np.float32)
return self._make_empty_content()
raise ValueError(f"Invalid metric target: {self._metric_target}")
@@ -343,7 +366,7 @@ class MeanAveragePrecision(Metric):
if self._metric_target == MetricTarget.MASKS:
return np.empty((0, 0, 0), dtype=bool)
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
return np.empty((0, 8), dtype=np.float32)
return np.empty((0, 4, 2), dtype=np.float32)
raise ValueError(f"Invalid metric target: {self._metric_target}")
def _filter_detections_by_size(
@@ -383,6 +406,8 @@ class MeanAveragePrecisionResult:
Attributes:
metric_target (MetricTarget): the type of data used for the metric -
boxes, masks or oriented bounding boxes.
class_agnostic (bool): When computing class-agnostic results, class ID
is set to `-1`.
mAP_map50_95 (float): the mAP score at IoU thresholds from `0.5` to `0.95`.
mAP_map50 (float): the mAP score at IoU threshold of `0.5`.
mAP_map75 (float): the mAP score at IoU threshold of `0.75`.
@@ -402,6 +427,7 @@ class MeanAveragePrecisionResult:
"""
metric_target: MetricTarget
is_class_agnostic: bool
@property
def map50_95(self) -> float:
@@ -436,6 +462,7 @@ class MeanAveragePrecisionResult:
out_str = (
f"{self.__class__.__name__}:\n"
f"Metric target: {self.metric_target}\n"
f"Class agnostic: {self.is_class_agnostic}\n"
f"mAP @ 50:95: {self.map50_95:.4f}\n"
f"mAP @ 50: {self.map50:.4f}\n"
f"mAP @ 75: {self.map75:.4f}\n"
+616
View File
@@ -0,0 +1,616 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional, Tuple, Union
import numpy as np
from matplotlib import pyplot as plt
from supervision.config import ORIENTED_BOX_COORDINATES
from supervision.detection.core import Detections
from supervision.detection.utils import (
box_iou_batch,
mask_iou_batch,
oriented_box_iou_batch,
)
from supervision.draw.color import LEGACY_COLOR_PALETTE
from supervision.metrics.core import AveragingMethod, Metric, MetricTarget
from supervision.metrics.utils.object_size import (
ObjectSizeCategory,
get_detection_size_category,
)
from supervision.metrics.utils.utils import ensure_pandas_installed
if TYPE_CHECKING:
import pandas as pd
class Precision(Metric):
"""
Precision is a metric used to evaluate object detection models. It is the ratio of
true positive detections to the total number of predicted detections. We calculate
it at different IoU thresholds.
In simple terms, Precision is a measure of a model's accuracy, calculated as:
`Precision = TP / (TP + FP)`
Here, `TP` is the number of true positives (correct detections), and `FP` is the
number of false positive detections (detected, but incorrectly).
Example:
```python
import supervision as sv
from supervision.metrics import Precision
predictions = sv.Detections(...)
targets = sv.Detections(...)
precision_metric = Precision()
precision_result = precision_metric.update(predictions, targets).compute()
print(precision_result)
print(precision_result.precision_at_50)
print(precision_result.small_objects.precision_at_50)
```
"""
def __init__(
self,
metric_target: MetricTarget = MetricTarget.BOXES,
averaging_method: AveragingMethod = AveragingMethod.WEIGHTED,
):
"""
Initialize the Precision metric.
Args:
metric_target (MetricTarget): The type of detection data to use.
averaging_method (AveragingMethod): The averaging method used to compute the
precision. Determines how the precision is aggregated across classes.
"""
self._metric_target = metric_target
self.averaging_method = averaging_method
self._predictions_list: List[Detections] = []
self._targets_list: List[Detections] = []
def reset(self) -> None:
"""
Reset the metric to its initial state, clearing all stored data.
"""
self._predictions_list = []
self._targets_list = []
def update(
self,
predictions: Union[Detections, List[Detections]],
targets: Union[Detections, List[Detections]],
) -> Precision:
"""
Add new predictions and targets to the metric, but do not compute the result.
Args:
predictions (Union[Detections, List[Detections]]): The predicted detections.
targets (Union[Detections, List[Detections]]): The target detections.
Returns:
(Precision): The updated metric instance.
"""
if not isinstance(predictions, list):
predictions = [predictions]
if not isinstance(targets, list):
targets = [targets]
if len(predictions) != len(targets):
raise ValueError(
f"The number of predictions ({len(predictions)}) and"
f" targets ({len(targets)}) during the update must be the same."
)
self._predictions_list.extend(predictions)
self._targets_list.extend(targets)
return self
def compute(self) -> PrecisionResult:
"""
Calculate the precision metric based on the stored predictions and ground-truth
data, at different IoU thresholds.
Returns:
(PrecisionResult): The precision metric result.
"""
result = self._compute(self._predictions_list, self._targets_list)
small_predictions, small_targets = self._filter_predictions_and_targets_by_size(
self._predictions_list, self._targets_list, ObjectSizeCategory.SMALL
)
result.small_objects = self._compute(small_predictions, small_targets)
medium_predictions, medium_targets = (
self._filter_predictions_and_targets_by_size(
self._predictions_list, self._targets_list, ObjectSizeCategory.MEDIUM
)
)
result.medium_objects = self._compute(medium_predictions, medium_targets)
large_predictions, large_targets = self._filter_predictions_and_targets_by_size(
self._predictions_list, self._targets_list, ObjectSizeCategory.LARGE
)
result.large_objects = self._compute(large_predictions, large_targets)
return result
def _compute(
self, predictions_list: List[Detections], targets_list: List[Detections]
) -> PrecisionResult:
iou_thresholds = np.linspace(0.5, 0.95, 10)
stats = []
for predictions, targets in zip(predictions_list, targets_list):
prediction_contents = self._detections_content(predictions)
target_contents = self._detections_content(targets)
if len(targets) > 0:
if len(predictions) == 0:
stats.append(
(
np.zeros((0, iou_thresholds.size), dtype=bool),
np.zeros((0,), dtype=np.float32),
np.zeros((0,), dtype=int),
targets.class_id,
)
)
else:
if self._metric_target == MetricTarget.BOXES:
iou = box_iou_batch(target_contents, prediction_contents)
elif self._metric_target == MetricTarget.MASKS:
iou = mask_iou_batch(target_contents, prediction_contents)
elif self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
iou = oriented_box_iou_batch(
target_contents, prediction_contents
)
else:
raise ValueError(
"Unsupported metric target for IoU calculation"
)
matches = self._match_detection_batch(
predictions.class_id, targets.class_id, iou, iou_thresholds
)
stats.append(
(
matches,
predictions.confidence,
predictions.class_id,
targets.class_id,
)
)
if not stats:
return PrecisionResult(
metric_target=self._metric_target,
averaging_method=self.averaging_method,
precision_scores=np.zeros(iou_thresholds.shape[0]),
precision_per_class=np.zeros((0, iou_thresholds.shape[0])),
iou_thresholds=iou_thresholds,
matched_classes=np.array([], dtype=int),
small_objects=None,
medium_objects=None,
large_objects=None,
)
concatenated_stats = [np.concatenate(items, 0) for items in zip(*stats)]
precision_scores, precision_per_class, unique_classes = (
self._compute_precision_for_classes(*concatenated_stats)
)
return PrecisionResult(
metric_target=self._metric_target,
averaging_method=self.averaging_method,
precision_scores=precision_scores,
precision_per_class=precision_per_class,
iou_thresholds=iou_thresholds,
matched_classes=unique_classes,
small_objects=None,
medium_objects=None,
large_objects=None,
)
def _compute_precision_for_classes(
self,
matches: np.ndarray,
prediction_confidence: np.ndarray,
prediction_class_ids: np.ndarray,
true_class_ids: np.ndarray,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
sorted_indices = np.argsort(-prediction_confidence)
matches = matches[sorted_indices]
prediction_class_ids = prediction_class_ids[sorted_indices]
unique_classes, class_counts = np.unique(true_class_ids, return_counts=True)
# Shape: PxTh,P,C,C -> CxThx3
confusion_matrix = self._compute_confusion_matrix(
matches, prediction_class_ids, unique_classes, class_counts
)
# Shape: CxThx3 -> CxTh
precision_per_class = self._compute_precision(confusion_matrix)
# Shape: CxTh -> Th
if self.averaging_method == AveragingMethod.MACRO:
precision_scores = np.mean(precision_per_class, axis=0)
elif self.averaging_method == AveragingMethod.MICRO:
confusion_matrix_merged = confusion_matrix.sum(0)
precision_scores = self._compute_precision(confusion_matrix_merged)
elif self.averaging_method == AveragingMethod.WEIGHTED:
class_counts = class_counts.astype(np.float32)
precision_scores = np.average(
precision_per_class, axis=0, weights=class_counts
)
return precision_scores, precision_per_class, unique_classes
@staticmethod
def _match_detection_batch(
predictions_classes: np.ndarray,
target_classes: np.ndarray,
iou: np.ndarray,
iou_thresholds: np.ndarray,
) -> np.ndarray:
num_predictions, num_iou_levels = (
predictions_classes.shape[0],
iou_thresholds.shape[0],
)
correct = np.zeros((num_predictions, num_iou_levels), dtype=bool)
correct_class = target_classes[:, None] == predictions_classes
for i, iou_level in enumerate(iou_thresholds):
matched_indices = np.where((iou >= iou_level) & correct_class)
if matched_indices[0].shape[0]:
combined_indices = np.stack(matched_indices, axis=1)
iou_values = iou[matched_indices][:, None]
matches = np.hstack([combined_indices, iou_values])
if matched_indices[0].shape[0] > 1:
matches = matches[matches[:, 2].argsort()[::-1]]
matches = matches[np.unique(matches[:, 1], return_index=True)[1]]
matches = matches[np.unique(matches[:, 0], return_index=True)[1]]
correct[matches[:, 1].astype(int), i] = True
return correct
@staticmethod
def _compute_confusion_matrix(
sorted_matches: np.ndarray,
sorted_prediction_class_ids: np.ndarray,
unique_classes: np.ndarray,
class_counts: np.ndarray,
) -> np.ndarray:
"""
Compute the confusion matrix for each class and IoU threshold.
Assumes the matches and prediction_class_ids are sorted by confidence
in descending order.
Arguments:
sorted_matches: np.ndarray, bool, shape (P, Th), that is True
if the prediction is a true positive at the given IoU threshold.
sorted_prediction_class_ids: np.ndarray, int, shape (P,), containing
the class id for each prediction.
unique_classes: np.ndarray, int, shape (C,), containing the unique
class ids.
class_counts: np.ndarray, int, shape (C,), containing the number
of true instances for each class.
Returns:
np.ndarray, shape (C, Th, 3), containing the true positives, false
positives, and false negatives for each class and IoU threshold.
"""
num_thresholds = sorted_matches.shape[1]
num_classes = unique_classes.shape[0]
confusion_matrix = np.zeros((num_classes, num_thresholds, 3))
for class_idx, class_id in enumerate(unique_classes):
is_class = sorted_prediction_class_ids == class_id
num_true = class_counts[class_idx]
num_predictions = is_class.sum()
if num_predictions == 0:
true_positives = np.zeros(num_thresholds)
false_positives = np.zeros(num_thresholds)
false_negatives = np.full(num_thresholds, num_true)
elif num_true == 0:
true_positives = np.zeros(num_thresholds)
false_positives = np.full(num_thresholds, num_predictions)
false_negatives = np.zeros(num_thresholds)
else:
true_positives = sorted_matches[is_class].sum(0)
false_positives = (1 - sorted_matches[is_class]).sum(0)
false_negatives = num_true - true_positives
confusion_matrix[class_idx] = np.stack(
[true_positives, false_positives, false_negatives], axis=1
)
return confusion_matrix
@staticmethod
def _compute_precision(confusion_matrix: np.ndarray) -> np.ndarray:
"""
Broadcastable function, computing the precision from the confusion matrix.
Arguments:
confusion_matrix: np.ndarray, shape (N, ..., 3), where the last dimension
contains the true positives, false positives, and false negatives.
Returns:
np.ndarray, shape (N, ...), containing the precision for each element.
"""
if not confusion_matrix.shape[-1] == 3:
raise ValueError(
f"Confusion matrix must have shape (..., 3), got "
f"{confusion_matrix.shape}"
)
true_positives = confusion_matrix[..., 0]
false_positives = confusion_matrix[..., 1]
denominator = true_positives + false_positives
precision = np.where(denominator == 0, 0, true_positives / denominator)
return precision
def _detections_content(self, detections: Detections) -> np.ndarray:
"""Return boxes, masks or oriented bounding boxes from detections."""
if self._metric_target == MetricTarget.BOXES:
return detections.xyxy
if self._metric_target == MetricTarget.MASKS:
return (
detections.mask
if detections.mask is not None
else self._make_empty_content()
)
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
obb = detections.data.get(ORIENTED_BOX_COORDINATES)
if obb is not None and len(obb) > 0:
return np.array(obb, dtype=np.float32)
return self._make_empty_content()
raise ValueError(f"Invalid metric target: {self._metric_target}")
def _make_empty_content(self) -> np.ndarray:
if self._metric_target == MetricTarget.BOXES:
return np.empty((0, 4), dtype=np.float32)
if self._metric_target == MetricTarget.MASKS:
return np.empty((0, 0, 0), dtype=bool)
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
return np.empty((0, 4, 2), dtype=np.float32)
raise ValueError(f"Invalid metric target: {self._metric_target}")
def _filter_detections_by_size(
self, detections: Detections, size_category: ObjectSizeCategory
) -> Detections:
"""Return a copy of detections with contents filtered by object size."""
new_detections = deepcopy(detections)
if detections.is_empty() or size_category == ObjectSizeCategory.ANY:
return new_detections
sizes = get_detection_size_category(new_detections, self._metric_target)
size_mask = sizes == size_category.value
new_detections.xyxy = new_detections.xyxy[size_mask]
if new_detections.mask is not None:
new_detections.mask = new_detections.mask[size_mask]
if new_detections.class_id is not None:
new_detections.class_id = new_detections.class_id[size_mask]
if new_detections.confidence is not None:
new_detections.confidence = new_detections.confidence[size_mask]
if new_detections.tracker_id is not None:
new_detections.tracker_id = new_detections.tracker_id[size_mask]
if new_detections.data is not None:
for key, value in new_detections.data.items():
new_detections.data[key] = np.array(value)[size_mask]
return new_detections
def _filter_predictions_and_targets_by_size(
self,
predictions_list: List[Detections],
targets_list: List[Detections],
size_category: ObjectSizeCategory,
) -> Tuple[List[Detections], List[Detections]]:
"""
Filter predictions and targets by object size category.
"""
new_predictions_list = []
new_targets_list = []
for predictions, targets in zip(predictions_list, targets_list):
new_predictions_list.append(
self._filter_detections_by_size(predictions, size_category)
)
new_targets_list.append(
self._filter_detections_by_size(targets, size_category)
)
return new_predictions_list, new_targets_list
@dataclass
class PrecisionResult:
"""
The results of the precision metric calculation.
Defaults to `0` if no detections or targets were provided.
Attributes:
metric_target (MetricTarget): the type of data used for the metric -
boxes, masks or oriented bounding boxes.
averaging_method (AveragingMethod): the averaging method used to compute the
precision. Determines how the precision is aggregated across classes.
precision_at_50 (float): the precision at IoU threshold of `0.5`.
precision_at_75 (float): the precision at IoU threshold of `0.75`.
precision_scores (np.ndarray): the precision scores at each IoU threshold.
Shape: `(num_iou_thresholds,)`
precision_per_class (np.ndarray): the precision scores per class and
IoU threshold. Shape: `(num_target_classes, num_iou_thresholds)`
iou_thresholds (np.ndarray): the IoU thresholds used in the calculations.
matched_classes (np.ndarray): the class IDs of all matched classes.
Corresponds to the rows of `precision_per_class`.
small_objects (Optional[PrecisionResult]): the Precision metric results
for small objects.
medium_objects (Optional[PrecisionResult]): the Precision metric results
for medium objects.
large_objects (Optional[PrecisionResult]): the Precision metric results
for large objects.
"""
metric_target: MetricTarget
averaging_method: AveragingMethod
@property
def precision_at_50(self) -> float:
return self.precision_scores[0]
@property
def precision_at_75(self) -> float:
return self.precision_scores[5]
precision_scores: np.ndarray
precision_per_class: np.ndarray
iou_thresholds: np.ndarray
matched_classes: np.ndarray
small_objects: Optional[PrecisionResult]
medium_objects: Optional[PrecisionResult]
large_objects: Optional[PrecisionResult]
def __str__(self) -> str:
"""
Format as a pretty string.
Example:
```python
print(precision_result)
```
"""
out_str = (
f"{self.__class__.__name__}:\n"
f"Metric target: {self.metric_target}\n"
f"Averaging method: {self.averaging_method}\n"
f"P @ 50: {self.precision_at_50:.4f}\n"
f"P @ 75: {self.precision_at_75:.4f}\n"
f"P @ thresh: {self.precision_scores}\n"
f"IoU thresh: {self.iou_thresholds}\n"
f"Precision per class:\n"
)
if self.precision_per_class.size == 0:
out_str += " No results\n"
for class_id, precision_of_class in zip(
self.matched_classes, self.precision_per_class
):
out_str += f" {class_id}: {precision_of_class}\n"
indent = " "
if self.small_objects is not None:
indented = indent + str(self.small_objects).replace("\n", f"\n{indent}")
out_str += f"\nSmall objects:\n{indented}"
if self.medium_objects is not None:
indented = indent + str(self.medium_objects).replace("\n", f"\n{indent}")
out_str += f"\nMedium objects:\n{indented}"
if self.large_objects is not None:
indented = indent + str(self.large_objects).replace("\n", f"\n{indent}")
out_str += f"\nLarge objects:\n{indented}"
return out_str
def to_pandas(self) -> "pd.DataFrame":
"""
Convert the result to a pandas DataFrame.
Returns:
(pd.DataFrame): The result as a DataFrame.
"""
ensure_pandas_installed()
import pandas as pd
pandas_data = {
"P@50": self.precision_at_50,
"P@75": self.precision_at_75,
}
if self.small_objects is not None:
small_objects_df = self.small_objects.to_pandas()
for key, value in small_objects_df.items():
pandas_data[f"small_objects_{key}"] = value
if self.medium_objects is not None:
medium_objects_df = self.medium_objects.to_pandas()
for key, value in medium_objects_df.items():
pandas_data[f"medium_objects_{key}"] = value
if self.large_objects is not None:
large_objects_df = self.large_objects.to_pandas()
for key, value in large_objects_df.items():
pandas_data[f"large_objects_{key}"] = value
return pd.DataFrame(pandas_data, index=[0])
def plot(self):
"""
Plot the precision results.
"""
labels = ["Precision@50", "Precision@75"]
values = [self.precision_at_50, self.precision_at_75]
colors = [LEGACY_COLOR_PALETTE[0]] * 2
if self.small_objects is not None:
small_objects = self.small_objects
labels += ["Small: P@50", "Small: P@75"]
values += [small_objects.precision_at_50, small_objects.precision_at_75]
colors += [LEGACY_COLOR_PALETTE[3]] * 2
if self.medium_objects is not None:
medium_objects = self.medium_objects
labels += ["Medium: P@50", "Medium: P@75"]
values += [medium_objects.precision_at_50, medium_objects.precision_at_75]
colors += [LEGACY_COLOR_PALETTE[2]] * 2
if self.large_objects is not None:
large_objects = self.large_objects
labels += ["Large: P@50", "Large: P@75"]
values += [large_objects.precision_at_50, large_objects.precision_at_75]
colors += [LEGACY_COLOR_PALETTE[4]] * 2
plt.rcParams["font.family"] = "monospace"
_, ax = plt.subplots(figsize=(10, 6))
ax.set_ylim(0, 1)
ax.set_ylabel("Value", fontweight="bold")
title = (
f"Precision, by Object Size"
f"\n(target: {self.metric_target.value},"
f" averaging: {self.averaging_method.value})"
)
ax.set_title(title, fontweight="bold")
x_positions = range(len(labels))
bars = ax.bar(x_positions, values, color=colors, align="center")
ax.set_xticks(x_positions)
ax.set_xticklabels(labels, rotation=45, ha="right")
for bar in bars:
y_value = bar.get_height()
ax.text(
bar.get_x() + bar.get_width() / 2,
y_value + 0.02,
f"{y_value:.2f}",
ha="center",
va="bottom",
)
plt.rcParams["font.family"] = "sans-serif"
plt.tight_layout()
plt.show()
+614
View File
@@ -0,0 +1,614 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional, Tuple, Union
import numpy as np
from matplotlib import pyplot as plt
from supervision.config import ORIENTED_BOX_COORDINATES
from supervision.detection.core import Detections
from supervision.detection.utils import (
box_iou_batch,
mask_iou_batch,
oriented_box_iou_batch,
)
from supervision.draw.color import LEGACY_COLOR_PALETTE
from supervision.metrics.core import AveragingMethod, Metric, MetricTarget
from supervision.metrics.utils.object_size import (
ObjectSizeCategory,
get_detection_size_category,
)
from supervision.metrics.utils.utils import ensure_pandas_installed
if TYPE_CHECKING:
import pandas as pd
class Recall(Metric):
"""
Recall is a metric used to evaluate object detection models. It is the ratio of
true positive detections to the total number of ground truth instances. We calculate
it at different IoU thresholds.
In simple terms, Recall is a measure of a model's completeness, calculated as:
`Recall = TP / (TP + FN)`
Here, `TP` is the number of true positives (correct detections), and `FN` is the
number of false negatives (missed detections).
Example:
```python
import supervision as sv
from supervision.metrics import Recall
predictions = sv.Detections(...)
targets = sv.Detections(...)
recall_metric = Recall()
recall_result = recall_metric.update(predictions, targets).compute()
print(recall_result)
print(recall_result.recall_at_50)
print(recall_result.small_objects.recall_at_50)
```
"""
def __init__(
self,
metric_target: MetricTarget = MetricTarget.BOXES,
averaging_method: AveragingMethod = AveragingMethod.WEIGHTED,
):
"""
Initialize the Recall metric.
Args:
metric_target (MetricTarget): The type of detection data to use.
averaging_method (AveragingMethod): The averaging method used to compute the
recall. Determines how the recall is aggregated across classes.
"""
self._metric_target = metric_target
self.averaging_method = averaging_method
self._predictions_list: List[Detections] = []
self._targets_list: List[Detections] = []
def reset(self) -> None:
"""
Reset the metric to its initial state, clearing all stored data.
"""
self._predictions_list = []
self._targets_list = []
def update(
self,
predictions: Union[Detections, List[Detections]],
targets: Union[Detections, List[Detections]],
) -> Recall:
"""
Add new predictions and targets to the metric, but do not compute the result.
Args:
predictions (Union[Detections, List[Detections]]): The predicted detections.
targets (Union[Detections, List[Detections]]): The target detections.
Returns:
(Recall): The updated metric instance.
"""
if not isinstance(predictions, list):
predictions = [predictions]
if not isinstance(targets, list):
targets = [targets]
if len(predictions) != len(targets):
raise ValueError(
f"The number of predictions ({len(predictions)}) and"
f" targets ({len(targets)}) during the update must be the same."
)
self._predictions_list.extend(predictions)
self._targets_list.extend(targets)
return self
def compute(self) -> RecallResult:
"""
Calculate the precision metric based on the stored predictions and ground-truth
data, at different IoU thresholds.
Returns:
(RecallResult): The precision metric result.
"""
result = self._compute(self._predictions_list, self._targets_list)
small_predictions, small_targets = self._filter_predictions_and_targets_by_size(
self._predictions_list, self._targets_list, ObjectSizeCategory.SMALL
)
result.small_objects = self._compute(small_predictions, small_targets)
medium_predictions, medium_targets = (
self._filter_predictions_and_targets_by_size(
self._predictions_list, self._targets_list, ObjectSizeCategory.MEDIUM
)
)
result.medium_objects = self._compute(medium_predictions, medium_targets)
large_predictions, large_targets = self._filter_predictions_and_targets_by_size(
self._predictions_list, self._targets_list, ObjectSizeCategory.LARGE
)
result.large_objects = self._compute(large_predictions, large_targets)
return result
def _compute(
self, predictions_list: List[Detections], targets_list: List[Detections]
) -> RecallResult:
iou_thresholds = np.linspace(0.5, 0.95, 10)
stats = []
for predictions, targets in zip(predictions_list, targets_list):
prediction_contents = self._detections_content(predictions)
target_contents = self._detections_content(targets)
if len(targets) > 0:
if len(predictions) == 0:
stats.append(
(
np.zeros((0, iou_thresholds.size), dtype=bool),
np.zeros((0,), dtype=np.float32),
np.zeros((0,), dtype=int),
targets.class_id,
)
)
else:
if self._metric_target == MetricTarget.BOXES:
iou = box_iou_batch(target_contents, prediction_contents)
elif self._metric_target == MetricTarget.MASKS:
iou = mask_iou_batch(target_contents, prediction_contents)
elif self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
iou = oriented_box_iou_batch(
target_contents, prediction_contents
)
else:
raise ValueError(
"Unsupported metric target for IoU calculation"
)
matches = self._match_detection_batch(
predictions.class_id, targets.class_id, iou, iou_thresholds
)
stats.append(
(
matches,
predictions.confidence,
predictions.class_id,
targets.class_id,
)
)
if not stats:
return RecallResult(
metric_target=self._metric_target,
averaging_method=self.averaging_method,
recall_scores=np.zeros(iou_thresholds.shape[0]),
recall_per_class=np.zeros((0, iou_thresholds.shape[0])),
iou_thresholds=iou_thresholds,
matched_classes=np.array([], dtype=int),
small_objects=None,
medium_objects=None,
large_objects=None,
)
concatenated_stats = [np.concatenate(items, 0) for items in zip(*stats)]
recall_scores, recall_per_class, unique_classes = (
self._compute_recall_for_classes(*concatenated_stats)
)
return RecallResult(
metric_target=self._metric_target,
averaging_method=self.averaging_method,
recall_scores=recall_scores,
recall_per_class=recall_per_class,
iou_thresholds=iou_thresholds,
matched_classes=unique_classes,
small_objects=None,
medium_objects=None,
large_objects=None,
)
def _compute_recall_for_classes(
self,
matches: np.ndarray,
prediction_confidence: np.ndarray,
prediction_class_ids: np.ndarray,
true_class_ids: np.ndarray,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
sorted_indices = np.argsort(-prediction_confidence)
matches = matches[sorted_indices]
prediction_class_ids = prediction_class_ids[sorted_indices]
unique_classes, class_counts = np.unique(true_class_ids, return_counts=True)
# Shape: PxTh,P,C,C -> CxThx3
confusion_matrix = self._compute_confusion_matrix(
matches, prediction_class_ids, unique_classes, class_counts
)
# Shape: CxThx3 -> CxTh
recall_per_class = self._compute_recall(confusion_matrix)
# Shape: CxTh -> Th
if self.averaging_method == AveragingMethod.MACRO:
recall_scores = np.mean(recall_per_class, axis=0)
elif self.averaging_method == AveragingMethod.MICRO:
confusion_matrix_merged = confusion_matrix.sum(0)
recall_scores = self._compute_recall(confusion_matrix_merged)
elif self.averaging_method == AveragingMethod.WEIGHTED:
class_counts = class_counts.astype(np.float32)
recall_scores = np.average(recall_per_class, axis=0, weights=class_counts)
return recall_scores, recall_per_class, unique_classes
@staticmethod
def _match_detection_batch(
predictions_classes: np.ndarray,
target_classes: np.ndarray,
iou: np.ndarray,
iou_thresholds: np.ndarray,
) -> np.ndarray:
num_predictions, num_iou_levels = (
predictions_classes.shape[0],
iou_thresholds.shape[0],
)
correct = np.zeros((num_predictions, num_iou_levels), dtype=bool)
correct_class = target_classes[:, None] == predictions_classes
for i, iou_level in enumerate(iou_thresholds):
matched_indices = np.where((iou >= iou_level) & correct_class)
if matched_indices[0].shape[0]:
combined_indices = np.stack(matched_indices, axis=1)
iou_values = iou[matched_indices][:, None]
matches = np.hstack([combined_indices, iou_values])
if matched_indices[0].shape[0] > 1:
matches = matches[matches[:, 2].argsort()[::-1]]
matches = matches[np.unique(matches[:, 1], return_index=True)[1]]
matches = matches[np.unique(matches[:, 0], return_index=True)[1]]
correct[matches[:, 1].astype(int), i] = True
return correct
@staticmethod
def _compute_confusion_matrix(
sorted_matches: np.ndarray,
sorted_prediction_class_ids: np.ndarray,
unique_classes: np.ndarray,
class_counts: np.ndarray,
) -> np.ndarray:
"""
Compute the confusion matrix for each class and IoU threshold.
Assumes the matches and prediction_class_ids are sorted by confidence
in descending order.
Arguments:
sorted_matches: np.ndarray, bool, shape (P, Th), that is True
if the prediction is a true positive at the given IoU threshold.
sorted_prediction_class_ids: np.ndarray, int, shape (P,), containing
the class id for each prediction.
unique_classes: np.ndarray, int, shape (C,), containing the unique
class ids.
class_counts: np.ndarray, int, shape (C,), containing the number
of true instances for each class.
Returns:
np.ndarray, shape (C, Th, 3), containing the true positives, false
positives, and false negatives for each class and IoU threshold.
"""
num_thresholds = sorted_matches.shape[1]
num_classes = unique_classes.shape[0]
confusion_matrix = np.zeros((num_classes, num_thresholds, 3))
for class_idx, class_id in enumerate(unique_classes):
is_class = sorted_prediction_class_ids == class_id
num_true = class_counts[class_idx]
num_predictions = is_class.sum()
if num_predictions == 0:
true_positives = np.zeros(num_thresholds)
false_positives = np.zeros(num_thresholds)
false_negatives = np.full(num_thresholds, num_true)
elif num_true == 0:
true_positives = np.zeros(num_thresholds)
false_positives = np.full(num_thresholds, num_predictions)
false_negatives = np.zeros(num_thresholds)
else:
true_positives = sorted_matches[is_class].sum(0)
false_positives = (1 - sorted_matches[is_class]).sum(0)
false_negatives = num_true - true_positives
confusion_matrix[class_idx] = np.stack(
[true_positives, false_positives, false_negatives], axis=1
)
return confusion_matrix
@staticmethod
def _compute_recall(confusion_matrix: np.ndarray) -> np.ndarray:
"""
Broadcastable function, computing the recall from the confusion matrix.
Arguments:
confusion_matrix: np.ndarray, shape (N, ..., 3), where the last dimension
contains the true positives, false positives, and false negatives.
Returns:
np.ndarray, shape (N, ...), containing the recall for each element.
"""
if not confusion_matrix.shape[-1] == 3:
raise ValueError(
f"Confusion matrix must have shape (..., 3), got "
f"{confusion_matrix.shape}"
)
true_positives = confusion_matrix[..., 0]
false_negatives = confusion_matrix[..., 2]
denominator = true_positives + false_negatives
recall = np.where(denominator == 0, 0, true_positives / denominator)
return recall
def _detections_content(self, detections: Detections) -> np.ndarray:
"""Return boxes, masks or oriented bounding boxes from detections."""
if self._metric_target == MetricTarget.BOXES:
return detections.xyxy
if self._metric_target == MetricTarget.MASKS:
return (
detections.mask
if detections.mask is not None
else self._make_empty_content()
)
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
obb = detections.data.get(ORIENTED_BOX_COORDINATES)
if obb is not None and len(obb) > 0:
return np.array(obb, dtype=np.float32)
return self._make_empty_content()
raise ValueError(f"Invalid metric target: {self._metric_target}")
def _make_empty_content(self) -> np.ndarray:
if self._metric_target == MetricTarget.BOXES:
return np.empty((0, 4), dtype=np.float32)
if self._metric_target == MetricTarget.MASKS:
return np.empty((0, 0, 0), dtype=bool)
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
return np.empty((0, 4, 2), dtype=np.float32)
raise ValueError(f"Invalid metric target: {self._metric_target}")
def _filter_detections_by_size(
self, detections: Detections, size_category: ObjectSizeCategory
) -> Detections:
"""Return a copy of detections with contents filtered by object size."""
new_detections = deepcopy(detections)
if detections.is_empty() or size_category == ObjectSizeCategory.ANY:
return new_detections
sizes = get_detection_size_category(new_detections, self._metric_target)
size_mask = sizes == size_category.value
new_detections.xyxy = new_detections.xyxy[size_mask]
if new_detections.mask is not None:
new_detections.mask = new_detections.mask[size_mask]
if new_detections.class_id is not None:
new_detections.class_id = new_detections.class_id[size_mask]
if new_detections.confidence is not None:
new_detections.confidence = new_detections.confidence[size_mask]
if new_detections.tracker_id is not None:
new_detections.tracker_id = new_detections.tracker_id[size_mask]
if new_detections.data is not None:
for key, value in new_detections.data.items():
new_detections.data[key] = np.array(value)[size_mask]
return new_detections
def _filter_predictions_and_targets_by_size(
self,
predictions_list: List[Detections],
targets_list: List[Detections],
size_category: ObjectSizeCategory,
) -> Tuple[List[Detections], List[Detections]]:
"""
Filter predictions and targets by object size category.
"""
new_predictions_list = []
new_targets_list = []
for predictions, targets in zip(predictions_list, targets_list):
new_predictions_list.append(
self._filter_detections_by_size(predictions, size_category)
)
new_targets_list.append(
self._filter_detections_by_size(targets, size_category)
)
return new_predictions_list, new_targets_list
@dataclass
class RecallResult:
"""
The results of the recall metric calculation.
Defaults to `0` if no detections or targets were provided.
Attributes:
metric_target (MetricTarget): the type of data used for the metric -
boxes, masks or oriented bounding boxes.
averaging_method (AveragingMethod): the averaging method used to compute the
recall. Determines how the recall is aggregated across classes.
recall_at_50 (float): the recall at IoU threshold of `0.5`.
recall_at_75 (float): the recall at IoU threshold of `0.75`.
recall_scores (np.ndarray): the recall scores at each IoU threshold.
Shape: `(num_iou_thresholds,)`
recall_per_class (np.ndarray): the recall scores per class and IoU threshold.
Shape: `(num_target_classes, num_iou_thresholds)`
iou_thresholds (np.ndarray): the IoU thresholds used in the calculations.
matched_classes (np.ndarray): the class IDs of all matched classes.
Corresponds to the rows of `recall_per_class`.
small_objects (Optional[RecallResult]): the Recall metric results
for small objects.
medium_objects (Optional[RecallResult]): the Recall metric results
for medium objects.
large_objects (Optional[RecallResult]): the Recall metric results
for large objects.
"""
metric_target: MetricTarget
averaging_method: AveragingMethod
@property
def recall_at_50(self) -> float:
return self.recall_scores[0]
@property
def recall_at_75(self) -> float:
return self.recall_scores[5]
recall_scores: np.ndarray
recall_per_class: np.ndarray
iou_thresholds: np.ndarray
matched_classes: np.ndarray
small_objects: Optional[RecallResult]
medium_objects: Optional[RecallResult]
large_objects: Optional[RecallResult]
def __str__(self) -> str:
"""
Format as a pretty string.
Example:
```python
print(recall_result)
```
"""
out_str = (
f"{self.__class__.__name__}:\n"
f"Metric target: {self.metric_target}\n"
f"Averaging method: {self.averaging_method}\n"
f"R @ 50: {self.recall_at_50:.4f}\n"
f"R @ 75: {self.recall_at_75:.4f}\n"
f"R @ thresh: {self.recall_scores}\n"
f"IoU thresh: {self.iou_thresholds}\n"
f"Recall per class:\n"
)
if self.recall_per_class.size == 0:
out_str += " No results\n"
for class_id, recall_of_class in zip(
self.matched_classes, self.recall_per_class
):
out_str += f" {class_id}: {recall_of_class}\n"
indent = " "
if self.small_objects is not None:
indented = indent + str(self.small_objects).replace("\n", f"\n{indent}")
out_str += f"\nSmall objects:\n{indented}"
if self.medium_objects is not None:
indented = indent + str(self.medium_objects).replace("\n", f"\n{indent}")
out_str += f"\nMedium objects:\n{indented}"
if self.large_objects is not None:
indented = indent + str(self.large_objects).replace("\n", f"\n{indent}")
out_str += f"\nLarge objects:\n{indented}"
return out_str
def to_pandas(self) -> "pd.DataFrame":
"""
Convert the result to a pandas DataFrame.
Returns:
(pd.DataFrame): The result as a DataFrame.
"""
ensure_pandas_installed()
import pandas as pd
pandas_data = {
"R@50": self.recall_at_50,
"R@75": self.recall_at_75,
}
if self.small_objects is not None:
small_objects_df = self.small_objects.to_pandas()
for key, value in small_objects_df.items():
pandas_data[f"small_objects_{key}"] = value
if self.medium_objects is not None:
medium_objects_df = self.medium_objects.to_pandas()
for key, value in medium_objects_df.items():
pandas_data[f"medium_objects_{key}"] = value
if self.large_objects is not None:
large_objects_df = self.large_objects.to_pandas()
for key, value in large_objects_df.items():
pandas_data[f"large_objects_{key}"] = value
return pd.DataFrame(pandas_data, index=[0])
def plot(self):
"""
Plot the recall results.
"""
labels = ["Recall@50", "Recall@75"]
values = [self.recall_at_50, self.recall_at_75]
colors = [LEGACY_COLOR_PALETTE[0]] * 2
if self.small_objects is not None:
small_objects = self.small_objects
labels += ["Small: R@50", "Small: R@75"]
values += [small_objects.recall_at_50, small_objects.recall_at_75]
colors += [LEGACY_COLOR_PALETTE[3]] * 2
if self.medium_objects is not None:
medium_objects = self.medium_objects
labels += ["Medium: R@50", "Medium: R@75"]
values += [medium_objects.recall_at_50, medium_objects.recall_at_75]
colors += [LEGACY_COLOR_PALETTE[2]] * 2
if self.large_objects is not None:
large_objects = self.large_objects
labels += ["Large: R@50", "Large: R@75"]
values += [large_objects.recall_at_50, large_objects.recall_at_75]
colors += [LEGACY_COLOR_PALETTE[4]] * 2
plt.rcParams["font.family"] = "monospace"
_, ax = plt.subplots(figsize=(10, 6))
ax.set_ylim(0, 1)
ax.set_ylabel("Value", fontweight="bold")
title = (
f"Recall, by Object Size"
f"\n(target: {self.metric_target.value},"
f" averaging: {self.averaging_method.value})"
)
ax.set_title(title, fontweight="bold")
x_positions = range(len(labels))
bars = ax.bar(x_positions, values, color=colors, align="center")
ax.set_xticks(x_positions)
ax.set_xticklabels(labels, rotation=45, ha="right")
for bar in bars:
y_value = bar.get_height()
ax.text(
bar.get_x() + bar.get_width() / 2,
y_value + 0.02,
f"{y_value:.2f}",
ha="center",
va="bottom",
)
plt.rcParams["font.family"] = "sans-serif"
plt.tight_layout()
plt.show()
View File
@@ -1,63 +0,0 @@
from collections import OrderedDict
from enum import Enum
import numpy as np
class TrackState(Enum):
New = 0
Tracked = 1
Lost = 2
Removed = 3
class BaseTrack:
_count = 0
def __init__(self):
self.track_id = 0
self.is_activated = False
self.state = TrackState.New
self.history = OrderedDict()
self.features = []
self.curr_feature = None
self.score = 0
self.start_frame = 0
self.frame_id = 0
self.time_since_update = 0
# multi-camera
self.location = (np.inf, np.inf)
@property
def end_frame(self) -> int:
return self.frame_id
@staticmethod
def next_id() -> int:
BaseTrack._count += 1
return BaseTrack._count
@staticmethod
def reset_counter():
BaseTrack._count = 0
BaseTrack.track_id = 0
BaseTrack.start_frame = 0
BaseTrack.frame_id = 0
BaseTrack.time_since_update = 0
def activate(self, *args):
raise NotImplementedError
def predict(self):
raise NotImplementedError
def update(self, *args, **kwargs):
raise NotImplementedError
def mark_lost(self):
self.state = TrackState.Lost
def mark_removed(self):
self.state = TrackState.Removed
+49 -204
View File
@@ -5,186 +5,9 @@ import numpy as np
from supervision.detection.core import Detections
from supervision.detection.utils import box_iou_batch
from supervision.tracker.byte_tracker import matching
from supervision.tracker.byte_tracker.basetrack import BaseTrack, TrackState
from supervision.tracker.byte_tracker.kalman_filter import KalmanFilter
class STrack(BaseTrack):
shared_kalman = KalmanFilter()
_external_count = 0
def __init__(self, tlwh, score, class_ids, minimum_consecutive_frames):
# wait activate
self._tlwh = np.asarray(tlwh, dtype=np.float32)
self.kalman_filter = None
self.mean, self.covariance = None, None
self.is_activated = False
self.score = score
self.class_ids = class_ids
self.tracklet_len = 0
self.external_track_id = -1
self.minimum_consecutive_frames = minimum_consecutive_frames
def predict(self):
mean_state = self.mean.copy()
if self.state != TrackState.Tracked:
mean_state[7] = 0
self.mean, self.covariance = self.kalman_filter.predict(
mean_state, self.covariance
)
@staticmethod
def multi_predict(stracks):
if len(stracks) > 0:
multi_mean = []
multi_covariance = []
for i, st in enumerate(stracks):
multi_mean.append(st.mean.copy())
multi_covariance.append(st.covariance)
if st.state != TrackState.Tracked:
multi_mean[i][7] = 0
multi_mean, multi_covariance = STrack.shared_kalman.multi_predict(
np.asarray(multi_mean), np.asarray(multi_covariance)
)
for i, (mean, cov) in enumerate(zip(multi_mean, multi_covariance)):
stracks[i].mean = mean
stracks[i].covariance = cov
def activate(self, kalman_filter, frame_id):
"""Start a new tracklet"""
self.kalman_filter = kalman_filter
self.internal_track_id = self.next_id()
self.mean, self.covariance = self.kalman_filter.initiate(
self.tlwh_to_xyah(self._tlwh)
)
self.tracklet_len = 0
self.state = TrackState.Tracked
if frame_id == 1:
self.is_activated = True
if self.minimum_consecutive_frames == 1:
self.external_track_id = self.next_external_id()
self.frame_id = frame_id
self.start_frame = frame_id
def re_activate(self, new_track, frame_id, new_id=False):
self.mean, self.covariance = self.kalman_filter.update(
self.mean, self.covariance, self.tlwh_to_xyah(new_track.tlwh)
)
self.tracklet_len = 0
self.state = TrackState.Tracked
self.frame_id = frame_id
if new_id:
self.internal_track_id = self.next_id()
self.score = new_track.score
def update(self, new_track, frame_id):
"""
Update a matched track
:type new_track: STrack
:type frame_id: int
:type update_feature: bool
:return:
"""
self.frame_id = frame_id
self.tracklet_len += 1
new_tlwh = new_track.tlwh
self.mean, self.covariance = self.kalman_filter.update(
self.mean, self.covariance, self.tlwh_to_xyah(new_tlwh)
)
self.state = TrackState.Tracked
if self.tracklet_len == self.minimum_consecutive_frames:
self.is_activated = True
if self.external_track_id == -1:
self.external_track_id = self.next_external_id()
self.score = new_track.score
@property
def tlwh(self):
"""Get current position in bounding box format `(top left x, top left y,
width, height)`.
"""
if self.mean is None:
return self._tlwh.copy()
ret = self.mean[:4].copy()
ret[2] *= ret[3]
ret[:2] -= ret[2:] / 2
return ret
@property
def tlbr(self):
"""Convert bounding box to format `(min x, min y, max x, max y)`, i.e.,
`(top left, bottom right)`.
"""
ret = self.tlwh.copy()
ret[2:] += ret[:2]
return ret
@staticmethod
def tlwh_to_xyah(tlwh):
"""Convert bounding box to format `(center x, center y, aspect ratio,
height)`, where the aspect ratio is `width / height`.
"""
ret = np.asarray(tlwh).copy()
ret[:2] += ret[2:] / 2
ret[2] /= ret[3]
return ret
def to_xyah(self):
return self.tlwh_to_xyah(self.tlwh)
@staticmethod
def next_external_id():
STrack._external_count += 1
return STrack._external_count
@staticmethod
def reset_external_counter():
STrack._external_count = 0
@staticmethod
def tlbr_to_tlwh(tlbr):
ret = np.asarray(tlbr).copy()
ret[2:] -= ret[:2]
return ret
@staticmethod
def tlwh_to_tlbr(tlwh):
ret = np.asarray(tlwh).copy()
ret[2:] += ret[:2]
return ret
def __repr__(self):
return "OT_{}_({}-{})".format(
self.internal_track_id, self.start_frame, self.end_frame
)
def detections2boxes(detections: Detections) -> np.ndarray:
"""
Convert Supervision Detections to numpy tensors for further computation.
Args:
detections (Detections): Detections/Targets in the format of sv.Detections.
Returns:
(np.ndarray): Detections as numpy tensors as in
`(x_min, y_min, x_max, y_max, confidence, class_id)` order.
"""
return np.hstack(
(
detections.xyxy,
detections.confidence[:, np.newaxis],
detections.class_id[:, np.newaxis],
)
)
from supervision.tracker.byte_tracker.single_object_track import STrack, TrackState
from supervision.tracker.byte_tracker.utils import IdCounter
class ByteTrack:
@@ -230,11 +53,17 @@ class ByteTrack:
self.max_time_lost = int(frame_rate / 30.0 * lost_track_buffer)
self.minimum_consecutive_frames = minimum_consecutive_frames
self.kalman_filter = KalmanFilter()
self.shared_kalman = KalmanFilter()
self.tracked_tracks: List[STrack] = []
self.lost_tracks: List[STrack] = []
self.removed_tracks: List[STrack] = []
# Warning, possible bug: If you also set internal_id to start at 1,
# all traces will be connected across objects.
self.internal_id_counter = IdCounter()
self.external_id_counter = IdCounter(start_id=1)
def update_with_detections(self, detections: Detections) -> Detections:
"""
Updates the tracker with the provided detections and returns the updated
@@ -274,8 +103,12 @@ class ByteTrack:
)
```
"""
tensors = detections2boxes(detections=detections)
tensors = np.hstack(
(
detections.xyxy,
detections.confidence[:, np.newaxis],
)
)
tracks = self.update_with_tensors(tensors=tensors)
if len(tracks) > 0:
@@ -301,7 +134,7 @@ class ByteTrack:
return detections
def reset(self):
def reset(self) -> None:
"""
Resets the internal state of the ByteTrack tracker.
@@ -311,11 +144,11 @@ class ByteTrack:
ensuring the tracker starts with a clean state for each new video.
"""
self.frame_id = 0
self.tracked_tracks: List[STrack] = []
self.lost_tracks: List[STrack] = []
self.removed_tracks: List[STrack] = []
BaseTrack.reset_counter()
STrack.reset_external_counter()
self.internal_id_counter.reset()
self.external_id_counter.reset()
self.tracked_tracks = []
self.lost_tracks = []
self.removed_tracks = []
def update_with_tensors(self, tensors: np.ndarray) -> List[STrack]:
"""
@@ -333,7 +166,6 @@ class ByteTrack:
lost_stracks = []
removed_stracks = []
class_ids = tensors[:, 5]
scores = tensors[:, 4]
bboxes = tensors[:, :4]
@@ -347,14 +179,18 @@ class ByteTrack:
scores_keep = scores[remain_inds]
scores_second = scores[inds_second]
class_ids_keep = class_ids[remain_inds]
class_ids_second = class_ids[inds_second]
if len(dets) > 0:
"""Detections"""
detections = [
STrack(STrack.tlbr_to_tlwh(tlbr), s, c, self.minimum_consecutive_frames)
for (tlbr, s, c) in zip(dets, scores_keep, class_ids_keep)
STrack(
STrack.tlbr_to_tlwh(tlbr),
score_keep,
self.minimum_consecutive_frames,
self.shared_kalman,
self.internal_id_counter,
self.external_id_counter,
)
for (tlbr, score_keep) in zip(dets, scores_keep)
]
else:
detections = []
@@ -372,7 +208,7 @@ class ByteTrack:
""" Step 2: First association, with high score detection boxes"""
strack_pool = joint_tracks(tracked_stracks, self.lost_tracks)
# Predict the current location with KF
STrack.multi_predict(strack_pool)
STrack.multi_predict(strack_pool, self.shared_kalman)
dists = matching.iou_distance(strack_pool, detections)
dists = matching.fuse_score(dists, detections)
@@ -387,7 +223,7 @@ class ByteTrack:
track.update(detections[idet], self.frame_id)
activated_starcks.append(track)
else:
track.re_activate(det, self.frame_id, new_id=False)
track.re_activate(det, self.frame_id)
refind_stracks.append(track)
""" Step 3: Second association, with low score detection boxes"""
@@ -395,8 +231,15 @@ class ByteTrack:
if len(dets_second) > 0:
"""Detections"""
detections_second = [
STrack(STrack.tlbr_to_tlwh(tlbr), s, c, self.minimum_consecutive_frames)
for (tlbr, s, c) in zip(dets_second, scores_second, class_ids_second)
STrack(
STrack.tlbr_to_tlwh(tlbr),
score_second,
self.minimum_consecutive_frames,
self.shared_kalman,
self.internal_id_counter,
self.external_id_counter,
)
for (tlbr, score_second) in zip(dets_second, scores_second)
]
else:
detections_second = []
@@ -416,13 +259,13 @@ class ByteTrack:
track.update(det, self.frame_id)
activated_starcks.append(track)
else:
track.re_activate(det, self.frame_id, new_id=False)
track.re_activate(det, self.frame_id)
refind_stracks.append(track)
for it in u_track:
track = r_tracked_stracks[it]
if not track.state == TrackState.Lost:
track.mark_lost()
track.state = TrackState.Lost
lost_stracks.append(track)
"""Deal with unconfirmed tracks, usually tracks with only one beginning frame"""
@@ -438,7 +281,7 @@ class ByteTrack:
activated_starcks.append(unconfirmed[itracked])
for it in u_unconfirmed:
track = unconfirmed[it]
track.mark_removed()
track.state = TrackState.Removed
removed_stracks.append(track)
""" Step 4: Init new stracks"""
@@ -450,8 +293,8 @@ class ByteTrack:
activated_starcks.append(track)
""" Step 5: Update state"""
for track in self.lost_tracks:
if self.frame_id - track.end_frame > self.max_time_lost:
track.mark_removed()
if self.frame_id - track.frame_id > self.max_time_lost:
track.state = TrackState.Removed
removed_stracks.append(track)
self.tracked_tracks = [
@@ -497,7 +340,7 @@ def joint_tracks(
return result
def sub_tracks(track_list_a: List, track_list_b: List) -> List[int]:
def sub_tracks(track_list_a: List[STrack], track_list_b: List[STrack]) -> List[int]:
"""
Returns a list of tracks from track_list_a after removing any tracks
that share the same internal_track_id with tracks in track_list_b.
@@ -518,7 +361,9 @@ def sub_tracks(track_list_a: List, track_list_b: List) -> List[int]:
return list(tracks.values())
def remove_duplicate_tracks(tracks_a: List, tracks_b: List) -> Tuple[List, List]:
def remove_duplicate_tracks(
tracks_a: List[STrack], tracks_b: List[STrack]
) -> Tuple[List[STrack], List[STrack]]:
pairwise_distance = matching.iou_distance(tracks_a, tracks_b)
matching_pairs = np.where(pairwise_distance < 0.15)
+10 -5
View File
@@ -1,10 +1,15 @@
from typing import List, Tuple
from __future__ import annotations
from typing import TYPE_CHECKING, List, Tuple
import numpy as np
from scipy.optimize import linear_sum_assignment
from supervision.detection.utils import box_iou_batch
if TYPE_CHECKING:
from supervision.tracker.byte_tracker.core import STrack
def indices_to_matches(
cost_matrix: np.ndarray, indices: np.ndarray, thresh: float
@@ -20,7 +25,7 @@ def indices_to_matches(
def linear_assignment(
cost_matrix: np.ndarray, thresh: float
) -> [np.ndarray, Tuple[int], Tuple[int, int]]:
) -> Tuple[np.ndarray, Tuple[int], Tuple[int, int]]:
if cost_matrix.size == 0:
return (
np.empty((0, 2), dtype=int),
@@ -35,7 +40,7 @@ def linear_assignment(
return indices_to_matches(cost_matrix, indices, thresh)
def iou_distance(atracks: List, btracks: List) -> np.ndarray:
def iou_distance(atracks: List[STrack], btracks: List[STrack]) -> np.ndarray:
if (len(atracks) > 0 and isinstance(atracks[0], np.ndarray)) or (
len(btracks) > 0 and isinstance(btracks[0], np.ndarray)
):
@@ -53,11 +58,11 @@ def iou_distance(atracks: List, btracks: List) -> np.ndarray:
return cost_matrix
def fuse_score(cost_matrix: np.ndarray, detections: List) -> np.ndarray:
def fuse_score(cost_matrix: np.ndarray, stracks: List[STrack]) -> np.ndarray:
if cost_matrix.size == 0:
return cost_matrix
iou_sim = 1 - cost_matrix
det_scores = np.array([det.score for det in detections])
det_scores = np.array([strack.score for strack in stracks])
det_scores = np.expand_dims(det_scores, axis=0).repeat(cost_matrix.shape[0], axis=0)
fuse_sim = iou_sim * det_scores
fuse_cost = 1 - fuse_sim
@@ -0,0 +1,178 @@
from __future__ import annotations
from enum import Enum
from typing import List
import numpy as np
import numpy.typing as npt
from supervision.tracker.byte_tracker.kalman_filter import KalmanFilter
from supervision.tracker.byte_tracker.utils import IdCounter
class TrackState(Enum):
New = 0
Tracked = 1
Lost = 2
Removed = 3
class STrack:
def __init__(
self,
tlwh: npt.NDArray[np.float32],
score: npt.NDArray[np.float32],
minimum_consecutive_frames: int,
shared_kalman: KalmanFilter,
internal_id_counter: IdCounter,
external_id_counter: IdCounter,
):
self.state = TrackState.New
self.is_activated = False
self.start_frame = 0
self.frame_id = 0
self._tlwh = np.asarray(tlwh, dtype=np.float32)
self.kalman_filter = None
self.shared_kalman = shared_kalman
self.mean, self.covariance = None, None
self.is_activated = False
self.score = score
self.tracklet_len = 0
self.minimum_consecutive_frames = minimum_consecutive_frames
self.internal_id_counter = internal_id_counter
self.external_id_counter = external_id_counter
self.internal_track_id = self.internal_id_counter.NO_ID
self.external_track_id = self.external_id_counter.NO_ID
def predict(self) -> None:
mean_state = self.mean.copy()
if self.state != TrackState.Tracked:
mean_state[7] = 0
self.mean, self.covariance = self.kalman_filter.predict(
mean_state, self.covariance
)
@staticmethod
def multi_predict(stracks: List[STrack], shared_kalman: KalmanFilter) -> None:
if len(stracks) > 0:
multi_mean = []
multi_covariance = []
for i, st in enumerate(stracks):
multi_mean.append(st.mean.copy())
multi_covariance.append(st.covariance)
if st.state != TrackState.Tracked:
multi_mean[i][7] = 0
multi_mean, multi_covariance = shared_kalman.multi_predict(
np.asarray(multi_mean), np.asarray(multi_covariance)
)
for i, (mean, cov) in enumerate(zip(multi_mean, multi_covariance)):
stracks[i].mean = mean
stracks[i].covariance = cov
def activate(self, kalman_filter: KalmanFilter, frame_id: int) -> None:
"""Start a new tracklet"""
self.kalman_filter = kalman_filter
self.internal_track_id = self.internal_id_counter.new_id()
self.mean, self.covariance = self.kalman_filter.initiate(
self.tlwh_to_xyah(self._tlwh)
)
self.tracklet_len = 0
self.state = TrackState.Tracked
if frame_id == 1:
self.is_activated = True
if self.minimum_consecutive_frames == 1:
self.external_track_id = self.external_id_counter.new_id()
self.frame_id = frame_id
self.start_frame = frame_id
def re_activate(self, new_track: STrack, frame_id: int) -> None:
self.mean, self.covariance = self.kalman_filter.update(
self.mean, self.covariance, self.tlwh_to_xyah(new_track.tlwh)
)
self.tracklet_len = 0
self.state = TrackState.Tracked
self.frame_id = frame_id
self.score = new_track.score
def update(self, new_track: STrack, frame_id: int) -> None:
"""
Update a matched track
:type new_track: STrack
:type frame_id: int
:type update_feature: bool
:return:
"""
self.frame_id = frame_id
self.tracklet_len += 1
new_tlwh = new_track.tlwh
self.mean, self.covariance = self.kalman_filter.update(
self.mean, self.covariance, self.tlwh_to_xyah(new_tlwh)
)
self.state = TrackState.Tracked
if self.tracklet_len == self.minimum_consecutive_frames:
self.is_activated = True
if self.external_track_id == self.external_id_counter.NO_ID:
self.external_track_id = self.external_id_counter.new_id()
self.score = new_track.score
@property
def tlwh(self) -> npt.NDArray[np.float32]:
"""Get current position in bounding box format `(top left x, top left y,
width, height)`.
"""
if self.mean is None:
return self._tlwh.copy()
ret = self.mean[:4].copy()
ret[2] *= ret[3]
ret[:2] -= ret[2:] / 2
return ret
@property
def tlbr(self) -> npt.NDArray[np.float32]:
"""Convert bounding box to format `(min x, min y, max x, max y)`, i.e.,
`(top left, bottom right)`.
"""
ret = self.tlwh.copy()
ret[2:] += ret[:2]
return ret
@staticmethod
def tlwh_to_xyah(tlwh) -> npt.NDArray[np.float32]:
"""Convert bounding box to format `(center x, center y, aspect ratio,
height)`, where the aspect ratio is `width / height`.
"""
ret = np.asarray(tlwh).copy()
ret[:2] += ret[2:] / 2
ret[2] /= ret[3]
return ret
def to_xyah(self) -> npt.NDArray[np.float32]:
return self.tlwh_to_xyah(self.tlwh)
@staticmethod
def tlbr_to_tlwh(tlbr) -> npt.NDArray[np.float32]:
ret = np.asarray(tlbr).copy()
ret[2:] -= ret[:2]
return ret
@staticmethod
def tlwh_to_tlbr(tlwh) -> npt.NDArray[np.float32]:
ret = np.asarray(tlwh).copy()
ret[2:] += ret[:2]
return ret
def __repr__(self) -> str:
return "OT_{}_({}-{})".format(
self.internal_track_id, self.start_frame, self.frame_id
)
+18
View File
@@ -0,0 +1,18 @@
class IdCounter:
def __init__(self, start_id: int = 0):
self.start_id = start_id
if self.start_id <= self.NO_ID:
raise ValueError(f"start_id must be greater than {self.NO_ID}")
self.reset()
def reset(self) -> None:
self._id = self.start_id
def new_id(self) -> int:
returned_id = self._id
self._id += 1
return returned_id
@property
def NO_ID(self) -> int:
return -1
+72
View File
@@ -0,0 +1,72 @@
from typing import Iterable, Optional
import numpy as np
from supervision.detection.core import Detections
from supervision.keypoint.core import KeyPoints
def keypoints_to_detections(
keypoints: KeyPoints, selected_keypoint_indices: Optional[Iterable[int]] = None
) -> Detections:
"""
Convert a KeyPoints object to a Detections object. This
approximates the bounding box of the detected object by
taking the bounding box that fits all keypoints.
Arguments:
keypoints (KeyPoints): The keypoints to convert to detections.
selected_keypoint_indices (Optional[Iterable[int]]): The
indices of the keypoints to include in the bounding box
calculation. This helps focus on a subset of keypoints,
e.g. when some are occluded. Captures all keypoints by default.
Returns:
detections (Detections): The converted detections object.
Example:
```python
keypoints = sv.KeyPoints.from_inference(...)
detections = keypoints_to_detections(keypoints)
```
"""
if keypoints.is_empty():
return Detections.empty()
detections_list = []
for i, xy in enumerate(keypoints.xy):
if selected_keypoint_indices:
xy = xy[selected_keypoint_indices]
# [0, 0] used by some frameworks to indicate missing keypoints
xy = xy[~np.all(xy == 0, axis=1)]
if len(xy) == 0:
xyxy = np.array([[0, 0, 0, 0]], dtype=np.float32)
else:
x_min = xy[:, 0].min()
x_max = xy[:, 0].max()
y_min = xy[:, 1].min()
y_max = xy[:, 1].max()
xyxy = np.array([[x_min, y_min, x_max, y_max]], dtype=np.float32)
if keypoints.confidence is None:
confidence = None
else:
confidence = keypoints.confidence[i]
if selected_keypoint_indices:
confidence = confidence[selected_keypoint_indices]
confidence = np.array([confidence.mean()], dtype=np.float32)
detections_list.append(
Detections(
xyxy=xyxy,
confidence=confidence,
)
)
detections = Detections.merge(detections_list)
detections.class_id = keypoints.class_id
detections.data = keypoints.data
detections = detections[detections.area > 0]
return detections
+300 -1
View File
@@ -69,7 +69,7 @@ def test_calculate_region_of_interest_limits(
exception: Exception,
) -> None:
with exception:
result = LineZone.calculate_region_of_interest_limits(vector=vector)
result = LineZone._calculate_region_of_interest_limits(vector=vector)
assert result == expected_result
@@ -303,6 +303,19 @@ def test_line_zone_one_detection_default_anchors(
[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],
[-2, 4 - 10, 2, 6 - 10],
],
[Position.CENTER],
[False, True, False, True, False],
[False, False, True, False, False],
),
( # Scrape line, right side, corner anchors
Vector(Point(0, 0), Point(10, 0)),
[
@@ -477,3 +490,289 @@ def test_line_zone_multiple_detections(
assert crossed_in_list == expected_crossed_in
assert crossed_out_list == expected_crossed_out
@pytest.mark.parametrize(
"vector, xyxy_sequence, triggering_anchors, crossing_acceptance_threshold, "
"expected_crossed_in, expected_crossed_out",
[
( # Detection lingers around line, all crosses counted
Vector(Point(0, 0), Point(10, 0)),
[
[2, 4, 3, 6],
[2, 4 - 10, 3, 6],
[2, 4, 3, 6],
[2, 4 - 10, 3, 6],
[2, 4 - 10, 3, 6],
],
[
Position.TOP_LEFT,
],
1,
[False, True, False, True, False],
[False, False, True, False, False],
),
( # Detection lingers around line, only final cross counted
Vector(Point(0, 0), Point(10, 0)),
[
[2, 4, 3, 6],
[2, 4 - 10, 3, 6],
[2, 4, 3, 6],
[2, 4 - 10, 3, 6],
[2, 4 - 10, 3, 6],
],
[
Position.TOP_LEFT,
],
2,
[False, False, False, False, True],
[False, False, False, False, False],
),
( # Detection lingers around line for a long time
Vector(Point(0, 0), Point(10, 0)),
[
[2, 4, 3, 6],
[2, 4 - 10, 3, 6],
[2, 4, 3, 6],
[2, 4 - 10, 3, 6],
[2, 4, 3, 6],
[2, 4 - 10, 3, 6],
[2, 4, 3, 6],
[2, 4 - 10, 3, 6],
[2, 4, 3, 6],
[2, 4 - 10, 3, 6],
[2, 4, 3, 6],
[2, 4 - 10, 3, 6],
[2, 4 - 10, 3, 6],
],
[
Position.TOP_LEFT,
],
2,
[False] * 12 + [True],
[False] * 13,
),
( # Detection lingers around line, longer cycle
Vector(Point(0, 0), Point(10, 0)),
[
[2, 4, 3, 6],
[2, 4 - 10, 3, 6],
[2, 4, 3, 6],
[2, 4, 3, 6],
[2, 4, 3, 6],
[2, 4 - 10, 3, 6],
[2, 4 - 10, 3, 6],
[2, 4 - 10, 3, 6],
[2, 4 - 10, 3, 6],
],
[
Position.TOP_LEFT,
],
4,
[False] * 8 + [True],
[False] * 9,
),
],
)
def test_line_zone_one_detection_long_horizon(
vector: Vector,
xyxy_sequence: List[List[float]],
triggering_anchors: List[Position],
crossing_acceptance_threshold: int,
expected_crossed_in: List[bool],
expected_crossed_out: List[bool],
) -> None:
line_zone = LineZone(
start=vector.start,
end=vector.end,
triggering_anchors=triggering_anchors,
crossing_acceptance_threshold=crossing_acceptance_threshold,
)
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, crossing_acceptance_threshold, "
"expected_crossed_in, expected_crossed_out, expected_count_in, "
"expected_count_out, exception",
[
( # One stays, one crosses, one disappears before crossing
Vector(Point(0, 0), Point(10, 0)),
[
[[4, 4, 6, 6], [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, 6, 6], [4, 4 - 10, 6, 6 - 10]],
[[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
],
[
Position.TOP_LEFT,
],
1,
[
[False, False, False],
[False, True, False],
[False, False],
[False, True],
[False, False],
],
[
[False, False, False],
[False, False, False],
[False, True],
[False, False],
[False, False],
],
[0, 1, 1, 2, 2],
[0, 0, 1, 1, 1],
DoesNotRaise(),
),
( # One stays, one crosses, one disappears immediately after crossing
Vector(Point(0, 0), Point(10, 0)),
[
[[4, 4, 6, 6], [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]],
[[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
[[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
],
[
Position.TOP_LEFT,
],
1,
[
[False, False, False],
[False, True, False],
[False, False, True],
[False, True],
[False, False],
],
[
[False, False, False],
[False, False, False],
[False, True, False],
[False, False],
[False, False],
],
[0, 1, 2, 3, 3],
[0, 0, 1, 1, 1],
DoesNotRaise(),
),
( # One stays, one crosses, one disappears before crossing
Vector(Point(0, 0), Point(10, 0)),
[
[[4, 4, 6, 6], [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, 6, 6], [4, 4 - 10, 6, 6 - 10]],
[[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
],
[
Position.TOP_LEFT,
],
2,
[
[False, False, False],
[False, False, False],
[False, False],
[False, False],
[False, True],
],
[
[False, False, False],
[False, False, False],
[False, False],
[False, False],
[False, False],
],
[0, 0, 0, 0, 1],
[0, 0, 0, 0, 0],
DoesNotRaise(),
),
( # One stays, one crosses, one disappears immediately after crossing
Vector(Point(0, 0), Point(10, 0)),
[
[[4, 4, 6, 6], [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]],
[[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
[[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
],
[
Position.TOP_LEFT,
],
2,
[
[False, False, False],
[False, False, False],
[False, False, False],
[False, False],
[False, True],
],
[
[False, False, False],
[False, False, False],
[False, False, False],
[False, False],
[False, False],
],
[0, 0, 0, 0, 1],
[0, 0, 0, 0, 0],
DoesNotRaise(),
),
],
)
def test_line_zone_long_horizon_disappearing_detections(
vector: Vector,
xyxy_sequence: List[List[Optional[List[float]]]],
anchors: List[Position],
crossing_acceptance_threshold: int,
expected_crossed_in: List[List[bool]],
expected_crossed_out: List[List[bool]],
expected_count_in: List[int],
expected_count_out: List[int],
exception: Exception,
) -> None:
with exception:
line_zone = LineZone(
start=vector.start,
end=vector.end,
triggering_anchors=anchors,
crossing_acceptance_threshold=crossing_acceptance_threshold,
)
crossed_in_list = []
crossed_out_list = []
count_in_list = []
count_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))
count_in_list.append(line_zone.in_count)
count_out_list.append(line_zone.out_count)
assert crossed_in_list == expected_crossed_in
assert crossed_out_list == expected_crossed_out
assert count_in_list == expected_count_in
assert count_out_list == expected_count_out
View File
+40
View File
@@ -0,0 +1,40 @@
from typing import List
import numpy as np
import pytest
import supervision as sv
@pytest.mark.parametrize(
"detections, expected_results",
[
(
[
sv.Detections(
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]]),
class_id=np.array([1, 1]),
confidence=np.array([1, 1]),
),
sv.Detections(
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]]),
class_id=np.array([1, 1]),
confidence=np.array([1, 1]),
),
],
sv.Detections(
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]]),
class_id=np.array([1, 1]),
confidence=np.array([1, 1]),
tracker_id=np.array([1, 2]),
),
),
],
)
def test_byte_tracker(
detections: List[sv.Detections],
expected_results: sv.Detections,
) -> None:
byte_tracker = sv.ByteTrack()
tracked_detections = [byte_tracker.update_with_detections(d) for d in detections]
assert tracked_detections[-1] == expected_results
+21 -2
View File
@@ -121,7 +121,15 @@ class MockDataclass:
(
Detections.empty(),
False,
{"xyxy", "class_id", "confidence", "mask", "tracker_id", "data"},
{
"xyxy",
"class_id",
"confidence",
"mask",
"tracker_id",
"data",
"metadata",
},
DoesNotRaise(),
),
(
@@ -134,6 +142,7 @@ class MockDataclass:
"mask",
"tracker_id",
"data",
"metadata",
"area",
"box_area",
},
@@ -149,6 +158,7 @@ class MockDataclass:
"mask",
"tracker_id",
"data",
"metadata",
},
DoesNotRaise(),
),
@@ -169,13 +179,22 @@ class MockDataclass:
"mask",
"tracker_id",
"data",
"metadata",
},
DoesNotRaise(),
),
(
Detections.empty(),
False,
{"xyxy", "class_id", "confidence", "mask", "tracker_id", "data"},
{
"xyxy",
"class_id",
"confidence",
"mask",
"tracker_id",
"data",
"metadata",
},
DoesNotRaise(),
),
],
+1 -1
View File
@@ -1,5 +1,5 @@
[tox]
envlist = py38,py39,py310,py311,py312
envlist = py38,py39,py310,py311,py312,py313
[testenv]
changedir = test