This commit is contained in:
2026-03-27 22:48:31 -03:00
parent bf30acd4df
commit 3d8e7291f3
9 changed files with 5 additions and 26 deletions

View 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()
}