Skip to content

Experiments

This log describes each phase in the order it actually happened, including the real bugs found along the way -- a real project's experiment log has dead ends and fixes in it, and hiding them would make this read like a tutorial instead of a research report.

Phase 0 -- dataset acquisition

motchallenge.net (the official MOT17 host) was unreachable from this development environment (curl returned "no route to host", not a timeout -- checked, not a transient blip). Switched to the public Hugging Face mirror Lekim89/MOT17, verified it served real JPEG content (not LFS pointer stubs) behind its resolve URLs before committing to it.

Bug 1 -- HF CDN rate-limiting under concurrency. The first download attempt used 12 concurrent worker threads with immediate 3-retry-no-delay on failure. This worked for the first ~2900/3308 files, then hit persistent HTTP 429 ("Too Many Requests") -- and because retries had no backoff, each retry immediately re-triggered the same 429, so the job finished with 5 permanently-failed files instead of recovering. Fixed by adding exponential backoff with jitter (min(30s, 2^attempt * 0.5s) + random(0, 0.5s)) and reducing worker count to 3-5; a rerun (idempotent, skips already-downloaded files) picked up the 5 stragglers with zero failures.

Phase 1 -- core algorithm implementation + smoke tests

ByteTrack and DeepSORT were both built from shared primitives (Kalman filter, IoU matrix, Track lifecycle) rather than wrapped from an existing tracking library, then smoke-tested against a synthetic single-object moving-box sequence before any real data was involved -- both correctly assigned one persistent track ID across all frames and confirmed at the expected min_hits frame.

Bug 2 -- py-motmetrics crashes outright under NumPy 2.x. Wiring up the first real accuracy check (MOTAccumulator.update) immediately threw AttributeError: np.asfarray was removed in the NumPy 2.0 release from inside motmetrics.distances.iou_matrix -- not a subtle numerical divergence, a hard crash on the very first call, because this project's numpy>=1.26 (no upper bound) dependency resolved to NumPy 2.x while py-motmetrics 1.4.0 still calls a function removed two major versions ago. Fixed by reimplementing the ~10-line IoU-to-distance conversion locally, reusing this project's own already-tested emot.tracking.iou utility instead of depending on an upstream fix landing. Verified against a synthetic test with a known expected ID-switch count before trusting it on real data.

Phase 2 -- test-driven bug: empty-trajectory heatmap crash

While writing tests/unit/test_analytics.py, a deliberately-included edge case (occupancy_heatmap({}) -- no tracks at all) crashed with ValueError: need at least one array to concatenate. The function had a dead if len(all_points) == 0: return zeros(...) guard placed after the np.concatenate call that was supposed to protect it -- the early-return check needed to happen before concatenation, not after. Fixed by checking for an empty input list before concatenating rather than checking the (never-reachable) result afterward. A concrete example of why the edge case belongs in the test suite before the dashboard ships a page that calls this function on a sequence with a still-empty detection cache.

Phase 3 -- proactive resumability fix (not a bug that fired, one that would have)

run_detection_cache.py's summary metadata (per-backbone latency/count stats) was originally written to summary.json only once, after the entire multi-hour loop finished. Caught during review, before it could actually bite: an interruption near the end of the ~2-3 hour detection caching run would have lost all summary metadata even though the per-file .npz detection caches (the actually expensive artifacts) were safely resumable. Fixed to write summary.json incrementally after every (sequence, backbone) pair completes, matching the resumability guarantee the rest of the pipeline already had.

Phase 4 -- .gitignore bug: results were being silently excluded

Copying the .gitignore pattern from this portfolio's industrial-visual-anomaly-detection project (which does correctly ignore /results/... except that project's results/ holds only model-training metrics, not this project's much larger detection cache) included /results/ verbatim, without adjusting it for this project's different directory layout, where results/ holds the small JSON metrics that should be committed and cache/ holds the large regenerable detection arrays that shouldn't. Caught immediately at the first git add after hyperparameter tuning finished (git status reported the JSON file as "ignored," not just "untracked" -- a signal worth noticing rather than force-adding past). Fixed by ignoring /cache/ instead of /results/.

Phase 5 -- full detection cache + hyperparameter tuning

Ran run_detection_cache.py for the full 4 sequences x 6 backbones grid (~3300 frames x 6 backbones ≈ 2.5 hours wall-clock on 8 CPU cores, the dominant cost in this project) and tune_trackers.py (30 Optuna trials each for ByteTrack and DeepSORT, on MOT17-02/yolo11s's val split) concurrently -- CPU-contended but both are independently bounded, hours-scale jobs, and neither depends on the other's output. Tuned MOTA on the val split landed at ~0.30 for both trackers -- a real, unglamorous number from off-the-shelf detectors on real crowded MOT17 footage, not adjusted to look better.

Phase 6 -- full benchmark grid + export benchmark

run_tracking_benchmark.py (48 runs, replaying cached detections through both tuned trackers) and run_export_benchmark.py (6 backbones x 3 variants: PyTorch FP32, ONNX FP32, ONNX INT8) both completed without further implementation bugs -- but produced the two most substantively interesting results of the project (not bugs, genuine findings): ByteTrack/DeepSORT's MOTA-vs-IDF1 disagreement on MOT17-04, and ONNX/INT8 export not improving CPU latency despite reliably shrinking file size. Both are discussed in full in docs/results.md rather than here, since they're findings about the domain, not about this codebase.


v1.0 -- the portfolio rebuild

Phases 7 onward document the second iteration: a re-run of everything on a faster machine with more data, a real ReID network, camera-motion compensation, a corrected edge benchmark and three new front-ends. Same rule as above: dead ends and bugs are kept in.

Phase 7 -- re-baselining and four new evaluation tools

Moved the whole pipeline to Apple silicon and the full MOT17 training set. Detection moved from CPU to the Apple GPU (about 10 ms/frame instead of 150), which made it practical to cover all 7 training sequences (5,330 frames) x 6 backbones, cache detections down to a 0.1 confidence floor (ByteTrack's second stage genuinely needs the 0.1-0.4 band; the v1 cache stopped at 0.25), and re-run every number. The old results were discarded, not mixed.

HOTA, implemented and cross-checked. emot/evaluation/hota.py follows the paper; validated against the reference TrackEval implementation on a synthetic-but-realistic tracking scenario (real MOT17-02 ground truth, simulated misses / noise / identity swaps / false positives): all eight sub-metrics agree to 10 decimal places. TrackEval still calls np.float, so it only runs under NumPy 2 with a shim -- another reason to re-implement rather than depend on it.

Detection AP. The v1 benchmark used downstream MOTA as its only accuracy axis, which conflates detector and tracker. emot/evaluation/detection_ap.py adds COCO-style AP50 and AP with ignore regions for heavily occluded people.

Tuning objective changed from MOTA to HOTA. MOTA barely registers identity errors -- exactly what ReID and the matching cascade fix -- so tuning for MOTA would have tuned the new trackers to look useless. Optuna runs are now seconds long (replay from cached embeddings), so each family gets 60 trials.

Phase 8 -- OSNet, from the paper

DeepSORT's v1 weakness was a frozen ImageNet ResNet18. Replaced the embedding with OSNet (Zhou et al., ICCV 2019), written from the paper so that parameter names match the official torchreid checkpoints and the authors' MSMT17 weights load with strict=True.

Verification beyond "it loads": the same crops were embedded by this implementation and by an independent community ONNX export of the same checkpoint -- cosine similarity 1.0000 for all of them. And on ground-truth crops the same-person vs different-person separation (d') rises from 1.75 (ResNet18) to 4.30 (OSNet).

Bug 2 -- a vectorisation that must not change results. DeepSORT's gating computed a 4x4 inverse per (track, detection) pair. Vectorising it over detections was a pure refactor, so it was checked against the old implementation (from git) on 120 frames of randomised crowd data: zero mismatching frames. The matching cascade (cascade=True) is a new mode, not a change to the baseline.

Phase 9 -- BoT-SORT and camera-motion compensation

Added BotSortTracker sharing ByteTrack's geometry and lifecycle exactly, so that use_cmc=False, use_reid=False is ByteTrack (a unit test asserts identical output). Camera motion is estimated by Lucas-Kanade flow + RANSAC similarity fit and cached per sequence. The estimator separates the sequences cleanly without any labels: static cameras measure 0.2-0.4 px/frame of global motion, moving ones 4.8-12.7 px. That measurement, not folklore, is what the "static"/"moving" labels in the results use.

Phase 10 -- the edge benchmark was wrong, and static INT8 scored zero

Bug 3 -- v1 compared different input shapes. Re-measuring on the new machine, ONNX was faster than PyTorch, the opposite of v1's finding. Cause: Ultralytics' PyTorch path pads 16:9 frames to 384x640 while v1's ONNX export used a 640x640 square (1.67x more pixels). The v1 benchmark was removed and replaced (run_edge_benchmark.py): matched shapes, one process per configuration, AP measured per row instead of a box-IoU proxy.

Bug 4 -- static INT8 detected nothing (AP 0.0). First attempt at QDQ static quantisation produced a valid, plausible-looking model that scored exactly zero. Diagnosis: the Detect head's decode graph mixes pixel coordinates (up to ~640) and probabilities (0-1) in one tensor so one 8-bit scale flattens every score. Keeping the decode nodes in float fixed it; percentile calibration still scored zero in the same experiment while MinMax worked, so the final recipe is float decode + MinMax. (Cause of the percentile failure was not isolated.)

Finding -- INT8 and threads. ONNX Runtime profiling shows dynamic INT8 swaps convolutions for the slow ConvInteger path, and static INT8's QLinearConv is slower than FP32 per core but scales with threads while FP32 does not. See Edge deployment.

Phase 11 -- parity between Python and TypeScript found a real bug

The web app runs TypeScript ports of ByteTrack, the analytics tools and YOLO post-processing. Fixtures recorded from the Python implementation feed parity tests (npm test). Bug 5 -- NMS silently dropped candidates. The first parity run failed on NMS: OpenCV's NMSBoxes(top_k=300) keeps only the 300 highest-scoring candidates before suppression, whereas Ultralytics suppresses first and then caps survivors. The Python reference was wrong in crowded frames; fixed (top_k=0, cap after), fixture regenerated, port and reference now agree.

Phase 12 -- input-resolution sweep

With detection this cheap, swept input size (320-1280 px long side) for yolo11 n/s/m and re-ran ByteTrack and BoT-SORT (CMC). Result in Results: resolution moves HOTA more than model size does.