Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9731ef9f3 | ||
|
|
b9475f14f3 | ||
|
|
587c61883b | ||
|
|
f866fe046f | ||
|
|
185322fcd1 | ||
|
|
1dd6ff6ce5 | ||
|
|
9e79cb06d0 | ||
|
|
94131ff56b | ||
|
|
ec0add34ee | ||
|
|
6387513805 | ||
|
|
9ed9cd2592 | ||
|
|
5d6ad66618 | ||
|
|
6f7ca11ea9 | ||
|
|
51a108b5ab | ||
|
|
cfe18afcc9 | ||
|
|
39acd616a6 | ||
|
|
1d0747fbfe | ||
|
|
c3618652cd | ||
|
|
1496888d92 | ||
|
|
058dc169f1 | ||
|
|
0d534346bf | ||
|
|
e73cc438e1 | ||
|
|
6a378a32f7 | ||
|
|
1534501a1d | ||
|
|
941700b9f8 | ||
|
|
4743701286 | ||
|
|
b58862d9f3 | ||
|
|
4b245dd5f1 | ||
|
|
32bf9be5d1 | ||
|
|
f56eb2503e | ||
|
|
b1c0c1b00a | ||
|
|
4c88106d14 | ||
|
|
3d0b4cba65 | ||
|
|
f7fd508400 | ||
|
|
32b10b5335 | ||
|
|
5f981bf4d9 | ||
|
|
5634632944 | ||
|
|
fc62e6a0bc | ||
|
|
837772392c | ||
|
|
386fb2e15f | ||
|
|
ad59b39257 | ||
|
|
57966ce9ec | ||
|
|
ca6609571a | ||
|
|
6690bd678a | ||
|
|
1c489eff98 | ||
|
|
f1e067041e | ||
|
|
73d69172db | ||
|
|
8881b5c588 | ||
|
|
83d9868350 | ||
|
|
c9a3069297 | ||
|
|
8864762ff1 | ||
|
|
39a12db1d4 | ||
|
|
55fafd0a40 | ||
|
|
cc28a51c18 | ||
|
|
9938c1d2ad | ||
|
|
51dba14fee | ||
|
|
78d70085cb | ||
|
|
e2cb82a7da | ||
|
|
78e1c2ceba | ||
|
|
898f2ba72c | ||
|
|
11f2a005e2 | ||
|
|
3ba7c1b8e2 | ||
|
|
3562bbf2ed | ||
|
|
408a9ad0ff | ||
|
|
acf1ef6d90 | ||
|
|
752818e577 | ||
|
|
2e38e8998f | ||
|
|
7878df14be | ||
|
|
dc449c4fff | ||
|
|
afcd0d6e5a | ||
|
|
6b48881de7 | ||
|
|
10beacabc0 | ||
|
|
f180f0f5be | ||
|
|
f906a2978f | ||
|
|
e4949a7de7 | ||
|
|
4e41e9b34f | ||
|
|
e804a01e85 | ||
|
|
d0559e104d | ||
|
|
fa8b6e125c | ||
|
|
47295cd5f7 | ||
|
|
2d48c5dba7 | ||
|
|
13b847d1dc | ||
|
|
7e19c633a6 | ||
|
|
8df41a5683 | ||
|
|
59b652d0f2 | ||
|
|
76429548b6 | ||
|
|
23301bbd17 | ||
|
|
77b7d3733a | ||
|
|
63388fdee9 | ||
|
|
c06d5f0131 | ||
|
|
a151685a57 | ||
|
|
cbaa605e08 | ||
|
|
fb1dbfd084 | ||
|
|
fefd005c2c | ||
|
|
2c53eb10a8 | ||
|
|
d381f43e50 | ||
|
|
dc08d88c5c | ||
|
|
af562950a3 | ||
|
|
ee9568945a | ||
|
|
ea03d55d6a | ||
|
|
3f55c3022e | ||
|
|
a54e491d49 | ||
|
|
4c03f899b9 | ||
|
|
1b5c592abb | ||
|
|
a5cad09782 | ||
|
|
483cc0677f | ||
|
|
b2a1eb5b21 | ||
|
|
fa979cf0ab | ||
|
|
2a1542c465 | ||
|
|
e543aac6dd | ||
|
|
3ee6de3ed3 | ||
|
|
16848e9160 | ||
|
|
071feeadce | ||
|
|
9587439f8d | ||
|
|
a61a6c7144 | ||
|
|
6ae1dfdcb0 | ||
|
|
fc40438f35 | ||
|
|
730bbd246c | ||
|
|
656f639db5 | ||
|
|
663522bed8 | ||
|
|
324872303b | ||
|
|
651ed9d470 | ||
|
|
6aedf4874f | ||
|
|
d33c159a26 | ||
|
|
572ea32c0c | ||
|
|
3415c15365 | ||
|
|
2ed66bfffc | ||
|
|
c6591f609f | ||
|
|
ba7760f9ad | ||
|
|
3f05b107ed | ||
|
|
d4c6fd7426 | ||
|
|
6d8d4fdddb | ||
|
|
adec783d49 | ||
|
|
2cca69be00 | ||
|
|
894ee7f5f9 | ||
|
|
23085eaf8a | ||
|
|
4deb9597b2 | ||
|
|
392d769483 | ||
|
|
f088e16fe2 | ||
|
|
30cf394a7b | ||
|
|
beabf6810c | ||
|
|
dbd6104c8b | ||
|
|
2a5ce95472 | ||
|
|
77ae682120 | ||
|
|
4a0b3bd2cf | ||
|
|
a9aad6727c | ||
|
|
7c6668b58e | ||
|
|
385c26ad78 | ||
|
|
d57078a7ed | ||
|
|
7a5672bbcc | ||
|
|
e6f0a8f3f0 | ||
|
|
7b50eed7d9 | ||
|
|
7c2711745b | ||
|
|
dbade52364 | ||
|
|
63392ab640 | ||
|
|
b466076383 | ||
|
|
14b3c0cf8a | ||
|
|
092085efb1 | ||
|
|
b8d62f6e29 | ||
|
|
6f55d9de9e | ||
|
|
4b75b9bd82 | ||
|
|
55378df392 | ||
|
|
4fd1450f80 | ||
|
|
c894a7a763 | ||
|
|
a4a2d8c05d | ||
|
|
30010b9773 | ||
|
|
fd61460cc2 | ||
|
|
a80be9422f | ||
|
|
eb3b172139 | ||
|
|
9975b63216 | ||
|
|
3c4793a092 | ||
|
|
b6a6746f8b | ||
|
|
a9831323f3 | ||
|
|
e6f29a45a3 | ||
|
|
06f678e5d2 | ||
|
|
47c98173ea | ||
|
|
c9ea75e3ca | ||
|
|
517b25f27f | ||
|
|
40340ef8a6 | ||
|
|
3d62caf4f3 | ||
|
|
dab2194f47 | ||
|
|
c257221fe0 | ||
|
|
7c5320409c | ||
|
|
10f372fe28 | ||
|
|
64dc2e91c8 | ||
|
|
9a4781f2a5 | ||
|
|
1633522768 | ||
|
|
d061a4c89b | ||
|
|
059633435c | ||
|
|
001f85ad0f | ||
|
|
d519861b1e | ||
|
|
12509e756a | ||
|
|
fcf8b7f378 | ||
|
|
ea3b9a5584 | ||
|
|
f7d7786c56 | ||
|
|
a713d3650a | ||
|
|
5385ca744b | ||
|
|
34229c7ad7 | ||
|
|
4c88e03b6c | ||
|
|
37943b2d6f | ||
|
|
f3d27d8381 | ||
|
|
c416474b63 | ||
|
|
ae8e6b2976 | ||
|
|
7141f11c0b | ||
|
|
37eadfe305 | ||
|
|
a4dfcacbfd | ||
|
|
3d1416790b | ||
|
|
348069fb07 | ||
|
|
fac1581fcd | ||
|
|
d075eb1e47 | ||
|
|
eac2691c6e | ||
|
|
a0242b0594 | ||
|
|
1e2bdeee35 | ||
|
|
62a396fe90 | ||
|
|
9941417ced | ||
|
|
79390062ce | ||
|
|
66e02b77b0 | ||
|
|
816a990083 | ||
|
|
93d0313460 | ||
|
|
d229d2b069 | ||
|
|
28fa054fdc | ||
|
|
6889e33da5 | ||
|
|
228b4cff0c | ||
|
|
a5948c8992 | ||
|
|
853cbc428f | ||
|
|
d3f6570a89 | ||
|
|
d1c8adb33a | ||
|
|
5cd6844ff4 | ||
|
|
c0d5a7fd25 | ||
|
|
159c507d7f | ||
|
|
fdd317e53e | ||
|
|
370996358b | ||
|
|
def1cc4b33 | ||
|
|
5f0f90b699 | ||
|
|
ae9628e537 | ||
|
|
32eaf4eb50 | ||
|
|
02dac5adfe | ||
|
|
b3d12ed652 | ||
|
|
7bca2fe765 | ||
|
|
a90b8717ee | ||
|
|
b7e02add30 | ||
|
|
7626c32a56 | ||
|
|
fe93979e0b | ||
|
|
684522a7e8 | ||
|
|
bbbb37434c | ||
|
|
4bfee1096e | ||
|
|
dd0ab6a2a7 | ||
|
|
cdf66fd78d | ||
|
|
6d4b08c753 | ||
|
|
7c57a1b8de | ||
|
|
17e35e2bcf | ||
|
|
69017093e1 | ||
|
|
e54ce38458 | ||
|
|
46416e430c | ||
|
|
2ad5b60c66 | ||
|
|
512edd0c9b | ||
|
|
60b9b716e9 | ||
|
|
4b64d4ddca | ||
|
|
9ded219dfb | ||
|
|
987a8f269f | ||
|
|
a57f4f22ae | ||
|
|
88f113d14b | ||
|
|
8d18328dff | ||
|
|
7ea4b2ac13 | ||
|
|
284901c06b | ||
|
|
b2dc1b2a17 | ||
|
|
d9e3ee9465 | ||
|
|
37b5dd477b | ||
|
|
092809b2be | ||
|
|
742a824d2c | ||
|
|
c88de3641c | ||
|
|
aec0bc3529 | ||
|
|
89769e49f7 | ||
|
|
86f95f914a | ||
|
|
eaeb51a8db | ||
|
|
8d562c16a6 | ||
|
|
dd4ffc6d97 | ||
|
|
2d50beeaa9 | ||
|
|
7b3d88602b | ||
|
|
90168644de | ||
|
|
aae3c69ee3 | ||
|
|
3bb22abe0f | ||
|
|
d99565dcd1 | ||
|
|
3692fb65f9 | ||
|
|
1af9c7d629 | ||
|
|
1d9f0b1994 | ||
|
|
77c71dacac | ||
|
|
cfb462ca0b | ||
|
|
c12c562e29 | ||
|
|
10652efbc4 | ||
|
|
3532130b2d | ||
|
|
79b45a4ea8 | ||
|
|
cc8be562b0 | ||
|
|
a1d8c69654 | ||
|
|
4326b87191 | ||
|
|
38dbf5d05d | ||
|
|
5e76f86ff9 | ||
|
|
01c2912a64 | ||
|
|
5fedfe5220 | ||
|
|
c43ca06609 | ||
|
|
b72ad4656b | ||
|
|
fdb036ecd5 | ||
|
|
e4cf743c1c | ||
|
|
19677bede5 | ||
|
|
a6e1f037d7 | ||
|
|
0bfcdeab6f | ||
|
|
83d4386fa2 | ||
|
|
42bb4c2865 | ||
|
|
4ed05e76fd | ||
|
|
2084f57d76 | ||
|
|
1b101b49ac | ||
|
|
abcb055815 | ||
|
|
c3d7dc0ee3 | ||
|
|
c6c447c527 | ||
|
|
b66702c5ff | ||
|
|
920a2c0cda | ||
|
|
6728527f36 | ||
|
|
b9b802e9ea | ||
|
|
19b7ca0a3d | ||
|
|
01caa1f528 | ||
|
|
11978410f0 | ||
|
|
8e91865fd0 | ||
|
|
89b911d5f9 | ||
|
|
763be226a4 | ||
|
|
c52223b4b9 | ||
|
|
a0e909669d | ||
|
|
698c085518 | ||
|
|
6d7dba7572 | ||
|
|
a18fb116a1 | ||
|
|
c54b4be4f0 | ||
|
|
fe2781cf85 | ||
|
|
e1af717c59 | ||
|
|
58c5aa981b | ||
|
|
17f482b1ba | ||
|
|
b39ead2ef0 | ||
|
|
ee3aa9f08d | ||
|
|
01f0f9ebe7 | ||
|
|
659ddec360 | ||
|
|
8bb609eca0 | ||
|
|
62c2c6a668 | ||
|
|
d1e11684e7 | ||
|
|
f7c47b6edb | ||
|
|
5391671838 | ||
|
|
11355c5192 | ||
|
|
9ee1b5dc5a | ||
|
|
d645bd816e | ||
|
|
5cce8a1599 | ||
|
|
d388857057 | ||
|
|
940243fc18 | ||
|
|
48c63e1b17 | ||
|
|
6d03253370 | ||
|
|
5974054c6c | ||
|
|
62d869ca87 | ||
|
|
1f610d820b | ||
|
|
22d827fac4 | ||
|
|
9eee76a6d1 | ||
|
|
d3210ed0cc | ||
|
|
cfd02c8c5c | ||
|
|
fd1277f29a | ||
|
|
b380bd2df7 | ||
|
|
89be6f92d2 | ||
|
|
37cc16e7a8 | ||
|
|
e8983bf427 | ||
|
|
1f5b1c7e01 | ||
|
|
589d4f9152 | ||
|
|
4678dfc74a | ||
|
|
2704a24ff6 | ||
|
|
b8daa408bf | ||
|
|
46f7a221ae | ||
|
|
895bcfbddb | ||
|
|
ab99c3615d | ||
|
|
37cacec704 | ||
|
|
42b4d439c8 | ||
|
|
7229409fab | ||
|
|
353fa93305 | ||
|
|
ad4418a350 | ||
|
|
3b4cd4276f | ||
|
|
d1c07bc106 | ||
|
|
6f3491f03c | ||
|
|
37f0ad8b14 | ||
|
|
02735848b5 | ||
|
|
b760b96e4f | ||
|
|
f4850b870f | ||
|
|
ecf5b1334f | ||
|
|
066099f42d | ||
|
|
b26e8f5814 | ||
|
|
b70269947d | ||
|
|
b4d93ce6c9 | ||
|
|
a2e2e3725a | ||
|
|
ba559c2658 | ||
|
|
0e7f3cdfe0 | ||
|
|
40e16118f8 | ||
|
|
dbd4aa7798 | ||
|
|
98aedd7383 | ||
|
|
d56c566b62 | ||
|
|
f176f46d3a | ||
|
|
cf3c1e4e10 | ||
|
|
6c7ee37225 | ||
|
|
abd149fa1a | ||
|
|
76975478dd | ||
|
|
145ed41305 | ||
|
|
5c13d9c0dc | ||
|
|
40ef90c76e | ||
|
|
2efe3030cb | ||
|
|
74ae1eec87 | ||
|
|
ea776b58a2 | ||
|
|
3e8a88a8d4 | ||
|
|
fbd96d10c4 | ||
|
|
c17de5c11b | ||
|
|
bda4003453 | ||
|
|
eb3e1e6293 | ||
|
|
a9214b2fdb | ||
|
|
2c21e9ecc1 | ||
|
|
70dd0e1c1d | ||
|
|
b2762dafaf | ||
|
|
251b952438 | ||
|
|
4a7d9ceaa9 | ||
|
|
e5215b967c | ||
|
|
f60d89f508 | ||
|
|
1b95d1dea2 | ||
|
|
f8ff332872 | ||
|
|
5ac3999aeb | ||
|
|
75a4ff1c64 | ||
|
|
d50aa67ac0 | ||
|
|
847f132aa0 | ||
|
|
c6e701be1c | ||
|
|
4ce033c4d7 | ||
|
|
57870817f5 | ||
|
|
3eb5c0b024 | ||
|
|
ed6fe98e24 | ||
|
|
8d374a6282 | ||
|
|
5b09368a0e | ||
|
|
0012052cdd | ||
|
|
85f400c162 | ||
|
|
865c4e4231 | ||
|
|
e9d5f861a2 | ||
|
|
a879c1d39a | ||
|
|
213d59d952 | ||
|
|
62a4d45c6c | ||
|
|
086091a1e2 | ||
|
|
302041d8aa | ||
|
|
9822406cde | ||
|
|
1d3f61af85 | ||
|
|
9b35f8c3c4 | ||
|
|
c339315b31 | ||
|
|
8edf7bb941 | ||
|
|
7e7eb8bd5a | ||
|
|
b6ae599a21 | ||
|
|
a6897d4117 | ||
|
|
d333d67912 | ||
|
|
5242614b01 | ||
|
|
b86b660634 | ||
|
|
ef1f968289 | ||
|
|
337b0e4a5b | ||
|
|
892185e4c6 | ||
|
|
c7611e384b | ||
|
|
68a1052cfc | ||
|
|
bfe4f0f829 | ||
|
|
dcdeea85d1 | ||
|
|
6b3735001b | ||
|
|
f3f5585f31 | ||
|
|
a988b72822 | ||
|
|
94230c51dc | ||
|
|
b59f867bcb | ||
|
|
70bc4b9d88 | ||
|
|
c5dfb47d5c | ||
|
|
2530c41ffa | ||
|
|
4c928eda2e | ||
|
|
05b17c483a | ||
|
|
9659f7b2eb | ||
|
|
5fc7c3aa53 | ||
|
|
f1e32e7c07 | ||
|
|
75b6384a9a | ||
|
|
426e36dea1 | ||
|
|
53b26d0c05 | ||
|
|
201a186539 | ||
|
|
0f42a87ee8 | ||
|
|
62a3cfe480 | ||
|
|
ef85b325de | ||
|
|
16a4491b59 | ||
|
|
e433527942 | ||
|
|
12c6b873e3 | ||
|
|
baffd98314 | ||
|
|
a4f79b59f1 | ||
|
|
921e8f32d4 | ||
|
|
ebbc79aeae | ||
|
|
70869c7337 | ||
|
|
3231f7f498 | ||
|
|
22d73580db | ||
|
|
7862f9cc70 | ||
|
|
044d5039b2 | ||
|
|
c3e46b0f9c | ||
|
|
1523ee21af | ||
|
|
c70988123d | ||
|
|
e0db07a80f | ||
|
|
6d30eef4a9 | ||
|
|
124c7d9b90 | ||
|
|
58a6fd046d | ||
|
|
edf2277ea0 | ||
|
|
3863a1f2e9 | ||
|
|
78fd2147f1 | ||
|
|
95f6ef0b22 | ||
|
|
0e26f1b22e | ||
|
|
cc640dc890 | ||
|
|
266bf3f10c | ||
|
|
df9257b8fc | ||
|
|
a0655aed08 | ||
|
|
1e43de0a41 | ||
|
|
3da10edb47 | ||
|
|
db2aa721f8 | ||
|
|
a05882add1 |
@@ -0,0 +1,4 @@
|
||||
# These owners will be the default owners for everything in
|
||||
# the repo. They will be requested for review when someone
|
||||
# opens a pull request.
|
||||
* @SkalskiP @onuralpszr
|
||||
@@ -4,13 +4,15 @@ updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: ⬆️
|
||||
target-branch: "develop"
|
||||
# Python
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: ⬆️
|
||||
target-branch: "develop"
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
|
||||
name: Clear cache
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 1 * *'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 1 * *" # Run at midnight on the first day of every month
|
||||
workflow_dispatch:
|
||||
|
||||
# Restrict permissions by default
|
||||
permissions:
|
||||
actions: write
|
||||
actions: write # Required for cache management
|
||||
|
||||
jobs:
|
||||
clear-cache:
|
||||
name: Clear cache
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Clear cache
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
console.log("About to clear")
|
||||
console.log("Starting cache cleanup...")
|
||||
const caches = await github.rest.actions.getActionsCacheList({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
})
|
||||
|
||||
let deletedCount = 0
|
||||
for (const cache of caches.data.actions_caches) {
|
||||
console.log(cache)
|
||||
github.rest.actions.deleteActionsCacheById({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
cache_id: cache.id,
|
||||
})
|
||||
console.log(`Deleting cache: ${cache.key} (${cache.size_in_bytes} bytes)`)
|
||||
try {
|
||||
await github.rest.actions.deleteActionsCacheById({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
cache_id: cache.id,
|
||||
})
|
||||
deletedCount++
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete cache ${cache.key}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
console.log("Clear completed")
|
||||
console.log(`Cache cleanup completed. Deleted ${deletedCount} caches.`)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Combine Dependabot PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 1 * * 3" # Wednesday at 01:00
|
||||
workflow_dispatch: # allows you to manually trigger the workflow
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
checks: read
|
||||
|
||||
jobs:
|
||||
combine-prs:
|
||||
name: Combine
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: combine-prs
|
||||
id: combine-prs
|
||||
uses: github/combine-prs@2909f404763c3177a456e052bdb7f2e85d3a7cb3 # v5.2.0
|
||||
with:
|
||||
labels: combined-pr
|
||||
@@ -1,64 +0,0 @@
|
||||
name: Notebook Check Pull Request
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
comment-welcome:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fetch pull request branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Fetch base develop branch
|
||||
run: git fetch -u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" develop:develop
|
||||
- name: Create message
|
||||
env:
|
||||
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
PR_NUM: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
# Preview links and tool usage only needed for notebook changes.
|
||||
readarray -t changed_notebooks < <(git diff --name-only develop | grep '\.ipynb$' || true)
|
||||
if [[ ${#changed_notebooks[@]} == 0 ]]; then
|
||||
echo "No notebooks modified in this pull request."
|
||||
else
|
||||
msg="<h4>Preview</h4>\n"
|
||||
msg+="Preview and run these notebook edits with Google Colab:\n<ul>\n"
|
||||
# Link to PR branch in user's fork that is always current.
|
||||
for fp in "${changed_notebooks[@]}"; do
|
||||
gh_path="${HEAD_REPOSITORY}/blob/${HEAD_REF}/${fp}"
|
||||
colab_url="https://colab.research.google.com/github/${gh_path}"
|
||||
msg+="<li><a href='${colab_url}'>${fp}</a></li>\n"
|
||||
done
|
||||
msg+="</ul>\n"
|
||||
|
||||
reviewnb_url="https://app.reviewnb.com/${GITHUB_REPOSITORY}/pull/${PR_NUM}/files/"
|
||||
msg+="Rendered <a href='${reviewnb_url}'>notebook diffs</a> available on ReviewNB.com.\n"
|
||||
|
||||
msg+="If commits are added to the pull request, synchronize your local branch: <code>git pull origin $HEAD_REF</code>\n"
|
||||
fi
|
||||
echo "MESSAGE=$msg" >> $GITHUB_ENV
|
||||
- name: Post comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_URL: ${{ github.event.pull_request.issue_url }}
|
||||
run: |
|
||||
# Env var defined in previous step. Escape string for JSON.
|
||||
body="$(echo -n -e $MESSAGE | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"
|
||||
# Add comment to pull request.
|
||||
curl -X POST \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
"${ISSUE_URL}/comments" \
|
||||
--data "{\"body\": $body}"
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Docs WorkFlow - Develop Tag 📚
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.ref}}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
pull-requests: write
|
||||
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 🔄 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: 🐍 Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: 📦 Install mkdocs-material
|
||||
run: pip install "mkdocs-material"
|
||||
- name: 📦 Install mkdocstrings[python]
|
||||
run: pip install "mkdocstrings[python]"
|
||||
- name: 📦 Install mkdocs-material[imaging]
|
||||
run: pip install "mkdocs-material[imaging]"
|
||||
- name: 📦 Install mike
|
||||
run: pip install "mike"
|
||||
- name: 📦 Install mkdocs-git-revision-date-localized-plugin
|
||||
run: pip install "mkdocs-git-revision-date-localized-plugin"
|
||||
- name: 📦 Install JupyterLab
|
||||
run: pip install jupyterlab
|
||||
- name: 📦 Install mkdocs-jupyter
|
||||
run: pip install mkdocs-jupyter
|
||||
- name: 📦 Install mkdocs-git-committers-plugin-2
|
||||
run: pip install mkdocs-git-committers-plugin-2
|
||||
- name: ⚙️ Configure git for github-actions
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
- name: 🚀 Deploy MkDoc-Material with mike
|
||||
run: |
|
||||
MKDOCS_GIT_COMMITTERS_APIKEY=${{ secrets.GITHUB_TOKEN }} mike deploy --push develop
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Build and Publish Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
# Ensure only one concurrent deployment
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.ref}}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Restrict permissions by default
|
||||
permissions:
|
||||
contents: write # Required for committing to gh-pages
|
||||
pages: write # Required for deploying to Pages
|
||||
pull-requests: write # Required for PR comments
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Publish Docs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
steps:
|
||||
- name: 📥 Checkout the repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🐍 Install uv and set Python ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: 🔑 Create GitHub App token (mkdocs)
|
||||
id: mkdocs_token
|
||||
uses: actions/create-github-app-token@3ff1caaa28b64c9cc276ce0a02e2ff584f3900c5 # v2.0.2
|
||||
with:
|
||||
app-id: ${{ secrets.MKDOCS_APP_ID }}
|
||||
private-key: ${{ secrets.MKDOCS_PEM }}
|
||||
owner: roboflow
|
||||
repositories: mkdocs-material-insiders
|
||||
|
||||
- name: 🏗️ Install dependencies
|
||||
run: |
|
||||
uv pip install -r pyproject.toml --extra docs
|
||||
# Install mkdocs-material-insiders using the GitHub App token
|
||||
uv pip install "git+https://roboflow:${{ steps.mkdocs_token.outputs.token }}@github.com/roboflow/mkdocs-material-insiders.git@9.5.49-insiders-4.53.14#egg=mkdocs-material[imaging]"
|
||||
|
||||
- name: ⚙️ Configure git for github-actions
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: 🚀 Deploy Development Docs
|
||||
if: (github.event_name == 'push' && github.ref == 'refs/heads/develop') || github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
MKDOCS_GIT_COMMITTERS_APIKEY=${{ secrets.GITHUB_TOKEN }} uv run mike deploy --push develop
|
||||
|
||||
- name: 🚀 Deploy Release Docs
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
run: |
|
||||
latest_tag=$(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
MKDOCS_GIT_COMMITTERS_APIKEY=${{ secrets.GITHUB_TOKEN }} uv run mike deploy --push --update-aliases $latest_tag latest
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Publish Supervision Pre-Releases to PyPI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "[0-9]+.[0-9]+[0-9]+.[0-9]+a[0-9]"
|
||||
- "[0-9]+.[0-9]+[0-9]+.[0-9]+b[0-9]"
|
||||
- "[0-9]+.[0-9]+[0-9]+.[0-9]+rc[0-9]"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {} # Explicitly remove all permissions by default
|
||||
|
||||
jobs:
|
||||
publish-pre-release:
|
||||
name: Publish Pre-release Package
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: test
|
||||
url: https://pypi.org/project/supervision/
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
id-token: write # Required for PyPI publishing
|
||||
contents: read # Required for checkout
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
steps:
|
||||
- name: 📥 Checkout the repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: 🏗️ Build source and wheel distributions
|
||||
run: |
|
||||
uv pip install -r pyproject.toml --extra build
|
||||
uv build
|
||||
uv run twine check --strict dist/*
|
||||
|
||||
- name: 🚀 Publish to PyPi
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
|
||||
with:
|
||||
attestations: true
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Supervision Release Documentation Workflow 📚
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.ref}}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
pull-requests: write
|
||||
|
||||
|
||||
jobs:
|
||||
doc-build-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
steps:
|
||||
- name: 🛎️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.head_ref }}
|
||||
|
||||
- name: 🐍 Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: 📦 Install mkdocs-material
|
||||
run: pip install "mkdocs-material"
|
||||
- name: 📦 Install mkdocstrings[python]
|
||||
run: pip install "mkdocstrings[python]"
|
||||
- name: 📦 Install mkdocs-material[imaging]
|
||||
run: pip install "mkdocs-material[imaging]"
|
||||
- name: 📦 Install mike
|
||||
run: pip install "mike"
|
||||
- name: 📦 Install mkdocs-git-revision-date-localized-plugin
|
||||
run: pip install "mkdocs-git-revision-date-localized-plugin"
|
||||
- name: 📦 Install JupyterLab
|
||||
run: pip install jupyterlab
|
||||
- name: 📦 Install mkdocs-jupyter
|
||||
run: pip install mkdocs-jupyter
|
||||
- name: 📦 Install mkdocs-git-committers-plugin-2
|
||||
run: pip install mkdocs-git-committers-plugin-2
|
||||
- name: ⚙️ Configure git for github-actions 👷
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
- name: 🚀 Deploy MkDoc-Material 📚
|
||||
run: |
|
||||
latest_tag=$(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
MKDOCS_GIT_COMMITTERS_APIKEY=${{ secrets.GITHUB_TOKEN }} mike deploy --push --update-aliases $latest_tag latest
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Publish Supervision Releases to PyPI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "[0-9]+.[0-9]+[0-9]+.[0-9]"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {} # Explicitly remove all permissions by default
|
||||
|
||||
jobs:
|
||||
publish-release:
|
||||
name: Publish Release Package
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: release
|
||||
url: https://pypi.org/project/supervision/
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
id-token: write # Required for PyPI publishing
|
||||
contents: read # Required for checkout
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
steps:
|
||||
- name: 📥 Checkout the repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: 🏗️ Build source and wheel distributions
|
||||
run: |
|
||||
uv pip install -r pyproject.toml --extra build
|
||||
uv build
|
||||
uv run twine check --strict dist/*
|
||||
|
||||
- name: 🚀 Publish to PyPi
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
|
||||
with:
|
||||
attestations: true
|
||||
@@ -1,42 +0,0 @@
|
||||
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]'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-publish-pre-release-pypi:
|
||||
name: Build and publish to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
steps:
|
||||
- name: 🛎️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
- name: 🐍 Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: 🏗️ Build source and wheel distributions
|
||||
run: |
|
||||
python -m pip install --upgrade build twine
|
||||
python -m build
|
||||
twine check --strict dist/*
|
||||
|
||||
- name: 🚀 Publish to PyPi
|
||||
uses: pypa/gh-action-pypi-publish@release/v1.10
|
||||
|
||||
- name: 🚀 Publish to Test-PyPi
|
||||
uses: pypa/gh-action-pypi-publish@release/v1.10
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Publish Supervision Releases to TestPyPI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {} # Explicitly remove all permissions by default
|
||||
|
||||
jobs:
|
||||
publish-testpypi:
|
||||
name: Publish Release Package
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: release
|
||||
url: https://pypi.org/project/supervision/
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
id-token: write # Required for PyPI publishing
|
||||
contents: read # Required for checkout
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
steps:
|
||||
- name: 📥 Checkout the repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: 🏗️ Build source and wheel distributions
|
||||
run: |
|
||||
uv pip install -r pyproject.toml --extra build
|
||||
uv build
|
||||
uv run twine check --strict dist/*
|
||||
|
||||
- name: 🚀 Publish to Test-PyPi
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
attestations: true
|
||||
@@ -1,39 +0,0 @@
|
||||
name: Publish Supervision Releases to PyPI and TestPyPI
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+[0-9]+.[0-9]'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-publish-pre-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
steps:
|
||||
- name: 🛎️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
- name: 🐍 Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: 🏗️ Build source and wheel distributions
|
||||
run: |
|
||||
python -m pip install --upgrade build twine
|
||||
python -m build
|
||||
twine check --strict dist/*
|
||||
|
||||
- name: 🚀 Publish to PyPi
|
||||
uses: pypa/gh-action-pypi-publish@release/v1.10
|
||||
|
||||
- name: 🚀 Publish to Test-PyPi
|
||||
uses: pypa/gh-action-pypi-publish@release/v1.10
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
@@ -4,18 +4,34 @@ on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
# Restrict permissions by default
|
||||
permissions:
|
||||
contents: read # Required for checkout
|
||||
checks: write # Required for test reporting
|
||||
|
||||
jobs:
|
||||
docs-build-test:
|
||||
name: Test docs build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
steps:
|
||||
- name: 🔄 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: 🐍 Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
- name: 📥 Checkout the repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: 🏗️ Install dependencies and Test Docs Build
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🐍 Install uv and set Python ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: 🏗️ Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install "mkdocs-material" "mkdocstrings[python]" "mkdocs-material[imaging]" mike "mkdocs-git-revision-date-localized-plugin" jupyterlab mkdocs-jupyter mkdocs-git-committers-plugin-2
|
||||
mkdocs build --verbose
|
||||
uv pip install -r pyproject.toml --extra docs
|
||||
|
||||
|
||||
- name: 🧪 Test Docs Build
|
||||
run: uv run mkdocs build --verbose
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
name: Python 3.8 - Min Dep Test WorkFlow
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
build-min-dep-test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8"]
|
||||
steps:
|
||||
- name: 🛎️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: 🐍 Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
# id based on python version
|
||||
id: python-setup
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install \
|
||||
attrs==23.1.0 \
|
||||
certifi==2023.7.22 \
|
||||
charset-normalizer==2.0.12 \
|
||||
cycler==0.12.1 \
|
||||
exceptiongroup==1.1.3 \
|
||||
fonttools==4.43.1 \
|
||||
idna==3.4 \
|
||||
iniconfig==2.0.0 \
|
||||
kiwisolver==1.4.5 \
|
||||
matplotlib==3.5.0 \
|
||||
numpy==1.21.2 \
|
||||
opencv-python==4.5.5.64 \
|
||||
Pillow==10.1.0 \
|
||||
packaging==23.2 \
|
||||
pluggy==1.3.0 \
|
||||
pyparsing==3.1.1 \
|
||||
pytest==7.2.0 \
|
||||
python-dateutil==2.8.2 \
|
||||
PyYAML==5.3 \
|
||||
requests==2.26.0 \
|
||||
scipy==1.10.0 \
|
||||
setuptools-scm==8.0.4 \
|
||||
six==1.16.0 \
|
||||
tomli==2.0.1 \
|
||||
tqdm==4.62.3 \
|
||||
typing_extensions==4.8.0 \
|
||||
urllib3==1.26.18 \
|
||||
defusedxml==0.7.1
|
||||
|
||||
- name: 🧪 Test
|
||||
run: "python -m pytest ./test"
|
||||
@@ -1,29 +0,0 @@
|
||||
name: Test WorkFlow
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
build-dev-test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
steps:
|
||||
- name: 🛎️ Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: 🐍 Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
check-latest: true
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install .
|
||||
pip install pytest
|
||||
|
||||
- name: 🧪 Test
|
||||
run: "python -m pytest ./test"
|
||||
@@ -0,0 +1,33 @@
|
||||
name: 🔧 Pytest/Test Workflow
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
run-tests:
|
||||
name: Import Test and Pytest Run
|
||||
timeout-minutes: 10
|
||||
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: 🚀 Install Packages
|
||||
run: uv pip install -r pyproject.toml --extra dev --extra docs --extra metrics
|
||||
|
||||
- name: 🧪 Run the Import test
|
||||
run: uv run python -c "import supervision; from supervision import assets; from supervision import metrics; print(supervision.__version__)"
|
||||
|
||||
- name: 🧪 Run the Test
|
||||
run: uv run pytest
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -25,14 +25,14 @@ repos:
|
||||
- id: mixed-line-ending
|
||||
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: '1.7.10'
|
||||
rev: '1.8.3'
|
||||
hooks:
|
||||
- id: bandit
|
||||
args: ["-c", "pyproject.toml"]
|
||||
additional_dependencies: ["bandit[toml]"]
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.8
|
||||
rev: v0.11.6
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
@@ -48,7 +48,7 @@ repos:
|
||||
# args: ["--number"]
|
||||
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.3.0
|
||||
rev: v2.4.1
|
||||
hooks:
|
||||
- id: codespell
|
||||
additional_dependencies:
|
||||
|
||||
+20
-20
@@ -128,15 +128,26 @@ PRs must pass all tests and linting requirements before they can be merged.
|
||||
|
||||
Before starting your work on the project, set up your development environment:
|
||||
|
||||
1. Clone your fork of the project:
|
||||
1. Clone your fork of the project (recommended to use shallow clone of develop branch):
|
||||
|
||||
**Option A: Recommended for most contributors (shallow clone of develop branch):**
|
||||
|
||||
```bash
|
||||
git clone --depth 1 -b develop https://github.com/YOUR_USERNAME/supervision.git
|
||||
cd supervision
|
||||
```
|
||||
|
||||
Replace `YOUR_USERNAME` with your GitHub username.
|
||||
|
||||
> Note: Using `--depth 1` creates a shallow clone with minimal history and `-b develop` ensures you start with the development branch. This significantly reduces download size while providing everything needed to contribute.
|
||||
|
||||
**Option B: Full repository clone (if you need complete history):**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/supervision.git
|
||||
cd supervision
|
||||
```
|
||||
|
||||
Replace `YOUR_USERNAME` with your GitHub username.
|
||||
|
||||
2. Create and activate a virtual environment:
|
||||
|
||||
```bash
|
||||
@@ -144,31 +155,20 @@ Before starting your work on the project, set up your development environment:
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
3. Install Poetry:
|
||||
3. Install `uv`:
|
||||
|
||||
Using pip:
|
||||
|
||||
```bash
|
||||
pip install -U pip setuptools
|
||||
pip install poetry
|
||||
```
|
||||
|
||||
Or using pipx (recommended for global installation):
|
||||
|
||||
```bash
|
||||
pipx install poetry
|
||||
```
|
||||
Follow the instructions on the [uv installation page](https://docs.astral.sh/uv/getting-started/installation/).
|
||||
|
||||
4. Install project dependencies:
|
||||
|
||||
```bash
|
||||
poetry install
|
||||
uv pip install -r pyproject.toml --extra dev --extra docs --extra metrics
|
||||
```
|
||||
|
||||
5. Run pytest to verify the setup:
|
||||
|
||||
```bash
|
||||
poetry run pytest
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## 🎨 Code Style and Quality
|
||||
@@ -181,7 +181,7 @@ Furthermore, we have integrated a pre-commit GitHub Action into our workflow. Th
|
||||
|
||||
To run the pre-commit tool, follow these steps:
|
||||
|
||||
1. Install pre-commit by running the following command: `poetry install --with dev`. It will not only install pre-commit but also install all the deps and dev-deps of project
|
||||
1. Install pre-commit by running the following command: `uv pip install -r pyproject.toml --extra dev`. It will not only install pre-commit but also install all the deps and dev-deps of project
|
||||
|
||||
2. Once pre-commit is installed, navigate to the project's root directory.
|
||||
|
||||
@@ -203,7 +203,7 @@ So far, **there is no type checking with mypy**. See [issue](https://github.com/
|
||||
|
||||
The `supervision` documentation is stored in a folder called `docs`. The project documentation is built using `mkdocs`.
|
||||
|
||||
To run the documentation, install the project requirements with `poetry install --with dev`. Then, run `mkdocs serve` to start the documentation server.
|
||||
To run the documentation, install the project requirements with `uv pip install -r pyproject.toml --extra dev --extra docs`. Then, run `mkdocs serve` to start the documentation server.
|
||||
|
||||
You can learn more about mkdocs on the [mkdocs website](https://www.mkdocs.org/).
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
[](https://badge.fury.io/py/supervision)
|
||||
[](https://colab.research.google.com/github/roboflow/supervision/blob/main/demo.ipynb)
|
||||
[](https://huggingface.co/spaces/Roboflow/Annotators)
|
||||
[](https://discord.gg/GbfgXGJ8Bk)
|
||||
[](https://discord.gg/GbfgXGJ8Bk)
|
||||
[](https://squidfunk.github.io/mkdocs-material/)
|
||||
|
||||
<div align="center">
|
||||
@@ -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! 🤝
|
||||
|
||||
[](https://github.com/roboflow/supervision/issues?q=is%3Aissue+is%3Aopen+label%3Ahacktoberfest)
|
||||
|
||||
## 💻 install
|
||||
|
||||
Pip install the supervision package in a
|
||||
@@ -137,88 +135,88 @@ for path, image, annotation in ds:
|
||||
|
||||
- load
|
||||
|
||||
```python
|
||||
dataset = sv.DetectionDataset.from_yolo(
|
||||
images_directory_path=...,
|
||||
annotations_directory_path=...,
|
||||
data_yaml_path=...
|
||||
)
|
||||
```python
|
||||
dataset = sv.DetectionDataset.from_yolo(
|
||||
images_directory_path=...,
|
||||
annotations_directory_path=...,
|
||||
data_yaml_path=...
|
||||
)
|
||||
|
||||
dataset = sv.DetectionDataset.from_pascal_voc(
|
||||
images_directory_path=...,
|
||||
annotations_directory_path=...
|
||||
)
|
||||
dataset = sv.DetectionDataset.from_pascal_voc(
|
||||
images_directory_path=...,
|
||||
annotations_directory_path=...
|
||||
)
|
||||
|
||||
dataset = sv.DetectionDataset.from_coco(
|
||||
images_directory_path=...,
|
||||
annotations_path=...
|
||||
)
|
||||
```
|
||||
dataset = sv.DetectionDataset.from_coco(
|
||||
images_directory_path=...,
|
||||
annotations_path=...
|
||||
)
|
||||
```
|
||||
|
||||
- split
|
||||
|
||||
```python
|
||||
train_dataset, test_dataset = dataset.split(split_ratio=0.7)
|
||||
test_dataset, valid_dataset = test_dataset.split(split_ratio=0.5)
|
||||
```python
|
||||
train_dataset, test_dataset = dataset.split(split_ratio=0.7)
|
||||
test_dataset, valid_dataset = test_dataset.split(split_ratio=0.5)
|
||||
|
||||
len(train_dataset), len(test_dataset), len(valid_dataset)
|
||||
# (700, 150, 150)
|
||||
```
|
||||
len(train_dataset), len(test_dataset), len(valid_dataset)
|
||||
# (700, 150, 150)
|
||||
```
|
||||
|
||||
- merge
|
||||
|
||||
```python
|
||||
ds_1 = sv.DetectionDataset(...)
|
||||
len(ds_1)
|
||||
# 100
|
||||
ds_1.classes
|
||||
# ['dog', 'person']
|
||||
```python
|
||||
ds_1 = sv.DetectionDataset(...)
|
||||
len(ds_1)
|
||||
# 100
|
||||
ds_1.classes
|
||||
# ['dog', 'person']
|
||||
|
||||
ds_2 = sv.DetectionDataset(...)
|
||||
len(ds_2)
|
||||
# 200
|
||||
ds_2.classes
|
||||
# ['cat']
|
||||
ds_2 = sv.DetectionDataset(...)
|
||||
len(ds_2)
|
||||
# 200
|
||||
ds_2.classes
|
||||
# ['cat']
|
||||
|
||||
ds_merged = sv.DetectionDataset.merge([ds_1, ds_2])
|
||||
len(ds_merged)
|
||||
# 300
|
||||
ds_merged.classes
|
||||
# ['cat', 'dog', 'person']
|
||||
```
|
||||
ds_merged = sv.DetectionDataset.merge([ds_1, ds_2])
|
||||
len(ds_merged)
|
||||
# 300
|
||||
ds_merged.classes
|
||||
# ['cat', 'dog', 'person']
|
||||
```
|
||||
|
||||
- save
|
||||
|
||||
```python
|
||||
dataset.as_yolo(
|
||||
images_directory_path=...,
|
||||
annotations_directory_path=...,
|
||||
data_yaml_path=...
|
||||
)
|
||||
```python
|
||||
dataset.as_yolo(
|
||||
images_directory_path=...,
|
||||
annotations_directory_path=...,
|
||||
data_yaml_path=...
|
||||
)
|
||||
|
||||
dataset.as_pascal_voc(
|
||||
images_directory_path=...,
|
||||
annotations_directory_path=...
|
||||
)
|
||||
dataset.as_pascal_voc(
|
||||
images_directory_path=...,
|
||||
annotations_directory_path=...
|
||||
)
|
||||
|
||||
dataset.as_coco(
|
||||
images_directory_path=...,
|
||||
annotations_path=...
|
||||
)
|
||||
```
|
||||
dataset.as_coco(
|
||||
images_directory_path=...,
|
||||
annotations_path=...
|
||||
)
|
||||
```
|
||||
|
||||
- convert
|
||||
|
||||
```python
|
||||
sv.DetectionDataset.from_yolo(
|
||||
images_directory_path=...,
|
||||
annotations_directory_path=...,
|
||||
data_yaml_path=...
|
||||
).as_pascal_voc(
|
||||
images_directory_path=...,
|
||||
annotations_directory_path=...
|
||||
)
|
||||
```
|
||||
```python
|
||||
sv.DetectionDataset.from_yolo(
|
||||
images_directory_path=...,
|
||||
annotations_directory_path=...,
|
||||
data_yaml_path=...
|
||||
).as_pascal_voc(
|
||||
images_directory_path=...,
|
||||
annotations_directory_path=...
|
||||
)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -353,7 +353,7 @@
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install -q ultralytics"
|
||||
"!pip install -q \"ultralytics<=8.3.40\""
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,16 +7,6 @@ comments: true
|
||||
Supervision offers an assets download utility that allows you to download video files
|
||||
that you can use in your demos.
|
||||
|
||||
## Install extra
|
||||
|
||||
To install the Supervision assets utility, you can use `pip`. This utility is available
|
||||
as an extra within the Supervision package.
|
||||
|
||||
!!! example "pip install"
|
||||
```bash
|
||||
pip install "supervision[assets]"
|
||||
```
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.assets.downloader.download_assets.download_assets">download_assets</a></h2>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,149 @@
|
||||
# CHANGELOG
|
||||
|
||||
### 0.25.0 <small>Nov 12, 2024</small>
|
||||
|
||||
- No removals or deprecations in this release!
|
||||
|
||||
- Essential update to the [`LineZone`](https://supervision.roboflow.com/0.25.0/detection/tools/line_zone/): when computing line crossings, detections that jitter might be counted twice (or more). This can now be solved with the `minimum_crossing_threshold` argument. If you set it to `2` or more, extra frames will be used to confirm the crossing, improving the accuracy significantly. ([#1540](https://github.com/roboflow/supervision/pull/1540))
|
||||
|
||||
- It is now possible to track objects detected as [`KeyPoints`](https://supervision.roboflow.com/0.25.0/keypoint/core/#supervision.keypoint.core.KeyPoints). See the complete step-by-step guide in the [Object Tracking Guide](https://supervision.roboflow.com/latest/how_to/track_objects/#keypoints). ([#1658](https://github.com/roboflow/supervision/pull/1658))
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO("yolov8m-pose.pt")
|
||||
tracker = sv.ByteTrack()
|
||||
trace_annotator = sv.TraceAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
results = model(frame)[0]
|
||||
key_points = sv.KeyPoints.from_ultralytics(results)
|
||||
|
||||
detections = key_points.as_detections()
|
||||
detections = tracker.update_with_detections(detections)
|
||||
|
||||
annotated_image = trace_annotator.annotate(frame.copy(), detections)
|
||||
return annotated_image
|
||||
|
||||
sv.process_video(
|
||||
source_path="input_video.mp4",
|
||||
target_path="output_video.mp4",
|
||||
callback=callback
|
||||
)
|
||||
```
|
||||
|
||||
- Added `is_empty` method to [`KeyPoints`](https://supervision.roboflow.com/0.25.0/keypoint/core/#supervision.keypoint.core.KeyPoints) to check if there are any keypoints in the object. ([#1658](https://github.com/roboflow/supervision/pull/1658))
|
||||
|
||||
- Added `as_detections` method to [`KeyPoints`](https://supervision.roboflow.com/0.25.0/keypoint/core/#supervision.keypoint.core.KeyPoints) that converts `KeyPoints` to `Detections`. ([#1658](https://github.com/roboflow/supervision/pull/1658))
|
||||
|
||||
- Added a new video to `supervision[assets]`. ([#1657](https://github.com/roboflow/supervision/pull/1657))
|
||||
|
||||
```python
|
||||
from supervision.assets import download_assets, VideoAssets
|
||||
|
||||
path_to_video = download_assets(VideoAssets.SKIING)
|
||||
```
|
||||
|
||||
- Supervision can now be used with [`Python 3.13`](https://docs.python.org/3/whatsnew/3.13.html). The most renowned update is the ability to run Python [without Global Interpreter Lock (GIL)](https://docs.python.org/3/whatsnew/3.13.html#whatsnew313-free-threaded-cpython). We expect support for this among our dependencies to be inconsistent, but if you do attempt it - let us know the results! ([#1595](https://github.com/roboflow/supervision/pull/1595))
|
||||
|
||||
- Added [`Mean Average Recall`](https://supervision.roboflow.com/latest/metrics/mean_average_recall/) mAR metric, which returns a recall score, averaged over IoU thresholds, detected object classes, and limits imposed on maximum considered detections. ([#1661](https://github.com/roboflow/supervision/pull/1661))
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
from supervision.metrics import MeanAverageRecall
|
||||
|
||||
predictions = sv.Detections(...)
|
||||
targets = sv.Detections(...)
|
||||
|
||||
map_metric = MeanAverageRecall()
|
||||
map_result = map_metric.update(predictions, targets).compute()
|
||||
|
||||
map_result.plot()
|
||||
```
|
||||
|
||||
- Added [`Precision`](https://supervision.roboflow.com/latest/metrics/precision/) and [`Recall`](https://supervision.roboflow.com/latest/metrics/recall/) metrics, providing a baseline for comparing model outputs to ground truth or another model ([#1609](https://github.com/roboflow/supervision/pull/1609))
|
||||
|
||||
```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()
|
||||
|
||||
recall_result.plot()
|
||||
```
|
||||
|
||||
- All Metrics now support Oriented Bounding Boxes (OBB) ([#1593](https://github.com/roboflow/supervision/pull/1593))
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
from supervision.metrics import F1_Score
|
||||
|
||||
predictions = sv.Detections(...)
|
||||
targets = sv.Detections(...)
|
||||
|
||||
f1_metric = MeanAverageRecall(metric_target=sv.MetricTarget.ORIENTED_BOUNDING_BOXES)
|
||||
f1_result = f1_metric.update(predictions, targets).compute()
|
||||
```
|
||||
|
||||
- Introducing Smart Labels! When `smart_position` is set for [`LabelAnnotator`](https://supervision.roboflow.com/0.25.0/detection/annotators/#supervision.annotators.core.LabelAnnotator), [`RichLabelAnnotator`](https://supervision.roboflow.com/0.25.0/detection/annotators/#supervision.annotators.core.RichLabelAnnotator) or [`VertexLabelAnnotator`](https://supervision.roboflow.com/0.25.0/detection/annotators/#supervision.annotators.core.RichLabelAnnotator), the labels will move around to avoid overlapping others. ([#1625](https://github.com/roboflow/supervision/pull/1625))
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
image = cv2.imread("image.jpg")
|
||||
|
||||
label_annotator = sv.LabelAnnotator(smart_position=True)
|
||||
|
||||
model = YOLO("yolo11m.pt")
|
||||
results = model(image)[0]
|
||||
detections = sv.Detections.from_ultralytics(results)
|
||||
|
||||
annotated_frame = label_annotator.annotate(first_frame.copy(), detections)
|
||||
sv.plot_image(annotated_frame)
|
||||
```
|
||||
|
||||
- Added the `metadata` variable to [`Detections`](https://supervision.roboflow.com/0.25.0/detection/core/#supervision.detection.core.Detections). It allows you to store custom data per-image, rather than per-detected-object as was possible with `data` variable. For example, `metadata` could be used to store the source video path, camera model or camera parameters. ([#1589](https://github.com/roboflow/supervision/pull/1589))
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO("yolov8m")
|
||||
|
||||
result = model("image.png")[0]
|
||||
detections = sv.Detections.from_ultralytics(result)
|
||||
|
||||
# Items in `data` must match length of detections
|
||||
object_ids = [num for num in range(len(detections))]
|
||||
detections.data["object_number"] = object_ids
|
||||
|
||||
# Items in `metadata` can be of any length.
|
||||
detections.metadata["camera_model"] = "Luxonis OAK-D"
|
||||
```
|
||||
|
||||
- Added a `py.typed` type hints metafile. It should provide a stronger signal to type annotators and IDEs that type support is available. ([#1586](https://github.com/roboflow/supervision/pull/1586))
|
||||
|
||||
- `ByteTrack` no longer requires `detections` to have a `class_id` ([#1637](https://github.com/roboflow/supervision/pull/1637))
|
||||
- `draw_line`, `draw_rectangle`, `draw_filled_rectangle`, `draw_polygon`, `draw_filled_polygon` and `PolygonZoneAnnotator` now comes with a default color ([#1591](https://github.com/roboflow/supervision/pull/1591))
|
||||
- Dataset classes are treated as case-sensitive when merging multiple datasets. ([#1643](https://github.com/roboflow/supervision/pull/1643))
|
||||
- Expanded [metrics documentation](https://supervision.roboflow.com/0.25.0/metrics/f1_score/) with example plots and printed results ([#1660](https://github.com/roboflow/supervision/pull/1660))
|
||||
- Added usage example for polygon zone ([#1608](https://github.com/roboflow/supervision/pull/1608))
|
||||
- Small improvements to error handling in polygons: ([#1602](https://github.com/roboflow/supervision/pull/1602))
|
||||
|
||||
- Updated [`ByteTrack`](https://supervision.roboflow.com/0.25.0/trackers/#supervision.tracker.byte_tracker.core.ByteTrack), removing shared variables. Previously, multiple instances of `ByteTrack` would share some date, requiring liberal use of `tracker.reset()`. ([#1603](https://github.com/roboflow/supervision/pull/1603)), ([#1528](https://github.com/roboflow/supervision/pull/1528))
|
||||
- Fixed a bug where `class_agnostic` setting in `MeanAveragePrecision` would not work. ([#1577](https://github.com/roboflow/supervision/pull/1577)) hacktoberfest
|
||||
- Removed welcome workflow from our CI system. ([#1596](https://github.com/roboflow/supervision/pull/1596))
|
||||
|
||||
- Large refactor of `ByteTrack`: STrack moved to separate class, removed superfluous `BaseTrack` class, removed unused variables ([#1603](https://github.com/roboflow/supervision/pull/1603))
|
||||
- Large refactor of `RichLabelAnnotator`, matching its contents with `LabelAnnotator`. ([#1625](https://github.com/roboflow/supervision/pull/1625))
|
||||
|
||||
### 0.24.0 <small>Oct 4, 2024</small>
|
||||
|
||||
- Added [F1 score](https://supervision.roboflow.com/0.24.0/metrics/f1_score/#supervision.metrics.f1_score.F1Score) as a new metric for detection and segmentation. [#1521](https://github.com/roboflow/supervision/pull/1521)
|
||||
|
||||
@@ -19,6 +19,10 @@ These features are phased out due to better alternatives or potential issues in
|
||||
|
||||
# Removed
|
||||
|
||||
### 0.25.0
|
||||
|
||||
No removals in this version!
|
||||
|
||||
### 0.24.0
|
||||
|
||||
- The `frame_resolution_wh ` parameter in [`sv.PolygonZone`](detection/tools/polygon_zone.md/#supervision.detection.tools.polygon_zone.PolygonZone) has been removed.
|
||||
|
||||
@@ -28,26 +28,28 @@ Annotators accept detections and apply box or mask visualizations to the detecti
|
||||
|
||||
</div>
|
||||
|
||||
=== "RoundBox"
|
||||
<!--
|
||||
=== "RoundBox"
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
detections = sv.Detections(...)
|
||||
image = ...
|
||||
detections = sv.Detections(...)
|
||||
|
||||
round_box_annotator = sv.RoundBoxAnnotator()
|
||||
annotated_frame = round_box_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
detections=detections
|
||||
)
|
||||
```
|
||||
round_box_annotator = sv.RoundBoxAnnotator()
|
||||
annotated_frame = round_box_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
detections=detections
|
||||
)
|
||||
```
|
||||
|
||||
<div class="result" markdown>
|
||||
<div class="result" markdown>
|
||||
|
||||
{ align=center width="800" }
|
||||
{ align=center width="800" }
|
||||
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
=== "BoxCorner"
|
||||
|
||||
@@ -234,7 +236,8 @@ Annotators accept detections and apply box or mask visualizations to the detecti
|
||||
|
||||
<div class="result" markdown>
|
||||
|
||||
{ align=center width="800" }
|
||||
{ align=center width="800" }
|
||||
|
||||
</div>
|
||||
|
||||
@@ -255,7 +258,8 @@ Annotators accept detections and apply box or mask visualizations to the detecti
|
||||
|
||||
<div class="result" markdown>
|
||||
|
||||
{ align=center width="800" }
|
||||
{ align=center width="800" }
|
||||
|
||||
</div>
|
||||
|
||||
@@ -283,7 +287,8 @@ Annotators accept detections and apply box or mask visualizations to the detecti
|
||||
|
||||
<div class="result" markdown>
|
||||
|
||||
{ align=center width="800" }
|
||||
{ align=center width="800" }
|
||||
|
||||
</div>
|
||||
|
||||
@@ -314,7 +319,8 @@ Annotators accept detections and apply box or mask visualizations to the detecti
|
||||
|
||||
<div class="result" markdown>
|
||||
|
||||
{ align=center width="800" }
|
||||
{ align=center width="800" }
|
||||
|
||||
</div>
|
||||
|
||||
@@ -341,24 +347,32 @@ Annotators accept detections and apply box or mask visualizations to the detecti
|
||||
|
||||
<div class="result" markdown>
|
||||
|
||||
{ align=center width="800" }
|
||||
{ align=center width="800" }
|
||||
|
||||
</div>
|
||||
|
||||
=== "Crop"
|
||||
<!-- === "Crop"
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
detections = sv.Detections(...)
|
||||
image = ...
|
||||
detections = sv.Detections(...)
|
||||
|
||||
crop_annotator = sv.CropAnnotator()
|
||||
annotated_frame = crop_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
detections=detections
|
||||
)
|
||||
```
|
||||
crop_annotator = sv.CropAnnotator()
|
||||
annotated_frame = crop_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
detections=detections
|
||||
)
|
||||
```
|
||||
<div class="result" markdown>
|
||||
|
||||
{ align=center width="800" }
|
||||
|
||||
</div>
|
||||
-->
|
||||
|
||||
=== "Blur"
|
||||
|
||||
@@ -377,7 +391,8 @@ Annotators accept detections and apply box or mask visualizations to the detecti
|
||||
|
||||
<div class="result" markdown>
|
||||
|
||||
{ align=center width="800" }
|
||||
{ align=center width="800" }
|
||||
|
||||
</div>
|
||||
|
||||
@@ -398,7 +413,8 @@ Annotators accept detections and apply box or mask visualizations to the detecti
|
||||
|
||||
<div class="result" markdown>
|
||||
|
||||
{ align=center width="800" }
|
||||
{ align=center width="800" }
|
||||
|
||||
</div>
|
||||
|
||||
@@ -429,7 +445,8 @@ Annotators accept detections and apply box or mask visualizations to the detecti
|
||||
|
||||
<div class="result" markdown>
|
||||
|
||||
{ align=center width="800" }
|
||||
{ align=center width="800" }
|
||||
|
||||
</div>
|
||||
|
||||
@@ -458,7 +475,8 @@ Annotators accept detections and apply box or mask visualizations to the detecti
|
||||
|
||||
<div class="result" markdown>
|
||||
|
||||
{ align=center width="800" }
|
||||
{ align=center width="800" }
|
||||
|
||||
</div>
|
||||
|
||||
@@ -479,7 +497,31 @@ Annotators accept detections and apply box or mask visualizations to the detecti
|
||||
|
||||
<div class="result" markdown>
|
||||
|
||||

|
||||
{ align=center width="800" }
|
||||
|
||||
</div>
|
||||
|
||||
=== "Comparison"
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
detections_1 = sv.Detections(...)
|
||||
detections_2 = sv.Detections(...)
|
||||
|
||||
comparison_annotator = sv.ComparisonAnnotator()
|
||||
annotated_frame = comparison_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
detections_1=detections_1,
|
||||
detections_2=detections_2
|
||||
)
|
||||
```
|
||||
|
||||
<div class="result" markdown>
|
||||
|
||||
{ align=center width="800" }
|
||||
|
||||
</div>
|
||||
|
||||
@@ -622,6 +664,12 @@ Annotators accept detections and apply box or mask visualizations to the detecti
|
||||
|
||||
:::supervision.annotators.core.BackgroundOverlayAnnotator
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.annotators.core.ComparisonAnnotator">ComparisonAnnotator</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.annotators.core.ComparisonAnnotator
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.annotators.core.ColorLookup">ColorLookup</a></h2>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# InferenceSlicer
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
<div class="md-typeset">
|
||||
|
||||
@@ -89,6 +89,18 @@ status: new
|
||||
|
||||
:::supervision.detection.utils.xywh_to_xyxy
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.detection.utils.xyxy_to_xywh">xyxy_to_xywh</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.detection.utils.xyxy_to_xywh
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.detection.utils.xyxy_to_xcycarh">xyxy_to_xcycarh</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.detection.utils.xyxy_to_xcycarh
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.detection.utils.xcycwh_to_xyxy">xcycwh_to_xyxy</a></h2>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||

|
||||
|
||||
# Benchmark a Model
|
||||
|
||||
Have you ever trained multiple detection models and wondered which one performs best on your specific use case? Or maybe you've downloaded a pre-trained model and want to verify its performance on your dataset? Model benchmarking is essential for making informed decisions about which model to deploy in production.
|
||||
|
||||
This guide will show an easy way to benchmark your results using `supervision`. It will go over:
|
||||
|
||||
1. [Loading a dataset](#loading-a-dataset)
|
||||
2. [Loading a model](#loading-a-model)
|
||||
3. [Benchmarking Basics](#benchmarking-basics)
|
||||
4. [Running a Model](#running-a-model)
|
||||
5. [Remapping Classes](#remapping-classes)
|
||||
6. [Visual Benchmarking](#visual-benchmarking)
|
||||
7. [Benchmarking Metrics](#benchmarking-metrics)
|
||||
8. [Mean Average Precision (mAP)](#mean-average-precision-map)
|
||||
9. [F1 Score](#f1-score)
|
||||
10. [Bonus: Model Leaderboard](#model-leaderboard)
|
||||
|
||||
This guide will use an instance segmentation model, but it applies to object detection, instance segmentation, and oriented bounding box models (OBB) too.
|
||||
|
||||
A condensed version of this guide is available as a [Colab Notebook](https://colab.research.google.com/drive/1HoOY9pZoVwGiRMmLHtir0qT6Uj45w6Ps?usp=sharing).
|
||||
|
||||
## Loading a Dataset
|
||||
|
||||
Suppose you start with a dataset. Perhaps you found it on [Universe](https://universe.roboflow.com/); perhaps you [labeled your own](https://roboflow.com/how-to-label/yolo11). In either case, this guide assumes you know of a labelled dataset at hand.
|
||||
|
||||
We'll use the following libraries:
|
||||
|
||||
- `roboflow` to manage the dataset and deploy models
|
||||
- `inference` to run the models
|
||||
- `supervision` to evaluate the model results
|
||||
|
||||
```bash
|
||||
pip install roboflow supervision
|
||||
pip install git+https://github.com/roboflow/inference.git@linas/allow-latest-rc-supervision
|
||||
```
|
||||
|
||||
!!! info
|
||||
|
||||
We're updating `inference` at the moment. Please install it as shown above.
|
||||
|
||||
Here's how you can download a dataset:
|
||||
|
||||
```python
|
||||
from roboflow import Roboflow
|
||||
|
||||
rf = Roboflow(api_key="<YOUR_API_KEY>")
|
||||
project = rf.workspace("<WORKSPACE_NAME>").project("<PROJECT_NAME>")
|
||||
dataset = project.version(<DATASET_VERSION_NUMBER>).download("<FORMAT>")
|
||||
```
|
||||
|
||||
If your dataset is from Universe, go to `Dataset` > `Download Dataset` > select the format (e.g. `YOLOv11`) > `Show download code`.
|
||||
|
||||
If labeling your own data, go to the [dashboard](https://app.roboflow.com/) and check this [guide](https://docs.roboflow.com/api-reference/workspace-and-project-ids) to find your workspace and project IDs.
|
||||
|
||||
In this guide, we shall use a small [Corgi v2](https://universe.roboflow.com/model-examples/segmented-animals-basic) dataset. It is well-labeled and comes with a test set.
|
||||
|
||||
```python
|
||||
from roboflow import Roboflow
|
||||
|
||||
rf = Roboflow(api_key="<YOUR_API_KEY>")
|
||||
project = rf.workspace("fbamse1-gm2os").project("corgi-v2")
|
||||
dataset = project.version(4).download("yolov11")
|
||||
```
|
||||
|
||||
This will create a folder called `Corgi-v2-4` with the dataset in the current working directory, with `train`, `test`, and `valid` folders and a `data.yaml` file.
|
||||
|
||||
## Loading a Model
|
||||
|
||||
Let's load a model.
|
||||
|
||||
=== "Inference, Local"
|
||||
|
||||
Roboflow supports a range of state-of-the-art [pre-trained models](https://inference.roboflow.com/quickstart/aliases/) for object detection, instance segmentation, and pose tracking. You don't even need an API key!
|
||||
|
||||
Let's load such a model with inference [`inference`](https://inference.roboflow.com/).
|
||||
|
||||
```python
|
||||
from inference import get_model
|
||||
|
||||
model = get_model(model_id="yolov11s-seg-640")
|
||||
```
|
||||
|
||||
=== "Inference, Deployed"
|
||||
|
||||
You can train and deploy a model without leaving the Roboflow platform. See this [guide](https://docs.roboflow.com/train/train/train-from-scratch) for more details.
|
||||
|
||||
To load a model, you can use inference:
|
||||
|
||||
```python
|
||||
from inference import get_model
|
||||
|
||||
model_id = "<PROJECT_NAME>/<MODEL_VERSION>"
|
||||
model = get_model(model_id=model_id)
|
||||
```
|
||||
|
||||
=== "Ultralytics"
|
||||
|
||||
Similarly to Inference, Ultralytics allows you to run a variety of models.
|
||||
|
||||
```bash
|
||||
pip install "ultralytics<=8.3.40"
|
||||
```
|
||||
|
||||
```python
|
||||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO("yolo11s-seg.pt")
|
||||
```
|
||||
|
||||
## Benchmarking Basics
|
||||
|
||||
Evaluating your model requires careful selection of the dataset. Which images should you use?Let's go over the different scenarios.
|
||||
|
||||
- **Unrelated Dataset**: If you have a dataset that was not used to train the model, this is the best choice.
|
||||
- **Training Set**: This is the set of images used to train the model. This is fine if the model was not trained on this dataset. Otherwise, **never** use it for benchmarking - the results will seem unrealistically good.
|
||||
- **Validation Set**: This is the set of images used to validate the model during training. Every Nth training epoch, the model is evaluated on the validation set. Often the training is stopped once the validation loss stops improving. Therefore, even while the images aren't used to train the model, it still indirectly influences the training outcome.
|
||||
- **Test Set**: This is the set of images kept aside for model testing. It is exactly the set you should use for benchmarking. If the dataset was split correctly, none of these images would be shown to the model during training.
|
||||
|
||||
Therefore, an unrelated dataset or the `test` set is the best choice for benchmarking.
|
||||
Several other problems may arise:
|
||||
|
||||
- **Extra Classes**: An unrelated dataset may contain additional classes which you may need to [filter out](https://supervision.roboflow.com/how_to/filter_detections/#by-set-of-classes) before computing metrics.
|
||||
- **Class Mismatch**: In an unrelated dataset, the class names or IDs may be different to what your model produces, you'll need to remap them, which is [shown in this guide](#running-a-model).
|
||||
- **Data Contamination**: The `test` set may not be split correctly, with images from the test set also present in `training` or `validation` set and used during training. In this case, the results will be overly optimistic. This also applies when **very similar** images are used for training and testing - e.g. those taken in the same environment, same lighting conditions, similar angle, etc.
|
||||
- **Missing Test Set**: Some datasets do not come with a test set. In this case, you should collect and [label](https://roboflow.com/annotate) your own data. Alternatively, a validation set could be used, but the results could be overly optimistic. Make sure to test in the real world as soon as possible.
|
||||
|
||||
## Running a Model
|
||||
|
||||
At this stage, you should have:
|
||||
|
||||
- A dataset of labeled images to evaluate the model.
|
||||
- A model prepared for benchmarking.
|
||||
|
||||
With these ready, we can now run the model and obtain predictions.
|
||||
We'll use `supervision` to create a dataset iterator, and then run the model on each image.
|
||||
|
||||
=== "Inference"
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
test_set = sv.DetectionDataset.from_yolo(
|
||||
images_directory_path=f"{dataset.location}/test/images",
|
||||
annotations_directory_path=f"{dataset.location}/test/labels",
|
||||
data_yaml_path=f"{dataset.location}/data.yaml"
|
||||
)
|
||||
|
||||
image_paths = []
|
||||
predictions_list = []
|
||||
targets_list = []
|
||||
|
||||
for image_path, image, label in test_set:
|
||||
result = model.infer(image)[0]
|
||||
predictions = sv.Detections.from_inference(result)
|
||||
|
||||
image_paths.append(image_path)
|
||||
predictions_list.append(predictions)
|
||||
targets_list.append(label)
|
||||
```
|
||||
|
||||
=== "Ultralytics"
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
test_set = sv.DetectionDataset.from_yolo(
|
||||
images_directory_path=f"{dataset.location}/test/images",
|
||||
annotations_directory_path=f"{dataset.location}/test/labels",
|
||||
data_yaml_path=f"{dataset.location}/data.yaml"
|
||||
)
|
||||
|
||||
image_paths = []
|
||||
predictions_list = []
|
||||
targets_list = []
|
||||
|
||||
for image_path, image, label in test_set:
|
||||
result = model(image)[0]
|
||||
predictions = sv.Detections.from_ultralytics(result)
|
||||
|
||||
image_paths.append(image_path)
|
||||
predictions_list.append(predictions)
|
||||
targets_list.append(label)
|
||||
```
|
||||
|
||||
## Remapping classes
|
||||
|
||||
Did you notice an issue in the above logic?
|
||||
Since we're using an unrelated dataset, the class names and IDs may be different from what the model was trained on.
|
||||
|
||||
We need to remap them to match the dataset classes. Here's how to do it:
|
||||
|
||||
```python
|
||||
def remap_classes(
|
||||
detections: sv.Detections,
|
||||
class_ids_from_to: dict[int, int],
|
||||
class_names_from_to: dict[str, str]
|
||||
) -> None:
|
||||
new_class_ids = [
|
||||
class_ids_from_to.get(class_id, class_id) for class_id in detections.class_id]
|
||||
detections.class_id = np.array(new_class_ids)
|
||||
|
||||
new_class_names = [
|
||||
class_names_from_to.get(name, name) for name in detections["class_name"]]
|
||||
predictions["class_name"] = np.array(new_class_names)
|
||||
```
|
||||
|
||||
Let's also remove the predictions that are not in the dataset classes.
|
||||
|
||||
=== "Inference"
|
||||
|
||||
Dataset class names and IDs can be found in the `data.yaml` file, or by printing `dataset.classes`.
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
test_set = sv.DetectionDataset.from_yolo(
|
||||
images_directory_path=f"{dataset.location}/test/images",
|
||||
annotations_directory_path=f"{dataset.location}/test/labels",
|
||||
data_yaml_path=f"{dataset.location}/data.yaml"
|
||||
)
|
||||
|
||||
image_paths = []
|
||||
predictions_list = []
|
||||
targets_list = []
|
||||
|
||||
for image_path, image, label in test_set:
|
||||
result = model.infer(image)[0]
|
||||
predictions = sv.Detections.from_inference(result)
|
||||
|
||||
remap_classes(
|
||||
detections=predictions,
|
||||
class_ids_from_to={16: 0},
|
||||
class_names_from_to={"dog": "Corgi"}
|
||||
)
|
||||
predictions = predictions[
|
||||
np.isin(predictions["class_name"], test_set.classes)
|
||||
]
|
||||
|
||||
image_paths.append(image_path)
|
||||
predictions_list.append(predictions)
|
||||
targets_list.append(label)
|
||||
```
|
||||
|
||||
=== "Ultralytics"
|
||||
|
||||
Dataset class names and IDs can be found in the `data.yaml` file, or by printing `dataset.classes`.
|
||||
|
||||
Each model will have a different class mapping, so make sure to check the model's documentation. In this case, the model was trained on the COCO dataset, with a class
|
||||
configuration found [here](https://github.com/ultralytics/ultralytics/blob/main/ultralytics/cfg/datasets/coco8.yaml).
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
test_set = sv.DetectionDataset.from_yolo(
|
||||
images_directory_path=f"{dataset.location}/test/images",
|
||||
annotations_directory_path=f"{dataset.location}/test/labels",
|
||||
data_yaml_path=f"{dataset.location}/data.yaml"
|
||||
)
|
||||
|
||||
image_paths = []
|
||||
predictions_list = []
|
||||
targets_list = []
|
||||
|
||||
for image_path, image, label in test_set:
|
||||
result = model(image)[0]
|
||||
predictions = sv.Detections.from_ultralytics(result)
|
||||
|
||||
remap_classes(
|
||||
detections=predictions,
|
||||
class_ids_from_to={16: 0},
|
||||
class_names_from_to={"dog": "Corgi"}
|
||||
)
|
||||
predictions = predictions[
|
||||
np.isin(predictions["class_name"], test_set.classes)
|
||||
]
|
||||
|
||||
image_paths.append(image_path)
|
||||
predictions_list.append(predictions)
|
||||
targets_list.append(label)
|
||||
```
|
||||
|
||||
## Visualizing Predictions
|
||||
|
||||
The first step in evaluating your model’s performance is to visualize its predictions.
|
||||
This gives an intuitive sense of how well your model is detecting objects and where it might be failing.
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
N = 9
|
||||
GRID_SIZE = (3, 3)
|
||||
|
||||
target_annotator = sv.PolygonAnnotator(color=sv.Color.from_hex("#8315f9"), thickness=8)
|
||||
prediction_annotator = sv.PolygonAnnotator(color=sv.Color.from_hex("#00cfc6"), thickness=6)
|
||||
|
||||
|
||||
annotated_images = []
|
||||
for image_path, predictions, targets in zip(
|
||||
image_paths[:N], predictions_list[:N], targets_list[:N]
|
||||
):
|
||||
annotated_image = cv2.imread(image_path)
|
||||
annotated_image = target_annotator.annotate(scene=annotated_image, detections=targets)
|
||||
annotated_image = prediction_annotator.annotate(scene=annotated_image, detections=prediction)
|
||||
annotated_images.append(annotated_image)
|
||||
|
||||
sv.plot_images_grid(images=annotated_images, grid_size=GRID_SIZE)
|
||||
```
|
||||
|
||||
Here, predictions in purple are targets (ground truth), and predictions in teal are model predictions.
|
||||
|
||||

|
||||
|
||||
!!! tip
|
||||
|
||||
Use `sv.BoxAnnotator` for object detection and `sv.OrientedBoxAnnotator` for OBB.
|
||||
|
||||
See [annotator documentation](https://supervision.roboflow.com/latest/detection/annotators/) for even more options.
|
||||
|
||||
## Benchmarking Metrics
|
||||
|
||||
With multiple models, fine details matter. Visual inspection may not be enough. `supervision` provides a collection of metrics that help obtain precise numerical results of model performance.
|
||||
|
||||
### Mean Average Precision (mAP)
|
||||
|
||||
We'll start with [MeanAveragePrecision (mAP)](https://supervision.roboflow.com/latest/metrics/mean_average_precision/#supervision.metrics.mean_average_precision.MeanAveragePrecision), which is the most commonly used metric for object detection. It measures the average precision across all classes and IoU thresholds.
|
||||
|
||||
For a thorough explanation, check out our [blog](https://blog.roboflow.com/mean-average-precision/) and [Youtube video](https://www.youtube.com/watch?v=oqXDdxF_Wuw).
|
||||
|
||||
Here, the most popular value is `mAP 50:95`. It represents the average precision across all classes and IoU thresholds (`0.5` to `0.95`), whereas other values such as `mAP 50` or `mAP 75` only consider a single IoU threshold (`0.5` and `0.75` respectively).
|
||||
|
||||
Let's compute the mAP:
|
||||
|
||||
```python
|
||||
from supervision.metrics import MeanAveragePrecision, MetricTarget
|
||||
|
||||
map_metric = MeanAveragePrecision(metric_target=MetricTarget.MASKS)
|
||||
map_result = map_metric.update(predictions_list, targets_list).compute()
|
||||
```
|
||||
|
||||
Try printing the result to see it at a glance:
|
||||
|
||||
```python
|
||||
print(map_result)
|
||||
```
|
||||
|
||||
```
|
||||
MeanAveragePrecisionResult:
|
||||
Metric target: MetricTarget.MASKS
|
||||
Class agnostic: False
|
||||
mAP @ 50:95: 0.2409
|
||||
mAP @ 50: 0.3591
|
||||
mAP @ 75: 0.2915
|
||||
mAP scores: [0.35909 0.3468 0.34556 ...]
|
||||
IoU thresh: [0.5 0.55 0.6 ...]
|
||||
AP per class:
|
||||
0: [0.35909 0.3468 0.34556 ...]
|
||||
...
|
||||
Small objects: ...
|
||||
Medium objects: ...
|
||||
Large objects: ...
|
||||
```
|
||||
|
||||
You can also plot the results:
|
||||
|
||||
```python
|
||||
map_result.plot()
|
||||
```
|
||||
|
||||

|
||||
|
||||
The metric also breaks down the results by detected object area. Small, medium and large are simply those with area less than 32², between 32² and 96², and greater than 96² pixels respectively.
|
||||
|
||||
### F1 Score
|
||||
|
||||
The [F1 Score](https://supervision.roboflow.com/latest/metrics/f1_score/) is another useful metric, especially when dealing with an imbalance between false positives and false negatives. It’s the harmonic mean of **precision** (how many predictions are correct) and **recall** (how many actual instances were detected).
|
||||
|
||||
Here's how you can compute the F1 score:
|
||||
|
||||
```python
|
||||
from supervision.metrics import F1Score, MetricTarget
|
||||
|
||||
f1_metric = F1Score(metric_target=MetricTarget.MASKS)
|
||||
f1_result = f1_metric.update(predictions_list, targets_list).compute()
|
||||
```
|
||||
|
||||
As with mAP, you can also print the result:
|
||||
|
||||
```python
|
||||
print(f1_result)
|
||||
```
|
||||
|
||||
```
|
||||
F1ScoreResult:
|
||||
Metric target: MetricTarget.MASKS
|
||||
Averaging method: AveragingMethod.WEIGHTED
|
||||
F1 @ 50: 0.5341
|
||||
F1 @ 75: 0.4636
|
||||
F1 @ thresh: [0.53406 0.5278 0.52153 ...]
|
||||
IoU thresh: [0.5 0.55 0.6 ...]
|
||||
F1 per class:
|
||||
0: [0.53406 0.5278 0.52153 ...]
|
||||
...
|
||||
Small objects: ...
|
||||
Medium objects: ...
|
||||
Large objects: ...
|
||||
```
|
||||
|
||||
Similarly, you can plot the results:
|
||||
|
||||
```python
|
||||
f1_result.plot()
|
||||
```
|
||||
|
||||

|
||||
|
||||
As with mAP, the metric also breaks down the results by detected object area. Small, medium and large are simply those with area less than 32², between 32² and 96², and greater than 96² pixels respectively.
|
||||
|
||||
## Model Leaderboard
|
||||
|
||||
Here to compare the basic models? We've got you covered. Check out our [Model Leaderboard](https://leaderboard.roboflow.com/) to see how different models perform and to get a sense of the state-of-the-art results. It's a great place to understand what the leading models can achieve and to compare your own results.
|
||||
|
||||
Even better, the repository is open source! You can see how the models were benchmarked, run the evaluation yourself, and even add your own models to the leaderboard. Check it out on [GitHub](https://github.com/roboflow/model-leaderboard)!
|
||||
|
||||

|
||||
|
||||
## Conclusion
|
||||
|
||||
In this guide, you've learned how to set up your environment, train or use pre-trained models, visualize predictions, and evaluate model performance with metrics like [mAP](https://supervision.roboflow.com/latest/metrics/mean_average_precision/), [F1 score](https://supervision.roboflow.com/latest/metrics/f1_score/), and got to know our Model Leaderboard.
|
||||
|
||||
A condensed version of this guide is also available as a [Colab Notebook](https://colab.research.google.com/drive/1HoOY9pZoVwGiRMmLHtir0qT6Uj45w6Ps?usp=sharing).
|
||||
|
||||
For more details, be sure to check out our [documentation](https://supervision.roboflow.com/latest/) and join our community discussions. If you find any issues, please let us know on [GitHub](https://github.com/roboflow/supervision/issues).
|
||||
|
||||
Best of luck with your benchmarking!
|
||||
+361
-16
@@ -1,18 +1,21 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Track Objects
|
||||
|
||||
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,
|
||||
you'll discover how to track these objects efficiently and annotate your video content
|
||||
for a deeper analysis.
|
||||
|
||||
## Object Detection & Segmentation
|
||||
|
||||
To make it easier for you to follow our tutorial download the video we will use as an
|
||||
example. You can do this using
|
||||
[`supervision[assets]`](/latest/assets/) extension.
|
||||
@@ -27,7 +30,7 @@ download_assets(VideoAssets.PEOPLE_WALKING)
|
||||
<source src="https://media.roboflow.com/supervision/video-examples/people-walking.mp4" type="video/mp4">
|
||||
</video>
|
||||
|
||||
## Run Inference
|
||||
### Run Inference
|
||||
|
||||
First, you'll need to obtain predictions from your object detection or segmentation
|
||||
model. In this tutorial, we are using the YOLOv8 model as an example. However,
|
||||
@@ -40,14 +43,19 @@ by obtaining model predictions and then annotating the frame based on these pred
|
||||
This `callback` function will be essential in the subsequent steps of the tutorial, as
|
||||
it will be modified to include tracking, labeling, and trace annotations.
|
||||
|
||||
!!! tip
|
||||
|
||||
Both object detection and segmentation models are supported. Try it with `yolov8n.pt` or `yolov8n-640-seg`!
|
||||
|
||||
=== "Ultralytics"
|
||||
|
||||
```{ .py }
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO("yolov8n.pt")
|
||||
box_annotator = sv.BoundingBoxAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
results = model(frame)[0]
|
||||
@@ -62,13 +70,14 @@ it will be modified to include tracking, labeling, and trace annotations.
|
||||
```
|
||||
|
||||
=== "Inference"
|
||||
|
||||
```{ .py }
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
from inference.models.utils import get_roboflow_model
|
||||
|
||||
model = get_roboflow_model(model_id="yolov8n-640", api_key=<ROBOFLOW API KEY>)
|
||||
box_annotator = sv.BoundingBoxAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
results = model.infer(frame)[0]
|
||||
@@ -86,7 +95,7 @@ it will be modified to include tracking, labeling, and trace annotations.
|
||||
<source src="https://media.roboflow.com/supervision/video-examples/how-to/track-objects/run-inference.mp4" type="video/mp4">
|
||||
</video>
|
||||
|
||||
## Tracking
|
||||
### Tracking
|
||||
|
||||
After running inference and obtaining predictions, the next step is to track the
|
||||
detected objects throughout the video. Utilizing Supervision’s
|
||||
@@ -95,6 +104,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
|
||||
@@ -102,7 +112,7 @@ enabling the continuous following of the object's motion path across different f
|
||||
|
||||
model = YOLO("yolov8n.pt")
|
||||
tracker = sv.ByteTrack()
|
||||
box_annotator = sv.BoundingBoxAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
results = model(frame)[0]
|
||||
@@ -118,6 +128,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
|
||||
@@ -125,7 +136,7 @@ enabling the continuous following of the object's motion path across different f
|
||||
|
||||
model = get_roboflow_model(model_id="yolov8n-640", api_key=<ROBOFLOW API KEY>)
|
||||
tracker = sv.ByteTrack()
|
||||
box_annotator = sv.BoundingBoxAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
results = model.infer(frame)[0]
|
||||
@@ -140,7 +151,7 @@ enabling the continuous following of the object's motion path across different f
|
||||
)
|
||||
```
|
||||
|
||||
## Annotate Video with Tracking IDs
|
||||
### Annotate Video with Tracking IDs
|
||||
|
||||
Annotating the video with tracking IDs helps in distinguishing and following each object
|
||||
distinctly. With the
|
||||
@@ -149,6 +160,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
|
||||
@@ -156,7 +168,7 @@ offering a clear visual representation of each object's class and unique identif
|
||||
|
||||
model = YOLO("yolov8n.pt")
|
||||
tracker = sv.ByteTrack()
|
||||
box_annotator = sv.BoundingBoxAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
label_annotator = sv.LabelAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
@@ -183,6 +195,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
|
||||
@@ -190,7 +203,7 @@ offering a clear visual representation of each object's class and unique identif
|
||||
|
||||
model = get_roboflow_model(model_id="yolov8n-640", api_key=<ROBOFLOW API KEY>)
|
||||
tracker = sv.ByteTrack()
|
||||
box_annotator = sv.BoundingBoxAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
label_annotator = sv.LabelAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
@@ -220,7 +233,7 @@ offering a clear visual representation of each object's class and unique identif
|
||||
<source src="https://media.roboflow.com/supervision/video-examples/how-to/track-objects/annotate-video-with-tracking-ids.mp4" type="video/mp4">
|
||||
</video>
|
||||
|
||||
## Annotate Video with Traces
|
||||
### Annotate Video with Traces
|
||||
|
||||
Adding traces to the video involves overlaying the historical paths of the detected
|
||||
objects. This feature, powered by the
|
||||
@@ -229,6 +242,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
|
||||
@@ -236,7 +250,7 @@ movement patterns and interactions between objects in the video.
|
||||
|
||||
model = YOLO("yolov8n.pt")
|
||||
tracker = sv.ByteTrack()
|
||||
box_annotator = sv.BoundingBoxAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
label_annotator = sv.LabelAnnotator()
|
||||
trace_annotator = sv.TraceAnnotator()
|
||||
|
||||
@@ -266,6 +280,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
|
||||
@@ -273,7 +288,7 @@ movement patterns and interactions between objects in the video.
|
||||
|
||||
model = get_roboflow_model(model_id="yolov8n-640", api_key=<ROBOFLOW API KEY>)
|
||||
tracker = sv.ByteTrack()
|
||||
box_annotator = sv.BoundingBoxAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
label_annotator = sv.LabelAnnotator()
|
||||
trace_annotator = sv.TraceAnnotator()
|
||||
|
||||
@@ -306,6 +321,336 @@ 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>
|
||||
|
||||
This structured walkthrough should give a detailed pathway to annotate videos
|
||||
effectively using Supervision’s various functionalities, including object tracking and
|
||||
trace annotations.
|
||||
## Keypoints
|
||||
|
||||
Models aren't limited to object detection and segmentation. Keypoint detection allows for detailed analysis of body joints and connections, especially valuable for applications like human pose estimation. This section introduces keypoint tracking. We'll walk through the steps of annotating keypoints, converting them into bounding box detections compatible with `ByteTrack`, and applying detection smoothing for enhanced stability.
|
||||
|
||||
To make it easier for you to follow our tutorial, let's download the video we will use as an
|
||||
example. You can do this using [`supervision[assets]`](/latest/assets/) extension.
|
||||
|
||||
```python
|
||||
from supervision.assets import download_assets, VideoAssets
|
||||
|
||||
download_assets(VideoAssets.SKIING)
|
||||
```
|
||||
|
||||
<video controls muted>
|
||||
<source src="https://media.roboflow.com/supervision/video-examples/how-to/track-objects/skiing-hd.mp4" type="video/mp4">
|
||||
</video>
|
||||
|
||||
### Keypoint Detection
|
||||
|
||||
First, you'll need to obtain predictions from your keypoint detection model. In this tutorial, we are using the YOLOv8 model as an example. However,
|
||||
Supervision is versatile and compatible with various models. Check this [link](/latest/keypoint/core/) for guidance on how to plug in other models.
|
||||
|
||||
We will define a `callback` function, which will process each frame of the video by obtaining model predictions and then annotating the frame based on these predictions.
|
||||
|
||||
Let's immediately visualize the results with our [`EdgeAnnotator`](/latest/keypoint/annotators/#supervision.keypoint.annotators.EdgeAnnotator) and [`VertexAnnotator`](https://supervision.roboflow.com/latest/keypoint/annotators/#supervision.keypoint.annotators.VertexAnnotator).
|
||||
|
||||
=== "Ultralytics"
|
||||
|
||||
```{ .py hl_lines="5 10-11" }
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO("yolov8m-pose.pt")
|
||||
edge_annotator = sv.EdgeAnnotator()
|
||||
vertex_annotator = sv.VertexAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
results = model(frame)[0]
|
||||
key_points = sv.KeyPoints.from_ultralytics(results)
|
||||
|
||||
annotated_frame = edge_annotator.annotate(
|
||||
frame.copy(), key_points=key_points)
|
||||
return vertex_annotator.annotate(
|
||||
annotated_frame, key_points=key_points)
|
||||
|
||||
sv.process_video(
|
||||
source_path="skiing.mp4",
|
||||
target_path="result.mp4",
|
||||
callback=callback
|
||||
)
|
||||
```
|
||||
|
||||
=== "Inference"
|
||||
|
||||
```{ .py hl_lines="5-6 11-12" }
|
||||
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>)
|
||||
edge_annotator = sv.EdgeAnnotator()
|
||||
vertex_annotator = sv.VertexAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
results = model.infer(frame)[0]
|
||||
key_points = sv.KeyPoints.from_inference(results)
|
||||
|
||||
annotated_frame = edge_annotator.annotate(
|
||||
frame.copy(), key_points=key_points)
|
||||
return vertex_annotator.annotate(
|
||||
annotated_frame, key_points=key_points)
|
||||
|
||||
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-only-keypoints.mp4" type="video/mp4">
|
||||
</video>
|
||||
|
||||
### Convert to Detections
|
||||
|
||||
Keypoint tracking is currently supported via the conversion of `KeyPoints` to `Detections`. This is achieved with the [`KeyPoints.as_detections()`](/latest/keypoint/core/#supervision.keypoint.core.KeyPoints.as_detections) function.
|
||||
|
||||
Let's convert to detections and visualize the results with our [`BoxAnnotator`](/latest/detection/annotators/#supervision.annotators.core.BoxAnnotator).
|
||||
|
||||
!!! 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="8 13 19-20" }
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO("yolov8m-pose.pt")
|
||||
edge_annotator = sv.EdgeAnnotator()
|
||||
vertex_annotator = sv.VertexAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
results = model(frame)[0]
|
||||
key_points = sv.KeyPoints.from_ultralytics(results)
|
||||
detections = key_points.as_detections()
|
||||
|
||||
annotated_frame = edge_annotator.annotate(
|
||||
frame.copy(), key_points=key_points)
|
||||
annotated_frame = vertex_annotator.annotate(
|
||||
annotated_frame, key_points=key_points)
|
||||
return box_annotator.annotate(
|
||||
annotated_frame, detections=detections)
|
||||
|
||||
sv.process_video(
|
||||
source_path="skiing.mp4",
|
||||
target_path="result.mp4",
|
||||
callback=callback
|
||||
)
|
||||
```
|
||||
|
||||
=== "Inference"
|
||||
|
||||
```{ .py hl_lines="9 14 20-21" }
|
||||
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>)
|
||||
edge_annotator = sv.EdgeAnnotator()
|
||||
vertex_annotator = sv.VertexAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
results = model.infer(frame)[0]
|
||||
key_points = sv.KeyPoints.from_inference(results)
|
||||
detections = key_points.as_detections()
|
||||
|
||||
annotated_frame = edge_annotator.annotate(
|
||||
frame.copy(), key_points=key_points)
|
||||
annotated_frame = vertex_annotator.annotate(
|
||||
annotated_frame, key_points=key_points)
|
||||
return box_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-converted-to-detections.mp4" type="video/mp4">
|
||||
</video>
|
||||
|
||||
### Keypoint Tracking
|
||||
|
||||
Now that we have a `Detections` object, we can track it throughout the video. Utilizing Supervision’s [`sv.ByteTrack`](/latest/trackers/#supervision.tracker.byte_tracker.core.ByteTrack) functionality, each detected object is assigned a unique tracker ID, enabling the continuous following of the object's motion path across different frames. We shall visualize the result with `TraceAnnotator`.
|
||||
|
||||
=== "Ultralytics"
|
||||
|
||||
```{ .py hl_lines="10-11 17 25-26" }
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO("yolov8m-pose.pt")
|
||||
edge_annotator = sv.EdgeAnnotator()
|
||||
vertex_annotator = sv.VertexAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
|
||||
tracker = sv.ByteTrack()
|
||||
trace_annotator = sv.TraceAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
results = model(frame)[0]
|
||||
key_points = sv.KeyPoints.from_ultralytics(results)
|
||||
detections = key_points.as_detections()
|
||||
detections = tracker.update_with_detections(detections)
|
||||
|
||||
annotated_frame = edge_annotator.annotate(
|
||||
frame.copy(), key_points=key_points)
|
||||
annotated_frame = vertex_annotator.annotate(
|
||||
annotated_frame, key_points=key_points)
|
||||
annotated_frame = box_annotator.annotate(
|
||||
annotated_frame, detections=detections)
|
||||
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="11-12 18 26-27" }
|
||||
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>)
|
||||
edge_annotator = sv.EdgeAnnotator()
|
||||
vertex_annotator = sv.VertexAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
|
||||
tracker = sv.ByteTrack()
|
||||
trace_annotator = sv.TraceAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
results = model.infer(frame)[0]
|
||||
key_points = sv.KeyPoints.from_inference(results)
|
||||
detections = key_points.as_detections()
|
||||
detections = tracker.update_with_detections(detections)
|
||||
|
||||
annotated_frame = edge_annotator.annotate(
|
||||
frame.copy(), key_points=key_points)
|
||||
annotated_frame = vertex_annotator.annotate(
|
||||
annotated_frame, key_points=key_points)
|
||||
annotated_frame = box_annotator.annotate(
|
||||
annotated_frame, detections=detections)
|
||||
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-with-tracking.mp4" type="video/mp4">
|
||||
</video>
|
||||
|
||||
### Bonus: Smoothing
|
||||
|
||||
We could stop here as we have successfully tracked the object detected by the keypoint model. However, we can further enhance the stability of the boxes by applying [`DetectionsSmoother`](/latest/detection/tools/smoother/). This tool helps in stabilizing the boxes by smoothing the bounding box coordinates across frames. It is very simple to use:
|
||||
|
||||
=== "Ultralytics"
|
||||
|
||||
```{ .py hl_lines="11 19" }
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO("yolov8m-pose.pt")
|
||||
edge_annotator = sv.EdgeAnnotator()
|
||||
vertex_annotator = sv.VertexAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
|
||||
tracker = sv.ByteTrack()
|
||||
smoother = sv.DetectionsSmoother()
|
||||
trace_annotator = sv.TraceAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
results = model(frame)[0]
|
||||
key_points = sv.KeyPoints.from_ultralytics(results)
|
||||
detections = key_points.as_detections()
|
||||
detections = tracker.update_with_detections(detections)
|
||||
detections = smoother.update_with_detections(detections)
|
||||
|
||||
annotated_frame = edge_annotator.annotate(
|
||||
frame.copy(), key_points=key_points)
|
||||
annotated_frame = vertex_annotator.annotate(
|
||||
annotated_frame, key_points=key_points)
|
||||
annotated_frame = box_annotator.annotate(
|
||||
annotated_frame, detections=detections)
|
||||
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="12 20" }
|
||||
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>)
|
||||
edge_annotator = sv.EdgeAnnotator()
|
||||
vertex_annotator = sv.VertexAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
|
||||
tracker = sv.ByteTrack()
|
||||
smoother = sv.DetectionsSmoother()
|
||||
trace_annotator = sv.TraceAnnotator()
|
||||
|
||||
def callback(frame: np.ndarray, _: int) -> np.ndarray:
|
||||
results = model.infer(frame)[0]
|
||||
key_points = sv.KeyPoints.from_inference(results)
|
||||
detections = key_points.as_detections()
|
||||
detections = tracker.update_with_detections(detections)
|
||||
detections = smoother.update_with_detections(detections)
|
||||
|
||||
annotated_frame = edge_annotator.annotate(
|
||||
frame.copy(), key_points=key_points)
|
||||
annotated_frame = vertex_annotator.annotate(
|
||||
annotated_frame, key_points=key_points)
|
||||
annotated_frame = box_annotator.annotate(
|
||||
annotated_frame, detections=detections)
|
||||
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-with-smoothing.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.
|
||||
|
||||
+47
-8
@@ -33,8 +33,9 @@ We write your reusable computer vision tools. Whether you need to load your data
|
||||
You can install `supervision` in a
|
||||
[**Python>=3.8**](https://www.python.org/) environment.
|
||||
|
||||
!!! example "pip install (recommended)"
|
||||
=== "pip"
|
||||
!!! example "Installation"
|
||||
|
||||
=== "pip (recommended)"
|
||||
[](https://badge.fury.io/py/supervision)
|
||||
[](https://pypistats.org/packages/supervision)
|
||||
[](https://github.com/roboflow/supervision/blob/main/LICENSE.md)
|
||||
@@ -44,6 +45,43 @@ You can install `supervision` in a
|
||||
pip install supervision
|
||||
```
|
||||
|
||||
=== "poetry"
|
||||
[](https://badge.fury.io/py/supervision)
|
||||
[](https://pypistats.org/packages/supervision)
|
||||
[](https://github.com/roboflow/supervision/blob/main/LICENSE.md)
|
||||
[](https://badge.fury.io/py/supervision)
|
||||
|
||||
```bash
|
||||
poetry add supervision
|
||||
```
|
||||
|
||||
=== "uv"
|
||||
[](https://badge.fury.io/py/supervision)
|
||||
[](https://pypistats.org/packages/supervision)
|
||||
[](https://github.com/roboflow/supervision/blob/main/LICENSE.md)
|
||||
[](https://badge.fury.io/py/supervision)
|
||||
|
||||
```bash
|
||||
uv pip install supervision
|
||||
```
|
||||
|
||||
For uv projects:
|
||||
|
||||
```bash
|
||||
uv add supervision
|
||||
```
|
||||
|
||||
=== "rye"
|
||||
[](https://badge.fury.io/py/supervision)
|
||||
[](https://pypistats.org/packages/supervision)
|
||||
[](https://github.com/roboflow/supervision/blob/main/LICENSE.md)
|
||||
[](https://badge.fury.io/py/supervision)
|
||||
|
||||
```bash
|
||||
rye add supervision
|
||||
```
|
||||
|
||||
|
||||
!!! example "conda/mamba install"
|
||||
=== "conda"
|
||||
[](https://anaconda.org/conda-forge/supervision) [](https://anaconda.org/conda-forge/supervision) [](https://anaconda.org/conda-forge/supervision) [](https://anaconda.org/conda-forge/supervision)
|
||||
@@ -63,7 +101,7 @@ You can install `supervision` in a
|
||||
=== "virtualenv"
|
||||
```bash
|
||||
# clone repository and navigate to root directory
|
||||
git clone https://github.com/roboflow/supervision.git
|
||||
git clone --depth 1 -b develop https://github.com/roboflow/supervision.git
|
||||
cd supervision
|
||||
|
||||
# setup python environment and activate it
|
||||
@@ -75,18 +113,19 @@ You can install `supervision` in a
|
||||
pip install -e "."
|
||||
```
|
||||
|
||||
=== "poetry"
|
||||
=== "uv"
|
||||
```bash
|
||||
# clone repository and navigate to root directory
|
||||
git clone https://github.com/roboflow/supervision.git
|
||||
git clone --depth 1 -b develop https://github.com/roboflow/supervision.git
|
||||
cd supervision
|
||||
|
||||
# setup python environment and activate it
|
||||
poetry env use python3.10
|
||||
poetry shell
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# installation
|
||||
poetry install
|
||||
uv pip install -r pyproject.toml -e . --all-extras
|
||||
|
||||
```
|
||||
|
||||
## 🚀 Quickstart
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
!function(){var i="analytics",analytics=window[i]=window[i]||[];if(!analytics.initialize)if(analytics.invoked)window.console&&console.error&&console.error("Segment snippet included twice.");else{analytics.invoked=!0;analytics.methods=["trackSubmit","trackClick","trackLink","trackForm","pageview","identify","reset","group","track","ready","alias","debug","page","screen","once","off","on","addSourceMiddleware","addIntegrationMiddleware","setAnonymousId","addDestinationMiddleware","register"];analytics.factory=function(e){return function(){if(window[i].initialized)return window[i][e].apply(window[i],arguments);var n=Array.prototype.slice.call(arguments);if(["track","screen","alias","group","page","identify"].indexOf(e)>-1){var c=document.querySelector("link[rel='canonical']");n.push({__t:"bpc",c:c&&c.getAttribute("href")||void 0,p:location.pathname,u:location.href,s:location.search,t:document.title,r:document.referrer})}n.unshift(e);analytics.push(n);return analytics}};for(var n=0;n<analytics.methods.length;n++){var key=analytics.methods[n];analytics[key]=analytics.factory(key)}analytics.load=function(key,n){var t=document.createElement("script");t.type="text/javascript";t.async=!0;t.setAttribute("data-global-segment-analytics-key",i);t.src="https://cdn.segment.com/analytics.js/v1/" + key + "/analytics.min.js";var r=document.getElementsByTagName("script")[0];r.parentNode.insertBefore(t,r);analytics._loadOptions=n};analytics._writeKey="rMvrPeZBJYyOPJSGCNhMlnTJb8VhFiWU";;analytics.SNIPPET_VERSION="5.2.0";
|
||||
analytics.load("eohFog7VZiAhGJGEr5Sh7BM1mFKmUvDC");
|
||||
document$.subscribe(analytics.page);
|
||||
}}();
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Annotators
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Keypoint Detection
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Mean Average Recall
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.metrics.mean_average_recall.MeanAverageRecall">MeanAverageRecall</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.metrics.mean_average_recall.MeanAverageRecall
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.metrics.mean_average_recall.MeanAverageRecallResult">MeanAverageRecallResult</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.metrics.mean_average_recall.MeanAverageRecallResult
|
||||
@@ -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
|
||||
@@ -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
|
||||
+1
-1
@@ -130,7 +130,7 @@
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install -q ultralytics supervision==0.18.0"
|
||||
"!pip install -q \"ultralytics<=8.3.40\" \"supervision==0.18.0\""
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Vendored
+1
-1
@@ -300,7 +300,7 @@
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install -q ultralytics"
|
||||
"!pip install -q \"ultralytics<=8.3.40\""
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+5
-5
@@ -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",
|
||||
"\n",
|
||||
"\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",
|
||||
"\n",
|
||||
"\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",
|
||||
"|  | False Negative, Incomplete bbox |\n",
|
||||
"| | Double detection, Incomplete bbox|\n",
|
||||
"| | Incomplete bounding box|\n",
|
||||
"|  | False Negative, Incomplete bbox |\n",
|
||||
"| | Double detection, Incomplete bbox|\n",
|
||||
"| | Incomplete bounding box|\n",
|
||||
"\n",
|
||||
"## Improving Object Detection Near Boundaries with Overlapping\n",
|
||||
"\n",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# ByteTrack
|
||||
|
||||
@@ -17,7 +17,7 @@ https://github.com/roboflow/supervision/assets/26109316/f84db7b5-79e2-4142-a1da-
|
||||
- clone repository and navigate to example directory
|
||||
|
||||
```bash
|
||||
git clone https://github.com/roboflow/supervision.git
|
||||
git clone --depth 1 -b develop https://github.com/roboflow/supervision.git
|
||||
cd supervision/examples/count_people_in_zone
|
||||
```
|
||||
|
||||
|
||||
@@ -35,9 +35,7 @@ def load_zones_config(file_path: str) -> List[np.ndarray]:
|
||||
|
||||
def initiate_annotators(
|
||||
polygons: List[np.ndarray], resolution_wh: Tuple[int, int]
|
||||
) -> Tuple[
|
||||
List[sv.PolygonZone], List[sv.PolygonZoneAnnotator], List[sv.BoundingBoxAnnotator]
|
||||
]:
|
||||
) -> Tuple[List[sv.PolygonZone], List[sv.PolygonZoneAnnotator], List[sv.BoxAnnotator]]:
|
||||
line_thickness = sv.calculate_optimal_line_thickness(resolution_wh=resolution_wh)
|
||||
text_scale = sv.calculate_optimal_text_scale(resolution_wh=resolution_wh)
|
||||
|
||||
@@ -54,7 +52,7 @@ def initiate_annotators(
|
||||
text_thickness=line_thickness * 2,
|
||||
text_scale=text_scale * 2,
|
||||
)
|
||||
box_annotator = sv.BoundingBoxAnnotator(
|
||||
box_annotator = sv.BoxAnnotator(
|
||||
color=COLORS.by_idx(index), thickness=line_thickness
|
||||
)
|
||||
zones.append(zone)
|
||||
@@ -97,7 +95,7 @@ def annotate(
|
||||
frame: np.ndarray,
|
||||
zones: List[sv.PolygonZone],
|
||||
zone_annotators: List[sv.PolygonZoneAnnotator],
|
||||
box_annotators: List[sv.BoundingBoxAnnotator],
|
||||
box_annotators: List[sv.BoxAnnotator],
|
||||
detections: sv.Detections,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
@@ -108,7 +106,7 @@ def annotate(
|
||||
zones (List[sv.PolygonZone]): A list of polygon zones used for detection.
|
||||
zone_annotators (List[sv.PolygonZoneAnnotator]): A list of annotators for
|
||||
drawing zone annotations.
|
||||
box_annotators (List[sv.BoundingBoxAnnotator]): A list of annotators for
|
||||
box_annotators (List[sv.BoxAnnotator]): A list of annotators for
|
||||
drawing box annotations.
|
||||
detections (sv.Detections): Detections to be used for annotation.
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@ gdown
|
||||
inference==0.9.17
|
||||
supervision>=0.20.0
|
||||
tqdm
|
||||
ultralytics
|
||||
ultralytics<=8.3.40
|
||||
|
||||
@@ -33,9 +33,7 @@ def load_zones_config(file_path: str) -> List[np.ndarray]:
|
||||
|
||||
def initiate_annotators(
|
||||
polygons: List[np.ndarray], resolution_wh: Tuple[int, int]
|
||||
) -> Tuple[
|
||||
List[sv.PolygonZone], List[sv.PolygonZoneAnnotator], List[sv.BoundingBoxAnnotator]
|
||||
]:
|
||||
) -> Tuple[List[sv.PolygonZone], List[sv.PolygonZoneAnnotator], List[sv.BoxAnnotator]]:
|
||||
line_thickness = sv.calculate_optimal_line_thickness(resolution_wh=resolution_wh)
|
||||
text_scale = sv.calculate_optimal_text_scale(resolution_wh=resolution_wh)
|
||||
|
||||
@@ -52,7 +50,7 @@ def initiate_annotators(
|
||||
text_thickness=line_thickness * 2,
|
||||
text_scale=text_scale * 2,
|
||||
)
|
||||
box_annotator = sv.BoundingBoxAnnotator(
|
||||
box_annotator = sv.BoxAnnotator(
|
||||
color=COLORS.by_idx(index), thickness=line_thickness
|
||||
)
|
||||
zones.append(zone)
|
||||
@@ -94,7 +92,7 @@ def annotate(
|
||||
frame: np.ndarray,
|
||||
zones: List[sv.PolygonZone],
|
||||
zone_annotators: List[sv.PolygonZoneAnnotator],
|
||||
box_annotators: List[sv.BoundingBoxAnnotator],
|
||||
box_annotators: List[sv.BoxAnnotator],
|
||||
detections: sv.Detections,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
@@ -105,7 +103,7 @@ def annotate(
|
||||
zones (List[sv.PolygonZone]): A list of polygon zones used for detection.
|
||||
zone_annotators (List[sv.PolygonZoneAnnotator]): A list of annotators for
|
||||
drawing zone annotations.
|
||||
box_annotators (List[sv.BoundingBoxAnnotator]): A list of annotators for
|
||||
box_annotators (List[sv.BoxAnnotator]): A list of annotators for
|
||||
drawing box annotations.
|
||||
detections (sv.Detections): Detections to be used for annotation.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ supervision package for multiple tasks such as drawing heatmap annotations, trac
|
||||
- clone repository and navigate to example directory
|
||||
|
||||
```bash
|
||||
git clone https://github.com/roboflow/supervision.git
|
||||
git clone --depth 1 -b develop https://github.com/roboflow/supervision.git
|
||||
cd supervision/examples/heatmap_and_track
|
||||
```
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
supervision[assets]==0.19.0
|
||||
ultralytics
|
||||
ultralytics<=8.3.40
|
||||
|
||||
@@ -22,7 +22,7 @@ https://github.com/roboflow/supervision/assets/26109316/d50118c1-2ae4-458d-915a-
|
||||
- clone repository and navigate to example directory
|
||||
|
||||
```bash
|
||||
git clone https://github.com/roboflow/supervision.git
|
||||
git clone --depth 1 -b develop https://github.com/roboflow/supervision.git
|
||||
cd supervision/examples/speed_estimation
|
||||
```
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ https://github.com/roboflow/supervision/assets/26109316/d051cc8a-dd15-41d4-aa36-
|
||||
- clone repository and navigate to example directory
|
||||
|
||||
```bash
|
||||
git clone https://github.com/roboflow/supervision.git
|
||||
git clone --depth 1 -b develop https://github.com/roboflow/supervision.git
|
||||
cd supervision/examples/time_in_zone
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
opencv-python
|
||||
supervision>=0.20.0
|
||||
ultralytics
|
||||
ultralytics<=8.3.40
|
||||
inference==0.9.17
|
||||
pytube
|
||||
|
||||
@@ -10,7 +10,7 @@ detection and Supervision for tracking and annotation.
|
||||
- clone repository and navigate to example directory
|
||||
|
||||
```bash
|
||||
git clone https://github.com/roboflow/supervision.git
|
||||
git clone --depth 1 -b develop https://github.com/roboflow/supervision.git
|
||||
cd supervision/examples/tracking
|
||||
```
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ def process_video(
|
||||
model = get_roboflow_model(model_id=model_id, api_key=roboflow_api_key)
|
||||
|
||||
tracker = sv.ByteTrack()
|
||||
box_annotator = sv.BoundingBoxAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
label_annotator = sv.LabelAnnotator()
|
||||
frame_generator = sv.get_video_frames_generator(source_path=source_video_path)
|
||||
video_info = sv.VideoInfo.from_video_path(video_path=source_video_path)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
inference==0.9.17
|
||||
supervision==0.19.0
|
||||
tqdm
|
||||
ultralytics
|
||||
ultralytics<=8.3.40
|
||||
|
||||
@@ -16,7 +16,7 @@ def process_video(
|
||||
model = YOLO(source_weights_path)
|
||||
|
||||
tracker = sv.ByteTrack()
|
||||
box_annotator = sv.BoundingBoxAnnotator()
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
label_annotator = sv.LabelAnnotator()
|
||||
frame_generator = sv.get_video_frames_generator(source_path=source_video_path)
|
||||
video_info = sv.VideoInfo.from_video_path(video_path=source_video_path)
|
||||
|
||||
@@ -13,7 +13,7 @@ https://github.com/roboflow/supervision/assets/26109316/c9436828-9fbf-4c25-ae8c-
|
||||
- clone repository and navigate to example directory
|
||||
|
||||
```bash
|
||||
git clone https://github.com/roboflow/supervision.git
|
||||
git clone --depth 1 -b develop https://github.com/roboflow/supervision.git
|
||||
cd supervision/examples/traffic_analysis
|
||||
```
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@ gdown
|
||||
inference==0.9.17
|
||||
supervision>=0.20.0
|
||||
tqdm
|
||||
ultralytics
|
||||
ultralytics<=8.3.40
|
||||
|
||||
+12
-2
@@ -42,6 +42,7 @@ nav:
|
||||
- Detect Small Objects: how_to/detect_small_objects.md
|
||||
- Track Objects on Video: how_to/track_objects.md
|
||||
- Process Datasets: how_to/process_datasets.md
|
||||
- Benchmark a Model: how_to/benchmark_a_model.md
|
||||
|
||||
- Reference - Code API:
|
||||
- Detection and Segmentation:
|
||||
@@ -66,7 +67,11 @@ nav:
|
||||
- Utils: datasets/utils.md
|
||||
- Metrics:
|
||||
- mAP: metrics/mean_average_precision.md
|
||||
- mAR: metrics/mean_average_recall.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
|
||||
@@ -144,9 +149,12 @@ plugins:
|
||||
group_by_category: true
|
||||
docstring_style: google
|
||||
show_symbol_type_heading: true
|
||||
show_root_heading: True
|
||||
show_symbol_type_toc: true
|
||||
show_category_heading: true
|
||||
domains: [std, py]
|
||||
inventories:
|
||||
- url: https://docs.python-requests.org/en/master/objects.inv
|
||||
domains: [std, py]
|
||||
- git-committers:
|
||||
repository: roboflow/supervision
|
||||
branch: develop
|
||||
@@ -171,11 +179,13 @@ markdown_extensions:
|
||||
check_paths: true
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
line_spans: __span
|
||||
pygments_lang_class: true
|
||||
|
||||
extra_javascript:
|
||||
- "https://widget.kapa.ai/kapa-widget.bundle.js"
|
||||
- "javascripts/init_kapa_widget.js"
|
||||
- "javascripts/cookbooks-card.js"
|
||||
- "javascripts/segment.js"
|
||||
- "https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.8/purify.min.js"
|
||||
|
||||
# Messages shown during document build
|
||||
|
||||
Generated
-4548
File diff suppressed because it is too large
Load Diff
+59
-79
@@ -1,18 +1,16 @@
|
||||
[tool.poetry]
|
||||
[project]
|
||||
name = "supervision"
|
||||
version = "0.24.0"
|
||||
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 = [
|
||||
"Piotr Skalski <piotr.skalski92@gmail.com>",
|
||||
"Linas Kondrackis <linas@roboflow.com>",
|
||||
]
|
||||
license = { text = "MIT" }
|
||||
version = "0.26.0rc5"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
packages = [{ include = "supervision" }]
|
||||
homepage = "https://github.com/roboflow/supervision"
|
||||
repository = "https://github.com/roboflow/supervision"
|
||||
documentation = "https://supervision.roboflow.com/latest/"
|
||||
requires-python = ">=3.8"
|
||||
authors = [
|
||||
{ name = "Piotr Skalski", email = "piotr.skalski92@gmail.com" }
|
||||
]
|
||||
maintainers = [
|
||||
{ name = "Piotr Skalski", email = "piotr.skalski92@gmail.com" },
|
||||
]
|
||||
keywords = [
|
||||
"machine-learning",
|
||||
"deep-learning",
|
||||
@@ -22,7 +20,6 @@ keywords = [
|
||||
"AI",
|
||||
"Roboflow",
|
||||
]
|
||||
|
||||
classifiers = [
|
||||
'Development Status :: 4 - Beta',
|
||||
'Intended Audience :: Developers',
|
||||
@@ -40,80 +37,59 @@ classifiers = [
|
||||
'Operating System :: POSIX :: Linux',
|
||||
'Operating System :: MacOS',
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.8"
|
||||
numpy = [
|
||||
{ version = ">=1.21.2,<1.23.3", python = "<=3.10" },
|
||||
{ version = ">=1.23.3", python = ">3.10" },
|
||||
]
|
||||
scipy = [
|
||||
{ version = "1.10.0", python = "<3.9" },
|
||||
{ version = "^1.10.0", python = ">=3.9" },
|
||||
]
|
||||
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 }
|
||||
# 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 }
|
||||
opencv-python = ">=4.5.5.64"
|
||||
|
||||
[tool.poetry.extras]
|
||||
assets = ["requests", "tqdm"]
|
||||
metrics = ["pandas", "pandas-stubs"]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
twine = "^5.1.1"
|
||||
pytest = ">=7.2.2,<9.0.0"
|
||||
wheel = ">=0.40,<0.45"
|
||||
build = ">=0.10,<1.3"
|
||||
ruff = ">=0.1.0"
|
||||
mypy = "^1.4.1"
|
||||
pre-commit = "^3.3.3"
|
||||
tox = "^4.11.4"
|
||||
notebook = ">=6.5.3,<8.0.0"
|
||||
ipywidgets = "^8.1.1"
|
||||
jupytext = "^1.16.1"
|
||||
nbconvert = "^7.14.2"
|
||||
docutils = [
|
||||
{ version = "^0.20.1", python = "<3.9" },
|
||||
{ version = "^0.21.1", python = ">=3.9" },
|
||||
dependencies = [
|
||||
"numpy>=1.21.2",
|
||||
"scipy>=1.10.0",
|
||||
"matplotlib>=3.6.0",
|
||||
"pyyaml>=5.3",
|
||||
"defusedxml>=0.7.1",
|
||||
"pillow>=9.4",
|
||||
"requests>=2.26.0",
|
||||
"tqdm>=4.62.3",
|
||||
"opencv-python>=4.5.5.64"
|
||||
]
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
mkdocs-material = { extras = ["imaging"], version = "^9.5.5" }
|
||||
mkdocstrings = ">=0.25.2,<0.27.0"
|
||||
mkdocstrings-python = "^1.10.9"
|
||||
mike = "^2.0.0"
|
||||
# For Documentation Development use Python 3.10 or above
|
||||
# Use Latest mkdocs-jupyter min 0.24.6 for Jupyter Notebook Theme support
|
||||
mkdocs-jupyter = "^0.24.3"
|
||||
mkdocs-git-committers-plugin-2 = "^2.4.1"
|
||||
mkdocs-git-revision-date-localized-plugin = "^1.2.4"
|
||||
|
||||
[tool.poetry.group.typecheck]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.typecheck.dependencies]
|
||||
types-pyyaml = "^6.0.12.20240808"
|
||||
types-cffi = "^1.16.0.20240331"
|
||||
types-requests = "^2.32.0.20240712"
|
||||
types-tqdm = "^4.66.0.20240417"
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/roboflow/supervision"
|
||||
Repository = "https://github.com/roboflow/supervision"
|
||||
Documentation = "https://supervision.roboflow.com/latest/"
|
||||
|
||||
[project.optional-dependencies]
|
||||
metrics = [
|
||||
"pandas>=2.0.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.2.2,<9.0.0",
|
||||
"tox>=4.11.4",
|
||||
"notebook>=6.5.3,<8.0.0",
|
||||
"ipywidgets>=8.1.1",
|
||||
"jupytext>=1.16.1",
|
||||
"nbconvert>=7.14.2",
|
||||
"docutils!=0.21"
|
||||
]
|
||||
docs = [
|
||||
"mkdocs-material[imaging]>=9.5.5",
|
||||
"mkdocstrings>=0.25.2,<0.30.0",
|
||||
"mkdocstrings-python>=1.10.9",
|
||||
"mike>=2.0.0",
|
||||
"mkdocs-jupyter>=0.24.3",
|
||||
"mkdocs-git-committers-plugin-2>=2.4.1; python_version >= '3.8' and python_version < '4'",
|
||||
"mkdocs-git-revision-date-localized-plugin>=1.2.4"
|
||||
]
|
||||
build = [
|
||||
"twine>=5.1.1,<7.0.0",
|
||||
"wheel>=0.40,<0.46",
|
||||
"build>=0.10,<1.3"
|
||||
]
|
||||
|
||||
[tool.bandit]
|
||||
target = ["test", "supervision"]
|
||||
tests = ["B201", "B301", "B318", "B314", "B303", "B413", "B412", "B410"]
|
||||
tests = ["B201", "B301", "B318", "B314", "B303", "B413", "B412"]
|
||||
|
||||
[tool.autoflake]
|
||||
check = true
|
||||
imports = ["cv2", "supervision"]
|
||||
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py38"
|
||||
|
||||
@@ -237,7 +213,7 @@ skip-magic-trailing-comma = false
|
||||
line-ending = "auto"
|
||||
|
||||
[tool.codespell]
|
||||
skip = "*.ipynb,poetry.lock"
|
||||
skip = "*.ipynb"
|
||||
count = true
|
||||
quiet-level = 3
|
||||
ignore-words-list = "STrack,sTrack,strack"
|
||||
@@ -246,8 +222,12 @@ ignore-words-list = "STrack,sTrack,strack"
|
||||
include-package-data = false
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["supervision*"]
|
||||
exclude = ["docs*", "test*", "examples*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
supervision = ["py.typed"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["setuptools >= 61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
+113
-2
@@ -9,11 +9,11 @@ except importlib_metadata.PackageNotFoundError:
|
||||
from supervision.annotators.core import (
|
||||
BackgroundOverlayAnnotator,
|
||||
BlurAnnotator,
|
||||
BoundingBoxAnnotator,
|
||||
BoxAnnotator,
|
||||
BoxCornerAnnotator,
|
||||
CircleAnnotator,
|
||||
ColorAnnotator,
|
||||
ComparisonAnnotator,
|
||||
CropAnnotator,
|
||||
DotAnnotator,
|
||||
EllipseAnnotator,
|
||||
@@ -45,7 +45,6 @@ from supervision.detection.line_zone import (
|
||||
LineZoneAnnotator,
|
||||
LineZoneAnnotatorMulticlass,
|
||||
)
|
||||
from supervision.detection.lmm import LMM
|
||||
from supervision.detection.overlap_filter import (
|
||||
OverlapFilter,
|
||||
box_non_max_merge,
|
||||
@@ -76,7 +75,11 @@ from supervision.detection.utils import (
|
||||
scale_boxes,
|
||||
xcycwh_to_xyxy,
|
||||
xywh_to_xyxy,
|
||||
xyxy_to_polygons,
|
||||
xyxy_to_xcycarh,
|
||||
xyxy_to_xywh,
|
||||
)
|
||||
from supervision.detection.vlm import LMM, VLM
|
||||
from supervision.draw.color import Color, ColorPalette
|
||||
from supervision.draw.utils import (
|
||||
calculate_optimal_line_thickness,
|
||||
@@ -118,3 +121,111 @@ from supervision.utils.video import (
|
||||
get_video_frames_generator,
|
||||
process_video,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LMM",
|
||||
"BackgroundOverlayAnnotator",
|
||||
"BaseDataset",
|
||||
"BlurAnnotator",
|
||||
"BoxAnnotator",
|
||||
"BoxCornerAnnotator",
|
||||
"ByteTrack",
|
||||
"CSVSink",
|
||||
"CircleAnnotator",
|
||||
"ClassificationDataset",
|
||||
"Classifications",
|
||||
"Color",
|
||||
"ColorAnnotator",
|
||||
"ColorLookup",
|
||||
"ColorPalette",
|
||||
"ComparisonAnnotator",
|
||||
"ConfusionMatrix",
|
||||
"CropAnnotator",
|
||||
"DetectionDataset",
|
||||
"Detections",
|
||||
"DetectionsSmoother",
|
||||
"DotAnnotator",
|
||||
"EdgeAnnotator",
|
||||
"EllipseAnnotator",
|
||||
"FPSMonitor",
|
||||
"HaloAnnotator",
|
||||
"HeatMapAnnotator",
|
||||
"IconAnnotator",
|
||||
"ImageSink",
|
||||
"InferenceSlicer",
|
||||
"JSONSink",
|
||||
"KeyPoints",
|
||||
"LabelAnnotator",
|
||||
"LineZone",
|
||||
"LineZoneAnnotator",
|
||||
"LineZoneAnnotatorMulticlass",
|
||||
"MaskAnnotator",
|
||||
"MeanAveragePrecision",
|
||||
"OrientedBoxAnnotator",
|
||||
"OverlapFilter",
|
||||
"PercentageBarAnnotator",
|
||||
"PixelateAnnotator",
|
||||
"Point",
|
||||
"PolygonAnnotator",
|
||||
"PolygonZone",
|
||||
"PolygonZoneAnnotator",
|
||||
"Position",
|
||||
"Rect",
|
||||
"RichLabelAnnotator",
|
||||
"RoundBoxAnnotator",
|
||||
"TraceAnnotator",
|
||||
"TriangleAnnotator",
|
||||
"VertexAnnotator",
|
||||
"VertexLabelAnnotator",
|
||||
"VideoInfo",
|
||||
"VideoSink",
|
||||
"box_iou_batch",
|
||||
"box_non_max_merge",
|
||||
"box_non_max_suppression",
|
||||
"calculate_masks_centroids",
|
||||
"calculate_optimal_line_thickness",
|
||||
"calculate_optimal_text_scale",
|
||||
"clip_boxes",
|
||||
"contains_holes",
|
||||
"contains_multiple_segments",
|
||||
"create_tiles",
|
||||
"crop_image",
|
||||
"cv2_to_pillow",
|
||||
"draw_filled_polygon",
|
||||
"draw_filled_rectangle",
|
||||
"draw_image",
|
||||
"draw_line",
|
||||
"draw_polygon",
|
||||
"draw_rectangle",
|
||||
"draw_text",
|
||||
"filter_polygons_by_area",
|
||||
"get_polygon_center",
|
||||
"get_video_frames_generator",
|
||||
"letterbox_image",
|
||||
"list_files_with_extensions",
|
||||
"mask_iou_batch",
|
||||
"mask_non_max_suppression",
|
||||
"mask_to_polygons",
|
||||
"mask_to_rle",
|
||||
"mask_to_xyxy",
|
||||
"move_boxes",
|
||||
"move_masks",
|
||||
"oriented_box_iou_batch",
|
||||
"overlay_image",
|
||||
"pad_boxes",
|
||||
"pillow_to_cv2",
|
||||
"plot_image",
|
||||
"plot_images_grid",
|
||||
"polygon_to_mask",
|
||||
"polygon_to_xyxy",
|
||||
"process_video",
|
||||
"resize_image",
|
||||
"rle_to_mask",
|
||||
"scale_boxes",
|
||||
"scale_image",
|
||||
"xcycwh_to_xyxy",
|
||||
"xywh_to_xyxy",
|
||||
"xyxy_to_polygons",
|
||||
"xyxy_to_xyah",
|
||||
"xyxy_to_xywh",
|
||||
]
|
||||
|
||||
+524
-209
@@ -16,10 +16,16 @@ from supervision.annotators.utils import (
|
||||
)
|
||||
from supervision.config import CLASS_NAME_DATA_FIELD, ORIENTED_BOX_COORDINATES
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.detection.utils import clip_boxes, mask_to_polygons
|
||||
from supervision.detection.utils import (
|
||||
clip_boxes,
|
||||
mask_to_polygons,
|
||||
polygon_to_mask,
|
||||
spread_out_boxes,
|
||||
xyxy_to_polygons,
|
||||
)
|
||||
from supervision.draw.color import Color, ColorPalette
|
||||
from supervision.draw.utils import draw_polygon
|
||||
from supervision.geometry.core import Position
|
||||
from supervision.draw.utils import draw_polygon, draw_rounded_rectangle, draw_text
|
||||
from supervision.geometry.core import Point, Position, Rect
|
||||
from supervision.utils.conversion import (
|
||||
ensure_cv2_image_for_annotation,
|
||||
ensure_pil_image_for_annotation,
|
||||
@@ -30,7 +36,8 @@ from supervision.utils.image import (
|
||||
overlay_image,
|
||||
scale_image,
|
||||
)
|
||||
from supervision.utils.internal import deprecated
|
||||
|
||||
CV2_FONT = cv2.FONT_HERSHEY_SIMPLEX
|
||||
|
||||
|
||||
class BoxAnnotator(BaseAnnotator):
|
||||
@@ -116,92 +123,6 @@ class BoxAnnotator(BaseAnnotator):
|
||||
return scene
|
||||
|
||||
|
||||
@deprecated(
|
||||
"`BoundingBoxAnnotator` is deprecated and has been renamed to `BoxAnnotator`."
|
||||
" `BoundingBoxAnnotator` will be removed in supervision-0.26.0."
|
||||
)
|
||||
class BoundingBoxAnnotator(BaseAnnotator):
|
||||
"""
|
||||
A class for drawing bounding boxes on an image using provided detections.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
color: Union[Color, ColorPalette] = ColorPalette.DEFAULT,
|
||||
thickness: int = 2,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
color (Union[Color, ColorPalette]): The color or color palette to use for
|
||||
annotating detections.
|
||||
thickness (int): Thickness of the bounding box lines.
|
||||
color_lookup (ColorLookup): Strategy for mapping colors to annotations.
|
||||
Options are `INDEX`, `CLASS`, `TRACK`.
|
||||
"""
|
||||
self.color: Union[Color, ColorPalette] = color
|
||||
self.thickness: int = thickness
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
detections: Detections,
|
||||
custom_color_lookup: Optional[np.ndarray] = None,
|
||||
) -> ImageType:
|
||||
"""
|
||||
Annotates the given scene with bounding boxes based on the provided detections.
|
||||
|
||||
Args:
|
||||
scene (ImageType): The image where bounding boxes will be drawn. `ImageType`
|
||||
is a flexible type, accepting either `numpy.ndarray` or `PIL.Image.Image`.
|
||||
detections (Detections): Object detections to annotate.
|
||||
custom_color_lookup (Optional[np.ndarray]): Custom color lookup array.
|
||||
Allows to override the default color mapping strategy.
|
||||
|
||||
Returns:
|
||||
The annotated image, matching the type of `scene` (`numpy.ndarray`
|
||||
or `PIL.Image.Image`)
|
||||
|
||||
Example:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
detections = sv.Detections(...)
|
||||
|
||||
bounding_box_annotator = sv.BoundingBoxAnnotator()
|
||||
annotated_frame = bounding_box_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
detections=detections
|
||||
)
|
||||
```
|
||||
|
||||

|
||||
"""
|
||||
assert isinstance(scene, np.ndarray)
|
||||
for detection_idx in range(len(detections)):
|
||||
x1, y1, x2, y2 = detections.xyxy[detection_idx].astype(int)
|
||||
color = resolve_color(
|
||||
color=self.color,
|
||||
detections=detections,
|
||||
detection_idx=detection_idx,
|
||||
color_lookup=self.color_lookup
|
||||
if custom_color_lookup is None
|
||||
else custom_color_lookup,
|
||||
)
|
||||
cv2.rectangle(
|
||||
img=scene,
|
||||
pt1=(x1, y1),
|
||||
pt2=(x2, y2),
|
||||
color=color.as_bgr(),
|
||||
thickness=self.thickness,
|
||||
)
|
||||
return scene
|
||||
|
||||
|
||||
class OrientedBoxAnnotator(BaseAnnotator):
|
||||
"""
|
||||
A class for drawing oriented bounding boxes on an image using provided detections.
|
||||
@@ -1054,6 +975,7 @@ class LabelAnnotator(BaseAnnotator):
|
||||
text_position: Position = Position.TOP_LEFT,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
border_radius: int = 0,
|
||||
smart_position: bool = False,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
@@ -1070,6 +992,7 @@ class LabelAnnotator(BaseAnnotator):
|
||||
Options are `INDEX`, `CLASS`, `TRACK`.
|
||||
border_radius (int): The radius to apply round edges. If the selected
|
||||
value is higher than the lower dimension, width or height, is clipped.
|
||||
smart_position (bool): Spread out the labels to avoid overlapping.
|
||||
"""
|
||||
self.border_radius: int = border_radius
|
||||
self.color: Union[Color, ColorPalette] = color
|
||||
@@ -1079,6 +1002,7 @@ class LabelAnnotator(BaseAnnotator):
|
||||
self.text_padding: int = text_padding
|
||||
self.text_anchor: Position = text_position
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
self.smart_position = smart_position
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
def annotate(
|
||||
@@ -1128,11 +1052,29 @@ class LabelAnnotator(BaseAnnotator):
|
||||

|
||||
"""
|
||||
|
||||
assert isinstance(scene, np.ndarray)
|
||||
font = cv2.FONT_HERSHEY_SIMPLEX
|
||||
anchors_coordinates = detections.get_anchors_coordinates(
|
||||
anchor=self.text_anchor
|
||||
).astype(int)
|
||||
self._validate_labels(labels, detections)
|
||||
|
||||
labels = self._get_labels_text(detections, labels)
|
||||
label_properties = self._get_label_properties(detections, labels)
|
||||
|
||||
if self.smart_position:
|
||||
xyxy = label_properties[:, :4]
|
||||
xyxy = spread_out_boxes(xyxy)
|
||||
label_properties[:, :4] = xyxy
|
||||
|
||||
self._draw_labels(
|
||||
scene=scene,
|
||||
labels=labels,
|
||||
label_properties=label_properties,
|
||||
detections=detections,
|
||||
custom_color_lookup=custom_color_lookup,
|
||||
)
|
||||
|
||||
return scene
|
||||
|
||||
def _validate_labels(self, labels: Optional[List[str]], detections: Detections):
|
||||
if labels is not None and len(labels) != len(detections):
|
||||
raise ValueError(
|
||||
f"The number of labels ({len(labels)}) does not match the "
|
||||
@@ -1140,72 +1082,121 @@ class LabelAnnotator(BaseAnnotator):
|
||||
f"should have exactly 1 label."
|
||||
)
|
||||
|
||||
for detection_idx, center_coordinates in enumerate(anchors_coordinates):
|
||||
color = resolve_color(
|
||||
color=self.color,
|
||||
detections=detections,
|
||||
detection_idx=detection_idx,
|
||||
color_lookup=(
|
||||
self.color_lookup
|
||||
if custom_color_lookup is None
|
||||
else custom_color_lookup
|
||||
),
|
||||
)
|
||||
def _get_label_properties(
|
||||
self,
|
||||
detections: Detections,
|
||||
labels: List[str],
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Calculate the numerical properties required to draw the labels on the image.
|
||||
|
||||
text_color = resolve_color(
|
||||
color=self.text_color,
|
||||
detections=detections,
|
||||
detection_idx=detection_idx,
|
||||
color_lookup=(
|
||||
self.color_lookup
|
||||
if custom_color_lookup is None
|
||||
else custom_color_lookup
|
||||
),
|
||||
)
|
||||
Returns:
|
||||
(np.ndarray): An array of label properties, containing columns:
|
||||
`min_x`, `min_y`, `max_x`, `max_y`, `padded_text_height`.
|
||||
"""
|
||||
label_properties = []
|
||||
anchors_coordinates = detections.get_anchors_coordinates(
|
||||
anchor=self.text_anchor
|
||||
).astype(int)
|
||||
|
||||
if labels is not None:
|
||||
text = labels[detection_idx]
|
||||
elif CLASS_NAME_DATA_FIELD in detections.data:
|
||||
text = detections.data[CLASS_NAME_DATA_FIELD][detection_idx]
|
||||
elif detections.class_id is not None:
|
||||
text = str(detections.class_id[detection_idx])
|
||||
else:
|
||||
text = str(detection_idx)
|
||||
|
||||
text_w, text_h = cv2.getTextSize(
|
||||
text=text,
|
||||
fontFace=font,
|
||||
for label, center_coords in zip(labels, anchors_coordinates):
|
||||
(text_w, text_h) = cv2.getTextSize(
|
||||
text=label,
|
||||
fontFace=CV2_FONT,
|
||||
fontScale=self.text_scale,
|
||||
thickness=self.text_thickness,
|
||||
)[0]
|
||||
text_w_padded = text_w + 2 * self.text_padding
|
||||
text_h_padded = text_h + 2 * self.text_padding
|
||||
|
||||
width_padded = text_w + 2 * self.text_padding
|
||||
height_padded = text_h + 2 * self.text_padding
|
||||
|
||||
text_background_xyxy = resolve_text_background_xyxy(
|
||||
center_coordinates=tuple(center_coordinates),
|
||||
text_wh=(text_w_padded, text_h_padded),
|
||||
center_coordinates=tuple(center_coords),
|
||||
text_wh=(width_padded, height_padded),
|
||||
position=self.text_anchor,
|
||||
)
|
||||
|
||||
text_x = text_background_xyxy[0] + self.text_padding
|
||||
text_y = text_background_xyxy[1] + self.text_padding + text_h
|
||||
label_properties.append(
|
||||
[
|
||||
*text_background_xyxy,
|
||||
text_h,
|
||||
]
|
||||
)
|
||||
|
||||
return np.array(label_properties).reshape(-1, 5)
|
||||
|
||||
@staticmethod
|
||||
def _get_labels_text(
|
||||
detections: Detections, custom_labels: Optional[List[str]]
|
||||
) -> List[str]:
|
||||
if custom_labels is not None:
|
||||
return [str(label) for label in custom_labels]
|
||||
|
||||
labels = []
|
||||
for idx in range(len(detections)):
|
||||
if CLASS_NAME_DATA_FIELD in detections.data:
|
||||
labels.append(detections.data[CLASS_NAME_DATA_FIELD][idx])
|
||||
elif detections.class_id is not None:
|
||||
labels.append(str(detections.class_id[idx]))
|
||||
else:
|
||||
labels.append(str(idx))
|
||||
return labels
|
||||
|
||||
def _draw_labels(
|
||||
self,
|
||||
scene: np.ndarray,
|
||||
labels: List[str],
|
||||
label_properties: np.ndarray,
|
||||
detections: Detections,
|
||||
custom_color_lookup: Optional[np.ndarray],
|
||||
) -> None:
|
||||
assert len(labels) == len(label_properties) == len(detections), (
|
||||
f"Number of label properties ({len(label_properties)}), "
|
||||
f"labels ({len(labels)}) and detections ({len(detections)}) "
|
||||
"do not match."
|
||||
)
|
||||
|
||||
color_lookup = (
|
||||
custom_color_lookup
|
||||
if custom_color_lookup is not None
|
||||
else self.color_lookup
|
||||
)
|
||||
|
||||
for idx, label_property in enumerate(label_properties):
|
||||
background_color = resolve_color(
|
||||
color=self.color,
|
||||
detections=detections,
|
||||
detection_idx=idx,
|
||||
color_lookup=color_lookup,
|
||||
)
|
||||
text_color = resolve_color(
|
||||
color=self.text_color,
|
||||
detections=detections,
|
||||
detection_idx=idx,
|
||||
color_lookup=color_lookup,
|
||||
)
|
||||
|
||||
box_xyxy = label_property[:4]
|
||||
text_height_padded = label_property[4]
|
||||
self.draw_rounded_rectangle(
|
||||
scene=scene,
|
||||
xyxy=text_background_xyxy,
|
||||
color=color.as_bgr(),
|
||||
xyxy=box_xyxy,
|
||||
color=background_color.as_bgr(),
|
||||
border_radius=self.border_radius,
|
||||
)
|
||||
|
||||
text_x = box_xyxy[0] + self.text_padding
|
||||
text_y = box_xyxy[1] + self.text_padding + text_height_padded
|
||||
cv2.putText(
|
||||
img=scene,
|
||||
text=text,
|
||||
text=labels[idx],
|
||||
org=(text_x, text_y),
|
||||
fontFace=font,
|
||||
fontFace=CV2_FONT,
|
||||
fontScale=self.text_scale,
|
||||
color=text_color.as_bgr(),
|
||||
thickness=self.text_thickness,
|
||||
lineType=cv2.LINE_AA,
|
||||
)
|
||||
return scene
|
||||
|
||||
@staticmethod
|
||||
def draw_rounded_rectangle(
|
||||
@@ -1266,6 +1257,7 @@ class RichLabelAnnotator(BaseAnnotator):
|
||||
text_position: Position = Position.TOP_LEFT,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
border_radius: int = 0,
|
||||
smart_position: bool = False,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
@@ -1282,6 +1274,7 @@ class RichLabelAnnotator(BaseAnnotator):
|
||||
Options are `INDEX`, `CLASS`, `TRACK`.
|
||||
border_radius (int): The radius to apply round edges. If the selected
|
||||
value is higher than the lower dimension, width or height, is clipped.
|
||||
smart_position (bool): Spread out the labels to avoid overlapping.
|
||||
"""
|
||||
self.color = color
|
||||
self.text_color = text_color
|
||||
@@ -1289,14 +1282,8 @@ class RichLabelAnnotator(BaseAnnotator):
|
||||
self.text_anchor = text_position
|
||||
self.color_lookup = color_lookup
|
||||
self.border_radius = border_radius
|
||||
if font_path is not None:
|
||||
try:
|
||||
self.font = ImageFont.truetype(font_path, font_size)
|
||||
except OSError:
|
||||
print(f"Font path '{font_path}' not found. Using PIL's default font.")
|
||||
self.font = self._load_default_font(font_size)
|
||||
else:
|
||||
self.font = self._load_default_font(font_size)
|
||||
self.smart_position = smart_position
|
||||
self.font = self._load_font(font_size, font_path)
|
||||
|
||||
@ensure_pil_image_for_annotation
|
||||
def annotate(
|
||||
@@ -1346,88 +1333,157 @@ class RichLabelAnnotator(BaseAnnotator):
|
||||
|
||||
"""
|
||||
assert isinstance(scene, Image.Image)
|
||||
self._validate_labels(labels, detections)
|
||||
|
||||
draw = ImageDraw.Draw(scene)
|
||||
anchors_coordinates = detections.get_anchors_coordinates(
|
||||
anchor=self.text_anchor
|
||||
).astype(int)
|
||||
labels = self._get_labels_text(detections, labels)
|
||||
label_properties = self._get_label_properties(draw, detections, labels)
|
||||
|
||||
if self.smart_position:
|
||||
xyxy = label_properties[:, :4]
|
||||
xyxy = spread_out_boxes(xyxy)
|
||||
label_properties[:, :4] = xyxy
|
||||
|
||||
self._draw_labels(
|
||||
draw=draw,
|
||||
labels=labels,
|
||||
label_properties=label_properties,
|
||||
detections=detections,
|
||||
custom_color_lookup=custom_color_lookup,
|
||||
)
|
||||
|
||||
return scene
|
||||
|
||||
def _validate_labels(self, labels: Optional[List[str]], detections: Detections):
|
||||
if labels is not None and len(labels) != len(detections):
|
||||
raise ValueError(
|
||||
f"The number of labels provided ({len(labels)}) does not match the "
|
||||
f"number of detections ({len(detections)}). Each detection should have "
|
||||
f"a corresponding label."
|
||||
)
|
||||
for detection_idx, center_coordinates in enumerate(anchors_coordinates):
|
||||
color = resolve_color(
|
||||
color=self.color,
|
||||
detections=detections,
|
||||
detection_idx=detection_idx,
|
||||
color_lookup=(
|
||||
self.color_lookup
|
||||
if custom_color_lookup is None
|
||||
else custom_color_lookup
|
||||
),
|
||||
f"The number of labels ({len(labels)}) does not match the "
|
||||
f"number of detections ({len(detections)}). Each detection "
|
||||
f"should have exactly 1 label."
|
||||
)
|
||||
|
||||
text_color = resolve_color(
|
||||
color=self.text_color,
|
||||
detections=detections,
|
||||
detection_idx=detection_idx,
|
||||
color_lookup=(
|
||||
self.color_lookup
|
||||
if custom_color_lookup is None
|
||||
else custom_color_lookup
|
||||
),
|
||||
def _get_label_properties(
|
||||
self, draw, detections: Detections, labels: List[str]
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Calculate the numerical properties required to draw the labels on the image.
|
||||
|
||||
Returns:
|
||||
(np.ndarray): An array of label properties, containing columns:
|
||||
`min_x`, `min_y`, `max_x`, `max_y`, `text_left_coordinate`,
|
||||
`text_top_coordinate`. The first 4 values are already padded
|
||||
with `text_padding`.
|
||||
"""
|
||||
label_properties = []
|
||||
|
||||
anchor_coordinates = detections.get_anchors_coordinates(
|
||||
anchor=self.text_anchor
|
||||
).astype(int)
|
||||
|
||||
for label, center_coords in zip(labels, anchor_coordinates):
|
||||
text_left, text_top, text_right, text_bottom = draw.textbbox(
|
||||
(0, 0), label, font=self.font
|
||||
)
|
||||
text_width = text_right - text_left
|
||||
text_height = text_bottom - text_top
|
||||
width_padded = text_width + 2 * self.text_padding
|
||||
height_padded = text_height + 2 * self.text_padding
|
||||
|
||||
if labels is not None:
|
||||
text = labels[detection_idx]
|
||||
elif CLASS_NAME_DATA_FIELD in detections.data:
|
||||
text = detections.data[CLASS_NAME_DATA_FIELD][detection_idx]
|
||||
elif detections.class_id is not None:
|
||||
text = str(detections.class_id[detection_idx])
|
||||
else:
|
||||
text = str(detection_idx)
|
||||
|
||||
left, top, right, bottom = draw.textbbox((0, 0), text, font=self.font)
|
||||
text_width = right - left
|
||||
text_height = bottom - top
|
||||
text_w_padded = text_width + 2 * self.text_padding
|
||||
text_h_padded = text_height + 2 * self.text_padding
|
||||
text_background_xyxy = resolve_text_background_xyxy(
|
||||
center_coordinates=tuple(center_coordinates),
|
||||
text_wh=(text_w_padded, text_h_padded),
|
||||
center_coordinates=tuple(center_coords),
|
||||
text_wh=(width_padded, height_padded),
|
||||
position=self.text_anchor,
|
||||
)
|
||||
|
||||
text_x = text_background_xyxy[0] + self.text_padding - left
|
||||
text_y = text_background_xyxy[1] + self.text_padding - top
|
||||
label_properties.append([*text_background_xyxy, text_left, text_top])
|
||||
|
||||
return np.array(label_properties).reshape(-1, 6)
|
||||
|
||||
@staticmethod
|
||||
def _get_labels_text(
|
||||
detections: Detections, custom_labels: Optional[List[str]]
|
||||
) -> List[str]:
|
||||
if custom_labels is not None:
|
||||
return custom_labels
|
||||
|
||||
labels = []
|
||||
for idx in range(len(detections)):
|
||||
if CLASS_NAME_DATA_FIELD in detections.data:
|
||||
labels.append(detections.data[CLASS_NAME_DATA_FIELD][idx])
|
||||
elif detections.class_id is not None:
|
||||
labels.append(str(detections.class_id[idx]))
|
||||
else:
|
||||
labels.append(str(idx))
|
||||
return labels
|
||||
|
||||
def _draw_labels(
|
||||
self,
|
||||
draw,
|
||||
labels: List[str],
|
||||
label_properties: np.ndarray,
|
||||
detections: Detections,
|
||||
custom_color_lookup: Optional[np.ndarray],
|
||||
) -> None:
|
||||
assert len(labels) == len(label_properties) == len(detections), (
|
||||
f"Number of label properties ({len(label_properties)}), "
|
||||
f"labels ({len(labels)}) and detections ({len(detections)}) "
|
||||
"do not match."
|
||||
)
|
||||
color_lookup = (
|
||||
custom_color_lookup
|
||||
if custom_color_lookup is not None
|
||||
else self.color_lookup
|
||||
)
|
||||
|
||||
for idx, label_property in enumerate(label_properties):
|
||||
background_color = resolve_color(
|
||||
color=self.color,
|
||||
detections=detections,
|
||||
detection_idx=idx,
|
||||
color_lookup=color_lookup,
|
||||
)
|
||||
text_color = resolve_color(
|
||||
color=self.text_color,
|
||||
detections=detections,
|
||||
detection_idx=idx,
|
||||
color_lookup=color_lookup,
|
||||
)
|
||||
|
||||
box_xyxy = label_property[:4]
|
||||
text_left = label_property[4]
|
||||
text_top = label_property[5]
|
||||
label_x_position = box_xyxy[0] + self.text_padding - text_left
|
||||
label_y_position = box_xyxy[1] + self.text_padding - text_top
|
||||
|
||||
draw.rounded_rectangle(
|
||||
text_background_xyxy,
|
||||
tuple(box_xyxy),
|
||||
radius=self.border_radius,
|
||||
fill=color.as_rgb(),
|
||||
fill=background_color.as_rgb(),
|
||||
outline=None,
|
||||
)
|
||||
draw.text(
|
||||
xy=(text_x, text_y),
|
||||
text=text,
|
||||
xy=(label_x_position, label_y_position),
|
||||
text=labels[idx],
|
||||
font=self.font,
|
||||
fill=text_color.as_rgb(),
|
||||
)
|
||||
return scene
|
||||
|
||||
@staticmethod
|
||||
def _load_default_font(size):
|
||||
"""
|
||||
PIL either loads a font that accepts a size (e.g. on my machine)
|
||||
or raises an error saying `load_default` does not accept arguments
|
||||
(e.g. in Colab).
|
||||
"""
|
||||
def _load_font(font_size: int, font_path: Optional[str]):
|
||||
def load_default_font(size):
|
||||
try:
|
||||
return ImageFont.load_default(size)
|
||||
except TypeError:
|
||||
return ImageFont.load_default()
|
||||
|
||||
if font_path is None:
|
||||
return load_default_font(font_size)
|
||||
|
||||
try:
|
||||
font = ImageFont.load_default(size)
|
||||
except TypeError:
|
||||
font = ImageFont.load_default()
|
||||
return font
|
||||
return ImageFont.truetype(font_path, font_size)
|
||||
except OSError:
|
||||
print(f"Font path '{font_path}' not found. Using PIL's default font.")
|
||||
return load_default_font(font_size)
|
||||
|
||||
|
||||
class IconAnnotator(BaseAnnotator):
|
||||
@@ -1801,11 +1857,9 @@ class HeatMapAnnotator(BaseAnnotator):
|
||||
temp = temp.astype(np.uint8)
|
||||
if self.kernel_size is not None:
|
||||
temp = cv2.blur(temp, (self.kernel_size, self.kernel_size))
|
||||
hsv = np.zeros(scene.shape)
|
||||
hsv = np.full(scene.shape, 255, dtype=np.uint8)
|
||||
hsv[..., 0] = temp
|
||||
hsv[..., 1] = 255
|
||||
hsv[..., 2] = 255
|
||||
temp = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR)
|
||||
temp = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
|
||||
mask = cv2.cvtColor(self.heat_mask.astype(np.uint8), cv2.COLOR_GRAY2BGR) > 0
|
||||
scene[mask] = cv2.addWeighted(temp, self.opacity, scene, 1 - self.opacity, 0)[
|
||||
mask
|
||||
@@ -2390,6 +2444,9 @@ class CropAnnotator(BaseAnnotator):
|
||||
detections=detections
|
||||
)
|
||||
```
|
||||
|
||||

|
||||
"""
|
||||
assert isinstance(scene, np.ndarray)
|
||||
crops = [
|
||||
@@ -2546,3 +2603,261 @@ class BackgroundOverlayAnnotator(BaseAnnotator):
|
||||
|
||||
np.copyto(scene, colored_mask)
|
||||
return scene
|
||||
|
||||
|
||||
class ComparisonAnnotator:
|
||||
"""
|
||||
Highlights the differences between two sets of detections.
|
||||
Useful for comparing results from two different models, or the difference
|
||||
between a ground truth and a prediction.
|
||||
|
||||
If present, uses the oriented bounding box data.
|
||||
Otherwise, if present, uses a mask.
|
||||
Otherwise, uses the bounding box data.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
color_1: Color = Color.RED,
|
||||
color_2: Color = Color.GREEN,
|
||||
color_overlap: Color = Color.BLUE,
|
||||
*,
|
||||
opacity: float = 0.75,
|
||||
label_1: str = "",
|
||||
label_2: str = "",
|
||||
label_overlap: str = "",
|
||||
label_scale: float = 1.0,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
color_1 (Color): Color of areas only present in the first set of
|
||||
detections.
|
||||
color_2 (Color): Color of areas only present in the second set of
|
||||
detections.
|
||||
color_overlap (Color): Color of areas present in both sets of detections.
|
||||
opacity (float): Annotator opacity, from `0` to `1`.
|
||||
label_1 (str): Label for the first set of detections.
|
||||
label_2 (str): Label for the second set of detections.
|
||||
label_overlap (str): Label for areas present in both sets of detections.
|
||||
label_scale (float): Controls how large the labels are.
|
||||
"""
|
||||
|
||||
self.color_1 = color_1
|
||||
self.color_2 = color_2
|
||||
self.color_overlap = color_overlap
|
||||
|
||||
self.opacity = opacity
|
||||
self.label_1 = label_1
|
||||
self.label_2 = label_2
|
||||
self.label_overlap = label_overlap
|
||||
self.label_scale = label_scale
|
||||
self.text_thickness = int(self.label_scale + 1.2)
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
def annotate(
|
||||
self, scene: ImageType, detections_1: Detections, detections_2: Detections
|
||||
) -> ImageType:
|
||||
"""
|
||||
Highlights the differences between two sets of detections.
|
||||
|
||||
Args:
|
||||
scene (ImageType): The image where detections will be drawn.
|
||||
`ImageType` is a flexible type, accepting either `numpy.ndarray`
|
||||
or `PIL.Image.Image`.
|
||||
detections_1 (Detections): The first set of detections or predictions.
|
||||
detections_2 (Detections): The second set of detections to compare or
|
||||
ground truth.
|
||||
|
||||
Returns:
|
||||
The annotated image.
|
||||
|
||||
Example:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
image = ...
|
||||
detections_1 = sv.Detections(...)
|
||||
detections_2 = sv.Detections(...)
|
||||
|
||||
comparison_annotator = sv.ComparisonAnnotator()
|
||||
annotated_frame = comparison_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
detections_1=detections_1,
|
||||
detections_2=detections_2
|
||||
)
|
||||
```
|
||||
|
||||

|
||||
"""
|
||||
assert isinstance(scene, np.ndarray)
|
||||
if detections_1.is_empty() and detections_2.is_empty():
|
||||
return scene
|
||||
|
||||
use_obb = self._use_obb(detections_1, detections_2)
|
||||
use_mask = self._use_mask(detections_1, detections_2)
|
||||
|
||||
if use_obb:
|
||||
mask_1 = self._mask_from_obb(scene, detections_1)
|
||||
mask_2 = self._mask_from_obb(scene, detections_2)
|
||||
|
||||
elif use_mask:
|
||||
mask_1 = self._mask_from_mask(scene, detections_1)
|
||||
mask_2 = self._mask_from_mask(scene, detections_2)
|
||||
|
||||
else:
|
||||
mask_1 = self._mask_from_xyxy(scene, detections_1)
|
||||
mask_2 = self._mask_from_xyxy(scene, detections_2)
|
||||
|
||||
mask_overlap = mask_1 & mask_2
|
||||
mask_1 = mask_1 & ~mask_overlap
|
||||
mask_2 = mask_2 & ~mask_overlap
|
||||
|
||||
color_layer = np.zeros_like(scene, dtype=np.uint8)
|
||||
color_layer[mask_overlap] = self.color_overlap.as_bgr()
|
||||
color_layer[mask_1] = self.color_1.as_bgr()
|
||||
color_layer[mask_2] = self.color_2.as_bgr()
|
||||
|
||||
scene[mask_overlap] = (1 - self.opacity) * scene[
|
||||
mask_overlap
|
||||
] + self.opacity * color_layer[mask_overlap]
|
||||
scene[mask_1] = (1 - self.opacity) * scene[mask_1] + self.opacity * color_layer[
|
||||
mask_1
|
||||
]
|
||||
scene[mask_2] = (1 - self.opacity) * scene[mask_2] + self.opacity * color_layer[
|
||||
mask_2
|
||||
]
|
||||
|
||||
self._draw_labels(scene)
|
||||
|
||||
return scene
|
||||
|
||||
@staticmethod
|
||||
def _use_obb(detections_1: Detections, detections_2: Detections) -> bool:
|
||||
assert not detections_1.is_empty() or not detections_2.is_empty()
|
||||
is_obb_1 = ORIENTED_BOX_COORDINATES in detections_1.data
|
||||
is_obb_2 = ORIENTED_BOX_COORDINATES in detections_2.data
|
||||
return (
|
||||
(is_obb_1 and is_obb_2)
|
||||
or (is_obb_1 and detections_2.is_empty())
|
||||
or (detections_1.is_empty() and is_obb_2)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _use_mask(detections_1: Detections, detections_2: Detections) -> bool:
|
||||
assert not detections_1.is_empty() or not detections_2.is_empty()
|
||||
is_mask_1 = detections_1.mask is not None
|
||||
is_mask_2 = detections_2.mask is not None
|
||||
return (
|
||||
(is_mask_1 and is_mask_2)
|
||||
or (is_mask_1 and detections_2.is_empty())
|
||||
or (detections_1.is_empty() and is_mask_2)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _mask_from_xyxy(scene: np.ndarray, detections: Detections) -> np.ndarray:
|
||||
mask = np.zeros(scene.shape[:2], dtype=np.bool_)
|
||||
if detections.is_empty():
|
||||
return mask
|
||||
|
||||
resolution_wh = scene.shape[1], scene.shape[0]
|
||||
polygons = xyxy_to_polygons(detections.xyxy)
|
||||
|
||||
for polygon in polygons:
|
||||
polygon_mask = polygon_to_mask(polygon, resolution_wh=resolution_wh)
|
||||
mask |= polygon_mask.astype(np.bool_)
|
||||
return mask
|
||||
|
||||
@staticmethod
|
||||
def _mask_from_obb(scene: np.ndarray, detections: Detections) -> np.ndarray:
|
||||
mask = np.zeros(scene.shape[:2], dtype=np.bool_)
|
||||
if detections.is_empty():
|
||||
return mask
|
||||
|
||||
resolution_wh = scene.shape[1], scene.shape[0]
|
||||
|
||||
for polygon in detections.data[ORIENTED_BOX_COORDINATES]:
|
||||
polygon_mask = polygon_to_mask(polygon, resolution_wh=resolution_wh)
|
||||
mask |= polygon_mask.astype(np.bool_)
|
||||
return mask
|
||||
|
||||
@staticmethod
|
||||
def _mask_from_mask(scene: np.ndarray, detections: Detections) -> np.ndarray:
|
||||
mask = np.zeros(scene.shape[:2], dtype=np.bool_)
|
||||
if detections.is_empty():
|
||||
return mask
|
||||
assert detections.mask is not None
|
||||
|
||||
for detections_mask in detections.mask:
|
||||
mask |= detections_mask.astype(np.bool_)
|
||||
return mask
|
||||
|
||||
def _draw_labels(self, scene: np.ndarray) -> None:
|
||||
"""
|
||||
Draw the labels, explaining what each color represents, with automatically
|
||||
computed positions.
|
||||
|
||||
Args:
|
||||
scene (np.ndarray): The image where the labels will be drawn.
|
||||
"""
|
||||
margin = int(50 * self.label_scale)
|
||||
gap = int(40 * self.label_scale)
|
||||
y0 = int(50 * self.label_scale)
|
||||
height = int(50 * self.label_scale)
|
||||
|
||||
marker_size = int(20 * self.label_scale)
|
||||
padding = int(10 * self.label_scale)
|
||||
text_box_corner_radius = int(10 * self.label_scale)
|
||||
marker_corner_radius = int(4 * self.label_scale)
|
||||
text_scale = self.label_scale
|
||||
|
||||
label_color_pairs = [
|
||||
(self.label_1, self.color_1),
|
||||
(self.label_2, self.color_2),
|
||||
(self.label_overlap, self.color_overlap),
|
||||
]
|
||||
|
||||
x0 = margin
|
||||
for text, color in label_color_pairs:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
(text_w, _) = cv2.getTextSize(
|
||||
text=text,
|
||||
fontFace=CV2_FONT,
|
||||
fontScale=self.label_scale,
|
||||
thickness=self.text_thickness,
|
||||
)[0]
|
||||
|
||||
width = text_w + marker_size + padding * 4
|
||||
center_x = x0 + width // 2
|
||||
center_y = y0 + height // 2
|
||||
|
||||
draw_rounded_rectangle(
|
||||
scene=scene,
|
||||
rect=Rect(x=x0, y=y0, width=width, height=height),
|
||||
color=Color.WHITE,
|
||||
border_radius=text_box_corner_radius,
|
||||
)
|
||||
|
||||
draw_rounded_rectangle(
|
||||
scene=scene,
|
||||
rect=Rect(
|
||||
x=x0 + padding,
|
||||
y=center_y - marker_size / 2,
|
||||
width=marker_size,
|
||||
height=marker_size,
|
||||
),
|
||||
color=color,
|
||||
border_radius=marker_corner_radius,
|
||||
)
|
||||
|
||||
draw_text(
|
||||
scene,
|
||||
text,
|
||||
text_anchor=Point(x=center_x + marker_size, y=center_y),
|
||||
text_scale=text_scale,
|
||||
text_thickness=self.text_thickness,
|
||||
)
|
||||
|
||||
x0 += width + gap
|
||||
|
||||
@@ -7,6 +7,9 @@ from supervision.detection.core import Detections
|
||||
from supervision.draw.color import Color, ColorPalette
|
||||
from supervision.geometry.core import Position
|
||||
|
||||
PENDING_TRACK_COLOR = Color.GREY
|
||||
PENDING_TRACK_ID = -1
|
||||
|
||||
|
||||
class ColorLookup(Enum):
|
||||
"""
|
||||
@@ -136,6 +139,8 @@ def resolve_color(
|
||||
detection_idx=detection_idx,
|
||||
color_lookup=color_lookup,
|
||||
)
|
||||
if color_lookup == ColorLookup.TRACK and idx == PENDING_TRACK_ID:
|
||||
return PENDING_TRACK_COLOR
|
||||
return get_color_by_index(color=color, idx=idx)
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
from supervision.assets.downloader import download_assets
|
||||
from supervision.assets.list import VideoAssets
|
||||
|
||||
__all__ = ["VideoAssets", "download_assets"]
|
||||
|
||||
@@ -4,19 +4,10 @@ from pathlib import Path
|
||||
from shutil import copyfileobj
|
||||
from typing import Union
|
||||
|
||||
from supervision.assets.list import VIDEO_ASSETS, VideoAssets
|
||||
from requests import get
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
try:
|
||||
from requests import get
|
||||
from tqdm.auto import tqdm
|
||||
except ImportError:
|
||||
raise ValueError(
|
||||
"\n"
|
||||
"Please install requests and tqdm to download assets \n"
|
||||
"or install supervision with assets \n"
|
||||
"pip install supervision[assets] \n"
|
||||
"\n"
|
||||
)
|
||||
from supervision.assets.list import VIDEO_ASSETS, VideoAssets
|
||||
|
||||
|
||||
def is_md5_hash_matching(filename: str, original_md5_hash: str) -> bool:
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -705,28 +705,6 @@ class ClassificationDataset(BaseDataset):
|
||||
"a list of paths `List[str]` instead."
|
||||
)
|
||||
|
||||
@property
|
||||
@deprecated(
|
||||
"`DetectionDataset.images` property is deprecated and will be removed in "
|
||||
"`supervision-0.26.0`. Iterate with `for path, image, annotation in dataset:` "
|
||||
"instead."
|
||||
)
|
||||
def images(self) -> Dict[str, np.ndarray]:
|
||||
"""
|
||||
Load all images to memory and return them as a dictionary.
|
||||
|
||||
!!! warning
|
||||
|
||||
Only use this when you need all images at once.
|
||||
It is much more memory-efficient to initialize dataset with
|
||||
image paths and use `for path, image, annotation in dataset:`.
|
||||
"""
|
||||
if self._images_in_memory:
|
||||
return self._images_in_memory
|
||||
|
||||
images = {image_path: cv2.imread(image_path) for image_path in self.image_paths}
|
||||
return images
|
||||
|
||||
def _get_image(self, image_path: str) -> np.ndarray:
|
||||
"""Assumes that image is in dataset"""
|
||||
if self._images_in_memory:
|
||||
|
||||
@@ -2,8 +2,8 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from supervision.config import ORIENTED_BOX_COORDINATES
|
||||
from supervision.dataset.utils import approximate_mask_with_polygons
|
||||
@@ -153,7 +153,18 @@ def load_yolo_annotations(
|
||||
image_paths = [
|
||||
str(path)
|
||||
for path in list_files_with_extensions(
|
||||
directory=images_directory_path, extensions=["jpg", "jpeg", "png"]
|
||||
directory=images_directory_path,
|
||||
extensions=[
|
||||
"bmp",
|
||||
"dng",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"mpo",
|
||||
"png",
|
||||
"tif",
|
||||
"tiff",
|
||||
"webp",
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
@@ -167,10 +178,16 @@ def load_yolo_annotations(
|
||||
annotations[image_path] = Detections.empty()
|
||||
continue
|
||||
|
||||
image = cv2.imread(image_path)
|
||||
# PIL is much faster than cv2 for checking image shape and mode: https://github.com/roboflow/supervision/issues/1554
|
||||
image = Image.open(image_path)
|
||||
lines = read_txt_file(file_path=annotation_path, skip_empty=True)
|
||||
h, w, _ = image.shape
|
||||
w, h = image.size
|
||||
resolution_wh = (w, h)
|
||||
if image.mode not in ("RGB", "L"):
|
||||
raise ValueError(
|
||||
f"Images must be 'RGB' or 'grayscale', \
|
||||
but {image_path} mode is '{image.mode}'."
|
||||
)
|
||||
|
||||
with_masks = _with_mask(lines=lines)
|
||||
with_masks = force_masks if force_masks else with_masks
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
@@ -10,12 +10,6 @@ from supervision.config import (
|
||||
CLASS_NAME_DATA_FIELD,
|
||||
ORIENTED_BOX_COORDINATES,
|
||||
)
|
||||
from supervision.detection.lmm import (
|
||||
LMM,
|
||||
from_florence_2,
|
||||
from_paligemma,
|
||||
validate_lmm_parameters,
|
||||
)
|
||||
from supervision.detection.overlap_filter import (
|
||||
box_non_max_merge,
|
||||
box_non_max_suppression,
|
||||
@@ -32,13 +26,23 @@ 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,
|
||||
)
|
||||
from supervision.detection.vlm import (
|
||||
LMM,
|
||||
VLM,
|
||||
from_florence_2,
|
||||
from_paligemma,
|
||||
from_qwen_2_5_vl,
|
||||
validate_vlm_parameters,
|
||||
)
|
||||
from supervision.geometry.core import Position
|
||||
from supervision.utils.internal import get_instance_variables
|
||||
from supervision.utils.internal import deprecated, get_instance_variables
|
||||
from supervision.validators import validate_detections_fields
|
||||
|
||||
|
||||
@@ -115,7 +119,7 @@ class Detections:
|
||||
xyxy (np.ndarray): An array of shape `(n, 4)` containing
|
||||
the bounding boxes coordinates in format `[x1, y1, x2, y2]`
|
||||
mask: (Optional[np.ndarray]): An array of shape
|
||||
`(n, H, W)` containing the segmentation masks.
|
||||
`(n, H, W)` containing the segmentation masks (`bool` data type).
|
||||
confidence (Optional[np.ndarray]): An array of shape
|
||||
`(n,)` containing the confidence scores of the detections.
|
||||
class_id (Optional[np.ndarray]): An array of shape
|
||||
@@ -125,6 +129,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 +140,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 +193,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),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -599,8 +608,10 @@ class Detections:
|
||||
detections = sv.Detections.from_inference(result)
|
||||
```
|
||||
"""
|
||||
with suppress(AttributeError):
|
||||
if hasattr(roboflow_result, "dict"):
|
||||
roboflow_result = roboflow_result.dict(exclude_none=True, by_alias=True)
|
||||
elif hasattr(roboflow_result, "json"):
|
||||
roboflow_result = roboflow_result.json()
|
||||
xyxy, confidence, class_id, masks, trackers, data = process_roboflow_result(
|
||||
roboflow_result=roboflow_result
|
||||
)
|
||||
@@ -704,7 +715,7 @@ class Detections:
|
||||
"""
|
||||
if "error" in azure_result:
|
||||
raise ValueError(
|
||||
f'Azure API returned an error {azure_result["error"]["message"]}'
|
||||
f"Azure API returned an error {azure_result['error']['message']}"
|
||||
)
|
||||
|
||||
xyxy, confidences, class_ids = [], [], []
|
||||
@@ -791,6 +802,10 @@ class Detections:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@deprecated(
|
||||
"`Detections.from_lmm` property is deprecated and will be removed in "
|
||||
"`supervision-0.31.0`. Use Detections.from_vlm instead."
|
||||
)
|
||||
def from_lmm(
|
||||
cls, lmm: Union[LMM, str], result: Union[str, dict], **kwargs: Any
|
||||
) -> Detections:
|
||||
@@ -829,16 +844,51 @@ class Detections:
|
||||
# array([0])
|
||||
```
|
||||
"""
|
||||
lmm = validate_lmm_parameters(lmm, result, kwargs)
|
||||
# filler logic mapping old from_lmm to new from_vlm
|
||||
lmm_to_vlm = {
|
||||
LMM.PALIGEMMA: VLM.PALIGEMMA,
|
||||
LMM.FLORENCE_2: VLM.FLORENCE_2,
|
||||
LMM.QWEN_2_5_VL: VLM.QWEN_2_5_VL,
|
||||
}
|
||||
|
||||
if lmm == LMM.PALIGEMMA:
|
||||
assert isinstance(result, str)
|
||||
# (this works even if the LMM enum is wrapped by @deprecated)
|
||||
if isinstance(lmm, Enum) and lmm.__class__.__name__ == "LMM":
|
||||
vlm = lmm_to_vlm[lmm]
|
||||
|
||||
elif isinstance(lmm, str):
|
||||
try:
|
||||
lmm_enum = LMM(lmm.lower())
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Invalid LMM string '{lmm}'. Must be one of "
|
||||
f"{[m.value for m in LMM]}"
|
||||
)
|
||||
vlm = lmm_to_vlm[lmm_enum]
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid type for 'lmm': {type(lmm)}. Must be LMM or str."
|
||||
)
|
||||
|
||||
return cls.from_vlm(vlm=vlm, result=result, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_vlm(
|
||||
cls, vlm: Union[VLM, str], result: Union[str, dict], **kwargs: Any
|
||||
) -> Detections:
|
||||
vlm = validate_vlm_parameters(vlm, result, kwargs)
|
||||
|
||||
if vlm == VLM.PALIGEMMA:
|
||||
xyxy, class_id, class_name = from_paligemma(result, **kwargs)
|
||||
data = {CLASS_NAME_DATA_FIELD: class_name}
|
||||
return cls(xyxy=xyxy, class_id=class_id, data=data)
|
||||
|
||||
if lmm == LMM.FLORENCE_2:
|
||||
assert isinstance(result, dict)
|
||||
if vlm == VLM.QWEN_2_5_VL:
|
||||
xyxy, class_id, class_name = from_qwen_2_5_vl(result, **kwargs)
|
||||
data = {CLASS_NAME_DATA_FIELD: class_name}
|
||||
return cls(xyxy=xyxy, class_id=class_id, data=data)
|
||||
|
||||
if vlm == VLM.FLORENCE_2:
|
||||
xyxy, labels, mask, xyxyxyxy = from_florence_2(result, **kwargs)
|
||||
if len(xyxy) == 0:
|
||||
return cls.empty()
|
||||
@@ -851,8 +901,6 @@ class Detections:
|
||||
|
||||
return cls(xyxy=xyxy, mask=mask, data=data)
|
||||
|
||||
raise ValueError(f"Unsupported LMM: {lmm}")
|
||||
|
||||
@classmethod
|
||||
def from_easyocr(cls, easyocr_results: list) -> Detections:
|
||||
"""
|
||||
@@ -985,6 +1033,7 @@ class Detections:
|
||||
"""
|
||||
empty_detections = Detections.empty()
|
||||
empty_detections.data = self.data
|
||||
empty_detections.metadata = self.metadata
|
||||
return self == empty_detections
|
||||
|
||||
@classmethod
|
||||
@@ -1018,16 +1067,16 @@ class Detections:
|
||||
detections_1 = sv.Detections(
|
||||
xyxy=np.array([[15, 15, 100, 100], [200, 200, 300, 300]]),
|
||||
class_id=np.array([1, 2]),
|
||||
data={'feature_vector': np.array([0.1, 0.2)])}
|
||||
)
|
||||
data={'feature_vector': np.array([0.1, 0.2])}
|
||||
)
|
||||
|
||||
detections_2 = sv.Detections(
|
||||
xyxy=np.array([[30, 30, 120, 120]]),
|
||||
class_id=np.array([1]),
|
||||
data={'feature_vector': [np.array([0.3])]}
|
||||
)
|
||||
data={'feature_vector': np.array([0.3])}
|
||||
)
|
||||
|
||||
merged_detections = Detections.merge([detections_1, detections_2])
|
||||
merged_detections = sv.Detections.merge([detections_1, detections_2])
|
||||
|
||||
merged_detections.xyxy
|
||||
array([[ 15, 15, 100, 100],
|
||||
@@ -1078,6 +1127,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 +1137,7 @@ class Detections:
|
||||
class_id=class_id,
|
||||
tracker_id=tracker_id,
|
||||
data=data,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def get_anchors_coordinates(self, anchor: Position) -> np.ndarray:
|
||||
@@ -1189,6 +1242,8 @@ class Detections:
|
||||
"""
|
||||
if isinstance(index, str):
|
||||
return self.data.get(index)
|
||||
if self.is_empty():
|
||||
return self
|
||||
if isinstance(index, int):
|
||||
index = [index]
|
||||
return Detections(
|
||||
@@ -1198,6 +1253,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]):
|
||||
@@ -1290,9 +1346,9 @@ class Detections:
|
||||
if len(self) == 0:
|
||||
return self
|
||||
|
||||
assert (
|
||||
self.confidence is not None
|
||||
), "Detections confidence must be given for NMS to be executed."
|
||||
assert self.confidence is not None, (
|
||||
"Detections confidence must be given for NMS to be executed."
|
||||
)
|
||||
|
||||
if class_agnostic:
|
||||
predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1)))
|
||||
@@ -1346,9 +1402,9 @@ class Detections:
|
||||
if len(self) == 0:
|
||||
return self
|
||||
|
||||
assert (
|
||||
self.confidence is not None
|
||||
), "Detections confidence must be given for NMM to be executed."
|
||||
assert self.confidence is not None, (
|
||||
"Detections confidence must be given for NMM to be executed."
|
||||
)
|
||||
|
||||
if class_agnostic:
|
||||
predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1)))
|
||||
@@ -1459,6 +1515,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 +1524,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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -41,6 +42,10 @@ class LineZone:
|
||||
to inside.
|
||||
out_count (int): The number of objects that have crossed the line from inside
|
||||
to outside.
|
||||
in_count_per_class (Dict[int, int]): Number of objects of each class that have
|
||||
crossed the line from outside to inside.
|
||||
out_count_per_class (Dict[int, int]): Number of objects of each class that have
|
||||
crossed the line from inside to outside.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -74,6 +79,7 @@ class LineZone:
|
||||
Position.BOTTOM_LEFT,
|
||||
Position.BOTTOM_RIGHT,
|
||||
),
|
||||
minimum_crossing_threshold: int = 1,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
@@ -84,10 +90,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
|
||||
minimum_crossing_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, minimum_crossing_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
|
||||
@@ -97,74 +111,20 @@ class LineZone:
|
||||
|
||||
@property
|
||||
def in_count(self) -> int:
|
||||
"""
|
||||
Number of objects that have crossed the line from
|
||||
outside to inside.
|
||||
"""
|
||||
return sum(self._in_count_per_class.values())
|
||||
|
||||
@property
|
||||
def out_count(self) -> int:
|
||||
"""
|
||||
Number of objects that have crossed the line from
|
||||
inside to outside.
|
||||
"""
|
||||
return sum(self._out_count_per_class.values())
|
||||
|
||||
@property
|
||||
def in_count_per_class(self) -> Dict[int, int]:
|
||||
"""
|
||||
Number of objects of each class that have crossed
|
||||
the line from outside to inside.
|
||||
"""
|
||||
return dict(self._in_count_per_class)
|
||||
|
||||
@property
|
||||
def out_count_per_class(self) -> Dict[int, int]:
|
||||
"""
|
||||
Number of objects of each class that have crossed the line
|
||||
from inside to outside.
|
||||
"""
|
||||
return dict(self._out_count_per_class)
|
||||
|
||||
@staticmethod
|
||||
def calculate_region_of_interest_limits(vector: Vector) -> Tuple[Vector, Vector]:
|
||||
magnitude = vector.magnitude
|
||||
|
||||
if magnitude == 0:
|
||||
raise ValueError("The magnitude of the vector cannot be zero.")
|
||||
|
||||
delta_x = vector.end.x - vector.start.x
|
||||
delta_y = vector.end.y - vector.start.y
|
||||
|
||||
unit_vector_x = delta_x / magnitude
|
||||
unit_vector_y = delta_y / magnitude
|
||||
|
||||
perpendicular_vector_x = -unit_vector_y
|
||||
perpendicular_vector_y = unit_vector_x
|
||||
|
||||
start_region_limit = Vector(
|
||||
start=vector.start,
|
||||
end=Point(
|
||||
x=vector.start.x + perpendicular_vector_x,
|
||||
y=vector.start.y + perpendicular_vector_y,
|
||||
),
|
||||
)
|
||||
end_region_limit = Vector(
|
||||
start=vector.end,
|
||||
end=Point(
|
||||
x=vector.end.x - perpendicular_vector_x,
|
||||
y=vector.end.y - perpendicular_vector_y,
|
||||
),
|
||||
)
|
||||
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]:
|
||||
"""
|
||||
Update the `in_count` and `out_count` based on the objects that cross the line.
|
||||
@@ -194,6 +154,123 @@ class LineZone:
|
||||
)
|
||||
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]:
|
||||
magnitude = vector.magnitude
|
||||
|
||||
if magnitude == 0:
|
||||
raise ValueError("The magnitude of the vector cannot be zero.")
|
||||
|
||||
delta_x = vector.end.x - vector.start.x
|
||||
delta_y = vector.end.y - vector.start.y
|
||||
|
||||
unit_vector_x = delta_x / magnitude
|
||||
unit_vector_y = delta_y / magnitude
|
||||
|
||||
perpendicular_vector_x = -unit_vector_y
|
||||
perpendicular_vector_y = unit_vector_x
|
||||
|
||||
start_region_limit = Vector(
|
||||
start=vector.start,
|
||||
end=Point(
|
||||
x=vector.start.x + perpendicular_vector_x,
|
||||
y=vector.start.y + perpendicular_vector_y,
|
||||
),
|
||||
)
|
||||
end_region_limit = Vector(
|
||||
start=vector.end,
|
||||
end=Point(
|
||||
x=vector.end.x - perpendicular_vector_x,
|
||||
y=vector.end.y - perpendicular_vector_y,
|
||||
),
|
||||
)
|
||||
return start_region_limit, end_region_limit
|
||||
|
||||
def _compute_anchor_sides(
|
||||
self, detections: Detections
|
||||
) -> Tuple[npt.NDArray[np.bool_], npt.NDArray[np.bool_], npt.NDArray[np.bool_]]:
|
||||
"""
|
||||
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): The detections to check.
|
||||
|
||||
Returns:
|
||||
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.
|
||||
"""
|
||||
assert len(detections) > 0
|
||||
assert detections.tracker_id is not None
|
||||
|
||||
all_anchors = np.array(
|
||||
[
|
||||
detections.get_anchors_coordinates(anchor)
|
||||
@@ -203,52 +280,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:
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import re
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from supervision.detection.utils import polygon_to_mask, polygon_to_xyxy
|
||||
|
||||
|
||||
class LMM(Enum):
|
||||
PALIGEMMA = "paligemma"
|
||||
FLORENCE_2 = "florence_2"
|
||||
|
||||
|
||||
RESULT_TYPES: Dict[LMM, type] = {LMM.PALIGEMMA: str, LMM.FLORENCE_2: dict}
|
||||
|
||||
REQUIRED_ARGUMENTS: Dict[LMM, List[str]] = {
|
||||
LMM.PALIGEMMA: ["resolution_wh"],
|
||||
LMM.FLORENCE_2: ["resolution_wh"],
|
||||
}
|
||||
|
||||
ALLOWED_ARGUMENTS: Dict[LMM, List[str]] = {
|
||||
LMM.PALIGEMMA: ["resolution_wh", "classes"],
|
||||
LMM.FLORENCE_2: ["resolution_wh"],
|
||||
}
|
||||
|
||||
SUPPORTED_TASKS_FLORENCE_2 = [
|
||||
"<OD>",
|
||||
"<CAPTION_TO_PHRASE_GROUNDING>",
|
||||
"<DENSE_REGION_CAPTION>",
|
||||
"<REGION_PROPOSAL>",
|
||||
"<OCR_WITH_REGION>",
|
||||
"<REFERRING_EXPRESSION_SEGMENTATION>",
|
||||
"<REGION_TO_SEGMENTATION>",
|
||||
"<OPEN_VOCABULARY_DETECTION>",
|
||||
"<REGION_TO_CATEGORY>",
|
||||
"<REGION_TO_DESCRIPTION>",
|
||||
]
|
||||
|
||||
|
||||
def validate_lmm_parameters(
|
||||
lmm: Union[LMM, str], result: Any, kwargs: Dict[str, Any]
|
||||
) -> LMM:
|
||||
if isinstance(lmm, str):
|
||||
try:
|
||||
lmm = LMM(lmm.lower())
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Invalid lmm value: {lmm}. Must be one of {[e.value for e in LMM]}"
|
||||
)
|
||||
|
||||
if not isinstance(result, RESULT_TYPES[lmm]):
|
||||
raise ValueError(
|
||||
f"Invalid LMM result type: {type(result)}. Must be {RESULT_TYPES[lmm]}"
|
||||
)
|
||||
|
||||
required_args = REQUIRED_ARGUMENTS.get(lmm, [])
|
||||
for arg in required_args:
|
||||
if arg not in kwargs:
|
||||
raise ValueError(f"Missing required argument: {arg}")
|
||||
|
||||
allowed_args = ALLOWED_ARGUMENTS.get(lmm, [])
|
||||
for arg in kwargs:
|
||||
if arg not in allowed_args:
|
||||
raise ValueError(f"Argument {arg} is not allowed for {lmm.name}")
|
||||
|
||||
return lmm
|
||||
|
||||
|
||||
def from_paligemma(
|
||||
result: str, resolution_wh: Tuple[int, int], classes: Optional[List[str]] = None
|
||||
) -> Tuple[np.ndarray, Optional[np.ndarray], np.ndarray]:
|
||||
w, h = resolution_wh
|
||||
pattern = re.compile(
|
||||
r"(?<!<loc\d{4}>)<loc(\d{4})><loc(\d{4})><loc(\d{4})><loc(\d{4})> ([\w\s\-]+)"
|
||||
)
|
||||
matches = pattern.findall(result)
|
||||
matches = np.array(matches) if matches else np.empty((0, 5))
|
||||
|
||||
xyxy, class_name = matches[:, [1, 0, 3, 2]], matches[:, 4]
|
||||
xyxy = xyxy.astype(int) / 1024 * np.array([w, h, w, h])
|
||||
class_name = np.char.strip(class_name.astype(str))
|
||||
class_id = None
|
||||
|
||||
if classes is not None:
|
||||
mask = np.array([name in classes for name in class_name]).astype(bool)
|
||||
xyxy, class_name = xyxy[mask], class_name[mask]
|
||||
class_id = np.array([classes.index(name) for name in class_name])
|
||||
|
||||
return xyxy, class_id, class_name
|
||||
|
||||
|
||||
def from_florence_2(
|
||||
result: dict, resolution_wh: Tuple[int, int]
|
||||
) -> Tuple[
|
||||
np.ndarray, Optional[np.ndarray], Optional[np.ndarray], Optional[np.ndarray]
|
||||
]:
|
||||
"""
|
||||
Parse results from the Florence 2 multi-model model.
|
||||
https://huggingface.co/microsoft/Florence-2-large
|
||||
|
||||
Parameters:
|
||||
result: dict containing the model output
|
||||
|
||||
Returns:
|
||||
xyxy (np.ndarray): An array of shape `(n, 4)` containing
|
||||
the bounding boxes coordinates in format `[x1, y1, x2, y2]`
|
||||
labels: (Optional[np.ndarray]): An array of shape `(n,)` containing
|
||||
the class labels for each bounding box
|
||||
masks: (Optional[np.ndarray]): An array of shape `(n, h, w)` containing
|
||||
the segmentation masks for each bounding box
|
||||
obb_boxes: (Optional[np.ndarray]): An array of shape `(n, 4, 2)` containing
|
||||
oriented bounding boxes.
|
||||
"""
|
||||
assert len(result) == 1, f"Expected result with a single element. Got: {result}"
|
||||
task = next(iter(result.keys()))
|
||||
if task not in SUPPORTED_TASKS_FLORENCE_2:
|
||||
raise ValueError(
|
||||
f"{task} not supported. Supported tasks are: {SUPPORTED_TASKS_FLORENCE_2}"
|
||||
)
|
||||
result = result[task]
|
||||
|
||||
if task in ["<OD>", "<CAPTION_TO_PHRASE_GROUNDING>", "<DENSE_REGION_CAPTION>"]:
|
||||
xyxy = np.array(result["bboxes"], dtype=np.float32)
|
||||
labels = np.array(result["labels"])
|
||||
return xyxy, labels, None, None
|
||||
|
||||
if task == "<REGION_PROPOSAL>":
|
||||
xyxy = np.array(result["bboxes"], dtype=np.float32)
|
||||
# provides labels, but they are ["", "", "", ...]
|
||||
return xyxy, None, None, None
|
||||
|
||||
if task == "<OCR_WITH_REGION>":
|
||||
xyxyxyxy = np.array(result["quad_boxes"], dtype=np.float32)
|
||||
xyxyxyxy = xyxyxyxy.reshape(-1, 4, 2)
|
||||
xyxy = np.array([polygon_to_xyxy(polygon) for polygon in xyxyxyxy])
|
||||
labels = np.array(result["labels"])
|
||||
return xyxy, labels, None, xyxyxyxy
|
||||
|
||||
if task in ["<REFERRING_EXPRESSION_SEGMENTATION>", "<REGION_TO_SEGMENTATION>"]:
|
||||
xyxy_list = []
|
||||
masks_list = []
|
||||
for polygons_of_same_class in result["polygons"]:
|
||||
for polygon in polygons_of_same_class:
|
||||
polygon = np.reshape(polygon, (-1, 2)).astype(np.int32)
|
||||
mask = polygon_to_mask(polygon, resolution_wh).astype(bool)
|
||||
masks_list.append(mask)
|
||||
xyxy = polygon_to_xyxy(polygon)
|
||||
xyxy_list.append(xyxy)
|
||||
# per-class labels also provided, but they are ["", "", "", ...]
|
||||
# when we figure out how to set class names, we can do
|
||||
# zip(result["labels"], result["polygons"])
|
||||
xyxy = np.array(xyxy_list, dtype=np.float32)
|
||||
masks = np.array(masks_list)
|
||||
return xyxy, None, masks, None
|
||||
|
||||
if task == "<OPEN_VOCABULARY_DETECTION>":
|
||||
xyxy = np.array(result["bboxes"], dtype=np.float32)
|
||||
labels = np.array(result["bboxes_labels"])
|
||||
# Also has "polygons" and "polygons_labels", but they don't seem to be used
|
||||
return xyxy, labels, None, None
|
||||
|
||||
if task in ["<REGION_TO_CATEGORY>", "<REGION_TO_DESCRIPTION>"]:
|
||||
assert isinstance(
|
||||
result, str
|
||||
), f"Expected string as <REGION_TO_CATEGORY> result, got {type(result)}"
|
||||
|
||||
if result == "No object detected.":
|
||||
return np.empty((0, 4), dtype=np.float32), np.array([]), None, None
|
||||
|
||||
pattern = re.compile(r"<loc_(\d+)><loc_(\d+)><loc_(\d+)><loc_(\d+)>")
|
||||
match = pattern.search(result)
|
||||
assert (
|
||||
match is not None
|
||||
), f"Expected string to end in location tags, but got {result}"
|
||||
|
||||
w, h = resolution_wh
|
||||
xyxy = np.array([match.groups()], dtype=np.float32)
|
||||
xyxy *= np.array([w, h, w, h]) / 1000
|
||||
result_string = result[: match.start()]
|
||||
labels = np.array([result_string])
|
||||
return xyxy, labels, None, None
|
||||
|
||||
assert False, f"Unimplemented task: {task}"
|
||||
@@ -226,8 +226,7 @@ def box_non_max_merge(
|
||||
for merge_group in merge_groups:
|
||||
if len(merge_group) == 0:
|
||||
raise ValueError(
|
||||
f"Empty group detected when non-max-merging "
|
||||
f"detections: {merge_groups}"
|
||||
f"Empty group detected when non-max-merging detections: {merge_groups}"
|
||||
)
|
||||
return merge_groups
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+304
-18
@@ -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
|
||||
@@ -11,6 +11,25 @@ from supervision.geometry.core import Vector
|
||||
MIN_POLYGON_POINT_COUNT = 3
|
||||
|
||||
|
||||
def xyxy_to_polygons(box: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Convert an array of boxes to an array of polygons.
|
||||
Retains the input datatype.
|
||||
|
||||
Args:
|
||||
box (np.ndarray): An array of boxes (N, 4), where each box is represented as a
|
||||
list of four coordinates in the format `(x_min, y_min, x_max, y_max)`.
|
||||
|
||||
Returns:
|
||||
np.ndarray: An array of polygons (N, 4, 2), where each polygon is
|
||||
represented as a list of four coordinates in the format `(x, y)`.
|
||||
"""
|
||||
polygon = np.zeros((box.shape[0], 4, 2), dtype=box.dtype)
|
||||
polygon[:, :, 0] = box[:, [0, 2, 2, 0]]
|
||||
polygon[:, :, 1] = box[:, [1, 1, 3, 3]]
|
||||
return polygon
|
||||
|
||||
|
||||
def polygon_to_mask(polygon: np.ndarray, resolution_wh: Tuple[int, int]) -> np.ndarray:
|
||||
"""Generate a mask from a polygon.
|
||||
|
||||
@@ -23,10 +42,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 +181,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):
|
||||
@@ -303,6 +321,43 @@ def xywh_to_xyxy(xywh: np.ndarray) -> np.ndarray:
|
||||
return xyxy
|
||||
|
||||
|
||||
def xyxy_to_xywh(xyxy: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Converts bounding box coordinates from `(x_min, y_min, x_max, y_max)`
|
||||
format to `(x, y, width, height)` format.
|
||||
|
||||
Args:
|
||||
xyxy (np.ndarray): A numpy array of shape `(N, 4)` where each row
|
||||
corresponds to a bounding box in the format `(x_min, y_min, x_max,
|
||||
y_max)`.
|
||||
|
||||
Returns:
|
||||
np.ndarray: A numpy array of shape `(N, 4)` where each row corresponds
|
||||
to a bounding box in the format `(x, y, width, height)`.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
xyxy = np.array([
|
||||
[10, 20, 40, 60],
|
||||
[15, 25, 50, 70]
|
||||
])
|
||||
|
||||
sv.xyxy_to_xywh(xyxy=xyxy)
|
||||
# array([
|
||||
# [10, 20, 30, 40],
|
||||
# [15, 25, 35, 45]
|
||||
# ])
|
||||
```
|
||||
"""
|
||||
xywh = xyxy.copy()
|
||||
xywh[:, 2] = xyxy[:, 2] - xyxy[:, 0]
|
||||
xywh[:, 3] = xyxy[:, 3] - xyxy[:, 1]
|
||||
return xywh
|
||||
|
||||
|
||||
def xcycwh_to_xyxy(xcycwh: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Converts bounding box coordinates from `(center_x, center_y, width, height)`
|
||||
@@ -342,6 +397,56 @@ def xcycwh_to_xyxy(xcycwh: np.ndarray) -> np.ndarray:
|
||||
return xyxy
|
||||
|
||||
|
||||
def xyxy_to_xcycarh(xyxy: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Converts bounding box coordinates from `(x_min, y_min, x_max, y_max)`
|
||||
into measurement space to format `(center x, center y, aspect ratio, height)`,
|
||||
where the aspect ratio is `width / height`.
|
||||
|
||||
Args:
|
||||
xyxy (np.ndarray): Bounding box in format `(x1, y1, x2, y2)`.
|
||||
Expected shape is `(N, 4)`.
|
||||
Returns:
|
||||
np.ndarray: Bounding box in format
|
||||
`(center x, center y, aspect ratio, height)`. Shape `(N, 4)`.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
xyxy = np.array([
|
||||
[10, 20, 40, 60],
|
||||
[15, 25, 50, 70]
|
||||
])
|
||||
|
||||
sv.xyxy_to_xcycarh(xyxy=xyxy)
|
||||
# array([
|
||||
# [25. , 40. , 0.75, 40. ],
|
||||
# [32.5 , 47.5 , 0.77777778, 45. ]
|
||||
# ])
|
||||
```
|
||||
|
||||
"""
|
||||
if xyxy.size == 0:
|
||||
return np.empty((0, 4), dtype=float)
|
||||
|
||||
x1, y1, x2, y2 = xyxy.T
|
||||
width = x2 - x1
|
||||
height = y2 - y1
|
||||
center_x = x1 + width / 2
|
||||
center_y = y1 + height / 2
|
||||
|
||||
aspect_ratio = np.divide(
|
||||
width,
|
||||
height,
|
||||
out=np.zeros_like(width, dtype=float),
|
||||
where=height != 0,
|
||||
)
|
||||
result = np.column_stack((center_x, center_y, aspect_ratio, height))
|
||||
return result.astype(float)
|
||||
|
||||
|
||||
def mask_to_xyxy(masks: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Converts a 3D `np.array` of 2D bool masks into a 2D `np.array` of bounding boxes.
|
||||
@@ -702,25 +807,71 @@ def move_masks(
|
||||
masks (npt.NDArray[np.bool_]): A 3D array of binary masks corresponding to the
|
||||
predictions. Shape: `(N, H, W)`, where N is the number of predictions, and
|
||||
H, W are the dimensions of each mask.
|
||||
offset (npt.NDArray[np.int32]): An array of shape `(2,)` containing non-negative
|
||||
int values `[dx, dy]`.
|
||||
offset (npt.NDArray[np.int32]): An array of shape `(2,)` containing int values
|
||||
`[dx, dy]`. Supports both positive and negative values for bidirectional
|
||||
movement.
|
||||
resolution_wh (Tuple[int, int]): The width and height of the desired mask
|
||||
resolution.
|
||||
|
||||
Returns:
|
||||
(npt.NDArray[np.bool_]) repositioned masks, optionally padded to the specified
|
||||
shape.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
mask = np.array([[[False, False, False, False],
|
||||
[False, True, True, False],
|
||||
[False, True, True, False],
|
||||
[False, False, False, False]]], dtype=bool)
|
||||
|
||||
offset = np.array([1, 1])
|
||||
sv.move_masks(mask, offset, resolution_wh=(4, 4))
|
||||
# array([[[False, False, False, False],
|
||||
# [False, False, False, False],
|
||||
# [False, False, True, True],
|
||||
# [False, False, True, True]]], dtype=bool)
|
||||
|
||||
offset = np.array([-2, 2])
|
||||
sv.move_masks(mask, offset, resolution_wh=(4, 4))
|
||||
# array([[[False, False, False, False],
|
||||
# [False, False, False, False],
|
||||
# [False, False, False, False],
|
||||
# [True, False, False, False]]], dtype=bool)
|
||||
```
|
||||
"""
|
||||
|
||||
if offset[0] < 0 or offset[1] < 0:
|
||||
raise ValueError(f"Offset values must be non-negative integers. Got: {offset}")
|
||||
|
||||
mask_array = np.full((masks.shape[0], resolution_wh[1], resolution_wh[0]), False)
|
||||
mask_array[
|
||||
:,
|
||||
offset[1] : masks.shape[1] + offset[1],
|
||||
offset[0] : masks.shape[2] + offset[0],
|
||||
] = masks
|
||||
|
||||
if offset[0] < 0:
|
||||
source_x_start = -offset[0]
|
||||
source_x_end = min(masks.shape[2], resolution_wh[0] - offset[0])
|
||||
destination_x_start = 0
|
||||
destination_x_end = min(resolution_wh[0], masks.shape[2] + offset[0])
|
||||
else:
|
||||
source_x_start = 0
|
||||
source_x_end = min(masks.shape[2], resolution_wh[0] - offset[0])
|
||||
destination_x_start = offset[0]
|
||||
destination_x_end = offset[0] + source_x_end - source_x_start
|
||||
|
||||
if offset[1] < 0:
|
||||
source_y_start = -offset[1]
|
||||
source_y_end = min(masks.shape[1], resolution_wh[1] - offset[1])
|
||||
destination_y_start = 0
|
||||
destination_y_end = min(resolution_wh[1], masks.shape[1] + offset[1])
|
||||
else:
|
||||
source_y_start = 0
|
||||
source_y_end = min(masks.shape[1], resolution_wh[1] - offset[1])
|
||||
destination_y_start = offset[1]
|
||||
destination_y_end = offset[1] + source_y_end - source_y_start
|
||||
|
||||
if source_x_end > source_x_start and source_y_end > source_y_start:
|
||||
mask_array[
|
||||
:,
|
||||
destination_y_start:destination_y_end,
|
||||
destination_x_start:destination_x_end,
|
||||
] = masks[:, source_y_start:source_y_end, source_x_start:source_x_end]
|
||||
|
||||
return mask_array
|
||||
|
||||
@@ -808,12 +959,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 +1041,61 @@ 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 not in merged_metadata:
|
||||
merged_metadata[key] = value
|
||||
continue
|
||||
|
||||
other_value = merged_metadata[key]
|
||||
if isinstance(value, np.ndarray) and isinstance(other_value, np.ndarray):
|
||||
if not np.array_equal(merged_metadata[key], value):
|
||||
raise ValueError(
|
||||
f"Conflicting metadata for key: '{key}': "
|
||||
"{type(value)}, {type(other_value)}."
|
||||
)
|
||||
elif isinstance(value, np.ndarray) or isinstance(other_value, np.ndarray):
|
||||
# Since [] == np.array([]).
|
||||
raise ValueError(
|
||||
f"Conflicting metadata for key: '{key}': "
|
||||
"{type(value)}, {type(other_value)}."
|
||||
)
|
||||
else:
|
||||
print("hm")
|
||||
if merged_metadata[key] != value:
|
||||
raise ValueError(f"Conflicting metadata for key: '{key}'.")
|
||||
|
||||
return merged_metadata
|
||||
|
||||
|
||||
def get_data_item(
|
||||
data: Dict[str, Union[np.ndarray, List]],
|
||||
index: Union[int, slice, List[int], np.ndarray],
|
||||
@@ -1039,3 +1269,59 @@ def cross_product(anchors: np.ndarray, vector: Vector) -> np.ndarray:
|
||||
)
|
||||
vector_start = np.array([vector.start.x, vector.start.y])
|
||||
return np.cross(vector_at_zero, anchors - vector_start)
|
||||
|
||||
|
||||
def spread_out_boxes(
|
||||
xyxy: np.ndarray,
|
||||
max_iterations: int = 100,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Spread out boxes that overlap with each other.
|
||||
|
||||
Args:
|
||||
xyxy: Numpy array of shape (N, 4) where N is the number of boxes.
|
||||
max_iterations: Maximum number of iterations to run the algorithm for.
|
||||
"""
|
||||
if len(xyxy) == 0:
|
||||
return xyxy
|
||||
|
||||
xyxy_padded = pad_boxes(xyxy, px=1)
|
||||
for _ in range(max_iterations):
|
||||
# NxN
|
||||
iou = box_iou_batch(xyxy_padded, xyxy_padded)
|
||||
np.fill_diagonal(iou, 0)
|
||||
if np.all(iou == 0):
|
||||
break
|
||||
|
||||
overlap_mask = iou > 0
|
||||
|
||||
# Nx2
|
||||
centers = (xyxy_padded[:, :2] + xyxy_padded[:, 2:]) / 2
|
||||
|
||||
# NxNx2
|
||||
delta_centers = centers[:, np.newaxis, :] - centers[np.newaxis, :, :]
|
||||
delta_centers *= overlap_mask[:, :, np.newaxis]
|
||||
|
||||
# Nx2
|
||||
delta_sum = np.sum(delta_centers, axis=1)
|
||||
delta_magnitude = np.linalg.norm(delta_sum, axis=1, keepdims=True)
|
||||
direction_vectors = np.divide(
|
||||
delta_sum,
|
||||
delta_magnitude,
|
||||
out=np.zeros_like(delta_sum),
|
||||
where=delta_magnitude != 0,
|
||||
)
|
||||
|
||||
force_vectors = np.sum(iou, axis=1)
|
||||
force_vectors = force_vectors[:, np.newaxis] * direction_vectors
|
||||
|
||||
force_vectors *= 10
|
||||
force_vectors[(force_vectors > 0) & (force_vectors < 2)] = 2
|
||||
force_vectors[(force_vectors < 0) & (force_vectors > -2)] = -2
|
||||
|
||||
force_vectors = force_vectors.astype(int)
|
||||
|
||||
xyxy_padded[:, [0, 1]] += force_vectors
|
||||
xyxy_padded[:, [2, 3]] += force_vectors
|
||||
|
||||
return pad_boxes(xyxy_padded, px=-1)
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import json
|
||||
import re
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from supervision.detection.utils import polygon_to_mask, polygon_to_xyxy
|
||||
from supervision.utils.internal import deprecated
|
||||
|
||||
|
||||
@deprecated(
|
||||
"`LMM` enum is deprecated and will be removed in "
|
||||
"`supervision-0.31.0`. Use VLM instead."
|
||||
)
|
||||
class LMM(Enum):
|
||||
PALIGEMMA = "paligemma"
|
||||
FLORENCE_2 = "florence_2"
|
||||
QWEN_2_5_VL = "qwen_2_5_vl"
|
||||
|
||||
|
||||
class VLM(Enum):
|
||||
PALIGEMMA = "paligemma"
|
||||
FLORENCE_2 = "florence_2"
|
||||
QWEN_2_5_VL = "qwen_2_5_vl"
|
||||
|
||||
|
||||
RESULT_TYPES: Dict[VLM, type] = {
|
||||
VLM.PALIGEMMA: str,
|
||||
VLM.FLORENCE_2: dict,
|
||||
VLM.QWEN_2_5_VL: str,
|
||||
}
|
||||
|
||||
REQUIRED_ARGUMENTS: Dict[VLM, List[str]] = {
|
||||
VLM.PALIGEMMA: ["resolution_wh"],
|
||||
VLM.FLORENCE_2: ["resolution_wh"],
|
||||
VLM.QWEN_2_5_VL: ["input_wh", "resolution_wh"],
|
||||
}
|
||||
|
||||
ALLOWED_ARGUMENTS: Dict[VLM, List[str]] = {
|
||||
VLM.PALIGEMMA: ["resolution_wh", "classes"],
|
||||
VLM.FLORENCE_2: ["resolution_wh"],
|
||||
VLM.QWEN_2_5_VL: ["input_wh", "resolution_wh", "classes"],
|
||||
}
|
||||
|
||||
SUPPORTED_TASKS_FLORENCE_2 = [
|
||||
"<OD>",
|
||||
"<CAPTION_TO_PHRASE_GROUNDING>",
|
||||
"<DENSE_REGION_CAPTION>",
|
||||
"<REGION_PROPOSAL>",
|
||||
"<OCR_WITH_REGION>",
|
||||
"<REFERRING_EXPRESSION_SEGMENTATION>",
|
||||
"<REGION_TO_SEGMENTATION>",
|
||||
"<OPEN_VOCABULARY_DETECTION>",
|
||||
"<REGION_TO_CATEGORY>",
|
||||
"<REGION_TO_DESCRIPTION>",
|
||||
]
|
||||
|
||||
|
||||
def validate_vlm_parameters(
|
||||
vlm: Union[VLM, str], result: Any, kwargs: Dict[str, Any]
|
||||
) -> VLM:
|
||||
if isinstance(vlm, str):
|
||||
try:
|
||||
vlm = VLM(vlm.lower())
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Invalid vlm value: {vlm}. Must be one of {[e.value for e in VLM]}"
|
||||
)
|
||||
|
||||
if not isinstance(result, RESULT_TYPES[vlm]):
|
||||
raise ValueError(
|
||||
f"Invalid VLM result type: {type(result)}. Must be {RESULT_TYPES[vlm]}"
|
||||
)
|
||||
|
||||
required_args = REQUIRED_ARGUMENTS.get(vlm, [])
|
||||
for arg in required_args:
|
||||
if arg not in kwargs:
|
||||
raise ValueError(f"Missing required argument: {arg}")
|
||||
|
||||
allowed_args = ALLOWED_ARGUMENTS.get(vlm, [])
|
||||
for arg in kwargs:
|
||||
if arg not in allowed_args:
|
||||
raise ValueError(f"Argument {arg} is not allowed for {vlm.name}")
|
||||
|
||||
return vlm
|
||||
|
||||
|
||||
def from_paligemma(
|
||||
result: str, resolution_wh: Tuple[int, int], classes: Optional[List[str]] = None
|
||||
) -> Tuple[np.ndarray, Optional[np.ndarray], np.ndarray]:
|
||||
"""
|
||||
Parse bounding boxes from paligemma-formatted text, scale them to the specified
|
||||
resolution, and optionally filter by classes.
|
||||
|
||||
Args:
|
||||
result: String containing paligemma-formatted locations and labels.
|
||||
resolution_wh: Tuple (width, height) to which we scale the box coordinates.
|
||||
classes: Optional list of valid class names. If provided, boxes and labels not
|
||||
in this list are filtered out.
|
||||
|
||||
Returns:
|
||||
xyxy (np.ndarray): An array of shape `(n, 4)` containing
|
||||
the bounding boxes coordinates in format `[x1, y1, x2, y2]`.
|
||||
class_id (Optional[np.ndarray]): An array of shape `(n,)` containing
|
||||
the class indices for each bounding box (or `None` if classes is not
|
||||
provided).
|
||||
class_name (np.ndarray): An array of shape `(n,)` containing
|
||||
the class labels for each bounding box.
|
||||
"""
|
||||
|
||||
w, h = resolution_wh
|
||||
if w <= 0 or h <= 0:
|
||||
raise ValueError(
|
||||
f"Both dimensions in resolution_wh must be positive. Got ({w}, {h})."
|
||||
)
|
||||
|
||||
pattern = re.compile(
|
||||
r"(?<!<loc\d{4}>)<loc(\d{4})><loc(\d{4})><loc(\d{4})><loc(\d{4})> ([\w\s\-]+)"
|
||||
)
|
||||
matches = pattern.findall(result)
|
||||
matches = np.array(matches) if matches else np.empty((0, 5))
|
||||
|
||||
if matches.shape[0] == 0:
|
||||
return np.empty((0, 4)), None, np.empty(0, dtype=str)
|
||||
|
||||
xyxy, class_name = matches[:, [1, 0, 3, 2]], matches[:, 4]
|
||||
xyxy = xyxy.astype(int) / 1024 * np.array([w, h, w, h])
|
||||
class_name = np.char.strip(class_name.astype(str))
|
||||
class_id = None
|
||||
|
||||
if classes is not None:
|
||||
mask = np.array([name in classes for name in class_name], dtype=bool)
|
||||
xyxy = xyxy[mask]
|
||||
class_name = class_name[mask]
|
||||
class_id = np.array([classes.index(name) for name in class_name])
|
||||
|
||||
return xyxy, class_id, class_name
|
||||
|
||||
|
||||
def from_qwen_2_5_vl(
|
||||
result: str,
|
||||
input_wh: Tuple[int, int],
|
||||
resolution_wh: Tuple[int, int],
|
||||
classes: Optional[List[str]] = None,
|
||||
) -> Tuple[np.ndarray, Optional[np.ndarray], np.ndarray]:
|
||||
"""
|
||||
Parse and scale bounding boxes from Qwen-2.5-VL style JSON output.
|
||||
|
||||
The JSON is expected to be enclosed in triple backticks with the format:
|
||||
```json
|
||||
[
|
||||
{"bbox_2d": [x1, y1, x2, y2], "label": "some class name"},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
Args:
|
||||
result: String containing the JSON snippet enclosed by triple backticks.
|
||||
input_wh: (input_width, input_height) describing the original bounding box
|
||||
scale.
|
||||
resolution_wh: (output_width, output_height) to which we rescale the boxes.
|
||||
classes: Optional list of valid class names. If provided, returned boxes/labels
|
||||
are filtered to only those classes found here.
|
||||
|
||||
Returns:
|
||||
xyxy (np.ndarray): An array of shape `(n, 4)` containing
|
||||
the bounding boxes coordinates in format `[x1, y1, x2, y2]`
|
||||
class_id (Optional[np.ndarray]): An array of shape `(n,)` containing
|
||||
the class indices for each bounding box (or None if `classes` is not
|
||||
provided)
|
||||
class_name (np.ndarray): An array of shape `(n,)` containing
|
||||
the class labels for each bounding box
|
||||
"""
|
||||
in_w, in_h = input_wh
|
||||
out_w, out_h = resolution_wh
|
||||
|
||||
if in_w <= 0 or in_h <= 0 or out_w <= 0 or out_h <= 0:
|
||||
raise ValueError(
|
||||
f"Both input and resolution dimensions must be positive. "
|
||||
f"Got input_wh=({in_w}, {in_h}), resolution_wh=({out_w}, {out_h})."
|
||||
)
|
||||
|
||||
pattern = re.compile(r"```json\s*(.*?)\s*```", re.DOTALL)
|
||||
|
||||
match = pattern.search(result)
|
||||
if not match:
|
||||
return np.empty((0, 4)), None, np.empty((0,), dtype=str)
|
||||
|
||||
json_snippet = match.group(1)
|
||||
|
||||
try:
|
||||
data = json.loads(json_snippet)
|
||||
except json.JSONDecodeError:
|
||||
return np.empty((0, 4)), None, np.empty((0,), dtype=str)
|
||||
|
||||
boxes_list = []
|
||||
labels_list = []
|
||||
|
||||
for item in data:
|
||||
if "bbox_2d" not in item or "label" not in item:
|
||||
continue
|
||||
boxes_list.append(item["bbox_2d"])
|
||||
labels_list.append(item["label"])
|
||||
|
||||
if not boxes_list:
|
||||
return np.empty((0, 4)), None, np.empty((0,), dtype=str)
|
||||
|
||||
xyxy = np.array(boxes_list, dtype=float)
|
||||
class_name = np.array(labels_list, dtype=str)
|
||||
|
||||
xyxy = xyxy / [in_w, in_h, in_w, in_h]
|
||||
xyxy = xyxy * [out_w, out_h, out_w, out_h]
|
||||
|
||||
class_id = None
|
||||
|
||||
if classes is not None:
|
||||
mask = np.array([label in classes for label in class_name], dtype=bool)
|
||||
xyxy = xyxy[mask]
|
||||
class_name = class_name[mask]
|
||||
class_id = np.array([classes.index(label) for label in class_name], dtype=int)
|
||||
|
||||
return xyxy, class_id, class_name
|
||||
|
||||
|
||||
def from_florence_2(
|
||||
result: dict, resolution_wh: Tuple[int, int]
|
||||
) -> Tuple[
|
||||
np.ndarray, Optional[np.ndarray], Optional[np.ndarray], Optional[np.ndarray]
|
||||
]:
|
||||
"""
|
||||
Parse results from the Florence 2 multi-model model.
|
||||
https://huggingface.co/microsoft/Florence-2-large
|
||||
|
||||
Parameters:
|
||||
result: dict containing the model output
|
||||
|
||||
Returns:
|
||||
xyxy (np.ndarray): An array of shape `(n, 4)` containing
|
||||
the bounding boxes coordinates in format `[x1, y1, x2, y2]`
|
||||
labels: (Optional[np.ndarray]): An array of shape `(n,)` containing
|
||||
the class labels for each bounding box
|
||||
masks: (Optional[np.ndarray]): An array of shape `(n, h, w)` containing
|
||||
the segmentation masks for each bounding box
|
||||
obb_boxes: (Optional[np.ndarray]): An array of shape `(n, 4, 2)` containing
|
||||
oriented bounding boxes.
|
||||
"""
|
||||
assert len(result) == 1, f"Expected result with a single element. Got: {result}"
|
||||
task = next(iter(result.keys()))
|
||||
if task not in SUPPORTED_TASKS_FLORENCE_2:
|
||||
raise ValueError(
|
||||
f"{task} not supported. Supported tasks are: {SUPPORTED_TASKS_FLORENCE_2}"
|
||||
)
|
||||
result = result[task]
|
||||
|
||||
if task in ["<OD>", "<CAPTION_TO_PHRASE_GROUNDING>", "<DENSE_REGION_CAPTION>"]:
|
||||
xyxy = np.array(result["bboxes"], dtype=np.float32)
|
||||
labels = np.array(result["labels"])
|
||||
return xyxy, labels, None, None
|
||||
|
||||
if task == "<REGION_PROPOSAL>":
|
||||
xyxy = np.array(result["bboxes"], dtype=np.float32)
|
||||
# provides labels, but they are ["", "", "", ...]
|
||||
return xyxy, None, None, None
|
||||
|
||||
if task == "<OCR_WITH_REGION>":
|
||||
xyxyxyxy = np.array(result["quad_boxes"], dtype=np.float32)
|
||||
xyxyxyxy = xyxyxyxy.reshape(-1, 4, 2)
|
||||
xyxy = np.array([polygon_to_xyxy(polygon) for polygon in xyxyxyxy])
|
||||
labels = np.array(result["labels"])
|
||||
return xyxy, labels, None, xyxyxyxy
|
||||
|
||||
if task in ["<REFERRING_EXPRESSION_SEGMENTATION>", "<REGION_TO_SEGMENTATION>"]:
|
||||
xyxy_list = []
|
||||
masks_list = []
|
||||
for polygons_of_same_class in result["polygons"]:
|
||||
for polygon in polygons_of_same_class:
|
||||
polygon = np.reshape(polygon, (-1, 2)).astype(np.int32)
|
||||
mask = polygon_to_mask(polygon, resolution_wh).astype(bool)
|
||||
masks_list.append(mask)
|
||||
xyxy = polygon_to_xyxy(polygon)
|
||||
xyxy_list.append(xyxy)
|
||||
# per-class labels also provided, but they are ["", "", "", ...]
|
||||
# when we figure out how to set class names, we can do
|
||||
# zip(result["labels"], result["polygons"])
|
||||
xyxy = np.array(xyxy_list, dtype=np.float32)
|
||||
masks = np.array(masks_list)
|
||||
return xyxy, None, masks, None
|
||||
|
||||
if task == "<OPEN_VOCABULARY_DETECTION>":
|
||||
xyxy = np.array(result["bboxes"], dtype=np.float32)
|
||||
labels = np.array(result["bboxes_labels"])
|
||||
# Also has "polygons" and "polygons_labels", but they don't seem to be used
|
||||
return xyxy, labels, None, None
|
||||
|
||||
if task in ["<REGION_TO_CATEGORY>", "<REGION_TO_DESCRIPTION>"]:
|
||||
assert isinstance(result, str), (
|
||||
f"Expected string as <REGION_TO_CATEGORY> result, got {type(result)}"
|
||||
)
|
||||
|
||||
if result == "No object detected.":
|
||||
return np.empty((0, 4), dtype=np.float32), np.array([]), None, None
|
||||
|
||||
pattern = re.compile(r"<loc_(\d+)><loc_(\d+)><loc_(\d+)><loc_(\d+)>")
|
||||
match = pattern.search(result)
|
||||
assert match is not None, (
|
||||
f"Expected string to end in location tags, but got {result}"
|
||||
)
|
||||
|
||||
w, h = resolution_wh
|
||||
xyxy = np.array([match.groups()], dtype=np.float32)
|
||||
xyxy *= np.array([w, h, w, h]) / 1000
|
||||
result_string = result[: match.start()]
|
||||
labels = np.array([result_string])
|
||||
return xyxy, labels, None, None
|
||||
|
||||
assert False, f"Unimplemented task: {task}"
|
||||
@@ -83,15 +83,16 @@ class Color:
|
||||
# Color(r=255, g=255, b=255)
|
||||
```
|
||||
|
||||
| Constant | Hex Code | RGB |
|
||||
|------------|------------|------------------|
|
||||
| `WHITE` | `#FFFFFF` | `(255, 255, 255)`|
|
||||
| `BLACK` | `#000000` | `(0, 0, 0)` |
|
||||
| `RED` | `#FF0000` | `(255, 0, 0)` |
|
||||
| `GREEN` | `#00FF00` | `(0, 255, 0)` |
|
||||
| `BLUE` | `#0000FF` | `(0, 0, 255)` |
|
||||
| `YELLOW` | `#FFFF00` | `(255, 255, 0)` |
|
||||
| `ROBOFLOW` | `#A351FB` | `(163, 81, 251)` |
|
||||
| Constant | Hex Code | RGB |
|
||||
|------------|------------|-------------------|
|
||||
| `WHITE` | `#FFFFFF` | `(255, 255, 255)` |
|
||||
| `BLACK` | `#000000` | `(0, 0, 0)` |
|
||||
| `GREY` | `#808080` | `(128, 128, 128)` |
|
||||
| `RED` | `#FF0000` | `(255, 0, 0)` |
|
||||
| `GREEN` | `#00FF00` | `(0, 255, 0)` |
|
||||
| `BLUE` | `#0000FF` | `(0, 0, 255)` |
|
||||
| `YELLOW` | `#FFFF00` | `(255, 255, 0)` |
|
||||
| `ROBOFLOW` | `#A351FB` | `(163, 81, 251)` |
|
||||
"""
|
||||
|
||||
r: int
|
||||
@@ -235,6 +236,10 @@ class Color:
|
||||
def BLACK(cls) -> Color:
|
||||
return Color.from_hex("#000000")
|
||||
|
||||
@classproperty
|
||||
def GREY(cls) -> Color:
|
||||
return Color.from_hex("#808080")
|
||||
|
||||
@classproperty
|
||||
def RED(cls) -> Color:
|
||||
return Color.from_hex("#FF0000")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -5,10 +5,11 @@ from typing import List, Optional, Tuple, Union
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from supervision import Rect, pad_boxes
|
||||
from supervision.annotators.base import ImageType
|
||||
from supervision.detection.utils import pad_boxes, spread_out_boxes
|
||||
from supervision.draw.color import Color
|
||||
from supervision.draw.utils import draw_rounded_rectangle
|
||||
from supervision.geometry.core import Rect
|
||||
from supervision.keypoint.core import KeyPoints
|
||||
from supervision.keypoint.skeletons import SKELETONS_BY_VERTEX_COUNT
|
||||
from supervision.utils.conversion import ensure_cv2_image_for_annotation
|
||||
@@ -201,6 +202,7 @@ class VertexLabelAnnotator:
|
||||
text_thickness: int = 1,
|
||||
text_padding: int = 10,
|
||||
border_radius: int = 0,
|
||||
smart_position: bool = False,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
@@ -215,6 +217,7 @@ class VertexLabelAnnotator:
|
||||
text_padding (int): The padding around the text.
|
||||
border_radius (int): The radius of the rounded corners of the
|
||||
boxes. Set to a high value to produce circles.
|
||||
smart_position (bool): Spread out the labels to avoid overlap.
|
||||
"""
|
||||
self.border_radius: int = border_radius
|
||||
self.color: Union[Color, List[Color]] = color
|
||||
@@ -222,6 +225,7 @@ class VertexLabelAnnotator:
|
||||
self.text_scale: float = text_scale
|
||||
self.text_thickness: int = text_thickness
|
||||
self.text_padding: int = text_padding
|
||||
self.smart_position = smart_position
|
||||
|
||||
def annotate(
|
||||
self,
|
||||
@@ -356,9 +360,12 @@ class VertexLabelAnnotator:
|
||||
for anchor, label in zip(anchors, labels)
|
||||
]
|
||||
)
|
||||
|
||||
xyxy_padded = pad_boxes(xyxy=xyxy, px=self.text_padding)
|
||||
|
||||
if self.smart_position:
|
||||
xyxy_padded = spread_out_boxes(xyxy_padded)
|
||||
xyxy = pad_boxes(xyxy=xyxy_padded, px=-self.text_padding)
|
||||
|
||||
for text, color, text_color, box, box_padded in zip(
|
||||
labels, colors, text_colors, xyxy, xyxy_padded
|
||||
):
|
||||
|
||||
+170
-10
@@ -1,13 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from supervision.config import CLASS_NAME_DATA_FIELD
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.detection.utils import get_data_item, is_data_equal
|
||||
from supervision.validators import validate_keypoints_fields
|
||||
|
||||
@@ -23,8 +23,8 @@ class KeyPoints:
|
||||
=== "Ultralytics"
|
||||
|
||||
Use [`sv.KeyPoints.from_ultralytics`](/latest/keypoint/core/#supervision.keypoint.core.KeyPoints.from_ultralytics)
|
||||
method, which accepts [YOLOv8](https://github.com/ultralytics/ultralytics)
|
||||
pose result.
|
||||
method, which accepts [YOLOv8-pose](https://docs.ultralytics.com/models/yolov8/), [YOLO11-pose](https://docs.ultralytics.com/models/yolo11/)
|
||||
[pose](https://docs.ultralytics.com/tasks/pose/) result.
|
||||
|
||||
```python
|
||||
import cv2
|
||||
@@ -32,7 +32,7 @@ class KeyPoints:
|
||||
from ultralytics import YOLO
|
||||
|
||||
image = cv2.imread(<SOURCE_IMAGE_PATH>)
|
||||
model = YOLO('yolov8s-pose.pt')
|
||||
model = YOLO('yolo11s-pose.pt')
|
||||
|
||||
result = model(image)[0]
|
||||
key_points = sv.KeyPoints.from_ultralytics(result)
|
||||
@@ -61,6 +61,7 @@ class KeyPoints:
|
||||
method, which accepts [MediaPipe](https://github.com/google-ai-edge/mediapipe)
|
||||
pose result.
|
||||
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import mediapipe as mp
|
||||
@@ -91,10 +92,10 @@ class KeyPoints:
|
||||
xy (np.ndarray): An array of shape `(n, m, 2)` containing
|
||||
`n` detected objects, each composed of `m` equally-sized
|
||||
sets of keypoints, where each point is `[x, y]`.
|
||||
confidence (Optional[np.ndarray]): An array of shape
|
||||
`(n, m)` containing the confidence scores of each keypoint.
|
||||
class_id (Optional[np.ndarray]): An array of shape
|
||||
`(n,)` containing the class ids of the detected objects.
|
||||
confidence (Optional[np.ndarray]): An array of shape
|
||||
`(n, m)` containing the confidence scores of each keypoint.
|
||||
data (Dict[str, Union[np.ndarray, List]]): A dictionary containing additional
|
||||
data where each key is a string representing the data type, and the value
|
||||
is either a NumPy array or a list of corresponding data of length `n`
|
||||
@@ -203,9 +204,10 @@ class KeyPoints:
|
||||
"You can retrieve it like so: inference_result = model.infer(image)[0]"
|
||||
)
|
||||
|
||||
with suppress(AttributeError):
|
||||
if hasattr(inference_result, "dict"):
|
||||
inference_result = inference_result.dict(exclude_none=True, by_alias=True)
|
||||
|
||||
elif hasattr(inference_result, "json"):
|
||||
inference_result = inference_result.json()
|
||||
if not inference_result.get("predictions"):
|
||||
return cls.empty()
|
||||
|
||||
@@ -313,6 +315,7 @@ class KeyPoints:
|
||||
key_points = sv.KeyPoints.from_mediapipe(
|
||||
face_landmarker_result, (image_width, image_height))
|
||||
```
|
||||
|
||||
""" # noqa: E501 // docs
|
||||
if hasattr(mediapipe_results, "pose_landmarks"):
|
||||
results = mediapipe_results.pose_landmarks
|
||||
@@ -472,7 +475,7 @@ class KeyPoints:
|
||||
A `sv.KeyPoints` object containing the keypoint coordinates, class IDs,
|
||||
and class names, and confidences of each keypoint.
|
||||
|
||||
Example:
|
||||
Examples:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
@@ -509,6 +512,91 @@ class KeyPoints:
|
||||
else:
|
||||
return cls.empty()
|
||||
|
||||
@classmethod
|
||||
def from_transformers(cls, transfomers_results: Any) -> KeyPoints:
|
||||
"""
|
||||
Create a `sv.KeyPoints` object from the
|
||||
[Transformers](https://github.com/huggingface/transformers) inference result.
|
||||
|
||||
Args:
|
||||
transfomers_results (Any): The output of a
|
||||
Transformers model containing instances with prediction data.
|
||||
|
||||
Returns:
|
||||
A `sv.KeyPoints` object containing the keypoint coordinates, class IDs,
|
||||
and class names, and confidences of each keypoint.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from PIL import Image
|
||||
import requests
|
||||
import supervision as sv
|
||||
import torch
|
||||
from transformers import (
|
||||
AutoProcessor,
|
||||
RTDetrForObjectDetection,
|
||||
VitPoseForPoseEstimation,
|
||||
)
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
image = Image.open(<SOURCE_IMAGE_PATH>)
|
||||
|
||||
DETECTION_MODEL_ID = "PekingU/rtdetr_r50vd_coco_o365"
|
||||
|
||||
detection_processor = AutoProcessor.from_pretrained(DETECTION_MODEL_ID, use_fast=True)
|
||||
detection_model = RTDetrForObjectDetection.from_pretrained(DETECTION_MODEL_ID, device_map=DEVICE)
|
||||
|
||||
inputs = detection_processor(images=frame, return_tensors="pt").to(DEVICE)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = detection_model(**inputs)
|
||||
|
||||
target_size = torch.tensor([(frame.height, frame.width)])
|
||||
results = detection_processor.post_process_object_detection(
|
||||
outputs, target_sizes=target_size, threshold=0.3)
|
||||
|
||||
detections = sv.Detections.from_transformers(results[0])
|
||||
boxes = sv.xyxy_to_xywh(detections[detections.class_id == 0].xyxy)
|
||||
|
||||
POSE_ESTIMATION_MODEL_ID = "usyd-community/vitpose-base-simple"
|
||||
|
||||
pose_estimation_processor = AutoProcessor.from_pretrained(POSE_ESTIMATION_MODEL_ID)
|
||||
pose_estimation_model = VitPoseForPoseEstimation.from_pretrained(
|
||||
POSE_ESTIMATION_MODEL_ID, device_map=DEVICE)
|
||||
|
||||
inputs = pose_estimation_processor(frame, boxes=[boxes], return_tensors="pt").to(DEVICE)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = pose_estimation_model(**inputs)
|
||||
|
||||
results = pose_estimation_processor.post_process_pose_estimation(outputs, boxes=[boxes])
|
||||
key_point = sv.KeyPoints.from_transformers(results[0])
|
||||
```
|
||||
|
||||
""" # noqa: E501 // docs
|
||||
|
||||
if "keypoints" in transfomers_results[0]:
|
||||
if transfomers_results[0]["keypoints"].cpu().numpy().size == 0:
|
||||
return cls.empty()
|
||||
|
||||
result_data = [
|
||||
(
|
||||
result["keypoints"].cpu().numpy(),
|
||||
result["scores"].cpu().numpy(),
|
||||
)
|
||||
for result in transfomers_results
|
||||
]
|
||||
|
||||
xy, scores = zip(*result_data)
|
||||
|
||||
return cls(
|
||||
xy=np.stack(xy).astype(np.float32),
|
||||
confidence=np.stack(scores).astype(np.float32),
|
||||
class_id=np.arange(len(xy)).astype(int),
|
||||
)
|
||||
else:
|
||||
return cls.empty()
|
||||
|
||||
def __getitem__(
|
||||
self, index: Union[int, slice, List[int], np.ndarray, str]
|
||||
) -> Union[KeyPoints, List, np.ndarray, None]:
|
||||
@@ -612,3 +700,75 @@ 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
|
||||
|
||||
def as_detections(
|
||||
self, 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:
|
||||
selected_keypoint_indices (Optional[Iterable[int]]): The
|
||||
indices of the keypoints to include in the bounding box
|
||||
calculation. This helps focus on a subset of keypoints,
|
||||
e.g. when some are occluded. Captures all keypoints by default.
|
||||
|
||||
Returns:
|
||||
detections (Detections): The converted detections object.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
keypoints = sv.KeyPoints.from_inference(...)
|
||||
detections = keypoints.as_detections()
|
||||
```
|
||||
"""
|
||||
if self.is_empty():
|
||||
return Detections.empty()
|
||||
|
||||
detections_list = []
|
||||
for i, xy in enumerate(self.xy):
|
||||
if selected_keypoint_indices:
|
||||
xy = xy[selected_keypoint_indices]
|
||||
|
||||
# [0, 0] used by some frameworks to indicate missing keypoints
|
||||
xy = xy[~np.all(xy == 0, axis=1)]
|
||||
if len(xy) == 0:
|
||||
xyxy = np.array([[0, 0, 0, 0]], dtype=np.float32)
|
||||
else:
|
||||
x_min = xy[:, 0].min()
|
||||
x_max = xy[:, 0].max()
|
||||
y_min = xy[:, 1].min()
|
||||
y_max = xy[:, 1].max()
|
||||
xyxy = np.array([[x_min, y_min, x_max, y_max]], dtype=np.float32)
|
||||
|
||||
if self.confidence is None:
|
||||
confidence = None
|
||||
else:
|
||||
confidence = self.confidence[i]
|
||||
if selected_keypoint_indices:
|
||||
confidence = confidence[selected_keypoint_indices]
|
||||
confidence = np.array([confidence.mean()], dtype=np.float32)
|
||||
|
||||
detections_list.append(
|
||||
Detections(
|
||||
xyxy=xyxy,
|
||||
confidence=confidence,
|
||||
)
|
||||
)
|
||||
|
||||
detections = Detections.merge(detections_list)
|
||||
detections.class_id = self.class_id
|
||||
detections.data = self.data
|
||||
detections = detections[detections.area > 0]
|
||||
|
||||
return detections
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from supervision.metrics.core import (
|
||||
CLASS_ID_NONE,
|
||||
AveragingMethod,
|
||||
Metric,
|
||||
MetricTarget,
|
||||
@@ -9,8 +8,33 @@ from supervision.metrics.mean_average_precision import (
|
||||
MeanAveragePrecision,
|
||||
MeanAveragePrecisionResult,
|
||||
)
|
||||
from supervision.metrics.mean_average_recall import (
|
||||
MeanAverageRecall,
|
||||
MeanAverageRecallResult,
|
||||
)
|
||||
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,
|
||||
get_object_size_category,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AveragingMethod",
|
||||
"F1Score",
|
||||
"F1ScoreResult",
|
||||
"MeanAveragePrecision",
|
||||
"MeanAveragePrecisionResult",
|
||||
"MeanAverageRecall",
|
||||
"MeanAverageRecallResult",
|
||||
"Metric",
|
||||
"MetricTarget",
|
||||
"ObjectSizeCategory",
|
||||
"Precision",
|
||||
"PrecisionResult",
|
||||
"Recall",
|
||||
"RecallResult",
|
||||
"get_detection_size_category",
|
||||
"get_object_size_category",
|
||||
]
|
||||
|
||||
+14
-15
@@ -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"
|
||||
|
||||
+118
-15
@@ -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,75 @@ 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.f1_50)
|
||||
# 0.7618
|
||||
|
||||
print(f1_result)
|
||||
# F1ScoreResult:
|
||||
# Metric target: MetricTarget.BOXES
|
||||
# Averaging method: AveragingMethod.WEIGHTED
|
||||
# F1 @ 50: 0.7618
|
||||
# F1 @ 75: 0.7487
|
||||
# F1 @ thresh: [0.76175 0.76068 0.76068]
|
||||
# IoU thresh: [0.5 0.55 0.6 ...]
|
||||
# F1 per class:
|
||||
# 0: [0.70968 0.70968 0.70968 ...]
|
||||
# ...
|
||||
# Small objects: ...
|
||||
# Medium objects: ...
|
||||
# Large objects: ...
|
||||
|
||||
f1_result.plot()
|
||||
```
|
||||
|
||||
{ align=center width="800" }
|
||||
"""
|
||||
|
||||
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 +104,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 +131,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 +185,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 +389,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 +460,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 -
|
||||
@@ -390,11 +476,11 @@ class F1ScoreResult:
|
||||
matched_classes (np.ndarray): the class IDs of all matched classes.
|
||||
Corresponds to the rows of `f1_per_class`.
|
||||
small_objects (Optional[F1ScoreResult]): the F1 metric results
|
||||
for small objects.
|
||||
for small objects (area < 32²).
|
||||
medium_objects (Optional[F1ScoreResult]): the F1 metric results
|
||||
for medium objects.
|
||||
for medium objects (32² ≤ area < 96²).
|
||||
large_objects (Optional[F1ScoreResult]): the F1 metric results
|
||||
for large objects.
|
||||
for large objects (area ≥ 96²).
|
||||
"""
|
||||
|
||||
metric_target: MetricTarget
|
||||
@@ -424,6 +510,19 @@ class F1ScoreResult:
|
||||
Example:
|
||||
```python
|
||||
print(f1_result)
|
||||
# F1ScoreResult:
|
||||
# Metric target: MetricTarget.BOXES
|
||||
# Averaging method: AveragingMethod.WEIGHTED
|
||||
# F1 @ 50: 0.7618
|
||||
# F1 @ 75: 0.7487
|
||||
# F1 @ thresh: [0.76175 0.76068 0.76068]
|
||||
# IoU thresh: [0.5 0.55 0.6 ...]
|
||||
# F1 per class:
|
||||
# 0: [0.70968 0.70968 0.70968 ...]
|
||||
# ...
|
||||
# Small objects: ...
|
||||
# Medium objects: ...
|
||||
# Large objects: ...
|
||||
```
|
||||
"""
|
||||
out_str = (
|
||||
@@ -487,6 +586,10 @@ class F1ScoreResult:
|
||||
def plot(self):
|
||||
"""
|
||||
Plot the F1 results.
|
||||
|
||||
{ align=center width="800" }
|
||||
"""
|
||||
|
||||
labels = ["F1@50", "F1@75"]
|
||||
|
||||
@@ -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,48 @@ 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.map50_95)
|
||||
# 0.4674
|
||||
|
||||
print(map_result)
|
||||
# MeanAveragePrecisionResult:
|
||||
# Metric target: MetricTarget.BOXES
|
||||
# Class agnostic: False
|
||||
# mAP @ 50:95: 0.4674
|
||||
# mAP @ 50: 0.5048
|
||||
# mAP @ 75: 0.4796
|
||||
# mAP scores: [0.50485 0.50377 0.50377 ...]
|
||||
# IoU thresh: [0.5 0.55 0.6 ...]
|
||||
# AP per class:
|
||||
# 0: [0.67699 0.67699 0.67699 ...]
|
||||
# ...
|
||||
# Small objects: ...
|
||||
# Medium objects: ...
|
||||
# Large objects: ...
|
||||
|
||||
map_result.plot()
|
||||
```
|
||||
|
||||
{ align=center width="800" }
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
metric_target: MetricTarget = MetricTarget.BOXES,
|
||||
@@ -36,17 +82,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 +120,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 +139,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 +209,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 +245,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 +273,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 +375,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 +387,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 +427,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`.
|
||||
@@ -394,14 +440,15 @@ class MeanAveragePrecisionResult:
|
||||
matched_classes (np.ndarray): the class IDs of all matched classes.
|
||||
Corresponds to the rows of `ap_per_class`.
|
||||
small_objects (Optional[MeanAveragePrecisionResult]): the mAP results
|
||||
for small objects.
|
||||
for small objects (area < 32²).
|
||||
medium_objects (Optional[MeanAveragePrecisionResult]): the mAP results
|
||||
for medium objects.
|
||||
for medium objects (32² ≤ area < 96²).
|
||||
large_objects (Optional[MeanAveragePrecisionResult]): the mAP results
|
||||
for large objects.
|
||||
for large objects (area ≥ 96²).
|
||||
"""
|
||||
|
||||
metric_target: MetricTarget
|
||||
is_class_agnostic: bool
|
||||
|
||||
@property
|
||||
def map50_95(self) -> float:
|
||||
@@ -430,12 +477,27 @@ class MeanAveragePrecisionResult:
|
||||
Example:
|
||||
```python
|
||||
print(map_result)
|
||||
# MeanAveragePrecisionResult:
|
||||
# Metric target: MetricTarget.BOXES
|
||||
# Class agnostic: False
|
||||
# mAP @ 50:95: 0.4674
|
||||
# mAP @ 50: 0.5048
|
||||
# mAP @ 75: 0.4796
|
||||
# mAP scores: [0.50485 0.50377 0.50377 ...]
|
||||
# IoU thresh: [0.5 0.55 0.6 ...]
|
||||
# AP per class:
|
||||
# 0: [0.67699 0.67699 0.67699 ...]
|
||||
# ...
|
||||
# Small objects: ...
|
||||
# Medium objects: ...
|
||||
# Large objects: ...
|
||||
```
|
||||
"""
|
||||
|
||||
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"
|
||||
@@ -500,6 +562,10 @@ class MeanAveragePrecisionResult:
|
||||
def plot(self):
|
||||
"""
|
||||
Plot the mAP results.
|
||||
|
||||
{ align=center width="800" }
|
||||
"""
|
||||
|
||||
labels = ["mAP@50:95", "mAP@50", "mAP@75"]
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
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 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 MeanAverageRecall(Metric):
|
||||
"""
|
||||
Mean Average Recall (mAR) measures how well the model detects
|
||||
and retrieves relevant objects by averaging recall over multiple
|
||||
IoU thresholds, classes and detection limits.
|
||||
|
||||
Intuitively, while Recall measures the ability to find all relevant
|
||||
objects, mAR narrows down how many detections are considered for each
|
||||
class. For example, mAR @ 100 considers the top 100 highest confidence
|
||||
detections for each class. mAR @ 1 considers only the highest
|
||||
confidence detection for each class.
|
||||
|
||||
Example:
|
||||
```python
|
||||
import supervision as sv
|
||||
from supervision.metrics import MeanAverageRecall
|
||||
|
||||
predictions = sv.Detections(...)
|
||||
targets = sv.Detections(...)
|
||||
|
||||
map_metric = MeanAverageRecall()
|
||||
map_result = map_metric.update(predictions, targets).compute()
|
||||
|
||||
print(mar_results.mar_at_100)
|
||||
# 0.5241
|
||||
|
||||
print(mar_results)
|
||||
# MeanAverageRecallResult:
|
||||
# Metric target: MetricTarget.BOXES
|
||||
# mAR @ 1: 0.1362
|
||||
# mAR @ 10: 0.4239
|
||||
# mAR @ 100: 0.5241
|
||||
# max detections: [1 10 100]
|
||||
# IoU thresh: [0.5 0.55 0.6 ...]
|
||||
# mAR per class:
|
||||
# 0: [0.78571 0.78571 0.78571 ...]
|
||||
# ...
|
||||
# Small objects: ...
|
||||
# Medium objects: ...
|
||||
# Large objects: ...
|
||||
|
||||
mar_results.plot()
|
||||
```
|
||||
|
||||
{ align=center width="800" }
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
metric_target: MetricTarget = MetricTarget.BOXES,
|
||||
):
|
||||
"""
|
||||
Initialize the Mean Average Recall metric.
|
||||
|
||||
Args:
|
||||
metric_target (MetricTarget): The type of detection data to use.
|
||||
"""
|
||||
self._metric_target = metric_target
|
||||
|
||||
self._predictions_list: List[Detections] = []
|
||||
self._targets_list: List[Detections] = []
|
||||
|
||||
self.max_detections = np.array([1, 10, 100])
|
||||
|
||||
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]],
|
||||
) -> MeanAverageRecall:
|
||||
"""
|
||||
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) -> MeanAverageRecallResult:
|
||||
"""
|
||||
Calculate the Mean Average Recall metric based on the stored predictions
|
||||
and ground-truth, at different IoU thresholds and maximum detection counts.
|
||||
|
||||
Returns:
|
||||
(MeanAverageRecallResult): The Mean Average Recall 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]
|
||||
) -> MeanAverageRecallResult:
|
||||
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 MeanAverageRecallResult(
|
||||
metric_target=self._metric_target,
|
||||
recall_scores=np.zeros(iou_thresholds.shape[0]),
|
||||
recall_per_class=np.zeros((0, iou_thresholds.shape[0])),
|
||||
max_detections=self.max_detections,
|
||||
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_per_k, recall_per_class, unique_classes = (
|
||||
self._compute_average_recall_for_classes(*concatenated_stats)
|
||||
)
|
||||
|
||||
return MeanAverageRecallResult(
|
||||
metric_target=self._metric_target,
|
||||
recall_scores=recall_scores_per_k,
|
||||
recall_per_class=recall_per_class,
|
||||
max_detections=self.max_detections,
|
||||
iou_thresholds=iou_thresholds,
|
||||
matched_classes=unique_classes,
|
||||
small_objects=None,
|
||||
medium_objects=None,
|
||||
large_objects=None,
|
||||
)
|
||||
|
||||
def _compute_average_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)
|
||||
|
||||
recalls_at_k = []
|
||||
for max_detections in self.max_detections:
|
||||
# Shape: PxTh,P,C,C -> CxThx3
|
||||
confusion_matrix = self._compute_confusion_matrix(
|
||||
matches,
|
||||
prediction_class_ids,
|
||||
unique_classes,
|
||||
class_counts,
|
||||
max_detections=max_detections,
|
||||
)
|
||||
|
||||
# Shape: CxThx3 -> CxTh
|
||||
recall_per_class = self._compute_recall(confusion_matrix)
|
||||
recalls_at_k.append(recall_per_class)
|
||||
|
||||
# Shape: KxCxTh -> KxC
|
||||
recalls_at_k = np.array(recalls_at_k)
|
||||
average_recall_per_class = np.mean(recalls_at_k, axis=2)
|
||||
|
||||
# Shape: KxC -> K
|
||||
recall_scores = np.mean(average_recall_per_class, axis=1)
|
||||
|
||||
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,
|
||||
max_detections: Optional[int] = None,
|
||||
) -> 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.
|
||||
|
||||
Args:
|
||||
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.
|
||||
max_detections: Optional[int], the maximum number of detections to
|
||||
consider for each class. Extra detections are considered false
|
||||
positives. By default, all detections are considered.
|
||||
|
||||
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:
|
||||
limited_matches = sorted_matches[is_class][slice(max_detections)]
|
||||
true_positives = limited_matches.sum(0)
|
||||
|
||||
false_positives = (1 - limited_matches).sum(0)
|
||||
false_negatives = num_true - true_positives
|
||||
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]]:
|
||||
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 MeanAverageRecallResult:
|
||||
# """
|
||||
# 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.
|
||||
# """
|
||||
"""
|
||||
The results of the Mean Average 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.
|
||||
mAR_at_1 (float): the Mean Average Recall, when considering only the top
|
||||
highest confidence detection for each class.
|
||||
mAR_at_10 (float): the Mean Average Recall, when considering top 10
|
||||
highest confidence detections for each class.
|
||||
mAR_at_100 (float): the Mean Average Recall, when considering top 100
|
||||
highest confidence detections for each class.
|
||||
recall_per_class (np.ndarray): the recall scores per class and IoU threshold.
|
||||
Shape: `(num_target_classes, num_iou_thresholds)`
|
||||
max_detections (np.ndarray): the array with maximum number of detections
|
||||
considered.
|
||||
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[MeanAverageRecallResult]): the Mean Average Recall
|
||||
metric results for small objects (area < 32²).
|
||||
medium_objects (Optional[MeanAverageRecallResult]): the Mean Average Recall
|
||||
metric results for medium objects (32² ≤ area < 96²).
|
||||
large_objects (Optional[MeanAverageRecallResult]): the Mean Average Recall
|
||||
metric results for large objects (area ≥ 96²).
|
||||
"""
|
||||
|
||||
metric_target: MetricTarget
|
||||
|
||||
@property
|
||||
def mAR_at_1(self) -> float:
|
||||
return self.recall_scores[0]
|
||||
|
||||
@property
|
||||
def mAR_at_10(self) -> float:
|
||||
return self.recall_scores[1]
|
||||
|
||||
@property
|
||||
def mAR_at_100(self) -> float:
|
||||
return self.recall_scores[2]
|
||||
|
||||
recall_scores: np.ndarray
|
||||
recall_per_class: np.ndarray
|
||||
max_detections: np.ndarray
|
||||
iou_thresholds: np.ndarray
|
||||
matched_classes: np.ndarray
|
||||
|
||||
small_objects: Optional[MeanAverageRecallResult]
|
||||
medium_objects: Optional[MeanAverageRecallResult]
|
||||
large_objects: Optional[MeanAverageRecallResult]
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Format as a pretty string.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# MeanAverageRecallResult:
|
||||
# Metric target: MetricTarget.BOXES
|
||||
# mAR @ 1: 0.1362
|
||||
# mAR @ 10: 0.4239
|
||||
# mAR @ 100: 0.5241
|
||||
# max detections: [1 10 100]
|
||||
# IoU thresh: [0.5 0.55 0.6 ...]
|
||||
# mAR per class:
|
||||
# 0: [0.78571 0.78571 0.78571 ...]
|
||||
# ...
|
||||
# Small objects: ...
|
||||
# Medium objects: ...
|
||||
# Large objects: ...
|
||||
```
|
||||
"""
|
||||
out_str = (
|
||||
f"{self.__class__.__name__}:\n"
|
||||
f"Metric target: {self.metric_target}\n"
|
||||
f"mAR @ 1: {self.mAR_at_1:.4f}\n"
|
||||
f"mAR @ 10: {self.mAR_at_10:.4f}\n"
|
||||
f"mAR @ 100: {self.mAR_at_100:.4f}\n"
|
||||
f"max detections: {self.max_detections}\n"
|
||||
f"IoU thresh: {self.iou_thresholds}\n"
|
||||
f"mAR 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 = {
|
||||
"mAR @ 1": self.mAR_at_1,
|
||||
"mAR @ 10": self.mAR_at_10,
|
||||
"mAR @ 100": self.mAR_at_100,
|
||||
}
|
||||
|
||||
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 Mean Average Recall results.
|
||||
|
||||
{ align=center width="800" }
|
||||
"""
|
||||
labels = ["mAR @ 1", "mAR @ 10", "mAR @ 100"]
|
||||
values = [self.mAR_at_1, self.mAR_at_10, self.mAR_at_100]
|
||||
colors = [LEGACY_COLOR_PALETTE[0]] * 3
|
||||
|
||||
if self.small_objects is not None:
|
||||
small_objects = self.small_objects
|
||||
labels += ["Small: mAR @ 1", "Small: mAR @ 10", "Small: mAR @ 100"]
|
||||
values += [
|
||||
small_objects.mAR_at_1,
|
||||
small_objects.mAR_at_10,
|
||||
small_objects.mAR_at_100,
|
||||
]
|
||||
colors += [LEGACY_COLOR_PALETTE[3]] * 3
|
||||
|
||||
if self.medium_objects is not None:
|
||||
medium_objects = self.medium_objects
|
||||
labels += ["Medium: mAR @ 1", "Medium: mAR @ 10", "Medium: mAR @ 100"]
|
||||
values += [
|
||||
medium_objects.mAR_at_1,
|
||||
medium_objects.mAR_at_10,
|
||||
medium_objects.mAR_at_100,
|
||||
]
|
||||
colors += [LEGACY_COLOR_PALETTE[2]] * 3
|
||||
|
||||
if self.large_objects is not None:
|
||||
large_objects = self.large_objects
|
||||
labels += ["Large: mAR @ 1", "Large: mAR @ 10", "Large: mAR @ 100"]
|
||||
values += [
|
||||
large_objects.mAR_at_1,
|
||||
large_objects.mAR_at_10,
|
||||
large_objects.mAR_at_100,
|
||||
]
|
||||
colors += [LEGACY_COLOR_PALETTE[4]] * 3
|
||||
|
||||
plt.rcParams["font.family"] = "monospace"
|
||||
|
||||
_, ax = plt.subplots(figsize=(10, 6))
|
||||
ax.set_ylim(0, 1)
|
||||
ax.set_ylabel("Value", fontweight="bold")
|
||||
title = (
|
||||
f"Mean Average Recall, by Object Size\n(target: {self.metric_target.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()
|
||||
@@ -0,0 +1,653 @@
|
||||
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.precision_at_50)
|
||||
# 0.8099
|
||||
|
||||
print(precision_result)
|
||||
# PrecisionResult:
|
||||
# Metric target: MetricTarget.BOXES
|
||||
# Averaging method: AveragingMethod.WEIGHTED
|
||||
# P @ 50: 0.8099
|
||||
# P @ 75: 0.7969
|
||||
# P @ thresh: [0.80992 0.80905 0.80905 ...]
|
||||
# IoU thresh: [0.5 0.55 0.6 ...]
|
||||
# Precision per class:
|
||||
# 0: [0.64706 0.64706 0.64706 ...]
|
||||
# ...
|
||||
# Small objects: ...
|
||||
# Medium objects: ...
|
||||
# Large objects: ...
|
||||
|
||||
print(precision_result.small_objects.precision_at_50)
|
||||
```
|
||||
|
||||
{ align=center width="800" }
|
||||
"""
|
||||
|
||||
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 (area < 32²).
|
||||
medium_objects (Optional[PrecisionResult]): the Precision metric results
|
||||
for medium objects (32² ≤ area < 96²).
|
||||
large_objects (Optional[PrecisionResult]): the Precision metric results
|
||||
for large objects (area ≥ 96²).
|
||||
"""
|
||||
|
||||
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)
|
||||
# PrecisionResult:
|
||||
# Metric target: MetricTarget.BOXES
|
||||
# Averaging method: AveragingMethod.WEIGHTED
|
||||
# P @ 50: 0.8099
|
||||
# P @ 75: 0.7969
|
||||
# P @ thresh: [0.80992 0.80905 0.80905 ...]
|
||||
# IoU thresh: [0.5 0.55 0.6 ...]
|
||||
# Precision per class:
|
||||
# 0: [0.64706 0.64706 0.64706 ...]
|
||||
# ...
|
||||
# Small objects: ...
|
||||
# Medium objects: ...
|
||||
# Large objects: ...
|
||||
```
|
||||
"""
|
||||
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.
|
||||
|
||||
{ align=center width="800" }
|
||||
"""
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,652 @@
|
||||
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.recall_at_50)
|
||||
# 0.7615
|
||||
|
||||
print(recall_result)
|
||||
# RecallResult:
|
||||
# Metric target: MetricTarget.BOXES
|
||||
# Averaging method: AveragingMethod.WEIGHTED
|
||||
# R @ 50: 0.7615
|
||||
# R @ 75: 0.7462
|
||||
# R @ thresh: [0.76151 0.76011 0.76011 0.75732 ...]
|
||||
# IoU thresh: [0.5 0.55 0.6 ...]
|
||||
# Recall per class:
|
||||
# 0: [0.78571 0.78571 0.78571 ...]
|
||||
# ...
|
||||
# Small objects: ...
|
||||
# Medium objects: ...
|
||||
# Large objects: ...
|
||||
|
||||
recall_result.plot()
|
||||
|
||||
```
|
||||
|
||||
{ align=center width="800" }
|
||||
"""
|
||||
|
||||
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 recall metric based on the stored predictions and ground-truth
|
||||
data, at different IoU thresholds.
|
||||
|
||||
Returns:
|
||||
(RecallResult): The recall 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 (area < 32²).
|
||||
medium_objects (Optional[RecallResult]): the Recall metric results
|
||||
for medium objects (32² ≤ area < 96²).
|
||||
large_objects (Optional[RecallResult]): the Recall metric results
|
||||
for large objects (area ≥ 96²).
|
||||
"""
|
||||
|
||||
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)
|
||||
# RecallResult:
|
||||
# Metric target: MetricTarget.BOXES
|
||||
# Averaging method: AveragingMethod.WEIGHTED
|
||||
# R @ 50: 0.7615
|
||||
# R @ 75: 0.7462
|
||||
# R @ thresh: [0.76151 0.76011 0.76011 0.75732 ...]
|
||||
# IoU thresh: [0.5 0.55 0.6 ...]
|
||||
# Recall per class:
|
||||
# 0: [0.78571 0.78571 0.78571 ...]
|
||||
# ...
|
||||
# Small objects: ...
|
||||
# Medium objects: ...
|
||||
# Large objects: ...
|
||||
```
|
||||
"""
|
||||
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.
|
||||
|
||||
{ align=center width="800" }
|
||||
"""
|
||||
|
||||
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()
|
||||
@@ -5,5 +5,6 @@ def ensure_pandas_installed():
|
||||
raise ImportError(
|
||||
"`metrics` extra is required to run the function."
|
||||
" Run `pip install 'supervision[metrics]'` or"
|
||||
" `poetry add supervision -E metrics`"
|
||||
" `poetry add supervision -E metrics` or"
|
||||
" `uv pip install 'supervision[metrics]'`"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -7,6 +7,7 @@ from typing import Callable, Generator, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -195,6 +196,9 @@ def process_video(
|
||||
source_path: str,
|
||||
target_path: str,
|
||||
callback: Callable[[np.ndarray, int], np.ndarray],
|
||||
max_frames: Optional[int] = None,
|
||||
show_progress: bool = False,
|
||||
progress_message: str = "Processing video",
|
||||
) -> None:
|
||||
"""
|
||||
Process a video file by applying a callback function on each frame
|
||||
@@ -207,6 +211,9 @@ def process_video(
|
||||
a numpy ndarray representation of a video frame and an
|
||||
int index of the frame and returns a processed numpy ndarray
|
||||
representation of the frame.
|
||||
max_frames (Optional[int]): The maximum number of frames to process.
|
||||
show_progress (bool): Whether to show a progress bar.
|
||||
progress_message (str): The message to display in the progress bar.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@@ -223,12 +230,31 @@ def process_video(
|
||||
```
|
||||
"""
|
||||
source_video_info = VideoInfo.from_video_path(video_path=source_path)
|
||||
video_frames_generator = get_video_frames_generator(
|
||||
source_path=source_path, end=max_frames
|
||||
)
|
||||
with VideoSink(target_path=target_path, video_info=source_video_info) as sink:
|
||||
if show_progress:
|
||||
# Calculate total_frames only when needed for tqdm
|
||||
total_frames = (
|
||||
min(source_video_info.total_frames, max_frames)
|
||||
if max_frames is not None
|
||||
else source_video_info.total_frames
|
||||
)
|
||||
for index, frame in enumerate(
|
||||
get_video_frames_generator(source_path=source_path)
|
||||
tqdm(
|
||||
video_frames_generator,
|
||||
total=total_frames,
|
||||
disable=not show_progress,
|
||||
desc=progress_message,
|
||||
)
|
||||
):
|
||||
result_frame = callback(frame, index)
|
||||
sink.write_frame(frame=result_frame)
|
||||
else:
|
||||
for index, frame in enumerate(video_frames_generator):
|
||||
result_frame = callback(frame, index)
|
||||
sink.write_frame(frame=result_frame)
|
||||
|
||||
|
||||
class FPSMonitor:
|
||||
|
||||
@@ -54,7 +54,7 @@ def validate_confidence(confidence: Any, n: int) -> None:
|
||||
|
||||
|
||||
def validate_keypoint_confidence(confidence: Any, n: int, m: int) -> None:
|
||||
expected_shape = f"({n,m})"
|
||||
expected_shape = f"({n, m})"
|
||||
actual_shape = str(getattr(confidence, "shape", None))
|
||||
|
||||
if confidence is not None:
|
||||
|
||||
+211
-2
@@ -106,6 +106,26 @@ TEST_DET_DIFFERENT_DATA = Detections(
|
||||
"never_seen_key": [9],
|
||||
},
|
||||
)
|
||||
TEST_DET_WITH_METADATA = Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]),
|
||||
class_id=np.array([1]),
|
||||
metadata={"source": "camera1"},
|
||||
)
|
||||
|
||||
TEST_DET_WITH_METADATA_2 = Detections(
|
||||
xyxy=np.array([[30, 30, 40, 40]]),
|
||||
class_id=np.array([2]),
|
||||
metadata={"source": "camera1"},
|
||||
)
|
||||
TEST_DET_NO_METADATA = Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]),
|
||||
class_id=np.array([1]),
|
||||
)
|
||||
TEST_DET_DIFFERENT_METADATA = Detections(
|
||||
xyxy=np.array([[50, 50, 60, 60]]),
|
||||
class_id=np.array([3]),
|
||||
metadata={"source": "camera2"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -203,6 +223,12 @@ TEST_DET_DIFFERENT_DATA = Detections(
|
||||
None,
|
||||
pytest.raises(IndexError),
|
||||
),
|
||||
(
|
||||
Detections.empty(),
|
||||
np.isin(Detections.empty()["class_name"], ["cat", "dog"]),
|
||||
Detections.empty(),
|
||||
DoesNotRaise(),
|
||||
), # Filter an empty detections by specific class names
|
||||
],
|
||||
)
|
||||
def test_getitem(
|
||||
@@ -258,6 +284,11 @@ def test_getitem(
|
||||
TEST_DET_1,
|
||||
DoesNotRaise(),
|
||||
), # Single detection and empty-array fields
|
||||
(
|
||||
[TEST_DET_ZERO_LENGTH, TEST_DET_ZERO_LENGTH],
|
||||
TEST_DET_ZERO_LENGTH,
|
||||
DoesNotRaise(),
|
||||
), # Zero-length fields across all Detections
|
||||
(
|
||||
[
|
||||
TEST_DET_1,
|
||||
@@ -287,12 +318,190 @@ def test_getitem(
|
||||
Detections.empty(),
|
||||
],
|
||||
mock_detections(
|
||||
xyxy=[[10, 10, 20, 20]],
|
||||
xyxy=np.array([[10, 10, 20, 20]]),
|
||||
class_id=[1],
|
||||
mask=[np.zeros((4, 4), dtype=bool)],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # Segmentation + Empty
|
||||
# Metadata
|
||||
(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]),
|
||||
class_id=np.array([1]),
|
||||
metadata={"source": "camera1"},
|
||||
),
|
||||
Detections.empty(),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]),
|
||||
class_id=np.array([1]),
|
||||
metadata={"source": "camera1"},
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # Metadata merge with empty detections
|
||||
(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]),
|
||||
class_id=np.array([1]),
|
||||
metadata={"source": "camera1"},
|
||||
),
|
||||
Detections(xyxy=np.array([[30, 30, 40, 40]]), class_id=np.array([2])),
|
||||
],
|
||||
None,
|
||||
pytest.raises(ValueError),
|
||||
), # Empty and non-empty metadata
|
||||
(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]),
|
||||
class_id=np.array([1]),
|
||||
metadata={"source": "camera1"},
|
||||
)
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]),
|
||||
class_id=np.array([1]),
|
||||
metadata={"source": "camera1"},
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # Single detection with metadata
|
||||
(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]),
|
||||
class_id=np.array([1]),
|
||||
metadata={"source": "camera1"},
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[30, 30, 40, 40]]),
|
||||
class_id=np.array([2]),
|
||||
metadata={"source": "camera1"},
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]]),
|
||||
class_id=np.array([1, 2]),
|
||||
metadata={"source": "camera1"},
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # Multiple metadata entries with identical values
|
||||
(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]),
|
||||
class_id=np.array([1]),
|
||||
metadata={"source": "camera1"},
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[50, 50, 60, 60]]),
|
||||
class_id=np.array([3]),
|
||||
metadata={"source": "camera2"},
|
||||
),
|
||||
],
|
||||
None,
|
||||
pytest.raises(ValueError),
|
||||
), # Different metadata values
|
||||
(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]),
|
||||
metadata={"source": "camera1", "resolution": "1080p"},
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[30, 30, 40, 40]]),
|
||||
metadata={"source": "camera1", "resolution": "1080p"},
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]]),
|
||||
metadata={"source": "camera1", "resolution": "1080p"},
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # Large metadata with multiple identical entries
|
||||
(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]), metadata={"source": "camera1"}
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[30, 30, 40, 40]]), metadata={"source": ["camera1"]}
|
||||
),
|
||||
],
|
||||
None,
|
||||
pytest.raises(ValueError),
|
||||
), # Inconsistent types in metadata values
|
||||
(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]), metadata={"source": "camera1"}
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[30, 30, 40, 40]]), metadata={"location": "indoor"}
|
||||
),
|
||||
],
|
||||
None,
|
||||
pytest.raises(ValueError),
|
||||
), # Metadata key mismatch
|
||||
(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]),
|
||||
metadata={
|
||||
"source": "camera1",
|
||||
"settings": {"resolution": "1080p", "fps": 30},
|
||||
},
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[30, 30, 40, 40]]),
|
||||
metadata={
|
||||
"source": "camera1",
|
||||
"settings": {"resolution": "1080p", "fps": 30},
|
||||
},
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]]),
|
||||
metadata={
|
||||
"source": "camera1",
|
||||
"settings": {"resolution": "1080p", "fps": 30},
|
||||
},
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # multi-field metadata
|
||||
(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]),
|
||||
metadata={"calibration_matrix": np.array([[1, 0], [0, 1]])},
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[30, 30, 40, 40]]),
|
||||
metadata={"calibration_matrix": np.array([[1, 0], [0, 1]])},
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]]),
|
||||
metadata={"calibration_matrix": np.array([[1, 0], [0, 1]])},
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # Identical 2D numpy arrays in metadata
|
||||
(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]]),
|
||||
metadata={"calibration_matrix": np.array([[1, 0], [0, 1]])},
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[30, 30, 40, 40]]),
|
||||
metadata={"calibration_matrix": np.array([[2, 0], [0, 2]])},
|
||||
),
|
||||
],
|
||||
None,
|
||||
pytest.raises(ValueError),
|
||||
), # Mismatching 2D numpy arrays in metadata
|
||||
],
|
||||
)
|
||||
def test_merge(
|
||||
@@ -302,7 +511,7 @@ def test_merge(
|
||||
) -> None:
|
||||
with exception:
|
||||
result = Detections.merge(detections_list=detections_list)
|
||||
assert result == expected_result
|
||||
assert result == expected_result, f"Expected: {expected_result}, Got: {result}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -408,8 +408,8 @@ def assert_csv_equal(file_name, expected_rows):
|
||||
with open(file_name, mode="r", newline="") as file:
|
||||
reader = csv.reader(file)
|
||||
for i, row in enumerate(reader):
|
||||
assert (
|
||||
[str(item) for item in expected_rows[i]] == row
|
||||
), f"Row in CSV didn't match expected output: {row} != {expected_rows[i]}"
|
||||
assert [str(item) for item in expected_rows[i]] == row, (
|
||||
f"Row in CSV didn't match expected output: {row} != {expected_rows[i]}"
|
||||
)
|
||||
|
||||
os.remove(file_name)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user