From 1530bbc999e4b31125408246de2228f301e3e66a Mon Sep 17 00:00:00 2001 From: asus Date: Mon, 5 Oct 2026 12:21:18 +0700 Subject: [PATCH] feat: chain track 5 frames so a moving object is re-seeded (REQ-189) trackForward sent the identical box to all five target frames. On batch 19 (extracted at 0.5 fps - one frame every 2 s) a truck entering the frame outruns a static seed, so the action either missed it or grabbed the wrong part. - geometry handling lifted to module-level shapeBox(), reused for the drawn shape and for each assist result - first frame still uses the drawn box exactly; frames 2-5 are seeded with the previous run's shape grown x1.5 about its centre (SEED_GROWTH) - a frame SAM3 refuses keeps the last good seed for the next attempt - REQ-189 rewritten to the chaining contract, including the accepted cost: a bad read can carry forward; this is not tracking, no motion model --- docs/requirements.md | 18 ++++++---- docs/tasks.md | 14 ++++++++ docs/ui-spec.md | 8 ++++- frontend/src/pages/ReviewPage.jsx | 59 +++++++++++++++++++++---------- 4 files changed, 73 insertions(+), 26 deletions(-) diff --git a/docs/requirements.md b/docs/requirements.md index 0e4d6c1..60e6e5e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -170,13 +170,17 @@ changes. one object and the model produces its shape. - **REQ-189** — In the review editor, the user can push the **selected shape's** box forward with *Track 5 Frames* (`[T]`): the next five frames each get their own REQ-043 box-assist - run, seeded with that box, and the shape is written as `source=manual`. Both geometry types - work — a bbox shape supplies its own box, a polygon is reduced to its bounding box. Each - frame is an **independent** run: a frame SAM3 refuses (nothing inside the box) is reported - and the rest still run; nothing selected, an unusable shape, or the last frame says so - instead of doing nothing. This is one-shot hand-off across the next frames, **not** - propagation of drawn exemplars (which stays a non-goal): nothing is carried frame to frame, - and the runs do not depend on each other. + run, and the shape is written as `source=manual`. Both geometry types work — a bbox shape + supplies its own box, a polygon is reduced to its bounding box. Runs are **chained, not + independent**: the first frame gets the drawn box untouched, and after each successful run + that run's resulting shape seeds the next frame, grown about its centre by ×1.5 so the + object has room to have moved. A frame SAM3 refuses (nothing inside the box) is reported + and the run continues from the last good seed. Nothing selected, an unusable shape, or the + last frame says so instead of doing nothing. Known cost of chaining: a wrong read on one + frame can carry into the ones after it — the result banner reports only what happened. This + is one-shot hand-off across the next frames, **not** propagation of drawn exemplars (which + stays a non-goal): nothing is tracked frame-to-frame with a motion model, and the runs do + not share state between invocations. - **REQ-044** — All annotations and review statuses are **persistent** — they survive a server restart, unlike today's in-memory sessions. - **REQ-045** — Review progress is visible (e.g. "120/300 reviewed"), and a batch can only diff --git a/docs/tasks.md b/docs/tasks.md index 7033bb6..3f5e138 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -1405,6 +1405,20 @@ read it. (accent border + glow) — no CSS added; **browser eyeball still owed by the user** (375 px 2-col wrap, long class names). +## Task — Track 5 Frames follows a moving object (REQ-189) `[DONE]` + +1. Root cause: `trackForward` sent the **same box** to all 5 frames, and batch 19 was + extracted at **fps 0.5** (one frame every 2 s, `batches.fps`) — a truck entering frame + outruns a static seed. Now the runs are **chained**: first frame gets the drawn box + untouched, each later frame is seeded with the previous run's resulting shape grown ×1.5 + about its centre (`SEED_GROWTH`, `growBox`), a refused frame keeps the last good seed. + Geometry handling moved to a module-level `shapeBox()` reused by both call sites. + → verify: **[DONE]** `npm run build` green; first frame un-grown (stationary case keeps + its exact precision); failure path leaves `seed` untouched; busy guard still blocks a + double-writes; **moving-object behaviour needs the user's GPU run** — expect chained + readjustment to hold a slow move, still lose a fast one at 0.5 fps (fix: re-extract at + 2 fps via *Trim → frames per second*, or REQ-190 video propagation). + ## Task — Track 5 Frames works at all (REQ-189) `[DONE]` 1. `ReviewPage.jsx:trackForward` read `geometry.coordinates` — a key the backend never emits diff --git a/docs/ui-spec.md b/docs/ui-spec.md index c0338af..3310ca9 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -995,7 +995,13 @@ previous geometry** and shows the error. Same optimistic-with-rollback pattern f inside the box) does not stop the rest — the banner then **starts** with `Tracked N of M` (M = frames actually ahead, ≤ 5) followed by the per-frame failures. A second press while a run is in flight is ignored. Shapes are written `source=manual`, exactly as a hand-drawn - one (REQ-189). + one. + Runs are **chained**: the first frame uses the drawn box exactly, and every later frame is + seeded with the previous run's resulting shape, grown ×1.5 about its centre so a moving + object still fits. A refused frame keeps the last good seed for the next attempt. Chaining + is not tracking — there is no motion model — so a wrong read can carry forward; only denser + frame extraction (higher fps) or real video propagation fixes a fast-moving object + (REQ-189). **Quick reclass bar** appears whenever a shape is selected: one button per class (`[n] name`, class-coloured) plus *Delete [Del]*. diff --git a/frontend/src/pages/ReviewPage.jsx b/frontend/src/pages/ReviewPage.jsx index c7db8e9..e21766f 100644 --- a/frontend/src/pages/ReviewPage.jsx +++ b/frontend/src/pages/ReviewPage.jsx @@ -9,6 +9,37 @@ import ReviewSidebar from '../components/ReviewSidebar' import ExemplarFilterPanel from '../components/ExemplarFilterPanel' import useExemplarPool from '../hooks/useExemplarPool' +// How much bigger the seed box gets before each frame after the first, so the +// object has room to have moved. Hand-tuned, not learned (REQ-189). +const SEED_GROWTH = 1.5 + +// SAM3 assist wants one normalized [x0, y0, x1, y1] box (REQ-043). A bbox +// shape already is that; a polygon is reduced to its bounding box. +function shapeBox(geometry) { + const pts = geometry?.points + if (geometry?.type === 'bbox' && pts?.length === 4) return pts + if (geometry?.type === 'polygon' && pts?.length) { + let minX = 1, minY = 1, maxX = 0, maxY = 0 + for (const [x, y] of pts) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + return [minX, minY, maxX, maxY] + } + return null +} + +function growBox([x0, y0, x1, y1], k) { + const cx = (x0 + x1) / 2 + const cy = (y0 + y1) / 2 + const hw = ((x1 - x0) * k) / 2 + const hh = ((y1 - y0) * k) / 2 + const clamp = (v) => Math.min(1, Math.max(0, v)) + return [clamp(cx - hw), clamp(cy - hh), clamp(cx + hw), clamp(cy + hh)] +} + export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }) { const [batch, setBatch] = useState(null) const [project, setProject] = useState(null) @@ -367,23 +398,8 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } return } - // SAM3 assist wants one normalized [x0, y0, x1, y1] box (REQ-043). A bbox - // shape already is that; a polygon is reduced to its bounding box. - const geometry = current.geometry - let box = null - if (geometry?.type === 'bbox' && geometry.points?.length === 4) { - box = geometry.points - } else if (geometry?.type === 'polygon' && geometry.points?.length) { - let minX = 1, minY = 1, maxX = 0, maxY = 0 - for (const [x, y] of geometry.points) { - if (x < minX) minX = x - if (x > maxX) maxX = x - if (y < minY) minY = y - if (y > maxY) maxY = y - } - box = [minX, minY, maxX, maxY] - } - if (!box) { + const firstBox = shapeBox(current.geometry) + if (!firstBox) { setError('That shape has no usable box — Track 5 Frames needs one.') return } @@ -397,12 +413,19 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject } setError('') try { let tracked = 0 + let seed = firstBox const failures = [] for (let i = 0; i < ahead.length; i++) { const targetFrame = ahead[i] + // The first frame uses the drawn box untouched. After that the object + // has had time to move, so the last result seeds a wider box. + const seedBox = i === 0 ? seed : growBox(seed, SEED_GROWTH) try { - await api.assist(targetFrame.id, { box, class_id: current.class_id }) + const created = await api.assist(targetFrame.id, { + box: seedBox, class_id: current.class_id, + }) tracked++ + seed = shapeBox(created?.geometry) ?? seed patchFrameLocally(targetFrame.id, { annotation_count: (targetFrame.annotation_count ?? 0) + 1 }) } catch (exc) { failures.push(`frame ${index + i + 2}: ${exc.message}`)