修复旧传播帧区域合并同步

- 为缺少稳定 lineage 的旧传播结果增加参考帧到传播帧的实例匹配
- 区域合并/去除同步时按来源帧、语义/颜色和空间最近候选定位对应传播 mask
- 保持稳定 source_annotation/source_mask/seed 匹配优先,避免同类不同实例误合并
- 补充 CanvasArea 回归测试覆盖旧传播帧合并时删除对应次级 mask
- 更新项目指南和设计冻结文档
This commit is contained in:
2026-05-04 02:52:38 +08:00
parent 2f55ecfe6a
commit 46b055eba8
4 changed files with 122 additions and 3 deletions

View File

@@ -1088,6 +1088,77 @@ describe('CanvasArea', () => {
}));
});
it('merges legacy propagated masks by nearest same-label result when stable lineage is missing', 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 70 70 L 120 70 L 120 120 L 70 120 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[70, 70, 120, 70, 120, 120, 70, 120]],
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_propagation', propagated_from_frame_id: 'frame-1' },
},
{
id: 'annotation-20',
annotationId: '20',
frameId: 'frame-2',
pathData: 'M 72 72 L 122 72 L 122 122 L 72 122 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[72, 72, 122, 72, 122, 122, 72, 122]],
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', propagated_from_frame_id: 'frame-1' },
},
],
});
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']);
expect(masks.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
saveStatus: 'dirty',
saved: false,
metadata: expect.objectContaining({ source: 'sam2_propagation' }),
}));
});
it('can hand propagated boolean operations to the workspace frame range selector', () => {
const onRequestBooleanFrameRange = vi.fn();
useStore.setState({

View File

@@ -118,6 +118,28 @@ function propagationLineageTokens(mask: Mask): Set<string> {
return tokens;
}
function maskSemanticKey(mask: Mask): string {
return [
mask.classMaskId ?? '',
mask.classId ?? '',
mask.className ?? '',
mask.label ?? '',
mask.color ?? '',
].join(':').toLowerCase();
}
function maskBboxCenter(mask: Mask): CanvasPoint | null {
const bbox = mask.bbox || segmentationBbox(mask.segmentation);
if (!bbox) return null;
return { x: bbox[0] + bbox[2] / 2, y: bbox[1] + bbox[3] / 2 };
}
function propagatedFromFrame(mask: Mask, sourceFrameId: string): boolean {
const metadata = mask.metadata || {};
return metadata.propagated_from_frame_id !== undefined
&& String(metadata.propagated_from_frame_id) === String(sourceFrameId);
}
function findLinkedMasksOnFrame(selectedIds: string[], allMasks: Mask[], targetFrameId?: string): string[] {
if (!targetFrameId || selectedIds.length === 0) return [];
const selectedMasks = selectedIds
@@ -131,7 +153,7 @@ function findLinkedMasksOnFrame(selectedIds: string[], allMasks: Mask[], targetF
propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token));
});
return allMasks
const linkedIds = allMasks
.filter((mask) => String(mask.frameId) === String(targetFrameId))
.filter((mask) => {
const candidateHasPropagation = isPropagationMask(mask);
@@ -140,6 +162,32 @@ function findLinkedMasksOnFrame(selectedIds: string[], allMasks: Mask[], targetF
return [...candidateTokens].some((token) => selectedTokens.has(token));
})
.map((mask) => mask.id);
const linkedIdSet = new Set(linkedIds);
selectedMasks.forEach((selectedMask) => {
if (isPropagationMask(selectedMask)) return;
const selectedCenter = maskBboxCenter(selectedMask);
const selectedSemanticKey = maskSemanticKey(selectedMask);
const candidates = allMasks
.filter((mask) => String(mask.frameId) === String(targetFrameId))
.filter((mask) => !linkedIdSet.has(mask.id))
.filter((mask) => isPropagationMask(mask))
.filter((mask) => propagatedFromFrame(mask, String(selectedMask.frameId)))
.filter((mask) => maskSemanticKey(mask) === selectedSemanticKey);
if (candidates.length === 0) return;
const best = candidates.reduce<{ mask: Mask; distance: number } | null>((currentBest, candidate) => {
const candidateCenter = maskBboxCenter(candidate);
const distance = selectedCenter && candidateCenter ? pointDistance(selectedCenter, candidateCenter) : 0;
if (!currentBest || distance < currentBest.distance) return { mask: candidate, distance };
return currentBest;
}, null);
if (best) {
linkedIds.push(best.mask.id);
linkedIdSet.add(best.mask.id);
}
});
return linkedIds;
}
function findPropagationChainMaskIds(selectedIds: string[], allMasks: Mask[]): Set<string> {