功能增加:点击 Canvas mask 后,右侧语义分类树会按 classId/className/label 自动匹配分类,并滚动聚焦到对应分类按钮。
功能增加:工作区新增按起止帧批量清空片段遮罩,复用传播范围输入,范围内已保存标注走 DELETE /api/ai/annotations/{id},本地 draft mask 同步移除。
功能增加:右侧语义分类树上方新增工作区 mask 透明度滑杆,写入 Zustand maskPreviewOpacity,Canvas mask 预览按该值渲染并保留选中加亮反馈。
功能增加:视频处理进度条记录最近自动传播区间,使用不同色系深浅渐变提示最近处理片段。
功能增加:工作区自动传播前会先保存 draft/dirty seed mask,使用稳定后端 source_annotation_id 入队,减少二次传播重复结果。
Bugfix:后端传播任务对旧临时 seed id、不同 SAM 2.1 权重结果做兼容清理;相同 seed 和相同权重才跳过,否则先删旧自动传播标注再重传。
Bugfix:修复 polygon 顶点拖拽结束后触发 Stage 平移导致画布中心偏移的问题,并补充测试环境对 drag target 的模拟。
Bugfix:工具提示会在数秒后自动隐藏,避免创建多边形/矩形等提示长期遮挡画布。
UI 调整:移除右侧面板顶部‘本体论与属性分类管理树’说明栏,减少无效占位。
UI 调整:左侧工具栏和右侧语义面板使用低对比 seg-scrollbar;左侧工具栏外扩滚动条槽位,避免滚动条挤占图标列。
UI 调整:工作区模型状态徽标改为紧凑显示,减少与传播权重选择重复;传播权重下拉改成深色背景和青色文字,避免灰底白字不可读。
UI 调整:缩略图状态框固定优先级,当前帧、人工/AI 标注帧、自动传播帧可用外框/内框组合同时表达。
测试:补充 VideoWorkspace、CanvasArea、FrameTimeline、OntologyInspector、ToolsPalette、useStore 和后端 test_ai 覆盖新增交互、传播去重、批量清空、透明度、滚动条和 UI 状态。
文档:同步更新 README、AGENTS 和 doc/03、doc/04、doc/07、doc/08、doc/09,记录当前功能、接口契约、需求设计冻结和测试覆盖。
345 lines
16 KiB
TypeScript
345 lines
16 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { ChevronDown, Tag, Eye, Plus, X, Loader2 } from 'lucide-react';
|
|
import { useStore } from '../store/useStore';
|
|
import type { TemplateClass } from '../store/useStore';
|
|
import { cn } from '../lib/utils';
|
|
import { getActiveTemplate } from '../lib/templateSelection';
|
|
import { analyzeMask, updateTemplate, type MaskAnalysisResult } from '../lib/api';
|
|
|
|
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 [maskAnalysis, setMaskAnalysis] = useState<MaskAnalysisResult | null>(null);
|
|
const [isAnalyzingMask, setIsAnalyzingMask] = useState(false);
|
|
const [analysisMessage, setAnalysisMessage] = useState('');
|
|
|
|
const activeTemplate = getActiveTemplate(templates, activeTemplateId);
|
|
const templateClasses = activeTemplate?.classes || [];
|
|
const allClasses = [...templateClasses].sort((a, b) => b.zIndex - a.zIndex);
|
|
const selectedMask = masks.find((mask) => selectedMaskIds.includes(mask.id)) || null;
|
|
const currentFrame = frames[currentFrameIndex] || null;
|
|
const classButtonRefs = useRef(new Map<string, HTMLButtonElement>());
|
|
|
|
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 updatedMasks = masks.map((mask) => {
|
|
if (!selectedIdSet.has(mask.id)) return mask;
|
|
return {
|
|
...mask,
|
|
templateId: templateId || mask.templateId,
|
|
classId: templateClass.id,
|
|
className: templateClass.name,
|
|
classZIndex: templateClass.zIndex,
|
|
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 (extractSkeleton = false) => {
|
|
if (!selectedMask || !currentFrame) {
|
|
setMaskAnalysis(null);
|
|
setAnalysisMessage(selectedMask ? '当前帧信息不可用,无法读取后端属性' : '请选择一个 mask 查看后端属性');
|
|
return;
|
|
}
|
|
setIsAnalyzingMask(true);
|
|
setAnalysisMessage('');
|
|
try {
|
|
const result = await analyzeMask(selectedMask, currentFrame, { extractSkeleton });
|
|
setMaskAnalysis(result);
|
|
setAnalysisMessage(result.message);
|
|
} catch (err) {
|
|
console.error('Mask analysis failed:', err);
|
|
setMaskAnalysis(null);
|
|
setAnalysisMessage('后端属性读取失败');
|
|
} finally {
|
|
setIsAnalyzingMask(false);
|
|
}
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
void refreshMaskAnalysis(false);
|
|
// 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]);
|
|
|
|
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,
|
|
category: '自定义',
|
|
};
|
|
setIsSavingClass(true);
|
|
setClassSaveMessage('');
|
|
try {
|
|
const updated = await updateTemplate(activeTemplate.id, {
|
|
name: activeTemplate.name,
|
|
description: activeTemplate.description,
|
|
classes: [...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);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="w-60 bg-[#0d0d0d] flex flex-col border-l border-white/5 shrink-0 z-10 overflow-hidden">
|
|
<div className="flex-1 overflow-y-auto seg-scrollbar p-4 flex flex-col gap-6">
|
|
{/* Template Selector */}
|
|
<div>
|
|
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">当前激活模板</h3>
|
|
<div className="relative">
|
|
<select
|
|
value={activeTemplate?.id || ''}
|
|
onChange={(e) => {
|
|
setActiveTemplateId(e.target.value || null);
|
|
setActiveClass(null);
|
|
}}
|
|
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 appearance-none cursor-pointer focus:outline-none focus:border-cyan-500/50"
|
|
>
|
|
<option value="">-- 选择模板 --</option>
|
|
{templates.map((t) => (
|
|
<option key={t.id} value={t.id}>{t.name}</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown size={12} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none" />
|
|
</div>
|
|
{activeTemplate && (
|
|
<div className="mt-2 text-[10px] text-gray-600">
|
|
{activeTemplate.classes?.length ?? 0} 个分类来自模板
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Workspace Mask Opacity */}
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<label htmlFor="workspace-mask-opacity" className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">
|
|
遮罩透明度
|
|
</label>
|
|
<span className="text-[10px] font-mono text-cyan-400">{maskPreviewOpacity}%</span>
|
|
</div>
|
|
<input
|
|
id="workspace-mask-opacity"
|
|
aria-label="遮罩透明度"
|
|
type="range"
|
|
min={10}
|
|
max={100}
|
|
step={5}
|
|
value={maskPreviewOpacity}
|
|
onChange={(event) => setMaskPreviewOpacity(Number(event.target.value))}
|
|
className="w-full accent-cyan-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* Semantic Classification Tree */}
|
|
<div>
|
|
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3 flex justify-between items-center">
|
|
<span>语义分类树 (高度/Z-Index)</span>
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{allClasses.map(cls => (
|
|
<div key={cls.id} className="flex flex-col gap-1">
|
|
<button
|
|
type="button"
|
|
ref={(node) => {
|
|
if (node) {
|
|
classButtonRefs.current.set(cls.id, node);
|
|
} else {
|
|
classButtonRefs.current.delete(cls.id);
|
|
}
|
|
}}
|
|
onClick={() => handleSelectClass(cls)}
|
|
aria-current={activeClassId === cls.id ? 'true' : undefined}
|
|
className={cn(
|
|
'flex items-center justify-between p-2 rounded bg-white/5 hover:bg-white/10 cursor-pointer group transition-colors text-left border',
|
|
activeClassId === cls.id ? 'border-cyan-500/50 bg-cyan-500/10' : 'border-transparent',
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: cls.color }} />
|
|
<span className="text-xs font-medium text-gray-200">{cls.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-[10px] text-gray-500 font-mono">z:{cls.zIndex}</span>
|
|
<Eye size={14} className="text-gray-500 group-hover:text-gray-300" />
|
|
</div>
|
|
</button>
|
|
</div>
|
|
))}
|
|
{allClasses.length === 0 && (
|
|
<div className="text-xs text-gray-600 text-center py-4">请先选择一个模板</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Add Custom Class */}
|
|
<div>
|
|
<div className="flex justify-between items-center mb-2">
|
|
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">自定义分类</h3>
|
|
<button
|
|
onClick={() => setShowAddForm(!showAddForm)}
|
|
className="text-cyan-400 hover:text-cyan-300 transition-colors"
|
|
>
|
|
<Plus size={12} />
|
|
</button>
|
|
</div>
|
|
{showAddForm && (
|
|
<div className="bg-[#1a1a1a] border border-white/10 rounded-lg p-3 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="color"
|
|
value={newClassColor}
|
|
onChange={(e) => setNewClassColor(e.target.value)}
|
|
className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={newClassName}
|
|
onChange={(e) => 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()}
|
|
/>
|
|
<button onClick={handleAddCustom} className="text-cyan-400 hover:text-cyan-300">
|
|
{isSavingClass ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
|
|
</button>
|
|
<button onClick={() => setShowAddForm(false)} className="text-gray-500 hover:text-gray-300">
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{classSaveMessage && (
|
|
<div className="mt-2 text-[10px] text-gray-500">{classSaveMessage}</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Current Active Object Properties */}
|
|
<div className="mt-4 pt-4 border-t border-[#222]">
|
|
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3">特定目标实例属性追踪</h3>
|
|
<div className="bg-white/5 rounded-lg p-3">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Tag size={12} className="text-cyan-400" />
|
|
<span className="text-xs font-semibold text-gray-200">
|
|
{activeClass?.name || activeTemplate?.name || '未选择'}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] text-gray-500 uppercase">当前选中区域:</span>
|
|
<span className="text-xs font-mono text-gray-300">{selectedMaskIds.length}</span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] text-gray-500 uppercase">后端模型置信度</label>
|
|
<div className="h-1.5 w-full bg-white/10 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-green-500"
|
|
style={{ width: `${Math.round((maskAnalysis?.confidence ?? 0) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-[10px] font-mono text-green-500 text-right">
|
|
{maskAnalysis?.confidence != null ? maskAnalysis.confidence.toFixed(4) : '无模型分数'}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] text-gray-500 uppercase">后端拓扑锚点:</span>
|
|
<span className="text-xs font-mono text-gray-300">{maskAnalysis?.topology_anchor_count ?? 0} 节点</span>
|
|
</div>
|
|
{analysisMessage && (
|
|
<div className="text-[10px] leading-relaxed text-gray-500">{analysisMessage}</div>
|
|
)}
|
|
<button
|
|
onClick={() => refreshMaskAnalysis(true)}
|
|
disabled={!selectedMask || isAnalyzingMask}
|
|
className="w-full mt-2 bg-white/5 hover:bg-white/10 border border-white/10 text-xs text-gray-300 py-1.5 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{isAnalyzingMask ? '提取中...' : '重新提取拓扑锚点'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|