feat: setup dataset enrichment app codebase and scripts

This commit is contained in:
asus committed 2026-08-05 11:52:27 +07:00
1 parent b5c28cc98a
commit d07578462e
72 files changed
+11370

No files matched your search

+338
View File
@@ -0,0 +1,338 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { navigate } from '../App'
import AnnotationCanvas from '../components/AnnotationCanvas'
import Filmstrip from '../components/Filmstrip'
import { AlertIcon, CheckIcon, XIcon } from '../components/Icons'
import QuickReclassBar from '../components/QuickReclassBar'
import ReviewSidebar from '../components/ReviewSidebar'
export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }) {
const [batch, setBatch] = useState(null)
const [project, setProject] = useState(null)
const [frames, setFrames] = useState([])
const [index, setIndex] = useState(0)
const [annotations, setAnnotations] = useState([])
const [selectedId, setSelectedId] = useState(null)
const [activeClass, setActiveClass] = useState(0)
const [assistMode, setAssistMode] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [activeJob, setActiveJob] = useState(null)
const batchId = batch?.id || rawBatchId
const stripRef = useRef(null)
const hasInitialAutoJump = useRef(false)
const frame = frames[index]
const reload = useCallback(async () => {
let targetBatchId = rawBatchId
if (!targetBatchId && projectId) {
const bList = await api.listBatches(projectId)
if (bList?.batches?.length > 0) targetBatchId = bList.batches[0].id
}
if (!targetBatchId) throw new Error('No batch found to review')
const loaded = await api.getBatch(targetBatchId)
setBatch(loaded)
const payload = await api.listFrames(targetBatchId)
setFrames(payload.frames)
if (!hasInitialAutoJump.current && payload.frames?.length > 0) {
hasInitialAutoJump.current = true
const firstAnnotated = payload.frames.findIndex((f) => (f.annotation_count ?? 0) > 0)
if (firstAnnotated > 0) setIndex(firstAnnotated)
}
const jobsPayload = await api.listJobs(loaded.project_id).catch(() => ({ jobs: [] }))
const currentJob = jobsPayload.jobs?.find(
(j) => j.batch_id === Number(targetBatchId) && ['queued', 'running'].includes(j.status)
)
setActiveJob(currentJob || null)
return loaded
}, [rawBatchId, projectId])
useEffect(() => {
reload().then((loaded) => api.getProject(loaded.project_id)).then((loadedProject) => {
setProject(loadedProject)
onProject?.(loadedProject)
}).catch((exc) => setError(exc.message))
}, [rawBatchId, projectId, reload])
const prevJobId = useRef(activeJob?.id)
useEffect(() => {
if (prevJobId.current && !activeJob) {
hasInitialAutoJump.current = false
reload().catch(() => {})
}
prevJobId.current = activeJob?.id
if (!activeJob) return
const timer = setInterval(() => {
reload().catch(() => {})
if (frame) api.frameAnnotations(frame.id).then((p) => setAnnotations(p.annotations)).catch(() => {})
}, 2000)
return () => clearInterval(timer)
}, [activeJob, reload, frame?.id])
useEffect(() => {
if (!frame) return
let cancelled = false
api.frameAnnotations(frame.id).then((payload) => !cancelled && setAnnotations(payload.annotations)).catch((exc) => !cancelled && setError(exc.message))
setSelectedId(null)
return () => { cancelled = true }
}, [frame?.id])
useEffect(() => {
const active = stripRef.current?.querySelector('[aria-current="true"]')
active?.scrollIntoView({ block: 'nearest', inline: 'center' })
}, [index])
function patchFrameLocally(frameId, patch) {
setFrames((rows) => rows.map((row) => (row.id === frameId ? { ...row, ...patch } : row)))
}
const setStatus = useCallback(async (status) => {
if (!frame) return
patchFrameLocally(frame.id, { review_status: status })
try {
await api.setFrameStatus(frame.id, status)
setBatch(await api.getBatch(batchId))
} catch (exc) { setError(exc.message) }
setIndex((current) => Math.min(current + 1, frames.length - 1))
}, [frame, frames.length, batchId])
async function createShape(geometry) {
try {
const created = await api.addAnnotation(frame.id, { class_id: activeClass, geometry })
setAnnotations((rows) => [...rows, created])
setSelectedId(created.id)
patchFrameLocally(frame.id, { annotation_count: (frame.annotation_count ?? 0) + 1 })
} catch (exc) { setError(exc.message) }
}
async function assist(box) {
setBusy(true); setError('')
try {
const created = await api.assist(frame.id, { box, class_id: activeClass })
setAnnotations((rows) => [...rows, created])
setSelectedId(created.id)
} catch (exc) { setError(exc.message) } finally { setBusy(false) }
}
async function updateShape(id, geometry, { local, commit } = {}) {
if (local && geometry) {
setAnnotations((rows) => rows.map((row) => (row.id === id ? { ...row, geometry } : row)))
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 removeSelected = useCallback(async () => {
if (selectedId == null || !frame) return
const id = selectedId
setAnnotations((rows) => rows.filter((row) => row.id !== id))
setSelectedId(null)
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])
const reclass = useCallback(async (classId) => {
setActiveClass(classId)
if (selectedId == null) return
try {
const updated = await api.patchAnnotation(selectedId, { class_id: classId })
setAnnotations((rows) => rows.map((row) => (row.id === updated.id ? updated : row)))
} 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) }
}
async function jumpToPending() {
try {
const { frame_id: frameId } = await api.nextPending(batchId, frame?.idx ?? -1)
const position = frames.findIndex((row) => row.id === frameId)
if (position >= 0) setIndex(position)
} catch (exc) { setError(exc.message) }
}
const jumpToNextAnnotated = useCallback(() => {
if (!frames?.length) return
const nextIdx = frames.findIndex((f, idx) => idx > index && (f.annotation_count ?? 0) > 0)
if (nextIdx >= 0) setIndex(nextIdx)
else {
const firstIdx = frames.findIndex((f) => (f.annotation_count ?? 0) > 0)
if (firstIdx >= 0) setIndex(firstIdx)
}
}, [frames, index])
const stateRef = useRef({})
stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode }
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
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)
if (isShortcutKey) { event.preventDefault(); event.stopPropagation() }
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))
else if (key === 'a' || key === 'A') setStatus?.('approved')
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 === 'Delete' || key === 'Backspace') removeSelected?.()
else if (/^[1-9]$/.test(key)) {
const classId = Number(key) - 1
if (project && classId < project.classes.length) reclass?.(classId)
}
}
function onKeyUp(event) {
if (event.target?.matches?.('input, textarea, select, [contenteditable="true"]')) return
if (event.key === 's' || event.key === 'S') { event.preventDefault(); event.stopPropagation(); stateRef.current.setAssistMode?.(false) }
}
document.addEventListener('keydown', onKeyDown, true)
document.addEventListener('keyup', onKeyUp, true)
return () => {
document.removeEventListener('keydown', onKeyDown, true)
document.removeEventListener('keyup', onKeyUp, true)
}
}, [])
if (error && !batch) return <p className="error-banner" style={{ margin: 20 }}><AlertIcon size={14} /> {error}</p>
if (!batch || !project) return <p className="empty" style={{ margin: 20, color: 'var(--text-muted)' }}>Loading batch data…</p>
const reviewed = (batch.review?.approved ?? 0) + (batch.review?.rejected ?? 0)
const classesList = project.classes ?? []
async function approveAllFrames() {
if (!window.confirm(`Mark all ${batch.review?.pending ?? 0} pending frames as approved?`)) return
try { await api.approveAllBatchFrames(batchId); await reload() } catch (exc) { setError(exc.message) }
}
async function clearClassInBatch(item) {
if (!window.confirm(`Clear all shapes of class "${item.name}" across ALL frames in this batch?`)) return
try {
await api.clearBatchClassAnnotations(batchId, item.class_id)
await reload()
if (frame) {
const payload = await api.frameAnnotations(frame.id)
setAnnotations(payload.annotations)
}
} catch (exc) { setError(exc.message) }
}
return (
<>
<div className="page-head">
<div>
<h1>{batch.date_label} · {batch.batch_label}</h1>
<p className="muted">
{batch.frame_count} frames · {reviewed}/{batch.frame_count} reviewed ·
{' '}<strong style={{ color: batch.annotation_count > 0 ? '#c084fc' : '#a1a1aa' }}>{batch.annotation_count} shapes</strong> · status {batch.status}
</p>
</div>
<span className="spacer" />
{(batch.review?.pending ?? 0) > 0 && batch.status !== 'merged' && (
<button className="btn" style={{ marginRight: 8 }} title="Mark all pending frames in this batch as approved" onClick={approveAllFrames}>
Approve All Frames ({batch.review?.pending})
</button>
)}
<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}
>
{batch.status === 'merged' ? 'Merged' : 'Approve batch'}
</button>
</div>
{activeJob && (
<div className="panel side-panel" style={{ marginBottom: 12, border: '1px solid rgba(168, 85, 247, 0.4)', background: 'rgba(24, 24, 27, 0.8)' }}>
<div className="row" style={{ fontSize: '0.85rem' }}>
<span className="dot ok" />
<strong style={{ textTransform: 'capitalize' }}>Auto-labeling in progress…</strong>
<span className="spacer" />
<span className="mono">{activeJob.progress}/{activeJob.total || '—'} frames</span>
</div>
<div className="progress" style={{ margin: '6px 0' }}>
<span style={{ width: `${activeJob.total ? (activeJob.progress / activeJob.total) * 100 : 50}%` }} />
</div>
</div>
)}
{error && <p className="error-banner" style={{ marginBottom: 12 }}><AlertIcon size={14} /> {error}</p>}
<div className="review">
<div className="review-main">
{frame && (
<AnnotationCanvas
frame={frame}
imageUrl={api.frameUrl(frame.id)}
annotations={annotations}
selectedId={selectedId}
activeClass={activeClass}
assistMode={assistMode}
classes={classesList}
onSelect={setSelectedId}
onCreate={createShape}
onUpdate={updateShape}
onAssist={assist}
/>
)}
<div className="frame-bar">
<button className="btn" onClick={() => setIndex((i) => Math.max(0, i - 1))} disabled={index === 0}>←</button>
<span className="mono">
{index + 1} / {frames.length}
{frame && <span className={`status-pill ${frame.review_status}`}>{frame.review_status}</span>}
</span>
<button className="btn" onClick={() => setIndex((i) => Math.min(frames.length - 1, i + 1))} disabled={index >= frames.length - 1}>→</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={jumpToNextAnnotated}
title="Jump to next frame with annotations [N]"
>
🏷️ Next Shape [N]
</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>
<button className="btn btn-primary" onClick={() => setStatus('approved')}><CheckIcon size={14} /> Approve [A]</button>
</div>
{selectedId != null && (
<QuickReclassBar classesList={classesList} reclass={reclass} removeSelected={removeSelected} />
)}
<Filmstrip frames={frames} index={index} onSelectIndex={setIndex} stripRef={stripRef} />
</div>
<ReviewSidebar
classesList={classesList}
activeClass={activeClass}
reclass={reclass}
clearClassInBatch={clearClassInBatch}
annotations={annotations}
selectedId={selectedId}
setSelectedId={setSelectedId}
removeSelected={removeSelected}
project={project}
jumpToNextAnnotated={jumpToNextAnnotated}
batchAnnotationCount={batch?.annotation_count ?? 0}
/>
</div>
</>
)
}