修复旧传播帧区域合并同步
- 为缺少稳定 lineage 的旧传播结果增加参考帧到传播帧的实例匹配 - 区域合并/去除同步时按来源帧、语义/颜色和空间最近候选定位对应传播 mask - 保持稳定 source_annotation/source_mask/seed 匹配优先,避免同类不同实例误合并 - 补充 CanvasArea 回归测试覆盖旧传播帧合并时删除对应次级 mask - 更新项目指南和设计冻结文档
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user