phase 3
This commit is contained in:
181
ui/detection-app/src/cv/tracks.ts
Normal file
181
ui/detection-app/src/cv/tracks.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Motion track matching and rendering.
|
||||
*
|
||||
* Matches bounding boxes across frames by IoU (Intersection over Union).
|
||||
* Uses Hungarian-style optimal assignment so every track gets its best match.
|
||||
* Renders VFX-style tracks: × at start/end, dotted trace in between.
|
||||
* Returns ImageData (RGBA, transparent background) for use as a FrameOverlay.
|
||||
*/
|
||||
|
||||
export type TrackPoint = { seq: number; cx: number; cy: number; w: number; h: number }
|
||||
export type Track = { id: number; points: TrackPoint[] }
|
||||
|
||||
type Box = { x: number; y: number; w: number; h: number }
|
||||
|
||||
function iou(a: Box, b: Box): number {
|
||||
const x1 = Math.max(a.x, b.x)
|
||||
const y1 = Math.max(a.y, b.y)
|
||||
const x2 = Math.min(a.x + a.w, b.x + b.w)
|
||||
const y2 = Math.min(a.y + a.h, b.y + b.h)
|
||||
const inter = Math.max(0, x2 - x1) * Math.max(0, y2 - y1)
|
||||
if (inter === 0) return 0
|
||||
const union = a.w * a.h + b.w * b.h - inter
|
||||
return union > 0 ? inter / union : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Match bounding boxes across frames by IoU.
|
||||
*
|
||||
* For each pair of consecutive frames, computes an IoU cost matrix and
|
||||
* assigns tracks to boxes using greedy-best-first on IoU score (descending).
|
||||
* minIoU: minimum overlap to consider a match (default 0.15).
|
||||
*/
|
||||
export function matchTracks(
|
||||
regionsByFrame: Record<number, Box[]>,
|
||||
minIoU = 0.15,
|
||||
): Track[] {
|
||||
const seqs = Object.keys(regionsByFrame).map(Number).sort((a, b) => a - b)
|
||||
if (seqs.length === 0) return []
|
||||
|
||||
let nextId = 0
|
||||
const active: Track[] = []
|
||||
const finished: Track[] = []
|
||||
|
||||
for (const seq of seqs) {
|
||||
const boxes = regionsByFrame[seq]
|
||||
|
||||
if (active.length === 0) {
|
||||
for (const b of boxes) {
|
||||
active.push({
|
||||
id: nextId++,
|
||||
points: [{ seq, cx: b.x + b.w / 2, cy: b.y + b.h / 2, w: b.w, h: b.h }],
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Build cost matrix: IoU between each active track's last box and each new box
|
||||
const costs: Array<{ trackIdx: number; boxIdx: number; score: number }> = []
|
||||
for (let t = 0; t < active.length; t++) {
|
||||
const last = active[t].points[active[t].points.length - 1]
|
||||
const trackBox: Box = { x: last.cx - last.w / 2, y: last.cy - last.h / 2, w: last.w, h: last.h }
|
||||
for (let b = 0; b < boxes.length; b++) {
|
||||
const score = iou(trackBox, boxes[b])
|
||||
if (score >= minIoU) {
|
||||
costs.push({ trackIdx: t, boxIdx: b, score })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Greedy-best-first assignment (sorted by descending IoU)
|
||||
costs.sort((a, b) => b.score - a.score)
|
||||
const matchedTracks = new Set<number>()
|
||||
const matchedBoxes = new Set<number>()
|
||||
const next: Track[] = []
|
||||
|
||||
for (const { trackIdx, boxIdx } of costs) {
|
||||
if (matchedTracks.has(trackIdx) || matchedBoxes.has(boxIdx)) continue
|
||||
matchedTracks.add(trackIdx)
|
||||
matchedBoxes.add(boxIdx)
|
||||
const b = boxes[boxIdx]
|
||||
active[trackIdx].points.push({
|
||||
seq, cx: b.x + b.w / 2, cy: b.y + b.h / 2, w: b.w, h: b.h,
|
||||
})
|
||||
next.push(active[trackIdx])
|
||||
}
|
||||
|
||||
// Unmatched tracks → finished
|
||||
for (let t = 0; t < active.length; t++) {
|
||||
if (!matchedTracks.has(t)) finished.push(active[t])
|
||||
}
|
||||
|
||||
// Unmatched boxes → new tracks
|
||||
for (let b = 0; b < boxes.length; b++) {
|
||||
if (!matchedBoxes.has(b)) {
|
||||
const box = boxes[b]
|
||||
next.push({
|
||||
id: nextId++,
|
||||
points: [{ seq, cx: box.x + box.w / 2, cy: box.y + box.h / 2, w: box.w, h: box.h }],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
active.length = 0
|
||||
active.push(...next)
|
||||
}
|
||||
|
||||
return [...finished, ...active]
|
||||
}
|
||||
|
||||
/**
|
||||
* Render tracks to ImageData (RGBA, transparent background).
|
||||
*
|
||||
* Only draws tracks that span ≥2 frames (single-frame detections are skipped).
|
||||
* × at start, × at end, dotted trace in between.
|
||||
* Filled dot at currentSeq.
|
||||
*/
|
||||
export function renderTracksToImageData(
|
||||
tracks: Track[],
|
||||
width: number,
|
||||
height: number,
|
||||
currentSeq: number,
|
||||
color = '#00e5ff',
|
||||
): ImageData {
|
||||
const canvas = new OffscreenCanvas(width, height)
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
ctx.strokeStyle = color
|
||||
ctx.fillStyle = color
|
||||
|
||||
for (const track of tracks) {
|
||||
const pts = track.points
|
||||
// Skip single-frame detections — no temporal signal
|
||||
if (pts.length < 2) continue
|
||||
|
||||
// Dotted trace connecting all centers
|
||||
ctx.globalAlpha = 0.75
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.setLineDash([4, 4])
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(pts[0].cx, pts[0].cy)
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(pts[i].cx, pts[i].cy)
|
||||
}
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
|
||||
// × at first point
|
||||
ctx.globalAlpha = 0.9
|
||||
ctx.lineWidth = 1.5
|
||||
drawX(ctx, pts[0].cx, pts[0].cy, 7)
|
||||
|
||||
// × at last point
|
||||
drawX(ctx, pts[pts.length - 1].cx, pts[pts.length - 1].cy, 7)
|
||||
|
||||
// Filled dot at current frame
|
||||
const curr = pts.find(p => p.seq === currentSeq)
|
||||
if (curr) {
|
||||
ctx.globalAlpha = 1
|
||||
ctx.beginPath()
|
||||
ctx.arc(curr.cx, curr.cy, 4, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.getImageData(0, 0, width, height)
|
||||
}
|
||||
|
||||
function drawX(
|
||||
ctx: OffscreenCanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
size: number,
|
||||
): void {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx - size, cy - size)
|
||||
ctx.lineTo(cx + size, cy + size)
|
||||
ctx.moveTo(cx + size, cy - size)
|
||||
ctx.lineTo(cx - size, cy + size)
|
||||
ctx.stroke()
|
||||
}
|
||||
Reference in New Issue
Block a user