Enhance annotations: save/download, fallback classes, photo mode icons

Add local annotation save fallback, PNG+txt download with drawn boxes,
shared classColors helper, photo mode icon toggles, and react-dropzone
/ react-icons dependencies.
This commit is contained in:
Armen Rohalov
2026-04-17 23:33:00 +03:00
parent 567092188d
commit 63cc18e788
15 changed files with 782 additions and 218 deletions
+76 -15
View File
@@ -1,5 +1,7 @@
import { useRef, useEffect, useState, useCallback } from 'react'
import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
import { MediaType } from '../../types'
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from './classColors'
interface Props {
media: Media
@@ -11,6 +13,12 @@ interface Props {
annotations: AnnotationListItem[]
}
export interface CanvasEditorHandle {
deleteSelected: () => void
deleteAll: () => void
hasSelection: () => boolean
}
interface DragState {
type: 'draw' | 'move' | 'resize'
startX: number
@@ -28,7 +36,10 @@ const AFFILIATION_COLORS: Record<number, string> = {
2: '#fa5252',
}
export default function CanvasEditor({ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations }: Props) {
const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor(
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations },
ref,
) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const imgRef = useRef<HTMLImageElement | null>(null)
@@ -39,11 +50,35 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
const [drawRect, setDrawRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null)
const [imgSize, setImgSize] = useState({ w: 0, h: 0 })
useImperativeHandle(ref, () => ({
deleteSelected() {
if (selected.size === 0) return
onDetectionsChange(detections.filter((_, i) => !selected.has(i)))
setSelected(new Set())
},
deleteAll() {
onDetectionsChange([])
setSelected(new Set())
},
hasSelection() {
return selected.size > 0
},
}), [selected, detections, onDetectionsChange])
const isVideo = media.mediaType === MediaType.Video
const loadImage = useCallback(() => {
if (isVideo) {
// Use natural size based on container; no image load
imgRef.current = null
return
}
const img = new Image()
img.crossOrigin = 'anonymous'
if (annotation) {
if (annotation && !media.path.startsWith('blob:')) {
img.src = `/api/annotations/annotations/${annotation.id}/image`
} else if (media.path.startsWith('blob:')) {
img.src = media.path
} else {
img.src = `/api/annotations/media/${media.id}/file`
}
@@ -51,10 +86,22 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
imgRef.current = img
setImgSize({ w: img.naturalWidth, h: img.naturalHeight })
}
}, [media, annotation])
}, [media, annotation, isVideo])
useEffect(() => { loadImage() }, [loadImage])
useEffect(() => {
if (!isVideo || !containerRef.current) return
const update = () => {
const c = containerRef.current
if (c) setImgSize({ w: c.clientWidth, h: c.clientHeight })
}
update()
const ro = new ResizeObserver(update)
ro.observe(containerRef.current)
return () => ro.disconnect()
}, [isVideo])
const toCanvas = useCallback((nx: number, ny: number) => ({
x: nx * imgSize.w * zoom + pan.x,
y: ny * imgSize.h * zoom + pan.y,
@@ -68,7 +115,8 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
const draw = useCallback(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx || !imgRef.current) return
if (!canvas || !ctx) return
if (!isVideo && !imgRef.current) return
const container = containerRef.current
if (container) {
@@ -78,7 +126,9 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.save()
ctx.drawImage(imgRef.current, pan.x, pan.y, imgSize.w * zoom, imgSize.h * zoom)
if (!isVideo && imgRef.current) {
ctx.drawImage(imgRef.current, pan.x, pan.y, imgSize.w * zoom, imgSize.h * zoom)
}
const timeWindowDets = getTimeWindowDetections()
const allDets = [...detections, ...timeWindowDets]
@@ -90,7 +140,7 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
const w = det.width * imgSize.w * zoom
const h = det.height * imgSize.h * zoom
const color = AFFILIATION_COLORS[det.affiliation] || '#FFD700'
const color = getClassColor(det.classNum)
ctx.strokeStyle = color
ctx.lineWidth = isSelected ? 2 : 1
ctx.strokeRect(cx, cy, w, h)
@@ -100,12 +150,20 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
ctx.fillRect(cx, cy, w, h)
ctx.globalAlpha = 1
const label = det.confidence < 0.995
? `${det.label} ${(det.confidence * 100).toFixed(0)}%`
: det.label
ctx.fillStyle = color
const name = det.label || getClassNameFallback(det.classNum)
const modeSuffix = getPhotoModeSuffix(det.classNum)
const confSuffix = det.confidence < 0.995 ? ` ${(det.confidence * 100).toFixed(0)}%` : ''
const label = `${name}${modeSuffix}${confSuffix}`
ctx.font = '11px sans-serif'
ctx.fillText(label, cx + 2, cy - 3)
const metrics = ctx.measureText(label)
const padX = 3
const labelH = 14
const labelW = metrics.width + padX * 2
ctx.fillStyle = color
ctx.fillRect(cx, cy - labelH, labelW, labelH)
ctx.fillStyle = '#000'
ctx.fillText(label, cx + padX, cy - 3)
if (det.combatReadiness === 1) {
ctx.fillStyle = '#40c057'
@@ -150,7 +208,8 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
}, [draw])
const getTimeWindowDetections = (): Detection[] => {
if (media.mediaType !== 2) return []
if (media.mediaType !== MediaType.Video) return []
if (annotation) return []
const timeTicks = currentTime * 10_000_000
return annotations
.filter(a => {
@@ -332,7 +391,7 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
}, [detections, selected, onDetectionsChange])
return (
<div ref={containerRef} className="flex-1 relative overflow-hidden cursor-crosshair">
<div ref={containerRef} className="w-full h-full flex-1 relative overflow-hidden cursor-crosshair">
<canvas
ref={canvasRef}
className="absolute inset-0"
@@ -344,4 +403,6 @@ export default function CanvasEditor({ media, annotation, detections, onDetectio
/>
</div>
)
}
})
export default CanvasEditor