Files
Pre_Seg_Server/src/components/OntologyInspector.tsx
admin 5ab4602535 feat: 完善视频传播、标注编辑和拆帧闭环
- 接入 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。
2026-05-01 20:27:33 +08:00

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>
);
}