feat: setup dataset enrichment app codebase and scripts
This commit is contained in:
1 parent
b5c28cc98a
commit
d07578462e
72 files changed
+11370
No files matched your search
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user