feat: 完善分割工作区交互与传播去重
功能增加:点击 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,记录当前功能、接口契约、需求设计冻结和测试覆盖。
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Layers, ChevronDown, Tag, Eye, Plus, X, Loader2 } from 'lucide-react';
|
||||
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';
|
||||
@@ -15,10 +15,12 @@ export function OntologyInspector() {
|
||||
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('');
|
||||
@@ -34,6 +36,33 @@ export function OntologyInspector() {
|
||||
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) {
|
||||
@@ -134,12 +163,7 @@ export function OntologyInspector() {
|
||||
|
||||
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">
|
||||
<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>
|
||||
@@ -166,6 +190,27 @@ export function OntologyInspector() {
|
||||
)}
|
||||
</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">
|
||||
@@ -176,7 +221,15 @@ export function OntologyInspector() {
|
||||
<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',
|
||||
|
||||
Reference in New Issue
Block a user