支持跨语义传播链区域合并

- 区域合并同步时允许 A 语义传播链并入 B 语义传播链
- 传播帧同时存在 B/A 对应结果时,将 A 合并进 B 并删除 A 对应标注
- 传播帧缺少 B 对应结果但存在 A 对应结果时,将 A 结果转换为 B 语义并标记为 dirty
- 保持稳定 lineage 匹配优先,旧传播结果继续用来源帧、语义/颜色和空间最近候选兜底
- 补充 CanvasArea 回归测试覆盖跨语义 B 吸收 A 以及缺少 B 对应结果场景
- 更新项目指南和设计冻结文档
This commit is contained in:
2026-05-04 03:08:41 +08:00
parent 46b055eba8
commit 94abad2794
4 changed files with 184 additions and 13 deletions

View File

@@ -1159,6 +1159,161 @@ describe('CanvasArea', () => {
}));
});
it('merges propagated A masks into propagated B masks when merging A into B on the reference frame', 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: 'B',
color: '#2563eb',
className: 'B',
classMaskId: 2,
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: '#dc2626',
className: 'A',
classMaskId: 1,
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: 'B',
color: '#2563eb',
className: 'B',
classMaskId: 2,
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: '#dc2626',
className: 'A',
classMaskId: 1,
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: '合并选中' }));
expect(screen.getByText('选择操作范围')).toBeInTheDocument();
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']);
const propagatedB = masks.find((mask) => mask.id === 'annotation-10');
expect(propagatedB).toEqual(expect.objectContaining({
label: 'B',
color: '#2563eb',
className: 'B',
classMaskId: 2,
saveStatus: 'dirty',
saved: false,
}));
expect(propagatedB?.bbox).toEqual([12, 12, 90, 90]);
expect(propagatedB?.area).toBe(4900);
expect(propagatedB?.segmentation?.flat()).toEqual(expect.arrayContaining([12, 12, 102, 102]));
});
it('turns propagated A masks into B masks when merging A into B and no propagated B exists on that frame', 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: 'B',
color: '#2563eb',
className: 'B',
classMaskId: 2,
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: '#dc2626',
className: 'A',
classMaskId: 1,
segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]],
saved: true,
saveStatus: 'saved',
},
{
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: '#dc2626',
className: 'A',
classMaskId: 1,
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: '合并选中' }));
expect(screen.getByText('选择操作范围')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '处理所有传播帧' }));
const masks = useStore.getState().masks;
expect(onDeleteMaskAnnotations).not.toHaveBeenCalledWith(expect.arrayContaining(['20']));
expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-20']);
expect(masks.find((mask) => mask.id === 'annotation-20')).toEqual(expect.objectContaining({
label: 'B',
color: '#2563eb',
className: 'B',
classMaskId: 2,
saveStatus: 'dirty',
saved: false,
metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2 }),
}));
});
it('can hand propagated boolean operations to the workspace frame range selector', () => {
const onRequestBooleanFrameRange = vi.fn();
useStore.setState({

View File

@@ -1516,14 +1516,13 @@ export function CanvasArea({
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);
if (hasSecondary && (hasPrimary || effectiveTool === 'area_merge')) targetFrameIds.add(targetFrameId);
});
return { currentFrameId, targetFrameIds };
}, [booleanSelectedMasks, frame, masks]);
}, [booleanSelectedMasks, effectiveTool, frame, masks]);
const runBooleanOperation = useCallback(async (targetFrameIds: Set<string>) => {
if (!frame || booleanSelectedMasks.length < 2) return;
@@ -1534,13 +1533,9 @@ export function CanvasArea({
const deletedMaskIds = new Set<string>();
const applyOperationForFrame = (targetFrameId: string) => {
const primaryTargetId = targetFrameId === currentFrameId
const linkedPrimaryTargetId = 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) => (
@@ -1548,14 +1543,33 @@ export function CanvasArea({
? [secondary.id]
: findLinkedMasksOnFrame([secondary.id], masks, targetFrameId)
)),
)).filter((maskId) => maskId !== primaryTarget.id && !deletedMaskIds.has(maskId));
)).filter((maskId) => maskId !== linkedPrimaryTargetId && !deletedMaskIds.has(maskId));
const secondaryTargets = secondaryTargetIds
.map((maskId) => masks.find((mask) => mask.id === maskId))
.filter((mask): mask is Mask => Boolean(mask));
const fallbackPrimaryTarget = effectiveTool === 'area_merge' ? secondaryTargets[0] : undefined;
const rawPrimaryTarget = masks.find((mask) => mask.id === linkedPrimaryTargetId) || fallbackPrimaryTarget;
if (!rawPrimaryTarget || deletedMaskIds.has(rawPrimaryTarget.id)) return;
const usingSecondaryAsPrimary = !linkedPrimaryTargetId && rawPrimaryTarget.id === fallbackPrimaryTarget?.id;
const primaryTarget = usingSecondaryAsPrimary
? {
...rawPrimaryTarget,
templateId: primary.templateId ?? rawPrimaryTarget.templateId,
classId: primary.classId,
className: primary.className,
classZIndex: primary.classZIndex,
classMaskId: primary.classMaskId,
label: primary.label,
color: primary.color,
}
: rawPrimaryTarget;
const primaryGeometry = maskToMultiPolygon(primaryTarget);
if (!primaryGeometry) return;
const clipGeometries = secondaryTargets
.filter((mask) => mask.id !== primaryTarget.id)
.map(maskToMultiPolygon)
.filter((geometry): geometry is MultiPolygon => Boolean(geometry));
if (clipGeometries.length === 0) return;
if (clipGeometries.length === 0 && !usingSecondaryAsPrimary) return;
const resultGeometry = effectiveTool === 'area_merge'
? polygonClipping.union(primaryGeometry, ...clipGeometries)
@@ -1573,7 +1587,9 @@ export function CanvasArea({
}
if (effectiveTool === 'area_merge') {
secondaryTargets.forEach((mask) => deletedMaskIds.add(mask.id));
secondaryTargets.forEach((mask) => {
if (mask.id !== primaryTarget.id) deletedMaskIds.add(mask.id);
});
}
};