feat: configurable track forward, track to end, skip-and-mark (REQ-189)
Track 5 Frames becomes Track Forward with a 1-100 count input, remembered in localStorage (default 5). A separate -> End action runs to the last frame behind a confirm and can be cancelled mid-flight. Frames that already hold a shape of the tracked class are skipped instead of re-annotated: hidden and dimmed shapes count as present. Skips are reported in the result banner and marked with a dot plus a class name on the filmstrip thumbnail. frames() now returns the class ids each frame holds, the same query row, no migration. Chaining and seed growth stay out: every frame is still seeded from the drawn box only.
This commit is contained in:
1 parent
f6b0335c93
commit
4f15335092
9 files changed
+226
-52
No files matched your search
+3
-3
@@ -3,8 +3,8 @@
|
||||
* FastAPI reports failures as {"detail": "..."} — unwrapped here so callers can
|
||||
* show the message the backend actually wrote instead of "500". */
|
||||
|
||||
async function request(path, { method = 'GET', body, form } = {}) {
|
||||
const options = { method, headers: {} }
|
||||
async function request(path, { method = 'GET', body, form, signal } = {}) {
|
||||
const options = { method, headers: {}, signal }
|
||||
if (form) {
|
||||
options.body = form
|
||||
} else if (body !== undefined) {
|
||||
@@ -135,7 +135,7 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: { annotation_ids: annotationIds, class_id: classId },
|
||||
}),
|
||||
assist: (frameId, body) => request(`/frames/${frameId}/assist`, { method: 'POST', body }),
|
||||
assist: (frameId, body, { signal } = {}) => request(`/frames/${frameId}/assist`, { method: 'POST', body, signal }),
|
||||
// The whole frame-local exemplar pool, re-sent on every drag (REQ-173/174).
|
||||
exemplarLabel: (frameId, body) =>
|
||||
request(`/frames/${frameId}/exemplar-label`, { method: 'POST', body }),
|
||||
|
||||
@@ -558,6 +558,7 @@ main.page {
|
||||
.frame-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(17, 24, 39, 0.45);
|
||||
@@ -628,6 +629,34 @@ main.page {
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
/* Track-forward skipped this frame: it already held the class (REQ-189). */
|
||||
.thumb .skip-dot {
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--warn);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
/* Frame count for Track Forward, in the frame bar next to the button. */
|
||||
.track-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-count input {
|
||||
width: 64px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.review-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import React from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
export default function Filmstrip({ frames, index, onSelectIndex, stripRef }) {
|
||||
export default function Filmstrip({ frames, index, onSelectIndex, stripRef, markedIds, markLabel = 'already has it' }) {
|
||||
return (
|
||||
<div className="filmstrip" ref={stripRef}>
|
||||
{frames.map((item, position) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`thumb ${item.review_status}`}
|
||||
aria-current={position === index}
|
||||
onClick={() => onSelectIndex(position)}
|
||||
title={`${item.filename} — ${item.review_status}`}
|
||||
>
|
||||
<img src={api.frameUrl(item.id, 120)} alt="" loading="lazy" />
|
||||
{item.annotation_count > 0 && <span className="badge">{item.annotation_count}</span>}
|
||||
</button>
|
||||
))}
|
||||
{frames.map((item, position) => {
|
||||
const marked = markedIds?.has(item.id)
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`thumb ${item.review_status}`}
|
||||
aria-current={position === index}
|
||||
onClick={() => onSelectIndex(position)}
|
||||
title={`${item.filename} — ${item.review_status}${marked ? ` — ${markLabel}` : ''}`}
|
||||
>
|
||||
<img src={api.frameUrl(item.id, 120)} alt="" loading="lazy" />
|
||||
{item.annotation_count > 0 && <span className="badge">{item.annotation_count}</span>}
|
||||
{marked && <span className="skip-dot" aria-hidden="true" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export default function ShortcutsPanel() {
|
||||
</div>
|
||||
<div>
|
||||
<dt><kbd>C</kbd> <kbd>T</kbd></dt>
|
||||
<dd>Copy prev / Track 5 frames</dd>
|
||||
<dd>Copy prev / Track forward</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,13 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
const [hiddenClasses, setHiddenClasses] = useState(() => new Set())
|
||||
const [hiddenShapeIds, setHiddenShapeIds] = useState(() => new Set())
|
||||
const [overriddenShapeIds, setOverriddenShapeIds] = useState(() => new Set())
|
||||
const [trackFrames, setTrackFrames] = useState(() => {
|
||||
const stored = Number(localStorage.getItem('trackFrames'))
|
||||
return Number.isFinite(stored) && stored >= 1 ? Math.min(100, Math.round(stored)) : 5
|
||||
})
|
||||
const [trackAbort, setTrackAbort] = useState(null)
|
||||
const [skippedIds, setSkippedIds] = useState(() => new Set())
|
||||
const [skippedLabel, setSkippedLabel] = useState('')
|
||||
|
||||
const batchId = batch?.id || rawBatchId
|
||||
const stripRef = useRef(null)
|
||||
@@ -354,11 +361,11 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
}
|
||||
}, [frames, index, frame, hiddenClasses])
|
||||
|
||||
const trackForward = useCallback(async () => {
|
||||
const trackForward = useCallback(async (toEnd = false) => {
|
||||
if (busy) return
|
||||
if (!frames?.length) return
|
||||
if (selectedId == null) {
|
||||
setError('Select a shape first — Track 5 Frames follows one shape forward.')
|
||||
setError('Select a shape first — Track Forward follows one shape forward.')
|
||||
return
|
||||
}
|
||||
const current = annotations.find((a) => a.id === selectedId)
|
||||
@@ -384,37 +391,71 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
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 Forward needs one.')
|
||||
return
|
||||
}
|
||||
const ahead = frames.slice(index + 1, index + 6)
|
||||
const count = Number(trackFrames) || 5
|
||||
const ahead = toEnd ? frames.slice(index + 1) : frames.slice(index + 1, index + 1 + count)
|
||||
if (!ahead.length) {
|
||||
setError('Last frame — Track 5 Frames has no frames ahead to follow into.')
|
||||
setError('Last frame — nothing ahead to follow into.')
|
||||
return
|
||||
}
|
||||
if (toEnd && !window.confirm(`Track ${ahead.length} frames to the end of the batch? That is one GPU call per frame.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
const className = (project?.classes ?? []).find((item) => item.class_id === current.class_id)?.name || 'that class'
|
||||
const controller = new AbortController()
|
||||
setTrackAbort(controller)
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setSkippedIds(new Set())
|
||||
setSkippedLabel('')
|
||||
try {
|
||||
let tracked = 0
|
||||
let skipped = 0
|
||||
const failures = []
|
||||
const marked = new Set()
|
||||
for (let i = 0; i < ahead.length; i++) {
|
||||
const targetFrame = ahead[i]
|
||||
// A frame that already holds this class is left alone — hidden or
|
||||
// dimmed shapes count. It is reported, never silently passed over.
|
||||
if ((targetFrame.class_ids ?? []).includes(current.class_id)) {
|
||||
skipped++
|
||||
marked.add(targetFrame.id)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await api.assist(targetFrame.id, { box, class_id: current.class_id })
|
||||
await api.assist(targetFrame.id, { box, class_id: current.class_id }, { signal: controller.signal })
|
||||
tracked++
|
||||
patchFrameLocally(targetFrame.id, { annotation_count: (targetFrame.annotation_count ?? 0) + 1 })
|
||||
patchFrameLocally(targetFrame.id, {
|
||||
annotation_count: (targetFrame.annotation_count ?? 0) + 1,
|
||||
// Keeps the skip check honest for a second run in this session.
|
||||
class_ids: (targetFrame.class_ids ?? []).includes(current.class_id)
|
||||
? targetFrame.class_ids
|
||||
: [...(targetFrame.class_ids ?? []), current.class_id],
|
||||
})
|
||||
} catch (exc) {
|
||||
if (controller.signal.aborted) {
|
||||
failures.push(`cancelled — frame ${index + i + 2} may still be saved by the server`)
|
||||
break
|
||||
}
|
||||
failures.push(`frame ${index + i + 2}: ${exc.message}`)
|
||||
}
|
||||
}
|
||||
if (failures.length) {
|
||||
setError(`Tracked ${tracked} of ${ahead.length} — ${failures.join(' · ')}`)
|
||||
if (marked.size) {
|
||||
setSkippedIds(marked)
|
||||
setSkippedLabel(className)
|
||||
}
|
||||
const parts = [`Tracked ${tracked} of ${ahead.length}`]
|
||||
if (skipped) parts.push(`skipped ${skipped} already had ${className}`)
|
||||
if (failures.length) parts.push(failures.join(' · '))
|
||||
if (tracked === 0 || skipped || failures.length) setError(parts.join(' — '))
|
||||
} finally {
|
||||
setTrackAbort(null)
|
||||
setBusy(false)
|
||||
}
|
||||
}, [annotations, selectedId, index, frames, busy])
|
||||
}, [annotations, selectedId, index, frames, busy, trackFrames, project])
|
||||
|
||||
const stateRef = useRef({})
|
||||
stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds, toggleHideSelected }
|
||||
@@ -451,7 +492,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
else if (key === 'u' || key === 'U') jumpToPending?.()
|
||||
else if (key === 'n' || key === 'N') jumpToNextAnnotated?.()
|
||||
else if (key === 'c' || key === 'C') copyPrevious?.()
|
||||
else if (key === 't' || key === 'T') trackForward?.()
|
||||
else if (key === 't' || key === 'T') trackForward?.(false)
|
||||
else if (key === 'h' || key === 'H') toggleHideSelected?.()
|
||||
else if (key === 'Delete' || key === 'Backspace') removeSelected?.()
|
||||
else if (/^[1-9]$/.test(key)) {
|
||||
@@ -685,18 +726,49 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
>
|
||||
📋 Copy Prev [C]
|
||||
</button>
|
||||
<label className="track-count" htmlFor="track-count-input">
|
||||
Track
|
||||
<input
|
||||
id="track-count-input" type="number" min="1" max="100" step="1"
|
||||
value={trackFrames}
|
||||
onChange={(e) => setTrackFrames(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Clamp and persist here, not in an effect: an effect would
|
||||
// save the half-typed value on every keystroke.
|
||||
const n = Number(trackFrames)
|
||||
const next = Number.isFinite(n) && n >= 1 ? Math.min(100, Math.round(n)) : 5
|
||||
setTrackFrames(next)
|
||||
localStorage.setItem('trackFrames', String(next))
|
||||
}}
|
||||
/>
|
||||
frames
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ padding: '2px 8px', fontSize: '0.78rem', borderColor: 'rgba(56, 189, 248, 0.5)', color: '#38bdf8', background: 'rgba(56, 189, 248, 0.1)' }}
|
||||
onClick={trackForward}
|
||||
onClick={() => trackForward(false)}
|
||||
disabled={selectedId == null || index >= frames.length - 1}
|
||||
title="SAM3 auto-track selected shape for next 5 frames [T]"
|
||||
title={`SAM3 auto-track selected shape forward ${trackFrames} frames [T]`}
|
||||
>
|
||||
🚀 Track 5 Frames [T]
|
||||
🚀 Track Forward [T]
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ padding: '2px 8px', fontSize: '0.78rem', borderColor: 'rgba(56, 189, 248, 0.5)', color: '#38bdf8', background: 'rgba(56, 189, 248, 0.1)' }}
|
||||
onClick={() => trackForward(true)}
|
||||
disabled={selectedId == null || index >= frames.length - 1}
|
||||
title="SAM3 auto-track selected shape forward to the end of the batch"
|
||||
>
|
||||
→ End
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
{busy && <span className="muted">asking SAM3…</span>}
|
||||
{busy && (
|
||||
trackAbort
|
||||
? <button type="button" className="btn" onClick={() => trackAbort.abort()}>Cancel</button>
|
||||
: <span className="muted">asking SAM3…</span>
|
||||
)}
|
||||
<button className="btn btn-danger" onClick={() => setStatus('rejected')}><XIcon size={14} /> Reject [X]</button>
|
||||
<button className="btn btn-primary" onClick={() => setStatus('approved')}><CheckIcon size={14} /> Approve [A]</button>
|
||||
</div>
|
||||
@@ -705,7 +777,11 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
<QuickReclassBar classesList={classesList} reclass={reclass} removeSelected={removeSelected} />
|
||||
)}
|
||||
|
||||
<Filmstrip frames={frames} index={index} onSelectIndex={setIndex} stripRef={stripRef} />
|
||||
<Filmstrip
|
||||
frames={frames} index={index} onSelectIndex={setIndex} stripRef={stripRef}
|
||||
markedIds={skippedIds}
|
||||
markLabel={`already has ${skippedLabel}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ReviewSidebar
|
||||
|
||||
Reference in new issue
Block a user