feat: 完善分割工作区导入导出与管理流程
- 新增基于 JWT 当前用户的登录恢复、角色权限、用户管理、审计日志和演示出厂重置后台接口与前端管理页。 - 重串 GT_label 导出和 GT Mask 导入逻辑:导出保留类别真实 maskid,导入仅接受灰度或 RGB 等通道 maskid 图,支持未知 maskid 策略、尺寸最近邻拉伸和导入预览。 - 统一分割结果导出体验:默认当前帧,按项目抽帧顺序和 XhXXmXXsXXXms 时间戳命名 ZIP 与图片,补齐 GT/Pro/Mix/分开 Mask 输出和映射 JSON。 - 调整工作区左侧工具栏:移除创建点/线段入口,新增画笔、橡皮擦及尺寸控制,并按绘制、布尔、导入/AI 工具分组分隔。 - 扩展 Canvas 编辑能力:画笔按语义分类绘制并可自动并入连通选中 mask,橡皮擦对选中区域扣除,优化布尔操作、选区、撤销重做和保存状态联动。 - 优化自动传播时间轴显示:同一蓝色系按传播新旧递进变暗,老传播记录达到阈值后统一旧记录色,并维护范围选择与清空后的历史显示。 - 将 AI 智能分割入口替换为更明确的 AI 元素图标,并同步侧栏、工作区和 AI 页面入口表现。 - 完善模板分类、maskid 工具函数、分类树联动、遮罩透明度、边缘平滑和传播链同步相关前端状态。 - 扩展后端项目、媒体、任务、Dashboard、模板和传播 runner 的用户隔离、任务控制、进度事件与兼容处理。 - 补充前后端测试,覆盖用户管理、GT_label 往返导入导出、GT Mask 校验和预览、画笔/橡皮擦、时间轴传播历史、导出范围、WebSocket 与 API 封装。 - 更新 AGENTS、README 和 doc 文档,记录当前接口契约、实现状态、测试计划、安装说明和 maskid/GT_label 规则。
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetStore } from '../test/storeTestUtils';
|
||||
import { useStore } from '../store/useStore';
|
||||
@@ -67,6 +67,9 @@ describe('OntologyInspector', () => {
|
||||
expect(useStore.getState().activeTemplateId).toBe('t1');
|
||||
expect(screen.getByText('胆囊')).toBeInTheDocument();
|
||||
expect(screen.getByText('肝脏')).toBeInTheDocument();
|
||||
expect(screen.getByText('maskid:1')).toBeInTheDocument();
|
||||
expect(screen.getByText('maskid:2')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/z:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adjusts workspace mask opacity from above the semantic tree', () => {
|
||||
@@ -151,13 +154,86 @@ describe('OntologyInspector', () => {
|
||||
classId: 'c2',
|
||||
className: '肝脏',
|
||||
classZIndex: 10,
|
||||
classMaskId: 2,
|
||||
label: '肝脏',
|
||||
color: '#00ff00',
|
||||
saveStatus: 'dirty',
|
||||
saved: false,
|
||||
}));
|
||||
expect(screen.getByText('当前选中区域:')).toBeInTheDocument();
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('当前选中区域:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies class changes to the same propagation chain across frames', () => {
|
||||
useStore.setState({
|
||||
selectedMaskIds: ['annotation-10'],
|
||||
masks: [
|
||||
{
|
||||
id: 'annotation-10',
|
||||
annotationId: '10',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '旧标签',
|
||||
color: '#06b6d4',
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
},
|
||||
{
|
||||
id: 'annotation-11',
|
||||
annotationId: '11',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 1 1 Z',
|
||||
label: '旧传播标签',
|
||||
color: '#06b6d4',
|
||||
metadata: {
|
||||
source_annotation_id: 10,
|
||||
source_mask_id: 'annotation-10',
|
||||
propagation_seed_key: 'annotation:10',
|
||||
},
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
},
|
||||
{
|
||||
id: 'annotation-99',
|
||||
annotationId: '99',
|
||||
frameId: 'frame-3',
|
||||
pathData: 'M 2 2 Z',
|
||||
label: '无关区域',
|
||||
color: '#ffffff',
|
||||
metadata: { source_annotation_id: 99 },
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<OntologyInspector />);
|
||||
fireEvent.click(screen.getByText('肝脏'));
|
||||
|
||||
const updated = useStore.getState().masks;
|
||||
expect(updated.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
|
||||
classId: 'c2',
|
||||
className: '肝脏',
|
||||
classMaskId: 2,
|
||||
label: '肝脏',
|
||||
color: '#00ff00',
|
||||
saveStatus: 'dirty',
|
||||
saved: false,
|
||||
}));
|
||||
expect(updated.find((mask) => mask.id === 'annotation-11')).toEqual(expect.objectContaining({
|
||||
classId: 'c2',
|
||||
className: '肝脏',
|
||||
classMaskId: 2,
|
||||
label: '肝脏',
|
||||
color: '#00ff00',
|
||||
saveStatus: 'dirty',
|
||||
saved: false,
|
||||
}));
|
||||
expect(updated.find((mask) => mask.id === 'annotation-99')).toEqual(expect.objectContaining({
|
||||
label: '无关区域',
|
||||
color: '#ffffff',
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('persists custom classes to the active backend template', async () => {
|
||||
@@ -187,6 +263,59 @@ describe('OntologyInspector', () => {
|
||||
expect(useStore.getState().templates[0].classes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('persists dragged semantic class order as layer priority without changing maskid', async () => {
|
||||
apiMock.updateTemplate.mockResolvedValueOnce({
|
||||
id: 't1',
|
||||
name: '腹腔镜模板',
|
||||
classes: [
|
||||
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 20, maskId: 2, category: '器官' },
|
||||
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
|
||||
],
|
||||
rules: [],
|
||||
});
|
||||
useStore.setState({
|
||||
masks: [{
|
||||
id: 'm-liver',
|
||||
annotationId: '42',
|
||||
frameId: 'frame-1',
|
||||
classId: 'c2',
|
||||
className: '肝脏',
|
||||
classZIndex: 10,
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '肝脏',
|
||||
color: '#00ff00',
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
}],
|
||||
});
|
||||
|
||||
render(<OntologyInspector />);
|
||||
const liverButton = screen.getByRole('button', { name: /肝脏/ });
|
||||
const gallbladderButton = screen.getByRole('button', { name: /胆囊/ });
|
||||
const dataTransfer = {
|
||||
effectAllowed: '',
|
||||
dropEffect: '',
|
||||
setData: vi.fn(),
|
||||
getData: vi.fn(() => 'c2'),
|
||||
};
|
||||
|
||||
fireEvent.dragStart(liverButton, { dataTransfer });
|
||||
fireEvent.dragOver(gallbladderButton, { dataTransfer });
|
||||
fireEvent.drop(gallbladderButton, { dataTransfer });
|
||||
|
||||
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
|
||||
classes: [
|
||||
expect.objectContaining({ id: 'c2', zIndex: 20, maskId: 2 }),
|
||||
expect.objectContaining({ id: 'c1', zIndex: 10, maskId: 1 }),
|
||||
],
|
||||
})));
|
||||
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
||||
classZIndex: 20,
|
||||
saveStatus: 'dirty',
|
||||
saved: false,
|
||||
}));
|
||||
});
|
||||
|
||||
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 }],
|
||||
@@ -214,15 +343,40 @@ describe('OntologyInspector', () => {
|
||||
expect(screen.queryByText('后端模型置信度')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('0.8200')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('4 节点')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新提取拓扑锚点' }));
|
||||
expect(screen.queryByRole('button', { name: '重新提取拓扑锚点' })).not.toBeInTheDocument();
|
||||
expect(apiMock.analyzeMask).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: 'm1' }),
|
||||
expect.objectContaining({ id: 'frame-1' }),
|
||||
{ extractSkeleton: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('applies backend edge smoothing to the selected mask and marks it dirty', async () => {
|
||||
it('ignores aborted mask analysis requests without showing an error', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
apiMock.analyzeMask.mockRejectedValueOnce({ code: 'ECONNABORTED', message: 'Request aborted' });
|
||||
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]],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<OntologyInspector />);
|
||||
|
||||
await waitFor(() => expect(apiMock.analyzeMask).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.queryByText('后端属性读取失败')).not.toBeInTheDocument());
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('previews backend edge smoothing while moving the slider without marking the mask dirty', async () => {
|
||||
useStore.setState({
|
||||
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
|
||||
selectedMaskIds: ['m1'],
|
||||
@@ -244,13 +398,100 @@ describe('OntologyInspector', () => {
|
||||
render(<OntologyInspector />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用边缘平滑' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.smoothMaskGeometry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'm1' }),
|
||||
expect.objectContaining({ id: 'frame-1' }),
|
||||
35,
|
||||
));
|
||||
await waitFor(() => expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
||||
pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z',
|
||||
segmentation: [[12, 12, 28, 12, 28, 28, 12, 28]],
|
||||
bbox: [12, 12, 16, 16],
|
||||
area: 256,
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
metadata: { geometry_smoothing_preview: { strength: 35, method: 'chaikin' } },
|
||||
})));
|
||||
expect(screen.getByText('已应用边缘平滑强度 35,预览中,点击应用后写入当前 mask。')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('debounces backend edge smoothing preview while dragging the slider', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
useStore.setState({
|
||||
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
|
||||
selectedMaskIds: ['m1'],
|
||||
masks: [
|
||||
{
|
||||
id: 'm1',
|
||||
annotationId: '10',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 30 10 L 30 30 Z',
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
segmentation: [[10, 10, 30, 10, 30, 30]],
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<OntologyInspector />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '15' } });
|
||||
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '25' } });
|
||||
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
|
||||
|
||||
expect(screen.getByText('正在等待停止拖动后生成边缘平滑预览...')).toBeInTheDocument();
|
||||
expect(apiMock.smoothMaskGeometry).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(219);
|
||||
});
|
||||
expect(apiMock.smoothMaskGeometry).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.smoothMaskGeometry).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.smoothMaskGeometry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'm1' }),
|
||||
expect.objectContaining({ id: 'frame-1' }),
|
||||
35,
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies a previewed edge smoothing result to the selected mask and marks it dirty', async () => {
|
||||
useStore.setState({
|
||||
frames: [{ id: 'frame-1', projectId: 'p1', index: 0, url: '/1.jpg', width: 100, height: 100 }],
|
||||
selectedMaskIds: ['m1'],
|
||||
masks: [
|
||||
{
|
||||
id: 'm1',
|
||||
annotationId: '10',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 30 10 L 30 30 Z',
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
segmentation: [[10, 10, 30, 10, 30, 30]],
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<OntologyInspector />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '应用边缘平滑' })).not.toBeDisabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用边缘平滑' }));
|
||||
|
||||
await waitFor(() => expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
||||
pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z',
|
||||
segmentation: [[12, 12, 28, 12, 28, 28, 12, 28]],
|
||||
@@ -258,8 +499,87 @@ describe('OntologyInspector', () => {
|
||||
area: 256,
|
||||
saveStatus: 'dirty',
|
||||
saved: false,
|
||||
metadata: { geometry_smoothing: { strength: 35, method: 'chaikin' } },
|
||||
})));
|
||||
expect(screen.getByText('已应用边缘平滑强度 35,请保存后生效')).toBeInTheDocument();
|
||||
expect(useStore.getState().masks[0].metadata?.geometry_smoothing).toBeUndefined();
|
||||
expect(apiMock.smoothMaskGeometry).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
expect(screen.getByText('已应用边缘平滑强度 35,已变为新的 mask,强度已重置为 0,请保存后生效')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies smoothing to linked propagation masks as one undoable geometry edit', async () => {
|
||||
useStore.setState({
|
||||
frames: [
|
||||
{ id: 'frame-0', projectId: 'p1', index: 0, url: '/0.jpg', width: 100, height: 100 },
|
||||
{ id: 'frame-1', projectId: 'p1', index: 1, url: '/1.jpg', width: 100, height: 100 },
|
||||
{ id: 'frame-2', projectId: 'p1', index: 2, url: '/2.jpg', width: 100, height: 100 },
|
||||
],
|
||||
selectedMaskIds: ['seed-mask'],
|
||||
masks: [
|
||||
{
|
||||
id: 'seed-mask',
|
||||
annotationId: '10',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 30 10 L 30 30 Z',
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
segmentation: [[10, 10, 30, 10, 30, 30]],
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
},
|
||||
{
|
||||
id: 'prop-backward',
|
||||
annotationId: '11',
|
||||
frameId: 'frame-0',
|
||||
pathData: 'M 11 11 L 31 11 L 31 31 Z',
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
segmentation: [[11, 11, 31, 11, 31, 31]],
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
metadata: { source_annotation_id: 10, source_mask_id: 'annotation-10', propagated_from_frame_id: 10 },
|
||||
},
|
||||
{
|
||||
id: 'prop-forward',
|
||||
annotationId: '12',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 12 12 L 32 12 L 32 32 Z',
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
segmentation: [[12, 12, 32, 12, 32, 32]],
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
metadata: { source_annotation_id: 10, source_mask_id: 'annotation-10', propagated_from_frame_id: 10 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<OntologyInspector />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('边缘平滑强度'), { target: { value: '35' } });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '应用边缘平滑' })).not.toBeDisabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用边缘平滑' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.smoothMaskGeometry).toHaveBeenCalledTimes(3));
|
||||
await waitFor(() => expect(useStore.getState().masks).toEqual([
|
||||
expect.objectContaining({ id: 'seed-mask', pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z', saveStatus: 'dirty', saved: false }),
|
||||
expect.objectContaining({ id: 'prop-backward', pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z', saveStatus: 'dirty', saved: false }),
|
||||
expect.objectContaining({ id: 'prop-forward', pathData: 'M 12 12 L 28 12 L 28 28 L 12 28 Z', saveStatus: 'dirty', saved: false }),
|
||||
]));
|
||||
expect(useStore.getState().masks.every((mask) => !mask.metadata?.geometry_smoothing)).toBe(true);
|
||||
expect(screen.getByText('已应用边缘平滑强度 35,已同步应用到传播链 3 个对应 mask,强度已重置为 0,请保存后生效')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
useStore.getState().undoMasks();
|
||||
});
|
||||
expect(useStore.getState().masks.map((mask) => mask.pathData)).toEqual([
|
||||
'M 10 10 L 30 10 L 30 30 Z',
|
||||
'M 11 11 L 31 11 L 31 31 Z',
|
||||
'M 12 12 L 32 12 L 32 32 Z',
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
useStore.getState().redoMasks();
|
||||
});
|
||||
expect(useStore.getState().masks.every((mask) => mask.pathData === 'M 12 12 L 28 12 L 28 28 L 12 28 Z')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user