fix: 避免自动传播重复叠加同源 mask

Bugfix:自动传播 worker 改为在本次目标帧段内按 seed 来源、方向、权重和签名查找旧传播结果;未修改且目标帧已覆盖时直接跳过,不再重复跑 SAM 造成 mask 堆叠。

Bugfix:同一 seed 被编辑、目标帧段只部分覆盖或切换 SAM 2.1 权重时,worker 会先删除本次目标帧段内同源旧自动传播标注,再重新传播。

Bugfix:未编辑的自动传播结果再次作为参考 seed 时会继承原始 propagation_seed_signature;编辑后的传播结果只保留 source_annotation_id/source_mask_id lineage,不继承旧签名,从而触发重传路径。

Bugfix:后端传播签名增加 canonical rounding,减少浮点精度细微变化导致未编辑 mask 被误判为已修改。

功能调整:清空片段遮罩改成与自动传播一致的时间轴范围选择流程,首次点击进入选区,拖拽选择起止帧后点击确认清空才执行。

接口契约:PropagationSeed 增加 propagation_seed_signature 字段,用于前端把未编辑传播结果绑定回原始 seed 传播链。

测试:补充前端 VideoWorkspace 范围清空、传播 lineage 传递测试;补充后端未编辑传播 seed 跳过重复传播、旧结果清理与换权重重传测试。

文档:同步更新 doc/03、doc/04、doc/07、doc/08、doc/09,明确 A/B 传播去重规则、清空片段范围选择和新增 seed signature 契约。
This commit is contained in:
2026-05-02 07:11:03 +08:00
parent 4899c8a08a
commit f365539ff2
10 changed files with 244 additions and 45 deletions

View File

@@ -316,6 +316,11 @@ describe('VideoWorkspace', () => {
saveStatus: 'dirty',
segmentation: [[0, 0, 10, 0, 10, 10]],
bbox: [0, 0, 10, 10],
metadata: {
source_annotation_id: 7,
source_mask_id: 'annotation-7',
propagation_seed_signature: 'old-signature',
},
}],
});
});
@@ -324,7 +329,12 @@ describe('VideoWorkspace', () => {
await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledWith('99', {
template_id: 2,
mask_data: { polygons: [], label: '胆囊' },
mask_data: {
polygons: [],
label: '胆囊',
source_annotation_id: 7,
source_mask_id: 'annotation-7',
},
points: undefined,
bbox: undefined,
}));
@@ -390,9 +400,28 @@ describe('VideoWorkspace', () => {
});
});
fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '1' } });
fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '2' } });
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
expect(screen.getByText('请在播放进度条或视频处理进度条上点击/拖拽选择清空起止帧,再点击“确认清空”')).toBeInTheDocument();
const processingBar = screen.getByLabelText('视频处理进度条');
vi.spyOn(processingBar, 'getBoundingClientRect').mockReturnValue({
left: 0,
right: 100,
top: 0,
bottom: 10,
width: 100,
height: 10,
x: 0,
y: 0,
toJSON: () => ({}),
});
fireEvent.pointerDown(processingBar, { clientX: 0, pointerId: 1 });
fireEvent.pointerMove(processingBar, { clientX: 50, pointerId: 1 });
fireEvent.pointerUp(processingBar, { clientX: 50, pointerId: 1 });
expect(screen.getByLabelText('传播起始帧')).toHaveValue(1);
expect(screen.getByLabelText('传播结束帧')).toHaveValue(2);
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('100');
@@ -539,6 +568,12 @@ describe('VideoWorkspace', () => {
color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 5,
source_mask_id: 'annotation-5',
propagation_seed_signature: 'seed-signature-5',
},
}],
});
});
@@ -604,6 +639,12 @@ describe('VideoWorkspace', () => {
color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 5,
source_mask_id: 'annotation-5',
propagation_seed_signature: 'seed-signature-5',
},
}],
});
});
@@ -618,6 +659,13 @@ describe('VideoWorkspace', () => {
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith(expect.objectContaining({
model: 'sam2.1_hiera_small',
steps: [expect.objectContaining({
seed: expect.objectContaining({
source_annotation_id: 5,
source_mask_id: 'annotation-5',
propagation_seed_signature: 'seed-signature-5',
}),
})],
})));
await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask处理 3 帧次,删除旧区域 0 个,保存 2 个区域')).toBeInTheDocument());
});

View File

@@ -40,6 +40,7 @@ type PropagationHistorySegment = {
colorIndex: number;
label: string;
};
type RangeSelectionMode = 'propagation' | 'clear' | null;
const PROPAGATION_POLL_INTERVAL_MS = 250;
const STATUS_MESSAGE_TTL_MS = 3600;
@@ -75,6 +76,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const [propagationStartFrame, setPropagationStartFrame] = useState(1);
const [propagationEndFrame, setPropagationEndFrame] = useState(1);
const [isPropagationRangeSelecting, setIsPropagationRangeSelecting] = useState(false);
const [rangeSelectionMode, setRangeSelectionMode] = useState<RangeSelectionMode>(null);
const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
const [propagationProgress, setPropagationProgress] = useState<PropagationProgress>(null);
const [propagationTaskId, setPropagationTaskId] = useState<number | null>(null);
@@ -226,6 +228,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setPropagationStartFrame(currentFrameNumber);
setPropagationEndFrame(Math.min(totalFrames, currentFrameNumber + 29));
setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false);
}, [currentFrameNumber, totalFrames]);
@@ -255,9 +258,13 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const frame = frameById.get(mask.frameId);
const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null;
if (!payload || !mask.annotationId) return null;
const propagationLineage = {
...(mask.metadata?.source_annotation_id !== undefined ? { source_annotation_id: mask.metadata.source_annotation_id } : {}),
...(mask.metadata?.source_mask_id !== undefined ? { source_mask_id: mask.metadata.source_mask_id } : {}),
};
const updatePayload = {
template_id: payload.template_id,
mask_data: payload.mask_data,
mask_data: { ...payload.mask_data, ...propagationLineage },
points: payload.points,
bbox: payload.bbox,
};
@@ -316,6 +323,12 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
}, [currentFrame, masks, setMasks]);
const handleClearFrameRangeMasks = useCallback(async () => {
if (rangeSelectionMode !== 'clear') {
setIsPropagationRangeSelecting(true);
setRangeSelectionMode('clear');
setStatusMessage('请在播放进度条或视频处理进度条上点击/拖拽选择清空起止帧,再点击“确认清空”');
return;
}
if (frames.length === 0) return;
const clampRangeFrameNumber = (value: number) => {
if (totalFrames <= 0) return 1;
@@ -354,13 +367,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setMasks(latestMasks.filter((mask) => !frameIdsToClear.has(String(mask.frameId))));
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !clearedMaskIds.has(id)));
setStatusMessage(`已清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的 ${rangeMasks.length} 个遮罩,其中后端标注 ${annotationIds.length}`);
setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false);
} catch (err) {
console.error('Delete range annotations failed:', err);
setStatusMessage('批量清空失败,请检查后端服务');
} finally {
setIsSaving(false);
}
}, [frames, masks, propagationEndFrame, propagationStartFrame, setMasks, setSelectedMaskIds, totalFrames]);
}, [frames, masks, propagationEndFrame, propagationStartFrame, rangeSelectionMode, setMasks, setSelectedMaskIds, totalFrames]);
const handleDeleteMaskAnnotations = useCallback(async (annotationIds: string[]) => {
if (annotationIds.length === 0) return;
@@ -463,9 +479,18 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
if (!seedPayload?.mask_data?.polygons?.length && !seedPayload?.bbox) {
return null;
}
const sourceAnnotationId = seedMask.annotationId && /^\d+$/.test(seedMask.annotationId)
const metadataSourceAnnotationId = Number(seedMask.metadata?.source_annotation_id);
const sourceAnnotationId = Number.isFinite(metadataSourceAnnotationId) && metadataSourceAnnotationId > 0
? metadataSourceAnnotationId
: seedMask.annotationId && /^\d+$/.test(seedMask.annotationId)
? Number(seedMask.annotationId)
: undefined;
const metadataSourceMaskId = typeof seedMask.metadata?.source_mask_id === 'string'
? seedMask.metadata.source_mask_id
: undefined;
const inheritedSeedSignature = typeof seedMask.metadata?.propagation_seed_signature === 'string'
? seedMask.metadata.propagation_seed_signature
: undefined;
return {
polygons: seedPayload.mask_data?.polygons,
bbox: seedPayload.bbox,
@@ -474,8 +499,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
color: seedPayload.mask_data?.color,
class_metadata: seedPayload.mask_data?.class,
template_id: seedPayload.template_id,
source_mask_id: seedMask.id,
source_mask_id: metadataSourceMaskId || seedMask.id,
source_annotation_id: sourceAnnotationId,
propagation_seed_signature: inheritedSeedSignature,
};
}, [activeTemplateId, currentFrame, currentProject?.id]);
@@ -485,8 +511,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setPropagationStartFrame(nextStart);
setPropagationEndFrame(nextEnd);
setHasExplicitPropagationRange(true);
setStatusMessage(`已选择自动传播范围:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)}`);
}, [clampFrameNumber]);
const actionLabel = rangeSelectionMode === 'clear' ? '清空范围' : '自动传播范围';
setStatusMessage(`已选择${actionLabel}:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)}`);
}, [clampFrameNumber, rangeSelectionMode]);
const handlePropagationStartInput = (value: number) => {
setPropagationStartFrame(clampFrameNumber(value || 1));
@@ -649,18 +676,22 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const handleAutoPropagate = async () => {
if (!hasExplicitPropagationRange && !isPropagationRangeSelecting) {
setIsPropagationRangeSelecting(true);
setRangeSelectionMode('propagation');
setStatusMessage('请在播放进度条或视频处理进度条上点击/拖拽选择传播起止帧,再点击“开始传播”');
return;
}
setRangeSelectionMode('propagation');
await runAutoPropagate();
};
const handleCancelPropagationRangeSelection = () => {
const previousMode = rangeSelectionMode;
setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false);
setPropagationStartFrame(currentFrameNumber || 1);
setPropagationEndFrame(Math.min(Math.max(totalFrames, 1), (currentFrameNumber || 1) + 29));
setStatusMessage('已取消自动传播范围选择');
setStatusMessage(previousMode === 'clear' ? '已取消清空片段范围选择' : '已取消自动传播范围选择');
};
const handleCancelPropagation = async () => {
@@ -800,14 +831,14 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
title="按当前起止帧清空这一段视频内的全部遮罩"
className="px-3 py-1.5 bg-red-500/10 hover:bg-red-500/20 border border-red-500/25 rounded-md text-xs transition-colors text-red-200 disabled:opacity-40 disabled:cursor-not-allowed"
>
{rangeSelectionMode === 'clear' ? '确认清空' : '清空片段遮罩'}
</button>
<button
onClick={handleAutoPropagate}
disabled={!currentProject?.id || !currentFrame?.id || isSaving || isExporting || isImportingGt || isPropagating}
className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{isPropagating ? '传播中...' : isPropagationRangeSelecting ? '开始传播' : '自动传播'}
{isPropagating ? '传播中...' : rangeSelectionMode === 'propagation' ? '开始传播' : '自动传播'}
</button>
{isPropagationRangeSelecting && (
<button