引入实例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:
@@ -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({
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
})],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user