feat: 完善 mask 编辑、传播平滑与开发重启闭环
功能增加: - 新增后端 /api/ai/smooth-mask 接口,对当前 mask polygon 执行 Chaikin 边缘平滑,并返回 polygon、bbox、area 与拓扑锚点。 - 在右侧实例属性面板加入边缘平滑强度和应用边缘平滑操作,应用后将 mask 标记为 draft/dirty,并通过正常保存链路落库。 - 保存标注与传播 seed 时保留 geometry_smoothing 元数据,自动传播 forward/backward 结果保存前应用同一平滑参数。 - 自动传播 seed signature 纳入平滑参数,修改平滑强度后会触发旧同源传播结果清理并重新传播。 - 支持跨帧跟随同一传播链 mask,AI 推送回工作区时保留当前帧视角。 Bugfix: - 修复中间帧向前传播时旧 forward/backward 同物体结果未被清理导致双重 mask 的问题。 - 修复 propagation worker 写入目标帧前只按旧方向清理导致 backward 重传残留的问题。 - 修复多边形顶点拖拽和编辑后画布视口异常移动的问题,并补充拖拽状态回写。 - 修复实例属性标题跟随全局 active class 而不是当前 mask label 的问题,并移除后端模型置信度展示。 开发与部署: - 新增 restart_dev_services.sh,使用 setsid 独立后台重启 FastAPI、Celery 和前端,写入 pid/log 文件并做 3000/8000 健康检查。 - 明确后端或 Celery 相关改动完成后需要运行重启脚本,保证运行态加载最新代码。 测试与文档: - 补充后端 smooth-mask、传播平滑 metadata、seed signature、传播去重方向覆盖等测试。 - 补充前端 OntologyInspector、VideoWorkspace、CanvasArea 和 api 契约测试,覆盖边缘平滑、传播参数、跨帧选区跟随和画布编辑行为。 - 更新 README、AGENTS、安装文档、前端元素审计、需求冻结、设计冻结和测试计划,记录当前真实行为与重启要求。
This commit is contained in:
@@ -6,11 +6,13 @@ import { OntologyInspector } from './OntologyInspector';
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
analyzeMask: vi.fn(),
|
||||
smoothMaskGeometry: vi.fn(),
|
||||
updateTemplate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
analyzeMask: apiMock.analyzeMask,
|
||||
smoothMaskGeometry: apiMock.smoothMaskGeometry,
|
||||
updateTemplate: apiMock.updateTemplate,
|
||||
}));
|
||||
|
||||
@@ -28,6 +30,17 @@ describe('OntologyInspector', () => {
|
||||
source: 'sam2.1_hiera_tiny',
|
||||
message: '已读取后端几何属性',
|
||||
});
|
||||
apiMock.smoothMaskGeometry.mockResolvedValue({
|
||||
polygons: [[[0.12, 0.12], [0.28, 0.12], [0.28, 0.28], [0.12, 0.28]]],
|
||||
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,
|
||||
topology_anchor_count: 4,
|
||||
topology_anchors: [],
|
||||
smoothing: { strength: 35, method: 'chaikin' },
|
||||
message: '已应用边缘平滑强度 35',
|
||||
});
|
||||
useStore.setState({
|
||||
templates: [
|
||||
{
|
||||
@@ -177,6 +190,8 @@ describe('OntologyInspector', () => {
|
||||
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 }],
|
||||
activeClass: { id: 'c3', name: '肿瘤', color: '#f97316', zIndex: 30 },
|
||||
activeClassId: 'c3',
|
||||
selectedMaskIds: ['m1'],
|
||||
masks: [
|
||||
{
|
||||
@@ -193,7 +208,11 @@ describe('OntologyInspector', () => {
|
||||
|
||||
render(<OntologyInspector />);
|
||||
|
||||
expect(await screen.findByText('0.8200')).toBeInTheDocument();
|
||||
expect(await screen.findByText('4 节点')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('胆囊')).toHaveLength(2);
|
||||
expect(screen.queryByText('肿瘤')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('后端模型置信度')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('0.8200')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('4 节点')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新提取拓扑锚点' }));
|
||||
expect(apiMock.analyzeMask).toHaveBeenLastCalledWith(
|
||||
@@ -202,4 +221,45 @@ describe('OntologyInspector', () => {
|
||||
{ extractSkeleton: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('applies backend edge smoothing 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' } });
|
||||
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: 'dirty',
|
||||
saved: false,
|
||||
metadata: { geometry_smoothing: { strength: 35, method: 'chaikin' } },
|
||||
})));
|
||||
expect(screen.getByText('已应用边缘平滑强度 35,请保存后生效')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user