支持中空mask编辑和传播保洞

- 前端按 polygonRingCounts 维护外圈/内洞 ring 分组,中空 mask 在调整多边形时显示内洞顶点和插点手柄。

- 保存与回显标注时将中空结构拆分为 mask_data.polygons 和 mask_data.holes,导入/普通 mask 共享同一编辑体验。

- 自动传播 seed 携带 holes,SAM 2 seed 栅格化时扣除内洞,避免中空 mask 以实心形式传播。

- 传播结果轮廓提取改为保留层级内洞,并在同步传播和 Celery 传播落库时写回 holes 与 hasHoles。

- 传播 seed 签名纳入 holes,并加固保存结果时 holes 与原始 polygon 索引对齐。

- 补充前端保存/回显、Canvas 内洞编辑和后端 SAM 2 hole 处理测试。

- 更新 AGENTS、接口契约、需求冻结、设计冻结和测试计划文档,移除中空结构未实现的旧描述。
This commit is contained in:
2026-05-03 18:28:46 +08:00
parent 739953bc13
commit f88f9bdbb9
15 changed files with 413 additions and 102 deletions

View File

@@ -506,6 +506,34 @@ describe('CanvasArea', () => {
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
});
it('shows editable vertices for inner rings of hollow polygon masks', () => {
useStore.setState({
selectedMaskIds: ['hollow-1'],
masks: [
{
id: 'hollow-1',
frameId: 'frame-1',
pathData: 'M 10 10 L 90 10 L 90 90 L 10 90 Z M 35 35 L 65 35 L 65 65 L 35 65 Z',
label: 'Hollow',
color: '#06b6d4',
segmentation: [
[10, 10, 90, 10, 90, 90, 10, 90],
[35, 35, 65, 35, 65, 65, 35, 65],
],
metadata: { hasHoles: true, polygonRingCounts: [2] },
},
],
});
render(<CanvasArea activeTool="edit_polygon" frame={frame} />);
const editableVertices = screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff');
expect(editableVertices).toHaveLength(8);
expect(editableVertices.map((element) => [element.getAttribute('data-x'), element.getAttribute('data-y')]))
.toContainEqual(['35', '35']);
});
it('selects a polygon mask and drags a vertex into dirty saved state', () => {
useStore.setState({
masks: [

View File

@@ -272,12 +272,38 @@ function closeRing(points: CanvasPoint[]): Pair[] {
return ring;
}
function segmentationRings(segmentation?: number[][]): Pair[][] {
return (segmentation || [])
.map((polygon) => closeRing(flatPolygonToPoints(polygon)))
.filter((ring) => ring.length >= 4);
}
function maskPolygonRingCounts(mask: Mask, ringCount: number): number[] | null {
const rawCounts = mask.metadata?.polygonRingCounts;
if (!Array.isArray(rawCounts)) return null;
const counts = rawCounts
.map((count) => Number(count))
.filter((count) => Number.isInteger(count) && count > 0);
const total = counts.reduce((sum, count) => sum + count, 0);
return total === ringCount ? counts : null;
}
function maskToMultiPolygon(mask: Mask): MultiPolygon | null {
const polygons = (mask.segmentation || [])
.map((polygon) => flatPolygonToPoints(polygon))
.filter((points) => points.length >= 3)
.map((points) => [closeRing(points)]);
return polygons.length > 0 ? polygons : null;
const rings = segmentationRings(mask.segmentation);
if (rings.length === 0) return null;
const counts = maskPolygonRingCounts(mask, rings.length);
if (counts) {
let offset = 0;
return counts.map((count) => {
const polygon = rings.slice(offset, offset + count);
offset += count;
return polygon;
}).filter((polygon) => polygon.length > 0);
}
if (mask.metadata?.hasHoles && rings.length > 1) {
return [rings];
}
return rings.map((ring) => [ring]);
}
function polygonsToMultiPolygon(polygons: CanvasPoint[][]): MultiPolygon | null {
@@ -303,6 +329,12 @@ function multiPolygonToSegmentation(geometry: MultiPolygon): number[][] {
.filter((polygon) => polygon.length >= 6);
}
function multiPolygonRingCounts(geometry: MultiPolygon): number[] {
return geometry
.map((polygon) => polygon.length)
.filter((count) => count > 0);
}
function multiPolygonArea(geometry: MultiPolygon): number {
return geometry.reduce((sum, polygon) => {
const [outerRing, ...holeRings] = polygon;
@@ -319,12 +351,14 @@ function multiPolygonHasHoles(geometry: MultiPolygon): boolean {
function maskWithSegmentation(
mask: Mask,
segmentation: number[][],
options: { area?: number; hasHoles?: boolean } = {},
options: { area?: number; hasHoles?: boolean; polygonRingCounts?: number[] } = {},
): Mask {
const bbox = segmentationBbox(segmentation);
const metadata = { ...(mask.metadata || {}) };
if (options.hasHoles === true) metadata.hasHoles = true;
if (options.hasHoles === false) delete metadata.hasHoles;
if (options.polygonRingCounts && options.polygonRingCounts.length > 0) metadata.polygonRingCounts = options.polygonRingCounts;
if (options.hasHoles === false) delete metadata.polygonRingCounts;
return {
...mask,
pathData: segmentationPath(segmentation),
@@ -761,6 +795,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
const createManualMaskFromGeometry = useCallback((shape: string, geometry: MultiPolygon): Mask | null => {
if (!frame?.id || !activeClass) return null;
const segmentation = multiPolygonToSegmentation(geometry);
const polygonRingCounts = multiPolygonRingCounts(geometry);
if (segmentation.length === 0) return null;
const area = multiPolygonArea(geometry);
if (area <= 1) return null;
@@ -784,6 +819,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
source: 'manual',
shape,
...(multiPolygonHasHoles(geometry) ? { hasHoles: true } : {}),
...(multiPolygonHasHoles(geometry) ? { polygonRingCounts } : {}),
},
};
addMask(mask);
@@ -1011,6 +1047,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
...maskWithSegmentation(selectedMask, resultSegmentation, {
area: multiPolygonArea(resultGeometry),
hasHoles: multiPolygonHasHoles(resultGeometry),
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
}),
templateId: activeTemplateId || selectedMask.templateId,
classId: activeClass.id,
@@ -1053,6 +1090,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
const nextMask = maskWithSegmentation(selectedMask, resultSegmentation, {
area: multiPolygonArea(resultGeometry),
hasHoles: multiPolygonHasHoles(resultGeometry),
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
});
setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask)));
setSelectedMaskId(selectedMask.id);
@@ -1216,11 +1254,22 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
const nextSegmentation = [...(mask.segmentation || [])];
nextSegmentation[polygonIndex] = nextPoints.flatMap((point) => [point.x, point.y]);
const bbox = segmentationBbox(nextSegmentation) || polygonBbox(nextPoints);
const nextGeometry = maskToMultiPolygon({ ...mask, segmentation: nextSegmentation });
const hasHoles = nextGeometry ? multiPolygonHasHoles(nextGeometry) : Boolean(mask.metadata?.hasHoles);
const metadata = { ...(mask.metadata || {}) };
if (hasHoles && nextGeometry) {
metadata.hasHoles = true;
metadata.polygonRingCounts = multiPolygonRingCounts(nextGeometry);
} else {
delete metadata.hasHoles;
delete metadata.polygonRingCounts;
}
updateMask(mask.id, {
pathData: segmentationPath(nextSegmentation),
segmentation: nextSegmentation,
bbox,
area: segmentationArea(nextSegmentation),
area: nextGeometry ? multiPolygonArea(nextGeometry) : segmentationArea(nextSegmentation),
metadata,
saveStatus: mask.annotationId ? 'dirty' : 'draft',
saved: mask.annotationId ? false : mask.saved,
});
@@ -1229,7 +1278,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
const updateMaskFromSegmentation = useCallback((
mask: Mask,
segmentation: number[][],
options: { area?: number; hasHoles?: boolean } = {},
options: { area?: number; hasHoles?: boolean; polygonRingCounts?: number[] } = {},
): Mask => {
return maskWithSegmentation(mask, segmentation, options);
}, []);
@@ -1288,6 +1337,17 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
return null;
}, [cursorPos, effectiveTool, manualCurrent, manualStart, polygonPoints]);
const selectedMaskEditableRings = React.useMemo(() => {
if (!selectedMask?.segmentation) return [];
const hasHoles = Boolean(selectedMask.metadata?.hasHoles);
if (!hasHoles) {
return [{ polygonIndex: selectedPolygonIndex, points: selectedMaskPoints }];
}
return selectedMask.segmentation
.map((_, polygonIndex) => ({ polygonIndex, points: segmentationToPoints(selectedMask.segmentation, polygonIndex) }))
.filter((ring) => ring.points.length >= 3);
}, [selectedMask, selectedMaskPoints, selectedPolygonIndex]);
const handleMaskSelect = (mask: Mask, event: any, polygonIndex = 0) => {
if (!isPolygonEditTool && !isBooleanTool) return;
event.cancelBubble = true;
@@ -1308,17 +1368,18 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
setSelectedVertexIndex(null);
};
const handleVertexDragStart = (mask: Mask, vertexIndex: number, event?: any) => {
const handleVertexDragStart = (mask: Mask, vertexIndex: number, polygonIndex = selectedPolygonIndex, event?: any) => {
if (event) event.cancelBubble = true;
setSelectedMaskId(mask.id);
setSelectedMaskIds([mask.id]);
setSelectedPolygonIndex(polygonIndex);
setSelectedVertexIndex(vertexIndex);
};
const handleVertexDrag = (mask: Mask, vertexIndex: number, event: any) => {
const handleVertexDrag = (mask: Mask, vertexIndex: number, event: any, polygonIndex = selectedPolygonIndex) => {
const imageWidth = frame?.width || image?.naturalWidth || image?.width || stageSize.width;
const imageHeight = frame?.height || image?.naturalHeight || image?.height || stageSize.height;
const currentPoints = segmentationToPoints(mask.segmentation, selectedPolygonIndex);
const currentPoints = segmentationToPoints(mask.segmentation, polygonIndex);
if (!currentPoints[vertexIndex]) return;
const nextPoints = currentPoints.map((point, index) => (
index === vertexIndex
@@ -1330,13 +1391,14 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
));
setSelectedMaskId(mask.id);
setSelectedMaskIds([mask.id]);
setSelectedPolygonIndex(polygonIndex);
setSelectedVertexIndex(vertexIndex);
updatePolygonMask(mask, nextPoints, selectedPolygonIndex);
updatePolygonMask(mask, nextPoints, polygonIndex);
};
const handleEdgeInsert = (mask: Mask, edgeIndex: number, event: any) => {
const handleEdgeInsert = (mask: Mask, edgeIndex: number, event: any, polygonIndex = selectedPolygonIndex) => {
event.cancelBubble = true;
const currentPoints = segmentationToPoints(mask.segmentation, selectedPolygonIndex);
const currentPoints = segmentationToPoints(mask.segmentation, polygonIndex);
const start = currentPoints[edgeIndex];
const end = currentPoints[(edgeIndex + 1) % currentPoints.length];
if (!start || !end) return;
@@ -1347,8 +1409,9 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
...currentPoints.slice(edgeIndex + 1),
];
setSelectedMaskId(mask.id);
setSelectedPolygonIndex(polygonIndex);
setSelectedVertexIndex(edgeIndex + 1);
updatePolygonMask(mask, nextPoints, selectedPolygonIndex);
updatePolygonMask(mask, nextPoints, polygonIndex);
};
const handlePathDoubleClick = (mask: Mask, event: any, polygonIndex = 0) => {
@@ -1404,6 +1467,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
const nextPrimary = updateMaskFromSegmentation(primary, resultSegmentation, {
area: multiPolygonArea(resultGeometry),
hasHoles: multiPolygonHasHoles(resultGeometry),
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
});
const secondaryIds = effectiveTool === 'area_merge'
? expandedPropagationDeletionMaskIds(booleanSelectedMasks.slice(1).map((mask) => mask.id), masks)
@@ -1587,49 +1651,58 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
))}
{/* Polygon edge insertion handles */}
{isPolygonEditTool && selectedMask && selectedMaskPoints.map((point, index) => {
const next = selectedMaskPoints[(index + 1) % selectedMaskPoints.length];
if (!next) return null;
return (
<Circle
key={`${selectedMask.id}-edge-${selectedPolygonIndex}-${index}`}
x={(point.x + next.x) / 2}
y={(point.y + next.y) / 2}
radius={3.5 / scale}
fill="#22d3ee"
stroke="#111827"
strokeWidth={1.5 / scale}
onClick={(event: any) => handleEdgeInsert(selectedMask, index, event)}
onTap={(event: any) => handleEdgeInsert(selectedMask, index, event)}
/>
);
})}
{isPolygonEditTool && selectedMask && selectedMaskEditableRings.flatMap(({ polygonIndex, points: ringPoints }) => (
ringPoints.map((point, index) => {
const next = ringPoints[(index + 1) % ringPoints.length];
if (!next) return null;
return (
<Circle
key={`${selectedMask.id}-edge-${polygonIndex}-${index}`}
x={(point.x + next.x) / 2}
y={(point.y + next.y) / 2}
radius={3.5 / scale}
fill="#22d3ee"
stroke="#111827"
strokeWidth={1.5 / scale}
onClick={(event: any) => handleEdgeInsert(selectedMask, index, event, polygonIndex)}
onTap={(event: any) => handleEdgeInsert(selectedMask, index, event, polygonIndex)}
/>
);
})
))}
{/* Polygon vertex editor */}
{isPolygonEditTool && selectedMask && selectedMaskPoints.map((point, index) => (
<Circle
key={`${selectedMask.id}-vertex-${selectedPolygonIndex}-${index}`}
x={point.x}
y={point.y}
radius={(selectedVertexIndex === index ? 6 : 4.5) / scale}
fill={selectedVertexIndex === index ? '#22d3ee' : '#ffffff'}
stroke={selectedMask.color}
strokeWidth={2 / scale}
draggable
onMouseDown={(event: any) => handleVertexDragStart(selectedMask, index, event)}
onTouchStart={(event: any) => handleVertexDragStart(selectedMask, index, event)}
onDragStart={(event: any) => handleVertexDragStart(selectedMask, index, event)}
onClick={(event: any) => {
event.cancelBubble = true;
setSelectedVertexIndex(index);
}}
onTap={(event: any) => {
event.cancelBubble = true;
setSelectedVertexIndex(index);
}}
onDragMove={(event: any) => handleVertexDrag(selectedMask, index, event)}
onDragEnd={(event: any) => handleVertexDrag(selectedMask, index, event)}
/>
{isPolygonEditTool && selectedMask && selectedMaskEditableRings.flatMap(({ polygonIndex, points: ringPoints }) => (
ringPoints.map((point, index) => {
const isActiveVertex = selectedPolygonIndex === polygonIndex && selectedVertexIndex === index;
return (
<Circle
key={`${selectedMask.id}-vertex-${polygonIndex}-${index}`}
x={point.x}
y={point.y}
radius={(isActiveVertex ? 6 : 4.5) / scale}
fill={isActiveVertex ? '#22d3ee' : '#ffffff'}
stroke={selectedMask.color}
strokeWidth={2 / scale}
draggable
onMouseDown={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
onTouchStart={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
onDragStart={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
onClick={(event: any) => {
event.cancelBubble = true;
setSelectedPolygonIndex(polygonIndex);
setSelectedVertexIndex(index);
}}
onTap={(event: any) => {
event.cancelBubble = true;
setSelectedPolygonIndex(polygonIndex);
setSelectedVertexIndex(index);
}}
onDragMove={(event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)}
onDragEnd={(event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)}
/>
);
})
))}
{/* AI Prompts Point Regions */}

View File

@@ -1124,6 +1124,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
: undefined;
return {
polygons: seedPayload.mask_data?.polygons,
holes: seedPayload.mask_data?.holes,
bbox: seedPayload.bbox,
points: seedPayload.points,
label: seedPayload.mask_data?.label,

View File

@@ -642,6 +642,57 @@ describe('api client contracts', () => {
}));
});
it('preserves hollow mask holes when saving and hydrating annotations', async () => {
const { annotationToMask, buildAnnotationPayload } = await import('./api');
const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 100 };
const hollowMask = {
id: 'm-hole',
frameId: '5',
pathData: 'M 10 10 L 90 10 L 90 90 L 10 90 Z M 35 35 L 65 35 L 65 65 L 35 65 Z',
label: '中空区域',
color: '#22c55e',
segmentation: [
[10, 10, 90, 10, 90, 90, 10, 90],
[35, 35, 65, 35, 65, 65, 35, 65],
],
metadata: { hasHoles: true, polygonRingCounts: [2] },
};
const payload = buildAnnotationPayload('9', hollowMask, frame);
expect(payload?.mask_data).toEqual(expect.objectContaining({
polygons: [[[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]]],
holes: [[[[0.35, 0.35], [0.65, 0.35], [0.65, 0.65], [0.35, 0.65]]]],
hasHoles: true,
polygonRingCounts: [2],
}));
const hydrated = annotationToMask({
id: 33,
project_id: 9,
frame_id: 5,
template_id: null,
mask_data: {
polygons: [[[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]]],
holes: [[[[0.35, 0.35], [0.65, 0.35], [0.65, 0.65], [0.35, 0.65]]]],
label: '中空区域',
color: '#22c55e',
},
points: null,
bbox: null,
created_at: 'created',
updated_at: 'updated',
}, frame);
expect(hydrated).toEqual(expect.objectContaining({
segmentation: [
[10, 10, 90, 10, 90, 90, 10, 90],
[35, 35, 65, 35, 65, 65, 35, 65],
],
metadata: expect.objectContaining({ hasHoles: true, polygonRingCounts: [2] }),
}));
});
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

@@ -395,6 +395,7 @@ export interface SavedAnnotation {
template_id: number | null;
mask_data: {
polygons?: number[][][];
holes?: number[][][][];
label?: string;
color?: string;
class?: {
@@ -424,6 +425,7 @@ export interface SaveAnnotationPayload {
template_id?: number;
mask_data?: {
polygons: number[][][];
holes?: number[][][][];
label?: string;
color?: string;
class?: {
@@ -449,6 +451,7 @@ export interface PropagateMasksPayload {
model?: AiModelId;
seed: {
polygons?: number[][][];
holes?: number[][][][];
bbox?: number[];
points?: number[][];
label?: string;
@@ -679,13 +682,73 @@ function pixelSegmentationToNormalizedPolygons(
.filter((points) => points.length > 0);
}
function metadataNumberArray(value: unknown): number[] | null {
if (!Array.isArray(value)) return null;
const counts = value
.map((item) => Number(item))
.filter((item) => Number.isInteger(item) && item > 0);
return counts.length === value.length ? counts : null;
}
function splitNormalizedPolygonsAndHoles(
polygons: number[][][],
metadata?: Record<string, unknown>,
): { polygons: number[][][]; holes?: number[][][][] } {
const counts = metadataNumberArray(metadata?.polygonRingCounts);
if (counts && counts.reduce((sum, count) => sum + count, 0) === polygons.length) {
const outers: number[][][] = [];
const holes: number[][][][] = [];
let offset = 0;
counts.forEach((count) => {
const group = polygons.slice(offset, offset + count);
offset += count;
if (group[0]) {
outers.push(group[0]);
holes.push(group.slice(1));
}
});
return holes.some((group) => group.length > 0) ? { polygons: outers, holes } : { polygons: outers };
}
if (metadata?.hasHoles && polygons.length > 1) {
return { polygons: [polygons[0]], holes: [polygons.slice(1)] };
}
return { polygons };
}
function mergeNormalizedPolygonsAndHoles(
polygons: number[][][],
holes: unknown,
): { segmentationPolygons: number[][][]; polygonRingCounts?: number[]; hasHoles: boolean } {
if (!Array.isArray(holes) || holes.length === 0) {
return { segmentationPolygons: polygons, hasHoles: false };
}
const segmentationPolygons: number[][][] = [];
const polygonRingCounts: number[] = [];
let hasHoles = false;
polygons.forEach((polygon, index) => {
const holeGroup = Array.isArray(holes[index]) ? holes[index] as number[][][] : [];
segmentationPolygons.push(polygon, ...holeGroup);
polygonRingCounts.push(1 + holeGroup.length);
if (holeGroup.length > 0) hasHoles = true;
});
return hasHoles
? { segmentationPolygons, polygonRingCounts, hasHoles }
: { segmentationPolygons: polygons, hasHoles: false };
}
export function buildAnnotationPayload(
projectId: string,
mask: Mask,
frame: Frame,
templateId?: string | null,
): SaveAnnotationPayload | null {
const polygons = pixelSegmentationToNormalizedPolygons(mask.segmentation, frame.width, frame.height);
const segmentationPolygons = pixelSegmentationToNormalizedPolygons(mask.segmentation, frame.width, frame.height);
if (segmentationPolygons.length === 0) return null;
const splitGeometry = splitNormalizedPolygonsAndHoles(segmentationPolygons, mask.metadata);
const polygons = splitGeometry.polygons;
if (polygons.length === 0) return null;
const effectiveTemplateId = mask.templateId || templateId || undefined;
const classMetadata = mask.classId || mask.className || mask.classZIndex !== undefined || mask.classMaskId !== undefined
@@ -707,6 +770,7 @@ export function buildAnnotationPayload(
mask_data: {
...metadata,
polygons,
...(splitGeometry.holes ? { holes: splitGeometry.holes } : {}),
label: mask.label,
color: mask.color,
...(classMetadata ? { class: classMetadata } : {}),
@@ -735,12 +799,18 @@ export function buildAnnotationPayload(
export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mask | null {
const polygons = annotation.mask_data?.polygons || [];
const firstPolygon = polygons[0];
const mergedGeometry = mergeNormalizedPolygonsAndHoles(polygons, annotation.mask_data?.holes);
const segmentationPolygons = mergedGeometry.segmentationPolygons;
const firstPolygon = segmentationPolygons[0];
if (!firstPolygon || firstPolygon.length === 0) return null;
const bbox = polygonToBbox(firstPolygon, frame.width, frame.height);
const classMetadata = annotation.mask_data?.class;
const { polygons: _polygons, label: _label, color: _color, class: _classMetadata, ...metadata } = annotation.mask_data || {};
const hasMetadata = Object.keys(metadata).length > 0;
const { polygons: _polygons, holes: _holes, label: _label, color: _color, class: _classMetadata, ...metadata } = annotation.mask_data || {};
const restoredMetadata = {
...metadata,
...(mergedGeometry.hasHoles ? { hasHoles: true, polygonRingCounts: mergedGeometry.polygonRingCounts } : {}),
};
const hasMetadata = Object.keys(restoredMetadata).length > 0;
return {
id: `annotation-${annotation.id}`,
annotationId: String(annotation.id),
@@ -752,14 +822,14 @@ export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mas
classMaskId: classMetadata?.maskId,
saveStatus: 'saved',
saved: true,
pathData: polygonToPath(firstPolygon, frame.width, frame.height),
pathData: segmentationPolygons.map((polygon) => polygonToPath(polygon, frame.width, frame.height)).join(' '),
label: classMetadata?.name || annotation.mask_data?.label || `Annotation ${annotation.id}`,
color: classMetadata?.color || annotation.mask_data?.color || '#06b6d4',
segmentation: polygons.map((polygon) => polygon.flatMap(([x, y]) => [x * frame.width, y * frame.height])),
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],
metadata: hasMetadata ? metadata : undefined,
metadata: hasMetadata ? restoredMetadata : undefined,
};
}