收紧传播帧布尔同步实例匹配
- 区域合并/去除同步传播帧时改用严格实例匹配,优先可靠 source_annotation_id/source_mask_id lineage。 - 旧传播结果缺少可靠 lineage 时,每个已选 mask 只匹配空间最近的一个同语义传播实例,避免同类别其它 mask 被一起合并或扣除。 - 保留点选高亮的宽松 legacy 分组,避免破坏同一传播 mask 不连通片段联动高亮体验。 - 新增 CanvasArea 回归测试,覆盖同类别多个 legacy 传播实例只合并目标实例。 - 更新 AGENTS、设计冻结和测试计划文档,明确布尔同步和高亮匹配策略的差异。
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user