收紧传播帧布尔同步实例匹配

- 区域合并/去除同步传播帧时改用严格实例匹配,优先可靠 source_annotation_id/source_mask_id lineage。

- 旧传播结果缺少可靠 lineage 时,每个已选 mask 只匹配空间最近的一个同语义传播实例,避免同类别其它 mask 被一起合并或扣除。

- 保留点选高亮的宽松 legacy 分组,避免破坏同一传播 mask 不连通片段联动高亮体验。

- 新增 CanvasArea 回归测试,覆盖同类别多个 legacy 传播实例只合并目标实例。

- 更新 AGENTS、设计冻结和测试计划文档,明确布尔同步和高亮匹配策略的差异。
This commit is contained in:
2026-05-04 05:35:04 +08:00
parent ee27f29495
commit 1ff757e2fa
5 changed files with 246 additions and 33 deletions

View File

@@ -1220,6 +1220,113 @@ describe('CanvasArea', () => {
}));
});
it('does not merge every same-class legacy propagation instance on later frames', async () => {
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
const frame2 = { ...frame, id: 'frame-2', index: 1 };
const legacyMetadata = {
source: 'sam2_propagation',
propagated_from_frame_id: 'frame-1',
propagation_seed_key: '{"label":"A","color":"#06b6d4"}',
};
useStore.setState({
masks: [
{
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: legacyMetadata,
},
{
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: legacyMetadata,
},
{
id: 'annotation-30',
annotationId: '30',
frameId: 'frame-2',
pathData: 'M 180 180 L 230 180 L 230 230 L 180 230 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[180, 180, 230, 180, 230, 230, 180, 230]],
saved: true,
saveStatus: 'saved',
metadata: legacyMetadata,
},
{
id: 'annotation-110',
annotationId: '110',
frameId: 'frame-3',
pathData: 'M 14 14 L 64 14 L 64 64 L 14 64 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[14, 14, 64, 14, 64, 64, 14, 64]],
saved: true,
saveStatus: 'saved',
metadata: legacyMetadata,
},
{
id: 'annotation-120',
annotationId: '120',
frameId: 'frame-3',
pathData: 'M 74 74 L 124 74 L 124 124 L 74 124 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[74, 74, 124, 74, 124, 124, 74, 124]],
saved: true,
saveStatus: 'saved',
metadata: legacyMetadata,
},
{
id: 'annotation-130',
annotationId: '130',
frameId: 'frame-3',
pathData: 'M 182 182 L 232 182 L 232 232 L 182 232 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[182, 182, 232, 182, 232, 232, 182, 232]],
saved: true,
saveStatus: 'saved',
metadata: legacyMetadata,
},
],
});
render(<CanvasArea activeTool="area_merge" frame={frame2} 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(['20', '120'])));
const masks = useStore.getState().masks;
expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-10', 'annotation-110', 'annotation-130', 'annotation-30']);
expect(onDeleteMaskAnnotations).not.toHaveBeenCalledWith(expect.arrayContaining(['30', '130']));
expect(masks.find((mask) => mask.id === 'annotation-130')).toEqual(expect.objectContaining({
saveStatus: 'saved',
}));
expect(masks.find((mask) => mask.id === 'annotation-110')).toEqual(expect.objectContaining({
saveStatus: 'dirty',
saved: false,
bbox: [14, 14, 110, 110],
}));
});
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({

View File

@@ -66,6 +66,27 @@ function propagationSourceMaskTokens(value: unknown): string[] {
return tokens;
}
function reliablePropagationLineageTokens(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') {
const seedKey = metadata.propagation_seed_key.trim();
if (/^(annotation|mask):/.test(seedKey)) {
tokens.add(`seed-key:${seedKey}`);
tokens.add(seedKey);
}
}
return tokens;
}
function isPropagationMask(mask: Mask): boolean {
const metadata = mask.metadata || {};
const source = typeof metadata.source === 'string' ? metadata.source.toLowerCase() : '';
@@ -141,40 +162,125 @@ function propagatedFromFrame(mask: Mask, sourceFrameId: string): boolean {
&& String(metadata.propagated_from_frame_id) === String(sourceFrameId);
}
function findLinkedMasksOnFrame(selectedIds: string[], allMasks: Mask[], targetFrameId?: string): string[] {
function propagationFallbackCompatible(selectedMask: Mask, candidate: Mask): boolean {
if (!isPropagationMask(candidate) || maskSemanticKey(candidate) !== maskSemanticKey(selectedMask)) return false;
const selectedMetadata = selectedMask.metadata || {};
const candidateMetadata = candidate.metadata || {};
if (!isPropagationMask(selectedMask)) {
return propagatedFromFrame(candidate, String(selectedMask.frameId));
}
const selectedOriginFrameId = selectedMetadata.propagated_from_frame_id;
const candidateOriginFrameId = candidateMetadata.propagated_from_frame_id;
if (
selectedOriginFrameId !== undefined
&& candidateOriginFrameId !== undefined
&& String(selectedOriginFrameId) !== String(candidateOriginFrameId)
) {
return false;
}
const selectedOriginFrameIndex = selectedMetadata.propagated_from_frame_index;
const candidateOriginFrameIndex = candidateMetadata.propagated_from_frame_index;
if (
selectedOriginFrameIndex !== undefined
&& candidateOriginFrameIndex !== undefined
&& String(selectedOriginFrameIndex) !== String(candidateOriginFrameIndex)
) {
return false;
}
const selectedSource = typeof selectedMetadata.source === 'string' ? selectedMetadata.source : '';
const candidateSource = typeof candidateMetadata.source === 'string' ? candidateMetadata.source : '';
if (selectedSource && candidateSource && selectedSource !== candidateSource) return false;
const selectedDirection = selectedMetadata.propagation_direction;
const candidateDirection = candidateMetadata.propagation_direction;
if (
selectedDirection !== undefined
&& candidateDirection !== undefined
&& String(selectedDirection) !== String(candidateDirection)
) {
return false;
}
return true;
}
function findLinkedMasksOnFrame(
selectedIds: string[],
allMasks: Mask[],
targetFrameId?: string,
options: { strictInstanceMatch?: boolean } = {},
): string[] {
if (!targetFrameId || selectedIds.length === 0) return [];
const selectedMasks = selectedIds
.map((id) => allMasks.find((mask) => mask.id === id))
.filter((mask): mask is Mask => Boolean(mask));
if (selectedMasks.length === 0) return [];
const selectedTokens = new Set<string>();
const selectedHasPropagation = selectedMasks.some(isPropagationMask);
selectedMasks.forEach((mask) => {
propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token));
});
if (!options.strictInstanceMatch) {
const selectedTokens = new Set<string>();
const selectedHasPropagation = selectedMasks.some(isPropagationMask);
selectedMasks.forEach((mask) => {
propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token));
});
const linkedIds = allMasks
.filter((mask) => String(mask.frameId) === String(targetFrameId))
.filter((mask) => {
const candidateHasPropagation = isPropagationMask(mask);
if (!selectedHasPropagation && !candidateHasPropagation) return false;
const candidateTokens = propagationLineageTokens(mask);
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
const linkedIds = allMasks
.filter((mask) => String(mask.frameId) === String(targetFrameId))
.filter((mask) => {
const candidateHasPropagation = isPropagationMask(mask);
if (!selectedHasPropagation && !candidateHasPropagation) return false;
const candidateTokens = propagationLineageTokens(mask);
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;
}
const targetMasks = allMasks.filter((mask) => String(mask.frameId) === String(targetFrameId));
const linkedIds: string[] = [];
const linkedIdSet = new Set<string>();
selectedMasks.forEach((selectedMask) => {
const selectedTokens = reliablePropagationLineageTokens(selectedMask);
const exactMatches = targetMasks.filter((mask) => {
if (!isPropagationMask(selectedMask) && !isPropagationMask(mask)) return false;
const candidateTokens = reliablePropagationLineageTokens(mask);
return [...candidateTokens].some((token) => selectedTokens.has(token));
});
exactMatches.forEach((mask) => {
if (!linkedIdSet.has(mask.id)) {
linkedIds.push(mask.id);
linkedIdSet.add(mask.id);
}
});
if (exactMatches.length > 0) return;
const selectedCenter = maskBboxCenter(selectedMask);
const candidates = targetMasks
.filter((mask) => !linkedIdSet.has(mask.id))
.filter((mask) => isPropagationMask(mask))
.filter((mask) => propagatedFromFrame(mask, String(selectedMask.frameId)))
.filter((mask) => maskSemanticKey(mask) === selectedSemanticKey);
.filter((mask) => propagationFallbackCompatible(selectedMask, mask));
if (candidates.length === 0) return;
const best = candidates.reduce<{ mask: Mask; distance: number } | null>((currentBest, candidate) => {
const candidateCenter = maskBboxCenter(candidate);
@@ -1622,9 +1728,9 @@ export function CanvasArea({
masks.forEach((mask) => {
const targetFrameId = String(mask.frameId);
if (targetFrameId === currentFrameId) return;
const hasPrimary = findLinkedMasksOnFrame([primary.id], masks, targetFrameId).length > 0;
const hasPrimary = findLinkedMasksOnFrame([primary.id], masks, targetFrameId, { strictInstanceMatch: true }).length > 0;
const hasSecondary = secondaryMasks.some((secondary) => (
findLinkedMasksOnFrame([secondary.id], masks, targetFrameId).length > 0
findLinkedMasksOnFrame([secondary.id], masks, targetFrameId, { strictInstanceMatch: true }).length > 0
));
if (hasSecondary && (hasPrimary || effectiveTool === 'area_merge')) targetFrameIds.add(targetFrameId);
});
@@ -1642,13 +1748,13 @@ export function CanvasArea({
const applyOperationForFrame = (targetFrameId: string) => {
const linkedPrimaryTargetId = targetFrameId === currentFrameId
? primary.id
: findLinkedMasksOnFrame([primary.id], masks, targetFrameId)[0];
: findLinkedMasksOnFrame([primary.id], masks, targetFrameId, { strictInstanceMatch: true })[0];
const secondaryTargetIds = Array.from(new Set(
secondaryMasks.flatMap((secondary) => (
targetFrameId === currentFrameId
? [secondary.id]
: findLinkedMasksOnFrame([secondary.id], masks, targetFrameId)
: findLinkedMasksOnFrame([secondary.id], masks, targetFrameId, { strictInstanceMatch: true })
)),
)).filter((maskId) => maskId !== linkedPrimaryTargetId && !deletedMaskIds.has(maskId));
const secondaryTargets = secondaryTargetIds