feat(web): live video tracker — iou-tracker.ts
Browse files- web/lib/iou-tracker.ts +157 -0
web/lib/iou-tracker.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// iou-tracker.ts — minimal SORT-style tracker, no external deps.
|
| 2 |
+
//
|
| 3 |
+
// Maintains persistent track IDs across frames by greedy IoU matching.
|
| 4 |
+
// Designed for objects that move smoothly between successive samples
|
| 5 |
+
// (drones in air, vehicles on a road, people walking). For erratic motion
|
| 6 |
+
// you'd want ByteTrack or a Kalman filter — this is intentionally simple.
|
| 7 |
+
|
| 8 |
+
export type Detection = {
|
| 9 |
+
x1: number;
|
| 10 |
+
y1: number;
|
| 11 |
+
x2: number;
|
| 12 |
+
y2: number;
|
| 13 |
+
score?: number;
|
| 14 |
+
label?: string;
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export type Track = Detection & {
|
| 18 |
+
id: number;
|
| 19 |
+
age: number; // total frames this track has existed
|
| 20 |
+
hits: number; // frames where the track was matched
|
| 21 |
+
framesSinceSeen: number; // frames since last successful match
|
| 22 |
+
color: string;
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
export type TrackerOptions = {
|
| 26 |
+
/** Minimum IoU for a detection to be associated with an existing track. */
|
| 27 |
+
iouThreshold?: number;
|
| 28 |
+
/** Tracks unseen for more than this many frames are retired. */
|
| 29 |
+
maxFramesUnseen?: number;
|
| 30 |
+
/** Color palette to cycle through for new tracks. Should match the canvas-UI palette. */
|
| 31 |
+
palette?: string[];
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const DEFAULT_PALETTE = [
|
| 35 |
+
"#22d3ee", // cyan
|
| 36 |
+
"#10b981", // emerald
|
| 37 |
+
"#3b82f6", // blue
|
| 38 |
+
"#fbbf24", // amber
|
| 39 |
+
"#a855f7", // purple
|
| 40 |
+
"#ec4899", // pink
|
| 41 |
+
"#f97316", // orange
|
| 42 |
+
"#84cc16", // lime
|
| 43 |
+
];
|
| 44 |
+
|
| 45 |
+
export function iou(a: Detection, b: Detection): number {
|
| 46 |
+
const x1 = Math.max(a.x1, b.x1);
|
| 47 |
+
const y1 = Math.max(a.y1, b.y1);
|
| 48 |
+
const x2 = Math.min(a.x2, b.x2);
|
| 49 |
+
const y2 = Math.min(a.y2, b.y2);
|
| 50 |
+
const interW = Math.max(0, x2 - x1);
|
| 51 |
+
const interH = Math.max(0, y2 - y1);
|
| 52 |
+
const inter = interW * interH;
|
| 53 |
+
if (inter === 0) return 0;
|
| 54 |
+
const aArea = (a.x2 - a.x1) * (a.y2 - a.y1);
|
| 55 |
+
const bArea = (b.x2 - b.x1) * (b.y2 - b.y1);
|
| 56 |
+
const union = aArea + bArea - inter;
|
| 57 |
+
return union > 0 ? inter / union : 0;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export class IoUTracker {
|
| 61 |
+
private tracks: Track[] = [];
|
| 62 |
+
private nextId = 1;
|
| 63 |
+
private opts: Required<TrackerOptions>;
|
| 64 |
+
|
| 65 |
+
constructor(opts: TrackerOptions = {}) {
|
| 66 |
+
this.opts = {
|
| 67 |
+
iouThreshold: opts.iouThreshold ?? 0.3,
|
| 68 |
+
maxFramesUnseen: opts.maxFramesUnseen ?? 5,
|
| 69 |
+
palette: opts.palette ?? DEFAULT_PALETTE,
|
| 70 |
+
};
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/** Process a new frame's detections. Returns the updated active tracks. */
|
| 74 |
+
update(detections: Detection[]): Track[] {
|
| 75 |
+
// Build IoU matrix between (existing tracks) × (new detections)
|
| 76 |
+
const matches = new Map<number, number>(); // trackIdx -> detIdx
|
| 77 |
+
const usedDets = new Set<number>();
|
| 78 |
+
const usedTracks = new Set<number>();
|
| 79 |
+
|
| 80 |
+
// Greedy: pick best pair, mark used, repeat
|
| 81 |
+
type Pair = { ti: number; di: number; iou: number };
|
| 82 |
+
const pairs: Pair[] = [];
|
| 83 |
+
for (let ti = 0; ti < this.tracks.length; ti++) {
|
| 84 |
+
for (let di = 0; di < detections.length; di++) {
|
| 85 |
+
const score = iou(this.tracks[ti], detections[di]);
|
| 86 |
+
if (score >= this.opts.iouThreshold) {
|
| 87 |
+
pairs.push({ ti, di, iou: score });
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
pairs.sort((a, b) => b.iou - a.iou);
|
| 92 |
+
for (const p of pairs) {
|
| 93 |
+
if (usedTracks.has(p.ti) || usedDets.has(p.di)) continue;
|
| 94 |
+
matches.set(p.ti, p.di);
|
| 95 |
+
usedTracks.add(p.ti);
|
| 96 |
+
usedDets.add(p.di);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Update matched tracks with new detections
|
| 100 |
+
for (const [ti, di] of matches) {
|
| 101 |
+
const t = this.tracks[ti];
|
| 102 |
+
const d = detections[di];
|
| 103 |
+
t.x1 = d.x1; t.y1 = d.y1; t.x2 = d.x2; t.y2 = d.y2;
|
| 104 |
+
t.score = d.score ?? t.score;
|
| 105 |
+
t.label = d.label ?? t.label;
|
| 106 |
+
t.age += 1;
|
| 107 |
+
t.hits += 1;
|
| 108 |
+
t.framesSinceSeen = 0;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// Increment unseen counter on unmatched tracks
|
| 112 |
+
for (let ti = 0; ti < this.tracks.length; ti++) {
|
| 113 |
+
if (!usedTracks.has(ti)) {
|
| 114 |
+
this.tracks[ti].age += 1;
|
| 115 |
+
this.tracks[ti].framesSinceSeen += 1;
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// Spawn new tracks for unmatched detections
|
| 120 |
+
for (let di = 0; di < detections.length; di++) {
|
| 121 |
+
if (usedDets.has(di)) continue;
|
| 122 |
+
const id = this.nextId++;
|
| 123 |
+
this.tracks.push({
|
| 124 |
+
...detections[di],
|
| 125 |
+
id,
|
| 126 |
+
age: 1,
|
| 127 |
+
hits: 1,
|
| 128 |
+
framesSinceSeen: 0,
|
| 129 |
+
color: this.opts.palette[(id - 1) % this.opts.palette.length],
|
| 130 |
+
});
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// Retire stale tracks
|
| 134 |
+
this.tracks = this.tracks.filter((t) => t.framesSinceSeen <= this.opts.maxFramesUnseen);
|
| 135 |
+
|
| 136 |
+
return this.activeTracks();
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/** Tracks visible in the most recent frame (framesSinceSeen === 0). */
|
| 140 |
+
activeTracks(): Track[] {
|
| 141 |
+
return this.tracks.filter((t) => t.framesSinceSeen === 0);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/** Every track currently maintained, including ones recently lost. */
|
| 145 |
+
allTracks(): Track[] {
|
| 146 |
+
return [...this.tracks];
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
reset(): void {
|
| 150 |
+
this.tracks = [];
|
| 151 |
+
this.nextId = 1;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
get totalTracksEverSeen(): number {
|
| 155 |
+
return this.nextId - 1;
|
| 156 |
+
}
|
| 157 |
+
}
|