同步传播链编辑并保护模板切换
- 修改激活模板时,如果当前项目已有 mask,先提示确认并清空所有本地 mask 和已保存后端标注;无 mask 项目可直接切换。 - 模板库详情页将“+ 新建分类”改为带编辑图标的“编辑模板”,并打开完整模板编辑弹窗。 - 区域合并会按 propagation lineage 找到其它传播帧的对应主区域和参与区域,逐帧执行 union,只删除实际参与合并的对应 mask。 - 重叠区域去除会按 propagation lineage 同步到其它传播帧的对应区域,保留参与扣除 mask,不再只改当前帧。 - 当前帧清空遮罩会同步删除这些 mask 的关联自动传播结果,并新增左侧工具栏清空入口。 - 传播链同步编辑保留 source、source_annotation_id、source_mask_id、propagation_seed_key 等 metadata,避免时间轴帧属性变色。 - 补充模板切换确认、模板编辑按钮、左侧清空入口、传播链合并/去除和清空传播链的前端回归测试。 - 更新 AGENTS、接口契约、冻结需求、设计冻结和测试计划文档。
This commit is contained in:
@@ -921,6 +921,75 @@ describe('CanvasArea', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('merges corresponding propagated masks on other frames without dropping the propagation lineage', async () => {
|
||||
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{
|
||||
id: 'annotation-1',
|
||||
annotationId: '1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
},
|
||||
{
|
||||
id: 'annotation-2',
|
||||
annotationId: '2',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
},
|
||||
{
|
||||
id: 'annotation-10',
|
||||
annotationId: '10',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
|
||||
},
|
||||
{
|
||||
id: 'annotation-20',
|
||||
annotationId: '20',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="area_merge" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
||||
const paths = screen.getAllByTestId('konva-path');
|
||||
fireEvent.click(paths[0]);
|
||||
fireEvent.click(paths[1]);
|
||||
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
|
||||
|
||||
await waitFor(() => expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(expect.arrayContaining(['2', '20'])));
|
||||
const masks = useStore.getState().masks;
|
||||
expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10']);
|
||||
expect(masks.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
|
||||
saveStatus: 'dirty',
|
||||
saved: false,
|
||||
metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('removes overlap from the primary selected mask with polygon difference', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
@@ -967,6 +1036,78 @@ describe('CanvasArea', () => {
|
||||
expect(useStore.getState().masks[1].id).toBe('m2');
|
||||
});
|
||||
|
||||
it('removes overlap from corresponding propagated masks while preserving secondary masks', async () => {
|
||||
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{
|
||||
id: 'annotation-1',
|
||||
annotationId: '1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 90 10 L 90 70 L 10 70 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[10, 10, 90, 10, 90, 70, 10, 70]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
},
|
||||
{
|
||||
id: 'annotation-2',
|
||||
annotationId: '2',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 50 30 L 120 30 L 120 80 L 50 80 Z',
|
||||
label: 'B',
|
||||
color: '#ff0000',
|
||||
segmentation: [[50, 30, 120, 30, 120, 80, 50, 80]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
},
|
||||
{
|
||||
id: 'annotation-10',
|
||||
annotationId: '10',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 12 12 L 92 12 L 92 72 L 12 72 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[12, 12, 92, 12, 92, 72, 12, 72]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
|
||||
},
|
||||
{
|
||||
id: 'annotation-20',
|
||||
annotationId: '20',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 52 32 L 122 32 L 122 82 L 52 82 Z',
|
||||
label: 'B',
|
||||
color: '#ff0000',
|
||||
segmentation: [[52, 32, 122, 32, 122, 82, 52, 82]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="area_remove" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
||||
const paths = screen.getAllByTestId('konva-path');
|
||||
fireEvent.click(paths[0]);
|
||||
fireEvent.click(paths[1]);
|
||||
fireEvent.click(screen.getByRole('button', { name: '从主区域去除' }));
|
||||
|
||||
await waitFor(() => expect(useStore.getState().masks.find((mask) => mask.id === 'annotation-10')?.saveStatus).toBe('dirty'));
|
||||
expect(onDeleteMaskAnnotations).not.toHaveBeenCalled();
|
||||
expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10', 'annotation-2', 'annotation-20']);
|
||||
expect(useStore.getState().masks.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
|
||||
saved: false,
|
||||
metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1 }),
|
||||
}));
|
||||
expect(useStore.getState().masks.find((mask) => mask.id === 'annotation-20')).toEqual(expect.objectContaining({
|
||||
saveStatus: 'saved',
|
||||
metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('renders inner overlap removal as a hole in the primary mask', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
|
||||
@@ -1436,55 +1436,88 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const handleBooleanOperation = async () => {
|
||||
if (!frame || booleanSelectedMasks.length < 2) return;
|
||||
const primary = booleanSelectedMasks[0];
|
||||
const primaryGeometry = maskToMultiPolygon(primary);
|
||||
if (!primaryGeometry) return;
|
||||
const secondaryMasks = booleanSelectedMasks.slice(1);
|
||||
const currentFrameId = String(frame.id);
|
||||
const targetFrameIds = new Set<string>([currentFrameId]);
|
||||
|
||||
const clipGeometries = booleanSelectedMasks
|
||||
.slice(1)
|
||||
.map(maskToMultiPolygon)
|
||||
.filter((geometry): geometry is MultiPolygon => Boolean(geometry));
|
||||
if (clipGeometries.length === 0) return;
|
||||
|
||||
const resultGeometry = effectiveTool === 'area_merge'
|
||||
? polygonClipping.union(primaryGeometry, ...clipGeometries)
|
||||
: polygonClipping.difference(primaryGeometry, ...clipGeometries);
|
||||
const resultSegmentation = multiPolygonToSegmentation(resultGeometry);
|
||||
|
||||
if (resultSegmentation.length === 0) {
|
||||
const deleteMaskIds = expandedPropagationDeletionMaskIds([primary.id], masks);
|
||||
const deleteIds = masks
|
||||
.filter((mask) => deleteMaskIds.has(mask.id))
|
||||
.map((mask) => mask.annotationId)
|
||||
.filter((annotationId): annotationId is string => Boolean(annotationId));
|
||||
setMasks(masks.filter((mask) => !deleteMaskIds.has(mask.id)));
|
||||
if (deleteIds.length > 0) await onDeleteMaskAnnotations?.(deleteIds);
|
||||
setSelectedMaskId(null);
|
||||
setSelectedMaskIds([]);
|
||||
setSelectedVertexIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPrimary = updateMaskFromSegmentation(primary, resultSegmentation, {
|
||||
area: multiPolygonArea(resultGeometry),
|
||||
hasHoles: multiPolygonHasHoles(resultGeometry),
|
||||
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
|
||||
masks.forEach((mask) => {
|
||||
const targetFrameId = String(mask.frameId);
|
||||
if (targetFrameId === currentFrameId) return;
|
||||
const hasPrimary = findLinkedMasksOnFrame([primary.id], masks, targetFrameId).length > 0;
|
||||
if (!hasPrimary) return;
|
||||
const hasSecondary = secondaryMasks.some((secondary) => (
|
||||
findLinkedMasksOnFrame([secondary.id], masks, targetFrameId).length > 0
|
||||
));
|
||||
if (hasSecondary) targetFrameIds.add(targetFrameId);
|
||||
});
|
||||
const secondaryIds = effectiveTool === 'area_merge'
|
||||
? expandedPropagationDeletionMaskIds(booleanSelectedMasks.slice(1).map((mask) => mask.id), masks)
|
||||
: new Set<string>();
|
||||
const secondaryAnnotationIds = effectiveTool === 'area_merge'
|
||||
? masks
|
||||
.filter((mask) => secondaryIds.has(mask.id))
|
||||
|
||||
const updatedMasks = new Map<string, Mask>();
|
||||
const deletedMaskIds = new Set<string>();
|
||||
|
||||
const applyOperationForFrame = (targetFrameId: string) => {
|
||||
const primaryTargetId = targetFrameId === currentFrameId
|
||||
? primary.id
|
||||
: findLinkedMasksOnFrame([primary.id], masks, targetFrameId)[0];
|
||||
const primaryTarget = masks.find((mask) => mask.id === primaryTargetId);
|
||||
if (!primaryTarget || deletedMaskIds.has(primaryTarget.id)) return;
|
||||
const primaryGeometry = maskToMultiPolygon(primaryTarget);
|
||||
if (!primaryGeometry) return;
|
||||
|
||||
const secondaryTargetIds = Array.from(new Set(
|
||||
secondaryMasks.flatMap((secondary) => (
|
||||
targetFrameId === currentFrameId
|
||||
? [secondary.id]
|
||||
: findLinkedMasksOnFrame([secondary.id], masks, targetFrameId)
|
||||
)),
|
||||
)).filter((maskId) => maskId !== primaryTarget.id && !deletedMaskIds.has(maskId));
|
||||
const secondaryTargets = secondaryTargetIds
|
||||
.map((maskId) => masks.find((mask) => mask.id === maskId))
|
||||
.filter((mask): mask is Mask => Boolean(mask));
|
||||
const clipGeometries = secondaryTargets
|
||||
.map(maskToMultiPolygon)
|
||||
.filter((geometry): geometry is MultiPolygon => Boolean(geometry));
|
||||
if (clipGeometries.length === 0) return;
|
||||
|
||||
const resultGeometry = effectiveTool === 'area_merge'
|
||||
? polygonClipping.union(primaryGeometry, ...clipGeometries)
|
||||
: polygonClipping.difference(primaryGeometry, ...clipGeometries);
|
||||
const resultSegmentation = multiPolygonToSegmentation(resultGeometry);
|
||||
|
||||
if (resultSegmentation.length === 0) {
|
||||
deletedMaskIds.add(primaryTarget.id);
|
||||
} else {
|
||||
updatedMasks.set(primaryTarget.id, updateMaskFromSegmentation(primaryTarget, resultSegmentation, {
|
||||
area: multiPolygonArea(resultGeometry),
|
||||
hasHoles: multiPolygonHasHoles(resultGeometry),
|
||||
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
|
||||
}));
|
||||
}
|
||||
|
||||
if (effectiveTool === 'area_merge') {
|
||||
secondaryTargets.forEach((mask) => deletedMaskIds.add(mask.id));
|
||||
}
|
||||
};
|
||||
|
||||
targetFrameIds.forEach(applyOperationForFrame);
|
||||
|
||||
const deletedAnnotationIds = Array.from(new Set(
|
||||
masks
|
||||
.filter((mask) => deletedMaskIds.has(mask.id))
|
||||
.map((mask) => mask.annotationId)
|
||||
.filter((annotationId): annotationId is string => Boolean(annotationId))
|
||||
: [];
|
||||
.filter((annotationId): annotationId is string => Boolean(annotationId)),
|
||||
));
|
||||
|
||||
setMasks(masks
|
||||
.filter((mask) => !secondaryIds.has(mask.id))
|
||||
.map((mask) => (mask.id === primary.id ? nextPrimary : mask)));
|
||||
if (secondaryAnnotationIds.length > 0) await onDeleteMaskAnnotations?.(secondaryAnnotationIds);
|
||||
setSelectedMaskId(primary.id);
|
||||
setSelectedMaskIds([primary.id]);
|
||||
.filter((mask) => !deletedMaskIds.has(mask.id))
|
||||
.map((mask) => updatedMasks.get(mask.id) || mask));
|
||||
if (deletedAnnotationIds.length > 0) await onDeleteMaskAnnotations?.(deletedAnnotationIds);
|
||||
if (deletedMaskIds.has(primary.id)) {
|
||||
setSelectedMaskId(null);
|
||||
setSelectedMaskIds([]);
|
||||
} else {
|
||||
setSelectedMaskId(primary.id);
|
||||
setSelectedMaskIds([primary.id]);
|
||||
}
|
||||
setSelectedVertexIndex(null);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ import { OntologyInspector } from './OntologyInspector';
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
analyzeMask: vi.fn(),
|
||||
deleteAnnotation: vi.fn(),
|
||||
smoothMaskGeometry: vi.fn(),
|
||||
updateTemplate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
analyzeMask: apiMock.analyzeMask,
|
||||
deleteAnnotation: apiMock.deleteAnnotation,
|
||||
smoothMaskGeometry: apiMock.smoothMaskGeometry,
|
||||
updateTemplate: apiMock.updateTemplate,
|
||||
}));
|
||||
@@ -41,6 +43,7 @@ describe('OntologyInspector', () => {
|
||||
smoothing: { strength: 35, method: 'chaikin' },
|
||||
message: '已应用边缘平滑强度 35',
|
||||
});
|
||||
apiMock.deleteAnnotation.mockResolvedValue(undefined);
|
||||
useStore.setState({
|
||||
templates: [
|
||||
{
|
||||
@@ -72,6 +75,44 @@ describe('OntologyInspector', () => {
|
||||
expect(screen.queryByText(/z:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('requires confirmation and clears existing masks before switching templates', async () => {
|
||||
useStore.setState({
|
||||
activeTemplateId: 't1',
|
||||
templates: [
|
||||
{
|
||||
id: 't1',
|
||||
name: '腹腔镜模板',
|
||||
classes: [{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 }],
|
||||
rules: [],
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
name: '头颈部模板',
|
||||
classes: [{ id: 'c2', name: '肿瘤', color: '#00ff00', zIndex: 20 }],
|
||||
rules: [],
|
||||
},
|
||||
],
|
||||
masks: [
|
||||
{ id: 'annotation-10', annotationId: '10', frameId: 'f1', pathData: 'M 0 0 Z', label: '胆囊', color: '#ff0000', saveStatus: 'saved', saved: true },
|
||||
{ id: 'draft-1', frameId: 'f1', pathData: 'M 1 1 Z', label: '草稿', color: '#06b6d4', saveStatus: 'draft' },
|
||||
],
|
||||
selectedMaskIds: ['annotation-10'],
|
||||
});
|
||||
|
||||
render(<OntologyInspector />);
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 't2' } });
|
||||
|
||||
expect(screen.getByText('确认修改激活模板')).toBeInTheDocument();
|
||||
expect(useStore.getState().activeTemplateId).toBe('t1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空并切换' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10'));
|
||||
expect(useStore.getState().masks).toEqual([]);
|
||||
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||
expect(useStore.getState().activeTemplateId).toBe('t2');
|
||||
});
|
||||
|
||||
it('adjusts workspace mask opacity from above the semantic tree', () => {
|
||||
render(<OntologyInspector />);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { analyzeMask, deleteAnnotation, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult, type SmoothMaskGeometryResult } from '../lib/api';
|
||||
import { isReservedUnclassifiedClass, nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
|
||||
|
||||
const SMOOTHING_PREVIEW_DEBOUNCE_MS = 220;
|
||||
@@ -23,6 +23,19 @@ function metadataNumber(value: unknown): number | null {
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
const maybeError = error as { response?: { status?: number }; status?: number } | null;
|
||||
return maybeError?.response?.status === 404 || maybeError?.status === 404;
|
||||
}
|
||||
|
||||
async function deleteAnnotationIfExists(annotationId: string) {
|
||||
try {
|
||||
await deleteAnnotation(annotationId);
|
||||
} catch (error) {
|
||||
if (!isNotFoundError(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function propagationSourceMaskTokens(value: unknown): string[] {
|
||||
if (typeof value !== 'string' || value.length === 0) return [];
|
||||
const tokens = [`mask:${value}`];
|
||||
@@ -70,6 +83,7 @@ export function OntologyInspector() {
|
||||
const selectedMaskIds = useStore((state) => state.selectedMaskIds);
|
||||
const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity);
|
||||
const setMasks = useStore((state) => state.setMasks);
|
||||
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
|
||||
const updateTemplateStore = useStore((state) => state.updateTemplate);
|
||||
const setActiveTemplateId = useStore((state) => state.setActiveTemplateId);
|
||||
const setActiveClass = useStore((state) => state.setActiveClass);
|
||||
@@ -86,6 +100,8 @@ export function OntologyInspector() {
|
||||
const [smoothingStrength, setSmoothingStrength] = useState(0);
|
||||
const [isPreviewingSmoothing, setIsPreviewingSmoothing] = useState(false);
|
||||
const [isSmoothingMask, setIsSmoothingMask] = useState(false);
|
||||
const [pendingTemplateSwitch, setPendingTemplateSwitch] = useState<{ templateId: string | null } | null>(null);
|
||||
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false);
|
||||
|
||||
const activeTemplate = getActiveTemplate(templates, activeTemplateId);
|
||||
const templateClasses = normalizeClassMaskIds(activeTemplate?.classes || []);
|
||||
@@ -215,6 +231,51 @@ export function OntologyInspector() {
|
||||
smoothingPreviewRef.current = null;
|
||||
}, []);
|
||||
|
||||
const applyActiveTemplateChange = React.useCallback((templateId: string | null) => {
|
||||
setActiveTemplateId(templateId);
|
||||
setActiveClass(null);
|
||||
}, [setActiveClass, setActiveTemplateId]);
|
||||
|
||||
const requestActiveTemplateChange = React.useCallback((templateId: string | null) => {
|
||||
if (templateId === (activeTemplateId || null)) return;
|
||||
if (!activeTemplateId && templateId && templateId === activeTemplate?.id) {
|
||||
applyActiveTemplateChange(templateId);
|
||||
return;
|
||||
}
|
||||
if (masks.length === 0) {
|
||||
applyActiveTemplateChange(templateId);
|
||||
return;
|
||||
}
|
||||
setPendingTemplateSwitch({ templateId });
|
||||
}, [activeTemplate?.id, activeTemplateId, applyActiveTemplateChange, masks.length]);
|
||||
|
||||
const confirmActiveTemplateChange = React.useCallback(async () => {
|
||||
if (!pendingTemplateSwitch) return;
|
||||
const nextTemplateId = pendingTemplateSwitch.templateId;
|
||||
const annotationIds = Array.from(new Set(
|
||||
useStore.getState().masks
|
||||
.map((mask) => mask.annotationId)
|
||||
.filter((annotationId): annotationId is string => Boolean(annotationId)),
|
||||
));
|
||||
setIsSwitchingTemplate(true);
|
||||
setClassSaveMessage(annotationIds.length > 0 ? '正在清空旧模板标注...' : '正在清空旧模板遮罩...');
|
||||
try {
|
||||
await Promise.all(annotationIds.map(deleteAnnotationIfExists));
|
||||
useStore.getState().setMasks([]);
|
||||
setSelectedMaskIds([]);
|
||||
applyActiveTemplateChange(nextTemplateId);
|
||||
setPendingTemplateSwitch(null);
|
||||
setClassSaveMessage(annotationIds.length > 0
|
||||
? `已清空 ${annotationIds.length} 个旧模板标注并切换激活模板`
|
||||
: '已清空旧模板遮罩并切换激活模板');
|
||||
} catch (err) {
|
||||
console.error('Switch active template failed:', err);
|
||||
setClassSaveMessage('切换模板失败,旧标注未清空');
|
||||
} finally {
|
||||
setIsSwitchingTemplate(false);
|
||||
}
|
||||
}, [applyActiveTemplateChange, pendingTemplateSwitch, setSelectedMaskIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
analysisRequestIdRef.current += 1;
|
||||
@@ -563,9 +624,9 @@ export function OntologyInspector() {
|
||||
<select
|
||||
value={activeTemplate?.id || ''}
|
||||
onChange={(e) => {
|
||||
setActiveTemplateId(e.target.value || null);
|
||||
setActiveClass(null);
|
||||
requestActiveTemplateChange(e.target.value || null);
|
||||
}}
|
||||
disabled={isSwitchingTemplate}
|
||||
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>
|
||||
@@ -754,6 +815,37 @@ export function OntologyInspector() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{pendingTemplateSwitch && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
|
||||
<div className="w-full max-w-md rounded-lg border border-amber-400/25 bg-[#151515] p-5 shadow-2xl">
|
||||
<h2 className="text-lg font-semibold text-white">确认修改激活模板</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-300">
|
||||
当前项目已经存在 {masks.length} 个 mask。修改激活模板会清空当前项目中所有已有 mask 和已保存标注,避免旧模板类别继续混用。
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-amber-200/70">
|
||||
项目没有任何 mask 时切换模板不会提示。
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingTemplateSwitch(null)}
|
||||
disabled={isSwitchingTemplate}
|
||||
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void confirmActiveTemplateChange()}
|
||||
disabled={isSwitchingTemplate}
|
||||
className="rounded bg-amber-500 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-400 disabled:opacity-60"
|
||||
>
|
||||
{isSwitchingTemplate ? '正在切换...' : '清空并切换'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('TemplateRegistry', () => {
|
||||
expect(screen.queryByPlaceholderText('类别')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the semantic tree title and opens the add-class modal from the detail view', async () => {
|
||||
it('shows the semantic tree title and opens the edit-template modal from the detail view', async () => {
|
||||
apiMock.getTemplates.mockResolvedValueOnce([
|
||||
{
|
||||
id: 't1',
|
||||
@@ -269,7 +269,9 @@ describe('TemplateRegistry', () => {
|
||||
expect(screen.queryByText(/Painter's Algorithm Weight/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('器官')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /新建分类/ }));
|
||||
expect(screen.queryByRole('button', { name: /新建分类/ })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑模板' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /添加分类/ }));
|
||||
expect(screen.getByDisplayValue('新类别')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
|
||||
@@ -138,17 +138,6 @@ export function TemplateRegistry() {
|
||||
category: '未分类',
|
||||
});
|
||||
|
||||
const openAddClass = (template: Template) => {
|
||||
const classes = normalizeClassMaskIds(template.classes ? [...template.classes] : []);
|
||||
const newClass = buildNewClass(classes);
|
||||
setSelectedTemplate(template);
|
||||
setEditName(template.name);
|
||||
setEditDesc(template.description || '');
|
||||
setEditClasses([...classes, newClass]);
|
||||
setEditingClassId(newClass.id);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const buildTemplatePayload = (template: Template | null, classes: TemplateClass[]) => ({
|
||||
name: template ? template.name : editName.trim(),
|
||||
description: template ? template.description || undefined : editDesc.trim() || undefined,
|
||||
@@ -511,10 +500,10 @@ export function TemplateRegistry() {
|
||||
</h2>
|
||||
{activeTemplate && (
|
||||
<button
|
||||
onClick={() => openAddClass(activeTemplate)}
|
||||
onClick={() => openEdit(activeTemplate)}
|
||||
className="bg-white/5 hover:bg-white/10 border border-white/10 px-4 py-1.5 rounded text-sm text-gray-300 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus size={14} /> 新建分类
|
||||
<Edit3 size={14} /> 编辑模板
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -69,6 +69,15 @@ describe('ToolsPalette', () => {
|
||||
expect(overlapButton.compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('exposes clear mask action in the left toolbar', () => {
|
||||
const onClearMasks = vi.fn();
|
||||
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} onClearMasks={onClearMasks} />);
|
||||
|
||||
fireEvent.click(screen.getByTitle('清空遮罩(含传播帧)'));
|
||||
|
||||
expect(onClearMasks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('separates drawing, editing, and external action tool groups', () => {
|
||||
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} canImportGtMask />);
|
||||
|
||||
@@ -76,12 +85,15 @@ describe('ToolsPalette', () => {
|
||||
const circleButton = screen.getByTitle('创建圆 (O)');
|
||||
const brushButton = screen.getByTitle('画笔 (B)');
|
||||
const removeButton = screen.getByTitle('重叠区域去除 (-)');
|
||||
const clearButton = screen.getByTitle('清空遮罩(含传播帧)');
|
||||
const importButton = screen.getByTitle('导入 GT Mask');
|
||||
|
||||
expect(separators).toHaveLength(2);
|
||||
expect(circleButton.compareDocumentPosition(separators[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(separators[0].compareDocumentPosition(brushButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(removeButton.compareDocumentPosition(separators[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(separators[1].compareDocumentPosition(clearButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(clearButton.compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(separators[1].compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
separators.forEach((separator) => {
|
||||
expect(separator).toHaveClass('bg-white/15');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { MousePointer2, PencilLine, Hexagon, Square, Circle, Brush, Eraser, Combine, Scissors, FileUp } from 'lucide-react';
|
||||
import { MousePointer2, PencilLine, Hexagon, Square, Circle, Brush, Eraser, Combine, Scissors, FileUp, Trash2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { AiSegmentationIcon } from './AiSegmentationIcon';
|
||||
import { useStore } from '../store/useStore';
|
||||
@@ -9,6 +9,7 @@ interface ToolsPaletteProps {
|
||||
setActiveTool: (tool: string) => void;
|
||||
onTriggerAI?: () => void;
|
||||
onImportGtMask?: () => void;
|
||||
onClearMasks?: () => void;
|
||||
canImportGtMask?: boolean;
|
||||
isImportingGtMask?: boolean;
|
||||
}
|
||||
@@ -18,6 +19,7 @@ export function ToolsPalette({
|
||||
setActiveTool,
|
||||
onTriggerAI,
|
||||
onImportGtMask,
|
||||
onClearMasks,
|
||||
canImportGtMask = false,
|
||||
isImportingGtMask = false,
|
||||
}: ToolsPaletteProps) {
|
||||
@@ -99,6 +101,15 @@ export function ToolsPalette({
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={onClearMasks}
|
||||
disabled={!onClearMasks}
|
||||
title="清空遮罩(含传播帧)"
|
||||
className="w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 text-red-300 hover:bg-red-500/10 hover:text-red-200 disabled:opacity-35 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={2.1} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onImportGtMask}
|
||||
disabled={!canImportGtMask || isImportingGtMask}
|
||||
|
||||
@@ -483,6 +483,52 @@ describe('VideoWorkspace', () => {
|
||||
expect(useStore.getState().masks).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears linked propagated masks when clearing current-frame masks', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||||
]);
|
||||
apiMock.deleteAnnotation.mockResolvedValue(undefined);
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{
|
||||
id: 'annotation-1',
|
||||
annotationId: '1',
|
||||
frameId: '10',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: 'Seed',
|
||||
color: '#06b6d4',
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
},
|
||||
{
|
||||
id: 'annotation-10',
|
||||
annotationId: '10',
|
||||
frameId: '11',
|
||||
pathData: 'M 1 1 Z',
|
||||
label: 'Propagated',
|
||||
color: '#06b6d4',
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
|
||||
},
|
||||
],
|
||||
selectedMaskIds: ['annotation-10'],
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空遮罩' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('1'));
|
||||
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10');
|
||||
expect(useStore.getState().masks).toEqual([]);
|
||||
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears masks across the selected frame range', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
|
||||
|
||||
@@ -280,6 +280,61 @@ const isPropagatedMask = (mask: Mask) => {
|
||||
|| mask.metadata?.propagation_seed_key !== undefined;
|
||||
};
|
||||
|
||||
const metadataNumber = (value: unknown): number | null => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const 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;
|
||||
};
|
||||
|
||||
const propagationLineageTokens = (mask: Mask): Set<string> => {
|
||||
const metadata = mask.metadata || {};
|
||||
const tokens = new Set<string>([`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;
|
||||
};
|
||||
|
||||
const findPropagationChainMaskIds = (selectedIds: string[], allMasks: Mask[]): Set<string> => {
|
||||
const selectedMasks = selectedIds
|
||||
.map((id) => allMasks.find((mask) => mask.id === id))
|
||||
.filter((mask): mask is Mask => Boolean(mask));
|
||||
const selectedTokens = new Set<string>();
|
||||
selectedMasks.forEach((mask) => {
|
||||
propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token));
|
||||
});
|
||||
if (selectedTokens.size === 0) return new Set(selectedIds);
|
||||
return new Set(
|
||||
allMasks
|
||||
.filter((mask) => {
|
||||
const candidateTokens = propagationLineageTokens(mask);
|
||||
return [...candidateTokens].some((token) => selectedTokens.has(token));
|
||||
})
|
||||
.map((mask) => mask.id),
|
||||
);
|
||||
};
|
||||
|
||||
const expandedPropagationDeletionMaskIds = (selectedIds: string[], allMasks: Mask[]): Set<string> => {
|
||||
const selectedIdSet = new Set(selectedIds);
|
||||
const chainIds = findPropagationChainMaskIds(selectedIds, allMasks);
|
||||
return new Set(
|
||||
allMasks
|
||||
.filter((mask) => selectedIdSet.has(mask.id) || (chainIds.has(mask.id) && isPropagatedMask(mask)))
|
||||
.map((mask) => mask.id),
|
||||
);
|
||||
};
|
||||
|
||||
const persistentMaskMetadata = (metadata?: Record<string, unknown>) => {
|
||||
if (!metadata) return {};
|
||||
const {
|
||||
@@ -732,25 +787,30 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const handleClearCurrentFrameMasks = useCallback(async () => {
|
||||
if (!currentFrame) return;
|
||||
const frameMasks = masks.filter((mask) => mask.frameId === currentFrame.id);
|
||||
const annotationIds = frameMasks
|
||||
.map((mask) => mask.annotationId)
|
||||
.filter((annotationId): annotationId is string => Boolean(annotationId));
|
||||
const maskIdsToClear = expandedPropagationDeletionMaskIds(frameMasks.map((mask) => mask.id), masks);
|
||||
const masksToClear = masks.filter((mask) => maskIdsToClear.has(mask.id));
|
||||
const annotationIds = Array.from(new Set(
|
||||
masksToClear
|
||||
.map((mask) => mask.annotationId)
|
||||
.filter((annotationId): annotationId is string => Boolean(annotationId)),
|
||||
));
|
||||
|
||||
setIsSaving(true);
|
||||
setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注...' : '正在清空本帧遮罩...');
|
||||
setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注和关联传播帧...' : '正在清空本帧遮罩和关联传播帧...');
|
||||
try {
|
||||
await deleteAnnotationsIfExist(annotationIds);
|
||||
setMasks(masks.filter((mask) => mask.frameId !== currentFrame.id));
|
||||
setMasks(masks.filter((mask) => !maskIdsToClear.has(mask.id)));
|
||||
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !maskIdsToClear.has(id)));
|
||||
setStatusMessage(annotationIds.length > 0
|
||||
? `已删除 ${annotationIds.length} 个后端标注`
|
||||
: '已清空本帧未保存遮罩');
|
||||
? `已删除 ${annotationIds.length} 个后端标注,已同步清空关联传播帧`
|
||||
: '已清空本帧未保存遮罩和关联传播帧');
|
||||
} catch (err) {
|
||||
console.error('Delete annotations failed:', err);
|
||||
setStatusMessage('删除失败,请检查后端服务');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [currentFrame, masks, setMasks]);
|
||||
}, [currentFrame, masks, setMasks, setSelectedMaskIds]);
|
||||
|
||||
const executeClearFrameRange = useCallback(async (request: ClearRangeConfirmState) => {
|
||||
const frameIdsToClear = new Set(request.frameIdsToClear);
|
||||
@@ -1844,6 +1904,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setActiveTool={setActiveTool}
|
||||
onTriggerAI={onNavigateToAI}
|
||||
onImportGtMask={() => gtMaskInputRef.current?.click()}
|
||||
onClearMasks={handleClearCurrentFrameMasks}
|
||||
canImportGtMask={Boolean(currentProject?.id && currentFrame?.id) && !isSaving && !isExporting && !isPropagating}
|
||||
isImportingGtMask={isImportingGt}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user