feat: 完善 AI 分割与工作区标注闭环
功能增加: - 将视频导入和生成帧拆成两个明确动作,项目库生成帧时选择 FPS,工作区不再自动触发拆帧。 - 为工作区新增调整多边形工具,支持选中 mask、拖动顶点、边中点插点、双击边界按位置插点,并保留多 polygon 子区域编辑。 - 打通 AI 页 SAM2/SAM3 结果到工作区的联动,生成 mask 后自动选中,可在右侧分类树换标签,并推送到工作区继续编辑。 - 增强 Dashboard WebSocket 连接状态与心跳,使用真实 onopen/onclose/onerror 状态驱动前端显示。 - 完善 SAM3 external worker 适配,支持 box prompt、semantic 请求级阈值和 video tracker 路径。 bugfix: - 修复 SAM2 文本语义误走自动分割的问题,改为提示使用点提示或切换 SAM3。 - 修复 SAM2 多候选重叠显示的问题,点提示和 auto fallback 默认只采用最高分候选。 - 修复 SAM2 反向点看起来无效的问题,带负点时启用背景过滤,过滤为空时移除旧候选。 - 修复 SAM3 单个 2D mask 结果无法转 polygon、低阈值 semantic 返回被默认阈值吞掉的问题。 - 修复 AI 页 mask 未选中导致分类树无法修改 SAM2 结果标签的问题。 测试和文档: - 补充 CanvasArea、AISegmentation、ProjectLibrary、VideoWorkspace、Dashboard、websocket 和 SAM engine/API 测试。 - 新增 backend/tests/test_sam2_engine.py,覆盖 SAM2 单候选请求和 auto fallback 行为。 - 更新 README、AGENTS 和 doc 需求/设计/接口/测试矩阵,按当前实现冻结功能状态。
This commit is contained in:
@@ -206,16 +206,58 @@ describe('CanvasArea', () => {
|
||||
{ x: 300, y: 150, type: 'neg' },
|
||||
],
|
||||
box: { x1: 120, y1: 80, x2: 260, y2: 200 },
|
||||
options: { auto_filter_background: true, min_score: 0.05 },
|
||||
}));
|
||||
expect(useStore.getState().masks).toHaveLength(1);
|
||||
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
||||
id: 'mask-box',
|
||||
segmentation: [[30, 30, 70, 30, 70, 70]],
|
||||
points: [[150, 100]],
|
||||
metadata: expect.objectContaining({ promptPointCount: 2 }),
|
||||
metadata: expect.objectContaining({ promptPointCount: 2, promptNegativePointCount: 1 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('removes the SAM2 candidate when a negative point filters it out', async () => {
|
||||
apiMock.predictMask
|
||||
.mockResolvedValueOnce({
|
||||
masks: [
|
||||
{
|
||||
id: 'mask-box',
|
||||
pathData: 'M 10 10 L 90 10 L 90 90 Z',
|
||||
label: 'AI Mask',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[10, 10, 90, 10, 90, 90]],
|
||||
bbox: [10, 10, 80, 80],
|
||||
area: 6400,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ masks: [] });
|
||||
|
||||
const { rerender } = render(<CanvasArea activeTool="box_select" frame={frame} />);
|
||||
const stage = screen.getByTestId('konva-stage');
|
||||
fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 });
|
||||
fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 });
|
||||
fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 });
|
||||
|
||||
await waitFor(() => expect(useStore.getState().masks).toHaveLength(1));
|
||||
|
||||
rerender(<CanvasArea activeTool="point_neg" frame={frame} />);
|
||||
fireEvent.click(stage, { clientX: 180, clientY: 120 });
|
||||
|
||||
await waitFor(() => expect(apiMock.predictMask).toHaveBeenNthCalledWith(2, {
|
||||
imageId: 'frame-1',
|
||||
imageWidth: 640,
|
||||
imageHeight: 360,
|
||||
model: 'sam2',
|
||||
points: [{ x: 180, y: 120, type: 'neg' }],
|
||||
box: { x1: 120, y1: 80, x2: 260, y2: 200 },
|
||||
options: { auto_filter_background: true, min_score: 0.05 },
|
||||
}));
|
||||
await waitFor(() => expect(useStore.getState().masks).toHaveLength(0));
|
||||
expect(await screen.findByText(/反向点已排除当前候选区域/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders only masks that belong to the current frame', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
@@ -250,6 +292,28 @@ describe('CanvasArea', () => {
|
||||
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1']));
|
||||
});
|
||||
|
||||
it('keeps a mask selected when opening the workspace polygon editor from AI results', () => {
|
||||
useStore.setState({
|
||||
selectedMaskIds: ['m1'],
|
||||
masks: [
|
||||
{
|
||||
id: 'm1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 0 0 L 10 0 L 10 10 Z',
|
||||
label: 'A',
|
||||
color: '#fff',
|
||||
segmentation: [[0, 0, 10, 0, 10, 10]],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="edit_polygon" frame={frame} />);
|
||||
|
||||
expect(useStore.getState().selectedMaskIds).toEqual(['m1']);
|
||||
expect(screen.getAllByTestId('konva-circle')
|
||||
.filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders imported GT seed points for editable point regions', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
@@ -415,6 +479,34 @@ describe('CanvasArea', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('selects a polygon with the edit tool and inserts a vertex by double-clicking an edge', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{
|
||||
id: 'draft-1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 90 10 L 90 40 Z',
|
||||
label: 'Draft',
|
||||
color: '#06b6d4',
|
||||
saveStatus: 'draft',
|
||||
segmentation: [[10, 10, 90, 10, 90, 40]],
|
||||
bbox: [10, 10, 80, 30],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="edit_polygon" frame={frame} />);
|
||||
const path = screen.getByTestId('konva-path');
|
||||
fireEvent.click(path);
|
||||
fireEvent.doubleClick(path, { clientX: 50, clientY: 10 });
|
||||
|
||||
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
||||
segmentation: [[10, 10, 50, 10, 90, 10, 90, 40]],
|
||||
pathData: 'M 10 10 L 50 10 L 90 10 L 90 40 Z',
|
||||
saveStatus: 'draft',
|
||||
}));
|
||||
});
|
||||
|
||||
it('edits the selected polygon in a multi-polygon mask', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
|
||||
Reference in New Issue
Block a user