引入实例ID驱动传播链匹配

- 前端保存标注写入并保留 instance_id,AI 自动推理 seed 携带 source_instance_id,避免同类多 mask 只按语义混在一起。

- 后端传播任务优先用 source_instance_id/instance_id 做幂等、替换和写入前清理,并保留 source_annotation_id/source_mask_id/legacy 兼容路径。

- 前端传播链匹配、删除/分类同步和布尔合并/去重加入实例 token,保持旧 lineage 和空间最近 legacy fallback。

- 补充前后端回归测试,覆盖同类别多实例传播、重传、布尔同步、断开多区域和保存/回显 metadata。

- 更新 AGENTS 与 doc 事实文档,明确 maskid 仍只用于语义分类、GT_label 和导出,不参与实例追踪。
This commit is contained in:
2026-05-04 05:54:23 +08:00
parent 1ff757e2fa
commit 5d73eacefe
15 changed files with 325 additions and 19 deletions

View File

@@ -1149,6 +1149,110 @@ describe('CanvasArea', () => {
}));
});
it('uses propagation instance ids to merge only the intended same-class propagated masks', 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',
metadata: { instance_id: 'same-class-primary' },
},
{
id: 'annotation-2',
annotationId: '2',
frameId: 'frame-1',
pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]],
saved: true,
saveStatus: 'saved',
metadata: { instance_id: 'same-class-secondary' },
},
{
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.1_hiera_tiny_propagation',
source_annotation_id: 101,
source_instance_id: 'same-class-primary',
instance_id: 'same-class-primary',
},
},
{
id: 'annotation-20',
annotationId: '20',
frameId: 'frame-2',
pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]],
saved: true,
saveStatus: 'saved',
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 202,
source_instance_id: 'same-class-secondary',
instance_id: 'same-class-secondary',
},
},
{
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: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 303,
source_instance_id: 'same-class-unselected',
instance_id: 'same-class-unselected',
},
},
],
});
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'])));
expect(onDeleteMaskAnnotations).not.toHaveBeenCalledWith(expect.arrayContaining(['30']));
const masks = useStore.getState().masks;
expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10', 'annotation-30']);
expect(masks.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
saveStatus: 'dirty',
saved: false,
metadata: expect.objectContaining({ source_instance_id: 'same-class-primary' }),
}));
expect(masks.find((mask) => mask.id === 'annotation-30')).toEqual(expect.objectContaining({
saveStatus: 'saved',
}));
});
it('merges legacy propagated masks by nearest same-label result when stable lineage is missing', async () => {
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
useStore.setState({

View File

@@ -66,12 +66,18 @@ function propagationSourceMaskTokens(value: unknown): string[] {
return tokens;
}
function propagationInstanceTokens(value: unknown): string[] {
return typeof value === 'string' && value.length > 0 ? [`instance:${value}`] : [];
}
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}`);
}
propagationInstanceTokens(metadata.source_instance_id).forEach((token) => tokens.add(token));
propagationInstanceTokens(metadata.instance_id).forEach((token) => tokens.add(token));
const sourceAnnotationId = metadataNumber(metadata.source_annotation_id);
if (sourceAnnotationId !== null) {
tokens.add(`annotation:${sourceAnnotationId}`);
@@ -105,6 +111,14 @@ function propagationLineageTokens(mask: Mask): Set<string> {
tokens.add(`annotation:${mask.annotationId}`);
}
let hasStablePropagationToken = false;
const instanceTokens = [
...propagationInstanceTokens(metadata.source_instance_id),
...propagationInstanceTokens(metadata.instance_id),
];
if (instanceTokens.length > 0) {
instanceTokens.forEach((token) => tokens.add(token));
hasStablePropagationToken = true;
}
const sourceAnnotationId = metadataNumber(metadata.source_annotation_id);
if (sourceAnnotationId !== null) {
tokens.add(`annotation:${sourceAnnotationId}`);

View File

@@ -1935,6 +1935,7 @@ describe('VideoWorkspace', () => {
max_frames: 2,
seed: {
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
holes: undefined,
bbox: [0.1, 0.1, 0.2, 0.2],
points: undefined,
label: '胆囊',
@@ -1943,6 +1944,8 @@ describe('VideoWorkspace', () => {
template_id: 2,
source_mask_id: 'annotation-5',
source_annotation_id: 5,
source_instance_id: 'annotation:5',
propagation_seed_signature: undefined,
smoothing: { strength: 35, method: 'chaikin' },
},
}],
@@ -1985,6 +1988,8 @@ describe('VideoWorkspace', () => {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 5,
source_mask_id: 'annotation-5',
source_instance_id: 'seed-instance-5',
instance_id: 'seed-instance-5',
propagation_seed_signature: 'seed-signature-5',
},
}],
@@ -2006,6 +2011,7 @@ describe('VideoWorkspace', () => {
seed: expect.objectContaining({
source_annotation_id: 5,
source_mask_id: 'annotation-5',
source_instance_id: 'seed-instance-5',
propagation_seed_signature: 'seed-signature-5',
}),
})],

View File

@@ -286,11 +286,23 @@ const propagationSourceMaskTokens = (value: unknown): string[] => {
return tokens;
};
const propagationInstanceTokens = (value: unknown): string[] => (
typeof value === 'string' && value.length > 0 ? [`instance:${value}`] : []
);
const propagationLineageTokens = (mask: Mask): Set<string> => {
const metadata = mask.metadata || {};
const tokens = new Set<string>([`mask:${mask.id}`]);
if (mask.annotationId) tokens.add(`annotation:${mask.annotationId}`);
let hasStablePropagationToken = false;
const instanceTokens = [
...propagationInstanceTokens(metadata.source_instance_id),
...propagationInstanceTokens(metadata.instance_id),
];
if (instanceTokens.length > 0) {
instanceTokens.forEach((token) => tokens.add(token));
hasStablePropagationToken = true;
}
const sourceAnnotationId = metadataNumber(metadata.source_annotation_id);
if (sourceAnnotationId !== null) {
tokens.add(`annotation:${sourceAnnotationId}`);
@@ -1512,6 +1524,11 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const metadataSourceMaskId = typeof seedMask.metadata?.source_mask_id === 'string'
? seedMask.metadata.source_mask_id
: undefined;
const metadataSourceInstanceId = typeof seedMask.metadata?.source_instance_id === 'string'
? seedMask.metadata.source_instance_id
: typeof seedMask.metadata?.instance_id === 'string'
? seedMask.metadata.instance_id
: undefined;
const inheritedSeedSignature = typeof seedMask.metadata?.propagation_seed_signature === 'string'
? seedMask.metadata.propagation_seed_signature
: undefined;
@@ -1539,6 +1556,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
template_id: seedPayload.template_id,
source_mask_id: metadataSourceMaskId || seedMask.id,
source_annotation_id: sourceAnnotationId,
source_instance_id: metadataSourceInstanceId || (sourceAnnotationId ? `annotation:${sourceAnnotationId}` : seedMask.id),
propagation_seed_signature: inheritedSeedSignature,
smoothing: geometrySmoothing,
};

View File

@@ -528,6 +528,7 @@ describe('api client contracts', () => {
frame_id: 5,
template_id: 2,
mask_data: {
instance_id: 'm1',
polygons: [[[0.1, 0.2], [0.9, 0.2], [0.9, 0.8]]],
label: '胆囊',
color: '#ff0000',
@@ -550,6 +551,8 @@ describe('api client contracts', () => {
class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 7 },
source: 'sam2.1_hiera_tiny_propagation',
propagated_from_frame_id: 4,
instance_id: 'instance:gallbladder-1',
source_instance_id: 'gallbladder-1',
geometry_smoothing: { strength: 35, method: 'chaikin' },
},
points: [[0.5, 0.5]],
@@ -575,6 +578,8 @@ describe('api client contracts', () => {
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
propagated_from_frame_id: 4,
instance_id: 'instance:gallbladder-1',
source_instance_id: 'gallbladder-1',
geometry_smoothing: { strength: 35, method: 'chaikin' },
},
}));
@@ -745,6 +750,8 @@ describe('api client contracts', () => {
propagated_from_frame_id: 1,
source_annotation_id: 7,
source_mask_id: 'annotation-7',
source_instance_id: 'tracked-instance-7',
instance_id: 'tracked-instance-7',
propagation_seed_key: 'annotation:7',
geometry_smoothing_preview: { strength: 35, method: 'chaikin' },
},
@@ -754,6 +761,8 @@ describe('api client contracts', () => {
propagated_from_frame_id: 1,
source_annotation_id: 7,
source_mask_id: 'annotation-7',
source_instance_id: 'tracked-instance-7',
instance_id: 'tracked-instance-7',
propagation_seed_key: 'annotation:7',
}),
}));

View File

@@ -466,6 +466,7 @@ export interface PropagateMasksPayload {
template_id?: number;
source_mask_id?: string;
source_annotation_id?: number;
source_instance_id?: string;
propagation_seed_signature?: string;
smoothing?: GeometrySmoothingOptions;
};
@@ -768,6 +769,10 @@ export function buildAnnotationPayload(
: undefined;
const geometrySmoothing = normalizeGeometrySmoothing(mask.metadata?.geometry_smoothing);
const metadata = persistableMaskMetadata(mask.metadata);
const existingInstanceId = typeof metadata.instance_id === 'string' && metadata.instance_id.length > 0
? metadata.instance_id
: undefined;
const instanceId = existingInstanceId || (mask.annotationId ? `annotation:${mask.annotationId}` : mask.id);
const payload: SaveAnnotationPayload = {
project_id: Number(projectId),
@@ -775,6 +780,7 @@ export function buildAnnotationPayload(
template_id: effectiveTemplateId ? Number(effectiveTemplateId) : undefined,
mask_data: {
...metadata,
instance_id: instanceId,
polygons,
...(splitGeometry.holes ? { holes: splitGeometry.holes } : {}),
label: mask.label,