- 接入 SAM2 视频传播能力:新增 /api/ai/propagate,支持用当前帧 mask/polygon/bbox 作为 seed,通过 SAM2 video predictor 向前、向后或双向传播,并可保存为真实 annotation。 - 接入 SAM3 video tracker:通过独立 Python 3.12 external worker 调用 SAM3 video predictor/tracker,使用本地 checkpoint 与 bbox seed 执行视频级跟踪,并在模型状态中标记 video_track 能力。 - 完善 SAM 模型分发:sam_registry 按 model_id 明确区分 sam2 propagation 与 sam3 video_track,避免两个模型链路混用。 - 打通前端“传播片段”:VideoWorkspace 使用当前选中 mask 和当前 AI 模型调用后端传播接口,传播结果回写并刷新工作区已保存标注。 - 增强 SAM3 本地 checkpoint 配置:新增 sam3_checkpoint_path 配置和 .env.example 示例,状态检查改为基于本地 checkpoint/独立环境/模型包可用性。 - 完善视频拆帧参数:/api/media/parse 支持 parse_fps、max_frames、target_width,后端任务保存帧时间戳、源帧号和 frame_sequence 元数据。 - 增加运行时 schema 兼容处理:启动时为旧 frames 表补充 timestamp_ms 和 source_frame_number 列,避免旧库升级后缺字段。 - 强化 Canvas 标注编辑:补齐多边形闭合、点工具、顶点拖拽、边中点插入、Delete/Backspace 删除、区域合并和重叠去除等交互。 - 增强语义分类联动:选中 mask 后可通过右侧语义分类树更新标签、颜色和 class metadata,并同步到保存/导出链路。 - 增加关键帧时间轴体验:FrameTimeline 显示具体时间信息,并支持键盘左右方向键切换关键帧。 - 完善 AI 交互分割参数:前端保留正向点、反向点、框选和 interactive prompt 的调用状态,支持 SAM2 细化候选区域与 SAM3 bbox 入口。 - 扩展后端/前端 API 类型:新增 propagateMasks、传播请求/响应 schema,并补齐 annotation、导出、模型状态和任务接口的测试覆盖。 - 更新项目文档:同步 README、AGENTS、接口契约、需求冻结、设计冻结、前端元素审计、实施计划和测试计划,标明真实功能边界与剩余风险。 - 增加测试覆盖:补充 SAM2/SAM3 传播、SAM3 状态、媒体拆帧参数、Canvas 编辑、语义标签切换、时间轴、工作区传播和 API 合约测试。 - 加强仓库安全边界:将 sam3权重/ 加入 .gitignore,避免本地模型权重被误提交。 验证:npm run test:run;pytest backend/tests;npm run lint;npm run build;python -m py_compile;git diff --check。
214 lines
9.8 KiB
TypeScript
214 lines
9.8 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Layers, ChevronDown, Tag, Eye, Plus, X } from 'lucide-react';
|
|
import { useStore } from '../store/useStore';
|
|
import type { TemplateClass } from '../store/useStore';
|
|
import { cn } from '../lib/utils';
|
|
import { getActiveTemplate } from '../lib/templateSelection';
|
|
|
|
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 masks = useStore((state) => state.masks);
|
|
const selectedMaskIds = useStore((state) => state.selectedMaskIds);
|
|
const setMasks = useStore((state) => state.setMasks);
|
|
const setActiveTemplateId = useStore((state) => state.setActiveTemplateId);
|
|
const setActiveClass = useStore((state) => state.setActiveClass);
|
|
|
|
// Project-level custom classes (in addition to template classes)
|
|
const [customClasses, setCustomClasses] = useState<TemplateClass[]>([]);
|
|
const [showAddForm, setShowAddForm] = useState(false);
|
|
const [newClassName, setNewClassName] = useState('');
|
|
const [newClassColor, setNewClassColor] = useState('#06b6d4');
|
|
|
|
const activeTemplate = getActiveTemplate(templates, activeTemplateId);
|
|
const templateClasses = activeTemplate?.classes || [];
|
|
const allClasses = [...templateClasses, ...customClasses].sort((a, b) => b.zIndex - a.zIndex);
|
|
|
|
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;
|
|
setMasks(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' : 'draft',
|
|
saved: mask.annotationId ? false : mask.saved,
|
|
};
|
|
}));
|
|
};
|
|
|
|
const handleAddCustom = () => {
|
|
if (!newClassName.trim()) return;
|
|
const maxZ = allClasses.length > 0 ? Math.max(...allClasses.map((c) => c.zIndex)) : 0;
|
|
const newClass: TemplateClass = {
|
|
id: `custom-${Date.now()}`,
|
|
name: newClassName.trim(),
|
|
color: newClassColor,
|
|
zIndex: maxZ + 10,
|
|
category: '自定义',
|
|
};
|
|
setCustomClasses([...customClasses, newClass]);
|
|
handleSelectClass(newClass);
|
|
setNewClassName('');
|
|
setShowAddForm(false);
|
|
};
|
|
|
|
return (
|
|
<div className="w-60 bg-[#0d0d0d] flex flex-col border-l border-white/5 shrink-0 z-10 overflow-hidden">
|
|
<div className="h-14 border-b border-white/5 flex items-center px-4 shrink-0 font-medium text-[10px] uppercase tracking-widest text-gray-500">
|
|
<Layers size={14} className="mr-2 text-gray-400" />
|
|
本体论与属性分类管理树
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto 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} 个分类来自模板
|
|
{customClasses.length > 0 && ` + ${customClasses.length} 个自定义`}
|
|
</div>
|
|
)}
|
|
</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"
|
|
onClick={() => handleSelectClass(cls)}
|
|
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">
|
|
<Plus size={14} />
|
|
</button>
|
|
<button onClick={() => setShowAddForm(false)} className="text-gray-500 hover:text-gray-300">
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</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 w-[94%]" />
|
|
</div>
|
|
<div className="text-[10px] font-mono text-green-500 text-right">0.9412</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">12 节点</span>
|
|
</div>
|
|
<button 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">
|
|
重新提取内侧中轴树骨架
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|