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
This commit is contained in:
asus committed 2026-10-05 12:21:18 +07:00
1 parent b71f1d4bf2
commit 1530bbc999
4 files changed
+73 -26

No files matched your search

+11 -7
View File
@@ -170,13 +170,17 @@ changes.
one object and the model produces its shape. one object and the model produces its shape.
- **REQ-189** — In the review editor, the user can push the **selected shape's** box forward - **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 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 run, and the shape is written as `source=manual`. Both geometry types work — a bbox shape
work — a bbox shape supplies its own box, a polygon is reduced to its bounding box. Each supplies its own box, a polygon is reduced to its bounding box. Runs are **chained, not
frame is an **independent** run: a frame SAM3 refuses (nothing inside the box) is reported independent**: the first frame gets the drawn box untouched, and after each successful run
and the rest still run; nothing selected, an unusable shape, or the last frame says so that run's resulting shape seeds the next frame, grown about its centre by ×1.5 so the
instead of doing nothing. This is one-shot hand-off across the next frames, **not** object has room to have moved. A frame SAM3 refuses (nothing inside the box) is reported
propagation of drawn exemplars (which stays a non-goal): nothing is carried frame to frame, and the run continues from the last good seed. Nothing selected, an unusable shape, or the
and the runs do not depend on each other. 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 - **REQ-044** — All annotations and review statuses are **persistent** — they survive a
server restart, unlike today's in-memory sessions. server restart, unlike today's in-memory sessions.
- **REQ-045** — Review progress is visible (e.g. "120/300 reviewed"), and a batch can only - **REQ-045** — Review progress is visible (e.g. "120/300 reviewed"), and a batch can only
+14
View File
@@ -1405,6 +1405,20 @@ read it.
(accent border + glow) — no CSS added; **browser eyeball still owed by the user** (accent border + glow) — no CSS added; **browser eyeball still owed by the user**
(375 px 2-col wrap, long class names). (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]` ## Task — Track 5 Frames works at all (REQ-189) `[DONE]`
1. `ReviewPage.jsx:trackForward` read `geometry.coordinates` — a key the backend never emits 1. `ReviewPage.jsx:trackForward` read `geometry.coordinates` — a key the backend never emits
+7 -1
View File
@@ -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` 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 (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 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 **Quick reclass bar** appears whenever a shape is selected: one button per class
(`[n] name`, class-coloured) plus *Delete [Del]*. (`[n] name`, class-coloured) plus *Delete [Del]*.
+41 -18
View File
@@ -9,6 +9,37 @@ import ReviewSidebar from '../components/ReviewSidebar'
import ExemplarFilterPanel from '../components/ExemplarFilterPanel' import ExemplarFilterPanel from '../components/ExemplarFilterPanel'
import useExemplarPool from '../hooks/useExemplarPool' 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 }) { export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }) {
const [batch, setBatch] = useState(null) const [batch, setBatch] = useState(null)
const [project, setProject] = useState(null) const [project, setProject] = useState(null)
@@ -367,23 +398,8 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
return return
} }
// SAM3 assist wants one normalized [x0, y0, x1, y1] box (REQ-043). A bbox const firstBox = shapeBox(current.geometry)
// shape already is that; a polygon is reduced to its bounding box. if (!firstBox) {
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) {
setError('That shape has no usable box — Track 5 Frames needs one.') setError('That shape has no usable box — Track 5 Frames needs one.')
return return
} }
@@ -397,12 +413,19 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
setError('') setError('')
try { try {
let tracked = 0 let tracked = 0
let seed = firstBox
const failures = [] const failures = []
for (let i = 0; i < ahead.length; i++) { for (let i = 0; i < ahead.length; i++) {
const targetFrame = ahead[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 { 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++ tracked++
seed = shapeBox(created?.geometry) ?? seed
patchFrameLocally(targetFrame.id, { annotation_count: (targetFrame.annotation_count ?? 0) + 1 }) patchFrameLocally(targetFrame.id, { annotation_count: (targetFrame.annotation_count ?? 0) + 1 })
} catch (exc) { } catch (exc) {
failures.push(`frame ${index + i + 2}: ${exc.message}`) failures.push(`frame ${index + i + 2}: ${exc.message}`)