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:
asus committed 2026-08-14 16:28:52 +07:00
1 parent 8285400254
commit 5c7c122105
80 files changed
+20074 -1412

No files matched your search

+254 -18
View File
@@ -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}