import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ChevronDown, Tag, Eye, Plus, X, Loader2, GripVertical } from 'lucide-react'; import { useStore } from '../store/useStore'; import type { Mask, TemplateClass } from '../store/useStore'; import { cn } from '../lib/utils'; import { getActiveTemplate } from '../lib/templateSelection'; import { analyzeMask, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult, type SmoothMaskGeometryResult } from '../lib/api'; import { nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds'; const SMOOTHING_PREVIEW_DEBOUNCE_MS = 220; const isRequestAbortError = (err: unknown) => { const error = err as { code?: string; message?: string; name?: string } | null; const message = error?.message || ''; return error?.code === 'ERR_CANCELED' || error?.code === 'ECONNABORTED' || error?.name === 'AbortError' || /request aborted|aborted|cancell?ed/i.test(message); }; function metadataNumber(value: unknown): number | null { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } function propagationSourceMaskTokens(value: unknown): string[] { if (typeof value !== 'string' || value.length === 0) return []; const tokens = [`mask:${value}`]; const annotationMatch = value.match(/^annotation-(\d+)$/); if (annotationMatch) { tokens.push(`annotation:${annotationMatch[1]}`); } return tokens; } function propagationLineageTokens(mask: { id: string; annotationId?: string; metadata?: Record }): Set { const metadata = mask.metadata || {}; const tokens = new Set([`mask:${mask.id}`]); if (mask.annotationId) { tokens.add(`annotation:${mask.annotationId}`); } const sourceAnnotationId = metadataNumber(metadata.source_annotation_id); if (sourceAnnotationId !== null) { tokens.add(`annotation:${sourceAnnotationId}`); } propagationSourceMaskTokens(metadata.source_mask_id).forEach((token) => tokens.add(token)); if (typeof metadata.propagation_seed_key === 'string' && metadata.propagation_seed_key.length > 0) { tokens.add(`seed-key:${metadata.propagation_seed_key}`); } return tokens; } function findPropagationChainMaskIds(selectedMask: Pick, masks: Mask[]): Set { const selectedTokens = propagationLineageTokens(selectedMask); return new Set( masks .filter((mask) => Array.from(selectedTokens).some((token) => propagationLineageTokens(mask).has(token))) .map((mask) => mask.id), ); } export function OntologyInspector() { const templates = useStore((state) => state.templates); const activeTemplateId = useStore((state) => state.activeTemplateId); const activeClassId = useStore((state) => state.activeClassId); const activeClass = useStore((state) => state.activeClass); const frames = useStore((state) => state.frames); const currentFrameIndex = useStore((state) => state.currentFrameIndex); const masks = useStore((state) => state.masks); const selectedMaskIds = useStore((state) => state.selectedMaskIds); const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity); const setMasks = useStore((state) => state.setMasks); const updateTemplateStore = useStore((state) => state.updateTemplate); const setActiveTemplateId = useStore((state) => state.setActiveTemplateId); const setActiveClass = useStore((state) => state.setActiveClass); const setMaskPreviewOpacity = useStore((state) => state.setMaskPreviewOpacity); const [showAddForm, setShowAddForm] = useState(false); const [newClassName, setNewClassName] = useState(''); const [newClassColor, setNewClassColor] = useState('#06b6d4'); const [isSavingClass, setIsSavingClass] = useState(false); const [classSaveMessage, setClassSaveMessage] = useState(''); const [dragClassId, setDragClassId] = useState(null); const [maskAnalysis, setMaskAnalysis] = useState(null); const [analysisMessage, setAnalysisMessage] = useState(''); const [smoothingStrength, setSmoothingStrength] = useState(0); const [isPreviewingSmoothing, setIsPreviewingSmoothing] = useState(false); const [isSmoothingMask, setIsSmoothingMask] = useState(false); const activeTemplate = getActiveTemplate(templates, activeTemplateId); const templateClasses = normalizeClassMaskIds(activeTemplate?.classes || []); const allClasses = [...templateClasses].sort((a, b) => b.zIndex - a.zIndex); const selectedMask = masks.find((mask) => selectedMaskIds.includes(mask.id)) || null; const selectedMaskLabel = selectedMask?.className || selectedMask?.label || '未选择'; const currentFrame = frames[currentFrameIndex] || null; const classButtonRefs = useRef(new Map()); const skipNextAutoAnalysisRef = useRef(false); const analysisRequestIdRef = useRef(0); const smoothingPreviewRef = useRef<{ maskId: string; baseMask: NonNullable; strength: number; result: SmoothMaskGeometryResult | null; applied: boolean; requestId: number; } | null>(null); const smoothingRequestIdRef = useRef(0); const smoothingPreviewTimerRef = useRef(null); const clearSmoothingPreviewTimer = React.useCallback(() => { if (smoothingPreviewTimerRef.current === null) return; window.clearTimeout(smoothingPreviewTimerRef.current); smoothingPreviewTimerRef.current = null; }, []); const selectedMaskClass = useMemo(() => { if (!selectedMask) return null; const allTemplateClasses = templates.flatMap((template) => ( template.classes.map((templateClass) => ({ template, templateClass })) )); const selectedName = selectedMask.className || selectedMask.label; return allTemplateClasses.find(({ templateClass }) => selectedMask.classId && templateClass.id === selectedMask.classId) || allTemplateClasses.find(({ templateClass }) => templateClass.name === selectedName && templateClass.color === selectedMask.color) || allTemplateClasses.find(({ templateClass }) => templateClass.name === selectedName) || null; }, [selectedMask?.classId, selectedMask?.className, selectedMask?.color, selectedMask?.id, selectedMask?.label, templates]); useEffect(() => { if (!selectedMaskClass) return; if (activeTemplateId !== selectedMaskClass.template.id) { setActiveTemplateId(selectedMaskClass.template.id); } setActiveClass(selectedMaskClass.templateClass); const timer = window.setTimeout(() => { const node = classButtonRefs.current.get(selectedMaskClass.templateClass.id); node?.scrollIntoView?.({ block: 'nearest' }); node?.focus?.({ preventScroll: true }); }, 0); return () => window.clearTimeout(timer); }, [activeTemplateId, selectedMaskClass, setActiveClass, setActiveTemplateId]); const handleSelectClass = (templateClass: TemplateClass) => { if (activeTemplate && !activeTemplateId) { setActiveTemplateId(activeTemplate.id); } setActiveClass(templateClass); const selectedIdSet = new Set(selectedMaskIds); const hasSelectedMasks = masks.some((mask) => selectedIdSet.has(mask.id)); if (!hasSelectedMasks) return; const templateId = activeTemplate?.id || activeTemplateId || undefined; const targetIdSet = new Set(); masks .filter((mask) => selectedIdSet.has(mask.id)) .forEach((mask) => { findPropagationChainMaskIds(mask, masks).forEach((maskId) => targetIdSet.add(maskId)); }); const updatedMasks = masks.map((mask) => { if (!targetIdSet.has(mask.id)) return mask; return { ...mask, templateId: templateId || mask.templateId, classId: templateClass.id, className: templateClass.name, classZIndex: templateClass.zIndex, classMaskId: templateClass.maskId, label: templateClass.name, color: templateClass.color, saveStatus: mask.annotationId ? 'dirty' as const : 'draft' as const, saved: mask.annotationId ? false : mask.saved, }; }); const selectedMasksOnTop = selectedMaskIds .map((id) => updatedMasks.find((mask) => mask.id === id)) .filter((mask): mask is (typeof updatedMasks)[number] => Boolean(mask)); setMasks([ ...updatedMasks.filter((mask) => !selectedIdSet.has(mask.id)), ...selectedMasksOnTop, ]); }; const refreshMaskAnalysis = async () => { const requestId = analysisRequestIdRef.current + 1; analysisRequestIdRef.current = requestId; if (!selectedMask || !currentFrame) { setMaskAnalysis(null); setAnalysisMessage(selectedMask ? '当前帧信息不可用,无法读取后端属性' : '请选择一个 mask 查看后端属性'); return; } setAnalysisMessage(''); try { const result = await analyzeMask(selectedMask, currentFrame); if (analysisRequestIdRef.current !== requestId) return; setMaskAnalysis(result); setAnalysisMessage(result.message); } catch (err) { if (analysisRequestIdRef.current !== requestId || isRequestAbortError(err)) return; console.error('Mask analysis failed:', err); setMaskAnalysis(null); setAnalysisMessage('后端属性读取失败'); } }; const restoreSmoothingPreview = React.useCallback(() => { const preview = smoothingPreviewRef.current; if (!preview || preview.applied) { smoothingPreviewRef.current = null; return; } const state = useStore.getState(); useStore.setState({ masks: state.masks.map((mask) => (mask.id === preview.maskId ? preview.baseMask : mask)), selectedMaskIds: state.selectedMaskIds, }); smoothingPreviewRef.current = null; }, []); React.useEffect(() => { return () => { analysisRequestIdRef.current += 1; clearSmoothingPreviewTimer(); restoreSmoothingPreview(); }; }, [clearSmoothingPreviewTimer, restoreSmoothingPreview]); React.useEffect(() => { const preview = smoothingPreviewRef.current; if (preview && preview.maskId !== selectedMask?.id) { restoreSmoothingPreview(); } }, [restoreSmoothingPreview, selectedMask?.id]); React.useEffect(() => { if (skipNextAutoAnalysisRef.current) { skipNextAutoAnalysisRef.current = false; return; } void refreshMaskAnalysis(); // selectedMask is intentionally tracked by id and geometry fields to avoid // re-running analysis for unrelated store changes. }, [selectedMask?.id, selectedMask?.segmentation, selectedMask?.points, currentFrame?.id]); React.useEffect(() => { const smoothing = selectedMask?.metadata?.geometry_smoothing; const strength = smoothing && typeof smoothing === 'object' ? Number((smoothing as Record).strength) : 0; setSmoothingStrength(Number.isFinite(strength) ? Math.min(Math.max(strength, 0), 100) : 0); }, [selectedMask?.id]); const applySmoothingResultToMask = React.useCallback(( mask: Mask, result: SmoothMaskGeometryResult, options: { commit: boolean }, ): Mask => { const metadata = { ...(mask.metadata || {}) }; delete metadata.geometry_smoothing_preview; if (options.commit) { delete metadata.geometry_smoothing; } else { metadata.geometry_smoothing_preview = result.smoothing; } return { ...mask, pathData: result.pathData, segmentation: result.segmentation, bbox: result.bbox, area: result.area, metadata, ...(options.commit ? { saveStatus: mask.annotationId ? 'dirty' as const : 'draft' as const, saved: mask.annotationId ? false : mask.saved, } : {}), }; }, []); const updateMaskWithSmoothingResult = React.useCallback(( maskId: string, result: SmoothMaskGeometryResult, options: { commit: boolean }, ) => { const state = useStore.getState(); const nextMasks = state.masks.map((mask) => ( mask.id === maskId ? applySmoothingResultToMask(mask, result, options) : mask )); if (options.commit) { setMasks(nextMasks); } else { useStore.setState({ masks: nextMasks }); } }, [applySmoothingResultToMask, setMasks]); const applySmoothingResultToAnalysis = React.useCallback(( result: SmoothMaskGeometryResult, sourceMask: NonNullable, suffix: string, ) => { setMaskAnalysis({ confidence: null, confidence_source: 'manual_or_imported', topology_anchor_count: result.topology_anchor_count, topology_anchors: result.topology_anchors, area: result.area, bbox: result.bbox, source: sourceMask.metadata?.source as string | undefined, message: result.message, }); setAnalysisMessage(`${result.message}${suffix}`); }, []); const runSmoothingPreview = React.useCallback(async (nextStrength: number) => { if (!selectedMask || !currentFrame) return; const existingPreview = smoothingPreviewRef.current?.maskId === selectedMask.id ? smoothingPreviewRef.current : null; const baseMask = existingPreview?.baseMask || selectedMask; const requestId = smoothingRequestIdRef.current + 1; smoothingRequestIdRef.current = requestId; if (nextStrength <= 0) { clearSmoothingPreviewTimer(); smoothingPreviewRef.current = { maskId: selectedMask.id, baseMask, strength: 0, result: null, applied: false, requestId, }; skipNextAutoAnalysisRef.current = true; useStore.setState({ masks: useStore.getState().masks.map((mask) => (mask.id === selectedMask.id ? baseMask : mask)), }); setAnalysisMessage('已预览恢复原始边缘,点击应用后写入当前 mask。'); setIsPreviewingSmoothing(false); return; } setAnalysisMessage('正在生成边缘平滑预览...'); try { const result = await smoothMaskGeometry(baseMask, currentFrame, nextStrength); if (smoothingRequestIdRef.current !== requestId) return; smoothingPreviewRef.current = { maskId: selectedMask.id, baseMask, strength: nextStrength, result, applied: false, requestId, }; skipNextAutoAnalysisRef.current = true; updateMaskWithSmoothingResult(selectedMask.id, result, { commit: false }); applySmoothingResultToAnalysis(result, baseMask, ',预览中,点击应用后写入当前 mask。'); } catch (err) { if (smoothingRequestIdRef.current !== requestId) return; console.error('Mask smoothing preview failed:', err); setAnalysisMessage('边缘平滑预览失败,请检查后端服务'); } finally { if (smoothingRequestIdRef.current === requestId) { setIsPreviewingSmoothing(false); } } }, [applySmoothingResultToAnalysis, clearSmoothingPreviewTimer, currentFrame, selectedMask, updateMaskWithSmoothingResult]); const previewSmoothing = React.useCallback((nextStrength: number) => { setSmoothingStrength(nextStrength); clearSmoothingPreviewTimer(); if (!selectedMask || !currentFrame) return; if (nextStrength <= 0) { void runSmoothingPreview(nextStrength); return; } setIsPreviewingSmoothing(true); setAnalysisMessage('正在等待停止拖动后生成边缘平滑预览...'); smoothingPreviewTimerRef.current = window.setTimeout(() => { smoothingPreviewTimerRef.current = null; void runSmoothingPreview(nextStrength); }, SMOOTHING_PREVIEW_DEBOUNCE_MS); }, [clearSmoothingPreviewTimer, currentFrame, runSmoothingPreview, selectedMask]); const handleApplySmoothing = async () => { if (!selectedMask || !currentFrame) { setAnalysisMessage('请选择一个 mask 后再应用边缘平滑'); return; } clearSmoothingPreviewTimer(); smoothingRequestIdRef.current += 1; setIsSmoothingMask(true); setAnalysisMessage(''); try { const existingPreview = smoothingPreviewRef.current?.maskId === selectedMask.id && smoothingPreviewRef.current.strength === smoothingStrength ? smoothingPreviewRef.current : null; const baseMask = existingPreview?.baseMask || selectedMask; if (smoothingStrength <= 0) { smoothingPreviewRef.current = null; setSmoothingStrength(0); setAnalysisMessage('边缘平滑强度为 0,当前 mask 保持原始边缘。'); return; } const state = useStore.getState(); const frameById = new Map(state.frames.map((frame) => [String(frame.id), frame])); const chainMaskIds = findPropagationChainMaskIds(baseMask, state.masks); chainMaskIds.add(selectedMask.id); const selectedResult = existingPreview?.result || await smoothMaskGeometry(baseMask, currentFrame, smoothingStrength); const resultEntries = new Map(); resultEntries.set(selectedMask.id, selectedResult); await Promise.all( Array.from(chainMaskIds) .filter((maskId) => maskId !== selectedMask.id) .map(async (maskId) => { const mask = state.masks.find((item) => item.id === maskId); const frame = mask ? frameById.get(String(mask.frameId)) : null; if (!mask || !frame) return; resultEntries.set(maskId, await smoothMaskGeometry(mask, frame, smoothingStrength)); }), ); const latestMasks = useStore.getState().masks; const historyBaseMasks = latestMasks.map((mask) => (mask.id === selectedMask.id ? baseMask : mask)); useStore.setState({ masks: historyBaseMasks }); const nextMasks = historyBaseMasks.map((mask) => { const result = resultEntries.get(mask.id); if (!result) return mask; return applySmoothingResultToMask(mask, result, { commit: true }); }); skipNextAutoAnalysisRef.current = true; setMasks(nextMasks); if (smoothingPreviewRef.current) { smoothingPreviewRef.current.applied = true; } smoothingPreviewRef.current = null; setSmoothingStrength(0); applySmoothingResultToAnalysis( selectedResult, baseMask, resultEntries.size > 1 ? `,已同步应用到传播链 ${resultEntries.size} 个对应 mask,强度已重置为 0,请保存后生效` : ',已变为新的 mask,强度已重置为 0,请保存后生效', ); } catch (err) { console.error('Mask smoothing failed:', err); setAnalysisMessage('边缘平滑失败,请检查后端服务'); } finally { setIsSmoothingMask(false); } }; const handleAddCustom = async () => { if (!newClassName.trim()) return; if (!activeTemplate) { setClassSaveMessage('请先选择一个模板'); return; } const maxZ = templateClasses.length > 0 ? Math.max(...templateClasses.map((c) => c.zIndex)) : 0; const newClass: TemplateClass = { id: `custom-${Date.now()}`, name: newClassName.trim(), color: newClassColor, zIndex: maxZ + 10, maskId: nextClassMaskId(templateClasses), category: '自定义', }; setIsSavingClass(true); setClassSaveMessage(''); try { const updated = await updateTemplate(activeTemplate.id, { name: activeTemplate.name, description: activeTemplate.description, classes: normalizeClassMaskIds([...templateClasses, newClass]), rules: activeTemplate.rules || [], }); updateTemplateStore(updated); setActiveTemplateId(updated.id); handleSelectClass(newClass); setNewClassName(''); setShowAddForm(false); setClassSaveMessage('自定义分类已保存到后端模板'); } catch (err) { console.error('Save custom class failed:', err); setClassSaveMessage('自定义分类保存失败'); } finally { setIsSavingClass(false); } }; const handleReorderClass = async (sourceClassId: string, targetClassId: string) => { if (!activeTemplate || sourceClassId === targetClassId) { setDragClassId(null); return; } const sourceIndex = allClasses.findIndex((item) => item.id === sourceClassId); const targetIndex = allClasses.findIndex((item) => item.id === targetClassId); if (sourceIndex < 0 || targetIndex < 0) { setDragClassId(null); return; } const reordered = [...allClasses]; const [source] = reordered.splice(sourceIndex, 1); reordered.splice(targetIndex, 0, source); const nextClasses = normalizeClassMaskIds( reordered.map((item, index) => ({ ...item, zIndex: (reordered.length - index) * 10, })), ); setIsSavingClass(true); setClassSaveMessage('正在保存分类覆盖顺序...'); try { const updated = await updateTemplate(activeTemplate.id, { name: activeTemplate.name, description: activeTemplate.description, classes: nextClasses, rules: activeTemplate.rules || [], }); updateTemplateStore(updated); setActiveTemplateId(updated.id); const zIndexByClassId = new Map(nextClasses.map((item) => [item.id, item.zIndex])); setMasks(useStore.getState().masks.map((mask) => ( mask.classId && zIndexByClassId.has(mask.classId) ? { ...mask, classZIndex: zIndexByClassId.get(mask.classId), saveStatus: mask.annotationId ? 'dirty' as const : mask.saveStatus, saved: mask.annotationId ? false : mask.saved, } : mask ))); const nextActiveClass = nextClasses.find((item) => item.id === activeClassId); if (nextActiveClass) setActiveClass(nextActiveClass); setClassSaveMessage('分类覆盖顺序已保存'); } catch (err) { console.error('Reorder class failed:', err); setClassSaveMessage('分类覆盖顺序保存失败'); } finally { setIsSavingClass(false); setDragClassId(null); } }; return (
{/* Template Selector */}

当前激活模板

{activeTemplate && (
{activeTemplate.classes?.length ?? 0} 个分类来自模板
)}
{/* Workspace Mask Opacity */}
{maskPreviewOpacity}%
setMaskPreviewOpacity(Number(event.target.value))} className="w-full accent-cyan-500" />
{/* Semantic Classification Tree */}

语义分类树(拖拽调层级)

{allClasses.map(cls => (
))} {allClasses.length === 0 && (
请先选择一个模板
)}
{/* Add Custom Class */}

自定义分类

{showAddForm && (
setNewClassColor(e.target.value)} className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer" /> setNewClassName(e.target.value)} placeholder="分类名称" className="flex-1 bg-[#111] border border-white/10 rounded px-2 py-1 text-xs text-white" onKeyDown={(e) => e.key === 'Enter' && handleAddCustom()} />
)} {classSaveMessage && (
{classSaveMessage}
)}
{/* Current Active Object Properties */}

特定目标实例属性追踪

{selectedMaskLabel}
后端拓扑锚点: {maskAnalysis?.topology_anchor_count ?? 0} 节点
{smoothingStrength}%
void previewSmoothing(Number(event.target.value))} disabled={!selectedMask || isSmoothingMask} className="w-full accent-cyan-500 disabled:opacity-40" />
{analysisMessage && (
{analysisMessage}
)}
); }