保持传播多区域结果为单个遮罩

- 后端传播落库时将同一 seed 在同一目标帧的多个不连通 polygon 保存到同一 annotation
- 同步任务传播和兼容同步传播接口的多 polygon 保存逻辑
- 传播结果 bbox 改为覆盖全部不连通 polygon,并保留多 polygon scores 与 holes
- 前端回显单条多 polygon annotation 时使用组合 bbox 和真实 polygon 面积
- 补充后端传播 worker 回归测试,验证不连通结果只生成一个 annotation
- 补充前端 API 回归测试,验证多 polygon annotation 回显为一个 mask
- 更新项目指南和设计冻结文档
This commit is contained in:
2026-05-04 02:32:31 +08:00
parent 5e570f789b
commit 0485ce4d92
7 changed files with 214 additions and 52 deletions

View File

@@ -693,6 +693,42 @@ describe('api client contracts', () => {
}));
});
it('restores disconnected polygons from one saved annotation as one mask with a combined bbox', async () => {
const { annotationToMask } = await import('./api');
const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 100 };
const hydrated = annotationToMask({
id: 44,
project_id: 9,
frame_id: 5,
template_id: null,
mask_data: {
polygons: [
[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2], [0.1, 0.2]],
[[0.7, 0.7], [0.9, 0.7], [0.9, 0.9], [0.7, 0.9]],
],
label: '多区域',
color: '#22c55e',
source: 'sam2.1_hiera_tiny_propagation',
},
points: null,
bbox: null,
created_at: 'created',
updated_at: 'updated',
}, frame);
expect(hydrated).toEqual(expect.objectContaining({
id: 'annotation-44',
pathData: 'M 10 10 L 20 10 L 20 20 L 10 20 Z M 70 70 L 90 70 L 90 90 L 70 90 Z',
segmentation: [
[10, 10, 20, 10, 20, 20, 10, 20],
[70, 70, 90, 70, 90, 90, 70, 90],
],
bbox: [10, 10, 80, 80],
area: 500,
}));
});
it('preserves propagation metadata when saving edited geometry without persisting preview-only smoothing fields', async () => {
const { buildAnnotationPayload } = await import('./api');
const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 50 };

View File

@@ -592,6 +592,12 @@ function polygonToBbox(points: number[][], width: number, height: number): [numb
return [minX, minY, maxX - minX, maxY - minY];
}
function polygonsToBbox(polygons: number[][][], width: number, height: number): [number, number, number, number] {
const points = polygons.flat();
if (points.length === 0) return [0, 0, 0, 0];
return polygonToBbox(points, width, height);
}
function polygonAreaPixels(points: number[][], width: number, height: number): number {
if (points.length < 3) return 0;
let total = 0;
@@ -803,7 +809,7 @@ export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mas
const segmentationPolygons = mergedGeometry.segmentationPolygons;
const firstPolygon = segmentationPolygons[0];
if (!firstPolygon || firstPolygon.length === 0) return null;
const bbox = polygonToBbox(firstPolygon, frame.width, frame.height);
const bbox = polygonsToBbox(segmentationPolygons, frame.width, frame.height);
const classMetadata = annotation.mask_data?.class;
const { polygons: _polygons, holes: _holes, label: _label, color: _color, class: _classMetadata, ...metadata } = annotation.mask_data || {};
const restoredMetadata = {
@@ -828,7 +834,7 @@ export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mas
segmentation: segmentationPolygons.map((polygon) => polygon.flatMap(([x, y]) => [x * frame.width, y * frame.height])),
points: annotation.points?.map(([x, y]) => [x * frame.width, y * frame.height]),
bbox,
area: bbox[2] * bbox[3],
area: segmentationPolygons.reduce((total, polygon) => total + polygonAreaPixels(polygon, frame.width, frame.height), 0),
metadata: hasMetadata ? restoredMetadata : undefined,
};
}