The chained attempt (1530bbc) fed each run's result into the next frame's
seed, so a wrong read on one frame carried into the ones after it. Runs are
independent again: every frame is seeded from the drawn shape, frames 2-5
with it grown x1.5 about its centre for motion tolerance. REQ-189, ui-spec
and tasks.md updated to the independent contract.
773 lines
33 KiB
React
773 lines
33 KiB
React
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 <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 ?? []
|
|
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 (
|
|
<>
|
|
<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` : 'Tune the filter for this batch, then merge it into a dataset'}
|
|
onClick={toDataPrep}
|
|
>
|
|
{batch.status === 'merged' ? 'Merged' : 'Prepare & merge'}
|
|
</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={visibleAnnotations}
|
|
selectedId={selectedId}
|
|
activeClass={activeClass}
|
|
assistMode={assistMode}
|
|
classes={classesList}
|
|
mode={mode}
|
|
selectedIds={markedIds}
|
|
exemplars={pool.exemplars}
|
|
preview={pool.preview?.shapes ?? null}
|
|
onSelect={setSelectedId}
|
|
onCreate={createShape}
|
|
onUpdate={updateShape}
|
|
onAssist={assist}
|
|
onExemplar={pool.add}
|
|
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={!visibleAnnotations.length}
|
|
>
|
|
Select all ({visibleAnnotations.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>
|
|
</>
|
|
)}
|
|
{mode === 'draw' && (
|
|
<span className="muted" style={{ fontSize: '0.78rem' }}>
|
|
{pool.exemplars.length
|
|
? `${pool.exemplars.filter((e) => e.positive).length} example(s), `
|
|
+ `${pool.exemplars.filter((e) => !e.positive).length} negative — `
|
|
+ 'tune the filters, then Apply'
|
|
: 'Drag or double-click an example · Shift-drag/click = not this'}
|
|
</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">
|
|
{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>
|
|
<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>
|
|
<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
|
|
exemplarPanel={mode === 'draw' && pool.active && (
|
|
<ExemplarFilterPanel
|
|
filters={pool.filters}
|
|
preview={pool.preview}
|
|
busy={busy}
|
|
className={activeClassName}
|
|
negatives={pool.exemplars.filter((item) => !item.positive).length}
|
|
replacing={annotations.filter((row) => row.class_id === activeClass).length}
|
|
onFilter={pool.setFilter}
|
|
onReset={pool.resetFilters}
|
|
onApply={pool.apply}
|
|
onDiscard={pool.discard}
|
|
onUndo={pool.undo}
|
|
/>
|
|
)}
|
|
classesList={classesList}
|
|
activeClass={activeClass}
|
|
reclass={reclass}
|
|
clearClassInBatch={clearClassInBatch}
|
|
clearClassInFrame={clearClassInFrame}
|
|
hiddenClasses={hiddenClasses}
|
|
onToggleHide={toggleHide}
|
|
hiddenShapeIds={hiddenShapeIds}
|
|
overriddenShapeIds={overriddenShapeIds}
|
|
onRestoreShape={restoreShape}
|
|
annotations={annotations}
|
|
selectedId={selectedId}
|
|
setSelectedId={setSelectedId}
|
|
removeAnnotation={removeAnnotation}
|
|
project={project}
|
|
jumpToNextAnnotated={jumpToNextAnnotated}
|
|
batchAnnotationCount={batch?.annotation_count ?? 0}
|
|
/>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|