feat: 完善工作区交互提示与后端属性分析

功能新增:
- 新增 POST /api/ai/analyze-mask 后端接口,基于 mask polygon、bbox、points 和 score 返回置信度来源、面积、拓扑锚点和后端分析提示。
- 前端新增 analyzeMask API 封装,并在本体检查面板读取选中 mask 的后端几何属性和重新提取拓扑锚点结果。
- 右侧语义分类树点击分类时,会给当前选中 mask 换标签、更新 class 元数据,并将选中 mask 移到前端渲染最上层,方便继续编辑。
- 分割工作区画布新增上下文操作提示,覆盖多边形 Enter 完成、Esc 取消、首节点闭合、拖拽图形、点区域、SAM 点/框提示、区域合并/去除选择顺序和多边形编辑。
- AI 智能分割画布新增正向点、反向点、边界框选和视口控制的上下文提示。
- 自动传播交互收敛为参考帧加起止帧范围加单个“自动传播”按钮,默认使用当前参考帧全部 mask 作为 seed。
- 时间轴改为用浅蓝色进度条区段标记自动传播生成的帧,而不是已编辑帧竖线提示。

Bugfix:
- AI 分割页无当前帧时移除外部演示背景图,改为明确空状态提示,避免误以为外部图片可参与真实推理。
- 工具栏魔法棒文案改为“打开 AI 智能分割”,避免误导为直接触发 SAM 推理。
- Canvas 底部当前图层信息改为显示真实选中 mask 标签和 annotation id,不再使用固定占位文本。
- 已保存标注回显时保留 mask metadata 中的传播来源、score 等字段,供时间轴和属性面板识别。
- 清理 server.ts 中遗留的 /api/login、/api/projects、/api/templates 内存 mock API,避免和 FastAPI 真实后端混淆。

测试:
- 补充 analyze-mask 后端测试,覆盖后端几何属性和锚点返回。
- 补充 api.analyzeMask 前端契约测试,覆盖 normalized polygon、bbox、points 和 extract_skeleton payload。
- 补充本体面板测试,覆盖后端属性读取、自定义分类写回后端模板、选中 mask 换标签和置顶显示。
- 补充 Canvas 测试,覆盖上下文提示、多边形完成提示、布尔选择顺序提示、当前图层真实显示和编辑优先级。
- 补充 AI 分割测试,覆盖无帧空状态和提示工具上下文提示。
- 更新 Konva 测试 mock,支持拖动过程、stroke/dash/fillRule 等渲染断言。

文档:
- 更新 README 和 AGENTS,说明 server.ts 不再保留业务 mock API。
- 更新 doc/02、doc/03、doc/04、doc/05、doc/07、doc/08、doc/09,记录后端属性分析、分类置顶显示、上下文提示、自动传播按钮、传播帧标记、测试覆盖和当前剩余限制。
This commit is contained in:
2026-05-02 02:10:37 +08:00
parent 4c21de02f8
commit b6a276cb8d
28 changed files with 796 additions and 231 deletions

View File

@@ -297,9 +297,10 @@ describe('CanvasArea', () => {
masks: [
{
id: 'm1',
annotationId: '42',
frameId: 'frame-1',
pathData: 'M 0 0 L 10 0 L 10 10 Z',
label: 'A',
label: '胆囊',
color: '#fff',
segmentation: [[0, 0, 10, 0, 10, 10]],
},
@@ -310,6 +311,7 @@ describe('CanvasArea', () => {
fireEvent.click(screen.getByTestId('konva-path'));
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1']));
expect(screen.getByText('当前图层: 胆囊 #42')).toBeInTheDocument();
});
it('keeps a mask selected when opening the workspace polygon editor from AI results', () => {
@@ -389,6 +391,37 @@ describe('CanvasArea', () => {
}));
});
it('moves a polygon vertex directly while dragging without a prior vertex click', () => {
useStore.setState({
selectedMaskIds: ['draft-1'],
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 handles = screen.getAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff');
fireEvent.mouseDown(handles[0]);
fireEvent.mouseMove(handles[0], { clientX: 25, clientY: 35 });
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
pathData: 'M 25 35 L 90 10 L 90 40 Z',
segmentation: [[25, 35, 90, 10, 90, 40]],
saveStatus: 'draft',
}));
});
it('deletes a selected polygon vertex without dropping below three points', () => {
useStore.setState({
masks: [
@@ -626,6 +659,11 @@ describe('CanvasArea', () => {
const paths = screen.getAllByTestId('konva-path');
fireEvent.click(paths[0]);
fireEvent.click(paths[1]);
const selectedPaths = screen.getAllByTestId('konva-path');
expect(selectedPaths[0]).toHaveAttribute('data-stroke', '#facc15');
expect(selectedPaths[0]).toHaveAttribute('data-dash', '');
expect(selectedPaths[1]).toHaveAttribute('data-stroke', '#fb7185');
expect(selectedPaths[1]).toHaveAttribute('data-dash', '6,4');
fireEvent.click(screen.getByRole('button', { name: '从主区域去除' }));
expect(useStore.getState().masks).toHaveLength(2);
@@ -796,9 +834,11 @@ describe('CanvasArea', () => {
it('finalizes a clicked polygon with Enter', () => {
render(<CanvasArea activeTool="create_polygon" frame={frame} />);
const stage = screen.getByTestId('konva-stage');
expect(screen.getByText(/点击画布添加顶点/)).toBeInTheDocument();
fireEvent.click(stage, { clientX: 120, clientY: 80 });
fireEvent.click(stage, { clientX: 220, clientY: 80 });
fireEvent.click(stage, { clientX: 180, clientY: 160 });
expect(screen.getByText(/点击黄色首节点或按 Enter 闭合完成/)).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'Enter' });
expect(useStore.getState().masks).toHaveLength(1);
@@ -831,6 +871,35 @@ describe('CanvasArea', () => {
expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0);
});
it('shows contextual guidance for boolean selection ordering', () => {
useStore.setState({
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 10 10 L 90 10 L 90 50 Z',
label: 'A',
color: '#06b6d4',
segmentation: [[10, 10, 90, 10, 90, 50]],
},
{
id: 'm2',
frameId: 'frame-1',
pathData: 'M 50 30 L 120 30 L 120 80 Z',
label: 'B',
color: '#ff0000',
segmentation: [[50, 30, 120, 30, 120, 80]],
},
],
});
render(<CanvasArea activeTool="area_remove" frame={frame} />);
expect(screen.getByText(/先点击要保留的主区域/)).toBeInTheDocument();
fireEvent.click(screen.getAllByTestId('konva-path')[0]);
expect(screen.getByText(/第一个是保留主区域/)).toBeInTheDocument();
});
it('applies the selected class to current-frame masks and marks saved masks dirty', () => {
useStore.setState({
activeTemplateId: '2',