mirror of
https://github.com/azaion/ui.git
synced 2026-04-23 12:36:35 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user