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:
@@ -1,12 +1,33 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetStore } from '../test/storeTestUtils';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { OntologyInspector } from './OntologyInspector';
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
analyzeMask: vi.fn(),
|
||||
updateTemplate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
analyzeMask: apiMock.analyzeMask,
|
||||
updateTemplate: apiMock.updateTemplate,
|
||||
}));
|
||||
|
||||
describe('OntologyInspector', () => {
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
vi.clearAllMocks();
|
||||
apiMock.analyzeMask.mockResolvedValue({
|
||||
confidence: 0.82,
|
||||
confidence_source: 'model_score',
|
||||
topology_anchor_count: 4,
|
||||
topology_anchors: [],
|
||||
area: 0.1,
|
||||
bbox: [0, 0, 0.1, 0.1],
|
||||
source: 'sam2.1_hiera_tiny',
|
||||
message: '已读取后端几何属性',
|
||||
});
|
||||
useStore.setState({
|
||||
templates: [
|
||||
{
|
||||
@@ -49,6 +70,14 @@ describe('OntologyInspector', () => {
|
||||
useStore.setState({
|
||||
selectedMaskIds: ['m1'],
|
||||
masks: [
|
||||
{
|
||||
id: 'm2',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 Z',
|
||||
label: '未选区域',
|
||||
color: '#ffffff',
|
||||
saveStatus: 'draft',
|
||||
},
|
||||
{
|
||||
id: 'm1',
|
||||
annotationId: '99',
|
||||
@@ -66,7 +95,8 @@ describe('OntologyInspector', () => {
|
||||
fireEvent.click(screen.getByText('肝脏'));
|
||||
|
||||
expect(useStore.getState().activeClassId).toBe('c2');
|
||||
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
||||
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['m2', 'm1']);
|
||||
expect(useStore.getState().masks[1]).toEqual(expect.objectContaining({
|
||||
templateId: 't1',
|
||||
classId: 'c2',
|
||||
className: '肝脏',
|
||||
@@ -80,16 +110,59 @@ describe('OntologyInspector', () => {
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds custom classes locally without backend persistence', () => {
|
||||
const { container } = render(<OntologyInspector />);
|
||||
it('persists custom classes to the active backend template', async () => {
|
||||
apiMock.updateTemplate.mockResolvedValueOnce({
|
||||
id: 't1',
|
||||
name: '腹腔镜模板',
|
||||
classes: [
|
||||
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, category: '器官' },
|
||||
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 10, category: '器官' },
|
||||
{ id: 'custom-1', name: '新局部分类', color: '#06b6d4', zIndex: 30, category: '自定义' },
|
||||
],
|
||||
rules: [],
|
||||
});
|
||||
|
||||
render(<OntologyInspector />);
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 't1' } });
|
||||
const customSection = screen.getByText('自定义分类').parentElement!;
|
||||
fireEvent.click(within(customSection).getByRole('button'));
|
||||
fireEvent.change(screen.getByPlaceholderText('分类名称'), { target: { value: '新局部分类' } });
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('分类名称'), { key: 'Enter' });
|
||||
|
||||
expect(screen.getAllByText('新局部分类')).toHaveLength(2);
|
||||
expect(await screen.findByText('自定义分类已保存到后端模板')).toBeInTheDocument();
|
||||
expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
|
||||
classes: expect.arrayContaining([expect.objectContaining({ name: '新局部分类', category: '自定义' })]),
|
||||
}));
|
||||
expect(useStore.getState().activeClass).toEqual(expect.objectContaining({ name: '新局部分类' }));
|
||||
expect(useStore.getState().templates[0].classes).toHaveLength(2);
|
||||
expect(container).toHaveTextContent('2 个分类来自模板 + 1 个自定义');
|
||||
expect(useStore.getState().templates[0].classes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('loads selected mask properties from the backend analyzer', async () => {
|
||||
useStore.setState({
|
||||
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
|
||||
selectedMaskIds: ['m1'],
|
||||
masks: [
|
||||
{
|
||||
id: 'm1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
segmentation: [[10, 10, 20, 10, 20, 20]],
|
||||
metadata: { source: 'sam2.1_hiera_tiny', score: 0.82 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<OntologyInspector />);
|
||||
|
||||
expect(await screen.findByText('0.8200')).toBeInTheDocument();
|
||||
expect(screen.getByText('4 节点')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新提取拓扑锚点' }));
|
||||
expect(apiMock.analyzeMask).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: 'm1' }),
|
||||
expect.objectContaining({ id: 'frame-1' }),
|
||||
{ extractSkeleton: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user