mirror of
https://github.com/azaion/ui.git
synced 2026-06-24 20:01:10 +00:00
feat(dataset): per-detection cards, in-browser editor, bulk-validate for local saves
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user