mirror of
https://github.com/azaion/ui.git
synced 2026-04-25 15:46:34 +00:00
feat(dataset): per-detection cards, in-browser editor, bulk-validate for local saves
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||||
|
import { AnnotationSource, AnnotationStatus } from '../types'
|
||||||
|
import type { Detection } from '../types'
|
||||||
|
|
||||||
|
export interface SavedDetection {
|
||||||
|
id: string
|
||||||
|
annotationLocalId: string
|
||||||
|
mediaId: string
|
||||||
|
mediaName: string
|
||||||
|
thumbnail: string
|
||||||
|
fullFrame: string
|
||||||
|
status: AnnotationStatus
|
||||||
|
source: AnnotationSource
|
||||||
|
createdDate: string
|
||||||
|
detection: Detection
|
||||||
|
time: string | null
|
||||||
|
flightId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SavedAnnotationsState {
|
||||||
|
saved: SavedDetection[]
|
||||||
|
addMany: (items: SavedDetection[]) => void
|
||||||
|
replaceGroup: (annotationLocalId: string, items: SavedDetection[]) => void
|
||||||
|
updateStatus: (ids: string[], status: AnnotationStatus) => void
|
||||||
|
removeSaved: (id: string) => void
|
||||||
|
clear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'az.savedAnnotations.v2'
|
||||||
|
|
||||||
|
const SavedAnnotationsContext = createContext<SavedAnnotationsState>(null!)
|
||||||
|
|
||||||
|
export function useSavedAnnotations() {
|
||||||
|
return useContext(SavedAnnotationsContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SavedAnnotationsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [saved, setSaved] = useState<SavedDetection[]>(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return raw ? (JSON.parse(raw) as SavedDetection[]) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)) } catch {}
|
||||||
|
}, [saved])
|
||||||
|
|
||||||
|
const addMany = useCallback((items: SavedDetection[]) => {
|
||||||
|
if (!items.length) return
|
||||||
|
const ids = new Set(items.map(i => i.id))
|
||||||
|
setSaved(prev => [...items, ...prev.filter(x => !ids.has(x.id))])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const replaceGroup = useCallback((annotationLocalId: string, items: SavedDetection[]) => {
|
||||||
|
setSaved(prev => [
|
||||||
|
...items,
|
||||||
|
...prev.filter(x => x.annotationLocalId !== annotationLocalId),
|
||||||
|
])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateStatus = useCallback((ids: string[], status: AnnotationStatus) => {
|
||||||
|
if (!ids.length) return
|
||||||
|
const idSet = new Set(ids)
|
||||||
|
setSaved(prev => prev.map(x => idSet.has(x.id) ? { ...x, status } : x))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeSaved = useCallback((id: string) => {
|
||||||
|
setSaved(prev => prev.filter(x => x.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clear = useCallback(() => setSaved([]), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SavedAnnotationsContext.Provider value={{ saved, addMany, replaceGroup, updateStatus, removeSaved, clear }}>
|
||||||
|
{children}
|
||||||
|
</SavedAnnotationsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,8 +6,11 @@ import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
|||||||
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
||||||
import AnnotationsSidebar from './AnnotationsSidebar'
|
import AnnotationsSidebar from './AnnotationsSidebar'
|
||||||
import DetectionClasses from '../../components/DetectionClasses'
|
import DetectionClasses from '../../components/DetectionClasses'
|
||||||
|
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
||||||
|
import { useFlight } from '../../components/FlightContext'
|
||||||
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
||||||
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from './classColors'
|
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from './classColors'
|
||||||
|
import { captureThumbnails } from './thumbnail'
|
||||||
import type { Media, AnnotationListItem, Detection } from '../../types'
|
import type { Media, AnnotationListItem, Detection } from '../../types'
|
||||||
|
|
||||||
export default function AnnotationsPage() {
|
export default function AnnotationsPage() {
|
||||||
@@ -22,6 +25,8 @@ export default function AnnotationsPage() {
|
|||||||
const rightPanel = useResizablePanel(200, 150, 350)
|
const rightPanel = useResizablePanel(200, 150, 350)
|
||||||
const videoPlayerRef = useRef<VideoPlayerHandle>(null)
|
const videoPlayerRef = useRef<VideoPlayerHandle>(null)
|
||||||
const canvasRef = useRef<CanvasEditorHandle>(null)
|
const canvasRef = useRef<CanvasEditorHandle>(null)
|
||||||
|
const { addMany } = useSavedAnnotations()
|
||||||
|
const { selectedFlight } = useFlight()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDetections([])
|
setDetections([])
|
||||||
@@ -34,6 +39,30 @@ export default function AnnotationsPage() {
|
|||||||
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
|
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
|
||||||
const body = { mediaId: selectedMedia.id, time, detections }
|
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:')) {
|
if (!selectedMedia.path.startsWith('blob:')) {
|
||||||
try {
|
try {
|
||||||
await api.post('/api/annotations/annotations', body)
|
await api.post('/api/annotations/annotations', body)
|
||||||
@@ -41,6 +70,7 @@ export default function AnnotationsPage() {
|
|||||||
`/api/annotations/annotations?mediaId=${selectedMedia.id}&pageSize=1000`,
|
`/api/annotations/annotations?mediaId=${selectedMedia.id}&pageSize=1000`,
|
||||||
)
|
)
|
||||||
setAnnotations(res.items)
|
setAnnotations(res.items)
|
||||||
|
pushToStore(`saved-${crypto.randomUUID()}`)
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to local save
|
// fall through to local save
|
||||||
@@ -60,7 +90,8 @@ export default function AnnotationsPage() {
|
|||||||
detections: [...detections],
|
detections: [...detections],
|
||||||
}
|
}
|
||||||
setAnnotations(prev => [...prev, local])
|
setAnnotations(prev => [...prev, local])
|
||||||
}, [selectedMedia, detections, currentTime])
|
pushToStore(local.id)
|
||||||
|
}, [selectedMedia, detections, currentTime, addMany, selectedFlight])
|
||||||
|
|
||||||
const handleDownload = useCallback(async (ann: AnnotationListItem) => {
|
const handleDownload = useCallback(async (ann: AnnotationListItem) => {
|
||||||
if (!selectedMedia) return
|
if (!selectedMedia) return
|
||||||
|
|||||||
@@ -75,16 +75,29 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
}
|
}
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
img.crossOrigin = 'anonymous'
|
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`
|
img.src = `/api/annotations/annotations/${annotation.id}/image`
|
||||||
} else if (media.path.startsWith('blob:')) {
|
} else if (isLocalPath) {
|
||||||
img.src = media.path
|
img.src = media.path
|
||||||
} else {
|
} else {
|
||||||
img.src = `/api/annotations/media/${media.id}/file`
|
img.src = `/api/annotations/media/${media.id}/file`
|
||||||
}
|
}
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
imgRef.current = img
|
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])
|
}, [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))
|
||||||
|
}
|
||||||
@@ -1,20 +1,39 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { FaPen } from 'react-icons/fa'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
import { useDebounce } from '../../hooks/useDebounce'
|
import { useDebounce } from '../../hooks/useDebounce'
|
||||||
import { useResizablePanel } from '../../hooks/useResizablePanel'
|
import { useResizablePanel } from '../../hooks/useResizablePanel'
|
||||||
import { useFlight } from '../../components/FlightContext'
|
import { useFlight } from '../../components/FlightContext'
|
||||||
|
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
||||||
import DetectionClasses from '../../components/DetectionClasses'
|
import DetectionClasses from '../../components/DetectionClasses'
|
||||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
|
||||||
import CanvasEditor from '../annotations/CanvasEditor'
|
import CanvasEditor from '../annotations/CanvasEditor'
|
||||||
|
import { recaptureThumbnails } from '../annotations/thumbnail'
|
||||||
|
import type { SavedDetection } from '../../components/SavedAnnotationsContext'
|
||||||
import type { DatasetItem, PaginatedResponse, ClassDistributionItem, AnnotationListItem, Detection, Media } from '../../types'
|
import type { DatasetItem, PaginatedResponse, ClassDistributionItem, AnnotationListItem, Detection, Media } from '../../types'
|
||||||
import { AnnotationStatus } from '../../types'
|
import { AnnotationSource, AnnotationStatus } from '../../types'
|
||||||
|
|
||||||
|
interface DatasetCard {
|
||||||
|
annotationId: string
|
||||||
|
imageName: string
|
||||||
|
status: AnnotationStatus
|
||||||
|
createdDate: string
|
||||||
|
thumbnailUrl: string
|
||||||
|
isSeed: boolean
|
||||||
|
isLocal: boolean
|
||||||
|
detections?: Detection[]
|
||||||
|
mediaId?: string
|
||||||
|
time?: string | null
|
||||||
|
fullFrame?: string
|
||||||
|
annotationLocalId?: string
|
||||||
|
}
|
||||||
|
|
||||||
type Tab = 'annotations' | 'editor' | 'distribution'
|
type Tab = 'annotations' | 'editor' | 'distribution'
|
||||||
|
|
||||||
export default function DatasetPage() {
|
export default function DatasetPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { selectedFlight } = useFlight()
|
const { selectedFlight } = useFlight()
|
||||||
|
const { saved: savedAnnotations, removeSaved, replaceGroup, updateStatus } = useSavedAnnotations()
|
||||||
const leftPanel = useResizablePanel(250, 200, 400)
|
const leftPanel = useResizablePanel(250, 200, 400)
|
||||||
|
|
||||||
const [items, setItems] = useState<DatasetItem[]>([])
|
const [items, setItems] = useState<DatasetItem[]>([])
|
||||||
@@ -53,21 +72,131 @@ export default function DatasetPage() {
|
|||||||
|
|
||||||
useEffect(() => { fetchItems() }, [fetchItems])
|
useEffect(() => { fetchItems() }, [fetchItems])
|
||||||
|
|
||||||
const handleDoubleClick = async (item: DatasetItem) => {
|
const cards = useMemo<DatasetCard[]>(() => {
|
||||||
|
const localCards: DatasetCard[] = savedAnnotations
|
||||||
|
.filter(sd => {
|
||||||
|
if (selectedFlight && sd.flightId && sd.flightId !== selectedFlight.id) return false
|
||||||
|
if (statusFilter !== null && sd.status !== statusFilter) return false
|
||||||
|
if (selectedClassNum && sd.detection.classNum !== selectedClassNum) return false
|
||||||
|
if (debouncedSearch && !sd.mediaName.toLowerCase().includes(debouncedSearch.toLowerCase())) return false
|
||||||
|
if (fromDate && sd.createdDate < fromDate) return false
|
||||||
|
if (toDate && sd.createdDate > `${toDate}T23:59:59`) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map(sd => ({
|
||||||
|
annotationId: sd.id,
|
||||||
|
imageName: sd.mediaName,
|
||||||
|
status: sd.status,
|
||||||
|
createdDate: sd.createdDate,
|
||||||
|
thumbnailUrl: sd.thumbnail,
|
||||||
|
isSeed: false,
|
||||||
|
isLocal: true,
|
||||||
|
detections: [sd.detection],
|
||||||
|
mediaId: sd.mediaId,
|
||||||
|
time: sd.time,
|
||||||
|
fullFrame: sd.fullFrame,
|
||||||
|
annotationLocalId: sd.annotationLocalId,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const remoteCards: DatasetCard[] = items.map(item => ({
|
||||||
|
annotationId: item.annotationId,
|
||||||
|
imageName: item.imageName,
|
||||||
|
status: item.status,
|
||||||
|
createdDate: item.createdDate,
|
||||||
|
thumbnailUrl: `/api/annotations/annotations/${item.annotationId}/thumbnail`,
|
||||||
|
isSeed: item.isSeed,
|
||||||
|
isLocal: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [...localCards, ...remoteCards]
|
||||||
|
}, [savedAnnotations, items, selectedFlight, statusFilter, objectsOnly, selectedClassNum, debouncedSearch, fromDate, toDate])
|
||||||
|
|
||||||
|
const [editorFullFrame, setEditorFullFrame] = useState<string>('')
|
||||||
|
const [editorLocalGroupId, setEditorLocalGroupId] = useState<string | null>(null)
|
||||||
|
const [editorSaving, setEditorSaving] = useState(false)
|
||||||
|
|
||||||
|
const handleDoubleClick = async (card: DatasetCard) => {
|
||||||
|
if (card.isLocal && card.detections && card.mediaId) {
|
||||||
|
setEditorAnnotation({
|
||||||
|
id: card.annotationId,
|
||||||
|
mediaId: card.mediaId,
|
||||||
|
time: card.time ?? null,
|
||||||
|
createdDate: card.createdDate,
|
||||||
|
userId: 'local',
|
||||||
|
source: AnnotationSource.Manual,
|
||||||
|
status: card.status,
|
||||||
|
isSplit: false,
|
||||||
|
splitTile: null,
|
||||||
|
detections: card.detections,
|
||||||
|
})
|
||||||
|
setEditorDetections(card.detections)
|
||||||
|
setEditorFullFrame(card.fullFrame ?? '')
|
||||||
|
setEditorLocalGroupId(card.annotationLocalId ?? null)
|
||||||
|
setTab('editor')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEditorFullFrame('')
|
||||||
|
setEditorLocalGroupId(null)
|
||||||
try {
|
try {
|
||||||
const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${item.annotationId}`)
|
const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${card.annotationId}`)
|
||||||
setEditorAnnotation(ann)
|
setEditorAnnotation(ann)
|
||||||
setEditorDetections(ann.detections)
|
setEditorDetections(ann.detections)
|
||||||
setTab('editor')
|
setTab('editor')
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEditorSave = async () => {
|
||||||
|
if (!editorAnnotation) return
|
||||||
|
setEditorSaving(true)
|
||||||
|
try {
|
||||||
|
if (editorLocalGroupId) {
|
||||||
|
const existing = savedAnnotations.find(s => s.annotationLocalId === editorLocalGroupId)
|
||||||
|
const thumbs = await recaptureThumbnails(editorFullFrame, editorDetections)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const items: SavedDetection[] = editorDetections.map((d, i) => ({
|
||||||
|
id: `${editorLocalGroupId}:${d.id ?? i}`,
|
||||||
|
annotationLocalId: editorLocalGroupId,
|
||||||
|
mediaId: editorAnnotation.mediaId,
|
||||||
|
mediaName: existing?.mediaName ?? '',
|
||||||
|
thumbnail: thumbs[i] ?? '',
|
||||||
|
fullFrame: editorFullFrame,
|
||||||
|
status: AnnotationStatus.Edited,
|
||||||
|
source: existing?.source ?? AnnotationSource.Manual,
|
||||||
|
createdDate: existing?.createdDate ?? now,
|
||||||
|
detection: d,
|
||||||
|
time: editorAnnotation.time,
|
||||||
|
flightId: existing?.flightId ?? null,
|
||||||
|
}))
|
||||||
|
replaceGroup(editorLocalGroupId, items)
|
||||||
|
}
|
||||||
|
setTab('annotations')
|
||||||
|
} finally {
|
||||||
|
setEditorSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditorCancel = () => {
|
||||||
|
if (editorAnnotation) setEditorDetections(editorAnnotation.detections)
|
||||||
|
setTab('annotations')
|
||||||
|
}
|
||||||
|
|
||||||
const handleValidate = async () => {
|
const handleValidate = async () => {
|
||||||
if (selectedIds.size === 0) return
|
if (selectedIds.size === 0) return
|
||||||
await api.post('/api/annotations/dataset/bulk-status', {
|
const allIds = Array.from(selectedIds)
|
||||||
annotationIds: Array.from(selectedIds),
|
const localIds = allIds.filter(id => id.startsWith('saved-') || id.startsWith('local-'))
|
||||||
status: AnnotationStatus.Validated,
|
const backendIds = allIds.filter(id => !id.startsWith('saved-') && !id.startsWith('local-'))
|
||||||
})
|
|
||||||
|
if (backendIds.length > 0) {
|
||||||
|
try {
|
||||||
|
await api.post('/api/annotations/dataset/bulk-status', {
|
||||||
|
annotationIds: backendIds,
|
||||||
|
status: AnnotationStatus.Validated,
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (localIds.length > 0) {
|
||||||
|
updateStatus(localIds, AnnotationStatus.Validated)
|
||||||
|
}
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
fetchItems()
|
fetchItems()
|
||||||
}
|
}
|
||||||
@@ -85,7 +214,7 @@ export default function DatasetPage() {
|
|||||||
const totalPages = Math.ceil(totalCount / pageSize)
|
const totalPages = Math.ceil(totalCount / pageSize)
|
||||||
|
|
||||||
const editorMedia: Media | null = editorAnnotation ? {
|
const editorMedia: Media | null = editorAnnotation ? {
|
||||||
id: editorAnnotation.mediaId, name: '', path: '', mediaType: 1, mediaStatus: 0,
|
id: editorAnnotation.mediaId, name: '', path: editorFullFrame, mediaType: 1, mediaStatus: 0,
|
||||||
duration: null, annotationCount: 0, waypointId: null, userId: '',
|
duration: null, annotationCount: 0, waypointId: null, userId: '',
|
||||||
} : null
|
} : null
|
||||||
|
|
||||||
@@ -124,7 +253,7 @@ export default function DatasetPage() {
|
|||||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
||||||
|
|
||||||
{/* Main area */}
|
{/* Main area */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
<div className="flex items-center gap-2 p-2 border-b border-az-border bg-az-panel text-xs flex-wrap">
|
<div className="flex items-center gap-2 p-2 border-b border-az-border bg-az-panel text-xs flex-wrap">
|
||||||
<input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)} className="bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" />
|
<input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)} className="bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" />
|
||||||
@@ -163,49 +292,70 @@ export default function DatasetPage() {
|
|||||||
{tab === 'annotations' && (
|
{tab === 'annotations' && (
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }}>
|
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }}>
|
||||||
{items.map(item => (
|
{cards.map(card => {
|
||||||
<div
|
const statusPill =
|
||||||
key={item.annotationId}
|
card.status === AnnotationStatus.Validated ? { cls: 'bg-az-green text-white', label: t('dataset.status.validated') } :
|
||||||
onClick={e => {
|
card.status === AnnotationStatus.Edited ? { cls: 'bg-az-blue text-white', label: t('dataset.status.edited') } :
|
||||||
if (e.ctrlKey) {
|
{ cls: 'bg-az-orange text-white', label: t('dataset.status.created') }
|
||||||
setSelectedIds(prev => {
|
const isSelected = selectedIds.has(card.annotationId)
|
||||||
const n = new Set(prev)
|
return (
|
||||||
n.has(item.annotationId) ? n.delete(item.annotationId) : n.add(item.annotationId)
|
<div
|
||||||
return n
|
key={card.annotationId}
|
||||||
})
|
onClick={e => {
|
||||||
} else {
|
if (e.ctrlKey) {
|
||||||
setSelectedIds(new Set([item.annotationId]))
|
setSelectedIds(prev => {
|
||||||
}
|
const n = new Set(prev)
|
||||||
}}
|
n.has(card.annotationId) ? n.delete(card.annotationId) : n.add(card.annotationId)
|
||||||
onDoubleClick={() => handleDoubleClick(item)}
|
return n
|
||||||
className={`bg-az-panel border rounded overflow-hidden cursor-pointer ${
|
})
|
||||||
selectedIds.has(item.annotationId) ? 'border-az-orange' : 'border-az-border'
|
} else {
|
||||||
} ${item.isSeed ? 'ring-2 ring-az-red' : ''}`}
|
setSelectedIds(new Set([card.annotationId]))
|
||||||
>
|
}
|
||||||
<img
|
}}
|
||||||
src={`/api/annotations/annotations/${item.annotationId}/thumbnail`}
|
onDoubleClick={() => handleDoubleClick(card)}
|
||||||
alt={item.imageName}
|
onContextMenu={e => {
|
||||||
className="w-full h-32 object-cover bg-az-bg"
|
if (!card.isLocal) return
|
||||||
loading="lazy"
|
e.preventDefault()
|
||||||
/>
|
removeSaved(card.annotationId)
|
||||||
<div className="p-1.5 text-xs">
|
}}
|
||||||
<div className="truncate text-az-text">{item.imageName}</div>
|
title={card.imageName}
|
||||||
<div className="flex justify-between">
|
className={`aspect-square bg-az-panel rounded border overflow-hidden cursor-pointer relative transition-colors ${
|
||||||
<span className="text-az-muted">{new Date(item.createdDate).toLocaleDateString()}</span>
|
isSelected ? 'border-az-orange' : 'border-az-border hover:border-az-blue'
|
||||||
<span className={`px-1 rounded ${
|
} ${card.isSeed ? 'ring-2 ring-az-red' : ''}`}
|
||||||
item.status === AnnotationStatus.Validated ? 'bg-az-green/20 text-az-green' :
|
>
|
||||||
item.status === AnnotationStatus.Edited ? 'bg-az-blue/20 text-az-blue' :
|
{card.thumbnailUrl ? (
|
||||||
'bg-az-muted/20 text-az-muted'
|
<img
|
||||||
}`}>
|
src={card.thumbnailUrl}
|
||||||
{item.status === AnnotationStatus.Validated ? t('dataset.status.validated') :
|
alt={card.imageName}
|
||||||
item.status === AnnotationStatus.Edited ? t('dataset.status.edited') :
|
className="w-full h-full object-cover bg-az-bg"
|
||||||
t('dataset.status.created')}
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-az-bg" />
|
||||||
|
)}
|
||||||
|
<span className={`absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full ${statusPill.cls}`}>
|
||||||
|
{statusPill.label}
|
||||||
|
</span>
|
||||||
|
{card.isLocal && (
|
||||||
|
<span className="absolute top-1.5 right-1.5 text-[9px] px-1.5 py-0.5 rounded bg-az-border text-az-text">
|
||||||
|
local
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => { e.stopPropagation(); handleDoubleClick(card) }}
|
||||||
|
title={t('dataset.edit') ?? 'Edit'}
|
||||||
|
className="absolute bottom-1.5 right-1.5 w-6 h-6 flex items-center justify-center rounded bg-az-bg/80 text-az-text hover:bg-az-orange hover:text-white"
|
||||||
|
>
|
||||||
|
<FaPen size={10} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{cards.length === 0 && (
|
||||||
|
<div className="text-center text-az-muted text-xs py-8">{t('common.noData')}</div>
|
||||||
|
)}
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex justify-center gap-2 py-3">
|
<div className="flex justify-center gap-2 py-3">
|
||||||
@@ -218,33 +368,66 @@ export default function DatasetPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'editor' && editorMedia && editorAnnotation && (
|
{tab === 'editor' && editorMedia && editorAnnotation && (
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||||
<CanvasEditor
|
<div className="absolute inset-0 flex flex-col">
|
||||||
media={editorMedia}
|
<div className="bg-az-panel border-b border-az-border px-2 py-1 flex gap-2 items-center shrink-0">
|
||||||
annotation={editorAnnotation}
|
<button
|
||||||
detections={editorDetections}
|
onClick={handleEditorSave}
|
||||||
onDetectionsChange={setEditorDetections}
|
disabled={editorSaving || (!editorLocalGroupId && editorDetections.length === 0)}
|
||||||
selectedClassNum={selectedClassNum}
|
className="px-2.5 py-1 rounded border border-az-green text-az-green text-[11px] hover:bg-az-green/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
currentTime={0}
|
>
|
||||||
annotations={[]}
|
{editorSaving ? 'Saving…' : t('common.save') ?? 'Save'}
|
||||||
/>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleEditorCancel}
|
||||||
|
disabled={editorSaving}
|
||||||
|
className="px-2.5 py-1 rounded border border-az-border text-az-text text-[11px] hover:bg-az-border/30 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t('common.cancel') ?? 'Cancel'}
|
||||||
|
</button>
|
||||||
|
<span className="text-az-muted text-[10px]">
|
||||||
|
{editorDetections.length} detection{editorDetections.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{!editorLocalGroupId && (
|
||||||
|
<span className="text-az-muted text-[10px] ml-auto">
|
||||||
|
remote save not wired yet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 relative">
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<CanvasEditor
|
||||||
|
media={editorMedia}
|
||||||
|
annotation={editorAnnotation}
|
||||||
|
detections={editorDetections}
|
||||||
|
onDetectionsChange={setEditorDetections}
|
||||||
|
selectedClassNum={selectedClassNum}
|
||||||
|
currentTime={0}
|
||||||
|
annotations={[]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'distribution' && (
|
{tab === 'distribution' && (
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto bg-az-bg">
|
||||||
<div className="space-y-1.5 max-w-2xl">
|
{distribution.map(d => {
|
||||||
{distribution.map(d => (
|
const pct = (d.count / maxDistCount) * 100
|
||||||
<div key={d.classNum} className="flex items-center gap-2 text-xs">
|
return (
|
||||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: d.color }} />
|
<div key={d.classNum} className="relative h-6 border-b border-az-border/40">
|
||||||
<span className="w-40 truncate text-az-text">{d.label}</span>
|
<div
|
||||||
<div className="flex-1 bg-az-bg rounded h-4 overflow-hidden">
|
className="absolute inset-y-0 left-0"
|
||||||
<div className="h-full rounded" style={{ width: `${(d.count / maxDistCount) * 100}%`, backgroundColor: d.color, opacity: 0.7 }} />
|
style={{ width: `${pct}%`, backgroundColor: d.color, opacity: 0.85 }}
|
||||||
|
/>
|
||||||
|
<div className="relative flex items-center justify-between h-full px-2 text-xs text-white tabular-nums">
|
||||||
|
<span className="truncate">{d.label}: {d.count}</span>
|
||||||
|
<span className="pl-2">{d.count}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-az-muted w-12 text-right">{d.count}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/features/admin/adminpage.tsx","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/videoplayer.tsx","./src/features/dataset/datasetpage.tsx","./src/features/flights/altitudechart.tsx","./src/features/flights/altitudedialog.tsx","./src/features/flights/drawcontrol.tsx","./src/features/flights/flightlistsidebar.tsx","./src/features/flights/flightmap.tsx","./src/features/flights/flightparamspanel.tsx","./src/features/flights/flightspage.tsx","./src/features/flights/jsoneditordialog.tsx","./src/features/flights/mappoint.tsx","./src/features/flights/minimap.tsx","./src/features/flights/waypointlist.tsx","./src/features/flights/windeffect.tsx","./src/features/flights/flightplanutils.ts","./src/features/flights/mapicons.ts","./src/features/flights/types.ts","./src/features/login/loginpage.tsx","./src/features/settings/settingspage.tsx","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/types/index.ts"],"version":"5.7.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/components/savedannotationscontext.tsx","./src/features/admin/adminpage.tsx","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/videoplayer.tsx","./src/features/annotations/classcolors.ts","./src/features/annotations/thumbnail.ts","./src/features/dataset/datasetpage.tsx","./src/features/flights/altitudechart.tsx","./src/features/flights/altitudedialog.tsx","./src/features/flights/drawcontrol.tsx","./src/features/flights/flightlistsidebar.tsx","./src/features/flights/flightmap.tsx","./src/features/flights/flightparamspanel.tsx","./src/features/flights/flightspage.tsx","./src/features/flights/jsoneditordialog.tsx","./src/features/flights/mappoint.tsx","./src/features/flights/minimap.tsx","./src/features/flights/waypointlist.tsx","./src/features/flights/windeffect.tsx","./src/features/flights/flightplanutils.ts","./src/features/flights/mapicons.ts","./src/features/flights/types.ts","./src/features/login/loginpage.tsx","./src/features/settings/settingspage.tsx","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/types/index.ts"],"version":"5.7.3"}
|
||||||
Reference in New Issue
Block a user