支持跨语义传播链区域合并
- 区域合并同步时允许 A 语义传播链并入 B 语义传播链 - 传播帧同时存在 B/A 对应结果时,将 A 合并进 B 并删除 A 对应标注 - 传播帧缺少 B 对应结果但存在 A 对应结果时,将 A 结果转换为 B 语义并标记为 dirty - 保持稳定 lineage 匹配优先,旧传播结果继续用来源帧、语义/颜色和空间最近候选兜底 - 补充 CanvasArea 回归测试覆盖跨语义 B 吸收 A 以及缺少 B 对应结果场景 - 更新项目指南和设计冻结文档
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user