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' import ExemplarFilterPanel from '../components/ExemplarFilterPanel' import useExemplarPool from '../hooks/useExemplarPool' // How much bigger the seed box gets for each frame after the first, so the // object has room to have moved. Hand-tuned, not learned (REQ-189). The box // always derives from the drawn shape — never from a previous run's result. const SEED_GROWTH = 1.5 // SAM3 assist wants one normalized [x0, y0, x1, y1] box (REQ-043). A bbox // shape already is that; a polygon is reduced to its bounding box. function shapeBox(geometry) { const pts = geometry?.points if (geometry?.type === 'bbox' && pts?.length === 4) return pts if (geometry?.type === 'polygon' && pts?.length) { 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 } return [minX, minY, maxX, maxY] } return null } function growBox([x0, y0, x1, y1], k) { const cx = (x0 + x1) / 2 const cy = (y0 + y1) / 2 const hw = ((x1 - x0) * k) / 2 const hh = ((y1 - y0) * k) / 2 const clamp = (v) => Math.min(1, Math.max(0, v)) return [clamp(cx - hw), clamp(cy - hh), clamp(cx + hw), clamp(cy + hh)] } 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 [mode, setMode] = useState('draw') const [markedIds, setMarkedIds] = useState([]) 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 [hiddenClasses, setHiddenClasses] = useState(() => new Set()) const [hiddenShapeIds, setHiddenShapeIds] = useState(() => new Set()) const [overriddenShapeIds, setOverriddenShapeIds] = useState(() => new Set()) 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) setMarkedIds([]) 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) if (hiddenClasses.has(created.class_id)) setOverriddenShapeIds((current) => new Set(current).add(created.id)) patchFrameLocally(frame.id, { annotation_count: (frame.annotation_count ?? 0) + 1 }) } catch (exc) { setError(exc.message) } } // The exemplar pool (REQ-173/174/175) owns the drag gesture in draw mode. const onExemplarApplied = useCallback((rows) => { setAnnotations(rows) setSelectedId(null) if (frame) patchFrameLocally(frame.id, { annotation_count: rows.length }) }, [frame]) const pool = useExemplarPool({ frameId: frame?.id, classId: activeClass, onApplied: onExemplarApplied, onError: setError, onBusy: setBusy, }) 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) if (hiddenClasses.has(created.class_id)) setOverriddenShapeIds((current) => new Set(current).add(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 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 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((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) { 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 toggleHide = useCallback((classId) => { setHiddenClasses((current) => { const next = new Set(current) if (next.has(classId)) next.delete(classId) else next.add(classId) return next }) // REQ-185: toggling a class drops its per-shape overrides — re-hiding a // class starts from a clean slate. const classShapeIds = annotations.filter((row) => row.class_id === classId).map((row) => row.id) setOverriddenShapeIds((current) => { if (!classShapeIds.some((id) => current.has(id))) return current const next = new Set(current) classShapeIds.forEach((id) => next.delete(id)) return next }) }, [annotations]) // REQ-185: the eye first un-hides an H-hidden shape, otherwise it overrides // the class hide — one rule, in one place. function restoreShape(id) { if (hiddenShapeIds.has(id)) { setHiddenShapeIds((current) => { const next = new Set(current) next.delete(id) return next }) } else { setOverriddenShapeIds((current) => (current.has(id) ? current : new Set(current).add(id))) } } // REQ-185: neither the shape's own hide nor its class's hide (without an // override) may reach the canvas. Session-only, never persisted. const isShapeVisible = (row) => !hiddenShapeIds.has(row.id) && (!hiddenClasses.has(row.class_id) || overriddenShapeIds.has(row.id)) const visibleAnnotations = annotations.filter(isShapeVisible) // REQ-185: H toggles hide on the single selection and every marked shape. function toggleHideSelected() { const ids = [...new Set([selectedId, ...markedIds].filter((id) => id != null))] const rows = ids.map((id) => annotations.find((row) => row.id === id)).filter(Boolean) if (!rows.length) return const toHide = rows.filter((row) => isShapeVisible(row)).map((row) => row.id) const toUnhide = rows.filter((row) => !isShapeVisible(row)).map((row) => row.id) if (toHide.length) { setHiddenShapeIds((current) => { const next = new Set(current) toHide.forEach((id) => next.add(id)) return next }) // Flags must not contradict: hiding drops any class-hide override. setOverriddenShapeIds((current) => { if (!toHide.some((id) => current.has(id))) return current const next = new Set(current) toHide.forEach((id) => next.delete(id)) return next }) } if (toUnhide.length) { setHiddenShapeIds((current) => { if (!toUnhide.some((id) => current.has(id))) return current const next = new Set(current) toUnhide.forEach((id) => next.delete(id)) return next }) } } const markAll = useCallback(() => setMarkedIds(visibleAnnotations.map((row) => row.id)), [visibleAnnotations]) 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) 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]) // 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() { 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 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) const overridden = created.filter((row) => hiddenClasses.has(row.class_id)).map((row) => row.id) if (overridden.length) setOverriddenShapeIds((current) => { const next = new Set(current) overridden.forEach((id) => next.add(id)) return next }) } catch (exc) { setError(exc.message) } }, [frames, index, frame, hiddenClasses]) const trackForward = useCallback(async () => { if (busy) return if (!frames?.length) return if (selectedId == null) { setError('Select a shape first — Track 5 Frames follows one shape forward.') return } const current = annotations.find((a) => a.id === selectedId) if (!current) { setError('That shape is gone — select another one.') return } const firstBox = shapeBox(current.geometry) if (!firstBox) { setError('That shape has no usable box — Track 5 Frames needs one.') return } const ahead = frames.slice(index + 1, index + 6) if (!ahead.length) { setError('Last frame — Track 5 Frames has no frames ahead to follow into.') return } setBusy(true) setError('') try { let tracked = 0 const failures = [] for (let i = 0; i < ahead.length; i++) { const targetFrame = ahead[i] // Every frame is seeded from the drawn box, never from a previous // result, so a bad read on one frame cannot carry into the next. // Frames 2-5 get it grown about its centre — the object has had // time to move by then. const seedBox = i === 0 ? firstBox : growBox(firstBox, SEED_GROWTH) try { await api.assist(targetFrame.id, { box: seedBox, class_id: current.class_id, }) tracked++ patchFrameLocally(targetFrame.id, { annotation_count: (targetFrame.annotation_count ?? 0) + 1 }) } catch (exc) { failures.push(`frame ${index + i + 2}: ${exc.message}`) } } if (failures.length) { setError(`Tracked ${tracked} of ${ahead.length} — ${failures.join(' · ')}`) } } finally { setBusy(false) } }, [annotations, selectedId, index, frames, busy]) const stateRef = useRef({}) stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds, toggleHideSelected } useEffect(() => { function onKeyDown(event) { if (event.target?.matches?.('input, textarea, select, [contenteditable="true"]')) return // 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, toggleHideSelected } = 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', 'c', 'C', 't', 'T', 'v', 'V', 'h', 'H', '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)) 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 === 'c' || key === 'C') copyPrevious?.() else if (key === 't' || key === 'T') trackForward?.() else if (key === 'h' || key === 'H') toggleHideSelected?.() 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
Loading batch data…
const reviewed = (batch.review?.approved ?? 0) + (batch.review?.rejected ?? 0) const classesList = project.classes ?? [] const activeClassName = classesList.find((item) => item.class_id === activeClass)?.name ?? 'this class' 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) } } async function clearClassInFrame(item) { const doomed = annotations.filter((row) => row.class_id === item.class_id) if (!doomed.length || !frame) return const ids = new Set(doomed.map((row) => row.id)) const previous = annotations const previousCount = frame.annotation_count ?? 0 setAnnotations((rows) => rows.filter((row) => !ids.has(row.id))) setMarkedIds((rows) => rows.filter((id) => !ids.has(id))) patchFrameLocally(frame.id, { annotation_count: Math.max(0, previousCount - doomed.length) }) try { await api.bulkDeleteAnnotations(doomed.map((row) => row.id)) setBatch(await api.getBatch(batchId)) } catch (exc) { setAnnotations(previous) setMarkedIds((rows) => [...rows, ...doomed.filter((row) => !rows.includes(row.id)).map((row) => row.id)]) patchFrameLocally(frame.id, { annotation_count: previousCount }) setError(exc.message) } } return ( <>{batch.frame_count} frames · {reviewed}/{batch.frame_count} reviewed · {' '} 0 ? '#c084fc' : '#a1a1aa' }}>{batch.annotation_count} shapes · status {batch.status}