支持中空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:
@@ -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: [
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user