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:
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user