feat(dataset): per-detection cards, in-browser editor, bulk-validate for local saves

This commit is contained in:
Armen Rohalov
2026-04-24 00:49:08 +03:00
parent 1fa749382f
commit b0829b4a90
6 changed files with 523 additions and 77 deletions
+32 -1
View File
@@ -6,8 +6,11 @@ import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
import AnnotationsSidebar from './AnnotationsSidebar'
import DetectionClasses from '../../components/DetectionClasses'
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
import { useFlight } from '../../components/FlightContext'
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from './classColors'
import { captureThumbnails } from './thumbnail'
import type { Media, AnnotationListItem, Detection } from '../../types'
export default function AnnotationsPage() {
@@ -22,6 +25,8 @@ export default function AnnotationsPage() {
const rightPanel = useResizablePanel(200, 150, 350)
const videoPlayerRef = useRef<VideoPlayerHandle>(null)
const canvasRef = useRef<CanvasEditorHandle>(null)
const { addMany } = useSavedAnnotations()
const { selectedFlight } = useFlight()
useEffect(() => {
setDetections([])
@@ -34,6 +39,30 @@ export default function AnnotationsPage() {
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
const body = { mediaId: selectedMedia.id, time, detections }
const { fullFrame, detectionThumbnails } = await captureThumbnails(
selectedMedia,
videoPlayerRef.current?.getVideoElement() ?? null,
detections,
)
const pushToStore = (annotationLocalId: string) => {
const createdDate = new Date().toISOString()
addMany(detections.map((d, i) => ({
id: `${annotationLocalId}:${d.id ?? i}`,
annotationLocalId,
mediaId: selectedMedia.id,
mediaName: selectedMedia.name,
thumbnail: detectionThumbnails[i] ?? '',
fullFrame,
status: AnnotationStatus.Created,
source: AnnotationSource.Manual,
createdDate,
detection: d,
time,
flightId: selectedFlight?.id ?? null,
})))
}
if (!selectedMedia.path.startsWith('blob:')) {
try {
await api.post('/api/annotations/annotations', body)
@@ -41,6 +70,7 @@ export default function AnnotationsPage() {
`/api/annotations/annotations?mediaId=${selectedMedia.id}&pageSize=1000`,
)
setAnnotations(res.items)
pushToStore(`saved-${crypto.randomUUID()}`)
return
} catch {
// fall through to local save
@@ -60,7 +90,8 @@ export default function AnnotationsPage() {
detections: [...detections],
}
setAnnotations(prev => [...prev, local])
}, [selectedMedia, detections, currentTime])
pushToStore(local.id)
}, [selectedMedia, detections, currentTime, addMany, selectedFlight])
const handleDownload = useCallback(async (ann: AnnotationListItem) => {
if (!selectedMedia) return
+16 -3
View File
@@ -75,16 +75,29 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
}
const img = new Image()
img.crossOrigin = 'anonymous'
if (annotation && !media.path.startsWith('blob:')) {
const isLocalPath = media.path.startsWith('blob:') || media.path.startsWith('data:')
if (annotation && !isLocalPath) {
img.src = `/api/annotations/annotations/${annotation.id}/image`
} else if (media.path.startsWith('blob:')) {
} else if (isLocalPath) {
img.src = media.path
} else {
img.src = `/api/annotations/media/${media.id}/file`
}
img.onload = () => {
imgRef.current = img
setImgSize({ w: img.naturalWidth, h: img.naturalHeight })
const w = img.naturalWidth
const h = img.naturalHeight
setImgSize({ w, h })
const c = containerRef.current
if (c && w && h) {
const fit = Math.min(c.clientWidth / w, c.clientHeight / h)
const clamped = Math.max(0.05, Math.min(10, fit))
setZoom(clamped)
setPan({
x: (c.clientWidth - w * clamped) / 2,
y: (c.clientHeight - h * clamped) / 2,
})
}
}
}, [media, annotation, isVideo])
+138
View File
@@ -0,0 +1,138 @@
import { MediaType } from '../../types'
import type { Detection, Media } from '../../types'
import { getClassColor } from './classColors'
const THUMB_MAX = 240
const CROP_PAD = 0.15
const FULL_FRAME_MAX = 1280
async function getSourceCanvas(
media: Media,
videoEl: HTMLVideoElement | null,
): Promise<{ canvas: HTMLCanvasElement; w: number; h: number } | null> {
const canvas = document.createElement('canvas')
if (media.mediaType === MediaType.Video && videoEl && videoEl.videoWidth) {
const w = videoEl.videoWidth
const h = videoEl.videoHeight
canvas.width = w
canvas.height = h
canvas.getContext('2d')?.drawImage(videoEl, 0, 0, w, h)
return { canvas, w, h }
}
if (media.mediaType === MediaType.Image) {
const img = new Image()
img.crossOrigin = 'anonymous'
img.src = media.path.startsWith('blob:')
? media.path
: `/api/annotations/media/${media.id}/file`
await new Promise<void>(resolve => {
img.onload = () => resolve()
img.onerror = () => resolve()
})
if (!img.naturalWidth) return null
const w = img.naturalWidth
const h = img.naturalHeight
canvas.width = w
canvas.height = h
canvas.getContext('2d')?.drawImage(img, 0, 0, w, h)
return { canvas, w, h }
}
return null
}
export interface ThumbnailCapture {
fullFrame: string
detectionThumbnails: string[]
}
export async function captureThumbnails(
media: Media,
videoEl: HTMLVideoElement | null,
detections: Detection[],
): Promise<ThumbnailCapture> {
const src = await getSourceCanvas(media, videoEl)
if (!src) return { fullFrame: '', detectionThumbnails: detections.map(() => '') }
const fullScale = Math.min(1, FULL_FRAME_MAX / src.w)
const full = document.createElement('canvas')
full.width = Math.max(1, Math.round(src.w * fullScale))
full.height = Math.max(1, Math.round(src.h * fullScale))
full.getContext('2d')?.drawImage(src.canvas, 0, 0, full.width, full.height)
const fullFrame = full.toDataURL('image/jpeg', 0.85)
const detectionThumbnails = detections.map(d => cropDetection(src, d))
return { fullFrame, detectionThumbnails }
}
function cropDetection(
src: { canvas: HTMLCanvasElement; w: number; h: number },
d: Detection,
): string {
const cxPx = d.centerX * src.w
const cyPx = d.centerY * src.h
const bw = d.width * src.w
const bh = d.height * src.h
const side = Math.max(bw, bh) * (1 + CROP_PAD * 2)
const sx = cxPx - side / 2
const sy = cyPx - side / 2
const ix0 = Math.max(0, Math.floor(sx))
const iy0 = Math.max(0, Math.floor(sy))
const ix1 = Math.min(src.w, Math.ceil(sx + side))
const iy1 = Math.min(src.h, Math.ceil(sy + side))
const iw = Math.max(1, ix1 - ix0)
const ih = Math.max(1, iy1 - iy0)
const out = document.createElement('canvas')
out.width = THUMB_MAX
out.height = THUMB_MAX
const ctx = out.getContext('2d')
if (ctx) {
ctx.fillStyle = '#1e1e1e'
ctx.fillRect(0, 0, THUMB_MAX, THUMB_MAX)
const scale = THUMB_MAX / side
ctx.drawImage(
src.canvas,
ix0, iy0, iw, ih,
(ix0 - sx) * scale, (iy0 - sy) * scale, iw * scale, ih * scale,
)
const bx = cxPx - bw / 2
const by = cyPx - bh / 2
ctx.strokeStyle = getClassColor(d.classNum)
ctx.lineWidth = Math.max(2, THUMB_MAX / 100)
ctx.strokeRect(
(bx - sx) * scale,
(by - sy) * scale,
bw * scale,
bh * scale,
)
}
return out.toDataURL('image/jpeg', 0.8)
}
export async function recaptureThumbnails(
fullFrameDataUrl: string,
detections: Detection[],
): Promise<string[]> {
if (!fullFrameDataUrl) return detections.map(() => '')
const img = new Image()
img.src = fullFrameDataUrl
await new Promise<void>(resolve => {
img.onload = () => resolve()
img.onerror = () => resolve()
})
if (!img.naturalWidth) return detections.map(() => '')
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')?.drawImage(img, 0, 0)
const src = { canvas, w: canvas.width, h: canvas.height }
return detections.map(d => cropDetection(src, d))
}