waltgrace commited on
Commit
6060fb2
·
verified ·
1 Parent(s): cf34cd5

feat(web): live video tracker — iou-tracker.ts

Browse files
Files changed (1) hide show
  1. 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
+ }