feat: add counting bench, triage, and dataset modules
This commit includes major additions and updates to the frontend and backend architectures, introducing new dataset management, live counting features, batch processing, and triage logic. Includes new UI pages, components, and API routes.
This commit is contained in:
1 parent
8285400254
commit
5c7c122105
80 files changed
+20074
-1412
No files matched your search
@@ -14,6 +14,8 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
const [index, setIndex] = useState(0)
|
||||
const [annotations, setAnnotations] = useState([])
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [mode, setMode] = useState('draw')
|
||||
const [markedIds, setMarkedIds] = useState([])
|
||||
const [activeClass, setActiveClass] = useState(0)
|
||||
const [assistMode, setAssistMode] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -78,6 +80,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
let cancelled = false
|
||||
api.frameAnnotations(frame.id).then((payload) => !cancelled && setAnnotations(payload.annotations)).catch((exc) => !cancelled && setError(exc.message))
|
||||
setSelectedId(null)
|
||||
setMarkedIds([])
|
||||
return () => { cancelled = true }
|
||||
}, [frame?.id])
|
||||
|
||||
@@ -124,19 +127,84 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
return
|
||||
}
|
||||
if (!commit) return
|
||||
const current = annotations.find((row) => row.id === id)
|
||||
if (!current) return
|
||||
try { await api.patchAnnotation(id, { geometry: current.geometry }) } catch (exc) { setError(exc.message) }
|
||||
const previous = annotations.find((row) => row.id === id)
|
||||
if (!previous) return
|
||||
// A commit may carry its own geometry (delete-vertex sends the shortened
|
||||
// polygon); falling back to the row's geometry covers drag/resize, which
|
||||
// already wrote through the {local:true} path.
|
||||
const next = geometry ?? previous.geometry
|
||||
setAnnotations((rows) => rows.map((row) => (row.id === id ? { ...row, geometry: next } : row)))
|
||||
try {
|
||||
await api.patchAnnotation(id, { geometry: next })
|
||||
} catch (exc) {
|
||||
setAnnotations((rows) => rows.map((row) => (row.id === id ? previous : row)))
|
||||
setError(exc.message)
|
||||
}
|
||||
}
|
||||
|
||||
const removeSelected = useCallback(async () => {
|
||||
if (selectedId == null || !frame) return
|
||||
const id = selectedId
|
||||
const removeAnnotation = useCallback(async (id) => {
|
||||
if (id == null || !frame) return
|
||||
const previous = annotations.find((row) => row.id === id)
|
||||
if (!previous) return
|
||||
setAnnotations((rows) => rows.filter((row) => row.id !== id))
|
||||
setSelectedId(null)
|
||||
setSelectedId((current) => (current === id ? null : current))
|
||||
setFrames((rows) => rows.map((row) => (row.id === frame.id ? { ...row, annotation_count: Math.max(0, (row.annotation_count ?? 1) - 1) } : row)))
|
||||
try { await api.deleteAnnotation(id) } catch (exc) { setError(exc.message) }
|
||||
}, [selectedId, frame])
|
||||
try {
|
||||
await api.deleteAnnotation(id)
|
||||
} catch (exc) {
|
||||
setAnnotations((rows) => [...rows, previous])
|
||||
setFrames((rows) => rows.map((row) => (row.id === frame.id ? { ...row, annotation_count: (row.annotation_count ?? 0) + 1 } : row)))
|
||||
setError(exc.message)
|
||||
}
|
||||
}, [annotations, frame])
|
||||
|
||||
const removeSelected = useCallback(() => removeAnnotation(selectedId), [removeAnnotation, selectedId])
|
||||
|
||||
const onMarquee = useCallback((ids, { add = false, toggle = false } = {}) => {
|
||||
setMarkedIds((current) => {
|
||||
if (toggle) {
|
||||
const id = ids[0]
|
||||
return current.includes(id) ? current.filter((row) => row !== id) : [...current, id]
|
||||
}
|
||||
if (!add) return ids
|
||||
return [...new Set([...current, ...ids])]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const markAll = useCallback(() => setMarkedIds(annotations.map((row) => row.id)), [annotations])
|
||||
|
||||
const removeMarked = useCallback(async () => {
|
||||
if (!markedIds.length || !frame) return
|
||||
if (!window.confirm(`Delete ${markedIds.length} selected shape(s) on this frame?`)) return
|
||||
const previous = annotations
|
||||
const doomed = new Set(markedIds)
|
||||
setAnnotations((rows) => rows.filter((row) => !doomed.has(row.id)))
|
||||
setMarkedIds([])
|
||||
patchFrameLocally(frame.id, {
|
||||
annotation_count: Math.max(0, (frame.annotation_count ?? doomed.size) - doomed.size),
|
||||
})
|
||||
try {
|
||||
await api.bulkDeleteAnnotations([...doomed])
|
||||
setBatch(await api.getBatch(batchId))
|
||||
} catch (exc) {
|
||||
setAnnotations(previous)
|
||||
patchFrameLocally(frame.id, { annotation_count: previous.length })
|
||||
setError(exc.message)
|
||||
}
|
||||
}, [markedIds, annotations, frame, batchId])
|
||||
|
||||
const reclassMarked = useCallback(async (classId) => {
|
||||
if (!markedIds.length) return
|
||||
const previous = annotations
|
||||
const targets = new Set(markedIds)
|
||||
setAnnotations((rows) => rows.map((row) => (targets.has(row.id) ? { ...row, class_id: classId } : row)))
|
||||
try {
|
||||
await api.bulkReclassAnnotations([...targets], classId)
|
||||
} catch (exc) {
|
||||
setAnnotations(previous)
|
||||
setError(exc.message)
|
||||
}
|
||||
}, [markedIds, annotations])
|
||||
|
||||
const reclass = useCallback(async (classId) => {
|
||||
setActiveClass(classId)
|
||||
@@ -147,8 +215,10 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
} catch (exc) { setError(exc.message) }
|
||||
}, [selectedId])
|
||||
|
||||
async function approveBatch() {
|
||||
try { await api.approveBatch(batchId); navigate(`/projects/${batch.project_id}/models`) } catch (exc) { setError(exc.message) }
|
||||
// Review signs frames off; it no longer merges. Data Prep is the gate that
|
||||
// turns a selection of batches into a dataset (REQ-131).
|
||||
function toDataPrep() {
|
||||
navigate(`/projects/${batch.project_id}/data-prep?batches=${batchId}`)
|
||||
}
|
||||
|
||||
async function jumpToPending() {
|
||||
@@ -169,17 +239,92 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
}
|
||||
}, [frames, index])
|
||||
|
||||
const copyPrevious = useCallback(async () => {
|
||||
if (!frames?.length || index === 0 || !frame) return
|
||||
const prevFrame = frames[index - 1]
|
||||
if (!prevFrame || (prevFrame.annotation_count ?? 0) === 0) return
|
||||
try {
|
||||
const payload = await api.frameAnnotations(prevFrame.id)
|
||||
if (!payload.annotations?.length) return
|
||||
|
||||
const created = await Promise.all(
|
||||
payload.annotations.map(a => api.addAnnotation(frame.id, { class_id: a.class_id, geometry: a.geometry }))
|
||||
)
|
||||
|
||||
setAnnotations(rows => [...rows, ...created])
|
||||
patchFrameLocally(frame.id, { annotation_count: (frame.annotation_count ?? 0) + created.length })
|
||||
if (created.length > 0) setSelectedId(created[0].id)
|
||||
} catch (exc) {
|
||||
setError(exc.message)
|
||||
}
|
||||
}, [frames, index, frame])
|
||||
|
||||
const trackForward = useCallback(async () => {
|
||||
if (selectedId == null || !frames?.length) return
|
||||
const current = annotations.find((a) => a.id === selectedId)
|
||||
if (!current) return
|
||||
|
||||
let box = null
|
||||
if (current.geometry?.type === 'polygon' && current.geometry.coordinates?.[0]) {
|
||||
const pts = current.geometry.coordinates[0]
|
||||
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
|
||||
}
|
||||
box = [minX, minY, maxX, maxY]
|
||||
}
|
||||
if (!box) return
|
||||
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
let updatedCount = 0
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const nextIdx = index + i
|
||||
if (nextIdx >= frames.length) break
|
||||
const targetFrame = frames[nextIdx]
|
||||
|
||||
await api.assist(targetFrame.id, { box, class_id: current.class_id })
|
||||
updatedCount++
|
||||
patchFrameLocally(targetFrame.id, { annotation_count: (targetFrame.annotation_count ?? 0) + 1 })
|
||||
}
|
||||
} catch (exc) {
|
||||
setError(exc.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [annotations, selectedId, index, frames])
|
||||
|
||||
const stateRef = useRef({})
|
||||
stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode }
|
||||
stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds }
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event) {
|
||||
if (event.target?.matches?.('input, textarea, select, [contenteditable="true"]')) return
|
||||
const { frames, project, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode } = stateRef.current
|
||||
// Ctrl/Cmd/Alt combos belong to the browser and the OS: without this,
|
||||
// Ctrl+S approves the frame and Ctrl+A/C/X/N/T all fire review actions.
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return
|
||||
const { frames, project, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds } = stateRef.current
|
||||
const key = event.key
|
||||
const isShortcutKey = /^[1-9]$/.test(key) || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Delete', 'Backspace', 'a', 'A', 'x', 'X', 'u', 'U', 's', 'S', 'n', 'N'].includes(key)
|
||||
const isShortcutKey = /^[1-9]$/.test(key) || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Delete', 'Backspace', 'a', 'A', 'x', 'X', 'u', 'U', 's', 'S', 'n', 'N', 'c', 'C', 't', 'T', 'v', 'V', 'Escape'].includes(key)
|
||||
if (isShortcutKey) { event.preventDefault(); event.stopPropagation() }
|
||||
|
||||
// In select mode the marquee owns Delete and the digits, otherwise a
|
||||
// 40-box selection would be thrown away by one keystroke meant for it.
|
||||
if (mode === 'select') {
|
||||
if (key === 'v' || key === 'V') { setMode?.('draw'); setMarkedIds?.([]); return }
|
||||
if (key === 'Escape') { setMarkedIds?.([]); return }
|
||||
if ((key === 'Delete' || key === 'Backspace') && markedIds?.length) { removeMarked?.(); return }
|
||||
if (/^[1-9]$/.test(key) && markedIds?.length) {
|
||||
const classId = Number(key) - 1
|
||||
if (project && classId < project.classes.length) reclassMarked?.(classId)
|
||||
return
|
||||
}
|
||||
} else if (key === 'v' || key === 'V') { setMode?.('select'); return }
|
||||
|
||||
if (key === 's' || key === 'S') setAssistMode?.(true)
|
||||
else if (key === 'ArrowLeft') setIndex((i) => Math.max(0, i - 1))
|
||||
else if (key === 'ArrowRight') setIndex((i) => Math.min((frames?.length || 1) - 1, i + 1))
|
||||
@@ -187,6 +332,8 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
else if (key === 'x' || key === 'X') setStatus?.('rejected')
|
||||
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 === 'Delete' || key === 'Backspace') removeSelected?.()
|
||||
else if (/^[1-9]$/.test(key)) {
|
||||
const classId = Number(key) - 1
|
||||
@@ -249,10 +396,10 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={(batch.review?.pending ?? 0) > 0 || batch.status === 'merged'}
|
||||
title={(batch.review?.pending ?? 0) > 0 ? `${batch.review?.pending} frame(s) still pending` : 'Merge the approved frames into the master dataset'}
|
||||
onClick={approveBatch}
|
||||
title={(batch.review?.pending ?? 0) > 0 ? `${batch.review?.pending} frame(s) still pending` : 'Tune the filter for this batch, then merge it into a dataset'}
|
||||
onClick={toDataPrep}
|
||||
>
|
||||
{batch.status === 'merged' ? 'Merged' : 'Approve batch'}
|
||||
{batch.status === 'merged' ? 'Merged' : 'Prepare & merge'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -283,13 +430,82 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
activeClass={activeClass}
|
||||
assistMode={assistMode}
|
||||
classes={classesList}
|
||||
mode={mode}
|
||||
selectedIds={markedIds}
|
||||
onSelect={setSelectedId}
|
||||
onCreate={createShape}
|
||||
onUpdate={updateShape}
|
||||
onAssist={assist}
|
||||
onMarquee={onMarquee}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="frame-bar" style={{ gap: 8 }}>
|
||||
<div className="mode-switch" role="group" aria-label="Canvas mode">
|
||||
{[['draw', 'Draw'], ['select', 'Select']].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={`btn${mode === value ? ' btn-primary' : ''}`}
|
||||
aria-pressed={mode === value}
|
||||
style={{ padding: '2px 10px', fontSize: '0.78rem' }}
|
||||
onClick={() => { setMode(value); setMarkedIds([]) }}
|
||||
title={value === 'draw'
|
||||
? 'Drag on empty canvas to draw a new box [V to switch]'
|
||||
: 'Drag to select every box you touch, then delete or reclass them at once [V to switch]'}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{mode === 'select' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ padding: '2px 8px', fontSize: '0.78rem' }}
|
||||
onClick={markAll}
|
||||
disabled={!annotations.length}
|
||||
>
|
||||
Select all ({annotations.length})
|
||||
</button>
|
||||
<span className="muted" style={{ fontSize: '0.78rem' }}>
|
||||
{markedIds.length
|
||||
? `${markedIds.length} selected · Shift-drag adds · Esc clears`
|
||||
: 'Drag across the boxes you want'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === 'select' && markedIds.length > 0 && (
|
||||
<div className="frame-bar" style={{ gap: 8, borderColor: 'rgba(56, 189, 248, 0.4)' }}>
|
||||
<strong style={{ fontSize: '0.82rem', color: '#38bdf8' }}>
|
||||
{markedIds.length} shape{markedIds.length === 1 ? '' : 's'}
|
||||
</strong>
|
||||
<span className="muted" style={{ fontSize: '0.78rem' }}>reclass to</span>
|
||||
{classesList.map((item) => (
|
||||
<button
|
||||
key={item.class_id}
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ padding: '2px 8px', fontSize: '0.78rem' }}
|
||||
onClick={() => reclassMarked(item.class_id)}
|
||||
title={`Move all ${markedIds.length} into "${item.name}" [${item.class_id + 1}]`}
|
||||
>
|
||||
[{item.class_id + 1}] {item.name}
|
||||
</button>
|
||||
))}
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn" style={{ padding: '2px 8px', fontSize: '0.78rem' }} onClick={() => setMarkedIds([])}>
|
||||
Clear [Esc]
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger" style={{ padding: '2px 8px', fontSize: '0.78rem' }} onClick={removeMarked}>
|
||||
<XIcon size={13} /> Delete {markedIds.length} [Del]
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="frame-bar">
|
||||
<button className="btn" onClick={() => setIndex((i) => Math.max(0, i - 1))} disabled={index === 0}>←</button>
|
||||
<span className="mono">
|
||||
@@ -306,6 +522,26 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
>
|
||||
🏷️ Next Shape [N]
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ padding: '2px 8px', fontSize: '0.78rem', borderColor: 'rgba(168, 85, 247, 0.5)', color: '#c084fc', background: 'rgba(168, 85, 247, 0.1)' }}
|
||||
onClick={copyPrevious}
|
||||
disabled={index === 0 || !(frames?.[index - 1]?.annotation_count > 0)}
|
||||
title="Copy all annotations from the previous frame [C]"
|
||||
>
|
||||
📋 Copy Prev [C]
|
||||
</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}
|
||||
disabled={selectedId == null || index >= frames.length - 1}
|
||||
title="SAM3 auto-track selected shape for next 5 frames [T]"
|
||||
>
|
||||
🚀 Track 5 Frames [T]
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
{busy && <span className="muted">asking SAM3…</span>}
|
||||
<button className="btn btn-danger" onClick={() => setStatus('rejected')}><XIcon size={14} /> Reject [X]</button>
|
||||
@@ -327,7 +563,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
annotations={annotations}
|
||||
selectedId={selectedId}
|
||||
setSelectedId={setSelectedId}
|
||||
removeSelected={removeSelected}
|
||||
removeAnnotation={removeAnnotation}
|
||||
project={project}
|
||||
jumpToNextAnnotated={jumpToNextAnnotated}
|
||||
batchAnnotationCount={batch?.annotation_count ?? 0}
|
||||
|
||||
Reference in new issue
Block a user