diff --git a/README.md b/README.md index b3c567bda..6e8a99460 100644 --- a/README.md +++ b/README.md @@ -83,20 +83,23 @@ For all CLI options, see the [tracking guide](https://trackers.roboflow.com/deve ## Algorithms -Each tracker below is a faithful implementation of its original paper's motion and association pipeline; appearance/ReID branches are not included where papers offer them — see each tracker's docs page for the exact scope. Pick the one that fits your scene, or run the benchmark to find out which performs best on your data. +Each tracker below is a faithful implementation of its original paper's motion and association pipeline; appearance/ReID branches are off by default, though BoT-SORT accepts one through `reid_model` (see the [ReID guide](https://trackers.roboflow.com/latest/guides/reid/)). Each tracker's docs page states the exact scope. Pick the one that fits your scene, or run the benchmark to find out which performs best on your data. - + -| Algorithm | Description | MOT17 HOTA | SportsMOT HOTA | SoccerNet HOTA | DanceTrack HOTA | -| :-------------------------------------------: | :------------------------------------------------------------------------------: | :--------: | :------------: | :------------: | :-------------: | -| [SORT](https://arxiv.org/abs/1602.00763) | Kalman filter + Hungarian matching baseline. | 58.4 | 70.8 | 81.6 | 47.2 | -| [ByteTrack](https://arxiv.org/abs/2110.06864) | Two-stage association using high and low confidence detections. | 60.1 | 73.0 | 84.0 | 53.3 | -| [OC-SORT](https://arxiv.org/abs/2203.14360) | Observation-centric recovery for lost tracks. | 61.9 | 71.7 | 78.4 | 54.1 | -| [BoT-SORT](https://arxiv.org/abs/2206.14651) | Camera motion compensation. | 63.7 | 73.8 | 84.5 | 57.8 | -| [C-BIoU](https://arxiv.org/abs/2211.14317) | Cascaded buffered IoU matching for fast or irregular motion. | 63.0 | 73.1 | 82.6 | 56.7 | -| [McByte](https://arxiv.org/abs/2506.01373) | Mask-conditioned tracking — adds propagated SAM/Cutie masks as a matching cue.\* | **64.1** | **76.5** | **85.0** | **67.2** | +| Algorithm | Description | MOT17 HOTA | SportsMOT HOTA | SoccerNet HOTA | DanceTrack HOTA | +| :-------------------------------------------------------------------: | :--------------------------------------------------------------------------------: | :--------: | :------------: | :------------: | :-------------: | +| [SORT](https://arxiv.org/abs/1602.00763) | Kalman filter + Hungarian matching baseline. | 58.4 | 70.8 | 81.6 | 47.2 | +| [ByteTrack](https://arxiv.org/abs/2110.06864) | Two-stage association using high and low confidence detections. | 60.1 | 73.0 | 84.0 | 53.3 | +| [OC-SORT](https://arxiv.org/abs/2203.14360) | Observation-centric recovery for lost tracks. | 61.9 | 71.7 | 78.4 | 54.1 | +| [C-BIoU](https://arxiv.org/abs/2211.14317) | Cascaded buffered IoU matching for fast or irregular motion. | 63.0 | 73.1 | 82.6 | 56.7 | +| [BoT-SORT](https://arxiv.org/abs/2206.14651) | Camera motion compensation. | 63.7 | 73.8 | 84.5 | 57.8 | +| [BoT-SORT + ReID](https://trackers.roboflow.com/develop/guides/reid/) | Appearance embeddings fused with motion, for harder association.\* | 63.9 | 75.5 | 84.3 | 57.0 | +| [McByte](https://arxiv.org/abs/2506.01373) | Mask-conditioned tracking — adds propagated SAM/Cutie masks as a matching cue.\*\* | **64.1** | **76.5** | **85.0** | **67.2** | -\*McByte needs optional heavyweight deps (`torch`, SAM, Cutie) not installed by default. It tops HOTA on all four benchmarks above — see the [McByte docs](https://trackers.roboflow.com/develop/trackers/mcbyte/) for setup. +\*BoT-SORT + ReID is listed with library default parameters and the default encoder `fastreid_mot17_sbs50` (BoT-SORT's own ReID model, trained on MOT17) on all four datasets. At its best tuned configuration on each dataset, it reaches 64.6 (MOT17), 77.8 (SportsMOT), 88.4 (SoccerNet) and 60.8 (DanceTrack) HOTA on the test sets, above BoT-SORT without ReID on all four. See the [ReID guide](https://trackers.roboflow.com/develop/guides/reid/). + +\*\*McByte needs optional heavyweight deps (`torch`, SAM, Cutie) not installed by default. It tops HOTA on all four benchmarks above. See the [McByte docs](https://trackers.roboflow.com/develop/trackers/mcbyte/) for setup. All scores use default parameters on the standard split. Detections come from a YOLOX detector (MOT17, SportsMOT, DanceTrack) or oracle ground-truth boxes (SoccerNet) — absolute numbers shift with detector quality. See the [tracker comparison](https://trackers.roboflow.com/develop/evaluations/results/) for tuned numbers and methodology. diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 757b481cd..91a46ae4b 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -10,7 +10,8 @@ comment) and update every listed sibling. If you change a cell somewhere else fi it's the source of truth, not just another copy. Discover live: grep -rn BENCH-XREF docs/ README.md -(15 xref comments as of 2026-08-12: 5 in results.md, 6 in docs/trackers/*.md, 3 in docs/index.md, 1 in README.md. +(20 xref comments as of 2026-09-28: 9 in results.md, 6 in docs/trackers/*.md, 3 in docs/index.md, 1 in README.md, + 1 in docs/guides/reid.md. A 16th lived in .github/copilot-instructions.md until 2026-08-12, when that file was deleted and its guidance merged into the root AGENTS.md — which deliberately carries NO benchmark table. Don't add one there.) @@ -19,7 +20,10 @@ Discover live: grep -rn BENCH-XREF docs/ README.md - id=sportsmot-default (## SportsMOT -> === "Default") - id=soccernet-default (## SoccerNet-tracking -> === "Default") - id=dancetrack-default (## DanceTrack -> === "Default") -- Tuned tables (all 4 benchmarks) have NO external duplicates — only referenced from within results.md itself. +- id=mot17-tuned, id=sportsmot-tuned, id=soccernet-tuned, id=dancetrack-tuned (=== "Tuned" tabs). Their BoT-SORT + rows are copied into [guides/reid.md](guides/reid.md) (Results, both tabs). Their BoT-SORT + ReID rows run the + other way: copied FROM guides/reid.md (the best test row across its two tabs), and their HOTA also appears in + the BoT-SORT + ReID footnote of index.md and README.md. ## Duplicate sites, per canonical row @@ -46,8 +50,8 @@ BoT-SORT row (mot17/sportsmot/soccernet/dancetrack): C-BIoU row (mot17/sportsmot/soccernet/dancetrack — the only tracker doc with a DanceTrack row): -> [trackers/cbiou.md](trackers/cbiou.md) (table, full row, all 4 benchmarks) + -> [index.md](index.md) (Algorithms table, HOTA column only) -> [../README.md](../README.md) (Algorithms table, HOTA column only) - -> docs/index.md does NOT have a C-BIoU row — don't add one when editing, that's existing scope, not a gap. McByte row (mot17/sportsmot/soccernet/dancetrack): -> [trackers/mcbyte.md](trackers/mcbyte.md) (McByte row, matching benchmark tab, full row) @@ -55,11 +59,16 @@ McByte row (mot17/sportsmot/soccernet/dancetrack): -> [../README.md](../README.md) (Algorithms table, HOTA column only; includes C-BIoU row too) — the index.md FAQ claim above is TRUE only while McByte is bolded-best in all 4 Default tables. Re-verify, don't assume. +BoT-SORT + ReID row (mot17/sportsmot/soccernet/dancetrack; Default = `fastreid_mot17_sbs50` at library defaults): + -> [index.md](index.md) (Algorithms table, HOTA column only) + -> [../README.md](../README.md) (Algorithms table, HOTA column only) + -> [guides/reid.md](guides/reid.md) is where the Tuned ReID rows come FROM, see Canonical tables above. + ## Structural asymmetries (intentional — do not "fix" by adding rows) - docs/trackers/{sort,bytetrack,ocsort,botsort}.md tables cover MOT17/SportsMOT/SoccerNet only, no DanceTrack row. - docs/trackers/cbiou.md is the only individual-tracker doc with a DanceTrack row. -- docs/index.md Algorithms table has 5 tracker rows (SORT/ByteTrack/OC-SORT/BoT-SORT/McByte), no C-BIoU row. -- README.md Algorithms table has 6 tracker rows (SORT/ByteTrack/OC-SORT/BoT-SORT/C-BIoU/McByte). +- docs/index.md and README.md Algorithms tables have the same 7 rows, in the same order as the results.md tables: + SORT/ByteTrack/OC-SORT/C-BIoU/BoT-SORT/BoT-SORT + ReID/McByte. - The root AGENTS.md carries NO benchmark table — it links to evaluations/results.md. That is deliberate; don't add a table back (it replaced .github/copilot-instructions.md, deleted 2026-08-12, which had one). - docs/trackers/mcbyte.md reports McByte vs a BoT-SORT baseline only (not vs SORT/ByteTrack/OC-SORT/C-BIoU). diff --git a/docs/assets/reid/soccernet-osnet-appearance-distances-vs-gap.png b/docs/assets/reid/soccernet-osnet-appearance-distances-vs-gap.png deleted file mode 100644 index 75dd587e1..000000000 Binary files a/docs/assets/reid/soccernet-osnet-appearance-distances-vs-gap.png and /dev/null differ diff --git a/docs/assets/reid/soccernet-osnet-appearance-distances.png b/docs/assets/reid/soccernet-osnet-appearance-distances.png deleted file mode 100644 index 61858b26d..000000000 Binary files a/docs/assets/reid/soccernet-osnet-appearance-distances.png and /dev/null differ diff --git a/docs/evaluations/results.md b/docs/evaluations/results.md index d44e88778..8de10aa0a 100644 --- a/docs/evaluations/results.md +++ b/docs/evaluations/results.md @@ -5,11 +5,11 @@ description: Side-by-side MOT benchmark comparison of SORT, ByteTrack, OC-SORT, # Tracker Comparison -This page shows head-to-head performance of SORT, ByteTrack, OC-SORT, BoT-SORT, C-BIoU, and McByte on standard MOT benchmarks. Results are shown with default parameters and with parameter-tuned configurations found via grid search. McByte is reported at default parameters only (mask-conditioned association enabled); it is designed to require no per-dataset tuning, so there is no Tuned row. +This page shows head-to-head performance of SORT, ByteTrack, OC-SORT, C-BIoU, BoT-SORT, and McByte on standard MOT benchmarks. Results are shown with default parameters and with parameter-tuned configurations found via grid search. McByte is reported at default parameters only (mask-conditioned association enabled); it is designed to require no per-dataset tuning, so there is no Tuned row. BoT-SORT + ReID is shown at library defaults in the Default tabs, with the default encoder `fastreid_mot17_sbs50` throughout; its Tuned rows show the best test result among the configurations in the [ReID guide](../guides/reid.md#results), each with an encoder trained on that dataset: `osnet_x1_0` fine-tuned on the SportsMOT, SoccerNet and DanceTrack train splits, and on MOT17 `fastreid_mot17_sbs50`, which the BoT-SORT authors trained on MOT17. !!! info "Benchmark version" - Results use **trackers v2.6.0** (released 2026-08-03). Detections are from YOLOX (MOT17, SportsMOT, DanceTrack) or ground-truth oracle boxes (SoccerNet). Parameters were tuned via grid search on held-out splits. See [Methodology](methodology.md) for details. + Results use **trackers v2.6.0** (released 2026-08-03), except the BoT-SORT + ReID rows, which were produced in v2.7.0, the release that adds appearance association. Detections are from YOLOX (MOT17, SportsMOT, DanceTrack) or ground-truth oracle boxes (SoccerNet). Parameters were tuned via grid search on held-out splits, and the ReID thresholds with [`trackers tune`](../guides/tune.md#tune-reid-thresholds). See [Methodology](methodology.md) for details. v2.6.0 changed all trackers' lost-track lifecycle from exclusive (`time_since_update < maximum_frames_without_update`) to inclusive (`<=`) boundary semantics, so confirmed tracks now survive one additional missed frame. Expect small IDSW/HOTA shifts versus earlier versions even for trackers whose parameters didn't change — see the [Changelog](../changelog.md) for details. @@ -28,43 +28,52 @@ Pedestrian tracking with crowded scenes and frequent occlusions. Strongly tests !!! info - Parameters were tuned on the validation set. Results are reported on the test set via Codabench submission. Detections come from a YOLOX model. BoT-SORT rows are CMC without appearance; see the [ReID guide](../guides/reid.md) for appearance association. + Parameters were tuned on the validation set. Results are reported on the test set via Codabench submission. Detections come from a YOLOX model. The BoT-SORT row is geometry and CMC only; BoT-SORT + ReID adds the encoder named under the table. The [ReID guide](../guides/reid.md#results) carries both fusion methods and the generic and fine-tuned encoders side by side. === "Default" - Results using default tracker parameters. + Results using default tracker parameters. The ReID row is BoT-SORT at library defaults with the default `fastreid_mot17_sbs50` encoder, BoT-SORT's own ReID model trained on MOT17 pedestrian crops (see [encoders](../guides/reid.md#choosing-an-encoder)). - | Tracker | HOTA | IDF1 | MOTA | - | :-------: | :------: | :------: | :------: | - | SORT | 58.4 | 69.9 | 67.2 | - | ByteTrack | 60.1 | 73.2 | 74.1 | - | OC-SORT | 61.9 | 76.4 | 76.0 | - | BoT-SORT | 63.7 | 78.7 | **79.2** | - | C-BIoU | 63.0 | 79.1 | 77.4 | - | McByte | **64.1** | **79.7** | 79.1 | + | Tracker | HOTA | IDF1 | MOTA | + | :-------------: | :------: | :------: | :------: | + | SORT | 58.4 | 69.9 | 67.2 | + | ByteTrack | 60.1 | 73.2 | 74.1 | + | OC-SORT | 61.9 | 76.4 | 76.0 | + | C-BIoU | 63.0 | 79.1 | 77.4 | + | BoT-SORT | 63.7 | 78.7 | **79.2** | + | BoT-SORT + ReID | 63.9 | 78.9 | **79.2** | + | McByte | **64.1** | **79.7** | 79.1 | === "Tuned" Results after grid search over tracker parameters. - | Tracker | HOTA | IDF1 | MOTA | - | :-------: | :------: | :------: | :------: | - | SORT | 60.4 | 72.5 | 75.8 | - | ByteTrack | 60.5 | 72.7 | 76.1 | - | OC-SORT | 62.0 | 76.5 | 77.3 | - | BoT-SORT | **63.8** | 78.7 | **79.4** | - | C-BIoU | 63.0 | **79.1** | 77.4 | + | Tracker | HOTA | IDF1 | MOTA | + | :-------------: | :------: | :------: | :------: | + | SORT | 60.4 | 72.5 | 75.8 | + | ByteTrack | 60.5 | 72.7 | 76.1 | + | OC-SORT | 62.0 | 76.5 | 77.3 | + | C-BIoU | 63.0 | 79.1 | 77.4 | + | BoT-SORT | 63.8 | 78.7 | **79.4** | + | BoT-SORT + ReID | **64.6** | **79.9** | **79.4** | - Tuned configuration for each tracker. + + + Tuned configuration for each tracker. The ReID row uses `fastreid_mot17_sbs50`, trained on MOT17 by the BoT-SORT authors, with `reid_config.fusion="adaptive"`: the best MOT17 test result in the [ReID guide](../guides/reid.md#results). It was chosen on test; on val-half, `botsort` scored 0.10 higher. ```yaml SORT: @@ -109,6 +118,15 @@ Pedestrian tracking with crowded scenes and frequent occlusions. Strongly tests track_activation_threshold: 0.7 buffer_ratio_first: 0.3 buffer_ratio_second: 0.5 + + BoT-SORT + ReID: + reid_model: fastreid_mot17_sbs50 + reid_config.fusion: adaptive + reid_config.appearance_weight: 1.908 + reid_config.adaptive_weight_cap: 0.545 + reid_config.appearance_floor: 0.381 + reid_config.proximity_threshold: 0.772 + minimum_iou_threshold_first_assoc: 0.846 ``` ## [SportsMOT](https://arxiv.org/abs/2304.05170) @@ -126,39 +144,48 @@ Sports broadcast tracking with fast motion, camera pans, and similar-looking tar === "Default" - Results using default tracker parameters. + Results using default tracker parameters. The ReID row is BoT-SORT at library defaults with the default `fastreid_mot17_sbs50` encoder, BoT-SORT's own ReID model trained on MOT17 pedestrian crops (see [encoders](../guides/reid.md#choosing-an-encoder)). - | Tracker | HOTA | IDF1 | MOTA | - | :-------: | :------: | :------: | :------: | - | SORT | 70.8 | 68.9 | 95.5 | - | ByteTrack | 73.0 | 72.5 | 96.4 | - | OC-SORT | 71.7 | 71.4 | 95.0 | - | BoT-SORT | 73.8 | 73.4 | 96.9 | - | C-BIoU | 73.1 | 72.6 | 96.7 | - | McByte | **76.5** | **76.9** | **97.0** | + | Tracker | HOTA | IDF1 | MOTA | + | :-------------: | :------: | :------: | :------: | + | SORT | 70.8 | 68.9 | 95.5 | + | ByteTrack | 73.0 | 72.5 | 96.4 | + | OC-SORT | 71.7 | 71.4 | 95.0 | + | C-BIoU | 73.1 | 72.6 | 96.7 | + | BoT-SORT | 73.8 | 73.4 | 96.9 | + | BoT-SORT + ReID | 75.5 | 75.2 | **97.0** | + | McByte | **76.5** | **76.9** | **97.0** | === "Tuned" Results after grid search over tracker parameters. - | Tracker | HOTA | IDF1 | MOTA | - | :-------: | :------: | :------: | :------: | - | SORT | 72.9 | 73.0 | 95.8 | - | ByteTrack | 73.3 | 73.5 | 95.9 | - | OC-SORT | 74.0 | **75.4** | 95.6 | - | BoT-SORT | **74.1** | 74.0 | **96.9** | - | C-BIoU | 73.1 | 72.6 | 96.7 | + | Tracker | HOTA | IDF1 | MOTA | + | :-------------: | :------: | :------: | :------: | + | SORT | 72.9 | 73.0 | 95.8 | + | ByteTrack | 73.3 | 73.5 | 95.9 | + | OC-SORT | 74.0 | 75.4 | 95.6 | + | C-BIoU | 73.1 | 72.6 | 96.7 | + | BoT-SORT | 74.1 | 74.0 | **96.9** | + | BoT-SORT + ReID | **77.8** | **78.5** | **96.9** | + + - Tuned configuration for each tracker. + Tuned configuration for each tracker. The ReID row uses `osnet_x1_0_sportsmot`, an OSNet x1.0 fine-tuned on SportsMOT train (see [encoders](../guides/reid.md#choosing-an-encoder)). ```yaml SORT: @@ -203,6 +230,12 @@ Sports broadcast tracking with fast motion, camera pans, and similar-looking tar track_activation_threshold: 0.7 buffer_ratio_first: 0.3 buffer_ratio_second: 0.5 + + BoT-SORT + ReID: + reid_model: osnet_x1_0_sportsmot + reid_config.fusion: botsort + reid_config.appearance_threshold: 0.3425 + reid_config.proximity_threshold: 0.9953 ``` ## [SoccerNet-tracking](https://arxiv.org/abs/2204.06918) @@ -216,43 +249,52 @@ Long sequences with dense interactions and partial occlusions. Tests long-term I !!! info - Parameters were tuned on the train set. Results are reported on the test set. SoccerNet-tracking has no validation split. This dataset provides oracle (ground-truth) detections. The BoT-SORT row is CMC without appearance; see the [ReID guide](../guides/reid.md) for appearance association. + Parameters were tuned on the train set. Results are reported on the test set. SoccerNet-tracking has no validation split. This dataset provides oracle (ground-truth) detections. The BoT-SORT row is geometry and CMC only; BoT-SORT + ReID adds the encoder named under the table. The [ReID guide](../guides/reid.md#results) carries both fusion methods and the generic and fine-tuned encoders side by side. === "Default" - Results using default tracker parameters. + Results using default tracker parameters. The ReID row is BoT-SORT at library defaults with the default `fastreid_mot17_sbs50` encoder, BoT-SORT's own ReID model trained on MOT17 pedestrian crops (see [encoders](../guides/reid.md#choosing-an-encoder)). - | Tracker | HOTA | IDF1 | MOTA | - | :-------: | :------: | :------: | :------: | - | SORT | 81.6 | 76.2 | 95.1 | - | ByteTrack | 84.0 | 78.1 | **97.8** | - | OC-SORT | 78.4 | 72.6 | 94.1 | - | BoT-SORT | 84.5 | 79.3 | 96.6 | - | C-BIoU | 82.6 | 76.6 | 97.0 | - | McByte | **85.0** | **79.9** | 97.0 | + | Tracker | HOTA | IDF1 | MOTA | + | :-------------: | :------: | :------: | :------: | + | SORT | 81.6 | 76.2 | 95.1 | + | ByteTrack | 84.0 | 78.1 | **97.8** | + | OC-SORT | 78.4 | 72.6 | 94.1 | + | C-BIoU | 82.6 | 76.6 | 97.0 | + | BoT-SORT | 84.5 | 79.3 | 96.6 | + | BoT-SORT + ReID | 84.3 | 79.1 | 96.5 | + | McByte | **85.0** | **79.9** | 97.0 | === "Tuned" Results after grid search over tracker parameters. - | Tracker | HOTA | IDF1 | MOTA | - | :-------: | :------: | :------: | :------: | - | SORT | 84.2 | 78.2 | 98.2 | - | ByteTrack | 84.0 | 78.1 | 98.2 | - | OC-SORT | 82.9 | 77.9 | 96.8 | - | BoT-SORT | 85.0 | 79.7 | 97.2 | - | C-BIoU | **85.7** | **80.0** | **99.3** | + | Tracker | HOTA | IDF1 | MOTA | + | :-------------: | :------: | :------: | :------: | + | SORT | 84.2 | 78.2 | 98.2 | + | ByteTrack | 84.0 | 78.1 | 98.2 | + | OC-SORT | 82.9 | 77.9 | 96.8 | + | C-BIoU | 85.7 | 80.0 | **99.3** | + | BoT-SORT | 85.0 | 79.7 | 97.2 | + | BoT-SORT + ReID | **88.4** | **84.4** | **99.3** | + + - Tuned configuration for each tracker. + Tuned configuration for each tracker. The ReID row uses `osnet_x1_0_soccernet`, an OSNet x1.0 fine-tuned on SoccerNet train (see [encoders](../guides/reid.md#choosing-an-encoder)). ```yaml SORT: @@ -297,6 +339,14 @@ Long sequences with dense interactions and partial occlusions. Tests long-term I track_activation_threshold: 0.48 buffer_ratio_first: 0.68 buffer_ratio_second: 0.50 + + BoT-SORT + ReID: + reid_model: osnet_x1_0_soccernet + reid_config.fusion: adaptive + reid_config.appearance_weight: 0.75 + reid_config.adaptive_weight_cap: 0.5 + reid_config.proximity_threshold: 1.0 + reid_config.appearance_floor: 0.8 ``` !!! note "SoccerNet buffer ordering exception" @@ -318,38 +368,47 @@ Group dancing tracking with uniform appearance, diverse motions, and extreme art === "Default" - Results using default tracker parameters. + Results using default tracker parameters. The ReID row is BoT-SORT at library defaults with the default `fastreid_mot17_sbs50` encoder, BoT-SORT's own ReID model trained on MOT17 pedestrian crops (see [encoders](../guides/reid.md#choosing-an-encoder)). - | Tracker | HOTA | IDF1 | MOTA | - | :-------: | :------: | :------: | :------: | - | SORT | 47.2 | 41.0 | 86.5 | - | ByteTrack | 53.3 | 53.6 | 90.3 | - | OC-SORT | 54.1 | 53.3 | 89.3 | - | BoT-SORT | 57.8 | 57.9 | 92.2 | - | C-BIoU | 56.7 | 56.7 | 92.2 | - | McByte | **67.2** | **68.6** | **92.5** | + | Tracker | HOTA | IDF1 | MOTA | + | :-------------: | :------: | :------: | :------: | + | SORT | 47.2 | 41.0 | 86.5 | + | ByteTrack | 53.3 | 53.6 | 90.3 | + | OC-SORT | 54.1 | 53.3 | 89.3 | + | C-BIoU | 56.7 | 56.7 | 92.2 | + | BoT-SORT | 57.8 | 57.9 | 92.2 | + | BoT-SORT + ReID | 57.0 | 57.1 | 92.1 | + | McByte | **67.2** | **68.6** | **92.5** | === "Tuned" Hyperparameter tuning, reporting the best tuned configuration per tracker evaluated on the test set (tuning performed on the valid split; if tuning did not outperform registry defaults, defaults are shown). - | Tracker | HOTA | IDF1 | MOTA | - | :-------: | :------: | :------: | :------: | - | SORT | 54.3 | 53.4 | 89.5 | - | ByteTrack | 55.3 | 55.2 | 89.9 | - | OC-SORT | 54.1 | 53.3 | 89.3 | - | BoT-SORT | **57.8** | 57.9 | 92.2 | - | C-BIoU | 57.7 | **58.7** | **92.4** | + | Tracker | HOTA | IDF1 | MOTA | + | :-------------: | :------: | :------: | :------: | + | SORT | 54.3 | 53.4 | 89.5 | + | ByteTrack | 55.3 | 55.2 | 89.9 | + | OC-SORT | 54.1 | 53.3 | 89.3 | + | C-BIoU | 57.7 | 58.7 | **92.4** | + | BoT-SORT | 57.8 | 57.9 | 92.2 | + | BoT-SORT + ReID | **60.8** | **61.6** | 91.7 | + + - Best configuration for each tracker. + Best configuration for each tracker. The ReID row uses `osnet_x1_0_dancetrack`, an OSNet x1.0 fine-tuned on DanceTrack train (see [encoders](../guides/reid.md#choosing-an-encoder)). ```yaml SORT: @@ -394,6 +453,12 @@ Group dancing tracking with uniform appearance, diverse motions, and extreme art high_conf_det_threshold: 0.34 buffer_ratio_first: 0.12 buffer_ratio_second: 0.10 + + BoT-SORT + ReID: + reid_model: osnet_x1_0_dancetrack + reid_config.fusion: botsort + reid_config.appearance_threshold: 0.4716 + reid_config.proximity_threshold: 0.6802 ``` !!! note "DanceTrack buffer ordering exception" @@ -412,7 +477,9 @@ Group dancing tracking with uniform appearance, diverse motions, and extreme art **BoT-SORT** is the choice when camera ego-motion is strong and you need the most stable identities. It extends ByteTrack with camera motion compensation (CMC) and confidence-aware association, which reduces ID switches on panning or handheld footage. Use BoT-SORT for sports broadcasts, drone video, or any scene where the camera moves frequently. The CMC overhead is small relative to the detector, so the trade-off favors identity stability over raw speed. -**C-BIoU** targets fast or irregular motion when you want buffered, cascaded geometric matching without camera motion compensation. In these benchmarks it leads on SoccerNet when tuned, and reaches the highest tuned IDF1 and MOTA on DanceTrack among the motion-only trackers. Use C-BIoU when BoT-SORT-style association is a good fit but CMC is unavailable or harmful, or when plain IoU matching is too strict. See [C-BIoU](../trackers/cbiou.md) for buffer scales **b1** and **b2**. +**C-BIoU** targets fast or irregular motion when you want buffered, cascaded geometric matching without camera motion compensation. In these benchmarks it leads on SoccerNet among the motion-only trackers when tuned, and reaches the highest tuned IDF1 and MOTA on DanceTrack among the motion-only trackers. Use C-BIoU when BoT-SORT-style association is a good fit but CMC is unavailable or harmful, or when plain IoU matching is too strict. See [C-BIoU](../trackers/cbiou.md) for buffer scales **b1** and **b2**. + +**BoT-SORT + ReID** adds an appearance encoder to BoT-SORT's association; enable it with `reid_model=`. Appearance helps where overlap alone cannot tell candidates apart. It can only relink a track that is still kept as lost, for `lost_track_buffer` frames (30 by default, about one second), and at the default `reid_config.proximity_threshold` it only scores pairs that already overlap. Recovering a target that leaves the frame takes a fully open gate and a buffer long enough to cover the absence, see [Choosing a proximity threshold](../guides/reid.md#choosing-a-proximity-threshold). The gain depends on how well the encoder separates your targets: largest with an encoder trained on footage like yours, smallest when targets look alike. Tune the ReID thresholds on your data with [`trackers tune`](../guides/tune.md#tune-reid-thresholds). **McByte** is the choice when you want the highest identity stability and can afford optional SAM/Cutie mask dependencies. It extends BoT-SORT-style association with temporally propagated segmentation masks as an extra matching cue, requiring no per-video tuning. Reported against Trackers' BoT-SORT baseline (without re-identification) at default parameters, McByte improves HOTA and IDF1 on all four datasets in this comparison, with the largest gain on DanceTrack. Use McByte when identity consistency matters more than raw throughput or dependency footprint. See [McByte](../trackers/mcbyte.md) for setup and the mask-conditioning parameters. diff --git a/docs/guides/reid.md b/docs/guides/reid.md index 58c37cf02..3a48e2cec 100644 --- a/docs/guides/reid.md +++ b/docs/guides/reid.md @@ -1,5 +1,5 @@ --- -title: ReID Appearance — BoT-SORT Appearance Association | Trackers +title: ReID Appearance description: Use ReID appearance association with BoT-SORT in Roboflow Trackers, from model loading to appearance threshold selection, with MOT17 and SoccerNet results. --- @@ -11,8 +11,8 @@ BoT-SORT can fuse appearance embeddings with IoU during association. Embeddings - How to enable appearance association on BoT-SORT - Which parameters control the appearance gate -- How to pick `ReIDConfig.appearance_threshold` for your encoder and domain -- What ReID changes on MOT17 and SoccerNet +- How to pick `reid_config.appearance_threshold` for your encoder and domain +- What ReID changes on MOT17, SportsMOT, DanceTrack and SoccerNet, with a generic and a fine-tuned encoder --- @@ -22,7 +22,7 @@ BoT-SORT can fuse appearance embeddings with IoU during association. Embeddings pip install "trackers[reid]" ``` -The `reid` extra installs the [`reid`](https://github.com/roboflow/re-ID) package, which loads the encoders and brings PyTorch with it. To combine it with other extras, such as `detection` for running a detector from the CLI, or to install with `uv`, see the [install guide](install.md#reid-bot-sort-appearance). +For extra contents and other options, see the [install guide](install.md). --- @@ -41,7 +41,7 @@ The `reid` extra installs the [`reid`](https://github.com/roboflow/re-ID) packag === "CLI" - `--reid.model` takes a curated alias, an `hf://` URL, or a local path, and enables appearance association on its own. The appearance gate is `--tracker.reid_config.appearance_threshold`. See the [CLI reference](cli.md) for every argument. + `--reid.model` takes a curated alias, an `hf://` URL, or a local path, and enables appearance association on its own. Tracker parameters keep their Python names, so the appearance gate is `--tracker.reid_config.appearance_threshold`. See the [CLI reference](cli.md) for every argument. ```bash trackers track \ @@ -56,30 +56,82 @@ The `reid` extra installs the [`reid`](https://github.com/roboflow/re-ID) packag Pass the current video frame as `tracker.update(detections, frame=frame_bgr)`. When `reid_model` is set, `update()` raises if `frame` is omitted. -For the model catalog and fine-tuning, see the [`reid` training guide](https://github.com/roboflow/re-ID/blob/main/docs/learn/train.md). +Fine-tuning an encoder on your own data is coming to the `reid` package. --- ## Key Parameters -| Parameter | Purpose | Tuning guidance | -| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `reid_model` | Appearance encoder queried during association. | Leave unset for IoU and CMC only. Pick a checkpoint trained on your object domain where possible. | -| `reid_config.ema_alpha` | EMA momentum for a track's appearance feature. | Default 0.9. Higher keeps a stable long-term identity; lower adapts faster to appearance change but drifts more. | -| `reid_config.appearance_threshold` | Maximum appearance distance `d_app` for appearance to lower a pair's matching cost. | BoT-SORT paper default 0.25. Calibrate per encoder and domain, see below. | -| `reid_config.proximity_threshold` | IoU gate applied before appearance (`IoU ≥ 1 - reid_config.proximity_threshold`), from true IoU even with GIoU/DIoU/CIoU. | Default 0.5. Lower restricts how far apart a pair may be before appearance stops contributing. | +| Parameter | Purpose | Tuning guidance | +| :--------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------- | +| `reid_model` | Appearance encoder queried during association. | Leave unset for IoU and CMC only. Pick a checkpoint trained on your object domain where possible. | +| `reid_config.ema_alpha` | EMA momentum for a track's appearance feature. | Default 0.9. Higher keeps a stable long-term identity; lower adapts faster to appearance change but drifts more. | +| `reid_config.appearance_threshold` | Maximum appearance distance for appearance to lower a pair's matching cost with `"botsort"`. | Default 0.25 (BoT-SORT paper). Calibrate per encoder and domain, see below. | +| `reid_config.proximity_threshold` | IoU gate applied before appearance (`IoU ≥ 1 - reid_config.proximity_threshold`), from true IoU even with GIoU/DIoU/CIoU. | Default 0.5. Raise to 1.0 where targets leave the frame and return, see [below](#choosing-a-proximity-threshold). | +| `reid_config.fusion` | How appearance combines with geometry: `"botsort"` takes the minimum of the two costs, `"adaptive"` adds a weighted appearance term. | Default `"botsort"`. See [choosing a fusion method](#choosing-a-fusion-method) before switching. | +| `reid_config.appearance_weight` | Base appearance weight when `reid_config.fusion="adaptive"`. Ignored otherwise. | Default 0.75. Raise where geometry is unreliable, see below. | +| `reid_config.adaptive_weight_cap` | Ceiling on the adaptive bonus when `reid_config.fusion="adaptive"`. Ignored otherwise. | Default 0.5. Raise together with `reid_config.appearance_weight`. | +| `reid_config.appearance_floor` | Minimum cosine similarity for appearance to contribute with `"adaptive"`; below it a pair is scored on geometry alone. | Default 0.0 (off, as in Deep OC-SORT). Calibrate per encoder; 0.8 is used for the tuned SoccerNet result below. | + +--- + +## Choosing an encoder + +The encoder decides how much appearance can help, and every threshold below depends on it. There are two kinds: + +- **A generic encoder**, used as published. The one used here is `fastreid_mot17_sbs50`, a FastReID SBS model with a ResNeSt-50 backbone and 25.4M parameters, trained on MOT17 pedestrian crops and released with [BoT-SORT](https://github.com/NirAharon/BoT-SORT). `trackers track` and `trackers tune` load it when ReID is enabled without naming a model; in Python, load it with `ReIDModel.from_pretrained("fastreid_mot17_sbs50")`, as in the [quickstart](#quickstart). It works out of the box, and how much it helps depends on the dataset, see [results](#results). +- **An encoder fine-tuned on your footage**, where the largest gains in the results come from. The results use an [OSNet](https://arxiv.org/abs/1905.00953) x1.0, a much smaller network with 2.2M parameters, fine-tuned on each dataset's train split. All four are available by name: `osnet_x1_0_mot17`, `osnet_x1_0_sportsmot`, `osnet_x1_0_dancetrack` and `osnet_x1_0_soccernet`. Load one with `ReIDModel.from_pretrained("osnet_x1_0_sportsmot")`, or pass the name to `--reid.model` on the command line. Training your own is coming to the `reid` package. + +Each encoder has its own distance scale, so choose the thresholds for the encoder you will track with. + +--- + +## Choosing a fusion method + +On every frame, BoT-SORT gives each track and detection pair a score and then picks the best matching. With ReID enabled, that score combines two signals: how much the boxes overlap, and how alike the two crops look. `reid_config.fusion` sets how they are combined: + +```python +tracker = BoTSORTTracker( + reid_model=reid_model, + reid_config=ReIDConfig(fusion="adaptive"), # default: "botsort" +) +``` + +- `"botsort"`: a pair scores as well as the stronger of the two, `score = 1 - min(box distance, appearance distance)`. +- `"adaptive"`: the two add up, `score = box overlap + weight * appearance similarity`. + +In more detail: + +**`"botsort"`.** Appearance is only used for a pair when its distance is below `reid_config.appearance_threshold` and the boxes pass the `reid_config.proximity_threshold` check. Otherwise the pair is scored on box overlap alone. + +**`"adaptive"`.** This is the appearance fusion from Deep OC-SORT. Each pair starts from its box overlap, and appearance is added on top: `score = box overlap + (reid_config.appearance_weight + bonus) * appearance similarity`. The bonus rewards a clear winner. For a track, it is how much more its most similar detection looks like it than the second most similar one; for a detection, the same against its two most similar tracks. Each margin is capped at `reid_config.adaptive_weight_cap`, and the pair's bonus is their average. So appearance counts more when one candidate clearly stands out, and less when several look alike. Negative similarities count as zero, so appearance can only raise a score. `reid_config.appearance_floor`, added by this library, sets the lowest appearance similarity that counts: below it, a pair is scored on box overlap alone. With the proximity gate open, this stops a lost track from taking an unrelated detection that only looks vaguely similar. + +!!! note "What `adaptive` includes" + + Only the appearance fusion is taken from Deep OC-SORT; the rest of that tracker is not added to BoT-SORT. `reid_config.appearance_threshold` has no effect with `reid_config.fusion="adaptive"`. + +Two consequences are worth knowing before switching: + +- **The similarity range changes.** `"botsort"` returns values in `[0, 1]`; `"adaptive"` returns `[0, 1 + reid_config.appearance_weight + reid_config.adaptive_weight_cap]`, which is `[0, 2.25]` at the defaults. Two thresholds are applied to that fused value: `minimum_iou_threshold_first_assoc`, which matches detections to confirmed and lost tracks, and `minimum_iou_threshold_unconfirmed_assoc`, which matches what is left to tracks that are not confirmed yet. Both are a different gate under the other method, so retune them when you switch, or the comparison measures the gate rather than the fusion. `minimum_iou_threshold_second_assoc` is unaffected, because that step matches on IoU alone. +- **The weights are domain-dependent.** The Deep OC-SORT paper uses `reid_config.appearance_weight=0.75` with `reid_config.adaptive_weight_cap=0.5` for MOT17, and `1.25` with `1.0` for DanceTrack, where dancers occlude constantly and geometry carries less. The defaults here follow the MOT17 pair. + +With the gate open, `adaptive` keeps IDs more stable than `"botsort"`, which switches between look-alike candidates from one frame to the next. `"botsort"` stays the default because it is the safer choice: appearance is only used below `reid_config.appearance_threshold`, and turning it on does not require retuning the other thresholds. --- ## Choosing an appearance threshold -BoT-SORT fuses costs as `min(d_iou, d_app)` with `d_app = 0.5 * (1 - cos_sim)`, and discards the appearance term when `d_app` exceeds `reid_config.appearance_threshold` (paper default 0.25) or when the pair fails the `reid_config.proximity_threshold` IoU gate. Appearance can therefore only lower a pair's cost, never veto a geometric match. Pick $\theta$ on a labeled split with the encoder you will track with: +This threshold only applies to `reid_config.fusion="botsort"`; with `"adaptive"` it has no effect. + +`reid_config.appearance_threshold` is how different two crops may look and still count as the same target. Appearance is compared as a distance, `0.5 * (1 - cosine similarity)`, running from 0 for identical crops to 1 for opposite ones; the BoT-SORT paper uses 0.25. Below the threshold, looking alike makes the tracker more willing to pair the two; above it, the pair stands on box overlap alone. Appearance only ever lowers a pair's cost, never raises it, but the assignment weighs every pair at once, so a look-alike pair can still take a detection away from a track that merely overlapped it. + +The right value depends on the encoder and the footage, so measure it on labeled data: -1. Embed GT crops. -2. Histogram `d_app` for association-local pairs: same video only, with frame gap bounded by the lost-track horizon (default 30 frames). Positives are same-ID; negatives are different-ID that could co-compete. Sample both classes with the same per-sequence quota, otherwise one crowded sequence decides the answer. -3. Choose $\theta$ so most same-ID pairs fall below it and most different-ID pairs fall above it. +1. Embed the ground-truth crops with the encoder you will track with. +2. Compare pairs of crops that could meet during tracking: same video, at most the lost-track buffer apart (30 frames by default). Take the same number of pairs from each sequence, so one crowded video does not decide the result. +3. Pick the value that keeps most same-person pairs below it and lets few different-person pairs through. -All three steps ship with Trackers, so you can run them on your own footage. `extract_ground_truth_embeddings` reads any MOT-format dataset, meaning a `gt/gt.txt` and an `img1` folder per sequence, and returns each crop's embedding alongside the identity, frame and sequence it came from: +Trackers has a helper for each step. `extract_ground_truth_embeddings` reads any dataset in [MOT format](../evaluations/evaluate.md#data-format): ```python from trackers.core.reid import ( @@ -97,21 +149,30 @@ for threshold in (0.10, 0.20, 0.25): plot_appearance_distances(distances, thresholds={0.20: "selected", 0.25: "default"}) ``` -See the [ReID API reference](../api/reid.md#choosing-a-threshold) for the full signatures. The figures on this page were produced with these helpers on MOT17 val and SoccerNet test ground truth. To reproduce them end to end, from download to calibrated threshold, open the [ReID cookbook](https://colab.research.google.com/github/roboflow/trackers/blob/develop/docs/cookbooks/how-to-add-reid-to-trackers.ipynb) in Colab. +The [ReID API reference](../api/reid.md#choosing-a-threshold) lists the full signatures, and the [ReID cookbook](https://colab.research.google.com/github/roboflow/trackers/blob/develop/docs/cookbooks/how-to-add-reid-to-trackers.ipynb) runs the whole flow in Colab. The example below uses 5000 same-person and 10000 different-person pairs, 1 to 30 frames apart. -**MOT17 val, `fastreid_mot17_sbs50`.** Same-ID distances peak near 0 and different-ID near 0.4. On association-local GT crop pairs (5000 same-ID, 10000 different-ID, frame gap 1 to 30), $\theta = 0.2$ keeps 68% of same-ID pairs while passing 1.1% of different-ID pairs. Raising $\theta$ to the BoT-SORT default 0.25 recovers same-ID pairs (79%) but nearly triples the different-ID pairs it admits (2.9%), which is why 0.2 is the better operating point here ([MOT17 re-ID study](https://www-sop.inria.fr/members/Francois.Bremond/Postscript/Tomasz__SCCAI_2025.pdf) Table 8 uses the same threshold). +### MOT17 with an encoder trained on MOT17 + +Blue bars are pairs of the same person, red bars are pairs of different people. A good threshold sits where the blue bars end and the red ones start. `fastreid_mot17_sbs50` on MOT17 val keeps the two groups mostly apart, with some overlap between 0.2 and 0.35. ![FastReID MOT17 SBS on MOT17 val GT](../assets/reid/mot17-fastreid-appearance-distances.png) -**SoccerNet test, `osnet_x1_0_msmt17_combineall`.** A pedestrian encoder on soccer footage squeezes every distance into a narrow range: same-ID pairs peak near 0.05 and different-ID pairs near 0.20 (similar kits). The two shapes still separate, but the scale no longer matches the thresholds BoT-SORT was tuned with. On association-local GT crop pairs (5000 same-ID, 10000 different-ID, frame gap 1 to 30), $\theta = 0.2$ admits 96% of same-ID pairs but also 49% of different-ID pairs, and tracking stays flat against CMC-only. $\theta = 0.1$ holds different-ID pairs to 9%, yet appearance still assists a mix of correct and same-kit pairs and costs HOTA and IDF1 (see the SoccerNet table below). Calibrate $\theta$ on your own domain rather than carrying 0.2 or 0.25 across. +This is what `rates_at` prints for two thresholds: + +| `reid_config.appearance_threshold` | same-person pairs below | different-person pairs below | +| :--------------------------------- | :---------------------: | :--------------------------: | +| 0.20 | 68% | 1.1% | +| 0.25 (paper default) | 79% | 2.9% | + +Moving the threshold right accepts more same-person pairs, but it also starts letting different-person pairs through. We use 0.2, the same value as the [MOT17 re-ID study](https://www-sop.inria.fr/members/Francois.Bremond/Postscript/Tomasz__SCCAI_2025.pdf) (Table 8). -![OSNet MSMT17 on SoccerNet test GT](../assets/reid/soccernet-osnet-appearance-distances.png) +If the two groups overlap instead, no threshold separates them: any value that keeps most same-person pairs also lets many different-person pairs through. Check the [proximity threshold](#choosing-a-proximity-threshold) and [fusion method](#choosing-a-fusion-method) first, and consider an encoder fine-tuned on your footage, see [results](#results). --- ## How far the threshold carries -To choose `lost_track_buffer`, it helps to see how Re-ID behaves as the frame gap grows. A player's encoded appearance can change over time, so `sweep_frame_gap` samples labeled crop pairs across widening gap bands. It does not simulate dropped frames or occlusions. Plot the results with `plot_frame_gap_sweep`: +A histogram fixes one frame gap, so it only describes re-association over that horizon. Sweeping the gap shows how long a track can stay lost before appearance stops helping to re-find it. `sweep_frame_gap` repeats the sampling above across widening bands, and `plot_frame_gap_sweep` draws the result: ```python from trackers.core.reid import plot_frame_gap_sweep, sweep_frame_gap @@ -120,24 +181,237 @@ sweep = sweep_frame_gap(embeddings, ids, frame_ids, sequence_ids) plot_frame_gap_sweep(sweep, thresholds={0.20: "selected", 0.25: "default"}) ``` -On MOT17 val, the typical appearance difference between two different people stays near 0.41 even at gaps up to 240 frames. For the same person, it grows from 0.04 one frame apart to 0.20 at 16 to 30 frames and 0.28 at 121 to 240 frames. Smaller values mean the crops look more alike, so matching the same person gets harder as the gap grows. +On MOT17 val, pairs of different people stay about as far apart at every gap. Pairs of the same person drift apart as the gap grows, because pose, lighting and occlusion change, so a threshold that catches them one frame apart misses more of them after a long absence. -ROC AUC shows how well the encoder separates same-ID from different-ID pairs across all thresholds: 1.0 is perfect separation, while 0.5 is no better than chance. The shaded bands in the plot show where most pair scores fall; AUC uses every pair. The last two columns show the share of each group accepted at the 0.2 threshold. +![FastReID MOT17 SBS separability vs frame gap](../assets/reid/mot17-fastreid-appearance-distances-vs-gap.png) -| Frame gap | ROC AUC | same-ID below 0.2 | different-ID below 0.2 | -| :--------- | :-----: | :---------------: | :--------------------: | -| 1 | 0.998 | 98.0% | 1.7% | -| 2 to 5 | 0.987 | 87.6% | 1.6% | -| 6 to 15 | 0.957 | 67.4% | 1.1% | -| 16 to 30 | 0.929 | 51.4% | 1.1% | -| 31 to 60 | 0.899 | 39.8% | 0.9% | -| 61 to 120 | 0.865 | 31.8% | 0.8% | -| 121 to 240 | 0.854 | 28.7% | 0.8% | +From this we learn: -![FastReID MOT17 SBS separability vs frame gap](../assets/reid/mot17-fastreid-appearance-distances-vs-gap.png) +- **Check the threshold at the gap you care about.** A threshold that works one frame apart can fail after a lost track: at 0.2 it matches 98% of same-person pairs one frame apart, but only 51% across the default 30-frame `lost_track_buffer`. +- **Long gaps make people harder to tell apart.** The same person looks less like themselves after a long absence, so same-person and different-person distances overlap more: ROC AUC, explained below, falls from 0.998 at a 1-frame gap to 0.854 beyond 120 frames. With the threshold at 0.2, appearance stops recognizing most people after a long absence: only 29% of same-person pairs more than 120 frames apart are under 0.2, against 98% one frame apart. Raising the threshold to catch those also lets more different people through, so if you increase `lost_track_buffer`, check both rates at that gap before raising `reid_config.appearance_threshold`. + +??? info "How to read ROC AUC, and the numbers per frame gap" + + ROC AUC answers one question: if you pick one pair of crops of the same person and one pair of different people at random, how often is the same-person pair closer? 1.0 means always, so some threshold separates the two groups perfectly. 0.5 means a coin flip, so appearance tells you nothing. Unlike the two rates in the table, it does not depend on a chosen threshold. It is also not where the shaded bands overlap in the figure: those bands only span the 10th to 90th percentiles, so at a 1-frame gap they never touch even though the AUC is 0.998. + + How it is calculated: each frame-gap band gets its own sample of same-person and different-person pairs. For every threshold from 0 to 1, count the share of same-person pairs below it and the share of different-person pairs below it, and plot the first share against the second. That traces the ROC curve from (0, 0) to (1, 1), and ROC AUC is the area under it. `sweep_frame_gap` gets the same number without drawing the curve, by comparing every same-person distance with every different-person distance and counting how often the same-person one is smaller, with ties counting as half (`trackers.core.reid.roc_auc`). The result is the lower panel of the figure above. + + The last two columns show the two rates at a threshold of 0.2. + + | Frame gap | ROC AUC | same-ID below 0.2 | different-ID below 0.2 | + | :--------- | :-----: | :---------------: | :--------------------: | + | 1 | 0.998 | 98.0% | 1.7% | + | 2 to 5 | 0.987 | 87.6% | 1.6% | + | 6 to 15 | 0.957 | 67.4% | 1.1% | + | 16 to 30 | 0.929 | 51.4% | 1.1% | + | 31 to 60 | 0.899 | 39.8% | 0.9% | + | 61 to 120 | 0.865 | 31.8% | 0.8% | + | 121 to 240 | 0.854 | 28.7% | 0.8% | + +--- -Two things follow. First, a threshold validated on adjacent frames says little about re-association: at $\theta = 0.2$ appearance helps 98% of same-ID pairs one frame apart but only 51% across the default 30-frame lost-track buffer. Second, the price of a tight $\theta$ over long gaps is missed re-associations rather than extra wrong ones, because the different-ID rate stays near 1% throughout. If you raise `lost_track_buffer` to recover tracks after long occlusions, raise `reid_config.appearance_threshold` with it and re-check the different-ID column. +## Choosing a proximity threshold -The cross-domain encoder fails differently. On SoccerNet the different-ID rate at $\theta = 0.2$ stays between 44% and 51% at every gap, so the frame gap is not what limits it; the encoder simply cannot separate players in matching kits at any horizon. Widening the gap costs same-ID pairs (99.6% down to 87.0%) without ever making the different-ID side usable, which is why $\theta$ has to come down to about 0.1 on this domain instead of being traded against the gap. +`reid_config.proximity_threshold` decides which track-detection pairs appearance is allowed to score. A pair is dropped before appearance is consulted whenever `1 - IoU` exceeds the threshold, so the 0.5 default limits appearance to pairs that already overlap at `IoU >= 0.5`, and 0.99 still requires `IoU >= 0.01`. Only 1.0 disables the gate. + +While a track is lost it has no detection of its own, so it is matched through its estimated position, which keeps moving as if the target had carried on. After a short occlusion that estimate has drifted, so it barely overlaps the detection when the target reappears. After the target leaves the frame it drifts out of view entirely and the overlap drops to zero. The 0.5 default blocks appearance in both cases, 0.99 lets it score the first one, and only 1.0 lets it score the second. BoT-SORT has no separate step for targets that leave and return, so opening the gate is the only way to keep their ID. + +SoccerNet test, oracle detections, `osnet_x1_0_soccernet`, library-default motion parameters, `reid_config.fusion="adaptive"` with its default weights: + +| `reid_config.proximity_threshold` | appearance consulted when | HOTA | IDF1 | ID switches | +| :-------------------------------- | :------------------------ | :---: | :---: | :---------: | +| none (no ReID) | | 84.56 | 79.35 | 2939 | +| 0.5 (default) | `IoU >= 0.50` | 84.52 | 79.27 | 3012 | +| 0.8 | `IoU >= 0.20` | 85.02 | 79.71 | 3268 | +| 1.0 | always | 85.46 | 81.18 | 1734 | + + +

SoccerNet SNMOT-147, top to bottom: gate 0.5, 0.8 and 1.0. The player who leaves the frame gets a new ID at 0.5 and 0.8, and keeps it at 1.0.

+ +HOTA and IDF1 improve as the gate opens, but ID switches do not follow the same curve. At 0.8 they are higher than with the default gate or without ReID: appearance can now match more distant boxes, but not yet the ones that recover a lost track. With the gate fully open they drop below both. If you open the gate, open it all the way. + +The right value depends on the footage. Use 1.0 when targets leave the frame or the camera moves a lot. Keep 0.5 when targets stay in view, as in DanceTrack: there an open gate only adds wrong matches between people who look alike. + +--- + +## Tuning the thresholds + +The steps above give a good starting point. `trackers tune` then searches `reid_config.appearance_threshold` and `reid_config.proximity_threshold` together, keeping the motion parameters you pass fixed, see [Tune ReID Thresholds](tune.md#tune-reid-thresholds). The command tunes the `"botsort"` thresholds; for `"adaptive"` it does not search the weights yet. + +--- -![OSNet MSMT17 separability vs frame gap](../assets/reid/soccernet-osnet-appearance-distances-vs-gap.png) +## Results + +BoT-SORT with and without ReID on the test splits, using the same detections and motion parameters. The ReID thresholds were tuned on a separate split first, and each configuration was then evaluated once on test. The best value for each dataset is in bold. Motion parameters are the tuned values from the [tracker comparison](../evaluations/results.md). + + + +=== "Fine-tuned encoder" + + `osnet_x1_0` fine-tuned on each dataset's train split (`osnet_x1_0_mot17`, `osnet_x1_0_sportsmot`, `osnet_x1_0_dancetrack`, `osnet_x1_0_soccernet`), with `reid_config.fusion="botsort"` and `reid_config.fusion="adaptive"`. + + | Dataset | Config | HOTA | IDF1 | MOTA | + | :--------: | :-------------------------- | :------: | :------: | :------: | + | MOT17 | BoT-SORT | 63.8 | 78.7 | **79.4** | + | | BoT-SORT + ReID | **64.0** | 78.9 | **79.4** | + | | BoT-SORT + ReID, `adaptive` | **64.0** | **79.2** | **79.4** | + | SportsMOT | BoT-SORT | 74.1 | 74.1 | 96.9 | + | | BoT-SORT + ReID | **77.8** | **78.5** | 96.9 | + | | BoT-SORT + ReID, `adaptive` | 76.4 | 76.8 | **97.0** | + | DanceTrack | BoT-SORT | 57.8 | 57.9 | 92.2 | + | | BoT-SORT + ReID | **60.8** | **61.6** | 91.7 | + | | BoT-SORT + ReID, `adaptive` | 58.3 | 58.2 | **92.3** | + | SoccerNet | BoT-SORT | 85.0 | 79.7 | 97.2 | + | | BoT-SORT + ReID | 87.3 | 83.2 | 98.7 | + | | BoT-SORT + ReID, `adaptive` | **88.4** | **84.4** | **99.3** | + + ??? info "Tuned ReID configuration" + + Tuned ReID configuration for each dataset and fusion method. Motion parameters are the BoT-SORT values from the [tracker comparison](../evaluations/results.md), except `minimum_iou_threshold_first_assoc` where listed. + + ```yaml + MOT17: + botsort: + reid_model: osnet_x1_0_mot17 + reid_config.appearance_threshold: 0.0448 + reid_config.proximity_threshold: 0.2697 + adaptive: + reid_model: osnet_x1_0_mot17 + reid_config.appearance_weight: 2.124 + reid_config.adaptive_weight_cap: 0.959 + reid_config.appearance_floor: 0.569 + reid_config.proximity_threshold: 0.342 + minimum_iou_threshold_first_assoc: 0.205 + + SportsMOT: + botsort: + reid_model: osnet_x1_0_sportsmot + reid_config.appearance_threshold: 0.3425 + reid_config.proximity_threshold: 0.9953 + adaptive: + reid_model: osnet_x1_0_sportsmot + reid_config.appearance_weight: 1.290 + reid_config.adaptive_weight_cap: 0.718 + reid_config.appearance_floor: 0.510 + reid_config.proximity_threshold: 0.983 + minimum_iou_threshold_first_assoc: 0.329 + + DanceTrack: + botsort: + reid_model: osnet_x1_0_dancetrack + reid_config.appearance_threshold: 0.4716 + reid_config.proximity_threshold: 0.6802 + adaptive: + reid_model: osnet_x1_0_dancetrack + reid_config.appearance_weight: 1.947 + reid_config.adaptive_weight_cap: 0.944 + reid_config.appearance_floor: 0.614 + reid_config.proximity_threshold: 0.690 + minimum_iou_threshold_first_assoc: 0.946 + + SoccerNet: + botsort: + reid_model: osnet_x1_0_soccernet + reid_config.appearance_threshold: 0.075 + reid_config.proximity_threshold: 1.0 + adaptive: + reid_model: osnet_x1_0_soccernet + reid_config.appearance_weight: 0.75 + reid_config.adaptive_weight_cap: 0.5 + reid_config.proximity_threshold: 1.0 + reid_config.appearance_floor: 0.8 + ``` + + Every configuration was tuned with `trackers tune` on its own tuning split¹, 20 trials per fusion method, then evaluated once on test. + + ¹ SoccerNet-tracking has no validation split. This encoder was trained on the first 45 of the 57 train sequences, so its thresholds were tuned on the other 12 (SNMOT-159 to SNMOT-170). The other datasets tune on val, MOT17 on val-half. + + Fine-tuning improves on BoT-SORT without ReID on every dataset: on test it adds 3.67 HOTA on SportsMOT, 3.43 on SoccerNet with `adaptive`, 3.00 on DanceTrack and 0.20 on MOT17. + + Each encoder is one training run, and training is not deterministic across seeds: two SportsMOT encoders trained identically apart from the seed scored 83.50 and 83.72 on val, and on DanceTrack the spread across three seeds reached 1.54 HOTA. Treat differences below a point between fine-tuned rows as noise. The SportsMOT encoder is the better of its two seeds, chosen on val; `crops_per_identity=8` is carried over from DanceTrack rather than tuned per dataset. + +=== "Generic encoder" + + Encoders used as published, without training on your data. `fastreid_mot17_sbs50` was trained on MOT17 by the BoT-SORT authors, so its MOT17 rows are in domain; on the other datasets it has not seen the footage, and it still helps on SportsMOT. Both fusion methods were tuned with `trackers tune`, 20 trials each, on each dataset's tuning split². + + | Dataset | Config | HOTA | IDF1 | MOTA | + | :--------: | :-------------------------- | :------: | :------: | :------: | + | MOT17 | BoT-SORT | 63.8 | 78.7 | **79.4** | + | | BoT-SORT + ReID | 63.8 | 78.8 | **79.4** | + | | BoT-SORT + ReID, `adaptive` | **64.6** | **79.9** | **79.4** | + | SportsMOT | BoT-SORT | 74.1 | 74.1 | **96.9** | + | | BoT-SORT + ReID | **75.5** | **75.5** | **96.9** | + | | BoT-SORT + ReID, `adaptive` | 74.1 | 73.7 | **96.9** | + | DanceTrack | BoT-SORT | **57.8** | **57.9** | 92.2 | + | | BoT-SORT + ReID | 57.6 | 57.6 | 92.1 | + | | BoT-SORT + ReID, `adaptive` | 57.5 | 57.3 | **92.3** | + | SoccerNet | BoT-SORT | 85.0 | 79.7 | 97.2 | + | | BoT-SORT + ReID | 85.0 | 79.7 | 97.2 | + | | BoT-SORT + ReID, `adaptive` | **85.9** | **80.4** | **97.9** | + + ??? info "Tuned ReID configuration" + + Tuned ReID configuration for each dataset. Motion parameters are the BoT-SORT values from the [tracker comparison](../evaluations/results.md). + + ```yaml + MOT17: + botsort: + reid_model: fastreid_mot17_sbs50 + reid_config.appearance_threshold: 0.10 + reid_config.proximity_threshold: 0.5 + adaptive: + reid_model: fastreid_mot17_sbs50 + reid_config.appearance_weight: 1.908 + reid_config.adaptive_weight_cap: 0.545 + reid_config.appearance_floor: 0.381 + reid_config.proximity_threshold: 0.772 + minimum_iou_threshold_first_assoc: 0.846 + + SportsMOT: + botsort: + reid_model: fastreid_mot17_sbs50 + reid_config.appearance_threshold: 0.4761 + reid_config.proximity_threshold: 0.5051 + adaptive: + reid_model: fastreid_mot17_sbs50 + reid_config.appearance_weight: 2.540 + reid_config.adaptive_weight_cap: 0.778 + reid_config.appearance_floor: 0.783 + reid_config.proximity_threshold: 0.216 + minimum_iou_threshold_first_assoc: 0.176 + + DanceTrack: + botsort: + reid_model: fastreid_mot17_sbs50 + reid_config.appearance_threshold: 0.4822 + reid_config.proximity_threshold: 0.5068 + adaptive: + reid_model: fastreid_mot17_sbs50 + reid_config.appearance_weight: 0.75 + reid_config.adaptive_weight_cap: 0.5 + reid_config.appearance_floor: 0.0 + reid_config.proximity_threshold: 0.5 + + SoccerNet: + botsort: + reid_model: fastreid_mot17_sbs50 + reid_config.appearance_threshold: 0.0467 + reid_config.proximity_threshold: 0.3511 + adaptive: + reid_model: fastreid_mot17_sbs50 + reid_config.appearance_weight: 1.178 + reid_config.adaptive_weight_cap: 0.621 + reid_config.appearance_floor: 0.195 + reid_config.proximity_threshold: 0.975 + minimum_iou_threshold_first_assoc: 0.349 + ``` + + ² SoccerNet-tracking has no validation split, so its tuning split is train. The other datasets tune on val, MOT17 on val-half. + + On test, a generic encoder adds HOTA on MOT17 with `adaptive` (+0.72), on SportsMOT with `botsort` (+1.31) and on SoccerNet with `adaptive` (+0.89), and stays below BoT-SORT on DanceTrack. On SoccerNet only the additive rule helps; under `botsort` the same encoder is flat, because the gate it was tuned to keeps appearance away from the pairs that would benefit. + + On MOT17 the tuning split preferred `botsort` by 0.10 HOTA, while `adaptive` scored 0.73 higher on test, the best MOT17 result on this page. + + SoccerNet uses ground-truth boxes as detections, so its numbers are not comparable to the YOLOX rows. diff --git a/docs/index.md b/docs/index.md index 144799c3f..c80f1a8a6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ --- title: Object Tracking in Python comments: false -description: Get started with Roboflow Trackers — install SORT, ByteTrack, OC-SORT, BoT-SORT, C-BIoU, and McByte, run your first tracking pipeline, and evaluate results with HOTA, IDF1, and MOTA metrics. +description: Get started with Roboflow Trackers — install SORT, ByteTrack, OC-SORT, C-BIoU, BoT-SORT, and McByte, run your first tracking pipeline, and evaluate results with HOTA, IDF1, and MOTA metrics. ---
@@ -121,20 +121,23 @@ For the full evaluation workflow, see the [evaluation guide](evaluations/evaluat Clean, modular implementations of leading trackers. All HOTA scores use default parameters. - + -| Algorithm | Description | MOT17 HOTA | SportsMOT HOTA | SoccerNet HOTA | DanceTrack HOTA | -| :-------------------------------------------: | :---------------------------------------------------------------------: | :--------: | :------------: | :------------: | :-------------: | -| [SORT](https://arxiv.org/abs/1602.00763) | Kalman filter + Hungarian matching baseline. | 58.4 | 70.8 | 81.6 | 47.2 | -| [ByteTrack](https://arxiv.org/abs/2110.06864) | Two-stage association using high and low confidence detections. | 60.1 | 73.0 | 84.0 | 53.3 | -| [OC-SORT](https://arxiv.org/abs/2203.14360) | Observation-centric recovery for lost tracks. | 61.9 | 71.7 | 78.4 | 54.1 | -| [BoT-SORT](https://arxiv.org/abs/2206.14651) | Camera motion compensation. | 63.7 | 73.8 | 84.5 | 57.8 | -| [C-BIoU](https://arxiv.org/abs/2211.14317) | Cascaded buffered IoU matching for fast or irregular motion. | 63.0 | 73.1 | 82.6 | 56.7 | -| [McByte](trackers/mcbyte.md) | Mask-conditioned tracking — adds propagated SAM/Cutie masks as a cue.\* | **64.1** | **76.5** | **85.0** | **67.2** | +| Algorithm | Description | MOT17 HOTA | SportsMOT HOTA | SoccerNet HOTA | DanceTrack HOTA | +| :-------------------------------------------: | :-----------------------------------------------------------------------: | :--------: | :------------: | :------------: | :-------------: | +| [SORT](https://arxiv.org/abs/1602.00763) | Kalman filter + Hungarian matching baseline. | 58.4 | 70.8 | 81.6 | 47.2 | +| [ByteTrack](https://arxiv.org/abs/2110.06864) | Two-stage association using high and low confidence detections. | 60.1 | 73.0 | 84.0 | 53.3 | +| [OC-SORT](https://arxiv.org/abs/2203.14360) | Observation-centric recovery for lost tracks. | 61.9 | 71.7 | 78.4 | 54.1 | +| [C-BIoU](https://arxiv.org/abs/2211.14317) | Cascaded buffered IoU matching for fast or irregular motion. | 63.0 | 73.1 | 82.6 | 56.7 | +| [BoT-SORT](https://arxiv.org/abs/2206.14651) | Camera motion compensation. | 63.7 | 73.8 | 84.5 | 57.8 | +| [BoT-SORT + ReID](guides/reid.md) | Appearance embeddings fused with motion, for harder association.\* | 63.9 | 75.5 | 84.3 | 57.0 | +| [McByte](trackers/mcbyte.md) | Mask-conditioned tracking — adds propagated SAM/Cutie masks as a cue.\*\* | **64.1** | **76.5** | **85.0** | **67.2** | !!! note - \*McByte needs optional heavyweight deps (`torch`, SAM, Cutie) not installed by default. It tops HOTA on all four benchmarks above — see the [McByte docs](trackers/mcbyte.md) for setup. + \*BoT-SORT + ReID is listed with library default parameters and the default encoder `fastreid_mot17_sbs50` (BoT-SORT's own ReID model, trained on MOT17) on all four datasets. At its best tuned configuration on each dataset, it reaches 64.6 (MOT17), 77.8 (SportsMOT), 88.4 (SoccerNet) and 60.8 (DanceTrack) HOTA on the test sets, above BoT-SORT without ReID on all four. See the [ReID guide](guides/reid.md). + + \*\*McByte needs optional heavyweight deps (`torch`, SAM, Cutie) not installed by default. It tops HOTA on all four benchmarks above. See the [McByte docs](trackers/mcbyte.md) for setup. For detailed benchmarks and tuned configurations, see the [tracker comparison](evaluations/results.md). diff --git a/docs/javascripts/grouped_tables.js b/docs/javascripts/grouped_tables.js new file mode 100644 index 000000000..8bd3caba5 --- /dev/null +++ b/docs/javascripts/grouped_tables.js @@ -0,0 +1,26 @@ +/** + * Grouped tables, such as the ReID results: the first row of each group names it in the first + * column and the rows below leave that cell empty. Merge each name into one cell spanning its + * group, and mark where each group starts so the stylesheet can draw a rule above it. + */ +document.addEventListener("DOMContentLoaded", function () { + document.querySelectorAll(".md-typeset table").forEach(function (table) { + const body = table.tBodies[0]; + if (!body || body.rows.length === 0) return; + const rows = Array.from(body.rows); + const isBlank = (row) => row.cells[0] && row.cells[0].textContent.trim() === ""; + if (isBlank(rows[0]) || !rows.some(isBlank)) return; + + let nameCell = null; + rows.forEach(function (row) { + if (isBlank(row)) { + nameCell.rowSpan += 1; + row.cells[0].remove(); + } else { + nameCell = row.cells[0]; + row.classList.add("group-start"); + } + }); + table.setAttribute("data-grouped", ""); + }); +}); diff --git a/docs/overrides/stylesheets/style.css b/docs/overrides/stylesheets/style.css index 4d6bebe1d..410fed62c 100644 --- a/docs/overrides/stylesheets/style.css +++ b/docs/overrides/stylesheets/style.css @@ -31,3 +31,13 @@ th, td { .md-typeset__table table:not([class]) { font-size: 0.6rem; } + +/* Grouped tables, such as the ReID results (see javascripts/grouped_tables.js): each group's name + spans its rows, vertically centred, and a heavier rule marks where each group starts. */ +.md-typeset table[data-grouped] tr.group-start > td { + border-top: 2px solid var(--md-default-fg-color--light); +} + +.md-typeset table[data-grouped] td[rowspan] { + vertical-align: middle; +} diff --git a/docs/trackers/botsort.md b/docs/trackers/botsort.md index 16bb174c9..0f56a0a3c 100644 --- a/docs/trackers/botsort.md +++ b/docs/trackers/botsort.md @@ -54,7 +54,7 @@ BoT-SORT keeps the same tracking-by-detection backbone as [ByteTrack](bytetrack. ## ReID appearance (optional) -BoT-SORT can fuse appearance embeddings with IoU during association via an optional `reid_model`. Install, usage, parameters, and MOT17 with/without ReID scores are on the [ReID appearance](../guides/reid.md) page. +BoT-SORT can fuse appearance embeddings with IoU during association via an optional `reid_model`. For ReID numbers go to the [ReID guide](../guides/reid.md#results), which holds the tables for both fusion rules and best configuration per dataset. Also learn how to install, use and choose parameters on the same page. ## Run on video, webcam, or RTSP stream diff --git a/mkdocs.yml b/mkdocs.yml index 6a0825e24..fc230eda8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -63,6 +63,7 @@ extra_javascript: - javascripts/mathjax.js - javascripts/cli_builder_framework.js - javascripts/command_builder.js + - javascripts/grouped_tables.js markdown_extensions: # Adds support for card grids diff --git a/src/trackers/core/botsort/tracker.py b/src/trackers/core/botsort/tracker.py index 8681e2c18..27ab2b6d1 100644 --- a/src/trackers/core/botsort/tracker.py +++ b/src/trackers/core/botsort/tracker.py @@ -18,7 +18,10 @@ from trackers.core.reid.config import ReIDConfig from trackers.core.reid.encoder import ReIDEncoder from trackers.core.reid.feature_bank import FeatureBank -from trackers.core.reid.fusion import fuse_botsort_reid_association +from trackers.core.reid.fusion import ( + fuse_adaptive_reid_association, + fuse_botsort_reid_association, +) from trackers.utils.cmc import CMC, CMCConfig, CMCMethod from trackers.utils.detections import default_confidences from trackers.utils.iou import BaseIoU, IoU @@ -97,9 +100,11 @@ class BoTSORTTracker(BaseTracker): association. Pass a ``reid.ReIDModel`` in normal use. Requires ``frame`` in :meth:`update`. When ``None`` (default), behaviour matches the geometry-only BoT-SORT baseline. - reid_config: Appearance association settings. Defaults to + reid_config: Appearance association and fusion settings. Defaults to :class:`ReIDConfig`. Passing it without ``reid_model`` raises - ``ValueError``; the encoder is supplied separately. + ``ValueError``; the encoder is supplied separately. The default + appearance threshold is ``0.25`` with ``fusion="botsort"``; the + adaptive appearance floor defaults to ``0.0`` (disabled). Notes: - Positive `maximum_frames_without_update` values are scaled by @@ -525,7 +530,7 @@ def _association_similarity( scores: np.ndarray, embeddings: np.ndarray | None, ) -> np.ndarray: - """Score-fused association similarity, with optional BoT-SORT ReID fusion.""" + """Score-fused association similarity, with optional ReID fusion.""" iou_sim_raw = self.iou.normalize_for_fusion(self._get_iou_matrix(tracklets, boxes, tracklet_boxes_by_id)) iou_sim_fused = _fuse_score(iou_sim_raw, scores) if embeddings is None or len(tracklets) == 0: @@ -537,9 +542,20 @@ def _association_similarity( if isinstance(self.iou, IoU) else self._get_iou_matrix(tracklets, boxes, tracklet_boxes_by_id, metric=IoU()) ) + appearance = appearance_similarity(track_feats, embeddings, det_embeddings_normalized=True) + if self.reid_config.fusion == "adaptive": + return fuse_adaptive_reid_association( + iou_sim_fused, + appearance, + proximity_iou_similarity=proximity_iou, + reid_proximity_threshold=self.reid_config.proximity_threshold, + reid_appearance_weight=self.reid_config.appearance_weight, + reid_adaptive_weight_cap=self.reid_config.adaptive_weight_cap, + reid_appearance_floor=self.reid_config.appearance_floor, + ) return fuse_botsort_reid_association( iou_sim_fused, - appearance_similarity(track_feats, embeddings, det_embeddings_normalized=True), + appearance, proximity_iou_similarity=proximity_iou, reid_proximity_threshold=self.reid_config.proximity_threshold, reid_appearance_threshold=self.reid_config.appearance_threshold, diff --git a/src/trackers/core/reid/config.py b/src/trackers/core/reid/config.py index 32c35704c..19f57c691 100644 --- a/src/trackers/core/reid/config.py +++ b/src/trackers/core/reid/config.py @@ -8,8 +8,11 @@ from __future__ import annotations +import math from dataclasses import dataclass +from trackers.core.reid.fusion import ReidFusionMethod + @dataclass(frozen=True) class ReIDConfig: @@ -21,28 +24,43 @@ class ReIDConfig: Attributes: ema_alpha: EMA momentum for track appearance features. Default ``0.9``. - appearance_threshold: Appearance distance gate. Drops the appearance term - when the halved cosine distance ``0.5 * (1 - cos_sim)`` exceeds this - value, leaving the pair scored on geometry alone. Default ``0.25`` - (BoT-SORT ``appearance_thresh``). + appearance_threshold: Maximum appearance distance used by ``"botsort"``. + Default ``0.25`` (BoT-SORT ``appearance_thresh``). Ignored by + ``"adaptive"``. proximity_threshold: IoU distance gate applied before appearance is used. Default ``0.5`` (BoT-SORT ``proximity_thresh``; requires ``IoU >= 1 - proximity_threshold``). + fusion: How appearance combines with geometry. ``"botsort"`` takes the + minimum cost; ``"adaptive"`` adds weighted appearance similarity. + appearance_weight: Base appearance weight for adaptive fusion. + adaptive_weight_cap: Maximum adaptive appearance bonus. + appearance_floor: Minimum cosine similarity for appearance to + contribute with ``"adaptive"``. Default ``0.0`` (disabled). Raises: - ValueError: If any field falls outside ``[0, 1]``. + ValueError: If a setting is outside its valid range. Example: - >>> ReIDConfig(appearance_threshold=0.3) - ReIDConfig(ema_alpha=0.9, appearance_threshold=0.3, proximity_threshold=0.5) + >>> ReIDConfig(appearance_threshold=0.3).appearance_threshold + 0.3 """ ema_alpha: float = 0.9 appearance_threshold: float = 0.25 proximity_threshold: float = 0.5 + fusion: ReidFusionMethod = "botsort" + appearance_weight: float = 0.75 + adaptive_weight_cap: float = 0.5 + appearance_floor: float = 0.0 def __post_init__(self) -> None: - for name in ("ema_alpha", "appearance_threshold", "proximity_threshold"): + for name in ("ema_alpha", "appearance_threshold", "proximity_threshold", "appearance_floor"): value = getattr(self, name) if not 0.0 <= value <= 1.0: raise ValueError(f"{name} must be in [0, 1], got {value}") + if self.fusion not in ("botsort", "adaptive"): + raise ValueError(f"Unknown fusion {self.fusion!r}. Valid options are: ('botsort', 'adaptive').") + for name in ("appearance_weight", "adaptive_weight_cap"): + value = getattr(self, name) + if not math.isfinite(value) or value < 0.0: + raise ValueError(f"{name} must be finite and non-negative, got {value}") diff --git a/src/trackers/core/reid/fusion.py b/src/trackers/core/reid/fusion.py index 23b389f40..b6cc6a6ef 100644 --- a/src/trackers/core/reid/fusion.py +++ b/src/trackers/core/reid/fusion.py @@ -8,6 +8,12 @@ # Copyright (c) 2022 Nir Aharon # Source: https://github.com/NirAharon/BoT-SORT # Reference: tracker/bot_sort.py (ReID appearance-IoU cost fusion) +# +# Adapted from GerardMaggiolino/Deep-OC-SORT (MIT) +# Copyright (c) 2023 Gerard Maggiolino +# Source: https://github.com/GerardMaggiolino/Deep-OC-SORT +# Reference: trackers/integrated_ocsort_embedding/association.py +# (compute_aw_new_metric, adaptive appearance weighting) # ------------------------------------------------------------------------ """Appearance-IoU fusion methods for ReID association. @@ -18,8 +24,12 @@ from __future__ import annotations +from typing import Literal + import numpy as np +ReidFusionMethod = Literal["botsort", "adaptive"] + def fuse_botsort_reid_association( association_similarity: np.ndarray, @@ -65,3 +75,118 @@ def fuse_botsort_reid_association( d_app = np.where(d_iou_proximity > reid_proximity_threshold, 1.0, d_app) fused_cost = np.minimum(d_iou, d_app) return 1.0 - fused_cost + + +def _top_two_margin(similarity: np.ndarray, axis: int, cap: float) -> np.ndarray: + """Margin between the best and second-best similarity along ``axis``, capped. + + A large margin means the best candidate stands clear of the rest, so appearance is discriminative for that row or + column. Zero when there are fewer than two candidates to compare. + """ + if similarity.shape[axis] < 2: + return np.zeros(similarity.shape[1 - axis], dtype=float) + partitioned = np.partition(similarity, -2, axis=axis) + best = np.take(partitioned, -1, axis=axis) + runner_up = np.take(partitioned, -2, axis=axis) + return np.minimum(best - runner_up, cap) + + +def fuse_adaptive_reid_association( + association_similarity: np.ndarray, + appearance_similarity: np.ndarray, + *, + reid_appearance_weight: float, + reid_adaptive_weight_cap: float, + reid_proximity_threshold: float, + reid_appearance_floor: float = 0.0, + proximity_iou_similarity: np.ndarray | None = None, +) -> np.ndarray: + """Fuse IoU and appearance with Deep OC-SORT's adaptive weighting. + + Adds a weighted appearance term to the geometric similarity instead of + taking a minimum. The weight grows when the best appearance match stands + clear of the runner-up and falls back to ``reid_appearance_weight`` when + the top candidates are hard to tell apart. + + This is the adaptive appearance weighting on its own, not the full Deep + OC-SORT association cost: the geometric term is whatever the caller + passes (BoT-SORT's score-fused IoU in this library, another tracker's + similarity elsewhere), and the velocity-direction term of the reference + cost is left out, since it belongs to OC-SORT's motion model. + Implements equations (4)-(6) of the Deep OC-SORT paper, matching + ``compute_aw_new_metric`` in the authors' ``integrated_ocsort_embedding`` + tracker, the variant behind their published results. + + ``proximity_iou_similarity`` is the standard-IoU gate (defaults to + ``association_similarity``). Pass it separately when association uses + GIoU/DIoU/CIoU so proximity still uses plain IoU. + + Args: + association_similarity: Geometry-based track-detection similarities with + shape ``(T, N)``. + appearance_similarity: Cosine similarities for the same pairs with shape + ``(T, N)``. + reid_appearance_weight: Base appearance weight, ``a_w`` in the paper. + The authors report ``0.75`` for MOT17 and MOT20 and ``1.25`` for + DanceTrack. + reid_adaptive_weight_cap: Upper bound on the adaptive bonus, ``epsilon`` + in the paper. The authors report ``0.5`` for MOT17 and MOT20 and + ``1.0`` for DanceTrack. + reid_proximity_threshold: Maximum standard-IoU distance at which + appearance may contribute. + reid_appearance_floor: Minimum cosine similarity for appearance to + contribute; below it the pair is scored on geometry alone. Default + ``0.0`` (disabled, the reference behaviour). + proximity_iou_similarity: Standard-IoU similarities with shape ``(T, N)``. + Defaults to ``association_similarity``. + + Returns: + Fused track-detection similarities with shape ``(T, N)``, spanning + ``[0, 1 + reid_appearance_weight + reid_adaptive_weight_cap]``. + Association thresholds tuned against the BoT-SORT fusion do not carry + over and need retuning. + + Notes: + Negative cosine similarities are clamped to zero, so appearance can + only ever raise a similarity. + + Geometric plausibility is enforced before fusion rather than after + matching. The reference fuses appearance ungated, runs the assignment, + then discards any matched pair whose raw IoU is below its IoU + threshold. BoT-SORT has no such post-match check: its association + threshold is applied to the fused similarity, which a zero-IoU pair + can clear on appearance alone. ``reid_proximity_threshold`` is where + that check lives here; ``1.0`` disables it, which is what lets a + track be recovered after leaving the frame, since its prediction no + longer overlaps the returning detection. The gate applies only to the + appearance term: the margin is measured over all candidates, as in + the reference, so a candidate excluded on geometry cannot read as + similarity ``0`` and widen the margin. + + The velocity-direction term of the full Deep OC-SORT cost is not + included; it belongs to OC-SORT's motion model. + + ``reid_appearance_floor`` is an extension over the reference, which has + no appearance gate at all. Because the fusion is additive, any positive + cosine similarity raises the fused score above the association + threshold once geometry contributes nothing, so with the proximity gate + disabled a lost track can capture an unrelated detection on a weak + appearance match instead of letting it start a new track. The floor + zeroes the appearance term for such pairs, leaving them scored on + geometry alone. Like the proximity gate, it applies only to the + appearance term: margins are still measured over all candidates. + """ + if proximity_iou_similarity is None: + proximity_iou_similarity = association_similarity + + appearance = np.clip(appearance_similarity, 0.0, None) + + track_margin = _top_two_margin(appearance, axis=1, cap=reid_adaptive_weight_cap) + detection_margin = _top_two_margin(appearance, axis=0, cap=reid_adaptive_weight_cap) + adaptive_bonus = (track_margin[:, np.newaxis] + detection_margin[np.newaxis, :]) / 2.0 + + out_of_range = (1.0 - proximity_iou_similarity) > reid_proximity_threshold + gated_appearance = np.where(out_of_range, 0.0, appearance) + gated_appearance = np.where(appearance < reid_appearance_floor, 0.0, gated_appearance) + + return association_similarity + (reid_appearance_weight + adaptive_bonus) * gated_appearance diff --git a/tests/cli/test_track.py b/tests/cli/test_track.py index 6145137a9..7ba96abf6 100644 --- a/tests/cli/test_track.py +++ b/tests/cli/test_track.py @@ -555,6 +555,27 @@ def test_appearance_config_parses_from_cli(self) -> None: args = parser.instantiate(parser.parse_args(["--tracker.reid_config.appearance_threshold", "0.2"])) assert args.tracker.reid_config == ReIDConfig(appearance_threshold=0.2) + def test_adaptive_config_parses_from_cli(self) -> None: + parser = _CLIParser() + _add_track_arguments(parser) + args = parser.instantiate( + parser.parse_args( + [ + "--tracker.reid_config.fusion", + "adaptive", + "--tracker.reid_config.appearance_weight", + "1.25", + "--tracker.reid_config.adaptive_weight_cap", + "1.0", + "--tracker.reid_config.appearance_floor", + "0.8", + ] + ) + ) + assert args.tracker.reid_config == ReIDConfig( + fusion="adaptive", appearance_weight=1.25, adaptive_weight_cap=1.0, appearance_floor=0.8 + ) + class TestInitTrackerDetectedFrameRate: """``detected_frame_rate`` should seed ``frame_rate`` unless the CLI already overrode it. diff --git a/tests/cli/test_tune.py b/tests/cli/test_tune.py index c147455d9..47569a57b 100644 --- a/tests/cli/test_tune.py +++ b/tests/cli/test_tune.py @@ -130,7 +130,15 @@ def test_encoder_and_search_space_reach_the_tuner_and_encoder_is_left_out_of_out assert tuner_class.call_args.kwargs["fixed_defaults"] == ["lost_track_buffer"] assert tuner_class.call_args.kwargs["search_space"] == space assert json.loads(output_path.read_text()) == { - "reid_config": {"ema_alpha": 0.9, "appearance_threshold": 0.2, "proximity_threshold": 0.5} + "reid_config": { + "ema_alpha": 0.9, + "appearance_threshold": 0.2, + "proximity_threshold": 0.5, + "fusion": "botsort", + "appearance_weight": 0.75, + "adaptive_weight_cap": 0.5, + "appearance_floor": 0.0, + } } def test_returns_1_when_encoder_fails_to_load(self, tmp_path: Path) -> None: diff --git a/tests/core/test_botsort_reid.py b/tests/core/test_botsort_reid.py index 27a65bbbc..a13bf82b5 100644 --- a/tests/core/test_botsort_reid.py +++ b/tests/core/test_botsort_reid.py @@ -17,7 +17,11 @@ from trackers.core.botsort.tracker import BoTSORTTracker from trackers.core.reid.config import ReIDConfig -from trackers.core.reid.fusion import fuse_botsort_reid_association +from trackers.core.reid.fusion import ( + ReidFusionMethod, + fuse_adaptive_reid_association, + fuse_botsort_reid_association, +) def _detection(xyxy: tuple[float, float, float, float], conf: float = 0.9) -> sv.Detections: @@ -270,3 +274,265 @@ def test_real_reid_model_runs_over_frames(self) -> None: assert len(tracker.tracks) == 1 bank = tracker.tracks[0].feature_bank assert bank is not None and bank.feature is not None + + +class TestFuseAdaptiveReidAssociation: + """Unit tests for Deep OC-SORT adaptive appearance weighting.""" + + def test_dominant_match_outweighs_tied_match(self) -> None: + # Same top appearance (0.9) and same IoU (0.6) in both cases. When the runner-up + # is far behind, the adaptive bonus lifts the appearance weight above the base + # 0.75; when the two candidates tie, no bonus is granted. + iou = np.array([[0.6, 0.6]], dtype=np.float32) + dominant = fuse_adaptive_reid_association( + iou, + np.array([[0.9, 0.1]], dtype=np.float32), + reid_appearance_weight=0.75, + reid_adaptive_weight_cap=0.5, + reid_proximity_threshold=0.5, + ) + tied = fuse_adaptive_reid_association( + iou, + np.array([[0.9, 0.9]], dtype=np.float32), + reid_appearance_weight=0.75, + reid_adaptive_weight_cap=0.5, + reid_proximity_threshold=0.5, + ) + # Gap 0.8 is capped at 0.5, halved to a 0.25 bonus: 0.6 + (0.75 + 0.25) * 0.9. + assert dominant[0, 0] == pytest.approx(1.5) + # No gap, so the base weight alone applies: 0.6 + 0.75 * 0.9. + assert tied[0, 0] == pytest.approx(1.275) + assert dominant[0, 0] > tied[0, 0] + + def test_low_proximity_ignores_appearance(self) -> None: + # IoU 0.36 fails the proximity gate (needs IoU > 1 - 0.5), so the score stays + # IoU-only despite a strong appearance match. + fused = fuse_adaptive_reid_association( + np.array([[0.36]], dtype=np.float32), + np.array([[0.9]], dtype=np.float32), + reid_appearance_weight=0.75, + reid_adaptive_weight_cap=0.5, + reid_proximity_threshold=0.5, + ) + assert fused[0, 0] == pytest.approx(0.36) + + def test_appearance_floor_drops_weak_matches(self) -> None: + # Cosine 0.6 is below the 0.65 floor, so the pair falls back to geometry alone; + # cosine 0.8 is above it and contributes as usual: 0.3 + 0.75 * 0.8. + fused = fuse_adaptive_reid_association( + np.array([[0.3, 0.3]], dtype=np.float32), + np.array([[0.6, 0.8]], dtype=np.float32), + reid_appearance_weight=0.75, + reid_adaptive_weight_cap=0.0, + reid_proximity_threshold=1.0, + reid_appearance_floor=0.65, + ) + assert fused[0, 0] == pytest.approx(0.3) + assert fused[0, 1] == pytest.approx(0.9) + + def test_floored_competitor_does_not_inflate_the_bonus(self) -> None: + # One detection clears the floor (0.9) and one does not (0.8). The margin must be + # measured over raw appearance, a gap of 0.1: flooring first would read the second + # detection as 0 and fabricate a gap of 0.9, capped to 0.5. + fused = fuse_adaptive_reid_association( + np.array([[0.6, 0.6]], dtype=np.float32), + np.array([[0.9, 0.8]], dtype=np.float32), + reid_appearance_weight=0.75, + reid_adaptive_weight_cap=0.5, + reid_proximity_threshold=0.5, + reid_appearance_floor=0.85, + ) + # Track gap 0.1, no detection gap (one track): 0.6 + (0.75 + 0.1 / 2) * 0.9. + assert fused[0, 0] == pytest.approx(1.32) + # Below the floor: geometry only. + assert fused[0, 1] == pytest.approx(0.6) + + def test_gated_competitor_does_not_inflate_the_bonus(self) -> None: + # Both detections look identical (0.9), so appearance cannot tell them apart and + # no bonus is owed. Gating one out on proximity must not change that: the bonus + # measures appearance discriminativeness, not geometric isolation. + appearance = np.array([[0.9, 0.9]], dtype=np.float32) + both_close = fuse_adaptive_reid_association( + np.array([[0.6, 0.6]], dtype=np.float32), + appearance, + reid_appearance_weight=0.75, + reid_adaptive_weight_cap=0.5, + reid_proximity_threshold=0.5, + ) + one_gated = fuse_adaptive_reid_association( + np.array([[0.6, 0.4]], dtype=np.float32), + appearance, + reid_appearance_weight=0.75, + reid_adaptive_weight_cap=0.5, + reid_proximity_threshold=0.5, + ) + + # Base weight only in both cases: 0.6 + 0.75 * 0.9. + assert both_close[0, 0] == pytest.approx(1.275) + assert one_gated[0, 0] == pytest.approx(1.275) + # The gated pair keeps its geometry and gains nothing from appearance. + assert one_gated[0, 1] == pytest.approx(0.4) + + def test_bonus_combines_track_and_detection_gaps(self) -> None: + # A non-square matrix so the per-track (axis 1) and per-detection (axis 0) gaps + # differ and both contribute. Track 0 leads its row by 0.6 and column 0 leads by + # 0.5, each capped at 0.4, giving a bonus of (0.4 + 0.4) / 2 = 0.4. + fused = fuse_adaptive_reid_association( + np.full((2, 3), 0.6, dtype=np.float32), + np.array([[0.9, 0.3, 0.2], [0.4, 0.3, 0.2]], dtype=np.float32), + reid_appearance_weight=0.5, + reid_adaptive_weight_cap=0.4, + reid_proximity_threshold=0.5, + ) + assert fused[0, 0] == pytest.approx(0.6 + (0.5 + 0.4) * 0.9) + # Track 1 leads its own row by only 0.1, so its bonus is smaller. + assert fused[1, 0] == pytest.approx(0.6 + (0.5 + (0.1 + 0.4) / 2) * 0.4) + + def test_negative_appearance_never_lowers_similarity(self) -> None: + # A negative cosine similarity is clamped to zero so appearance can only ever + # raise a score, never push a geometrically sound pair below the threshold. + fused = fuse_adaptive_reid_association( + np.array([[0.8]], dtype=np.float32), + np.array([[-0.5]], dtype=np.float32), + reid_appearance_weight=0.75, + reid_adaptive_weight_cap=0.5, + reid_proximity_threshold=0.5, + ) + assert fused[0, 0] == pytest.approx(0.8) + + def test_single_candidate_gets_no_adaptive_bonus(self) -> None: + # With one track and one detection there is no runner-up to compare against, + # so only the base weight applies: 0.7 + 0.75 * 0.8. + fused = fuse_adaptive_reid_association( + np.array([[0.7]], dtype=np.float32), + np.array([[0.8]], dtype=np.float32), + reid_appearance_weight=0.75, + reid_adaptive_weight_cap=0.5, + reid_proximity_threshold=0.5, + ) + assert fused[0, 0] == pytest.approx(1.3) + + def test_proximity_uses_standard_iou_not_giou(self) -> None: + # The tracker always passes two different matrices: association similarity is + # IoU fused with detection confidence, while the gate is raw IoU. A version + # that ignores the caller's gate matrix would keep the appearance term here. + association_iou = np.array([[0.80]], dtype=np.float32) + fused = fuse_adaptive_reid_association( + association_iou, + np.array([[0.95]], dtype=np.float32), + reid_appearance_weight=0.75, + reid_adaptive_weight_cap=0.5, + reid_proximity_threshold=0.5, + proximity_iou_similarity=np.array([[0.35]], dtype=np.float32), + ) + assert fused[0, 0] == pytest.approx(float(association_iou[0, 0])) + + +class TestReidFusionSelection: + """The fusion selector on the tracker.""" + + def test_botsort_fusion_is_the_default(self) -> None: + assert BoTSORTTracker().reid_config.fusion == "botsort" + + @pytest.mark.parametrize("threshold", [0.01, 1.0]) + def test_appearance_threshold_is_ignored_under_adaptive(self, threshold: float) -> None: + """The BoT-SORT appearance threshold does not affect adaptive fusion. + + The track is offered two detections: the nearer one looks wrong, the farther one looks + right at cosine 0.8. Appearance has to win for the farther box to be matched. Under the + BoT-SORT rule a threshold of 0.01 would reject that match (``d_app`` is 0.1), so a tracker + that let the threshold reach the adaptive path would fall back to geometry and pick the + nearer box. + """ + identity = _norm(np.array([1.0, 0.0, 0.0, 0.0])) + similar = _norm(np.array([0.8, 0.6, 0.0, 0.0])) # cosine 0.8 against identity + impostor = _norm(np.array([0.0, 1.0, 0.0, 0.0])) + + class _PhaseEncoder: + phase = 1 + + def extract_features(self, detections: sv.Detections, frame: np.ndarray) -> np.ndarray: + rows = [] + for box in detections.xyxy: + key = (round(float(box[0])), round(float(box[1]))) + if self.phase == 1: + rows.append(identity) + else: + rows.append(impostor if key == (10, 10) else similar) + return np.stack(rows) + + encoder = _PhaseEncoder() + frame = _frame(1) + tracker = BoTSORTTracker( + enable_cmc=False, + minimum_iou_threshold_first_assoc=0.01, + reid_config=ReIDConfig(fusion="adaptive", appearance_threshold=threshold, proximity_threshold=0.99), + reid_model=encoder, + ) + tracker.update(_detection((10.0, 10.0, 30.0, 30.0)), frame=frame) + track_id = int(tracker.tracks[0].tracker_id) + + competitors = sv.Detections( + xyxy=np.array([[10.0, 10.0, 30.0, 30.0], [14.0, 14.0, 34.0, 34.0]], dtype=np.float32), + confidence=np.array([0.9, 0.9], dtype=np.float32), + ) + encoder.phase = 2 + out = tracker.update(competitors, frame=frame) + + assert out.tracker_id is not None + matched = out.xyxy[out.tracker_id == track_id][0] + assert (float(matched[0]), float(matched[1])) == (14.0, 14.0) + + def test_adaptive_parameters_reach_fusion(self, monkeypatch: pytest.MonkeyPatch) -> None: + import trackers.core.botsort.tracker as tracker_module + + received: dict[str, float] = {} + + def spy(association, appearance, **kwargs): + received.update(kwargs) + return tracker_module.fuse_botsort_reid_association( + association, appearance, reid_proximity_threshold=0.5, reid_appearance_threshold=0.25 + ) + + monkeypatch.setattr(tracker_module, "fuse_adaptive_reid_association", spy) + tracker = BoTSORTTracker( + enable_cmc=False, + reid_model=_KeyedReIDEncoder({(10, 10): _norm(np.array([1.0, 0.0, 0.0, 0.0]))}), + reid_config=ReIDConfig( + fusion="adaptive", + appearance_weight=0.5, + adaptive_weight_cap=0.4, + proximity_threshold=0.9, + appearance_floor=0.3, + ), + ) + tracker.update(_detection((10.0, 10.0, 30.0, 30.0)), frame=_frame(5)) + tracker.update(_detection((10.0, 10.0, 30.0, 30.0)), frame=_frame(6)) + + assert received["reid_appearance_weight"] == 0.5 + assert received["reid_adaptive_weight_cap"] == 0.4 + assert received["reid_proximity_threshold"] == 0.9 + assert received["reid_appearance_floor"] == 0.3 + + def test_adaptive_fusion_changes_association(self) -> None: + # Non-default weights: at 0.75 with the cap binding the effective weight is exactly + # 1.0, which makes the fusion symmetric in its inputs and would hide wiring mistakes. + far = (10.0, 10.0, 30.0, 30.0) + near = (11.0, 11.0, 31.0, 31.0) + feature = _norm(np.array([1.0, 0.0, 0.0, 0.0])) + + def run(fusion: ReidFusionMethod) -> np.ndarray: + tracker = BoTSORTTracker( + enable_cmc=False, + reid_model=_KeyedReIDEncoder({(10, 10): feature, (11, 11): feature}), + reid_config=ReIDConfig(fusion=fusion, appearance_weight=0.3, adaptive_weight_cap=0.1), + minimum_iou_threshold_first_assoc=1.02, + ) + tracker.update(_detection(far), frame=_frame(5)) + return tracker.update(_detection(near), frame=_frame(6)).tracker_id + + # The gate sits in the window that separates the two fusions. Taking a minimum + # of costs can never exceed a similarity of 1, while adding a weighted + # appearance term reaches 0.74 + 0.3 * 1.0, so only the latter clears 1.02. + np.testing.assert_array_equal(run("botsort"), [-1]) + np.testing.assert_array_equal(run("adaptive"), [0]) diff --git a/tests/core/test_reid_config.py b/tests/core/test_reid_config.py index 69f714774..db3d4026b 100644 --- a/tests/core/test_reid_config.py +++ b/tests/core/test_reid_config.py @@ -16,23 +16,51 @@ class TestReIDConfig: """Validation of the standalone ReID fusion settings.""" - @pytest.mark.parametrize("parameter", ["ema_alpha", "appearance_threshold", "proximity_threshold"]) @pytest.mark.parametrize("value", [-0.01, 1.01]) - def test_rejects_values_outside_unit_range(self, parameter: str, value: float) -> None: + def test_rejects_invalid_reid_ema_alpha(self, value: float) -> None: + with pytest.raises(ValueError, match="ema_alpha"): + ReIDConfig(ema_alpha=value) + + @pytest.mark.parametrize("parameter", ["appearance_threshold", "proximity_threshold", "appearance_floor"]) + @pytest.mark.parametrize("value", [-0.01, 1.01]) + def test_rejects_invalid_association_thresholds(self, parameter: str, value: float) -> None: with pytest.raises(ValueError, match=parameter): # mypy cannot narrow a dynamic dict against typed kwargs. ReIDConfig(**{parameter: value}) # type: ignore[arg-type] - @pytest.mark.parametrize("parameter", ["ema_alpha", "appearance_threshold", "proximity_threshold"]) @pytest.mark.parametrize("value", [0.0, 1.0]) - def test_accepts_unit_range_boundaries(self, parameter: str, value: float) -> None: + def test_accepts_ema_alpha_boundaries(self, value: float) -> None: + assert ReIDConfig(ema_alpha=value).ema_alpha == value + + @pytest.mark.parametrize("parameter", ["appearance_threshold", "proximity_threshold", "appearance_floor"]) + @pytest.mark.parametrize("value", [0.0, 1.0]) + def test_accepts_association_threshold_boundaries(self, parameter: str, value: float) -> None: # mypy cannot narrow a dynamic dict against typed kwargs. config = ReIDConfig(**{parameter: value}) # type: ignore[arg-type] assert getattr(config, parameter) == value def test_defaults_match_botsort(self) -> None: - # BoT-SORT's appearance_thresh, proximity_thresh and feature EMA momentum. config = ReIDConfig() assert (config.ema_alpha, config.appearance_threshold, config.proximity_threshold) == (0.9, 0.25, 0.5) + + def test_adaptive_defaults_match_approved_fusion(self) -> None: + config = ReIDConfig(fusion="adaptive") + + assert (config.fusion, config.appearance_weight, config.adaptive_weight_cap, config.appearance_floor) == ( + "adaptive", + 0.75, + 0.5, + 0.0, + ) + + def test_unknown_fusion_raises(self) -> None: + with pytest.raises(ValueError, match="Unknown fusion"): + ReIDConfig(fusion="deepocsort") # type: ignore[arg-type] + + @pytest.mark.parametrize("parameter", ["appearance_weight", "adaptive_weight_cap"]) + @pytest.mark.parametrize("value", [-0.1, float("nan"), float("inf")]) + def test_rejects_unusable_adaptive_weights(self, parameter: str, value: float) -> None: + with pytest.raises(ValueError, match=parameter): + ReIDConfig(**{parameter: value}) # type: ignore[arg-type]