完善项目导入、模板与分割工作区交互

- 增强 DICOM/视频项目导入与演示数据:DICOM 按文件名自然顺序处理,导入后展示上传与解析任务进度,恢复演示出厂设置保留演示视频和演示 DICOM 项目,并补充 demo media seed 逻辑。

- 完善项目管理:项目支持重命名、删除、复制,删除使用站内确认弹窗,复制支持新项目重置和全内容复制,DICOM 项目不显示生成帧入口。

- 完善 GT Mask 与导出链路:只支持 8-bit maskid 图导入,非法/全背景图明确拒绝,尺寸自动适配,高精度 polygon 回显;统一导出默认当前帧,GT_label 使用 uint8 和真实 maskid,待分类 maskid 0 与背景一致。

- 完善分割工作区交互:新增画笔和橡皮擦并支持尺寸控制,移除创建点/线段入口,工具栏按类别分隔,AI 智能分割使用明确 AI 图标,取消黄色 seed point,清空/删除传播 mask 后同步清理空帧时间轴状态。

- 完善传播与时间轴:自动传播使用 SAM 2.1 权重任务,参考帧无遮罩时提示,传播历史按同一蓝色系递进变暗,删除/清空传播链时保留人工或独立 AI 标注来源。

- 完善模板库:新增头颈部 CT 分割默认模板,所有模板保留 maskid 0 待分类,支持鼠标复制模板、拖拽层级、JSON 批量导入预览、删除 label 和站内删除确认。

- 完善用户与高风险确认:用户改密码、删除用户、恢复演示出厂设置和清空人工/AI 标注帧均改为站内确认交互,避免浏览器原生 prompt/confirm。

- 补充前后端测试与文档:更新项目、模板、GT 导入、导出、传播、DICOM、用户管理等测试,并同步 README、AGENTS 和 doc 下实现/契约/测试计划文档。
This commit is contained in:
2026-05-03 17:11:59 +08:00
parent afcddfaeb9
commit 481ffa5b67
47 changed files with 3650 additions and 676 deletions

View File

@@ -192,8 +192,8 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
- 创建数据库表; - 创建数据库表;
- 检查/创建 MinIO bucket `seg-media` - 检查/创建 MinIO bucket `seg-media`
- 测试 Redis 连接; - 测试 Redis 连接;
- 后台 seed 默认模板; - 后台 seed 默认模板包括“腹腔镜胆囊切除术”和“头颈部CT分割”
- 如果本地存在 `Data_MyVideo_1.mp4`,后台 seed 默认演示项目并拆前 100 帧。 - 如果本地存在 `demo_video_path` 和配置的 `demo_dicom_dir` DICOM 序列,后台 seed 默认演示视频项目和演示 DICOM 项目DICOM 会按文件名自然顺序生成帧。
- API 路由包括: - API 路由包括:
- `POST /api/auth/login` - `POST /api/auth/login`
- `GET /api/auth/me` - `GET /api/auth/me`
@@ -237,18 +237,18 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
## 主要业务流程 ## 主要业务流程
1. 登录:`Login.tsx` 调用 `POST /api/auth/login`,后端用 `users` 表和密码哈希校验凭证,默认启动时会种子化开发管理员 `admin / 123456`;成功后返回签名 JWT`GET /api/auth/me` 可读取当前用户;角色包括 `admin``annotator``viewer`,写入类业务接口要求 `admin/annotator`,用户管理后台要求 `admin` 1. 登录:`Login.tsx` 调用 `POST /api/auth/login`,后端用 `users` 表和密码哈希校验凭证,默认启动时会种子化开发管理员 `admin / 123456`;成功后返回签名 JWT`GET /api/auth/me` 可读取当前用户;角色包括 `admin``annotator``viewer`,写入类业务接口要求 `admin/annotator`,用户管理后台要求 `admin`
2. 用户管理:`Sidebar` 仅对 `admin` 显示“用户管理”,`UserAdmin.tsx` 调用 `/api/admin/users` 新增、停用/启用、改角色、改密码和删除无项目用户,并调用 `/api/admin/audit-logs` 展示登录和管理操作审计;演示部署可通过“恢复演示出厂设置”二次确认后调用 `/api/admin/demo-factory-reset`,清空演示数据,只保留默认 admin 和一个尚未生成帧的演示视频项目。 2. 用户管理:`Sidebar` 仅对 `admin` 显示“用户管理”,`UserAdmin.tsx` 调用 `/api/admin/users` 新增、停用/启用、改角色、改密码和删除无项目用户,并调用 `/api/admin/audit-logs` 展示登录和管理操作审计;演示部署可通过“恢复演示出厂设置”二次确认后调用 `/api/admin/demo-factory-reset`,清空演示数据,只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目。
3. 项目管理:`ProjectLibrary.tsx` 调用项目 API 创建项目、拉取列表、删除项目删除当前项目后会清空工作区当前项目、帧、mask 和选区。 3. 项目管理:`ProjectLibrary.tsx` 调用项目 API 创建项目、拉取列表、重命名项目、复制项目和删除项目;项目卡片删除按钮旁提供复制入口,复制时可选择“新项目重置”(复制项目媒体和已生成帧序列,但清空标注/mask或“全内容复制”复制项目、帧序列、标注和关联 mask 元数据),任务运行历史不复制删除当前项目后会清空工作区当前项目、帧、mask 和选区。
4. 上传资源:视频走 `/api/media/upload`,只上传源文件并关联项目,不自动拆帧;DICOM 批量走 `/api/media/upload/dicom` 4. 上传资源:视频走 `/api/media/upload`,只上传源文件并关联项目,不自动拆帧;项目库在视频上传期间显示导入进度条、百分比和已上传字节。只有视频项目在尚未生成帧、未处于项目名称编辑状态且未解析中时显示“生成帧”DICOM 项目不显示生成帧入口DICOM 批量走 `/api/media/upload/dicom`,前端和后端都会按文件名自然顺序排序 `.dcm` 文件,避免 `10.dcm` 排在 `2.dcm` 前导致切片错位DICOM 上传期间显示导入进度条、本次有效文件数量和已上传字节,上传完成后轮询解析任务进度直到完成、失败或取消
5. 生成帧入队:用户在项目库点击“生成帧”,选择目标 FPS 后前端调用 `/api/media/parse`;后端创建 `ProcessingTask` 并投递 Celery接口支持 `parse_fps``max_frames``target_width` 标准帧序列参数;项目库和模板库的成功/失败短反馈使用非阻塞 `TransientNotice`,会自动消失。 5. 生成帧入队:用户在项目库点击“生成帧”,选择目标 FPS 后前端调用 `/api/media/parse`;后端创建 `ProcessingTask` 并投递 Celery接口支持 `parse_fps``max_frames``target_width` 标准帧序列参数;项目库和模板库的成功/失败短反馈使用非阻塞 `TransientNotice`,会自动消失。
6. worker 执行Celery worker 用 FFmpeg 优先拆视频帧,失败后用 OpenCV fallbackDICOM 使用 pydicom视频帧`frame_%06d.jpg` 连续命名并记录 `timestamp_ms``source_frame_number` 和任务 `frame_sequence` 元数据。 6. worker 执行Celery worker 用 FFmpeg 优先拆视频帧,失败后用 OpenCV fallbackDICOM 使用 pydicomworker 下载和读取 DICOM 时也按文件名自然顺序排序;视频/DICOM 解析完成后都`frame_%06d.jpg` 连续生成项目帧序列,并记录 `timestamp_ms``source_frame_number` 和任务 `frame_sequence` 元数据后续工作区、时间轴、AI 传播、标注和导出共用同一套帧序列逻辑
7. 帧展示:`VideoWorkspace.tsx` 调用 `/api/projects/{id}/frames``CanvasArea.tsx``FrameTimeline.tsx` 显示当前帧与时间轴缩略图;`CanvasArea` 会按容器和帧尺寸默认居中放大底图并保留边距;`FrameTimeline` 会根据已保存标注回显到 `Mask.metadata` 的传播来源,把自动传播生成的帧在视频处理进度条显示为蓝色区段,人工/AI 标注帧显示红色竖线;每次自动传播成功处理帧后,`VideoWorkspace` 会把本次传播范围作为当前会话历史片段传给 `FrameTimeline`,在视频处理进度条上叠加同一蓝色系、最新传播最亮、旧传播逐次变暗且第 5 次及更早统一为阈值旧记录色的纯色条;视频处理进度条和红/蓝标识可点击跳转到对应帧;底部缩略图中人工/AI 标注帧用红色边框、自动传播/推理帧用蓝色边框,同一帧同时具备两种状态时红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍以青色外框高亮优先;若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注;进入自动传播、清空遮罩或特定范围帧导出选择模式时,播放进度条和视频处理进度条会显示黄色范围框,并可点击/拖拽选择起止帧;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。 7. 帧展示:`VideoWorkspace.tsx` 调用 `/api/projects/{id}/frames``CanvasArea.tsx``FrameTimeline.tsx` 显示当前帧与时间轴缩略图;`CanvasArea` 会按容器和帧尺寸默认居中放大底图并保留边距;`FrameTimeline` 会根据已保存标注回显到 `Mask.metadata` 的传播来源,把自动传播生成的帧在视频处理进度条显示为蓝色区段,人工/AI 标注帧显示红色竖线;每次自动传播成功处理帧后,`VideoWorkspace` 会把本次传播范围作为当前会话历史片段传给 `FrameTimeline`,在视频处理进度条上叠加同一蓝色系、最新传播最亮、旧传播逐次变暗且第 5 次及更早统一为阈值旧记录色的纯色条;传播历史条只显示当前仍有自动传播 mask 的帧,删除 mask 或清空范围后会按剩余传播 mask 自动裁剪,空帧不保留红/蓝颜色;视频处理进度条和红/蓝标识可点击跳转到对应帧;底部缩略图中人工/AI 标注帧用红色边框、自动传播/推理帧用蓝色边框,同一帧同时具备两种状态时红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍以青色外框高亮优先;若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注;进入自动传播、清空遮罩或特定范围帧导出选择模式时,播放进度条和视频处理进度条会显示黄色范围框,并可点击/拖拽选择起止帧;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。
8. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、画笔和橡皮擦生成/编辑 polygon mask多边形可按 Enter 或点击首节点闭合;画笔/橡皮擦可在左侧工具栏调整大小,画笔要求右侧语义分类树已有选中类别,画出的圆形连续笔触会在鼠标松开时一次性 union 成 mask若与当前选中 mask 连通则自动合并到该 mask橡皮擦要求已选中 mask 并在松开时从该 mask 中 difference 扣除;未选中特定 mask 时Canvas 会按右侧语义分类树拖拽得到的内部覆盖优先级从低到高渲染 mask使高优先级类别显示在上层Canvas 左上角工具上下文提示会在切换工具或操作状态变化时短暂显示,数秒后自动隐藏,避免长期遮挡底图;工具栏有“调整多边形”入口,左侧 `ToolsPalette` 使用紧凑垂直布局并在高度不足时自身滚动,且在“重叠区域去除”之后提供紫色“导入 GT Mask”入口工作区左侧工具栏不展示 AI 页的正向选点、反向选点和边界框选,也不重复放置撤销/重做;点击 mask 后可按住顶点直接拖动并实时更新 polygon顶点/seed point 拖拽结束不会触发 Stage 平移或重置 Canvas 视口;也可删除 polygon 顶点、通过边中点或双击边界插入新顶点,并能选择编辑多 polygon mask 的单个子区域;选中整块 mask 可用 Delete/Backspace 删除,已保存 mask 会同步后端删除;区域合并/去除会隐藏编辑手柄并显示已选数量,第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,使用 `polygon-clipping` 做 union/difference内含去除结果用 even-odd 规则渲染 holeZustand 维护 `maskHistory/maskFuture` 支持撤销/重做。 8. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、画笔和橡皮擦生成/编辑 polygon mask多边形可按 Enter 或点击首节点闭合;画笔/橡皮擦可在左侧工具栏调整大小,画笔要求右侧语义分类树已有选中类别,画出的圆形连续笔触会在鼠标松开时一次性 union 成 mask若与当前选中 mask 连通则自动合并到该 mask橡皮擦要求已选中 mask 并在松开时从该 mask 中 difference 扣除;普通 mask 和导入 mask 都不显示黄色 seed point也不提供 seed point 拖动;未选中特定 mask 时Canvas 会按右侧语义分类树拖拽得到的内部覆盖优先级从低到高渲染 mask使高优先级类别显示在上层Canvas 左上角工具上下文提示会在切换工具或操作状态变化时短暂显示,数秒后自动隐藏,避免长期遮挡底图;工具栏有“调整多边形”入口,左侧 `ToolsPalette` 使用紧凑垂直布局并在高度不足时自身滚动,且在“重叠区域去除”之后提供紫色“导入 GT Mask”入口工作区左侧工具栏不展示 AI 页的正向选点、反向选点和边界框选,也不重复放置撤销/重做;点击 mask 后可按住顶点直接拖动并实时更新 polygon顶点拖拽结束不会触发 Stage 平移或重置 Canvas 视口;也可删除 polygon 顶点、通过边中点或双击边界插入新顶点,并能选择编辑多 polygon mask 的单个子区域;选中整块 mask 可用 Delete/Backspace 删除,已保存 mask 会同步后端删除;删除传播 seed 或任一传播结果时会扩展删除同一传播链上的自动传播 mask但保留其他帧独立 AI 推理或人工标注 mask区域合并/去除会隐藏编辑手柄并显示已选数量,第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,使用 `polygon-clipping` 做 union/difference内含去除结果用 even-odd 规则渲染 holeZustand 维护 `maskHistory/maskFuture` 支持撤销/重做。
9. AI 分割:侧栏和工作区工具栏的 AI 智能分割入口使用 Bot + Sparkles 组合图标强化 AI 识别;前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选AI 画布会按容器和当前帧尺寸默认居中放大底图并保留边距;工作区和 AI 页面都可点击已有提示点删除单点AI 页面也可删除最近锚点、删除选中候选或清空本页锚点;这些删除入口会限制在当前提示点/本页 AI 候选范围内,避免误删工作区已有 mask。SAM 2.1 框选会建立候选 mask后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 maskAI 页面框选会先固化 `promptBox`,执行分割时只框选发送 `box` prompt框选后继续加正/反点发送 `interactive` prompt重复执行高精度分割会替换上一次 AI 页候选,只保留最新一个候选。包含反向点时工作区会传 `options.auto_filter_background=true``min_score=0.05`,如果后端过滤为空则移除旧候选 mask。后端 `ai.py` 期望按 `image_id``prompt_type``prompt_data``model` 和可选 `options` 调用 SAM registry。当前 registry 暴露 `sam2.1_hiera_tiny``sam2.1_hiera_small``sam2.1_hiera_base_plus``sam2.1_hiera_large`,并兼容 `sam2` 作为 tiny 别名;`model=sam3` 会被拒绝,`semantic` 文本提示也被禁用。SAM 2.1 支持点/框/interactive/自动分割和 video predictor 传播多候选默认只采用最高分区域避免重叠候选同时显示AI 页面只渲染本页最新生成的候选 mask不会把工作区已有 mask 带入 AI 画布AI 页面生成的 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接改标签,推送到工作区会切到“调整多边形”并保留选择和当前帧视角。`options.crop_to_prompt` 可对点/框/interactive prompt 做局部裁剪推理并回映射,`options.auto_filter_background` 可按分数和负向点过滤结果。 9. AI 分割:侧栏和工作区工具栏的 AI 智能分割入口使用 Bot + Sparkles 组合图标强化 AI 识别;前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选AI 画布会按容器和当前帧尺寸默认居中放大底图并保留边距;工作区和 AI 页面都可点击已有提示点删除单点AI 页面也可删除最近锚点、删除选中候选或清空本页锚点;这些删除入口会限制在当前提示点/本页 AI 候选范围内,避免误删工作区已有 mask。SAM 2.1 框选会建立候选 mask后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 maskAI 页面框选会先固化 `promptBox`,执行分割时只框选发送 `box` prompt框选后继续加正/反点发送 `interactive` prompt重复执行高精度分割会替换上一次 AI 页候选,只保留最新一个候选。包含反向点时工作区会传 `options.auto_filter_background=true``min_score=0.05`,如果后端过滤为空则移除旧候选 mask。后端 `ai.py` 期望按 `image_id``prompt_type``prompt_data``model` 和可选 `options` 调用 SAM registry。当前 registry 暴露 `sam2.1_hiera_tiny``sam2.1_hiera_small``sam2.1_hiera_base_plus``sam2.1_hiera_large`,并兼容 `sam2` 作为 tiny 别名;`model=sam3` 会被拒绝,`semantic` 文本提示也被禁用。SAM 2.1 支持点/框/interactive/自动分割和 video predictor 传播多候选默认只采用最高分区域避免重叠候选同时显示AI 页面只渲染本页最新生成的候选 mask不会把工作区已有 mask 带入 AI 画布AI 页面生成的 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接改标签,推送到工作区会切到“调整多边形”并保留选择和当前帧视角。`options.crop_to_prompt` 可对点/框/interactive prompt 做局部裁剪推理并回映射,`options.auto_filter_background` 可按分数和负向点过滤结果。
10. 视频片段传播:工作区以当前打开帧作为参考帧,使用该帧全部 mask 作为 seed并用传播起始帧和传播结束帧指定追踪范围用户可直接修改数字框也可点击“自动传播”进入时间轴范围选择模式在播放进度条或视频处理进度条上点击/拖拽选择范围,再点击“开始传播”。工作区顶栏有独立“传播权重”选择器,可为本次传播二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,也不影响 AI 单帧分割权重;前端提交传播前会先保存当前项目中的 draft/dirty mask使 seed 优先带稳定的后端 `source_annotation_id`,再按传播权重 id、seed mask、seed 来源 id 和前/后方向组装 `steps` 并调用 `POST /api/ai/propagate/task` 创建 `propagate_masks` 后台任务;后端入队时会规范化/校验权重 id 并把规范化后的 id 写入任务 payload/resultCelery worker 顺序执行各 step避免多个视频 tracker 并发抢占 GPU每个 step 会根据 seed 来源 id、方向和 seed 签名做幂等判断,同权重且未改变的 seed 直接跳过,已改变或换用其他权重的 seed 会先删除同源旧自动传播标注再重传;旧版本用前端临时 `source_mask_id` 生成的传播标注会按同一参考帧、方向和语义信息兼容清理;中间帧人工新增/修改同一物体后重新传播时,后端会在写入目标帧新结果前按语义和空间重叠清理旧传播结果,且写入前清理不受旧结果传播方向限制;后端按项目帧序列下载片段帧,当前使用所选 SAM 2.1 权重变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,并把后续帧结果保存为 `Annotation`;若历史或外部 seed 仍带 `geometry_smoothing`forward/backward 两个方向的传播结果保存前仍会应用同一参数;当前工作区平滑按钮应用后会直接改写实际 polygon后续传播以新几何参与签名和追踪。工作区轮询 `GET /api/tasks/{task_id}` 展示进度并刷新标注Dashboard 也能显示/取消/重试传播任务。 10. 视频片段传播:工作区以当前打开帧作为参考帧,使用该帧全部 mask 作为 seed并用传播起始帧和传播结束帧指定追踪范围如果当前参考帧没有 mask点击开始传播会提示“当前参考帧无遮罩”不会提交任务或保存其它帧标注用户可直接修改数字框,也可点击“自动传播”进入时间轴范围选择模式,在播放进度条或视频处理进度条上点击/拖拽选择范围,再点击“开始传播”。工作区顶栏有独立“传播权重”选择器,可为本次传播二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,也不影响 AI 单帧分割权重;前端提交传播前保存当前参考帧中的 draft/dirty mask使 seed 优先带稳定的后端 `source_annotation_id`,再按传播权重 id、seed mask、seed 来源 id 和前/后方向组装 `steps` 并调用 `POST /api/ai/propagate/task` 创建 `propagate_masks` 后台任务;后端入队时会规范化/校验权重 id 并把规范化后的 id 写入任务 payload/resultCelery worker 顺序执行各 step避免多个视频 tracker 并发抢占 GPU每个 step 会根据 seed 来源 id、方向和 seed 签名做幂等判断,同权重且未改变的 seed 直接跳过,已改变或换用其他权重的 seed 会先删除同源旧自动传播标注再重传;旧版本用前端临时 `source_mask_id` 生成的传播标注会按同一参考帧、方向和语义信息兼容清理;中间帧人工新增/修改同一物体后重新传播时,后端会在写入目标帧新结果前按语义和空间重叠清理旧传播结果,且写入前清理不受旧结果传播方向限制;后端按项目帧序列下载片段帧,当前使用所选 SAM 2.1 权重变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,并把后续帧结果保存为 `Annotation`;若历史或外部 seed 仍带 `geometry_smoothing`forward/backward 两个方向的传播结果保存前仍会应用同一参数;当前工作区平滑按钮应用后会直接改写实际 polygon后续传播以新几何参与签名和追踪。工作区轮询 `GET /api/tasks/{task_id}` 展示进度并刷新标注Dashboard 也能显示/取消/重试传播任务。
11. GT 导入:工作区左侧工具栏“导入 GT Mask”调用 `/api/ai/import-gt-mask`;选择文件后前端会显示导入结果预览,并让用户决定未知 maskid 处理方式,可舍弃未知类别,也可导入为“未定义类别”等待重新命名。后端用 `cv2.IMREAD_UNCHANGED` 读取 mask,避免导出的低数值/16-bit `GT_label图` 被压成 0GT 图片必须是灰度 maskid 图,或 RGB 三通道完全相同的 `[X,X,X]` maskid 图0 为背景、X 为 maskid不符合时返回明确错误;灰度/RGB 等通道图按模板 `maskId` 匹配类别,超出现有类别时按 `unknown_color_policy` 处理;如果 mask 图片尺寸和当前帧不同,会按当前帧长宽最近邻拉伸后再提取区域;每个连通域生成 polygon 标注,并用 distance transform 生成 seed point;前端回显 seed point,拖动后可归档更新 11. GT 导入:工作区左侧工具栏“导入 GT Mask”调用 `/api/ai/import-gt-mask`;选择文件后前端会显示导入结果预览,并让用户决定未知 maskid 处理方式,可舍弃未知类别,也可导入为“未定义类别”等待重新命名。后端用 `cv2.IMREAD_UNCHANGED` 读取 mask 并校验 dtypeGT 图片必须是 8-bit 灰度 maskid 图,或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图0 为背景、X 为 1-255 的 maskid16-bit/uint16 GT_label、普通彩色类别图和全背景 0 图都会返回明确错误全背景图错误信息固定为“GT Mask 图片中没有非背景 maskid 区域。”;灰度/RGB 等通道图按模板 `maskId` 匹配类别,超出现有类别时按 `unknown_color_policy` 处理;如果 mask 图片尺寸和当前帧不同,会按当前帧长宽最近邻拉伸后再提取区域;每个连通域用高精度 contour 生成 polygon 标注,保留更多边界点并设置点数上限避免拖慢前端;导入结果与普通 mask 共用拓扑锚点统计、边缘平滑、顶点编辑、分类和保存链路;后端仍可写入 distance transform seed point 供数据兼容,但前端不显示或拖动 seed point。
12. 模板管理:`TemplateRegistry.tsx` 管理分类、颜色、maskid 和内部覆盖顺序;`OntologyInspector.tsx` 在工作区显示当前模板分类树也支持拖拽调整内部覆盖顺序。maskid 只作为 GT_label/类别 ID不参与排序。 12. 模板管理:`TemplateRegistry.tsx` 管理分类、颜色、maskid 和内部覆盖顺序;所有新建、复制、批量导入和后端返回的模板都会归一化包含黑色 `[0,0,0]``maskid: 0` 的“待分类”保留类,该类固定在语义分类树最后,不能删除,也不能拖拽到更高层级;批量导入 JSON 会先预览分类数量、maskid 分配起点和缺失颜色提示语法或结构错误以内联错误展示系统默认模板包括“腹腔镜胆囊切除术”和“头颈部CT分割”恢复演示出厂设置只删除用户私有模板系统默认模板会保留模板库“生效中模板架构清单”里的每个模板卡片支持鼠标点击复制复制会创建当前用户私有副本并保留分类名称、颜色、maskid、内部层级和规则同时重建类别内部 id模板库详情页的分类区标题为“语义分类树拖拽调层级右上角提供“+ 新建分类”,每个分类行右侧用垃圾桶图标删除该 label不再展示“未分类/批量导入/模板名”等来源标签;如果项目中的已保存 mask 引用了当前模板里已被删除的类别,工作区打开项目回显时会把该 mask 降级为 `maskid: 0` 的“待分类”mask 并标记为待保存;模板库详情页和编辑弹窗都支持拖拽调整语义类别层级顺序,拖拽会重算 `zIndex` 并保存到后端,保存后当前详情页会立刻刷新;`OntologyInspector.tsx` 在工作区显示当前模板分类树也支持拖拽调整内部覆盖顺序。maskid 只作为 GT_label/类别 ID不参与排序。
13. 导出:工作区使用统一“分割结果导出”入口,导出前先保存待归档 mask用户可选择整体视频、特定范围帧或当前图片默认导出范围为当前图片并勾选分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图。选择特定范围帧时,可直接修改起止帧输入框,也可在播放进度条或视频处理进度条上点击/拖拽选择导出范围;选择 Mix_label 时可调透明度,默认 0.3,并显示当前/待导出第一帧预览。下载 ZIP 文件名使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`,项目名来自 `Project.name` 并替换文件系统不安全字符,时间戳格式为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based 顺序而非原视频帧号。后端保留兼容的 COCO JSON 和 PNG mask ZIP 接口,同时新增统一结果 ZIP统一 ZIP 固定包含 `annotations_coco.json``maskid_GT像素值_类别映射.json``原始图片/`;导出时 GT_label 像素值使用类别真实 `maskid`缺失 `maskid` 的旧标注才补下一个可用值,保证导出的 GT_label 可按同一模板再导入;选择分开 mask 时输出 `分开Mask分割结果/{视频名称_时间戳_项目帧序号}_分别导出/{视频名称_时间戳_项目帧序号}_{类别名称}_maskid{maskid}.png`,同一帧同一类别合并为一张图;选择 GT_label/Pro_label/Mix_label 时分别输出 `GT_label图/{视频名称_时间戳_项目帧序号}.png``Pro_label彩色分割结果/{视频名称_时间戳_项目帧序号}.png``Mix_label重叠覆盖彩色分割结果/{视频名称_时间戳_项目帧序号}.png`。maskid 不参与覆盖排序GT_label/Pro_label/Mix_label 重叠区域覆盖顺序由内部拖拽排序字段决定,并与未选中状态下的 Canvas 显示顺序一致。 13. 导出:工作区使用统一“分割结果导出”入口,导出前先保存待归档 mask用户可选择整体视频、特定范围帧或当前图片默认导出范围为当前图片并勾选分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图。选择特定范围帧时,可直接修改起止帧输入框,也可在播放进度条或视频处理进度条上点击/拖拽选择导出范围;选择 Mix_label 时可调透明度,默认 0.3,并显示当前/待导出第一帧预览。下载 ZIP 文件名使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`,项目名来自 `Project.name` 并替换文件系统不安全字符,时间戳格式为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based 顺序而非原视频帧号。后端保留兼容的 COCO JSON 和 PNG mask ZIP 接口,同时新增统一结果 ZIP统一 ZIP 固定包含 `annotations_coco.json``maskid_GT像素值_类别映射.json``原始图片/`;导出时 GT_label 固定写 8-bit uint8 PNG像素值使用类别真实 `maskid`其中 `maskid: 0` 的“待分类”与背景同为 0Pro_label 中也与背景同为黑色 `[0,0,0]`,缺失 `maskid` 的旧标注才补下一个可用值,正整数 maskid 超出 1-255 会拒绝导出,保证导出的 GT_label 可按同一模板再导入;选择分开 mask 时输出 `分开Mask分割结果/{视频名称_时间戳_项目帧序号}_分别导出/{视频名称_时间戳_项目帧序号}_{类别名称}_maskid{maskid}.png`,同一帧同一类别合并为一张图;选择 GT_label/Pro_label/Mix_label 时分别输出 `GT_label图/{视频名称_时间戳_项目帧序号}.png``Pro_label彩色分割结果/{视频名称_时间戳_项目帧序号}.png``Mix_label重叠覆盖彩色分割结果/{视频名称_时间戳_项目帧序号}.png`。maskid 不参与覆盖排序GT_label/Pro_label/Mix_label 重叠区域覆盖顺序由内部拖拽排序字段决定,并与未选中状态下的 Canvas 显示顺序一致。
--- ---
@@ -258,17 +258,17 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
- 前端 `predictMask()` 已按后端 `PredictRequest` 发送 `image_id``prompt_type``prompt_data``model`,并将后端 `polygons` 转成 Konva 可渲染的 `pathData` - 前端 `predictMask()` 已按后端 `PredictRequest` 发送 `image_id``prompt_type``prompt_data``model`,并将后端 `polygons` 转成 Konva 可渲染的 `pathData`
- 手工绘制工具会生成可保存的 `Mask.segmentation`;撤销/重做通过 `maskHistory/maskFuture` 工作。 - 手工绘制工具会生成可保存的 `Mask.segmentation`;撤销/重做通过 `maskHistory/maskFuture` 工作。
- Polygon 顶点编辑和新增顶点会重算 `pathData/segmentation/bbox/area`;已保存 mask 进入 dirty 状态后复用归档 PATCH 链路。 - Polygon 顶点编辑和新增顶点会重算 `pathData/segmentation/bbox/area`;已保存 mask 进入 dirty 状态后复用归档 PATCH 链路。
- 区域合并/去除会重算主 mask 的几何;合并已保存的次级 mask 时会通过工作区回调删除对应后端标注。 - 区域合并/去除会重算主 mask 的几何;合并已保存的次级 mask 时会通过工作区回调删除对应后端标注,并同步删除次级 mask 同传播链上的自动传播结果
- 前端 `importGtMask()` 已对齐后端 `/api/ai/import-gt-mask`;工作区左侧工具栏“导入 GT Mask”会在上传前显示导入结果预览并选择未知 maskid 策略,后端支持二值 mask、低数值/16-bit GT_label 灰度图和 RGB 三通道完全相同的 `[X,X,X]` maskid 图,不再按彩色 RGB 类别图做颜色匹配;尺寸不同的 mask 会最近邻拉伸到当前帧,导入后回显多类别标注和 seed point - 前端 `importGtMask()` 已对齐后端 `/api/ai/import-gt-mask`;工作区左侧工具栏“导入 GT Mask”会在上传前显示导入结果预览并选择未知 maskid 策略,后端支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,不再按彩色 RGB 类别图做颜色匹配,也不接受 16-bit/uint16 GT_label;尺寸不同的 mask 会最近邻拉伸到当前帧,导入后回显多类别高精度 polygon 标注,不显示黄色 seed point并能直接使用普通 mask 的拓扑统计、边缘平滑、编辑和保存能力
- 前端 `exportCoco()` 已对齐后端 `/api/export/{project_id}/coco`;前端 `exportMasks()` 已对齐后端 `/api/export/{project_id}/masks`;前端 `exportSegmentationResults()` 已对齐后端 `/api/export/{project_id}/results`;工作区“分割结果导出”按钮会先保存当前待归档 mask再按所选范围、outputs 和 Mix_label 透明度下载统一 ZIP特定范围帧导出可用帧号输入框或时间轴拖拽选择范围下载文件名按项目库项目名、导出范围首尾时间戳和首尾项目帧序号生成统一 ZIP 包含 maskid/GT 像素值映射 JSON、原始图片文件夹、按帧/类别合并的分开 Mask 文件夹、GT_label 图文件夹、Pro_label 彩色图文件夹和 Mix_label 叠加图文件夹GT_label 像素值使用类别真实 `maskid` 并跨图一致。 - 前端 `exportCoco()` 已对齐后端 `/api/export/{project_id}/coco`;前端 `exportMasks()` 已对齐后端 `/api/export/{project_id}/masks`;前端 `exportSegmentationResults()` 已对齐后端 `/api/export/{project_id}/results`;工作区“分割结果导出”按钮会先保存当前待归档 mask再按所选范围、outputs 和 Mix_label 透明度下载统一 ZIP特定范围帧导出可用帧号输入框或时间轴拖拽选择范围下载文件名按项目库项目名、导出范围首尾时间戳和首尾项目帧序号生成统一 ZIP 包含 maskid/GT 像素值映射 JSON、原始图片文件夹、按帧/类别合并的分开 Mask 文件夹、GT_label 图文件夹、Pro_label 彩色图文件夹和 Mix_label 叠加图文件夹GT_label 固定为 uint8 PNG像素值使用类别真实 `maskid` 并跨图一致。
- 右侧语义分类树和 Canvas “应用分类”都会把分类变更同步到同一传播链前后帧对应 mask识别依据为 `source_annotation_id``source_mask_id``propagation_seed_key`,被同步更新的已保存 mask 会进入 dirty 状态,等待工作区归档保存 PATCH 到后端。 - 右侧语义分类树和 Canvas “应用分类”都会把分类变更同步到同一传播链前后帧对应 mask识别依据为 `source_annotation_id``source_mask_id``propagation_seed_key`,被同步更新的已保存 mask 会进入 dirty 状态,等待工作区归档保存 PATCH 到后端;保存 dirty mask 时会保留 `source`、传播 seed 和来源 id 等 metadata避免传播帧在时间轴上变成人工/AI 标注帧
- 工作区保存状态按钮会按当前项目待保存数量显示“保存 X 个改动”或“已全部保存”,并已接入 `POST /api/ai/annotate``PATCH /api/ai/annotations/{id}`;加载工作区时会通过 `GET /api/ai/annotations` 回显已保存标注。 - 工作区保存状态按钮会按当前项目待保存数量显示“保存 X 个改动”或“已全部保存”,并已接入 `POST /api/ai/annotate``PATCH /api/ai/annotations/{id}`;加载工作区时会通过 `GET /api/ai/annotations` 回显已保存标注。
- 右侧实例属性面板“边缘平滑强度/应用边缘平滑”已接入 `POST /api/ai/smooth-mask`;滑杆会即时更新数值,但后端预览请求有短防抖,避免拖动时连续请求卡顿;预览不写入撤销历史也不标 dirty点击应用后会把返回 polygon 作为新的实际 mask 几何写入当前 mask 和同传播链前后对应 mask整次应用作为一个撤销/重做历史步骤,相关 mask 标记为 dirty/draft平滑强度重置为 0用户可继续用 polygon 编辑工具调整新多边形。 - 右侧实例属性面板“边缘平滑强度/应用边缘平滑”已接入 `POST /api/ai/smooth-mask`;滑杆会即时更新数值,但后端预览请求有短防抖,避免拖动时连续请求卡顿;预览不写入撤销历史也不标 dirty点击应用后会把返回 polygon 作为新的实际 mask 几何写入当前 mask 和同传播链前后对应 mask整次应用作为一个撤销/重做历史步骤,相关 mask 标记为 dirty/draft平滑强度重置为 0用户可继续用 polygon 编辑工具调整新多边形。
- 工作区“自动传播”按钮已接入 `POST /api/ai/propagate/task`;若用户尚未显式设置范围,第一次点击会进入时间轴范围选择模式,第二次点击“开始传播”才提交后台任务;当前启用所选 SAM 2.1 变体的视频 predictor 后台任务,运行中轮询任务进度,完成后刷新后端已保存标注;工作区顶栏模型状态用紧凑 GPU/CPU 徽标,具体 SAM 2.1 传播权重由旁边下拉选择;同步 `POST /api/ai/propagate` 仍作为单 seed 兼容接口保留。 - 工作区“自动传播”按钮已接入 `POST /api/ai/propagate/task`;若用户尚未显式设置范围,第一次点击会进入时间轴范围选择模式,第二次点击“开始传播”才提交后台任务;当前启用所选 SAM 2.1 变体的视频 predictor 后台任务,运行中轮询任务进度,完成后刷新后端已保存标注;工作区顶栏模型状态用紧凑 GPU/CPU 徽标,具体 SAM 2.1 传播权重由旁边下拉选择;同步 `POST /api/ai/propagate` 仍作为单 seed 兼容接口保留。
- 工作区顶栏短状态会自动消失;保存、导出、导入 GT、传播进行中和无帧项目提示会保留到状态变化。 - 工作区顶栏短状态会自动消失;保存、导出、导入 GT、传播进行中和无帧项目提示会保留到状态变化。
- 工作区“清空遮罩”会调用 `DELETE /api/ai/annotations/{id}` 删除当前帧已保存标注,并清空当前帧本地 mask。 - 工作区“清空遮罩”会调用 `DELETE /api/ai/annotations/{id}` 删除当前帧已保存标注,并清空当前帧本地 mask。
- 项目状态已统一为 `pending``parsing``ready``error`;前端 `src/lib/api.ts` 会兼容归一化旧库中可能存在的 `Ready``Parsing``Error` - 项目状态已统一为 `pending``parsing``ready``error`;前端 `src/lib/api.ts` 会兼容归一化旧库中可能存在的 `Ready``Parsing``Error`
- 项目库的视频导入与生成帧是两个独立动作:导入视频只上传源文件,生成帧按钮才会带 `parse_fps` 调用 `/api/media/parse`;工作区不会再因“有视频但无帧”自动创建拆帧任务。 - 项目库的视频导入与生成帧是两个独立动作:导入视频只上传源文件,并通过 Axios `onUploadProgress` 在项目库显示导入进度;生成帧按钮才会带 `parse_fps` 调用 `/api/media/parse`DICOM 批量导入也会显示上传进度和文件数量,上传完成后创建解析任务并轮询显示解析进度。工作区不会再因“有视频但无帧”自动创建拆帧任务。
- `server.ts` 不再提供旧版 `/api/login``/api/projects``/api/templates` mock当前前端真实 API 调用走 FastAPI 的 `/api/auth/*``/api/projects``/api/templates` 等接口。 - `server.ts` 不再提供旧版 `/api/login``/api/projects``/api/templates` mock当前前端真实 API 调用走 FastAPI 的 `/api/auth/*``/api/projects``/api/templates` 等接口。
- `Dashboard.tsx` 初始统计、任务进度和活动日志来自 `GET /api/dashboard/overview`;任务进度来自 `processing_tasks` queued/running/success/failed/cancelled处理中统计只计算 queued/running支持取消 queued/running 任务、重试 failed/cancelled 任务和查看失败详情。Celery worker 通过 Redis pub/sub 的 `seg:progress` 频道推送细粒度进度,再由 FastAPI 广播到 `/ws/progress`;前端 WebSocket 客户端通过 `onopen/onclose/onerror` 更新连接状态,并定时发送 `ping` 心跳。 - `Dashboard.tsx` 初始统计、任务进度和活动日志来自 `GET /api/dashboard/overview`;任务进度来自 `processing_tasks` queued/running/success/failed/cancelled处理中统计只计算 queued/running支持取消 queued/running 任务、重试 failed/cancelled 任务和查看失败详情。Celery worker 通过 Redis pub/sub 的 `seg:progress` 频道推送细粒度进度,再由 FastAPI 广播到 `/ws/progress`;前端 WebSocket 客户端通过 `onopen/onclose/onerror` 更新连接状态,并定时发送 `ping` 心跳。
@@ -317,7 +317,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
- FastAPI 已有真实 `users` 表、密码哈希和签名 JWT默认 `admin / 123456` 只是开发种子用户,生产部署应通过环境变量或数据库改密。 - FastAPI 已有真实 `users` 表、密码哈希和签名 JWT默认 `admin / 123456` 只是开发种子用户,生产部署应通过环境变量或数据库改密。
- 业务路由会校验 Bearer token项目、帧、标注、任务、Dashboard 和导出按当前用户拥有的项目过滤,模板支持系统模板(`owner_user_id IS NULL`)和用户模板。 - 业务路由会校验 Bearer token项目、帧、标注、任务、Dashboard 和导出按当前用户拥有的项目过滤,模板支持系统模板(`owner_user_id IS NULL`)和用户模板。
- 角色分为 `admin``annotator``viewer``admin/annotator` 可调用写入类业务接口,`viewer` 只能调用读接口;`/api/admin/*` 仅允许 `admin` - 角色分为 `admin``annotator``viewer``admin/annotator` 可调用写入类业务接口,`viewer` 只能调用读接口;`/api/admin/*` 仅允许 `admin`
- 管理员后台支持用户新增、停用/启用、改角色、改密码、删除无项目用户、查看登录/用户管理审计日志,以及二次确认后恢复演示出厂设置;禁止管理员删除、停用或降级自己。 - 管理员后台支持用户新增、停用/启用、改角色、站内弹窗改密码、站内确认删除无项目用户、查看登录/用户管理审计日志,以及站内二次确认后恢复演示出厂设置;禁止管理员删除、停用或降级自己。
- JWT 默认开发密钥在 `backend/config.py`,生产部署必须通过 `backend/.env` 覆盖 `JWT_SECRET_KEY` - JWT 默认开发密钥在 `backend/config.py`,生产部署必须通过 `backend/.env` 覆盖 `JWT_SECRET_KEY`
- `backend/.env``.gitignore` 忽略不要提交真实数据库、MinIO、Redis、模型路径等敏感配置。 - `backend/.env``.gitignore` 忽略不要提交真实数据库、MinIO、Redis、模型路径等敏感配置。
- `start_services.sh` 中包含本机路径和 sudo 启动逻辑,迁移机器时要审查。 - `start_services.sh` 中包含本机路径和 sudo 启动逻辑,迁移机器时要审查。

View File

@@ -12,13 +12,13 @@
## 核心功能 ## 核心功能
- **多媒体资产管理** — 支持视频MP4/AVI/MOV和 DICOM 医学影像上传;视频导入与生成帧分离,生成帧时选择目标 FPS项目卡片可删除项目及其关联帧、标注和任务记录 - **多媒体资产管理** — 支持视频MP4/AVI/MOV和 DICOM 医学影像上传项目库会在导入时显示上传进度条、已上传字节、DICOM 文件数量和上传后的解析任务进度;视频导入与生成帧分离,生成帧时选择目标 FPS项目卡片可重命名、复制或删除项目及其关联帧、标注和任务记录;复制时可选择“新项目重置”只保留媒体/帧序列,或“全内容复制”连同标注和 mask 元数据一起复制
- **AI 智能分割引擎** — 当前产品入口启用 SAM 2.1 四个变体tiny/small/base+/large选择侧栏和工作区跳转入口使用 Bot + Sparkles 组合图标强化 AI 识别支持点分割point、框分割box、交互式正/反点细化、提示点单点删除、AI 候选单独删除、自动分割auto和 Celery 后台 video predictor 传播,前端默认只采用最高分候选避免重叠备选同时显示 - **AI 智能分割引擎** — 当前产品入口启用 SAM 2.1 四个变体tiny/small/base+/large选择侧栏和工作区跳转入口使用 Bot + Sparkles 组合图标强化 AI 识别支持点分割point、框分割box、交互式正/反点细化、提示点单点删除、AI 候选单独删除、自动分割auto和 Celery 后台 video predictor 传播,前端默认只采用最高分候选避免重叠备选同时显示
- **交互式画布标注** — 基于 Konva 的高性能 Canvas工作区和 AI 画布会默认居中放大底图并保留边距;工作区支持缩放/平移/手工多边形/矩形/圆/画笔/橡皮擦、polygon 顶点直接拖动/删除、边中点插点、双击边界插点、区域合并/去除、撤销/重做;画笔和橡皮擦支持尺寸调节,画笔可按当前语义分类生成连续区域并自动合并连通的选中 mask橡皮擦可从选中 mask 扣除区域;未选中特定 mask 时按右侧语义分类树的内部优先级叠放显示AI 智能分割页单独提供正/反点和框选,实时渲染 Mask 遮罩 - **交互式画布标注** — 基于 Konva 的高性能 Canvas工作区和 AI 画布会默认居中放大底图并保留边距;工作区支持缩放/平移/手工多边形/矩形/圆/画笔/橡皮擦、polygon 顶点直接拖动/删除、边中点插点、双击边界插点、区域合并/去除、撤销/重做;mask 不再显示黄色 seed point也不提供 seed point 拖动;画笔和橡皮擦支持尺寸调节,画笔可按当前语义分类生成连续区域并自动合并连通的选中 mask橡皮擦可从选中 mask 扣除区域;删除传播 seed 或传播结果时会同步删除同一传播链上的自动传播 mask但保留其他帧独立 AI 推理/人工标注 mask未选中特定 mask 时按右侧语义分类树的内部优先级叠放显示AI 智能分割页单独提供正/反点和框选,实时渲染 Mask 遮罩
- **GT Mask 导入** — 工作区可导入二值 mask、GT_label 灰度图或 RGB 三通道完全相同的 `[X,X,X]` maskid 图;导入前会显示本地预览,不符合灰度/maskid 图要求时反馈错误,尺寸不同会按当前帧长宽最近邻拉伸;后端按 maskid 匹配当前模板类别并生成 polygon 标注和 seed point超出现有类别的 maskid 可由用户选择舍弃或导入为“未定义类别”等待重新命名 - **GT Mask 导入** — 工作区可导入 8-bit 二值/灰度 maskid 图或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图;导入前会显示本地预览,不符合 8-bit 灰度/maskid 图要求时反馈错误,尺寸不同会按当前帧长宽最近邻拉伸;后端按 maskid 匹配当前模板类别并生成高精度 polygon 标注,导入后与普通 mask 一样不显示黄色 seed point并共用拓扑锚点统计、边缘平滑、顶点编辑、分类和保存链路超出现有类别的 maskid 可由用户选择舍弃或导入为“未定义类别”等待重新命名
- **本体字典管理** — 可配置的分类体系、颜色映射、稳定且跨图一致的 maskid右侧分类树可拖拽调整内部图层覆盖顺序maskid 不参与排序 - **本体字典管理** — 可配置的分类体系、颜色映射、稳定且跨图一致的 maskid所有模板默认带 `maskid: 0` 的黑色“待分类”保留类,该类固定在语义分类树最后、不可删除也不可拖到上层;模板库“生效中模板架构清单”可用鼠标复制现有模板为当前用户私有副本,详情页“语义分类树(拖拽调层级)”支持新建分类、拖拽调层级和垃圾桶删除 label模板编辑弹窗和右侧分类树可拖拽调整内部图层覆盖顺序maskid 不参与排序
- **项目工作区** — 项目创建、帧浏览、多图层标注、自动传播帧提示、进度追踪 - **项目工作区** — 项目创建、帧浏览、多图层标注、自动传播帧提示、进度追踪
- **数据导出** — 工作区使用统一“分割结果导出”入口,可选择整体视频、特定范围帧或当前图片;特定范围帧支持输入帧号或在时间轴进度条上拖拽选择,并导出 COCO JSON、maskid/GT 像素值映射、原始图片、分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图GT/Pro/Mix 的重叠覆盖顺序和右侧语义分类树内部优先级一致GT_label 背景为 0类别值使用模板中的真实 maskid缺失 maskid 的旧标注才补下一个可用正整数 - **数据导出** — 工作区使用统一“分割结果导出”入口,可选择整体视频、特定范围帧或当前图片;特定范围帧支持输入帧号或在时间轴进度条上拖拽选择,并导出 COCO JSON、maskid/GT 像素值映射、原始图片、分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图GT/Pro/Mix 的重叠覆盖顺序和右侧语义分类树内部优先级一致GT_label 固定为 8-bit uint8 PNG背景为 0类别值使用模板中的真实 maskid`maskid: 0` 的“待分类”在 GT_label 中与背景同为 0在 Pro_label 中与背景同为黑色 `[0,0,0]`,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出
--- ---
@@ -340,7 +340,7 @@ celery -A celery_app:celery_app worker --loglevel=info --concurrency=1
nohup celery -A celery_app:celery_app worker --loglevel=info --concurrency=1 > /tmp/celery.log 2>&1 & nohup celery -A celery_app:celery_app worker --loglevel=info --concurrency=1 > /tmp/celery.log 2>&1 &
``` ```
视频导入只创建项目并把源视频保存到 MinIO不会自动拆帧用户在项目库点击“生成帧”后再选择目标 FPS 并调用 `POST /api/media/parse`。项目库和模板库的成功/失败反馈使用非阻塞短提示,会自动消失,不再用浏览器 `alert()` 阻塞后续操作。该接口只创建 `processing_tasks` 记录并把任务投递给 Celery真正的 FFmpeg/OpenCV/pydicom 拆帧由 worker 执行。接口支持 `parse_fps``max_frames``target_width`,用于生成后续 SAM 2 视频处理可复用的标准帧序列;视频`frame_%06d.jpg` 连续命名,帧表会记录 `timestamp_ms``source_frame_number`,任务完成结果会返回 `frame_sequence` 元数据。worker 每次更新任务状态后会发布到 Redis `seg:progress` 频道FastAPI 订阅后转发到 `/ws/progress`,前端 Dashboard 可实时更新。Dashboard 的任务进度区展示 queued/running/success/failed/cancelled 最近任务,处理中统计只计算 queued/runningWebSocket 状态由浏览器 `onopen/onclose/onerror` 驱动,客户端会定时发送 `ping` 心跳,服务端返回 `status` 确认连接。Dashboard 也可调用 `/api/tasks/{id}/cancel``/api/tasks/{id}/retry``/api/tasks/{id}` 完成任务取消、重试与失败详情查看。 视频导入只创建项目并把源视频保存到 MinIO不会自动拆帧项目库导入面板会用 Axios 上传回调显示上传进度、百分比和字节数。用户在项目库点击“生成帧”后,再选择目标 FPS 并调用 `POST /api/media/parse`DICOM 批量导入会在前端选择、后端上传、worker 下载和 pydicom 读取四个环节按文件名自然顺序排序,保证 `1.dcm、2.dcm、10.dcm` 这种序列按可见数字顺序转成项目帧;上传阶段同样显示进度条和本次有效 `.dcm` 文件数量,上传完成后项目库会轮询解析任务进度直到完成、失败或取消。项目卡片支持复制项目:`新项目重置` 会复制项目媒体字段和已生成帧序列但不复制标注,`全内容复制` 会额外复制标注和关联 mask 元数据,任务运行历史不复制。项目库和模板库的成功/失败反馈使用非阻塞短提示,会自动消失,不再用浏览器 `alert()` 阻塞后续操作;项目删除、模板删除、用户改密码/删除和演示出厂重置等高风险操作使用站内确认弹窗。该接口只创建 `processing_tasks` 记录并把任务投递给 Celery真正的 FFmpeg/OpenCV/pydicom 拆帧由 worker 执行。接口支持 `parse_fps``max_frames``target_width`,用于生成后续 SAM 2 视频处理可复用的标准帧序列;视频/DICOM 解析后都`frame_%06d.jpg` 连续生成项目帧,帧表会记录 `timestamp_ms``source_frame_number`,任务完成结果会返回 `frame_sequence` 元数据。worker 每次更新任务状态后会发布到 Redis `seg:progress` 频道FastAPI 订阅后转发到 `/ws/progress`,前端 Dashboard 可实时更新。Dashboard 的任务进度区展示 queued/running/success/failed/cancelled 最近任务,处理中统计只计算 queued/runningWebSocket 状态由浏览器 `onopen/onclose/onerror` 驱动,客户端会定时发送 `ping` 心跳,服务端返回 `status` 确认连接。Dashboard 也可调用 `/api/tasks/{id}/cancel``/api/tasks/{id}/retry``/api/tasks/{id}` 完成任务取消、重试与失败详情查看。
### 步骤 7: 安装前端依赖并构建 ### 步骤 7: 安装前端依赖并构建
@@ -417,7 +417,9 @@ cd ~/Desktop/Seg_Server
后端启动时会自动种子化默认管理员 `admin / 123456`,密码以哈希形式存入 `users` 表。登录成功返回签名 JWT前端会把 token 写入 `localStorage` 并通过 `Authorization: Bearer <token>` 调用业务接口;页面刷新后会用 `/api/auth/me` 恢复当前用户。 后端启动时会自动种子化默认管理员 `admin / 123456`,密码以哈希形式存入 `users` 表。登录成功返回签名 JWT前端会把 token 写入 `localStorage` 并通过 `Authorization: Bearer <token>` 调用业务接口;页面刷新后会用 `/api/auth/me` 恢复当前用户。
当前项目、帧、标注、任务、Dashboard 和导出接口已经按当前 JWT 用户拥有的项目隔离;模板支持系统模板(`owner_user_id IS NULL`)和用户模板。角色分为 `admin``annotator``viewer``admin/annotator` 可调用写入类业务接口,`viewer` 只能读取;管理员会在侧栏看到“用户管理”,可通过 `/api/admin/users` 新增、停用/启用、改角色、改密码和删除无项目用户,并通过 `/api/admin/audit-logs` 查看登录与用户管理审计。演示部署还提供“恢复演示出厂设置”,二次确认后调用 `/api/admin/demo-factory-reset`,只保留默认 admin 与一个尚未生成帧的演示视频项目。生产部署时必须在 `backend/.env` 覆盖 `JWT_SECRET_KEY` 并修改默认管理员密码。 当前项目、帧、标注、任务、Dashboard 和导出接口已经按当前 JWT 用户拥有的项目隔离;模板支持系统模板(`owner_user_id IS NULL`)和用户模板。角色分为 `admin``annotator``viewer``admin/annotator` 可调用写入类业务接口,`viewer` 只能读取;管理员会在侧栏看到“用户管理”,可通过 `/api/admin/users` 新增、停用/启用、改角色、改密码和删除无项目用户,并通过 `/api/admin/audit-logs` 查看登录与用户管理审计。演示部署还提供“恢复演示出厂设置”,站内二次确认后调用 `/api/admin/demo-factory-reset`,只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目。生产部署时必须在 `backend/.env` 覆盖 `JWT_SECRET_KEY` 并修改默认管理员密码。
系统默认模板会在后端启动时幂等补齐当前包括“腹腔镜胆囊切除术”和“头颈部CT分割”所有新建、复制、导入和后端返回的模板都会归一化带上黑色 `maskid: 0` 的“待分类”保留类并固定在语义分类树最后。恢复演示出厂设置只删除用户私有模板默认系统模板会继续保留。模板库左侧“生效中模板架构清单”里的复制按钮会把任一模板复制成当前用户私有副本并保留分类名称、颜色、maskid、内部层级顺序和规则。
--- ---
@@ -510,11 +512,11 @@ pip install -e . --no-build-isolation
- 工作区点选/框选会使用当前帧的数据库 `frame.id` 调用 `/api/ai/predict` - 工作区点选/框选会使用当前帧的数据库 `frame.id` 调用 `/api/ai/predict`
- 工作区 SAM 2.1 交互式细化包含反向点时会启用后端背景过滤;若反向点排除了当前候选区域并返回空结果,前端会移除旧候选 mask。 - 工作区 SAM 2.1 交互式细化包含反向点时会启用后端背景过滤;若反向点排除了当前候选区域并返回空结果,前端会移除旧候选 mask。
- AI 页面只显示本页最新生成的 SAM 2.1 候选,不会把工作区已有 mask 带入 AI 画布;重复执行高精度分割会替换上一次 AI 页候选;新生成 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接给生成结果换标签;如果换标签的 mask 属于传播链,系统会同步更新前后帧对应传播 mask 的分类元数据;“推送至工作区编辑”会切回工作区的多边形调整工具并保留选择和当前帧视角,不会因工作区重新加载而跳回第一帧。 - AI 页面只显示本页最新生成的 SAM 2.1 候选,不会把工作区已有 mask 带入 AI 画布;重复执行高精度分割会替换上一次 AI 页候选;新生成 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接给生成结果换标签;如果换标签的 mask 属于传播链,系统会同步更新前后帧对应传播 mask 的分类元数据;“推送至工作区编辑”会切回工作区的多边形调整工具并保留选择和当前帧视角,不会因工作区重新加载而跳回第一帧。
- 工作区传播功能会使用当前打开参考帧的全部 mask 作为 seed按用户设置的传播起始帧和传播结束帧向前/向后追踪;用户可直接修改数字框,也可先点击“自动传播”进入时间轴范围选择模式,在播放进度条或视频处理进度条上点击/拖拽选择范围,再点击“开始传播”。工作区顶栏可单独选择本次传播使用的 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换;前端提交传播前会先保存当前项目中的 draft/dirty mask使 seed 优先携带稳定的后端 `source_annotation_id`,再把传播权重 id、seed、seed 来源 id 和方向组装为 `/api/ai/propagate/task` 后台任务。后端入队时会规范化/校验权重 id并把规范化后的 id 写入任务 payload/resultworker 会按 seed 来源、方向和 seed 签名去重,同权重且未改变的 mask 二次传播时直接跳过,已改变或换用其他权重的 mask 会先删除同源旧自动传播标注再重传;旧版本使用前端临时 `source_mask_id` 生成的传播结果会按同一参考帧、方向和语义信息兼容清理,中间帧人工新增或修改同一物体后重新传播时,也会在写入目标帧新结果前按语义和空间重叠清理旧传播结果,且写入前清理不受旧结果传播方向限制,避免向前传播时与早先向后生成的旧 mask 叠加。若历史或外部 seed metadata 仍带 `geometry_smoothing`,后端仍会在 forward/backward 两个方向保存前应用同一平滑参数;当前工作区平滑按钮应用后会直接改写实际 polygon因此后续传播以新几何参与签名和追踪。任务进度写入 `processing_tasks` 并可在 Dashboard 查看/取消/重试,工作区轮询任务状态并刷新已保存标注。传播结果回显后,视频处理进度条会把自动传播生成的帧区段标为蓝色,人工/AI 标注帧显示为红色竖线;每次自动传播成功处理过的范围会在当前会话中额外叠加同一蓝色系、最新传播最亮、旧传播逐次变暗且第 5 次及更早统一为阈值旧记录色的纯色片段,用于辨认第一次、第二次、第 N 次传播;普通状态下点击视频处理进度条或红/蓝帧标识可跳转到对应帧,底部缩略图也会用红色边框标识人工/AI 标注帧、蓝色边框标识传播/推理帧;如果同一帧同时有人工作业和传播结果,红色人工/AI 标注框优先保留,蓝色传播状态以内描边表达;当前帧如果同时是人工/AI 标注帧,会显示青色外框加红色内描边,固定为外层当前帧、内层标注框。 - 工作区传播功能会使用当前打开参考帧的全部 mask 作为 seed按用户设置的传播起始帧和传播结束帧向前/向后追踪;用户可直接修改数字框,也可先点击“自动传播”进入时间轴范围选择模式,在播放进度条或视频处理进度条上点击/拖拽选择范围,再点击“开始传播”。工作区顶栏可单独选择本次传播使用的 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换;前端提交传播前会先保存当前项目中的 draft/dirty mask使 seed 优先携带稳定的后端 `source_annotation_id`,再把传播权重 id、seed、seed 来源 id 和方向组装为 `/api/ai/propagate/task` 后台任务。后端入队时会规范化/校验权重 id并把规范化后的 id 写入任务 payload/resultworker 会按 seed 来源、方向和 seed 签名去重,同权重且未改变的 mask 二次传播时直接跳过,已改变或换用其他权重的 mask 会先删除同源旧自动传播标注再重传;旧版本使用前端临时 `source_mask_id` 生成的传播结果会按同一参考帧、方向和语义信息兼容清理,中间帧人工新增或修改同一物体后重新传播时,也会在写入目标帧新结果前按语义和空间重叠清理旧传播结果,且写入前清理不受旧结果传播方向限制,避免向前传播时与早先向后生成的旧 mask 叠加。若历史或外部 seed metadata 仍带 `geometry_smoothing`,后端仍会在 forward/backward 两个方向保存前应用同一平滑参数;当前工作区平滑按钮应用后会直接改写实际 polygon因此后续传播以新几何参与签名和追踪。任务进度写入 `processing_tasks` 并可在 Dashboard 查看/取消/重试,工作区轮询任务状态并刷新已保存标注。传播结果回显后,视频处理进度条会把自动传播生成的帧区段标为蓝色,人工/AI 标注帧显示为红色竖线;每次自动传播成功处理过的范围会在当前会话中额外叠加同一蓝色系、最新传播最亮、旧传播逐次变暗且第 5 次及更早统一为阈值旧记录色的纯色片段,用于辨认第一次、第二次、第 N 次传播;这些传播历史片段会按当前仍存在的传播 mask 自动裁剪,删除 mask 后无 mask 的帧不会继续显示红/蓝颜色;普通状态下点击视频处理进度条或红/蓝帧标识可跳转到对应帧,底部缩略图也会用红色边框标识人工/AI 标注帧、蓝色边框标识传播/推理帧;如果同一帧同时有人工作业和传播结果,红色人工/AI 标注框优先保留,蓝色传播状态以内描边表达;当前帧如果同时是人工/AI 标注帧,会显示青色外框加红色内描边,固定为外层当前帧、内层标注框。
- 右侧实例属性面板的“边缘平滑强度”滑杆会先防抖预览;点击“应用边缘平滑”后会把平滑结果作为新的实际 polygon 写入当前 mask并同步写入同一传播链前后对应 mask整次操作进入同一个撤销/重做历史步骤,应用后强度重置为 0用户可继续用“调整多边形”编辑新多边形。 - 右侧实例属性面板的“边缘平滑强度”滑杆会先防抖预览;点击“应用边缘平滑”后会把平滑结果作为新的实际 polygon 写入当前 mask并同步写入同一传播链前后对应 mask整次操作进入同一个撤销/重做历史步骤,应用后强度重置为 0用户可继续用“调整多边形”编辑新多边形。分类和平滑这类跨传播链操作保存时会保留传播来源 metadata避免原本的自动传播帧在时间轴上被改成红色人工/AI 标注帧。
- 前端 `exportCoco()` 已对齐到 `/api/export/{projectId}/coco``exportMasks()` 已对齐到 `/api/export/{projectId}/masks`,统一导出 `exportSegmentationResults()` 已对齐到 `/api/export/{projectId}/results` - 前端 `exportCoco()` 已对齐到 `/api/export/{projectId}/coco``exportMasks()` 已对齐到 `/api/export/{projectId}/masks`,统一导出 `exportSegmentationResults()` 已对齐到 `/api/export/{projectId}/results`
- 工作区“分割结果导出”按钮已绑定下载流程;点击后可在下拉栏选择整体视频、特定范围帧或当前图片,默认选择当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图。选择特定范围帧时,可直接修改帧号,也可在播放进度条或视频处理进度条上拖拽选择范围;选择 Mix_label 时可调遮罩透明度,默认 0.3,并显示当前/待导出第一帧预览。导出前会先保存当前待归档的前端 mask。下载 ZIP 命名为 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`,项目名会替换文件系统不安全字符;时间戳来自帧 `timestampMs` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based 帧顺序,不使用原视频帧号。统一导出 ZIP 固定包含 `annotations_coco.json``maskid_GT像素值_类别映射.json``原始图片/`GT_label 像素值使用类别真实 maskid,缺失 maskid 的旧标注才补下一个可用正整数并写入映射 JSON因此同一模板导出的 GT_label 可直接再导入;选择分开 mask 时输出 `分开Mask分割结果/`,按帧子目录和类别 maskid 合并命名;选择 GT_label、Pro_label、Mix_label 时分别输出 `GT_label图/``Pro_label彩色分割结果/``Mix_label重叠覆盖彩色分割结果/`,重叠区域按右侧语义分类树内部优先级从低到高覆盖。 - 工作区“分割结果导出”按钮已绑定下载流程;点击后可在下拉栏选择整体视频、特定范围帧或当前图片,默认选择当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图。选择特定范围帧时,可直接修改帧号,也可在播放进度条或视频处理进度条上拖拽选择范围;选择 Mix_label 时可调遮罩透明度,默认 0.3,并显示当前/待导出第一帧预览。导出前会先保存当前待归档的前端 mask。下载 ZIP 命名为 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`,项目名会替换文件系统不安全字符;时间戳来自帧 `timestampMs` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based 帧顺序,不使用原视频帧号。统一导出 ZIP 固定包含 `annotations_coco.json``maskid_GT像素值_类别映射.json``原始图片/`GT_label 固定输出 8-bit uint8 PNG像素值使用类别真实 maskid`maskid: 0` 的“待分类”保持 0Pro_label 中保持黑色 `[0,0,0]`,缺失 maskid 的旧标注才补下一个可用正整数并写入映射 JSON正整数 maskid 超出 1-255 会拒绝导出,因此同一模板导出的 GT_label 可直接再导入;选择分开 mask 时输出 `分开Mask分割结果/`,按帧子目录和类别 maskid 合并命名;选择 GT_label、Pro_label、Mix_label 时分别输出 `GT_label图/``Pro_label彩色分割结果/``Mix_label重叠覆盖彩色分割结果/`,重叠区域按右侧语义分类树内部优先级从低到高覆盖。
- 工作区左侧工具栏“导入 GT Mask”按钮已绑定 `/api/ai/import-gt-mask`,入口位于“重叠区域去除”之后;选择文件后会先显示导入结果预览,并让用户决定未知 maskid 的处理方式,可“舍弃未知类别”或“导入为未定义”。后端使用 `cv2.IMREAD_UNCHANGED` 读取 mask,因此导出的低数值/16-bit `GT_label图` 能按 maskid 重新识别;导入格式限定为灰度 maskid 图或 RGB 三通道完全相同的 `[X,X,X]` maskid 图0 为背景X 为 maskid尺寸不同会自动按当前帧长宽最近邻拉伸导入后刷新并回显已保存标注 seed point。 - 工作区左侧工具栏“导入 GT Mask”按钮已绑定 `/api/ai/import-gt-mask`,入口位于“重叠区域去除”之后;选择文件后会先显示导入结果预览,并让用户决定未知 maskid 的处理方式,可“舍弃未知类别”或“导入为未定义”。后端使用 `cv2.IMREAD_UNCHANGED` 读取 mask 并校验 dtype;导入格式限定为 8-bit 灰度 maskid 图或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图0 为背景X 为 1-255 的 maskid16-bit/uint16 GT_label 和全背景 0 图都会被拒绝全背景错误信息为“GT Mask 图片中没有非背景 maskid 区域。”;尺寸不同会自动按当前帧长宽最近邻拉伸;导入时使用高精度 contour尽量保留边缘细节同时对单个轮廓设置点数上限以控制前端性能导入后刷新并回显已保存标注,但不显示或拖动 seed point,后续可像普通 mask 一样做拓扑统计、边缘平滑和 polygon 编辑
- 工作区保存状态按钮会按当前项目待保存数量显示“保存 X 个改动”或“已全部保存”;点击后会把未保存 mask 写入 `POST /api/ai/annotate`,并把 dirty mask 写入 `PATCH /api/ai/annotations/{id}` - 工作区保存状态按钮会按当前项目待保存数量显示“保存 X 个改动”或“已全部保存”;点击后会把未保存 mask 写入 `POST /api/ai/annotate`,并把 dirty mask 写入 `PATCH /api/ai/annotations/{id}`
- 工作区“清空遮罩”会通过 `DELETE /api/ai/annotations/{id}` 删除当前帧已保存标注,并清空当前帧本地 mask。 - 工作区“清空遮罩”会通过 `DELETE /api/ai/annotations/{id}` 删除当前帧已保存标注,并清空当前帧本地 mask。

View File

@@ -39,6 +39,7 @@ class Settings(BaseSettings):
default_admin_username: str = "admin" default_admin_username: str = "admin"
default_admin_password: str = "123456" default_admin_password: str = "123456"
demo_video_path: str = "/home/wkmgc/Desktop/Seg_Server/Data_MyVideo_1.mp4" demo_video_path: str = "/home/wkmgc/Desktop/Seg_Server/Data_MyVideo_1.mp4"
demo_dicom_dir: str = "/home/wkmgc/Desktop/Seg_Server/2024_2_5_王芳/※2F458C45CFAA4C7CB76A39AA2BFE436B"
class Config: class Config:
env_file = ".env" env_file = ".env"

View File

@@ -4,8 +4,6 @@ import asyncio
import json import json
import logging import logging
import os import os
import shutil
import tempfile
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -15,10 +13,9 @@ from sqlalchemy import inspect, text
from config import settings from config import settings
from database import Base, engine, SessionLocal from database import Base, engine, SessionLocal
from minio_client import ensure_bucket_exists, upload_file from minio_client import ensure_bucket_exists
from progress_events import PROGRESS_CHANNEL from progress_events import PROGRESS_CHANNEL
from redis_client import get_redis_client, ping as redis_ping from redis_client import get_redis_client, ping as redis_ping
from statuses import PROJECT_STATUS_PENDING, PROJECT_STATUS_READY
from routers import projects, templates, media, ai, export, auth, dashboard, tasks, admin from routers import projects, templates, media, ai, export, auth, dashboard, tasks, admin
@@ -27,9 +24,24 @@ logging.basicConfig(
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
RESERVED_UNCLASSIFIED_CLASS = {
"id": "reserved-unclassified",
"name": "待分类",
"color": "#000000",
"zIndex": 0,
"maskId": 0,
"category": "系统保留",
}
DEFAULT_VIDEO_PATH = settings.demo_video_path
def _with_reserved_unclassified_class(classes: list[dict]) -> list[dict]:
filtered = [
item for item in classes
if item.get("id") != RESERVED_UNCLASSIFIED_CLASS["id"]
and item.get("name") != RESERVED_UNCLASSIFIED_CLASS["name"]
and item.get("maskId") != 0
]
return [*filtered, dict(RESERVED_UNCLASSIFIED_CLASS)]
def _ensure_runtime_schema_columns() -> None: def _ensure_runtime_schema_columns() -> None:
"""Add nullable columns introduced after initial create_all deployments.""" """Add nullable columns introduced after initial create_all deployments."""
@@ -72,92 +84,51 @@ def _seed_default_admin_and_ownership_sync() -> None:
def _seed_default_project_sync() -> None: def _seed_default_project_sync() -> None:
"""Synchronously seed the default video project on first startup.""" """Synchronously seed the bundled demo video and DICOM projects on first startup."""
import cv2 from models import Project
from models import Project, Frame
from routers.auth import ensure_default_admin from routers.auth import ensure_default_admin
from services.frame_parser import parse_video, upload_frames_to_minio, extract_thumbnail from services.demo_media import (
DEMO_DICOM_PROJECT_NAME,
DEMO_VIDEO_PROJECT_NAME,
create_parsed_dicom_demo_project,
create_unparsed_video_demo_project,
demo_dicom_files,
)
db = SessionLocal() db = SessionLocal()
try: try:
admin = ensure_default_admin(db) admin = ensure_default_admin(db)
existing = db.query(Project).filter(Project.name == "Data_MyVideo_1").first() existing_video = db.query(Project).filter(Project.name == DEMO_VIDEO_PROJECT_NAME).first()
if existing is not None: if existing_video is not None and existing_video.owner_user_id is None:
if existing.owner_user_id is None: existing_video.owner_user_id = admin.id
existing.owner_user_id = admin.id db.commit()
elif existing_video is None and os.path.exists(settings.demo_video_path):
video_project = create_unparsed_video_demo_project(
db,
owner=admin,
video_path=settings.demo_video_path,
project_name=DEMO_VIDEO_PROJECT_NAME,
)
logger.info("Seeded default video project id=%s", video_project.id)
existing_dicom = db.query(Project).filter(Project.name == DEMO_DICOM_PROJECT_NAME).first()
if existing_dicom is not None:
if existing_dicom.owner_user_id is None:
existing_dicom.owner_user_id = admin.id
db.commit() db.commit()
return return
if not os.path.exists(DEFAULT_VIDEO_PATH): if not demo_dicom_files(settings.demo_dicom_dir):
logger.warning("Default video not found at %s", DEFAULT_VIDEO_PATH) logger.warning("Default DICOM series not found at %s", settings.demo_dicom_dir)
return return
project = Project( project = create_parsed_dicom_demo_project(
name="Data_MyVideo_1", db,
description="默认演示视频", owner=admin,
status=PROJECT_STATUS_PENDING, dicom_dir=settings.demo_dicom_dir,
source_type="video", project_name=DEMO_DICOM_PROJECT_NAME,
parse_fps=30.0,
owner_user_id=admin.id,
) )
db.add(project) logger.info("Seeded default DICOM project id=%s with %d frames", project.id, len(project.frames))
db.commit()
db.refresh(project)
with open(DEFAULT_VIDEO_PATH, "rb") as f:
data = f.read()
object_name = f"uploads/{project.id}/Data_MyVideo_1.mp4"
upload_file(object_name, data, content_type="video/mp4", length=len(data))
project.video_path = object_name
db.commit()
# Parse frames
tmp_dir = tempfile.mkdtemp(prefix=f"seg_seed_{project.id}_")
try:
local_path = os.path.join(tmp_dir, "video.mp4")
with open(local_path, "wb") as f:
f.write(data)
output_dir = os.path.join(tmp_dir, "frames")
os.makedirs(output_dir, exist_ok=True)
frame_files, original_fps = parse_video(local_path, output_dir, fps=30, max_frames=100)
project.original_fps = original_fps
# Extract thumbnail
thumbnail_path = os.path.join(tmp_dir, "thumbnail.jpg")
try:
extract_thumbnail(local_path, thumbnail_path)
with open(thumbnail_path, "rb") as f:
thumb_data = f.read()
thumb_object = f"projects/{project.id}/thumbnail.jpg"
upload_file(thumb_object, thumb_data, content_type="image/jpeg", length=len(thumb_data))
project.thumbnail_url = thumb_object
except Exception as exc: # noqa: BLE001
logger.warning("Thumbnail extraction failed: %s", exc)
object_names = upload_frames_to_minio(frame_files, project.id)
for idx, obj_name in enumerate(object_names):
img = cv2.imread(frame_files[idx])
h, w = img.shape[:2] if img is not None else (None, None)
timestamp_ms = idx * 1000.0 / 30.0
source_frame_number = int(round(idx * original_fps / 30.0)) if original_fps else None
frame = Frame(
project_id=project.id,
frame_index=idx,
image_url=obj_name,
width=w,
height=h,
timestamp_ms=timestamp_ms,
source_frame_number=source_frame_number,
)
db.add(frame)
project.status = PROJECT_STATUS_READY
db.commit()
logger.info("Seeded default project id=%s with %d frames", project.id, len(object_names))
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception as exc: except Exception as exc:
logger.error("Failed to seed default project: %s", exc) logger.error("Failed to seed default project: %s", exc)
finally: finally:
@@ -170,53 +141,120 @@ def _seed_default_templates_sync() -> None:
db = SessionLocal() db = SessionLocal()
try: try:
if db.query(Template).first() is not None: ensure_default_templates(db)
return
# Laparoscopic cholecystectomy template (35 classes)
colors = [
(134, 124, 118), (0, 157, 142), (245, 161, 0), (255, 172, 159), (146, 175, 236), (155, 62, 0),
(255, 91, 0), (255, 234, 0), (85, 111, 181), (155, 132, 0), (181, 227, 14), (72, 0, 255),
(255, 0, 255), (29, 32, 136), (240, 16, 116), (160, 15, 95), (0, 155, 33), (0, 160, 233),
(52, 184, 178), (66, 115, 82), (90, 120, 41), (255, 0, 0), (117, 0, 0), (167, 24, 233),
(42, 8, 66), (112, 113, 150), (0, 255, 0), (255, 255, 255), (0, 255, 255), (181, 85, 105),
(113, 102, 140), (202, 202, 200), (197, 83, 181), (136, 162, 196), (138, 251, 213),
]
names = [
'', '线', '肿瘤', '血管阻断夹', '棉球', '双极电凝',
'肝脏', '胆囊', '分离钳', '脂肪', '止血海绵', '肝总管',
'吸引器', '剪刀', '超声刀', '止血纱布', '胆总管', '生物夹',
'无损伤钳', '钳夹', '喷洒', '胆囊管', '动脉', '电凝',
'静脉', '标本袋', '引流管', '纱布', '金属钛夹', '韧带',
'肝蒂', '推结器', '乳胶管-血管阻断', '吻合器', '术中超声',
]
classes = []
for idx, (rgb, name) in enumerate(zip(colors, names)):
color_hex = f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
classes.append({
"id": f"cls-lap-{idx}",
"name": name,
"color": color_hex,
"zIndex": (len(names) - idx) * 10,
"category": "腹腔镜胆囊切除术",
})
template = Template(
name="腹腔镜胆囊切除术",
description="腹腔镜胆囊切除术LC手术器械与解剖结构语义分割模板共35个分类",
color="#06b6d4",
z_index=0,
mapping_rules={"classes": classes, "rules": []},
)
db.add(template)
db.commit()
logger.info("Seeded default template '腹腔镜胆囊切除术' with %d classes", len(classes))
except Exception as exc: except Exception as exc:
logger.error("Failed to seed default templates: %s", exc) logger.error("Failed to seed default templates: %s", exc)
finally: finally:
db.close() db.close()
def _template_classes(
template_name: str,
names: list[str],
colors: list[tuple[int, int, int]],
*,
id_prefix: str,
) -> list[dict]:
classes = []
for idx, (rgb, name) in enumerate(zip(colors, names)):
color_hex = f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
classes.append({
"id": f"{id_prefix}-{idx}",
"name": name,
"color": color_hex,
"zIndex": (len(names) - idx) * 10,
"maskId": idx + 1,
"category": template_name,
})
return classes
def ensure_default_templates(db) -> None:
"""Ensure all bundled system templates exist."""
from models import Template
default_templates = [
{
"name": "腹腔镜胆囊切除术",
"description": "腹腔镜胆囊切除术LC手术器械与解剖结构语义分割模板共35个分类",
"color": "#06b6d4",
"z_index": 0,
"classes": _with_reserved_unclassified_class(_template_classes(
"腹腔镜胆囊切除术",
[
'', '线', '肿瘤', '血管阻断夹', '棉球', '双极电凝',
'肝脏', '胆囊', '分离钳', '脂肪', '止血海绵', '肝总管',
'吸引器', '剪刀', '超声刀', '止血纱布', '胆总管', '生物夹',
'无损伤钳', '钳夹', '喷洒', '胆囊管', '动脉', '电凝',
'静脉', '标本袋', '引流管', '纱布', '金属钛夹', '韧带',
'肝蒂', '推结器', '乳胶管-血管阻断', '吻合器', '术中超声',
],
[
(134, 124, 118), (0, 157, 142), (245, 161, 0), (255, 172, 159), (146, 175, 236), (155, 62, 0),
(255, 91, 0), (255, 234, 0), (85, 111, 181), (155, 132, 0), (181, 227, 14), (72, 0, 255),
(255, 0, 255), (29, 32, 136), (240, 16, 116), (160, 15, 95), (0, 155, 33), (0, 160, 233),
(52, 184, 178), (66, 115, 82), (90, 120, 41), (255, 0, 0), (117, 0, 0), (167, 24, 233),
(42, 8, 66), (112, 113, 150), (0, 255, 0), (255, 255, 255), (0, 255, 255), (181, 85, 105),
(113, 102, 140), (202, 202, 200), (197, 83, 181), (136, 162, 196), (138, 251, 213),
],
id_prefix="cls-lap",
)),
},
{
"name": "头颈部CT分割",
"description": "头颈部CT分割",
"color": "#ef4444",
"z_index": 10,
"classes": _with_reserved_unclassified_class(_template_classes(
"头颈部CT分割",
[
"肿瘤/结节 (Tumor/Nodule)",
"下颌骨 (Mandible)",
"甲状腺 (Thyroid)",
"气管 (Trachea)",
"颈椎 (Cervical Spine)",
"颈动脉 (Carotid Artery)",
"颈静脉 (Jugular Vein)",
"腮腺 (Parotid Gland)",
"下颌下腺 (Submandibular Gland)",
"舌骨 (Hyoid Bone)",
],
[
(255, 0, 0),
(0, 255, 0),
(0, 0, 255),
(255, 255, 0),
(255, 0, 255),
(0, 255, 255),
(255, 128, 0),
(128, 0, 128),
(0, 128, 128),
(128, 128, 0),
],
id_prefix="cls-head-neck-ct",
)),
},
]
for definition in default_templates:
existing = db.query(Template).filter(
Template.name == definition["name"],
Template.owner_user_id.is_(None),
).first()
if existing is not None:
continue
template = Template(
name=definition["name"],
description=definition["description"],
color=definition["color"],
z_index=definition["z_index"],
mapping_rules={"classes": definition["classes"], "rules": []},
owner_user_id=None,
)
db.add(template)
logger.info("Seeded default template '%s' with %d classes", definition["name"], len(definition["classes"]))
db.commit()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown hooks.""" """Application lifespan: startup and shutdown hooks."""

View File

@@ -9,7 +9,6 @@ from sqlalchemy.orm import Session
from config import settings from config import settings
from database import get_db from database import get_db
from minio_client import upload_file
from models import Annotation, AuditLog, Frame, Mask, ProcessingTask, Project, Template, User from models import Annotation, AuditLog, Frame, Mask, ProcessingTask, Project, Template, User
from routers.auth import ensure_default_admin, hash_password, require_admin, write_audit_log from routers.auth import ensure_default_admin, hash_password, require_admin, write_audit_log
from schemas import ( from schemas import (
@@ -20,13 +19,19 @@ from schemas import (
DemoFactoryResetRequest, DemoFactoryResetRequest,
UserOut, UserOut,
) )
from statuses import PROJECT_STATUS_PENDING from services.demo_media import (
DEMO_DICOM_PROJECT_NAME,
DEMO_VIDEO_PROJECT_NAME,
create_parsed_dicom_demo_project,
create_unparsed_video_demo_project,
demo_dicom_files,
)
router = APIRouter(prefix="/api/admin", tags=["Admin"]) router = APIRouter(prefix="/api/admin", tags=["Admin"])
VALID_ROLES = {"admin", "annotator", "viewer"} VALID_ROLES = {"admin", "annotator", "viewer"}
DEMO_RESET_CONFIRMATION = "RESET_DEMO_FACTORY" DEMO_RESET_CONFIRMATION = "RESET_DEMO_FACTORY"
DEMO_PROJECT_NAME = "Data_MyVideo_1" DEMO_PROJECT_NAME = DEMO_DICOM_PROJECT_NAME
def _normalize_role(role: str | None) -> str: def _normalize_role(role: str | None) -> str:
@@ -191,7 +196,7 @@ def reset_demo_factory(
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin_user: User = Depends(require_admin), admin_user: User = Depends(require_admin),
) -> dict: ) -> dict:
"""Reset a demo deployment to one admin account and one unparsed demo video project.""" """Reset a demo deployment to one admin account, the demo video, and the demo DICOM project."""
if payload.confirmation != DEMO_RESET_CONFIRMATION: if payload.confirmation != DEMO_RESET_CONFIRMATION:
raise HTTPException(status_code=400, detail="Invalid reset confirmation") raise HTTPException(status_code=400, detail="Invalid reset confirmation")
@@ -200,6 +205,11 @@ def reset_demo_factory(
status_code=409, status_code=409,
detail=f"Demo video not found: {settings.demo_video_path}", detail=f"Demo video not found: {settings.demo_video_path}",
) )
if not demo_dicom_files(settings.demo_dicom_dir):
raise HTTPException(
status_code=409,
detail=f"Demo DICOM series not found: {settings.demo_dicom_dir}",
)
requested_by = admin_user.username requested_by = admin_user.username
preserved_admin = ensure_default_admin(db) preserved_admin = ensure_default_admin(db)
@@ -226,37 +236,39 @@ def reset_demo_factory(
if not preserved_admin: if not preserved_admin:
raise HTTPException(status_code=500, detail="Default admin was not preserved") raise HTTPException(status_code=500, detail="Default admin was not preserved")
project = Project( video_project = create_unparsed_video_demo_project(
name=DEMO_PROJECT_NAME, db,
description="默认演示视频,尚未生成帧", owner=preserved_admin,
status=PROJECT_STATUS_PENDING, video_path=settings.demo_video_path,
source_type="video", project_name=DEMO_VIDEO_PROJECT_NAME,
parse_fps=30.0,
owner_user_id=preserved_admin.id,
) )
db.add(project) video_project.frame_count = 0
db.flush()
with open(settings.demo_video_path, "rb") as file_obj: dicom_project = create_parsed_dicom_demo_project(
data = file_obj.read() db,
object_name = f"uploads/{project.id}/{os.path.basename(settings.demo_video_path)}" owner=preserved_admin,
upload_file(object_name, data, content_type="video/mp4", length=len(data)) dicom_dir=settings.demo_dicom_dir,
project.video_path = object_name project_name=DEMO_PROJECT_NAME,
project.thumbnail_url = None )
project.original_fps = None
db.commit()
db.refresh(preserved_admin) db.refresh(preserved_admin)
db.refresh(project) db.refresh(video_project)
db.refresh(dicom_project)
video_project.frame_count = len(video_project.frames)
dicom_project.frame_count = len(dicom_project.frames)
projects = [video_project, dicom_project]
write_audit_log( write_audit_log(
db, db,
actor=preserved_admin, actor=preserved_admin,
action="admin.demo_factory_reset", action="admin.demo_factory_reset",
target_type="project", target_type="project",
target_id=project.id, target_id=dicom_project.id,
detail={ detail={
"project_name": project.name, "project_names": [project.name for project in projects],
"video_path": project.video_path, "video_path": video_project.video_path,
"dicom_path": dicom_project.video_path,
"source_types": [project.source_type for project in projects],
"frame_counts": {project.name: len(project.frames) for project in projects},
"deleted_counts": deleted_counts, "deleted_counts": deleted_counts,
"requested_by": requested_by, "requested_by": requested_by,
}, },
@@ -264,7 +276,8 @@ def reset_demo_factory(
return { return {
"admin_user": preserved_admin, "admin_user": preserved_admin,
"project": project, "project": dicom_project,
"projects": projects,
"deleted_counts": deleted_counts, "deleted_counts": deleted_counts,
"message": "演示环境已恢复出厂设置", "message": "演示环境已恢复出厂设置",
} }

View File

@@ -39,6 +39,10 @@ from services.sam_registry import ModelUnavailableError, sam_registry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/ai", tags=["AI"]) router = APIRouter(prefix="/api/ai", tags=["AI"])
GT_MASK_EMPTY_DETAIL = "GT Mask 图片中没有非背景 maskid 区域。"
GT_IMPORT_MAX_CONTOUR_POINTS = 2048
GT_IMPORT_CONTOUR_EPSILON_RATIO = 0.00075
GT_IMPORT_MIN_CONTOUR_EPSILON = 0.35
def _owned_project_or_404(project_id: int, db: Session, current_user: User) -> Project: def _owned_project_or_404(project_id: int, db: Session, current_user: User) -> Project:
@@ -152,13 +156,19 @@ def _load_frame_image(frame: Frame) -> np.ndarray:
def _normalized_contour(contour: np.ndarray, width: int, height: int) -> list[list[float]]: def _normalized_contour(contour: np.ndarray, width: int, height: int) -> list[list[float]]:
"""Approximate a contour and convert it to normalized polygon coordinates.""" """Convert a contour to a detailed normalized polygon with a point-count cap."""
arc_length = cv2.arcLength(contour, True) arc_length = cv2.arcLength(contour, True)
epsilon = max(1.0, arc_length * 0.01) epsilon = max(GT_IMPORT_MIN_CONTOUR_EPSILON, arc_length * GT_IMPORT_CONTOUR_EPSILON_RATIO)
approx = cv2.approxPolyDP(contour, epsilon, True) approx = cv2.approxPolyDP(contour, epsilon, True)
while len(approx) > GT_IMPORT_MAX_CONTOUR_POINTS and epsilon < arc_length * 0.02:
epsilon *= 1.5
approx = cv2.approxPolyDP(contour, epsilon, True)
points = approx.reshape(-1, 2) points = approx.reshape(-1, 2)
if len(points) < 3: if len(points) < 3:
points = contour.reshape(-1, 2) points = contour.reshape(-1, 2)
if len(points) > GT_IMPORT_MAX_CONTOUR_POINTS:
step = int(math.ceil(len(points) / GT_IMPORT_MAX_CONTOUR_POINTS))
points = points[::step]
return [ return [
[ [
min(max(float(x) / max(width, 1), 0.0), 1.0), min(max(float(x) / max(width, 1), 0.0), 1.0),
@@ -977,6 +987,13 @@ async def import_gt_mask(
if image is None: if image is None:
raise HTTPException(status_code=400, detail="Invalid mask image") raise HTTPException(status_code=400, detail="Invalid mask image")
invalid_format_detail = (
"GT Mask 图片不符合要求:仅支持 8-bit 灰度图,或 8-bit RGB 三通道完全相同的 maskid 图"
"(背景 0像素值为 1-255 的 maskid"
)
if image.dtype != np.uint8:
raise HTTPException(status_code=400, detail=invalid_format_detail)
if image.ndim == 2: if image.ndim == 2:
label_image = image label_image = image
elif image.ndim == 3 and image.shape[2] >= 3: elif image.ndim == 3 and image.shape[2] >= 3:
@@ -984,16 +1001,10 @@ async def import_gt_mask(
# GT label images are maskid maps: either grayscale or RGB/BGR where # GT label images are maskid maps: either grayscale or RGB/BGR where
# all three color channels contain the same maskid value [X, X, X]. # all three color channels contain the same maskid value [X, X, X].
if not (np.array_equal(channels[:, :, 0], channels[:, :, 1]) and np.array_equal(channels[:, :, 1], channels[:, :, 2])): if not (np.array_equal(channels[:, :, 0], channels[:, :, 1]) and np.array_equal(channels[:, :, 1], channels[:, :, 2])):
raise HTTPException( raise HTTPException(status_code=400, detail=invalid_format_detail)
status_code=400,
detail="GT Mask 图片不符合要求:请上传灰度图,或 RGB 三通道完全相同的 maskid 图(背景 0像素值为 maskid",
)
label_image = channels[:, :, 0] label_image = channels[:, :, 0]
else: else:
raise HTTPException( raise HTTPException(status_code=400, detail=invalid_format_detail)
status_code=400,
detail="GT Mask 图片不符合要求:请上传灰度图,或 RGB 三通道完全相同的 maskid 图(背景 0像素值为 maskid",
)
width = int(frame.width or image.shape[1]) width = int(frame.width or image.shape[1])
height = int(frame.height or image.shape[0]) height = int(frame.height or image.shape[0])
@@ -1041,12 +1052,12 @@ async def import_gt_mask(
if not import_items: if not import_items:
if skipped_unknown > 0: if skipped_unknown > 0:
raise HTTPException(status_code=400, detail="No matching GT mask classes found") raise HTTPException(status_code=400, detail="No matching GT mask classes found")
raise HTTPException(status_code=400, detail="No foreground mask regions found") raise HTTPException(status_code=400, detail=GT_MASK_EMPTY_DETAIL)
annotations: list[Annotation] = [] annotations: list[Annotation] = []
for item in import_items: for item in import_items:
binary = item["binary"] binary = item["binary"]
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
for contour in contours: for contour in contours:
if cv2.contourArea(contour) < 1: if cv2.contourArea(contour) < 1:
@@ -1085,7 +1096,7 @@ async def import_gt_mask(
annotations.append(annotation) annotations.append(annotation)
if not annotations: if not annotations:
raise HTTPException(status_code=400, detail="No foreground mask regions found") raise HTTPException(status_code=400, detail=GT_MASK_EMPTY_DETAIL)
db.commit() db.commit()
for annotation in annotations: for annotation in annotations:

View File

@@ -64,7 +64,7 @@ def _annotation_mask_id(annotation: Annotation) -> int | None:
value = int(class_meta[key]) value = int(class_meta[key])
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
if value > 0: if value >= 0:
return value return value
return None return None
@@ -361,7 +361,7 @@ def _build_gt_class_mapping(annotations: list[Annotation]) -> tuple[dict[str, in
ordered = sorted( ordered = sorted(
entries_by_key.values(), entries_by_key.values(),
key=lambda item: ( key=lambda item: (
item["maskidHint"] if isinstance(item.get("maskidHint"), int) and item["maskidHint"] > 0 else 10_000_000, item["maskidHint"] if isinstance(item.get("maskidHint"), int) and item["maskidHint"] >= 0 else 10_000_000,
str(item["className"]), str(item["className"]),
str(item["key"]), str(item["key"]),
), ),
@@ -375,6 +375,8 @@ def _build_gt_class_mapping(annotations: list[Annotation]) -> tuple[dict[str, in
nonlocal next_maskid nonlocal next_maskid
while next_maskid in used_maskids: while next_maskid in used_maskids:
next_maskid += 1 next_maskid += 1
if next_maskid > 255:
raise HTTPException(status_code=400, detail="GT_label 仅支持 8-bit maskid类别值必须在 1-255 之间")
value = next_maskid value = next_maskid
used_maskids.add(value) used_maskids.add(value)
next_maskid += 1 next_maskid += 1
@@ -382,7 +384,12 @@ def _build_gt_class_mapping(annotations: list[Annotation]) -> tuple[dict[str, in
for entry in ordered: for entry in ordered:
hinted_maskid = entry.get("maskidHint") hinted_maskid = entry.get("maskidHint")
if isinstance(hinted_maskid, int) and hinted_maskid > 0 and hinted_maskid not in used_maskids: if isinstance(hinted_maskid, int) and hinted_maskid > 255:
raise HTTPException(status_code=400, detail="GT_label 仅支持 8-bit maskid类别值必须在 1-255 之间")
if isinstance(hinted_maskid, int) and hinted_maskid == 0:
maskid = 0
used_maskids.add(maskid)
elif isinstance(hinted_maskid, int) and 0 < hinted_maskid <= 255 and hinted_maskid not in used_maskids:
maskid = hinted_maskid maskid = hinted_maskid
used_maskids.add(maskid) used_maskids.add(maskid)
else: else:
@@ -513,7 +520,7 @@ def _write_result_mask_outputs(
) )
needs_fused_output = include_semantic or include_pro_label or include_mix_label needs_fused_output = include_semantic or include_pro_label or include_mix_label
semantic = np.zeros((height, width), dtype=np.uint16) if needs_fused_output else None semantic = np.zeros((height, width), dtype=np.uint8) if needs_fused_output else None
pro_label = np.zeros((height, width, 3), dtype=np.uint8) if (include_pro_label or include_mix_label) else None pro_label = np.zeros((height, width, 3), dtype=np.uint8) if (include_pro_label or include_mix_label) else None
if needs_fused_output: if needs_fused_output:

View File

@@ -1,6 +1,7 @@
"""Media upload and parsing endpoints.""" """Media upload and parsing endpoints."""
import logging import logging
import re
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
@@ -22,6 +23,13 @@ router = APIRouter(prefix="/api/media", tags=["Media"])
ALLOWED_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".png", ".jpg", ".jpeg", ".dcm"} ALLOWED_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".png", ".jpg", ".jpeg", ".dcm"}
def natural_filename_key(filename: str) -> tuple[object, ...]:
return tuple(
int(part) if part.isdigit() else part.casefold()
for part in re.split(r"(\d+)", Path(filename).name)
)
def _get_ext(filename: str) -> str: def _get_ext(filename: str) -> str:
return Path(filename).suffix.lower() return Path(filename).suffix.lower()
@@ -124,6 +132,12 @@ async def upload_dicom_batch(
if not files: if not files:
raise HTTPException(status_code=400, detail="No files uploaded") raise HTTPException(status_code=400, detail="No files uploaded")
sorted_files = sorted(
[file for file in files if file.filename and file.filename.lower().endswith(".dcm")],
key=lambda file: natural_filename_key(file.filename or ""),
)
if not sorted_files:
raise HTTPException(status_code=400, detail="No valid DICOM files uploaded")
uploaded = [] uploaded = []
if project_id: if project_id:
@@ -135,10 +149,10 @@ async def upload_dicom_batch(
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
else: else:
# Create new DICOM project # Create new DICOM project
first_name = files[0].filename or "DICOM_Series" first_name = sorted_files[0].filename or "DICOM_Series"
project = Project( project = Project(
name=first_name, name=first_name,
description=f"DICOM series with {len(files)} files", description=f"DICOM series with {len(sorted_files)} files",
status=PROJECT_STATUS_PENDING, status=PROJECT_STATUS_PENDING,
source_type="dicom", source_type="dicom",
owner_user_id=current_user.id, owner_user_id=current_user.id,
@@ -149,9 +163,7 @@ async def upload_dicom_batch(
project_id = project.id project_id = project.id
logger.info("Auto-created DICOM project id=%s", project_id) logger.info("Auto-created DICOM project id=%s", project_id)
for file in files: for file in sorted_files:
if not file.filename or not file.filename.lower().endswith(".dcm"):
continue
data = await file.read() data = await file.read()
object_name = f"uploads/{project_id}/dicom/{file.filename}" object_name = f"uploads/{project_id}/dicom/{file.filename}"
try: try:

View File

@@ -7,15 +7,38 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import Project, Frame, User from models import Annotation, Mask, Project, Frame, User
from routers.auth import get_current_user, require_editor from routers.auth import get_current_user, require_editor
from schemas import ProjectCreate, ProjectOut, ProjectUpdate, FrameCreate, FrameOut from schemas import ProjectCopyRequest, ProjectCreate, ProjectOut, ProjectUpdate, FrameCreate, FrameOut
from minio_client import get_presigned_url from minio_client import get_presigned_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["Projects"]) router = APIRouter(prefix="/api/projects", tags=["Projects"])
def _next_project_copy_name(db: Session, owner_user_id: int, source_name: str) -> str:
base_name = f"{source_name} 副本"
existing_names = {
row[0]
for row in db.query(Project.name)
.filter(Project.owner_user_id == owner_user_id, Project.name.like(f"{base_name}%"))
.all()
}
if base_name not in existing_names:
return base_name
suffix = 2
while f"{base_name} {suffix}" in existing_names:
suffix += 1
return f"{base_name} {suffix}"
def _prepare_project_response(project: Project) -> Project:
project.frame_count = len(project.frames)
if project.thumbnail_url:
project.thumbnail_url = get_presigned_url(project.thumbnail_url, expires=3600)
return project
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Projects # Projects
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -59,9 +82,7 @@ def list_projects(
.all() .all()
) )
for p in projects: for p in projects:
p.frame_count = len(p.frames) _prepare_project_response(p)
if p.thumbnail_url:
p.thumbnail_url = get_presigned_url(p.thumbnail_url, expires=3600)
return projects return projects
@@ -82,10 +103,85 @@ def get_project(
).first() ).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
project.frame_count = len(project.frames) return _prepare_project_response(project)
if project.thumbnail_url:
project.thumbnail_url = get_presigned_url(project.thumbnail_url, expires=3600)
return project @router.post(
"/{project_id}/copy",
response_model=ProjectOut,
status_code=status.HTTP_201_CREATED,
summary="Copy a project",
)
def copy_project(
project_id: int,
payload: ProjectCopyRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_editor),
) -> Project:
"""Copy a project. Reset copies media/frame sequence; full also copies annotations and mask metadata."""
source = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
if not source:
raise HTTPException(status_code=404, detail="Project not found")
next_name = (payload.name or "").strip() if payload.name is not None else ""
if not next_name:
next_name = _next_project_copy_name(db, current_user.id, source.name)
copied = Project(
name=next_name,
description=source.description,
video_path=source.video_path,
thumbnail_url=source.thumbnail_url,
status=source.status,
source_type=source.source_type,
original_fps=source.original_fps,
parse_fps=source.parse_fps,
owner_user_id=current_user.id,
)
db.add(copied)
db.flush()
frame_id_map: dict[int, int] = {}
for frame in sorted(source.frames, key=lambda item: item.frame_index):
copied_frame = Frame(
project_id=copied.id,
frame_index=frame.frame_index,
image_url=frame.image_url,
width=frame.width,
height=frame.height,
timestamp_ms=frame.timestamp_ms,
source_frame_number=frame.source_frame_number,
)
db.add(copied_frame)
db.flush()
frame_id_map[frame.id] = copied_frame.id
if payload.mode == "full":
for annotation in sorted(source.annotations, key=lambda item: item.id):
copied_annotation = Annotation(
project_id=copied.id,
frame_id=frame_id_map.get(annotation.frame_id) if annotation.frame_id is not None else None,
template_id=annotation.template_id,
mask_data=annotation.mask_data,
points=annotation.points,
bbox=annotation.bbox,
)
db.add(copied_annotation)
db.flush()
for mask in annotation.masks:
db.add(Mask(
annotation_id=copied_annotation.id,
mask_url=mask.mask_url,
format=mask.format,
))
db.commit()
db.refresh(copied)
logger.info("Copied project id=%s to id=%s mode=%s", project_id, copied.id, payload.mode)
return _prepare_project_response(copied)
@router.patch( @router.patch(
@@ -108,6 +204,10 @@ def update_project(
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
for key, value in payload.model_dump(exclude_unset=True).items(): for key, value in payload.model_dump(exclude_unset=True).items():
if key == "name":
value = (value or "").strip()
if not value:
raise HTTPException(status_code=400, detail="Project name is required")
setattr(project, key, value) setattr(project, key, value)
db.commit() db.commit()

View File

@@ -14,15 +14,38 @@ from schemas import TemplateCreate, TemplateOut, TemplateUpdate
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/templates", tags=["Templates"]) router = APIRouter(prefix="/api/templates", tags=["Templates"])
RESERVED_UNCLASSIFIED_CLASS = {
"id": "reserved-unclassified",
"name": "待分类",
"color": "#000000",
"zIndex": 0,
"maskId": 0,
"category": "系统保留",
}
def _is_reserved_class(item: dict) -> bool:
return (
item.get("id") == RESERVED_UNCLASSIFIED_CLASS["id"]
or item.get("name") == RESERVED_UNCLASSIFIED_CLASS["name"]
or item.get("maskId") == 0
)
def _normalize_template_classes(classes: list[dict] | None) -> list[dict]:
normalized = [item for item in (classes or []) if not _is_reserved_class(item)]
return [*normalized, dict(RESERVED_UNCLASSIFIED_CLASS)]
def _pack_mapping_rules(data: dict) -> dict: def _pack_mapping_rules(data: dict) -> dict:
"""Pack classes/rules into mapping_rules for DB storage.""" """Pack classes/rules into mapping_rules for DB storage."""
mapping = data.get("mapping_rules") or {} mapping = data.get("mapping_rules") or {}
if "classes" in data and data["classes"] is not None: if "classes" in data and data["classes"] is not None:
mapping["classes"] = data.pop("classes") mapping["classes"] = _normalize_template_classes(data.pop("classes"))
if "rules" in data and data["rules"] is not None: if "rules" in data and data["rules"] is not None:
mapping["rules"] = data.pop("rules") mapping["rules"] = data.pop("rules")
if "classes" in mapping:
mapping["classes"] = _normalize_template_classes(mapping.get("classes"))
data["mapping_rules"] = mapping data["mapping_rules"] = mapping
return data return data
@@ -31,7 +54,7 @@ def _unpack_template(template: Template) -> Template:
"""Unpack mapping_rules into classes/rules for response.""" """Unpack mapping_rules into classes/rules for response."""
mapping = template.mapping_rules or {} mapping = template.mapping_rules or {}
# Set as attributes so Pydantic from_attributes can pick them up # Set as attributes so Pydantic from_attributes can pick them up
template.classes = mapping.get("classes", []) template.classes = _normalize_template_classes(mapping.get("classes", []))
template.rules = mapping.get("rules", []) template.rules = mapping.get("rules", [])
return template return template

View File

@@ -1,7 +1,7 @@
"""Pydantic schemas for request/response validation.""" """Pydantic schemas for request/response validation."""
from datetime import datetime from datetime import datetime
from typing import Optional, Any from typing import Literal, Optional, Any
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -83,6 +83,11 @@ class ProjectUpdate(BaseModel):
parse_fps: Optional[float] = None parse_fps: Optional[float] = None
class ProjectCopyRequest(BaseModel):
mode: Literal["reset", "full"] = "reset"
name: Optional[str] = None
class ProjectOut(ProjectBase): class ProjectOut(ProjectBase):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -96,6 +101,7 @@ class ProjectOut(ProjectBase):
class DemoFactoryResetOut(BaseModel): class DemoFactoryResetOut(BaseModel):
admin_user: UserOut admin_user: UserOut
project: ProjectOut project: ProjectOut
projects: list[ProjectOut]
deleted_counts: dict[str, int] deleted_counts: dict[str, int]
message: str message: str

View File

@@ -0,0 +1,128 @@
"""Helpers for seeding the bundled demo media project."""
from __future__ import annotations
import os
import shutil
import tempfile
from pathlib import Path
import cv2
from sqlalchemy.orm import Session
from minio_client import upload_file
from models import Frame, Project, User
from services.frame_parser import natural_filename_key, parse_dicom, upload_frames_to_minio
from statuses import PROJECT_STATUS_PENDING, PROJECT_STATUS_READY
DEMO_DICOM_PROJECT_NAME = "演示DICOM序列"
DEMO_DICOM_PARSE_FPS = 30.0
DEMO_VIDEO_PROJECT_NAME = "Data_MyVideo_1"
def demo_dicom_files(dicom_dir: str) -> list[Path]:
"""Return .dcm files in natural file-name order."""
root = Path(dicom_dir)
if not root.exists() or not root.is_dir():
return []
return sorted(
[path for path in root.iterdir() if path.is_file() and path.name.lower().endswith(".dcm")],
key=lambda path: natural_filename_key(path.name),
)
def create_unparsed_video_demo_project(
db: Session,
*,
owner: User,
video_path: str,
project_name: str = DEMO_VIDEO_PROJECT_NAME,
) -> Project:
"""Create the bundled demo video project without extracting frames."""
source = Path(video_path)
if not source.exists() or not source.is_file():
raise FileNotFoundError(f"Demo video not found: {video_path}")
project = Project(
name=project_name,
description="默认演示视频,尚未生成帧",
status=PROJECT_STATUS_PENDING,
source_type="video",
parse_fps=30.0,
original_fps=None,
owner_user_id=owner.id,
)
db.add(project)
db.flush()
data = source.read_bytes()
object_name = f"uploads/{project.id}/{source.name}"
upload_file(object_name, data, content_type="video/mp4", length=len(data))
project.video_path = object_name
project.thumbnail_url = None
db.commit()
db.refresh(project)
return project
def create_parsed_dicom_demo_project(
db: Session,
*,
owner: User,
dicom_dir: str,
project_name: str = DEMO_DICOM_PROJECT_NAME,
) -> Project:
"""Create the demo DICOM project, upload the series, and register parsed frames."""
dcm_files = demo_dicom_files(dicom_dir)
if not dcm_files:
raise FileNotFoundError(f"Demo DICOM series not found: {dicom_dir}")
project = Project(
name=project_name,
description=f"默认演示 DICOM 序列,已按文件名自然顺序生成 {len(dcm_files)}",
status=PROJECT_STATUS_PENDING,
source_type="dicom",
parse_fps=DEMO_DICOM_PARSE_FPS,
original_fps=None,
owner_user_id=owner.id,
)
db.add(project)
db.flush()
dicom_prefix = f"uploads/{project.id}/dicom"
for dcm_file in dcm_files:
data = dcm_file.read_bytes()
upload_file(
f"{dicom_prefix}/{dcm_file.name}",
data,
content_type="application/dicom",
length=len(data),
)
project.video_path = dicom_prefix
tmp_dir = tempfile.mkdtemp(prefix=f"seg_demo_dicom_{project.id}_")
try:
output_dir = os.path.join(tmp_dir, "frames")
frame_files = parse_dicom(dicom_dir, output_dir)
object_names = upload_frames_to_minio(frame_files, project.id)
for idx, obj_name in enumerate(object_names):
image = cv2.imread(frame_files[idx])
height, width = image.shape[:2] if image is not None else (None, None)
db.add(Frame(
project_id=project.id,
frame_index=idx,
image_url=obj_name,
width=width,
height=height,
timestamp_ms=idx * 1000.0 / DEMO_DICOM_PARSE_FPS,
source_frame_number=idx,
))
if object_names:
project.thumbnail_url = object_names[0]
project.status = PROJECT_STATUS_READY
db.commit()
db.refresh(project)
return project
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)

View File

@@ -2,6 +2,7 @@
import logging import logging
import os import os
import re
import shutil import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -16,6 +17,14 @@ from minio_client import upload_file, BUCKET_NAME
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def natural_filename_key(filename: str) -> Tuple[object, ...]:
"""Sort file names by their visible numeric order instead of pure lexicographic order."""
return tuple(
int(part) if part.isdigit() else part.casefold()
for part in re.split(r"(\d+)", Path(filename).name)
)
def get_video_fps(video_path: str) -> float: def get_video_fps(video_path: str) -> float:
"""Read the original frame rate of a video file.""" """Read the original frame rate of a video file."""
cap = cv2.VideoCapture(video_path) cap = cv2.VideoCapture(video_path)
@@ -150,7 +159,8 @@ def parse_dicom(
""" """
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
dcm_files = sorted( dcm_files = sorted(
[f for f in os.listdir(dicom_dir) if f.lower().endswith(".dcm")] [f for f in os.listdir(dicom_dir) if f.lower().endswith(".dcm")],
key=natural_filename_key,
) )
frame_paths: List[str] = [] frame_paths: List[str] = []

View File

@@ -15,6 +15,7 @@ from models import Frame, ProcessingTask, Project
from progress_events import publish_task_progress_event from progress_events import publish_task_progress_event
from services.frame_parser import ( from services.frame_parser import (
extract_thumbnail, extract_thumbnail,
natural_filename_key,
parse_dicom, parse_dicom,
parse_video, parse_video,
upload_frames_to_minio, upload_frames_to_minio,
@@ -188,7 +189,10 @@ def run_parse_media_task(db: Session, task_id: int) -> dict[str, Any]:
os.makedirs(dcm_dir, exist_ok=True) os.makedirs(dcm_dir, exist_ok=True)
client = get_minio_client() client = get_minio_client()
objects = list(client.list_objects(BUCKET_NAME, prefix=project.video_path, recursive=True)) objects = sorted(
list(client.list_objects(BUCKET_NAME, prefix=project.video_path, recursive=True)),
key=lambda obj: natural_filename_key(obj.object_name),
)
for obj in objects: for obj in objects:
_ensure_not_cancelled(db, task) _ensure_not_cancelled(db, task)
if obj.object_name.lower().endswith(".dcm"): if obj.object_name.lower().endswith(".dcm"):

View File

@@ -1,6 +1,6 @@
from models import Annotation, AuditLog, Frame, Mask, ProcessingTask, Project, Template, User from models import Annotation, AuditLog, Frame, Mask, ProcessingTask, Project, Template, User
from routers.auth import create_access_token, hash_password from routers.auth import create_access_token, hash_password
from statuses import PROJECT_STATUS_PENDING from statuses import PROJECT_STATUS_READY
def test_admin_user_management_and_audit_logs(client, db_session): def test_admin_user_management_and_audit_logs(client, db_session):
@@ -83,17 +83,34 @@ def test_admin_cannot_delete_self_or_user_with_projects(client, db_session):
assert response.status_code == 409 assert response.status_code == 409
def test_demo_factory_reset_leaves_admin_and_unparsed_demo_video(client, db_session, monkeypatch, tmp_path): def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_session, monkeypatch, tmp_path):
video_path = tmp_path / "Data_MyVideo_1.mp4" video_path = tmp_path / "Data_MyVideo_1.mp4"
video_path.write_bytes(b"demo-video") video_path.write_bytes(b"demo-video")
monkeypatch.setattr("routers.admin.settings.demo_video_path", str(video_path)) monkeypatch.setattr("routers.admin.settings.demo_video_path", str(video_path))
dicom_dir = tmp_path / "dicom"
dicom_dir.mkdir()
for name in ["10.dcm", "2.dcm", "1.dcm"]:
(dicom_dir / name).write_bytes(name.encode())
monkeypatch.setattr("routers.admin.settings.demo_dicom_dir", str(dicom_dir))
parsed_frame_paths = []
for idx in range(3):
frame_path = tmp_path / f"frame_{idx:06d}.jpg"
frame_path.write_bytes(b"frame")
parsed_frame_paths.append(str(frame_path))
uploaded = [] uploaded = []
monkeypatch.setattr("routers.admin.upload_file", lambda object_name, data, content_type, length: uploaded.append({ monkeypatch.setattr("services.demo_media.upload_file", lambda object_name, data, content_type, length: uploaded.append({
"object_name": object_name, "object_name": object_name,
"data": data, "data": data,
"content_type": content_type, "content_type": content_type,
"length": length, "length": length,
})) }))
monkeypatch.setattr("services.demo_media.parse_dicom", lambda dicom_dir_arg, output_dir: parsed_frame_paths)
monkeypatch.setattr(
"services.demo_media.upload_frames_to_minio",
lambda frame_files, project_id: [f"projects/{project_id}/frames/frame_{idx:06d}.jpg" for idx, _ in enumerate(frame_files)],
)
extra_user = User(username="doctor", password_hash=hash_password("secret123"), role="annotator", is_active=1) extra_user = User(username="doctor", password_hash=hash_password("secret123"), role="annotator", is_active=1)
db_session.add(extra_user) db_session.add(extra_user)
@@ -113,7 +130,15 @@ def test_demo_factory_reset_leaves_admin_and_unparsed_demo_video(client, db_sess
z_index=1, z_index=1,
owner_user_id=extra_user.id, owner_user_id=extra_user.id,
) )
db_session.add_all([task, private_template]) system_template = Template(
name="头颈部CT分割",
description="头颈部CT分割",
color="#ef4444",
z_index=10,
owner_user_id=None,
mapping_rules={"classes": [{"name": "肿瘤/结节 (Tumor/Nodule)", "color": "#ff0000", "maskId": 1}], "rules": []},
)
db_session.add_all([task, private_template, system_template])
db_session.commit() db_session.commit()
db_session.refresh(frame) db_session.refresh(frame)
annotation = Annotation(project_id=old_project.id, frame_id=frame.id, mask_data={"label": "old"}) annotation = Annotation(project_id=old_project.id, frame_id=frame.id, mask_data={"label": "old"})
@@ -130,24 +155,36 @@ def test_demo_factory_reset_leaves_admin_and_unparsed_demo_video(client, db_sess
data = response.json() data = response.json()
assert data["message"] == "演示环境已恢复出厂设置" assert data["message"] == "演示环境已恢复出厂设置"
assert data["admin_user"]["username"] == "admin" assert data["admin_user"]["username"] == "admin"
assert data["project"]["name"] == "Data_MyVideo_1" assert data["project"]["name"] == "演示DICOM序列"
assert data["project"]["status"] == PROJECT_STATUS_PENDING assert data["project"]["status"] == PROJECT_STATUS_READY
assert data["project"]["frame_count"] == 0 assert data["project"]["source_type"] == "dicom"
assert data["project"]["video_path"] == f"uploads/{data['project']['id']}/Data_MyVideo_1.mp4" assert data["project"]["frame_count"] == 3
assert uploaded == [{ assert data["project"]["video_path"] == f"uploads/{data['project']['id']}/dicom"
"object_name": data["project"]["video_path"], assert [project["name"] for project in data["projects"]] == ["Data_MyVideo_1", "演示DICOM序列"]
"data": b"demo-video", assert data["projects"][0]["status"] == "pending"
"content_type": "video/mp4", assert data["projects"][0]["source_type"] == "video"
"length": len(b"demo-video"), assert data["projects"][0]["frame_count"] == 0
}] assert data["projects"][1]["status"] == PROJECT_STATUS_READY
assert data["projects"][1]["source_type"] == "dicom"
assert data["projects"][1]["frame_count"] == 3
assert [item["object_name"] for item in uploaded] == [
f"uploads/{data['projects'][0]['id']}/Data_MyVideo_1.mp4",
f"uploads/{data['project']['id']}/dicom/1.dcm",
f"uploads/{data['project']['id']}/dicom/2.dcm",
f"uploads/{data['project']['id']}/dicom/10.dcm",
]
assert [item["content_type"] for item in uploaded] == ["video/mp4", "application/dicom", "application/dicom", "application/dicom"]
assert [user.username for user in db_session.query(User).all()] == ["admin"] assert [user.username for user in db_session.query(User).all()] == ["admin"]
assert db_session.query(Project).count() == 1 assert db_session.query(Project).count() == 2
assert db_session.query(Frame).count() == 0 assert db_session.query(Frame).count() == 3
assert [frame.source_frame_number for frame in db_session.query(Frame).order_by(Frame.frame_index).all()] == [0, 1, 2]
assert db_session.query(Annotation).count() == 0 assert db_session.query(Annotation).count() == 0
assert db_session.query(Mask).count() == 0 assert db_session.query(Mask).count() == 0
assert db_session.query(ProcessingTask).count() == 0 assert db_session.query(ProcessingTask).count() == 0
assert db_session.query(Template).filter(Template.owner_user_id.is_not(None)).count() == 0 assert db_session.query(Template).filter(Template.owner_user_id.is_not(None)).count() == 0
preserved_templates = db_session.query(Template).filter(Template.owner_user_id.is_(None)).all()
assert [template.name for template in preserved_templates] == ["头颈部CT分割"]
assert db_session.query(AuditLog).count() == 1 assert db_session.query(AuditLog).count() == 1
assert db_session.query(AuditLog).first().action == "admin.demo_factory_reset" assert db_session.query(AuditLog).first().action == "admin.demo_factory_reset"

View File

@@ -1149,6 +1149,81 @@ def test_import_gt_mask_creates_annotations_with_seed_points(client):
assert 0.0 <= body[0]["points"][0][1] <= 1.0 assert 0.0 <= body[0]["points"][0][1] <= 1.0
def test_import_gt_mask_polygons_work_with_analysis_and_smoothing(client):
project, frame, _ = _create_project_and_frame(client)
mask = np.zeros((360, 640), dtype=np.uint8)
cv2.ellipse(mask, (260, 160), (130, 70), 20, 0, 360, 1, thickness=-1)
ok, encoded = cv2.imencode(".png", mask)
assert ok
response = client.post(
"/api/ai/import-gt-mask",
data={
"project_id": str(project["id"]),
"frame_id": str(frame["id"]),
"label": "Imported GT",
"color": "#22c55e",
},
files={"file": ("mask.png", encoded.tobytes(), "image/png")},
)
assert response.status_code == 201
annotation = response.json()[0]
assert annotation["mask_data"]["source"] == "gt_mask"
analysis = client.post("/api/ai/analyze-mask", json={
"frame_id": frame["id"],
"mask_data": annotation["mask_data"],
"points": annotation["points"],
"bbox": annotation["bbox"],
})
assert analysis.status_code == 200
assert analysis.json()["topology_anchor_count"] == len(annotation["mask_data"]["polygons"][0])
smoothing = client.post("/api/ai/smooth-mask", json={
"frame_id": frame["id"],
"mask_data": annotation["mask_data"],
"points": annotation["points"],
"bbox": annotation["bbox"],
"strength": 35,
})
assert smoothing.status_code == 200
assert smoothing.json()["topology_anchor_count"] == len(smoothing.json()["polygons"][0])
def test_import_gt_mask_preserves_detailed_contours(client):
project, frame, _ = _create_project_and_frame(client)
mask = np.zeros((360, 640), dtype=np.uint8)
center = np.array([320, 180])
vertices = []
for index in range(96):
angle = 2 * np.pi * index / 96
radius = 120 if index % 2 == 0 else 88
vertices.append([
int(center[0] + np.cos(angle) * radius),
int(center[1] + np.sin(angle) * radius),
])
cv2.fillPoly(mask, [np.array(vertices, dtype=np.int32)], 1)
ok, encoded = cv2.imencode(".png", mask)
assert ok
response = client.post(
"/api/ai/import-gt-mask",
data={
"project_id": str(project["id"]),
"frame_id": str(frame["id"]),
"label": "Detailed GT",
"color": "#22c55e",
},
files={"file": ("mask.png", encoded.tobytes(), "image/png")},
)
assert response.status_code == 201
polygon = response.json()[0]["mask_data"]["polygons"][0]
assert len(polygon) > 80
assert len(polygon) <= 2048
def test_import_gt_mask_splits_label_values(client): def test_import_gt_mask_splits_label_values(client):
project, frame, _ = _create_project_and_frame(client) project, frame, _ = _create_project_and_frame(client)
mask = np.zeros((360, 640), dtype=np.uint8) mask = np.zeros((360, 640), dtype=np.uint8)
@@ -1174,7 +1249,27 @@ def test_import_gt_mask_splits_label_values(client):
assert all(len(item["points"]) == 1 for item in body) assert all(len(item["points"]) == 1 for item in body)
def test_import_gt_mask_preserves_low_value_gtlabel_png(client): def test_import_gt_mask_rejects_background_only_label_image(client):
project, frame, _ = _create_project_and_frame(client)
mask = np.zeros((360, 640), dtype=np.uint8)
ok, encoded = cv2.imencode(".png", mask)
assert ok
response = client.post(
"/api/ai/import-gt-mask",
data={
"project_id": str(project["id"]),
"frame_id": str(frame["id"]),
"label": "GT Class",
},
files={"file": ("empty-gt-label.png", encoded.tobytes(), "image/png")},
)
assert response.status_code == 400
assert response.json()["detail"] == "GT Mask 图片中没有非背景 maskid 区域。"
def test_import_gt_mask_accepts_uint8_low_value_gtlabel_png(client):
project, frame, _ = _create_project_and_frame(client) project, frame, _ = _create_project_and_frame(client)
template = client.post("/api/templates", json={ template = client.post("/api/templates", json={
"name": "GTLabel Template", "name": "GTLabel Template",
@@ -1185,7 +1280,7 @@ def test_import_gt_mask_preserves_low_value_gtlabel_png(client):
], ],
"rules": [], "rules": [],
}).json() }).json()
mask = np.zeros((360, 640), dtype=np.uint16) mask = np.zeros((360, 640), dtype=np.uint8)
cv2.rectangle(mask, (40, 40), (140, 140), 1, thickness=-1) cv2.rectangle(mask, (40, 40), (140, 140), 1, thickness=-1)
ok, encoded = cv2.imencode(".png", mask) ok, encoded = cv2.imencode(".png", mask)
assert ok assert ok
@@ -1241,7 +1336,7 @@ def test_import_gt_mask_rejects_rgb_color_masks(client):
assert "RGB 三通道完全相同" in response.json()["detail"] assert "RGB 三通道完全相同" in response.json()["detail"]
def test_import_gt_mask_reads_uint16_gt_label_and_maps_maskid_class(client): def test_import_gt_mask_rejects_uint16_gt_label(client):
project, frame, _ = _create_project_and_frame(client) project, frame, _ = _create_project_and_frame(client)
template = client.post("/api/templates", json={ template = client.post("/api/templates", json={
"name": "Label Template", "name": "Label Template",
@@ -1266,13 +1361,8 @@ def test_import_gt_mask_reads_uint16_gt_label_and_maps_maskid_class(client):
files={"file": ("gt_label.png", encoded.tobytes(), "image/png")}, files={"file": ("gt_label.png", encoded.tobytes(), "image/png")},
) )
assert response.status_code == 201 assert response.status_code == 400
body = response.json() assert "仅支持 8-bit" in response.json()["detail"]
assert len(body) == 1
assert body[0]["mask_data"]["gt_label_value"] == 1
assert body[0]["mask_data"]["label"] == "肿瘤"
assert body[0]["mask_data"]["class"]["maskId"] == 1
assert body[0]["mask_data"]["class"]["color"] == "#ff0000"
def test_import_gt_mask_handles_unknown_maskid_policy_and_resizes_to_frame(client): def test_import_gt_mask_handles_unknown_maskid_policy_and_resizes_to_frame(client):

View File

@@ -169,6 +169,7 @@ def test_export_results_zip_contains_coco_original_images_and_selected_mask_outp
"key": f"template:{annotation['template_id']}", "key": f"template:{annotation['template_id']}",
"template_id": annotation["template_id"], "template_id": annotation["template_id"],
}] }]
assert gt_label.dtype == np.uint8
assert gt_label[0, 0] == 0 assert gt_label[0, 0] == 0
assert gt_label[20, 50] == 1 assert gt_label[20, 50] == 1
assert pro_label[20, 50].tolist() == [212, 182, 6] assert pro_label[20, 50].tolist() == [212, 182, 6]
@@ -234,6 +235,7 @@ def test_export_results_uses_internal_layer_order_for_gt_pro_and_mix_outputs(cli
cv2.IMREAD_COLOR, cv2.IMREAD_COLOR,
) )
assert gt_label.dtype == np.uint8
assert gt_label[10, 10] == high_value assert gt_label[10, 10] == high_value
assert pro_label[10, 10].tolist() == [0, 0, 255] assert pro_label[10, 10].tolist() == [0, 0, 255]
assert mix_label[10, 10].tolist() == [127, 127, 255] assert mix_label[10, 10].tolist() == [127, 127, 255]
@@ -365,10 +367,74 @@ def test_export_results_preserves_template_maskid_consistently_across_frames(cli
"key": "class:tumor", "key": "class:tumor",
"template_id": None, "template_id": None,
}] }]
assert first_label.dtype == np.uint8
assert second_label.dtype == np.uint8
assert first_label[5, 5] == 7 assert first_label[5, 5] == 7
assert second_label[5, 5] == 7 assert second_label[5, 5] == 7
def test_export_results_keeps_unclassified_maskid_zero_black_in_gt_and_pro(client, monkeypatch):
monkeypatch.setattr("routers.export.download_file", lambda object_name: _fake_image_bytes(20, 20))
project = client.post("/api/projects", json={
"name": "Unclassified Export Project",
"video_path": "uploads/8/unclassified.mp4",
}).json()
frame = client.post(f"/api/projects/{project['id']}/frames", json={
"project_id": project["id"],
"frame_index": 0,
"image_url": "frames/source.jpg",
"width": 20,
"height": 20,
"timestamp_ms": 0,
}).json()
client.post("/api/ai/annotate", json={
"project_id": project["id"],
"frame_id": frame["id"],
"mask_data": {
"polygons": [[[0.1, 0.1], [0.8, 0.1], [0.8, 0.8], [0.1, 0.8]]],
"label": "待分类",
"color": "#000000",
"class": {
"id": "reserved-unclassified",
"name": "待分类",
"color": "#000000",
"maskId": 0,
"zIndex": 0,
},
},
})
response = client.get(f"/api/export/{project['id']}/results?scope=all&outputs=gt_label,pro_label")
assert response.status_code == 200
with zipfile.ZipFile(BytesIO(response.content)) as archive:
mapping = json.loads(archive.read("maskid_GT像素值_类别映射.json"))
stem = "unclassified_0h00m00s000ms_frame000001"
gt_label = cv2.imdecode(
np.frombuffer(archive.read(f"GT_label图/{stem}.png"), dtype=np.uint8),
cv2.IMREAD_UNCHANGED,
)
pro_label = cv2.imdecode(
np.frombuffer(archive.read(f"Pro_label彩色分割结果/{stem}.png"), dtype=np.uint8),
cv2.IMREAD_COLOR,
)
assert mapping["classes"] == [{
"gt_pixel_value": 0,
"maskid": 0,
"chineseName": "待分类",
"className": "待分类",
"categoryName": "",
"rgb": [0, 0, 0],
"color": "#000000",
"key": "class:reserved-unclassified",
"template_id": None,
}]
assert gt_label.dtype == np.uint8
assert gt_label[5, 5] == 0
assert pro_label[5, 5].tolist() == [0, 0, 0]
def test_exported_gtlabel_round_trips_through_gt_mask_import_with_template_maskid(client, monkeypatch): def test_exported_gtlabel_round_trips_through_gt_mask_import_with_template_maskid(client, monkeypatch):
monkeypatch.setattr("routers.export.download_file", lambda object_name: _fake_image_bytes(20, 20)) monkeypatch.setattr("routers.export.download_file", lambda object_name: _fake_image_bytes(20, 20))
project = client.post("/api/projects", json={ project = client.post("/api/projects", json={
@@ -423,6 +489,7 @@ def test_exported_gtlabel_round_trips_through_gt_mask_import_with_template_maski
gt_label = cv2.imdecode(np.frombuffer(exported_gt_label, dtype=np.uint8), cv2.IMREAD_UNCHANGED) gt_label = cv2.imdecode(np.frombuffer(exported_gt_label, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
mapping = json.loads(archive.read("maskid_GT像素值_类别映射.json")) mapping = json.loads(archive.read("maskid_GT像素值_类别映射.json"))
assert gt_label.dtype == np.uint8
assert gt_label[5, 5] == 7 assert gt_label[5, 5] == 7
assert mapping["classes"][0]["maskid"] == 7 assert mapping["classes"][0]["maskid"] == 7
@@ -446,6 +513,36 @@ def test_exported_gtlabel_round_trips_through_gt_mask_import_with_template_maski
assert imported[0]["mask_data"]["class"]["maskId"] == 7 assert imported[0]["mask_data"]["class"]["maskId"] == 7
def test_export_results_rejects_gtlabel_maskid_outside_uint8_range(client, monkeypatch):
monkeypatch.setattr("routers.export.download_file", lambda object_name: _fake_image_bytes(20, 20))
project = client.post("/api/projects", json={
"name": "Large MaskId Project",
"video_path": "uploads/8/large-maskid.mp4",
}).json()
frame = client.post(f"/api/projects/{project['id']}/frames", json={
"project_id": project["id"],
"frame_index": 0,
"image_url": "frames/source.jpg",
"width": 20,
"height": 20,
}).json()
client.post("/api/ai/annotate", json={
"project_id": project["id"],
"frame_id": frame["id"],
"mask_data": {
"polygons": [[[0.1, 0.1], [0.8, 0.1], [0.8, 0.8], [0.1, 0.8]]],
"label": "TooLarge",
"color": "#ff0000",
"class": {"id": "too-large", "name": "TooLarge", "color": "#ff0000", "maskId": 300, "zIndex": 30},
},
})
response = client.get(f"/api/export/{project['id']}/results?scope=all&outputs=gt_label")
assert response.status_code == 400
assert "8-bit maskid" in response.json()["detail"]
def test_export_missing_project_returns_404(client): def test_export_missing_project_returns_404(client):
assert client.get("/api/export/999/coco").status_code == 404 assert client.get("/api/export/999/coco").status_code == 404
assert client.get("/api/export/999/masks").status_code == 404 assert client.get("/api/export/999/masks").status_code == 404

View File

@@ -48,15 +48,37 @@ def test_upload_dicom_batch_filters_files_and_creates_project(client, monkeypatc
response = client.post( response = client.post(
"/api/media/upload/dicom", "/api/media/upload/dicom",
files=[ files=[
("files", ("a.dcm", b"dcm", "application/dicom")), ("files", ("10.dcm", b"dcm10", "application/dicom")),
("files", ("skip.txt", b"text", "text/plain")), ("files", ("skip.txt", b"text", "text/plain")),
("files", ("2.dcm", b"dcm2", "application/dicom")),
("files", ("1.dcm", b"dcm1", "application/dicom")),
], ],
) )
assert response.status_code == 201 assert response.status_code == 201
data = response.json() data = response.json()
assert data["uploaded_count"] == 1 assert data["uploaded_count"] == 3
assert uploaded == [f"uploads/{data['project_id']}/dicom/a.dcm"] assert uploaded == [
f"uploads/{data['project_id']}/dicom/1.dcm",
f"uploads/{data['project_id']}/dicom/2.dcm",
f"uploads/{data['project_id']}/dicom/10.dcm",
]
project_detail = client.get(f"/api/projects/{data['project_id']}").json()
assert project_detail["name"] == "1.dcm"
def test_upload_dicom_batch_rejects_when_no_valid_dicom(client, monkeypatch):
monkeypatch.setattr("routers.media.upload_file", lambda *args, **kwargs: None)
response = client.post(
"/api/media/upload/dicom",
files=[
("files", ("notes.txt", b"text", "text/plain")),
],
)
assert response.status_code == 400
assert response.json()["detail"] == "No valid DICOM files uploaded"
def test_parse_media_queues_background_task(client, monkeypatch): def test_parse_media_queues_background_task(client, monkeypatch):
@@ -194,6 +216,101 @@ def test_parse_task_runner_registers_frames(client, db_session, monkeypatch, tmp
assert frames[0]["source_frame_number"] == 0 assert frames[0]["source_frame_number"] == 0
def test_parse_dicom_reads_files_in_natural_filename_order(monkeypatch, tmp_path):
from pathlib import Path
import numpy as np
from services.frame_parser import parse_dicom
dcm_dir = tmp_path / "dcm"
output_dir = tmp_path / "frames"
dcm_dir.mkdir()
for name in ["10.dcm", "2.dcm", "1.dcm"]:
(dcm_dir / name).write_bytes(b"dcm")
read_order = []
class FakeDicom:
pixel_array = np.ones((2, 2), dtype=np.uint8)
def fake_dcmread(path):
read_order.append(Path(path).name)
return FakeDicom()
def fake_imwrite(path, image, params=None):
Path(path).write_bytes(image.tobytes())
return True
monkeypatch.setattr("services.frame_parser.dcmread", fake_dcmread)
monkeypatch.setattr("services.frame_parser.cv2.imwrite", fake_imwrite)
frame_files = parse_dicom(str(dcm_dir), str(output_dir))
assert read_order == ["1.dcm", "2.dcm", "10.dcm"]
assert [Path(path).name for path in frame_files] == ["frame_000000.jpg", "frame_000001.jpg", "frame_000002.jpg"]
def test_parse_task_runner_downloads_dicom_objects_in_natural_filename_order(client, db_session, monkeypatch, tmp_path):
from types import SimpleNamespace
from models import ProcessingTask
from services.media_task_runner import run_parse_media_task
project = client.post("/api/projects", json={
"name": "DICOM",
"video_path": "uploads/1/dicom",
"source_type": "dicom",
"parse_fps": 30,
}).json()
task = ProcessingTask(
task_type="parse_dicom",
status="queued",
progress=0,
project_id=project["id"],
payload={"source_type": "dicom"},
)
db_session.add(task)
db_session.commit()
db_session.refresh(task)
class FakeClient:
def list_objects(self, bucket, prefix, recursive=True):
return [
SimpleNamespace(object_name=f"{prefix}/10.dcm"),
SimpleNamespace(object_name=f"{prefix}/2.dcm"),
SimpleNamespace(object_name=f"{prefix}/1.dcm"),
]
downloaded = []
frame_files = []
for idx in range(3):
frame_file = tmp_path / f"frame_{idx:06d}.jpg"
frame_file.write_bytes(b"fake image")
frame_files.append(str(frame_file))
monkeypatch.setattr("services.media_task_runner.get_minio_client", lambda: FakeClient())
monkeypatch.setattr(
"services.media_task_runner.download_file",
lambda object_name: downloaded.append(object_name) or b"dcm",
)
monkeypatch.setattr("services.media_task_runner.parse_dicom", lambda *args, **kwargs: frame_files)
monkeypatch.setattr(
"services.media_task_runner.upload_frames_to_minio",
lambda frames, project_id: [f"projects/{project_id}/frames/{idx}.jpg" for idx, _ in enumerate(frames)],
)
monkeypatch.setattr("services.media_task_runner.publish_task_progress_event", lambda task: None)
result = run_parse_media_task(db_session, task.id)
assert result["frames_extracted"] == 3
assert downloaded == [
"uploads/1/dicom/1.dcm",
"uploads/1/dicom/2.dcm",
"uploads/1/dicom/10.dcm",
]
def test_parse_task_runner_skips_already_cancelled_task(db_session): def test_parse_task_runner_skips_already_cancelled_task(db_session):
from models import ProcessingTask from models import ProcessingTask
from services.media_task_runner import run_parse_media_task from services.media_task_runner import run_parse_media_task

View File

@@ -42,6 +42,9 @@ def test_project_crud_and_frames(client, monkeypatch):
assert updated.json()["name"] == "Renamed" assert updated.json()["name"] == "Renamed"
assert updated.json()["status"] == "ready" assert updated.json()["status"] == "ready"
empty_name = client.patch(f"/api/projects/{project_id}", json={"name": " "})
assert empty_name.status_code == 400
deleted = client.delete(f"/api/projects/{project_id}") deleted = client.delete(f"/api/projects/{project_id}")
assert deleted.status_code == 204 assert deleted.status_code == 204
assert client.get(f"/api/projects/{project_id}").status_code == 404 assert client.get(f"/api/projects/{project_id}").status_code == 404
@@ -83,10 +86,97 @@ def test_delete_project_cascades_related_records(client, db_session):
assert db_session.query(ProcessingTask).filter(ProcessingTask.project_id == project_id).count() == 0 assert db_session.query(ProcessingTask).filter(ProcessingTask.project_id == project_id).count() == 0
def test_copy_project_reset_copies_frame_sequence_without_annotations(client, db_session):
created = client.post("/api/projects", json={
"name": "Reset Source",
"description": "desc",
"video_path": "uploads/source.mp4",
"thumbnail_url": "thumb.jpg",
"status": "ready",
"parse_fps": 12,
})
assert created.status_code == 201
project_id = created.json()["id"]
frame = client.post(f"/api/projects/{project_id}/frames", json={
"project_id": project_id,
"frame_index": 0,
"image_url": "frames/source/frame_000000.jpg",
"width": 640,
"height": 360,
"timestamp_ms": 0,
"source_frame_number": 0,
})
assert frame.status_code == 201
annotation = client.post("/api/ai/annotate", json={
"project_id": project_id,
"frame_id": frame.json()["id"],
"mask_data": {"label": "Tumor", "polygons": [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]]},
})
assert annotation.status_code == 201
copied = client.post(f"/api/projects/{project_id}/copy", json={"mode": "reset"})
assert copied.status_code == 201
copied_body = copied.json()
assert copied_body["name"] == "Reset Source 副本"
assert copied_body["frame_count"] == 1
assert copied_body["video_path"] == "uploads/source.mp4"
assert copied_body["parse_fps"] == 12
copied_frames = db_session.query(Frame).filter(Frame.project_id == copied_body["id"]).all()
assert len(copied_frames) == 1
assert copied_frames[0].image_url == "frames/source/frame_000000.jpg"
assert db_session.query(Annotation).filter(Annotation.project_id == copied_body["id"]).count() == 0
def test_copy_project_full_copies_annotations_and_mask_metadata(client, db_session):
created = client.post("/api/projects", json={
"name": "Full Source",
"status": "ready",
})
assert created.status_code == 201
project_id = created.json()["id"]
frame = client.post(f"/api/projects/{project_id}/frames", json={
"project_id": project_id,
"frame_index": 0,
"image_url": "frames/source/frame_000000.jpg",
"width": 640,
"height": 360,
})
assert frame.status_code == 201
frame_id = frame.json()["id"]
annotation = client.post("/api/ai/annotate", json={
"project_id": project_id,
"frame_id": frame_id,
"mask_data": {"label": "Tumor", "polygons": [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]]},
"points": [[0.1, 0.1]],
"bbox": [0.1, 0.1, 0.1, 0.1],
})
assert annotation.status_code == 201
annotation_id = annotation.json()["id"]
db_session.add(Mask(annotation_id=annotation_id, mask_url="masks/source.png", format="png"))
db_session.commit()
copied = client.post(f"/api/projects/{project_id}/copy", json={"mode": "full"})
assert copied.status_code == 201
copied_body = copied.json()
copied_frames = db_session.query(Frame).filter(Frame.project_id == copied_body["id"]).all()
copied_annotations = db_session.query(Annotation).filter(Annotation.project_id == copied_body["id"]).all()
assert copied_body["name"] == "Full Source 副本"
assert len(copied_frames) == 1
assert len(copied_annotations) == 1
assert copied_annotations[0].id != annotation_id
assert copied_annotations[0].frame_id == copied_frames[0].id
assert copied_annotations[0].mask_data["label"] == "Tumor"
assert copied_annotations[0].bbox == [0.1, 0.1, 0.1, 0.1]
assert copied_annotations[0].masks[0].mask_url == "masks/source.png"
def test_project_and_frame_404s(client): def test_project_and_frame_404s(client):
assert client.get("/api/projects/999").status_code == 404 assert client.get("/api/projects/999").status_code == 404
assert client.patch("/api/projects/999", json={"name": "x"}).status_code == 404 assert client.patch("/api/projects/999", json={"name": "x"}).status_code == 404
assert client.delete("/api/projects/999").status_code == 404 assert client.delete("/api/projects/999").status_code == 404
assert client.post("/api/projects/999/copy", json={"mode": "reset"}).status_code == 404
assert client.post("/api/projects/999/frames", json={ assert client.post("/api/projects/999/frames", json={
"project_id": 999, "project_id": 999,
"frame_index": 0, "frame_index": 0,

View File

@@ -37,3 +37,55 @@ def test_template_404s(client):
assert client.get("/api/templates/999").status_code == 404 assert client.get("/api/templates/999").status_code == 404
assert client.patch("/api/templates/999", json={"name": "x"}).status_code == 404 assert client.patch("/api/templates/999", json={"name": "x"}).status_code == 404
assert client.delete("/api/templates/999").status_code == 404 assert client.delete("/api/templates/999").status_code == 404
def test_default_head_neck_ct_template_is_seeded_and_visible(client, db_session):
from main import ensure_default_templates
from models import Template
ensure_default_templates(db_session)
ensure_default_templates(db_session)
templates = db_session.query(Template).filter(Template.owner_user_id.is_(None)).all()
names = [template.name for template in templates]
assert names.count("头颈部CT分割") == 1
listing = client.get("/api/templates")
assert listing.status_code == 200
head_neck = next(template for template in listing.json() if template["name"] == "头颈部CT分割")
assert head_neck["description"] == "头颈部CT分割"
expected_names = [
"肿瘤/结节 (Tumor/Nodule)",
"下颌骨 (Mandible)",
"甲状腺 (Thyroid)",
"气管 (Trachea)",
"颈椎 (Cervical Spine)",
"颈动脉 (Carotid Artery)",
"颈静脉 (Jugular Vein)",
"腮腺 (Parotid Gland)",
"下颌下腺 (Submandibular Gland)",
"舌骨 (Hyoid Bone)",
"待分类",
]
expected_colors = [
"#ff0000",
"#00ff00",
"#0000ff",
"#ffff00",
"#ff00ff",
"#00ffff",
"#ff8000",
"#800080",
"#008080",
"#808000",
"#000000",
]
actual_names = [item["name"] for item in head_neck["classes"]]
actual_colors = [item["color"] for item in head_neck["classes"]]
actual_mask_ids = [item["maskId"] for item in head_neck["classes"]]
if actual_names != expected_names:
raise AssertionError(f"Unexpected head-neck classes: {actual_names}")
if actual_colors != expected_colors:
raise AssertionError(f"Unexpected head-neck colors: {actual_colors}")
if actual_mask_ids != [*list(range(1, 11)), 0]:
raise AssertionError(f"Unexpected head-neck mask IDs: {actual_mask_ids}")

View File

@@ -36,23 +36,22 @@ Word 方案描述的理想系统包含:
| MinIO 对象存储 | 已落地 | `backend/minio_client.py` | | MinIO 对象存储 | 已落地 | `backend/minio_client.py` |
| Redis 连接 | 已落地 | 用于 Celery broker/result backend并通过 `seg:progress` pub/sub 转发任务进度 | | Redis 连接 | 已落地 | 用于 Celery broker/result backend并通过 `seg:progress` pub/sub 转发任务进度 |
| 视频拆帧 | 已落地 | `backend/services/frame_parser.py``backend/routers/media.py` | | 视频拆帧 | 已落地 | `backend/services/frame_parser.py``backend/routers/media.py` |
| DICOM 批量导入 | 部分落地 | 上传和解析存在,项目级体验还需完善 | | DICOM 批量导入 | 落地 | 上传、文件名自然排序、解析任务创建和项目库解析进度回显均已接入 |
| WebSocket 进度 | 已落地 | 拆帧进度写入任务表后发布到 Redis `seg:progress`FastAPI 广播到 `/ws/progress` | | WebSocket 进度 | 已落地 | 拆帧进度写入任务表后发布到 Redis `seg:progress`FastAPI 广播到 `/ws/progress` |
| SAM 推理 | 部分落地 | 当前产品入口启用 SAM 2.1 tiny/small/base+/large 和真实 GPU/SAM2.1 状态接口SAM 2.1 已接 point/box/interactive 和 video predictor 片段传播。SAM 3 桥接源码保留,但前端入口和后端 registry 已禁用 | | SAM 推理 | 部分落地 | 当前产品入口启用 SAM 2.1 tiny/small/base+/large 和真实 GPU/SAM2.1 状态接口SAM 2.1 已接 point/box/interactive 和 video predictor 片段传播。SAM 3 桥接源码保留,但前端入口和后端 registry 已禁用 |
| 模板库 | 部分落地 | 分类、颜色、maskid 和拖拽排序能存储和编辑右侧语义分类树也可拖拽调整内部覆盖顺序PNG mask 导出时会按内部优先级做语义融合裁决,前端预览裁决尚未落地 | | 模板库 | 部分落地 | 分类、颜色、maskid、JSON 批量导入预览和拖拽排序能存储和编辑右侧语义分类树也可拖拽调整内部覆盖顺序PNG mask 导出时会按内部优先级做语义融合裁决,前端预览裁决尚未落地 |
| 标注持久化 | 部分落地 | 后端有 `Annotation`前端已接入新增、回显、分类更新、传播链前后帧同目标同步换类、当前帧删除、手工绘制、GT mask 导入、seed point 编辑、polygon 顶点拖动/删除、边中点插点和多 polygon 子区域编辑;复杂洞结构编辑未落地 | | 标注持久化 | 部分落地 | 后端有 `Annotation`前端已接入新增、回显、分类更新、传播链前后帧同目标同步换类、当前帧删除、手工绘制、GT mask 导入、polygon 顶点拖动/删除、边中点插点和多 polygon 子区域编辑;复杂洞结构编辑未落地 |
| COCO / Mask 导出 | 已落地基础能力 | `backend/routers/export.py`COCO JSON、兼容 PNG mask ZIP 和统一分割结果 ZIP 均已接入;统一 ZIP 包含 maskid/GT 像素值映射、原始图片、按帧/类别合并的分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图GT_label 像素值使用类别真实 maskid缺失 maskid 的旧标注才补下一个可用正整数 | | COCO / Mask 导出 | 已落地基础能力 | `backend/routers/export.py`COCO JSON、兼容 PNG mask ZIP 和统一分割结果 ZIP 均已接入;统一 ZIP 包含 maskid/GT 像素值映射、原始图片、按帧/类别合并的分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图GT_label 固定为 8-bit uint8 PNG像素值使用类别真实 maskid其中 `maskid:0` 的“待分类”和背景同为 0缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出 |
## 当前代码尚未落地的目标 ## 当前代码尚未落地的目标
- SAM 3`sam3_engine.py``sam3_external_worker.py``setup_sam3_env.sh` 作为历史实现保留;由于当前系统不给文本提示,前端不再展示 SAM 3后端 registry 也不暴露 `sam3`。官方没有 SAM 3 tiny/small 权重,当前可选最小真实 SAM 权重仍是 SAM 2.1 tiny。 - SAM 3`sam3_engine.py``sam3_external_worker.py``setup_sam3_env.sh` 作为历史实现保留;由于当前系统不给文本提示,前端不再展示 SAM 3后端 registry 也不暴露 `sam3`。官方没有 SAM 3 tiny/small 权重,当前可选最小真实 SAM 权重仍是 SAM 2.1 tiny。
- Celery 异步任务队列:已注册 Celery app 和拆帧 worker task`/api/media/parse` 会创建任务表记录并入队 - GT mask 导入:当前仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图导入,后端会按 maskid 拆分区域,生成高精度 polygon 标注;超出现有类别的 maskid 可舍弃或导入为未定义类别16-bit/uint16 GT_label 和普通彩色类别图会被拒绝尺寸不一致会自动最近邻拉伸到当前帧骨架提取、HDBSCAN 和更复杂的模板自动映射尚未实现
- GT mask 导入:当前已支持二值 mask、灰度/16-bit GT_label 图和 RGB 三通道完全相同的 `[X,X,X]` maskid 图导入,后端会按 maskid 拆分区域,生成 polygon 标注和距离变换 seed point超出现有类别的 maskid 可舍弃或导入为未定义类别;普通彩色类别图会被拒绝,尺寸不一致会自动最近邻拉伸到当前帧骨架提取、HDBSCAN 和更复杂的模板自动映射尚未实现。 - Mask 到点区域的拓扑降维:后端保留 distance transform seed point 数据兼容;前端不再显示黄色 seed point也不提供 seed point 拖拽编辑骨架提取、HDBSCAN 等增强尚未实现。
- Mask 到点区域的拓扑降维:当前完成 distance transform seed point 和前端 seed point 拖拽编辑骨架提取、HDBSCAN 等增强尚未实现。
- 类别优先级融合PNG mask 导出时已按内部优先级生成语义融合 mask前端裁决预览尚未实现。 - 类别优先级融合PNG mask 导出时已按内部优先级生成语义融合 mask前端裁决预览尚未实现。
- 撤销/重做:当前已有全局 mask 历史栈。 - 撤销/重做:当前已有全局 mask 历史栈。
- 保存状态按钮:工作区按钮按待保存数量显示“保存 X 个改动”或“已全部保存”,并调用 `POST /api/ai/annotate` 保存当前未归档 mask通过 `PATCH /api/ai/annotations/{id}` 更新 dirty mask。 - 保存状态按钮:工作区按钮按待保存数量显示“保存 X 个改动”或“已全部保存”,并调用 `POST /api/ai/annotate` 保存当前未归档 mask通过 `PATCH /api/ai/annotations/{id}` 更新 dirty mask。
## 结论 ## 结论
当前项目已经从 UI 原型推进到“可上传、可异步拆帧、可取消/重试任务、可查看失败详情、可实时查看任务进度、可浏览项目帧、可维护模板、可手工绘制、可逐点编辑 polygon、可边中点插点、可多 polygon 子区域编辑、可区域合并/去除、可用可选 SAM 2.1 做点/框 AI 推理、可对点/框 prompt 做裁剪推理和背景过滤、可用 SAM 2.1 进行视频片段传播、可导入多类别 GT mask、可编辑 seed point、可保存标注、可导出 COCO/语义 mask ZIP、可查看 Dashboard 后端概览”的全栈雏形。下一阶段最重要的是继续补齐复杂洞结构编辑、GT mask 骨架/聚类增强和传播任务异步化 当前项目已经从 UI 原型推进到“可上传、可异步拆帧、可取消/重试任务、可查看失败详情、可实时查看任务进度、可浏览项目帧、可维护模板、可手工绘制、可逐点编辑 polygon、可边中点插点、可多 polygon 子区域编辑、可区域合并/去除、可用可选 SAM 2.1 做点/框 AI 推理、可对点/框 prompt 做裁剪推理和背景过滤、可用 SAM 2.1 后台任务进行视频片段传播、可导入多类别 GT mask、可保存标注、可导出 COCO/语义 mask ZIP、可查看 Dashboard 后端概览”的全栈雏形。下一阶段最重要的是继续补齐复杂洞结构编辑、GT mask 骨架/聚类增强和前端语义融合预览

View File

@@ -24,7 +24,7 @@
- 检查 MinIO bucket。 - 检查 MinIO bucket。
- 测试 Redis。 - 测试 Redis。
- Seed 默认模板。 - Seed 默认模板。
- 如果存在 `Data_MyVideo_1.mp4`,创建默认项目并拆前 100 帧。 - 如果存在 `demo_video_path` 和配置的 `demo_dicom_dir` DICOM 序列,创建默认演示视频项目和演示 DICOM 项目DICOM 按文件名自然顺序生成帧。
## 前端模块切换 ## 前端模块切换
@@ -71,7 +71,7 @@
2. `UserAdmin.tsx` 调用 `GET/POST/PATCH/DELETE /api/admin/users` 完成用户新增、停用/启用、角色修改、改密码和删除无项目用户。 2. `UserAdmin.tsx` 调用 `GET/POST/PATCH/DELETE /api/admin/users` 完成用户新增、停用/启用、角色修改、改密码和删除无项目用户。
3. `UserAdmin.tsx` 调用 `GET /api/admin/audit-logs` 展示登录成功/失败以及用户管理操作审计。 3. `UserAdmin.tsx` 调用 `GET /api/admin/audit-logs` 展示登录成功/失败以及用户管理操作审计。
4. `UserAdmin.tsx` 危险区“恢复演示出厂设置”需要浏览器确认和输入 `RESET_DEMO_FACTORY`,随后调用 `POST /api/admin/demo-factory-reset` 4. `UserAdmin.tsx` 危险区“恢复演示出厂设置”需要浏览器确认和输入 `RESET_DEMO_FACTORY`,随后调用 `POST /api/admin/demo-factory-reset`
5. 后端 `backend/routers/admin.py` 会阻止管理员删除、停用或降级自己,并阻止删除仍拥有项目的用户;演示出厂重置会清空其它用户、项目帧、标注、任务和私有模板,重新创建一个尚未生成帧的 `Data_MyVideo_1` 视频项目。 5. 后端 `backend/routers/admin.py` 会阻止管理员删除、停用或降级自己,并阻止删除仍拥有项目的用户;演示出厂重置会清空其它用户、项目帧、标注、任务和私有模板,重新创建演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目。
### 项目与拆帧 ### 项目与拆帧
@@ -111,6 +111,6 @@
- 前端 API/WS 地址虽然已支持环境变量和 hostname 推导,但部署时仍需要确认浏览器可访问 `:8000` 后端。 - 前端 API/WS 地址虽然已支持环境变量和 hostname 推导,但部署时仍需要确认浏览器可访问 `:8000` 后端。
- AI 当前启用 SAM 2.1 tiny/small/base+/large 点/框/interactive 路径;语义文本提示和 SAM 3 产品入口已禁用,`model=sam3` 会被后端拒绝。SAM 3 源码保留但不计入当前可用功能。 - AI 当前启用 SAM 2.1 tiny/small/base+/large 点/框/interactive 路径;语义文本提示和 SAM 3 产品入口已禁用,`model=sam3` 会被后端拒绝。SAM 3 源码保留但不计入当前可用功能。
- 工作区顶部“分割结果导出”和保存状态按钮、左侧工具栏“导入 GT Mask”已接入统一导出、GT 多类别导入、seed point 回显/编辑、标注新增和 dirty 标注更新;导入 GT Mask 支持二值 mask、低数值/16-bit GT_label 图和 RGB 三通道完全相同的 `[X,X,X]` maskid 图,未知 maskid 可由用户选择舍弃或导入为未定义类别,普通彩色类别图会被拒绝,尺寸不同会自动最近邻拉伸到当前帧。保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;统一导出可选择整体视频、特定范围帧或当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;特定范围帧导出支持直接输入起止帧,也支持在播放进度条或视频处理进度条上点击/拖拽选择范围Mix_label 支持默认 0.3 的透明度调节和首帧预览;后端统一导出 ZIP 固定包含 maskid/GT 像素值映射 JSON 与原始图片文件夹GT_label 像素值使用类别真实 maskid缺失 maskid 的旧标注才补下一个可用正整数,并按客户命名规则输出分开 Mask、GT_label、Pro_label 和 Mix_label 文件夹清空当前帧遮罩会删除对应后端标注。手工绘制、polygon 顶点拖动/删除、区域合并/去除和撤销重做已经落到前端 mask 数据结构。 - 工作区顶部“分割结果导出”和保存状态按钮、左侧工具栏“导入 GT Mask”已接入统一导出、GT 多类别导入、标注新增和 dirty 标注更新;导入 GT Mask 支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,未知 maskid 可由用户选择舍弃或导入为未定义类别,16-bit/uint16 GT_label 和普通彩色类别图会被拒绝,尺寸不同会自动最近邻拉伸到当前帧GT 连通域会生成高精度 polygon导入后和普通 mask 一样不显示黄色 seed point并与普通 mask 共用拓扑统计、边缘平滑、编辑和保存链路。保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;统一导出可选择整体视频、特定范围帧或当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;特定范围帧导出支持直接输入起止帧,也支持在播放进度条或视频处理进度条上点击/拖拽选择范围Mix_label 支持默认 0.3 的透明度调节和首帧预览;后端统一导出 ZIP 固定包含 maskid/GT 像素值映射 JSON 与原始图片文件夹GT_label 固定输出 8-bit uint8 PNG像素值使用类别真实 maskid其中 `maskid:0` 的“待分类”和背景同为 0缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出,并按客户命名规则输出分开 Mask、GT_label、Pro_label 和 Mix_label 文件夹清空当前帧遮罩会删除对应后端标注。手工绘制、polygon 顶点拖动/删除、区域合并/去除和撤销重做已经落到前端 mask 数据结构。
- Dashboard 初始统计、队列和活动日志来自后端聚合接口;解析队列来自 `processing_tasks`worker 进度通过 Redis `seg:progress` 转发到 WebSocket。任务取消、重试和失败详情已接入前后端。 - Dashboard 初始统计、队列和活动日志来自后端聚合接口;解析队列来自 `processing_tasks`worker 进度通过 Redis `seg:progress` 转发到 WebSocket。任务取消、重试和失败详情已接入前后端。
- 后端已接入 Bearer JWT 鉴权、当前用户项目隔离和角色权限;写入类业务接口要求 `admin/annotator`,管理员用户后台要求 `admin`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展。 - 后端已接入 Bearer JWT 鉴权、当前用户项目隔离和角色权限;写入类业务接口要求 `admin/annotator`,管理员用户后台要求 `admin`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展。

View File

@@ -36,7 +36,7 @@
| 修改角色 / 启停用 / 改密码 | 真实可用 | 调用 `PATCH /api/admin/users/{id}`;后端禁止管理员把自己降级或停用,避免锁死后台 | | 修改角色 / 启停用 / 改密码 | 真实可用 | 调用 `PATCH /api/admin/users/{id}`;后端禁止管理员把自己降级或停用,避免锁死后台 |
| 删除用户 | 真实可用 | 调用 `DELETE /api/admin/users/{id}`;后端禁止删除自己,且用户名下仍有项目时返回 409避免悬空项目数据 | | 删除用户 | 真实可用 | 调用 `DELETE /api/admin/users/{id}`;后端禁止删除自己,且用户名下仍有项目时返回 409避免悬空项目数据 |
| 审计日志 | 真实可用 | 调用 `GET /api/admin/audit-logs`,展示登录成功/失败、用户新增、修改和删除等管理操作 | | 审计日志 | 真实可用 | 调用 `GET /api/admin/audit-logs`,展示登录成功/失败、用户新增、修改和删除等管理操作 |
| 恢复演示出厂设置 | 真实可用 | 管理员点击危险区按钮后先浏览器确认,再输入 `RESET_DEMO_FACTORY`;前端调用 `POST /api/admin/demo-factory-reset`,后端只保留默认 admin 与一个尚未生成帧的演示视频项目,并清空用户、项目帧、标注、任务和私有模板等演示数据 | | 恢复演示出厂设置 | 真实可用 | 管理员点击危险区按钮后先浏览器确认,再输入 `RESET_DEMO_FACTORY`;前端调用 `POST /api/admin/demo-factory-reset`,后端只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,并清空用户、项目帧、标注、任务和私有模板等演示数据 |
## Dashboard 系统概况 ## Dashboard 系统概况
@@ -59,11 +59,11 @@
| 项目卡片缩略图 | 真实可用 | 后端返回 MinIO 预签名 `thumbnail_url` 时显示 | | 项目卡片缩略图 | 真实可用 | 后端返回 MinIO 预签名 `thumbnail_url` 时显示 |
| 点击项目进入工作区 | 真实可用 | 设置 `currentProject` 后切到 `workspace` | | 点击项目进入工作区 | 真实可用 | 设置 `currentProject` 后切到 `workspace` |
| 新建项目 | 真实可用 | 调用 `POST /api/projects` | | 新建项目 | 真实可用 | 调用 `POST /api/projects` |
| 导入视频文件 | 真实可用 | 创建项目、上传源视频、刷新项目列表;不会自动拆帧 | | 导入视频文件 | 真实可用 | 创建项目、上传源视频、刷新项目列表;不会自动拆帧;上传期间显示项目库导入进度条、百分比和已上传字节 |
| 生成帧按钮 | 真实可用 | 仅对已导入源视频且尚无帧、非 parsing 状态的项目显示,调用 `parseMedia(projectId, { parseFps })` | | 生成帧按钮 | 真实可用 | 仅对已导入源视频且尚无帧、非 parsing 状态的项目显示,调用 `parseMedia(projectId, { parseFps })` |
| 生成帧 FPS 滑块 | 真实可用 | 值传入 `/api/media/parse?parse_fps=...`,决定后台拆帧目标 FPS | | 生成帧 FPS 滑块 | 真实可用 | 值传入 `/api/media/parse?parse_fps=...`,决定后台拆帧目标 FPS |
| 项目卡片 FPS 徽标 | 真实可用 | 右上角显示关键帧序列目标 `parse_fps`;原始视频帧率只在卡片底部以“原 xx fps”显示 | | 项目卡片 FPS 徽标 | 真实可用 | 右上角显示关键帧序列目标 `parse_fps`;原始视频帧率只在卡片底部以“原 xx fps”显示 |
| 导入 DICOM 序列 | 部分可用 | 可上传 `.dcm` 并触发解析;体验和错误反馈较粗 | | 导入 DICOM 序列 | 真实可用 | 可上传 `.dcm` 并触发解析;上传前按文件名自然顺序排序,后端解析也保持同一顺序;上传期间显示导入进度条、有效 DICOM 文件数量和已上传字节,上传完成后继续显示解析任务进度直到完成、失败或取消 |
| 项目状态徽标 | 真实可用 | 项目状态统一为 `pending/parsing/ready/error`,前端兼容归一化旧状态值 | | 项目状态徽标 | 真实可用 | 项目状态统一为 `pending/parsing/ready/error`,前端兼容归一化旧状态值 |
| 删除项目按钮 | 真实可用 | 点击垃圾桶按钮会确认删除,调用 `DELETE /api/projects/{id}`成功后从项目库移除若删除的是当前项目会清空工作区当前项目、帧、mask 和选区 | | 删除项目按钮 | 真实可用 | 点击垃圾桶按钮会确认删除,调用 `DELETE /api/projects/{id}`成功后从项目库移除若删除的是当前项目会清空工作区当前项目、帧、mask 和选区 |
| 操作成功/失败提示 | 真实可用 | 使用非阻塞 `TransientNotice` 浮层,自动消失,不会拦截后续按钮、输入框或画布操作 | | 操作成功/失败提示 | 真实可用 | 使用非阻塞 `TransientNotice` 浮层,自动消失,不会拦截后续按钮、输入框或画布操作 |
@@ -78,8 +78,8 @@
| 无帧项目提示 | 真实可用 | 如果 `video_path` 存在但无帧,只提示回到项目库生成帧,不自动创建拆帧任务 | | 无帧项目提示 | 真实可用 | 如果 `video_path` 存在但无帧,只提示回到项目库生成帧,不自动创建拆帧任务 |
| SAM 模型状态徽标 | 真实可用 | 工作区顶栏使用紧凑 GPU/CPU 状态徽标,避免和旁边的“传播权重”下拉重复显示 SAM 2.1 变体名称;悬停仍可查看模型状态说明 | | SAM 模型状态徽标 | 真实可用 | 工作区顶栏使用紧凑 GPU/CPU 状态徽标,避免和旁边的“传播权重”下拉重复显示 SAM 2.1 变体名称;悬停仍可查看模型状态说明 |
| 已保存标注回显 | 真实可用 | 加载工作区帧后调用 `GET /api/ai/annotations` 并渲染已保存 mask回显时保留当前项目帧里尚未保存的 AI/手工 draft mask避免从 AI 页推送的候选被覆盖 | | 已保存标注回显 | 真实可用 | 加载工作区帧后调用 `GET /api/ai/annotations` 并渲染已保存 mask回显时保留当前项目帧里尚未保存的 AI/手工 draft mask避免从 AI 页推送的候选被覆盖 |
| “分割结果导出”按钮 | 真实可用 | 原“导出 JSON 标注集”和“导出 PNG Mask ZIP”已合并为一个入口点击后可选择整体视频、特定范围帧或当前图片默认导出范围为当前图片并勾选导出分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;选择“特定范围帧”后会进入和自动传播、清空遮罩一致的时间轴范围选择模式,可在播放进度条或视频处理进度条上点击/拖拽选择导出起止帧,也可直接修改起止帧输入框;选择 Mix_label 时可调透明度,默认 0.3,并显示当前/待导出第一帧预览;提交前会保存未归档 mask然后调用 `GET /api/export/{project_id}/results` 下载 ZIP浏览器下载名和后端 `Content-Disposition` 均使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;时间戳格式为 `0h00m00s000ms`,帧序号来自项目抽帧后的 1-based 顺序,不使用原视频帧号;包内固定包含 `annotations_coco.json``maskid_GT像素值_类别映射.json``原始图片/`;选择分开 mask 时包含按帧子目录组织且同类合并的 `分开Mask分割结果/`,选择 GT_label/Pro_label/Mix_label 时分别包含 `GT_label图/``Pro_label彩色分割结果/``Mix_label重叠覆盖彩色分割结果/`。GT_label 图背景为 0语义类别值使用类别真实 maskid缺失 maskid 的旧标注才补下一个可用正整数 | | “分割结果导出”按钮 | 真实可用 | 原“导出 JSON 标注集”和“导出 PNG Mask ZIP”已合并为一个入口点击后可选择整体视频、特定范围帧或当前图片默认导出范围为当前图片并勾选导出分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;选择“特定范围帧”后会进入和自动传播、清空遮罩一致的时间轴范围选择模式,可在播放进度条或视频处理进度条上点击/拖拽选择导出起止帧,也可直接修改起止帧输入框;选择 Mix_label 时可调透明度,默认 0.3,并显示当前/待导出第一帧预览;提交前会保存未归档 mask然后调用 `GET /api/export/{project_id}/results` 下载 ZIP浏览器下载名和后端 `Content-Disposition` 均使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;时间戳格式为 `0h00m00s000ms`,帧序号来自项目抽帧后的 1-based 顺序,不使用原视频帧号;包内固定包含 `annotations_coco.json``maskid_GT像素值_类别映射.json``原始图片/`;选择分开 mask 时包含按帧子目录组织且同类合并的 `分开Mask分割结果/`,选择 GT_label/Pro_label/Mix_label 时分别包含 `GT_label图/``Pro_label彩色分割结果/``Mix_label重叠覆盖彩色分割结果/`。GT_label 图固定为 8-bit uint8 PNG背景为 0语义类别值使用类别真实 maskid`maskid: 0` 的“待分类”与背景同为 0Pro_label 中也与背景同为黑色 `[0,0,0]`,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出 |
| “导入 GT Mask”按钮 | 真实可用 | 入口已从工作区顶栏移动到左侧工具栏“重叠区域去除”之后,使用紫色图标底色;选择图片后先弹出导入结果预览和未知 maskid 策略选择,可舍弃未知类别或导入为未定义类别;随后调用 `POST /api/ai/import-gt-mask`,后端支持二值 mask、低数值/16-bit GT_label 图和 RGB 三通道完全相同的 `[X,X,X]` maskid 图,不符合灰度/maskid 图要求时返回错误;尺寸不同会自动最近邻拉伸到当前帧,再按类别/连通域生成 polygon 标注与距离变换 seed point,最后回显到工作区 | | “导入 GT Mask”按钮 | 真实可用 | 入口已从工作区顶栏移动到左侧工具栏“重叠区域去除”之后,使用紫色图标底色;选择图片后先弹出导入结果预览和未知 maskid 策略选择,可舍弃未知类别或导入为未定义类别;随后调用 `POST /api/ai/import-gt-mask`,后端支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,不符合 8-bit 灰度/maskid 图要求时返回错误16-bit/uint16 GT_label 会被拒绝;尺寸不同会自动最近邻拉伸到当前帧,再按类别/连通域生成高精度 polygon 标注,最后回显到工作区;导入 mask 与普通 mask 一样不显示黄色 seed point并共用拓扑锚点统计、边缘平滑、编辑、分类和保存链路 |
| 参考帧/起止帧/传播权重/自动传播 | 真实可用 | 当前打开帧即参考帧,前端会使用该帧全部 mask 作为 seed工作区顶栏有独立“传播权重”下拉可在传播前二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,不影响 AI 智能分割页的单帧推理权重选择;传播权重下拉使用深色背景和青色文字,避免默认灰底白字不可读;如果用户尚未显式设置范围,点击“自动传播”会先进入时间轴范围选择模式,播放进度条和视频处理进度条都可点击/拖拽回填传播起始帧和传播结束帧,再点击“开始传播”提交;用户也可直接改数字框后点击按钮传播。提交后前端把传播权重 id、seed mask、seed 来源 id、未编辑传播结果的原始 seed 签名和前/后方向步骤提交到 `POST /api/ai/propagate/task`,后端先规范化/校验权重 id再创建 `processing_tasks` 并由 Celery 执行对应 SAM 2.1 video predictorworker 会在本次目标帧段内按 seed 来源和几何/语义签名做幂等判断,未改变且目标帧已有结果的 seed 直接跳过,已改变、目标帧只部分覆盖或换权重时会先删除本次目标帧段内同源旧自动传播标注再重新传播;历史或外部 seed 若仍带边缘平滑参数,后端仍按完整签名兼容处理;当前前端平滑应用会直接改写 polygon因此传播以新几何参与签名中间帧人工新增/修改同一物体后重新传播时,后端会按语义和目标帧空间重叠清理旧传播结果,写入前清理不受旧结果 `propagation_direction` 限制,避免 backward 重传时与旧 forward mask 重叠;传播中顶栏显示任务进度、已处理帧次、删除旧区域数和已保存区域数,前端轮询 `GET /api/tasks/{task_id}` 并刷新已保存标注;任务可取消,若完成后 0 个新区域会明确提示没有生成新 mask 或已跳过未改变 mask | | 参考帧/起止帧/传播权重/自动传播 | 真实可用 | 当前打开帧即参考帧,前端会使用该帧全部 mask 作为 seed工作区顶栏有独立“传播权重”下拉可在传播前二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,不影响 AI 智能分割页的单帧推理权重选择;传播权重下拉使用深色背景和青色文字,避免默认灰底白字不可读;如果用户尚未显式设置范围,点击“自动传播”会先进入时间轴范围选择模式,播放进度条和视频处理进度条都可点击/拖拽回填传播起始帧和传播结束帧,再点击“开始传播”提交;用户也可直接改数字框后点击按钮传播。提交后前端把传播权重 id、seed mask、seed 来源 id、未编辑传播结果的原始 seed 签名和前/后方向步骤提交到 `POST /api/ai/propagate/task`,后端先规范化/校验权重 id再创建 `processing_tasks` 并由 Celery 执行对应 SAM 2.1 video predictorworker 会在本次目标帧段内按 seed 来源和几何/语义签名做幂等判断,未改变且目标帧已有结果的 seed 直接跳过,已改变、目标帧只部分覆盖或换权重时会先删除本次目标帧段内同源旧自动传播标注再重新传播;历史或外部 seed 若仍带边缘平滑参数,后端仍按完整签名兼容处理;当前前端平滑应用会直接改写 polygon因此传播以新几何参与签名中间帧人工新增/修改同一物体后重新传播时,后端会按语义和目标帧空间重叠清理旧传播结果,写入前清理不受旧结果 `propagation_direction` 限制,避免 backward 重传时与旧 forward mask 重叠;传播中顶栏显示任务进度、已处理帧次、删除旧区域数和已保存区域数,前端轮询 `GET /api/tasks/{task_id}` 并刷新已保存标注;任务可取消,若完成后 0 个新区域会明确提示没有生成新 mask 或已跳过未改变 mask |
| 清空片段遮罩 | 真实可用 | 点击“清空片段遮罩”后会进入和自动传播一致的时间轴范围选择模式,用户可在播放进度条或视频处理进度条上点击/拖拽选择起止帧;顶栏提供“清空全部”和“保留人工/AI”两种模式默认清空全部以保持旧行为“清空全部”会删除该帧段内所有本地 draft mask并对已保存 mask 调用 `DELETE /api/ai/annotations/{annotation_id}`,若范围内存在人工绘制或 AI 智能分割生成的红色“人工/AI 标注帧”会先弹出确认;“保留人工/AI”只删除自动传播/推理 mask不弹出人工帧确认人工/AI 标注帧、范围外 mask 和未被清空的选区会保留;同时按清空范围裁剪当前会话的自动传播历史条,避免已清空片段仍显示最近传播进度 | | 清空片段遮罩 | 真实可用 | 点击“清空片段遮罩”后会进入和自动传播一致的时间轴范围选择模式,用户可在播放进度条或视频处理进度条上点击/拖拽选择起止帧;顶栏提供“清空全部”和“保留人工/AI”两种模式默认清空全部以保持旧行为“清空全部”会删除该帧段内所有本地 draft mask并对已保存 mask 调用 `DELETE /api/ai/annotations/{annotation_id}`,若范围内存在人工绘制或 AI 智能分割生成的红色“人工/AI 标注帧”会先弹出确认;“保留人工/AI”只删除自动传播/推理 mask不弹出人工帧确认人工/AI 标注帧、范围外 mask 和未被清空的选区会保留;同时按清空范围裁剪当前会话的自动传播历史条,避免已清空片段仍显示最近传播进度 |
| 保存状态按钮 | 真实可用 | 顶栏按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;未保存 mask 写入 `POST /api/ai/annotate`dirty mask 写入 `PATCH /api/ai/annotations/{id}`;保存成功后会重新拉取后端标注,并用 saved annotation 替换本次提交的 draft mask避免仍显示未保存 | | 保存状态按钮 | 真实可用 | 顶栏按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;未保存 mask 写入 `POST /api/ai/annotate`dirty mask 写入 `PATCH /api/ai/annotations/{id}`;保存成功后会重新拉取后端标注,并用 saved annotation 替换本次提交的 draft mask避免仍显示未保存 |
@@ -97,11 +97,10 @@
| AI 推理中提示 | 真实可用 | 请求期间会显示 | | AI 推理中提示 | 真实可用 | 请求期间会显示 |
| 手工多边形/矩形/圆/画笔/橡皮擦 | 真实可用 | 多边形点击取点后可按 Enter 完成,也可在三点后点击首节点闭合;矩形/圆拖拽生成 polygon画笔按当前语义分类生成连续圆形笔触并在松开时 union 成 mask若与选中 mask 连通则自动合并;橡皮擦从选中 mask 中扣除笔触区域;均写入 `Mask.segmentation`,可归档保存 | | 手工多边形/矩形/圆/画笔/橡皮擦 | 真实可用 | 多边形点击取点后可按 Enter 完成,也可在三点后点击首节点闭合;矩形/圆拖拽生成 polygon画笔按当前语义分类生成连续圆形笔触并在松开时 union 成 mask若与选中 mask 连通则自动合并;橡皮擦从选中 mask 中扣除笔触区域;均写入 `Mask.segmentation`,可归档保存 |
| 画布上下文提示 | 真实可用 | 切换到多边形、矩形、圆、画笔、橡皮擦、区域合并/去除、调整多边形等隐性操作工具时,画布左上角显示当前工具的完成/取消/选择顺序提示;提示会在数秒后自动隐藏,避免长期遮挡待编辑图像,工具或操作状态变化时会重新出现 | | 画布上下文提示 | 真实可用 | 切换到多边形、矩形、圆、画笔、橡皮擦、区域合并/去除、调整多边形等隐性操作工具时,画布左上角显示当前工具的完成/取消/选择顺序提示;提示会在数秒后自动隐藏,避免长期遮挡待编辑图像,工具或操作状态变化时会重新出现 |
| Mask 渲染 | 真实可用 | 前端会把推理、手工绘制、GT 导入和已保存标注转成 Konva `pathData` 渲染;未选中特定 mask 时,当前帧 mask 会按右侧“语义分类树”拖拽得到的内部覆盖优先级从低到高渲染,使高优先级类别显示在上层;有选中 mask 时保留编辑态置顶行为,方便操作 | | Mask 渲染 | 真实可用 | 前端会把推理、手工绘制、GT 导入和已保存标注转成 Konva `pathData` 渲染;普通 mask 和导入 mask 都不显示黄色 seed point未选中特定 mask 时,当前帧 mask 会按右侧“语义分类树”拖拽得到的内部覆盖优先级从低到高渲染,使高优先级类别显示在上层;有选中 mask 时保留编辑态置顶行为,方便操作 |
| Mask 透明度 | 真实可用 | 右侧语义分类树上方的“遮罩透明度”滑杆写入全局 `maskPreviewOpacity`,工作区 Canvas 和 AI 智能分割页都会使用该值调整 mask 预览透明度,选中 mask 会在该基础上略微加亮 | | Mask 透明度 | 真实可用 | 右侧语义分类树上方的“遮罩透明度”滑杆写入全局 `maskPreviewOpacity`,工作区 Canvas 和 AI 智能分割页都会使用该值调整 mask 预览透明度,选中 mask 会在该基础上略微加亮 |
| 传播链跨帧选区跟随 | 真实可用 | 用户选中某个 mask 后切到同一自动传播结果覆盖的其他帧时,`CanvasArea` 会根据 `source_annotation_id``source_mask_id``propagation_seed_key` 查找目标帧对应传播 mask 并自动选中;找不到同链结果时才清空选区 | | 传播链跨帧选区跟随 | 真实可用 | 用户选中某个 mask 后切到同一自动传播结果覆盖的其他帧时,`CanvasArea` 会根据 `source_annotation_id``source_mask_id``propagation_seed_key` 查找目标帧对应传播 mask 并自动选中;找不到同链结果时才清空选区 |
| Polygon 逐点编辑 / 删除 | 真实可用 | 点击 mask 后显示 polygon 顶点;按住顶点即可直接拖动并实时重算 `pathData/segmentation/bbox/area`,不需要先单击选中顶点,已保存 mask 标为 dirty顶点拖拽结束不会触发 Stage 平移Canvas 当前缩放和位置保持不变;选中顶点后 Delete/Backspace 可删点但保留至少三点;选中 mask 但未选中顶点时 Delete/Backspace 删除整个 mask已保存 mask 会同步调用后端删除 | | Polygon 逐点编辑 / 删除 | 真实可用 | 点击 mask 后显示 polygon 顶点;按住顶点即可直接拖动并实时重算 `pathData/segmentation/bbox/area`,不需要先单击选中顶点,已保存 mask 标为 dirty顶点拖拽结束不会触发 Stage 平移Canvas 当前缩放和位置保持不变;选中顶点后 Delete/Backspace 可删点但保留至少三点;选中 mask 但未选中顶点时 Delete/Backspace 删除整个 mask已保存 mask 会同步调用后端删除;若删除对象是传播 seed 或传播结果,前端会按 `source_annotation_id``source_mask_id``propagation_seed_key` 同步删除同链自动传播 mask但不删除其他帧独立 AI 推理/人工 mask |
| GT seed point 回显/编辑 | 真实可用 | 已保存标注的 `points` 会显示为黄色 seed 点;拖动后标记为 dirty归档保存会更新后端 |
| 应用分类 | 真实可用 | Canvas 右下角按钮可将当前选择的模板分类应用到本帧 mask并同步同一传播链前后帧的对应 mask右侧语义分类树点击分类时会优先改当前已选 mask并通过 `source_annotation_id``source_mask_id``propagation_seed_key` 同步更新同一传播链上的前后传播 mask同时把已选 mask 移到前端渲染最上层方便继续编辑;已保存 mask 会标为 dirty归档保存时更新后端 | | 应用分类 | 真实可用 | Canvas 右下角按钮可将当前选择的模板分类应用到本帧 mask并同步同一传播链前后帧的对应 mask右侧语义分类树点击分类时会优先改当前已选 mask并通过 `source_annotation_id``source_mask_id``propagation_seed_key` 同步更新同一传播链上的前后传播 mask同时把已选 mask 移到前端渲染最上层方便继续编辑;已保存 mask 会标为 dirty归档保存时更新后端 |
| 清空遮罩 | 真实可用 | 工作区中会删除当前帧已保存标注并清空当前帧本地 mask | | 清空遮罩 | 真实可用 | 工作区中会删除当前帧已保存标注并清空当前帧本地 mask |
| 保存状态计数 | 真实可用 | 底部显示已保存、未保存、待更新数量 | | 保存状态计数 | 真实可用 | 底部显示已保存、未保存、待更新数量 |
@@ -115,9 +114,9 @@
| 拖拽/选择 | 真实可用 | 控制 Canvas 是否可拖拽 | | 拖拽/选择 | 真实可用 | 控制 Canvas 是否可拖拽 |
| 调整多边形 | 真实可用 | 选中 polygon mask 后显示顶点和边中点;支持按住顶点直接拖动、点击边中点插点、双击边界按位置插点 | | 调整多边形 | 真实可用 | 选中 polygon mask 后显示顶点和边中点;支持按住顶点直接拖动、点击边中点插点、双击边界按位置插点 |
| 多边形/矩形/圆/画笔/橡皮擦 | 真实可用 | 切换 activeTool 后由 `CanvasArea` 生成或编辑可保存的 polygon mask画笔/橡皮擦在工具栏显示尺寸滑杆 | | 多边形/矩形/圆/画笔/橡皮擦 | 真实可用 | 切换 activeTool 后由 `CanvasArea` 生成或编辑可保存的 polygon mask画笔/橡皮擦在工具栏显示尺寸滑杆 |
| 区域合并/去除 | 真实可用 | 选择工具后点击多个 mask右下角显示已选数量和操作按钮合并/去除模式会隐藏 polygon 编辑手柄,避免手柄抢占多选点击;布尔选择态中第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,避免主区域和扣除区域看起来像随机阴影差异;使用 `polygon-clipping` 做 union / difference合并会保留主 mask 并移除被合并 mask去除会从主 mask 扣除后续选中 mask内含扣除会保留 hole ring 并用 even-odd 规则渲染 | | 区域合并/去除 | 真实可用 | 选择工具后点击多个 mask右下角显示已选数量和操作按钮合并/去除模式会隐藏 polygon 编辑手柄,避免手柄抢占多选点击;布尔选择态中第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,避免主区域和扣除区域看起来像随机阴影差异;使用 `polygon-clipping` 做 union / difference合并会保留主 mask 并移除被合并 mask且移除次级 mask 时会同步删除其同链自动传播结果;去除会从主 mask 扣除后续选中 mask内含扣除会保留 hole ring 并用 even-odd 规则渲染 |
| 导入 GT Mask | 真实可用 | 位于“重叠区域去除”之后,点击后打开文件选择器,并在上传前选择未知类别处理策略;该入口不切换 activeTool | | 导入 GT Mask | 真实可用 | 位于“重叠区域去除”之后,点击后打开文件选择器,并在上传前选择未知类别处理策略;该入口不切换 activeTool |
| 魔法棒 SAM 触发 | 部分可用 | 切到 AI 页;不是直接执行推理 | | AI 智能分割跳转入口 | 真实可用 | 切到 AI 智能分割页;不是直接执行推理 |
| AI 正向选点/反向选点/框选 | 不在工作区工具栏显示 | 这些是 AI 智能分割页功能,工作区左侧工具栏不再提供正向选点、反向选点和边界框选按钮 | | AI 正向选点/反向选点/框选 | 不在工作区工具栏显示 | 这些是 AI 智能分割页功能,工作区左侧工具栏不再提供正向选点、反向选点和边界框选按钮 |
| AI 智能分割入口 | 真实可用 | 位于工作区工具栏底部,使用和侧栏一致的 Bot + Sparkles 组合图标;点击后切到 AI 智能分割页 | | AI 智能分割入口 | 真实可用 | 位于工作区工具栏底部,使用和侧栏一致的 Bot + Sparkles 组合图标;点击后切到 AI 智能分割页 |
| 撤销/重做 | 真实可用 | 绑定 Zustand `maskHistory/maskFuture`,工作区只保留顶栏按钮和快捷键 `Ctrl/Cmd+Z``Ctrl/Cmd+Shift+Z``Ctrl/Cmd+Y`AI 页保留自己的按钮;左侧工具栏不再重复放置撤销/重做;输入框聚焦时不拦截快捷键;工作区顶栏撤销图标使用琥珀色、重做图标使用蓝紫色,提高深色顶栏里的识别度 | | 撤销/重做 | 真实可用 | 绑定 Zustand `maskHistory/maskFuture`,工作区只保留顶栏按钮和快捷键 `Ctrl/Cmd+Z``Ctrl/Cmd+Shift+Z``Ctrl/Cmd+Y`AI 页保留自己的按钮;左侧工具栏不再重复放置撤销/重做;输入框聚焦时不拦截快捷键;工作区顶栏撤销图标使用琥珀色、重做图标使用蓝紫色,提高深色顶栏里的识别度 |
@@ -131,7 +130,7 @@
| 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)`;非当前帧中,人工/AI 标注帧使用红色边框,自动传播/推理帧使用蓝色边框;同一帧同时有人工/AI 标注和自动传播结果时,红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注,避免状态颜色互相覆盖 | | 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)`;非当前帧中,人工/AI 标注帧使用红色边框,自动传播/推理帧使用蓝色边框;同一帧同时有人工/AI 标注和自动传播结果时,红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注,避免状态颜色互相覆盖 |
| 顶部 range 拖动 | 真实可用 | 改变当前帧 | | 顶部 range 拖动 | 真实可用 | 改变当前帧 |
| 具体时间显示 | 真实可用 | 根据项目 `parse_fps/original_fps` 显示当前时间和总时长,格式为 `mm:ss.cc` | | 具体时间显示 | 真实可用 | 根据项目 `parse_fps/original_fps` 显示当前时间和总时长,格式为 `mm:ss.cc` |
| 播放进度条 / 视频处理进度条 | 真实可用 | 播放进度条位于上方,视频处理进度条位于下方;当前帧位置用一条白色竖线贯穿两条进度条,避免和青色播放进度、红/蓝处理状态混淆;视频处理进度条普通状态下可点击跳转到对应帧;根据已保存标注回显的 `mask_data.source``propagated_from_frame_id``source_annotation_id``source_mask_id``propagation_seed_key` 识别自动传播生成的帧并显示蓝色区段,人工绘制或 AI 智能分割生成的帧显示红色竖线,红/蓝标识也可点击跳转到对应帧;每次自动传播成功处理帧后,工作区会在当前会话记录最近传播范围,并在视频处理进度条上叠加同一蓝色系的纯色片段,按距最新传播的时间顺序逐次变暗,且第 5 次及更早统一为阈值旧记录色,辅助识别第一次、第二次、第 N 次传播;清空片段遮罩会同步移除或裁剪与清空范围重叠的传播历史片段;未处理背景使用中性灰以和红/蓝/传播历史标记区分;工作区进入自动传播或清空片段遮罩的范围选择模式时,两条进度条显示 amber 选区,并额外用洋红色起始线和黄绿色结束线贯穿两条进度条,表示待处理起止帧,颜色避开附近的青色、红色、蓝色和 amber 元素 | | 播放进度条 / 视频处理进度条 | 真实可用 | 播放进度条位于上方,视频处理进度条位于下方;当前帧位置用一条白色竖线贯穿两条进度条,避免和青色播放进度、红/蓝处理状态混淆;视频处理进度条普通状态下可点击跳转到对应帧;根据已保存标注回显的 `mask_data.source``propagated_from_frame_id``source_annotation_id``source_mask_id``propagation_seed_key` 识别自动传播生成的帧并显示蓝色区段,人工绘制或 AI 智能分割生成的帧显示红色竖线,红/蓝标识也可点击跳转到对应帧;每次自动传播成功处理帧后,工作区会在当前会话记录最近传播范围,并在视频处理进度条上叠加同一蓝色系的纯色片段,按距最新传播的时间顺序逐次变暗,且第 5 次及更早统一为阈值旧记录色,辅助识别第一次、第二次、第 N 次传播;传播历史片段会按当前仍存在的自动传播 mask 自动裁剪或拆分,清空片段遮罩或单独删除传播 mask 后,无任何 mask 的帧不会继续显示红/蓝颜色;未处理背景使用中性灰以和红/蓝/传播历史标记区分;工作区进入自动传播或清空片段遮罩的范围选择模式时,两条进度条显示 amber 选区,并额外用洋红色起始线和黄绿色结束线贯穿两条进度条,表示待处理起止帧,颜色避开附近的青色、红色、蓝色和 amber 元素 |
| 播放/暂停 | 真实可用 | 当前代码按 `parse_fps/original_fps` 推进帧,最多 30fps | | 播放/暂停 | 真实可用 | 当前代码按 `parse_fps/original_fps` 推进帧,最多 30fps |
| 方向键切帧 | 真实可用 | 全局监听左右方向键切到上一帧/下一帧;焦点在 input、textarea、select 或 contentEditable 内时不会拦截 | | 方向键切帧 | 真实可用 | 全局监听左右方向键切到上一帧/下一帧;焦点在 input、textarea、select 或 contentEditable 内时不会拦截 |
@@ -139,7 +138,7 @@
| 元素 | 状态 | 说明 | | 元素 | 状态 | 说明 |
|------|------|------| |------|------|------|
| 模板选择 | 部分可用 | 读取全局 templates可切换 activeTemplateId | | 模板选择 | 真实可用 | 读取全局 templates可切换 activeTemplateId并会驱动分类树、mask 分类和导出类别信息 |
| 面板滚动条 | 真实可用 | 右侧本体/语义分类面板内容过长时自身滚动;滚动条使用 `seg-scrollbar`默认低对比融入深色侧栏hover/focus 时才增强显示 | | 面板滚动条 | 真实可用 | 右侧本体/语义分类面板内容过长时自身滚动;滚动条使用 `seg-scrollbar`默认低对比融入深色侧栏hover/focus 时才增强显示 |
| 面板标题 | 已简化 | 原“本体论与属性分类管理树”固定说明栏已移除,右侧面板直接展示模板、透明度和语义分类树 | | 面板标题 | 已简化 | 原“本体论与属性分类管理树”固定说明栏已移除,右侧面板直接展示模板、透明度和语义分类树 |
| 分类树展示 / 换标签 | 真实可用 | 显示当前模板 classes点击分类会设为后续新 mask 的 activeClass如果 Canvas 已选 mask则同步更新已选 mask 及同一传播链前后帧对应 mask 的标签、颜色和 class 元数据,并把已选 mask 移到前端渲染最上层;当用户在 Canvas 点击已有 mask 时,本面板会按 mask 的 class id / 名称自动切换模板、设置 active class并滚动/聚焦到对应分类按钮 | | 分类树展示 / 换标签 | 真实可用 | 显示当前模板 classes点击分类会设为后续新 mask 的 activeClass如果 Canvas 已选 mask则同步更新已选 mask 及同一传播链前后帧对应 mask 的标签、颜色和 class 元数据,并把已选 mask 移到前端渲染最上层;当用户在 Canvas 点击已有 mask 时,本面板会按 mask 的 class id / 名称自动切换模板、设置 active class并滚动/聚焦到对应分类按钮 |
@@ -179,13 +178,12 @@
| 编辑模板 | 真实可用 | 调用 `PATCH /api/templates/{id}` | | 编辑模板 | 真实可用 | 调用 `PATCH /api/templates/{id}` |
| 删除模板 | 真实可用 | 调用 `DELETE /api/templates/{id}` | | 删除模板 | 真实可用 | 调用 `DELETE /api/templates/{id}` |
| 添加/删除分类 | 真实可用 | 保存在模板 `mapping_rules.classes` | | 添加/删除分类 | 真实可用 | 保存在模板 `mapping_rules.classes` |
| 拖拽排序 | 真实可用 | 模板库和工作区右侧语义分类树都可拖拽调整内部覆盖优先级,保存时写后端;工作区拖拽会同步当前同类 mask 的 `classZIndex` 并标记待保存;界面只显示类别稳定 maskidmaskid 不作为排序规范 | | 拖拽排序 | 真实可用 | 模板库详情页、模板编辑弹窗和工作区右侧语义分类树都可拖拽调整内部覆盖优先级,保存时写后端;模板库详情页拖拽会刷新当前详情并同步当前工作区同类 mask 的 `classZIndex`工作区拖拽会同步当前同类 mask 的 `classZIndex` 并标记待保存;界面只显示类别稳定 maskidmaskid 不作为排序规范;黑色 `maskid: 0` 的“待分类”保留类固定在最后,不可删除或拖拽上移 |
| JSON 批量导入 | 部分可用 | 前端解析 JSON 并加入编辑态,保存后才落库 | | JSON 批量导入 | 真实可用 | 前端解析 JSON 并显示导入数量、maskid 起点和缺失颜色提示;导入后加入编辑态,保存模板时落库 |
| 载入腹腔镜 35 分类 | 真实可用 | 前端内置数据;后端也 seed 默认模板 |
| mapping rules | 部分可用 | 可存 `rules`,但当前没有运行时映射执行引擎;适合后续用于导入外部标签、别名归一化或跨数据集类别映射 | | mapping rules | 部分可用 | 可存 `rules`,但当前没有运行时映射执行引擎;适合后续用于导入外部标签、别名归一化或跨数据集类别映射 |
## 总体结论 ## 总体结论
当前前端真实可用的主链路是JWT 登录、刷新恢复用户、退出登录、Dashboard 当前用户概览、当前用户项目列表、新建项目、上传视频/DICOM、显式生成帧、浏览帧、播放帧、工作区手工绘制、点/框 AI 推理、视频片段传播、GT mask 导入、标注保存/回显、COCO 导出、PNG mask ZIP 导出、模板 CRUD。 当前前端真实可用的主链路是JWT 登录、刷新恢复用户、退出登录、Dashboard 当前用户概览、当前用户项目列表、新建项目、上传视频/DICOM、显式生成帧、浏览帧、播放帧、工作区手工绘制、点/框 AI 推理、视频片段传播、GT mask 导入、标注保存/回显、统一分割结果 ZIP 导出、兼容 COCO/PNG mask ZIP 导出、模板 CRUD。
当前最主要的 Mock 或未打通链路是:真正的文本语义分割已因无文本提示入口而暂时禁用;复杂洞结构编辑、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单、全业务操作审计和 mapping rules 运行时映射执行引擎仍未落地。登录页“端到端加密”等安全文案仍只是 UI 文案;登录和用户管理操作审计已落库并可在管理员后台查看。 当前最主要的 Mock 或未打通链路是:真正的文本语义分割已因无文本提示入口而暂时禁用;复杂洞结构编辑、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单、全业务操作审计和 mapping rules 运行时映射执行引擎仍未落地。登录页“端到端加密”等安全文案仍只是 UI 文案;登录和用户管理操作审计已落库并可在管理员后台查看。

View File

@@ -26,13 +26,13 @@ Authorization: Bearer <token>
| `getProjects()` | `GET /api/projects` | 对齐 | 前端映射 `frame_count``thumbnail_url` 等字段 | | `getProjects()` | `GET /api/projects` | 对齐 | 前端映射 `frame_count``thumbnail_url` 等字段 |
| `createProject(payload)` | `POST /api/projects` | 对齐 | 支持 `name``description``parse_fps` | | `createProject(payload)` | `POST /api/projects` | 对齐 | 支持 `name``description``parse_fps` |
| `updateProject(id, payload)` | `PATCH /api/projects/{id}` | 对齐 | 后端是 `PATCH /api/projects/{id}` | | `updateProject(id, payload)` | `PATCH /api/projects/{id}` | 对齐 | 后端是 `PATCH /api/projects/{id}` |
| `deleteProject(id)` | `DELETE /api/projects/{id}` | 对齐 | 当前 UI 未明显接入 | | `deleteProject(id)` | `DELETE /api/projects/{id}` | 对齐 | 项目卡片删除按钮已接入,删除前使用站内确认弹窗 |
| `getTemplates()` | `GET /api/templates` | 对齐 | 前端从 `mapping_rules` 取 classes/rules | | `getTemplates()` | `GET /api/templates` | 对齐 | 前端从 `mapping_rules` 取 classes/rules |
| `createTemplate(payload)` | `POST /api/templates` | 对齐 | 后端会打包 classes/rules 到 mapping_rules | | `createTemplate(payload)` | `POST /api/templates` | 对齐 | 后端会打包 classes/rules 到 mapping_rules |
| `updateTemplate(id, payload)` | `PATCH /api/templates/{id}` | 对齐 | 模板编辑页使用 | | `updateTemplate(id, payload)` | `PATCH /api/templates/{id}` | 对齐 | 模板编辑页使用 |
| `deleteTemplate(id)` | `DELETE /api/templates/{id}` | 对齐 | 模板编辑页使用 | | `deleteTemplate(id)` | `DELETE /api/templates/{id}` | 对齐 | 模板编辑页使用 |
| `uploadMedia(file, projectId)` | `POST /api/media/upload` | 对齐 | multipart form-data | | `uploadMedia(file, projectId, options?)` | `POST /api/media/upload` | 对齐 | multipart form-data`options.onProgress` 用于项目库上传进度 |
| `uploadDicomBatch(files, projectId)` | `POST /api/media/upload/dicom` | 对齐 | multipart form-data | | `uploadDicomBatch(files, projectId, options?)` | `POST /api/media/upload/dicom` | 对齐 | multipart form-data`options.onProgress` 用于项目库上传进度,上传完成后项目库轮询解析任务进度 |
| `parseMedia(projectId, options?)` | `POST /api/media/parse?project_id=...` | 对齐 | 创建异步拆帧任务并返回 task由项目库“生成帧”显式调用支持 `parse_fps``max_frames``target_width` | | `parseMedia(projectId, options?)` | `POST /api/media/parse?project_id=...` | 对齐 | 创建异步拆帧任务并返回 task由项目库“生成帧”显式调用支持 `parse_fps``max_frames``target_width` |
| `getTask(taskId)` | `GET /api/tasks/{task_id}` | 对齐 | 查询异步任务状态 | | `getTask(taskId)` | `GET /api/tasks/{task_id}` | 对齐 | 查询异步任务状态 |
| `cancelTask(taskId)` | `POST /api/tasks/{task_id}/cancel` | 对齐 | 取消 queued/running 任务,后端写 cancelled 并尝试 revoke Celery | | `cancelTask(taskId)` | `POST /api/tasks/{task_id}/cancel` | 对齐 | 取消 queued/running 任务,后端写 cancelled 并尝试 revoke Celery |
@@ -47,7 +47,7 @@ Authorization: Bearer <token>
| `saveAnnotation(payload)` | `POST /api/ai/annotate` | 对齐 | 工作区归档保存当前项目未保存 mask | | `saveAnnotation(payload)` | `POST /api/ai/annotate` | 对齐 | 工作区归档保存当前项目未保存 mask |
| `updateAnnotation(annotationId, payload)` | `PATCH /api/ai/annotations/{annotation_id}` | 对齐 | 工作区归档保存 dirty mask | | `updateAnnotation(annotationId, payload)` | `PATCH /api/ai/annotations/{annotation_id}` | 对齐 | 工作区归档保存 dirty mask |
| `deleteAnnotation(annotationId)` | `DELETE /api/ai/annotations/{annotation_id}` | 对齐 | 工作区清空当前帧已保存标注 | | `deleteAnnotation(annotationId)` | `DELETE /api/ai/annotations/{annotation_id}` | 对齐 | 工作区清空当前帧已保存标注 |
| `importGtMask(file, projectId, frameId, templateId?, options?)` | `POST /api/ai/import-gt-mask` | 对齐 | multipart 上传 GT mask支持 `unknown_color_policy=discard/undefined`;后端仅接受灰度 maskid 图或 RGB 三通道完全相同的 `[X,X,X]` maskid 图0 为背景、X 为 maskid;按模板 `maskId` 匹配类别,未知 maskid 可舍弃或导入为未定义类别;尺寸不同会最近邻拉伸到当前帧,连通域会生成 polygon 标注 seed point | | `importGtMask(file, projectId, frameId, templateId?, options?)` | `POST /api/ai/import-gt-mask` | 对齐 | multipart 上传 GT mask支持 `unknown_color_policy=discard/undefined`;后端仅接受 8-bit 灰度 maskid 图或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图0 为背景、X 为 1-255 的 maskid16-bit/uint16 GT_label、全背景 0 图和普通彩色类别图会被拒绝全背景错误信息固定为“GT Mask 图片中没有非背景 maskid 区域。”;按模板 `maskId` 匹配类别,未知 maskid 可舍弃或导入为未定义类别;尺寸不同会最近邻拉伸到当前帧,连通域会生成高精度 polygon 标注;导入标注可直接用于 `/api/ai/analyze-mask``/api/ai/smooth-mask`,前端不显示或拖动 seed point |
| `getDashboardOverview()` | `GET /api/dashboard/overview` | 对齐 | Dashboard 初始统计、队列和活动日志 | | `getDashboardOverview()` | `GET /api/dashboard/overview` | 对齐 | Dashboard 初始统计、队列和活动日志 |
| `exportCoco(projectId)` | `GET /api/export/{projectId}/coco` | 对齐 | 后端实际是 `GET /api/export/{project_id}/coco` | | `exportCoco(projectId)` | `GET /api/export/{projectId}/coco` | 对齐 | 后端实际是 `GET /api/export/{project_id}/coco` |
| `exportMasks(projectId)` | `GET /api/export/{projectId}/masks` | 对齐 | 下载单标注 mask、语义融合 mask 和类别映射 ZIP | | `exportMasks(projectId)` | `GET /api/export/{projectId}/masks` | 对齐 | 下载单标注 mask、语义融合 mask 和类别映射 ZIP |
@@ -63,7 +63,7 @@ Authorization: Bearer <token>
| GET | `/api/auth/me` | 当前用户 | | GET | `/api/auth/me` | 当前用户 |
| GET/POST/PATCH/DELETE | `/api/admin/users` | 管理员用户管理 | | GET/POST/PATCH/DELETE | `/api/admin/users` | 管理员用户管理 |
| GET | `/api/admin/audit-logs` | 管理员审计日志 | | GET | `/api/admin/audit-logs` | 管理员审计日志 |
| POST | `/api/admin/demo-factory-reset` | 演示部署恢复出厂设置;请求体需 `confirmation=RESET_DEMO_FACTORY` | | POST | `/api/admin/demo-factory-reset` | 演示部署恢复出厂设置;请求体需 `confirmation=RESET_DEMO_FACTORY`;重置后保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目;响应包含兼容单个 `project` 和完整 `projects` 列表 |
| POST | `/api/projects` | 创建项目 | | POST | `/api/projects` | 创建项目 |
| GET | `/api/projects` | 项目列表 | | GET | `/api/projects` | 项目列表 |
| GET | `/api/projects/{project_id}` | 项目详情 | | GET | `/api/projects/{project_id}` | 项目详情 |
@@ -98,7 +98,7 @@ Authorization: Bearer <token>
| GET | `/api/dashboard/overview` | Dashboard 聚合快照 | | GET | `/api/dashboard/overview` | Dashboard 聚合快照 |
| GET | `/api/export/{project_id}/coco` | 导出 COCO JSON | | GET | `/api/export/{project_id}/coco` | 导出 COCO JSON |
| GET | `/api/export/{project_id}/masks` | 导出 PNG mask ZIP | | GET | `/api/export/{project_id}/masks` | 导出 PNG mask ZIP |
| GET | `/api/export/{project_id}/results` | 统一导出分割结果 ZIP包含 `annotations_coco.json``maskid_GT像素值_类别映射.json``原始图片/` 和按参数选择的 `分开Mask分割结果/``GT_label图/``Pro_label彩色分割结果/``Mix_label重叠覆盖彩色分割结果/`GT_label 背景为 0类别值使用模板中的真实 maskid缺失 maskid 的旧标注才补下一个可用正整数 | | GET | `/api/export/{project_id}/results` | 统一导出分割结果 ZIP包含 `annotations_coco.json``maskid_GT像素值_类别映射.json``原始图片/` 和按参数选择的 `分开Mask分割结果/``GT_label图/``Pro_label彩色分割结果/``Mix_label重叠覆盖彩色分割结果/`GT_label 固定输出 8-bit uint8 PNG背景为 0类别值使用模板中的真实 maskid`maskid:0` 待分类和背景同为 0缺失 maskid 的旧标注才补下一个可用正整数;正整数 maskid 超出 1-255 时拒绝导出 |
| GET | `/health` | 健康检查 | | GET | `/health` | 健康检查 |
| WS | `/ws/progress` | WebSocket 进度通道,未出现在 OpenAPI paths 中 | | WS | `/ws/progress` | WebSocket 进度通道,未出现在 OpenAPI paths 中 |
@@ -301,7 +301,7 @@ SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2`
- `getProjectAnnotations()` 已接入 `GET /api/ai/annotations` - `getProjectAnnotations()` 已接入 `GET /api/ai/annotations`
- `updateAnnotation()` 已接入 `PATCH /api/ai/annotations/{annotationId}` - `updateAnnotation()` 已接入 `PATCH /api/ai/annotations/{annotationId}`
- `deleteAnnotation()` 已接入 `DELETE /api/ai/annotations/{annotationId}` - `deleteAnnotation()` 已接入 `DELETE /api/ai/annotations/{annotationId}`
- `importGtMask()` 已接入 `POST /api/ai/import-gt-mask`,导入后端生成的 polygon 标注、原始 `gt_label_value`、原图尺寸/是否拉伸信息和 seed point。导入端使用 `cv2.IMREAD_UNCHANGED` 保留低数值/16-bit GT_label 图的像素值;灰度图和 RGB 三通道相等图按模板 `maskId` 匹配类别,不再按彩色 RGB 颜色匹配类别;超出现有类别时由 `unknown_color_policy` 决定舍弃或写为 `gt_unknown_class` 未定义类别。 - `importGtMask()` 已接入 `POST /api/ai/import-gt-mask`,导入后端生成的高精度 polygon 标注、原始 `gt_label_value`、原图尺寸/是否拉伸信息。导入端使用 `cv2.IMREAD_UNCHANGED` 读取后校验 dtype仅接受 8-bit 灰度图和 8-bit RGB 三通道相等图,并按模板 `maskId` 匹配类别16-bit/uint16 GT_label、全背景 0 图和普通彩色 RGB 类别图都会返回格式错误全背景图保留“GT Mask 图片中没有非背景 maskid 区域。”提示;超出现有类别时由 `unknown_color_policy` 决定舍弃或写为 `gt_unknown_class` 未定义类别。导入 mask 与普通 mask 共用拓扑统计、边缘平滑和保存更新接口,前端不显示黄色 seed point。
- `exportMasks()` 已接入 `GET /api/export/{projectId}/masks` - `exportMasks()` 已接入 `GET /api/export/{projectId}/masks`
- `parseMedia()` 已改为创建 Celery 后台任务,并返回 `ProcessingTask` - `parseMedia()` 已改为创建 Celery 后台任务,并返回 `ProcessingTask`
- `queuePropagationTask()` 已接入 `/api/ai/propagate/task`,自动传播不再依赖长时间同步 HTTP 请求。 - `queuePropagationTask()` 已接入 `/api/ai/propagate/task`,自动传播不再依赖长时间同步 HTTP 请求。
@@ -312,7 +312,7 @@ SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2`
- Dashboard 任务列表已展示 queued/running/success/failed/cancelled 任务,并可通过 `getTask()` 查看失败详情;`summary.parsing_task_count` 仍只统计 queued/running。 - Dashboard 任务列表已展示 queued/running/success/failed/cancelled 任务,并可通过 `getTask()` 查看失败详情;`summary.parsing_task_count` 仍只统计 queued/running。
- 工作区“分割结果导出”已调用 `exportSegmentationResults()`,并会先保存未归档 mask旧的 `exportCoco()` / `exportMasks()` 仍保留为兼容接口。 - 工作区“分割结果导出”已调用 `exportSegmentationResults()`,并会先保存未归档 mask旧的 `exportCoco()` / `exportMasks()` 仍保留为兼容接口。
- PNG mask ZIP 已包含每帧 `semantic_frame_*.png``semantic_classes.json`,重叠区域按 zIndex 裁决。 - PNG mask ZIP 已包含每帧 `semantic_frame_*.png``semantic_classes.json`,重叠区域按 zIndex 裁决。
- 统一导出 ZIP 下载文件名为 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;项目名来自 `Project.name` 并会替换文件系统不安全字符,时间戳来自帧 `timestamp_ms` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based `frame_index + 1`,不使用原视频 `source_frame_number`。ZIP 内包含 `annotations_coco.json``maskid_GT像素值_类别映射.json``原始图片/`。原始图片按 `视频名称_时间戳_项目帧序号` 命名;选择分开 mask 时写入 `分开Mask分割结果/{视频名称_时间戳_项目帧序号}_分别导出/{视频名称_时间戳_项目帧序号}_{类别名称}_maskid{maskid}.png`,同一帧同一类别会合并为一张二值 mask选择 GT_label 图时写入 `GT_label图/{视频名称_时间戳_项目帧序号}.png`;选择 Pro_label 彩色图时写入 `Pro_label彩色分割结果/{视频名称_时间戳_项目帧序号}.png`;选择 Mix_label 叠加图时写入 `Mix_label重叠覆盖彩色分割结果/{视频名称_时间戳_项目帧序号}.png`,透明度由 `mix_opacity` 控制,默认 0.3。导出时 maskid 与 GT_label 像素值相同;有模板 maskid 的类别保留真实 maskid缺失 maskid 的旧标注补下一个可用正整数并写入映射 JSON跨图一致maskid 不参与覆盖排序,覆盖顺序仍使用内部拖拽排序字段。 - 统一导出 ZIP 下载文件名为 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;项目名来自 `Project.name` 并会替换文件系统不安全字符,时间戳来自帧 `timestamp_ms` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based `frame_index + 1`,不使用原视频 `source_frame_number`。ZIP 内包含 `annotations_coco.json``maskid_GT像素值_类别映射.json``原始图片/`。原始图片按 `视频名称_时间戳_项目帧序号` 命名;选择分开 mask 时写入 `分开Mask分割结果/{视频名称_时间戳_项目帧序号}_分别导出/{视频名称_时间戳_项目帧序号}_{类别名称}_maskid{maskid}.png`,同一帧同一类别会合并为一张二值 mask选择 GT_label 图时写入 `GT_label图/{视频名称_时间戳_项目帧序号}.png`,固定为 8-bit uint8 PNG;选择 Pro_label 彩色图时写入 `Pro_label彩色分割结果/{视频名称_时间戳_项目帧序号}.png`;选择 Mix_label 叠加图时写入 `Mix_label重叠覆盖彩色分割结果/{视频名称_时间戳_项目帧序号}.png`,透明度由 `mix_opacity` 控制,默认 0.3。导出时 maskid 与 GT_label 像素值相同;有模板 maskid 的类别保留真实 maskid其中 `maskid:0` 的“待分类”和背景同为 0缺失 maskid 的旧标注补下一个可用正整数并写入映射 JSON跨图一致正整数 maskid 必须在 1-255 内,超出时拒绝导出;maskid 不参与覆盖排序,覆盖顺序仍使用内部拖拽排序字段。
## 仍需处理的接口问题 ## 仍需处理的接口问题

View File

@@ -46,7 +46,7 @@
1. COCO JSON 调用 `/api/export/{projectId}/coco` 1. COCO JSON 调用 `/api/export/{projectId}/coco`
2. PNG Mask ZIP 调用 `/api/export/{projectId}/masks` 2. PNG Mask ZIP 调用 `/api/export/{projectId}/masks`
3. 兼容 PNG Mask ZIP 仍保留单标注二值 `mask_*.png`,同时输出 `semantic_frame_*.png``semantic_classes.json` 3. 兼容 PNG Mask ZIP 仍保留单标注二值 `mask_*.png`,同时输出 `semantic_frame_*.png``semantic_classes.json`
4. 统一导出调用 `/api/export/{projectId}/results`,支持整体视频、特定范围帧、当前图片三种范围,以及分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图ZIP 固定包含 maskid/GT 像素值映射 JSON 和原始图片文件夹,各输出文件夹按客户指定的 `视频名称_0h00m00s000ms_项目帧序号` 规则命名GT_label 图背景为 0类别值优先使用模板中的真实 maskid缺失 maskid 的旧标注才补下一个可用正整数 4. 统一导出调用 `/api/export/{projectId}/results`,支持整体视频、特定范围帧、当前图片三种范围,以及分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图ZIP 固定包含 maskid/GT 像素值映射 JSON 和原始图片文件夹,各输出文件夹按客户指定的 `视频名称_0h00m00s000ms_项目帧序号` 规则命名GT_label 图固定为 8-bit uint8 PNG背景为 0类别值优先使用模板中的真实 maskid其中 `maskid:0` 的“待分类”和背景同为 0缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出
剩余建议: 剩余建议:
@@ -95,17 +95,17 @@ Dashboard 的解析队列现在已经从“项目状态派生”升级为任务
## 阶段 6GT 导入与点区域(已完成基础增强版) ## 阶段 6GT 导入与点区域(已完成基础增强版)
Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前已经完成基础增强版:导入二值/标签 mask 图片后,后端按非零像素值拆分类别,再按连通域生成 polygon 标注,并用距离变换提取一个正向 seed point。 Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前已经完成基础增强版:导入二值/标签 mask 图片后,后端按非零像素值拆分类别,再按连通域生成高精度 polygon 标注;导入结果与普通 mask 共用拓扑统计、边缘平滑、编辑和保存链路,前端不显示或拖动 seed point。
已完成: 已完成:
1. 工作区左侧工具栏提供“导入 GT Mask”入口位置在“重叠区域去除”之后。 1. 工作区左侧工具栏提供“导入 GT Mask”入口位置在“重叠区域去除”之后。
2. 前端调用 `POST /api/ai/import-gt-mask` multipart 接口。 2. 前端调用 `POST /api/ai/import-gt-mask` multipart 接口。
3. 后端按非零像素值拆分多类别 mask。 3. 后端按非零像素值拆分多类别 mask。
4. 后端使用 OpenCV contour 提取每个类别下的连通域。 4. 后端使用 OpenCV 高精度 contour 提取每个类别下的连通域,尽量保留边界细节,并用点数上限保护前端性能
5. 后端使用 distance transform 生成 `points` seed。 5. 后端保留 distance transform `points` seed 供数据兼容
6. 导入结果写入 `annotations` 表并回显为工作区 mask。 6. 导入结果写入 `annotations` 表并回显为工作区 mask。
7. 前端 seed point 转为像素坐标显示在 Canvas 上,拖动后会标记标注为 dirty 并可归档保存 7. 前端不显示 seed point,也不提供 seed point 拖动;导入 mask 与普通 mask 保持一致的可选中、顶点编辑、拓扑统计、边缘平滑和保存体验
剩余建议: 剩余建议:

View File

@@ -16,7 +16,8 @@
- 角色包括 `admin``annotator``viewer``admin/annotator` 可写入业务数据和触发 AI/传播,`viewer` 只能访问读接口,用户管理后台仅 `admin` 可用。 - 角色包括 `admin``annotator``viewer``admin/annotator` 可写入业务数据和触发 AI/传播,`viewer` 只能访问读接口,用户管理后台仅 `admin` 可用。
- 管理员侧栏显示“用户管理”入口;管理员可以新增用户、修改角色、停用/启用、修改密码、删除无项目用户。 - 管理员侧栏显示“用户管理”入口;管理员可以新增用户、修改角色、停用/启用、修改密码、删除无项目用户。
- 系统记录登录成功/失败和用户管理操作到 `audit_logs`,管理员后台可查看最近审计日志。 - 系统记录登录成功/失败和用户管理操作到 `audit_logs`,管理员后台可查看最近审计日志。
- 管理员后台提供“恢复演示出厂设置”危险操作;前端必须二次确认,后端也必须校验 `confirmation=RESET_DEMO_FACTORY`,执行后只保留默认 admin 账号、系统模板和一个尚未生成帧的演示视频项目,清空其它用户、项目、帧、标注、任务、用户模板和旧审计记录,并写入本次重置审计。 - 管理员后台提供“恢复演示出厂设置”危险操作;前端必须二次确认,后端也必须校验 `confirmation=RESET_DEMO_FACTORY`,执行后只保留默认 admin 账号、系统模板、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,清空其它用户、项目、帧、标注、任务、用户模板和旧审计记录,并写入本次重置审计。
- 系统默认模板至少包含“腹腔镜胆囊切除术”和“头颈部CT分割”恢复演示出厂设置不得删除系统默认模板。
## R2 项目管理 ## R2 项目管理
@@ -24,10 +25,12 @@
- 用户可以新建项目,前端调用 `POST /api/projects`;后端把项目归属到当前登录用户。 - 用户可以新建项目,前端调用 `POST /api/projects`;后端把项目归属到当前登录用户。
- 用户可以选择项目,进入工作区。 - 用户可以选择项目,进入工作区。
- 用户可以导入视频文件,前端创建项目、上传文件并刷新项目列表;导入视频不自动拆帧。 - 用户可以导入视频文件,前端创建项目、上传文件并刷新项目列表;导入视频不自动拆帧。
- 用户可以对已导入且尚未生成帧的视频项目点击“生成帧”,在弹窗中选择目标 FPS 后创建拆帧任务。 - 用户可以对已导入且尚未生成帧的视频项目点击“生成帧”,在弹窗中选择目标 FPS 后创建拆帧任务;项目名称编辑状态下不能显示/触发生成帧入口DICOM 项目不能显示生成帧入口
- 用户可以导入 DICOM 序列,前端上传 DICOM、触发拆帧、刷新项目列表。 - 用户可以导入 DICOM 序列,前端上传 DICOM、触发拆帧、刷新项目列表。
- 用户可以在项目库项目卡片上修改项目名称,名称不能为空。
- 用户可以在项目卡片删除按钮旁复制项目;复制时可选择“新项目重置”或“全内容复制”。新项目重置必须复制项目媒体字段和已生成帧序列,但不复制标注或 mask 元数据;全内容复制必须额外复制标注和关联 mask 元数据,并将复制标注重新指向新项目中的对应帧。任务运行历史不复制。
- 用户可以在项目卡片上删除项目;前端调用 `DELETE /api/projects/{id}`删除成功后从项目库移除若删除当前项目则清空工作区当前项目、帧、mask 和选区。 - 用户可以在项目卡片上删除项目;前端调用 `DELETE /api/projects/{id}`删除成功后从项目库移除若删除当前项目则清空工作区当前项目、帧、mask 和选区。
- 后端支持项目创建、列表、详情、局部更新和删除。 - 后端支持项目创建、列表、详情、局部更新、复制和删除。
- 后端删除项目时通过 ORM 级联删除项目帧、标注、导出 mask 元数据和后台任务记录。 - 后端删除项目时通过 ORM 级联删除项目帧、标注、导出 mask 元数据和后台任务记录。
- 后端支持项目帧创建、列表和单帧查询。 - 后端支持项目帧创建、列表和单帧查询。
@@ -36,9 +39,11 @@
- 后端允许上传视频、图片、DICOM 文件,其他扩展名返回 400。 - 后端允许上传视频、图片、DICOM 文件,其他扩展名返回 400。
- 未提供项目 ID 上传时,后端自动创建项目。 - 未提供项目 ID 上传时,后端自动创建项目。
- 提供项目 ID 上传时,后端把上传对象关联到该项目。 - 提供项目 ID 上传时,后端把上传对象关联到该项目。
- 项目库导入视频和导入 DICOM 序列时,前端必须显示导入进度条;浏览器提供上传总字节数时显示百分比和已上传/总字节数未提供总字节数时显示已上传字节的非确定进度。DICOM 导入还必须显示本次有效 `.dcm` 文件数量,并在上传完成后持续显示解析任务进度,直到成功、失败或取消。
- 拆帧接口根据项目 `source_type` 处理视频或 DICOM。 - 拆帧接口根据项目 `source_type` 处理视频或 DICOM。
- 拆帧接口支持 `parse_fps``max_frames``target_width` 参数,用于生成可被 SAM 2 视频处理复用的标准帧序列。 - 拆帧接口支持 `parse_fps``max_frames``target_width` 参数,用于生成可被 SAM 2 视频处理复用的标准帧序列。
- 视频帧使用连续 `frame_%06d.jpg` 命名,默认从 `frame_000000.jpg` 开始,并按 `target_width` 缩放 - DICOM 批量导入和解析必须按文件名自然顺序处理 `.dcm` 文件,避免数字文件名被字符串排序打乱
- 视频/DICOM 解析后都使用连续 `frame_%06d.jpg` 命名,默认从 `frame_000000.jpg` 开始;视频帧按 `target_width` 缩放。
- 拆帧完成后写入 `frames` 记录,并把项目状态设为 `ready` - 拆帧完成后写入 `frames` 记录,并把项目状态设为 `ready`
- 每条帧记录包含 `frame_index``image_url``width``height``timestamp_ms``source_frame_number` - 每条帧记录包含 `frame_index``image_url``width``height``timestamp_ms``source_frame_number`
- 任务完成结果包含 `frame_sequence` 元数据:`original_fps``parse_fps``frame_count``duration_ms``target_width`、帧宽高和对象存储前缀。 - 任务完成结果包含 `frame_sequence` 元数据:`original_fps``parse_fps``frame_count``duration_ms``target_width`、帧宽高和对象存储前缀。
@@ -68,21 +73,22 @@
- 工具栏可以切换当前 active tool。 - 工具栏可以切换当前 active tool。
- 工作区左侧工具栏不展示正向点、反向点、框选工具;这些入口只属于 AI 智能分割页。 - 工作区左侧工具栏不展示正向点、反向点、框选工具;这些入口只属于 AI 智能分割页。
- 侧栏“AI智能分割”和工作区工具栏 AI 跳转入口必须使用带明确 AI 语义的图标,而不是普通魔法棒等泛化工具图标。 - 侧栏“AI智能分割”和工作区工具栏 AI 跳转入口必须使用带明确 AI 语义的图标,而不是普通魔法棒等泛化工具图标。
- 魔法棒按钮切换到 AI 页面。 - 工作区 AI 智能分割入口切换到 AI 页面。
- 多边形、矩形、圆、画笔、橡皮擦工具会在 Canvas 上生成或编辑可保存的 polygon mask左侧工具栏不再提供创建点和创建线段入口。 - 多边形、矩形、圆、画笔、橡皮擦工具会在 Canvas 上生成或编辑可保存的 polygon mask左侧工具栏不再提供创建点和创建线段入口。
- 多边形通过点击取点并按 Enter 完成,也支持三点后点击首节点闭合;矩形、圆通过拖拽生成;画笔和橡皮擦支持调整大小。 - 多边形通过点击取点并按 Enter 完成,也支持三点后点击首节点闭合;矩形、圆通过拖拽生成;画笔和橡皮擦支持调整大小。
- 画笔工具只在语义分类树有选中类别时可用,按住拖动时以圆形笔触采样,鼠标松开后一次性 union 成连续区域;如果笔触与当前选中 mask 连通,默认合并到该 mask否则生成新的当前类别 mask。 - 画笔工具只在语义分类树有选中类别时可用,按住拖动时以圆形笔触采样,鼠标松开后一次性 union 成连续区域;如果笔触与当前选中 mask 连通,默认合并到该 mask否则生成新的当前类别 mask。
- 橡皮擦工具只在当前帧已选中 mask 时可用,按住拖动时以圆形笔触采样,鼠标松开后从选中 mask 中 difference 扣除;扣空时删除该 mask已保存 mask 仍需同步后端删除。 - 橡皮擦工具只在当前帧已选中 mask 时可用,按住拖动时以圆形笔触采样,鼠标松开后从选中 mask 中 difference 扣除;扣空时删除该 mask已保存 mask 仍需同步后端删除。
- 创建多边形、创建矩形、区域合并/去除、调整多边形等 Canvas 左上角上下文提示只作为短提示,切换工具或操作状态变化时显示,数秒后自动隐藏,避免长期遮挡待编辑图像;再次切换工具或操作状态变化会重新显示。 - 创建多边形、创建矩形、区域合并/去除、调整多边形等 Canvas 左上角上下文提示只作为短提示,切换工具或操作状态变化时显示,数秒后自动隐藏,避免长期遮挡待编辑图像;再次切换工具或操作状态变化会重新显示。
- 绘制工具点击已有 mask 时应继续执行当前绘制动作,不应被 mask 选择逻辑吞掉。 - 绘制工具点击已有 mask 时应继续执行当前绘制动作,不应被 mask 选择逻辑吞掉。
- 工具栏提供“调整多边形”工具,用户可以点击 mask 进入 polygon 顶点编辑态;按住顶点即可直接拖动并实时更新 mask 几何,不需要先单击选中顶点,已保存 mask 会标记为 dirty顶点和 seed point 等子节点拖拽不能冒泡成画布拖拽,编辑结束后 Canvas 当前缩放和平移视口必须保持不变 - 所有 polygon mask 都不显示黄色 seed point也不提供 seed point 拖动;普通手工/AI/GT mask 在画布上应保持一致的区域渲染、选择、顶点编辑、拓扑统计、边缘平滑和保存体验
- 工具栏提供“调整多边形”工具,用户可以点击 mask 进入 polygon 顶点编辑态;按住顶点即可直接拖动并实时更新 mask 几何,不需要先单击选中顶点,已保存 mask 会标记为 dirty顶点拖拽不能冒泡成画布拖拽编辑结束后 Canvas 当前缩放和平移视口必须保持不变。
- 工具栏在“重叠区域去除”之后提供“导入 GT Mask”入口该入口使用区别于普通编辑工具的紫色底色不切换 activeTool。 - 工具栏在“重叠区域去除”之后提供“导入 GT Mask”入口该入口使用区别于普通编辑工具的紫色底色不切换 activeTool。
- 顶点编辑态显示边中点插入手柄;点击边中点会在该边中间新增顶点。 - 顶点编辑态显示边中点插入手柄;点击边中点会在该边中间新增顶点。
- “调整多边形”工具下双击 polygon 边界时,会在最接近的线段上按双击位置新增顶点。 - “调整多边形”工具下双击 polygon 边界时,会在最接近的线段上按双击位置新增顶点。
- 顶点编辑态下选中顶点后可用 Delete/Backspace 删除顶点,但不会让 polygon 少于三点。 - 顶点编辑态下选中顶点后可用 Delete/Backspace 删除顶点,但不会让 polygon 少于三点。
- 选中整个 mask 且未选中具体顶点时Delete/Backspace 删除该 mask已保存 mask 同步调用后端删除接口。 - 选中整个 mask 且未选中具体顶点时Delete/Backspace 删除该 mask已保存 mask 同步调用后端删除接口;如果删除对象属于自动传播链或是传播 seed应同步删除同一传播链上的自动传播 mask但不能删除其他帧独立 AI 推理或人工标注 mask
- 撤销、重做绑定全局 `maskHistory/maskFuture`,工作区支持顶栏按钮和 Canvas 快捷键AI 页支持自己的按钮;左侧工具栏不重复放置撤销/重做入口。 - 撤销、重做绑定全局 `maskHistory/maskFuture`,工作区支持顶栏按钮和 Canvas 快捷键AI 页支持自己的按钮;左侧工具栏不重复放置撤销/重做入口。
- 区域合并工具支持多选当前帧 mask并使用 polygon union 生成合并后的主 mask。 - 区域合并工具支持多选当前帧 mask并使用 polygon union 生成合并后的主 mask;被合并移除的次级 mask 若带传播链,应同步删除其同链自动传播结果
- 区域去除工具支持多选当前帧 mask并从第一个选中的主 mask 中扣除后续选中 mask。 - 区域去除工具支持多选当前帧 mask并从第一个选中的主 mask 中扣除后续选中 mask。
- 区域合并/去除模式显示已选数量,并隐藏 polygon 编辑手柄以避免手柄抢占多选点击;第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓。 - 区域合并/去除模式显示已选数量,并隐藏 polygon 编辑手柄以避免手柄抢占多选点击;第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓。
- 区域去除结果包含内洞时,前端保留 hole ring 并用 even-odd 规则渲染。 - 区域去除结果包含内洞时,前端保留 hole ring 并用 even-odd 规则渲染。
@@ -118,7 +124,8 @@
- 工作区传播功能以当前打开帧作为参考帧,并使用该帧全部 mask 作为 seed用户不再选择“选中区域/当前帧全部”传播对象。 - 工作区传播功能以当前打开帧作为参考帧,并使用该帧全部 mask 作为 seed用户不再选择“选中区域/当前帧全部”传播对象。
- 工作区传播功能允许设置传播起始帧和传播结束帧;前端以当前参考帧为 seed只向起止范围内位于参考帧之前和之后的帧传播源帧不重复保存。 - 工作区传播功能允许设置传播起始帧和传播结束帧;前端以当前参考帧为 seed只向起止范围内位于参考帧之前和之后的帧传播源帧不重复保存。
- 工作区只保留一个“自动传播”按钮,点击后在指定范围内按前向/后向自动生成 mask。 - 工作区只保留一个“自动传播”按钮,点击后在指定范围内按前向/后向自动生成 mask。
- 自动传播提交前,前端必须先保存当前项目中的 draft/dirty mask参考帧 seed 优先使用后端 `annotation_id` 作为稳定来源,避免第一次用前端临时 id 传播、后续保存后无法替换旧传播结果 - 当前参考帧没有 mask 时,点击“开始传播”必须提示“当前参考帧无遮罩”,且不得提交传播任务或保存其它帧标注
- 自动传播提交前,前端必须只保存当前参考帧中的 draft/dirty mask参考帧 seed 优先使用后端 `annotation_id` 作为稳定来源,避免第一次用前端临时 id 传播、后续保存后无法替换旧传播结果;其它帧的脏标注不能在传播准备阶段触发无关后端更新。
- 前端会把多个 seed 或双向范围拆成 `steps`,通过 `POST /api/ai/propagate/task` 创建 `propagate_masks` 后台任务,避免长 HTTP 请求卡在浏览器侧,同时避免并发抢占 GPU。 - 前端会把多个 seed 或双向范围拆成 `steps`,通过 `POST /api/ai/propagate/task` 创建 `propagate_masks` 后台任务,避免长 HTTP 请求卡在浏览器侧,同时避免并发抢占 GPU。
- `POST /api/ai/propagate` 作为单 seed 同步兼容接口保留;`POST /api/ai/propagate/task` 是工作区自动传播使用的任务接口。两者当前支持四个 SAM 2.1 变体;兼容 `model=sam2` 并归一化为 tiny。SAM 2.1 使用官方 `SAM2VideoPredictor.add_new_mask()``propagate_in_video()` - `POST /api/ai/propagate` 作为单 seed 同步兼容接口保留;`POST /api/ai/propagate/task` 是工作区自动传播使用的任务接口。两者当前支持四个 SAM 2.1 变体;兼容 `model=sam2` 并归一化为 tiny。SAM 2.1 使用官方 `SAM2VideoPredictor.add_new_mask()``propagate_in_video()`
- 自动传播任务写入 `processing_tasks`,前端轮询 `GET /api/tasks/{task_id}` 显示进度并刷新标注Dashboard 也能看到该任务,任务可取消和重试。 - 自动传播任务写入 `processing_tasks`,前端轮询 `GET /api/tasks/{task_id}` 显示进度并刷新标注Dashboard 也能看到该任务,任务可取消和重试。
@@ -143,17 +150,19 @@
- 工作区加载项目帧后会查询已保存标注并回显。 - 工作区加载项目帧后会查询已保存标注并回显。
- 工作区支持导入 GT mask 图片,前端调用 `POST /api/ai/import-gt-mask` - 工作区支持导入 GT mask 图片,前端调用 `POST /api/ai/import-gt-mask`
- 导入 GT Mask 时,前端必须让用户选择未知 maskid 处理策略:舍弃未知类别,或导入为“未定义类别”等待后续重新命名。 - 导入 GT Mask 时,前端必须让用户选择未知 maskid 处理策略:舍弃未知类别,或导入为“未定义类别”等待后续重新命名。
- 后端导入 GT mask 时必须支持二值 mask、灰度/16-bit `GT_label图`,以及 RGB 三通道完全相同的 `[X,X,X]` maskid 图0 是背景X 是 maskid。灰度/RGB 等通道图按当前模板 `maskId` 匹配类别,超出现有类别时按用户选择的策略处理;普通彩色 RGB 类别图不再视为合法 GT mask必须返回图片不符合要求的明确错误。 - 后端导入 GT mask 时必须支持 8-bit 二值/灰度 `GT_label图`,以及 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图0 是背景X 是 1-255 的 maskid。灰度/RGB 等通道图按当前模板 `maskId` 匹配类别,超出现有类别时按用户选择的策略处理;16-bit/uint16 GT_label 和普通彩色 RGB 类别图不再视为合法 GT mask必须返回图片不符合要求的明确错误。
- 后端导入 GT mask 时必须把全背景 0 图视为非法 GT mask返回“GT Mask 图片中没有非背景 maskid 区域。”,前端导入预览也必须保留同一提示并禁止继续导入。
- 导入 GT mask 前端必须提供导入结果预览,显示检测到的 maskid、未知 maskid 和尺寸适配提示;如果 mask 图片尺寸与当前帧不同,后端导入前必须按当前帧长宽用最近邻插值拉伸,使 mask 可适配当前图片。 - 导入 GT mask 前端必须提供导入结果预览,显示检测到的 maskid、未知 maskid 和尺寸适配提示;如果 mask 图片尺寸与当前帧不同,后端导入前必须按当前帧长宽用最近邻插值拉伸,使 mask 可适配当前图片。
- 后端导入 GT mask 时按非背景像素值或颜色拆分多类别区域,再按连通域生成 polygon 标注,并通过距离变换写入 seed point - 后端导入 GT mask 时按非背景像素值或颜色拆分多类别区域,再按连通域生成高精度 polygon 标注;轮廓提取应尽量保留边界细节,同时对单轮廓点数设置上限避免严重影响前端渲染和编辑性能;可通过距离变换写入内部 `points` seed 供数据兼容
- 前端回显导入标注的 seed point;拖动 seed point 后,已保存标注会变为 dirty归档保存时会更新后端 `points` - 前端回显导入标注的 seed point,也不提供 seed point 拖动;导入 mask 必须与普通 mask 共用拓扑锚点统计、边缘平滑、顶点编辑、分类和保存能力
## R8 模板库 ## R8 模板库
- 前端展示模板列表,调用 `GET /api/templates` - 前端展示模板列表,调用 `GET /api/templates`
- 用户可以新建、编辑、删除模板。 - 用户可以新建、编辑、删除模板,也可以在“生效中模板架构清单”中用鼠标复制现有模板为当前用户私有副本
- 模板分类存放在 `mapping_rules.classes`,规则存放在 `mapping_rules.rules` - 模板分类存放在 `mapping_rules.classes`,规则存放在 `mapping_rules.rules`
- 前端支持添加/删除分类、拖拽排序后更新内部覆盖优先级、JSON 批量导入、加载腹腔镜默认分类。界面不展示内部优先级数值,只展示每个类别稳定的 `maskid` - 所有新建、复制、批量导入和后端返回的模板必须包含 `maskid: 0`、颜色 `[0,0,0]`/`#000000`、名称为“待分类”的保留分类;该分类固定显示在语义分类树最后,不能删除,也不能通过拖拽上移
- 前端支持添加/删除分类、拖拽排序后更新内部覆盖优先级和 JSON 批量导入。模板详情页分类区标题必须显示为“语义分类树(拖拽调层级)”,右上角按钮必须显示为“+ 新建分类”;分类行右侧不得显示“未分类/批量导入/模板名”等描述标签,必须显示垃圾桶图标并可点击删除该 label。复制模板必须保留分类名称、颜色、`maskid`、内部层级顺序和规则,但要重建类别内部 id。界面不展示内部优先级数值只展示每个类别稳定的 `maskid`
- 后端支持模板创建、列表、详情、局部更新和删除。 - 后端支持模板创建、列表、详情、局部更新和删除。
## R9 本体检查面板 ## R9 本体检查面板
@@ -162,11 +171,12 @@
- 面板显示模板分类;新增自定义分类会写入当前激活模板的后端 `mapping_rules.classes` - 面板显示模板分类;新增自定义分类会写入当前激活模板的后端 `mapping_rules.classes`
- 用户可以选择具体分类;新 AI mask 会记录 `classId``className``classZIndex`,并在保存时写入 `mask_data.class` - 用户可以选择具体分类;新 AI mask 会记录 `classId``className``classZIndex`,并在保存时写入 `mask_data.class`
- 如果 Canvas 当前已经选中一个或多个 mask点击语义分类树会把这些 mask 的 `label``color` 和 class 元数据改为该分类;如果这些 mask 属于自动传播链,还必须通过 `source_annotation_id``source_mask_id``propagation_seed_key` 同步更新同一传播链前后帧的对应 mask已保存 mask 会进入 `dirty` 状态,归档保存时更新后端。 - 如果 Canvas 当前已经选中一个或多个 mask点击语义分类树会把这些 mask 的 `label``color` 和 class 元数据改为该分类;如果这些 mask 属于自动传播链,还必须通过 `source_annotation_id``source_mask_id``propagation_seed_key` 同步更新同一传播链前后帧的对应 mask已保存 mask 会进入 `dirty` 状态,归档保存时更新后端。
- 打开工作区回显项目标注时,如果已保存 mask 的 class 不再存在于其所属模板中,前端必须把该 mask 转为 `maskid: 0` 的“待分类”mask保留几何标记为 dirty等待用户重新分类并保存。
- 添加自定义分类需要先选择模板,保存时调用 `PATCH /api/templates/{id}` 并同步全局模板 store。 - 添加自定义分类需要先选择模板,保存时调用 `PATCH /api/templates/{id}` 并同步全局模板 store。
- “特定目标实例属性追踪”下方显示当前选中 mask 的 `className/label`,不显示全局 active class 的旧值。 - “特定目标实例属性追踪”下方显示当前选中 mask 的 `className/label`,不显示全局 active class 的旧值。
- 当前实例属性面板不展示“当前选中区域”计数;当前 mask 交互以单选为主,计数长期为 1不作为有效业务信息展示。 - 当前实例属性面板不展示“当前选中区域”计数;当前 mask 交互以单选为主,计数长期为 1不作为有效业务信息展示。
- 选中 mask 后,拓扑锚点调用 `POST /api/ai/analyze-mask` 自动读取,不再显示固定占位值;后端 `topology_anchor_count` 必须表示 polygon 的真实顶点数量,不能用抽样后的展示点数代替;前端必须静默忽略 abort/cancel 或过期的分析请求,避免快速切换 mask、拖动平滑预览或卸载组件时误显示“后端属性读取失败”前端不再展示“后端模型置信度”条目也不再提供“重新提取拓扑锚点”调试按钮。 - 选中 mask 后,拓扑锚点调用 `POST /api/ai/analyze-mask` 自动读取,不再显示固定占位值;后端 `topology_anchor_count` 必须表示 polygon 的真实顶点数量,不能用抽样后的展示点数代替;前端必须静默忽略 abort/cancel 或过期的分析请求,避免快速切换 mask、拖动平滑预览或卸载组件时误显示“后端属性读取失败”前端不再展示“后端模型置信度”条目也不再提供“重新提取拓扑锚点”调试按钮。
- 选中 mask 后,右侧实例属性面板提供“边缘平滑强度”和“应用边缘平滑”;调整滑杆时必须立即更新数值,但后端预览请求需要做短防抖,用户停止拖动约 220ms 后再调用 `POST /api/ai/smooth-mask` 并用返回 polygon 临时预览当前 mask 边缘,避免连续拖动时请求过密造成卡顿;预览阶段不标 dirty点击“应用边缘平滑”后确认当前预览结果前端必须把平滑 polygon 作为新的实际 mask 几何写入当前 mask并同步写入同一传播链前后对应 mask整次平滑应用必须作为一个撤销/重做历史步骤,撤销/重做要同时作用于当前 mask 和传播链对应 mask应用后相关 mask 标记为 dirty/draft平滑强度重置为 0用户仍可继续用 polygon 编辑工具编辑平滑后的新多边形,并通过顶栏保存状态按钮落库。后端平滑必须对 AI/SAM 密集轮廓执行去噪简化、Chaikin 平滑和二次简化,使结果 polygon 的密集边缘点实际减少;强度映射必须低段温和、高段继续递进,避免 20% 左右已经过度平滑且后续档位无明显变化。 - 选中 mask 后,右侧实例属性面板提供“边缘平滑强度”和“应用边缘平滑”;调整滑杆时必须立即更新数值,但后端预览请求需要做短防抖,用户停止拖动约 220ms 后再调用 `POST /api/ai/smooth-mask` 并用返回 polygon 临时预览当前 mask 边缘,避免连续拖动时请求过密造成卡顿;预览阶段不标 dirty点击“应用边缘平滑”后确认当前预览结果前端必须把平滑 polygon 作为新的实际 mask 几何写入当前 mask并同步写入同一传播链前后对应 mask整次平滑应用必须作为一个撤销/重做历史步骤,撤销/重做要同时作用于当前 mask 和传播链对应 mask应用后相关 mask 标记为 dirty/draft平滑强度重置为 0用户仍可继续用 polygon 编辑工具编辑平滑后的新多边形,并通过顶栏保存状态按钮落库;保存 dirty 传播链 mask 时必须保留传播来源 metadata不能让原本自动传播帧变为人工/AI 标注帧。后端平滑必须对 AI/SAM 密集轮廓执行去噪简化、Chaikin 平滑和二次简化,使结果 polygon 的密集边缘点实际减少;强度映射必须低段温和、高段继续递进,避免 20% 左右已经过度平滑且后续档位无明显变化。
## R10 Dashboard 与 WebSocket ## R10 Dashboard 与 WebSocket
@@ -187,11 +197,11 @@
- 后端支持 `GET /api/export/{project_id}/coco` 导出 COCO JSON。 - 后端支持 `GET /api/export/{project_id}/coco` 导出 COCO JSON。
- 后端支持 `GET /api/export/{project_id}/masks` 导出 PNG mask ZIP。 - 后端支持 `GET /api/export/{project_id}/masks` 导出 PNG mask ZIP。
- 后端支持 `GET /api/export/{project_id}/results` 统一导出分割结果 ZIP参数支持整体视频、特定范围帧和当前图片三种范围并支持分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图Mix_label 透明度默认 0.3。 - 后端支持 `GET /api/export/{project_id}/results` 统一导出分割结果 ZIP参数支持整体视频、特定范围帧和当前图片三种范围并支持分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图Mix_label 透明度默认 0.3。
- 统一导出 ZIP 必须固定包含 `maskid_GT像素值_类别映射.json`,记录当前导出中每个类别的 `maskid`、GT_label 像素值、中文名、类别名、RGB 值、颜色和类别 keyGT_label 背景值固定为 0语义类别值使用类别真实 maskid缺失 maskid 的旧标注才补下一个可用正整数,且同一类别跨图片保持一致。 - 统一导出 ZIP 必须固定包含 `maskid_GT像素值_类别映射.json`,记录当前导出中每个类别的 `maskid`、GT_label 像素值、中文名、类别名、RGB 值、颜色和类别 keyGT_label 必须固定输出 8-bit uint8 PNG背景值固定为 0语义类别值使用类别真实 maskid缺失 maskid 的旧标注才补下一个可用正整数,且同一类别跨图片保持一致`maskid: 0` 的“待分类”必须在映射中保留为 0GT_label 内与背景同为 0正整数 maskid 超出 1-255 时必须拒绝导出
- 统一导出 ZIP 必须固定包含 `原始图片/` 文件夹,导出范围内每帧的原始图片命名为 `视频名称_时间戳_项目帧序号` 加原图片扩展名;视频名称来自项目视频文件名,时间戳来自帧 `timestamp_ms` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based `frame_index + 1`,不使用原视频帧号。 - 统一导出 ZIP 必须固定包含 `原始图片/` 文件夹,导出范围内每帧的原始图片命名为 `视频名称_时间戳_项目帧序号` 加原图片扩展名;视频名称来自项目视频文件名,时间戳来自帧 `timestamp_ms` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based `frame_index + 1`,不使用原视频帧号。
- 选择“分开 Mask”时统一导出 ZIP 必须包含 `分开Mask分割结果/`;每帧建立 `{视频名称_时间戳_项目帧序号}_分别导出` 子文件夹,同一帧同一类别的所有 annotation 合并为一张二值 PNG文件名包含 `视频名称_时间戳_项目帧序号_{类别名称}_maskid{maskid}` - 选择“分开 Mask”时统一导出 ZIP 必须包含 `分开Mask分割结果/`;每帧建立 `{视频名称_时间戳_项目帧序号}_分别导出` 子文件夹,同一帧同一类别的所有 annotation 合并为一张二值 PNG文件名包含 `视频名称_时间戳_项目帧序号_{类别名称}_maskid{maskid}`
- 选择“GT_label 黑白图”时,统一导出 ZIP 必须包含 `GT_label图/`;每帧输出一张融合后的 GT_label PNG文件名为 `视频名称_时间戳_项目帧序号`重叠区域仍按内部拖拽排序从低到高覆盖maskid 不构成排序规范。 - 选择“GT_label 黑白图”时,统一导出 ZIP 必须包含 `GT_label图/`;每帧输出一张融合后的 GT_label PNG文件名为 `视频名称_时间戳_项目帧序号`重叠区域仍按内部拖拽排序从低到高覆盖maskid 不构成排序规范。
- 选择“Pro_label 彩色图”时,统一导出 ZIP 必须包含 `Pro_label彩色分割结果/`;每帧输出一张按类别 RGB 上色的 PNG背景为 `[0,0,0]` - 选择“Pro_label 彩色图”时,统一导出 ZIP 必须包含 `Pro_label彩色分割结果/`;每帧输出一张按类别 RGB 上色的 PNG背景为 `[0,0,0]``maskid: 0` 的“待分类”也必须输出为黑色 `[0,0,0]`
- 选择“Mix_label 叠加图”时,统一导出 ZIP 必须包含 `Mix_label重叠覆盖彩色分割结果/`;每帧输出一张彩色 label 叠加原始图片的 PNG透明度可选且默认为 0.3。 - 选择“Mix_label 叠加图”时,统一导出 ZIP 必须包含 `Mix_label重叠覆盖彩色分割结果/`;每帧输出一张彩色 label 叠加原始图片的 PNG透明度可选且默认为 0.3。
- GT_label、Pro_label 和 Mix_label 的重叠区域覆盖顺序必须和右侧“语义分类树”的内部覆盖优先级一致,低优先级先写入,高优先级后写入。 - GT_label、Pro_label 和 Mix_label 的重叠区域覆盖顺序必须和右侧“语义分类树”的内部覆盖优先级一致,低优先级先写入,高优先级后写入。
- 分割结果导出 ZIP 文件名必须使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;项目名来自项目库中的 `Project.name`,时间戳来自导出范围首尾帧 `timestamp_ms` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based `frame_index + 1` - 分割结果导出 ZIP 文件名必须使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;项目名来自项目库中的 `Project.name`,时间戳来自导出范围首尾帧 `timestamp_ms` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based `frame_index + 1`
@@ -200,7 +210,7 @@
- 当前前端 `exportSegmentationResults()` API 封装已对齐统一导出路径。 - 当前前端 `exportSegmentationResults()` API 封装已对齐统一导出路径。
- 工作区“分割结果导出”按钮已替代原 JSON/PNG 两个按钮;点击后在下拉栏内选择导出范围、勾选导出内容,并在选择 Mix_label 时调节遮罩透明度和查看当前/待导出第一帧预览;导出范围默认选中“当前图片”,导出前会先保存当前未归档 mask。选择“特定范围帧”时用户既可以直接修改起止帧输入框也可以像自动传播、清空遮罩一样在播放进度条或视频处理进度条上点击/拖拽选择导出范围。 - 工作区“分割结果导出”按钮已替代原 JSON/PNG 两个按钮;点击后在下拉栏内选择导出范围、勾选导出内容,并在选择 Mix_label 时调节遮罩透明度和查看当前/待导出第一帧预览;导出范围默认选中“当前图片”,导出前会先保存当前未归档 mask。选择“特定范围帧”时用户既可以直接修改起止帧输入框也可以像自动传播、清空遮罩一样在播放进度条或视频处理进度条上点击/拖拽选择导出范围。
- PNG mask ZIP 包含单标注二值 mask、按 zIndex 融合后的每帧语义 mask 和 `semantic_classes.json` - PNG mask ZIP 包含单标注二值 mask、按 zIndex 融合后的每帧语义 mask 和 `semantic_classes.json`
- 统一导出的 GT_label 图背景值固定为 0所有语义类别值优先保留模板类别真实 maskid缺失 maskid 的旧标注才按下一个可用正整数补值。 - 统一导出的 GT_label 图必须是 8-bit uint8 PNG背景值固定为 0所有语义类别值优先保留模板类别真实 maskid缺失 maskid 的旧标注才按下一个可用正整数补值;有效类别值范围为 0-255其中 0 仅用于背景和系统保留的“待分类”
## R12 配置 ## R12 配置

View File

@@ -26,7 +26,7 @@
| 模型状态 | `src/components/ModelStatusBadge.tsx` | 展示 GPU 与当前 SAM 模型真实可用状态;工作区顶栏使用 compact 形态,只显示 GPU/CPU 状态,具体传播权重由旁边下拉负责 | | 模型状态 | `src/components/ModelStatusBadge.tsx` | 展示 GPU 与当前 SAM 模型真实可用状态;工作区顶栏使用 compact 形态,只显示 GPU/CPU 状态,具体传播权重由旁边下拉负责 |
| 登录页 | `src/components/Login.tsx` | 调用登录 API写入 store | | 登录页 | `src/components/Login.tsx` | 调用登录 API写入 store |
| Dashboard | `src/components/Dashboard.tsx` | 展示统计、任务控制、失败详情和 WebSocket 进度消息 | | Dashboard | `src/components/Dashboard.tsx` | 展示统计、任务控制、失败详情和 WebSocket 进度消息 |
| 项目库 | `src/components/ProjectLibrary.tsx` | 项目列表、新建、删除、导入视频/DICOM、显式生成帧 | | 项目库 | `src/components/ProjectLibrary.tsx` | 项目列表、新建、重命名、删除、导入视频/DICOM、显式生成帧 |
| 工作区 | `src/components/VideoWorkspace.tsx` | 加载帧和模板组织工具栏、Canvas、本体面板、时间轴 | | 工作区 | `src/components/VideoWorkspace.tsx` | 加载帧和模板组织工具栏、Canvas、本体面板、时间轴 |
| Canvas | `src/components/CanvasArea.tsx` | 显示帧、缩放平移、点/框提示、渲染 mask | | Canvas | `src/components/CanvasArea.tsx` | 显示帧、缩放平移、点/框提示、渲染 mask |
| 工具栏 | `src/components/ToolsPalette.tsx` | 切换工作区编辑工具、在“重叠区域去除”后触发 GT Mask 导入、跳转 AI 页面AI 跳转入口复用 Bot + Sparkles 组合图标以明确表达 AI 智能分割;不再放置 AI 正/反点和框选工具,也不重复放置撤销/重做;拖拽/选择到创建圆、画笔/橡皮擦/区域合并/重叠区域去除、导入 GT Mask/AI 智能分割三类工具之间用浅灰横线分隔;紧凑垂直布局,高度不足时自身滚动;外层宽 56px按钮列固定 48px滚动条使用右侧外扩空间和低对比 `seg-scrollbar` | | 工具栏 | `src/components/ToolsPalette.tsx` | 切换工作区编辑工具、在“重叠区域去除”后触发 GT Mask 导入、跳转 AI 页面AI 跳转入口复用 Bot + Sparkles 组合图标以明确表达 AI 智能分割;不再放置 AI 正/反点和框选工具,也不重复放置撤销/重做;拖拽/选择到创建圆、画笔/橡皮擦/区域合并/重叠区域去除、导入 GT Mask/AI 智能分割三类工具之间用浅灰横线分隔;紧凑垂直布局,高度不足时自身滚动;外层宽 56px按钮列固定 48px滚动条使用右侧外扩空间和低对比 `seg-scrollbar` |
@@ -34,7 +34,7 @@
| 时间轴 | `src/components/FrameTimeline.tsx` | 帧导航、播放进度、视频处理进度条、自动传播历史片段、自动传播/清空遮罩/导出范围选择、左右方向键切帧、播放和当前/总时长显示 | | 时间轴 | `src/components/FrameTimeline.tsx` | 帧导航、播放进度、视频处理进度条、自动传播历史片段、自动传播/清空遮罩/导出范围选择、左右方向键切帧、播放和当前/总时长显示 |
| 本体面板 | `src/components/OntologyInspector.tsx` | 模板选择、工作区 mask 透明度、分类树、后端自定义分类、mask 后端属性分析;内容过长时自身滚动,滚动条使用低对比 `seg-scrollbar` | | 本体面板 | `src/components/OntologyInspector.tsx` | 模板选择、工作区 mask 透明度、分类树、后端自定义分类、mask 后端属性分析;内容过长时自身滚动,滚动条使用低对比 `seg-scrollbar` |
| AI 页面 | `src/components/AISegmentation.tsx` | 独立 AI 推理视图,使用当前项目帧 | | AI 页面 | `src/components/AISegmentation.tsx` | 独立 AI 推理视图,使用当前项目帧 |
| 模板库 | `src/components/TemplateRegistry.tsx` | 模板 CRUD、分类编辑、导入、排序 | | 模板库 | `src/components/TemplateRegistry.tsx` | 模板 CRUD、分类编辑、导入、详情页和编辑弹窗拖拽排序 |
| 短提示浮层 | `src/components/TransientNotice.tsx` | 项目库和模板库的非阻塞成功/失败提示,自动消失 | | 短提示浮层 | `src/components/TransientNotice.tsx` | 项目库和模板库的非阻塞成功/失败提示,自动消失 |
## 后端模块 ## 后端模块
@@ -88,21 +88,22 @@
2. 项目、帧、媒体上传/拆帧、AI 标注、传播任务、任务列表、Dashboard 和导出接口都通过当前 JWT 用户过滤项目资源。 2. 项目、帧、媒体上传/拆帧、AI 标注、传播任务、任务列表、Dashboard 和导出接口都通过当前 JWT 用户过滤项目资源。
3. `Template.owner_user_id` 支持用户模板;`owner_user_id IS NULL` 的模板视为系统模板,可作为默认分类体系对用户可见。 3. `Template.owner_user_id` 支持用户模板;`owner_user_id IS NULL` 的模板视为系统模板,可作为默认分类体系对用户可见。
4. 角色分为 `admin``annotator``viewer``admin/annotator` 可调用写入类业务接口,`viewer` 只能调用读接口;`/api/admin/*` 仅允许 `admin` 4. 角色分为 `admin``annotator``viewer``admin/annotator` 可调用写入类业务接口,`viewer` 只能调用读接口;`/api/admin/*` 仅允许 `admin`
5. `UserAdmin.tsx` 仅在当前用户角色为 `admin` 时从 `Sidebar` 展示,调用 `/api/admin/users` 完成新增、角色修改、停用/启用、密码修改和删除无项目用户,调用 `/api/admin/audit-logs` 展示登录和管理操作审计;危险区“恢复演示出厂设置”先用浏览器确认,再要求输入 `RESET_DEMO_FACTORY`,随后调用 `/api/admin/demo-factory-reset` 5. `UserAdmin.tsx` 仅在当前用户角色为 `admin` 时从 `Sidebar` 展示,调用 `/api/admin/users` 完成新增、角色修改、停用/启用、密码修改和删除无项目用户,调用 `/api/admin/audit-logs` 展示登录和管理操作审计;改密码、删除用户和危险区“恢复演示出厂设置”均使用站内弹窗确认,恢复出厂设置要求输入 `RESET_DEMO_FACTORY` 后调用 `/api/admin/demo-factory-reset`
6. `POST /api/admin/demo-factory-reset` 仅允许 `admin`,会重置默认 admin 密码/角色/启用状态删除其它用户、项目、帧、标注、mask、任务、用户模板和旧审计重新创建 `Data_MyVideo_1` 项目并上传 `settings.demo_video_path` 指向的视频作为未生成帧的源视频项目;系统模板保留以保证重置后仍可标注。 6. `POST /api/admin/demo-factory-reset` 仅允许 `admin`,会重置默认 admin 密码/角色/启用状态删除其它用户、项目、帧、标注、mask、任务、用户模板和旧审计重新创建 `settings.demo_video_path` 指向的演示视频项目,以及 `settings.demo_dicom_dir` 指向的演示 DICOM 项目DICOM 会按文件名自然顺序上传和生成帧;系统模板保留以保证重置后仍可标注。
7. 缺失、过期或伪造的 Bearer token 会在业务路由返回 401权限不足返回 403其他用户项目资源对当前用户表现为 404。 7. 缺失、过期或伪造的 Bearer token 会在业务路由返回 401权限不足返回 403其他用户项目资源对当前用户表现为 404。
### 项目导入与生成帧 ### 项目导入与生成帧
1. `ProjectLibrary` 创建项目。 1. `ProjectLibrary` 创建项目。
2. 导入视频时上传源视频到 `/api/media/upload` 并关联项目;该步骤不调用 `/api/media/parse` 2. 导入视频时上传源视频到 `/api/media/upload` 并关联项目;该步骤不调用 `/api/media/parse`上传期间项目库显示导入进度条、百分比和已上传字节,完成后短暂显示“视频导入完成”。
3. 用户在项目卡片点击“生成帧”,在弹窗中选择目标 FPS。 3. 用户在项目卡片点击“生成帧”,在弹窗中选择目标 FPS。
4. 前端调用 `/api/media/parse` 创建异步拆帧任务;可通过 `parse_fps``max_frames``target_width` 指定标准帧序列参数。 4. 前端调用 `/api/media/parse` 创建异步拆帧任务;可通过 `parse_fps``max_frames``target_width` 指定标准帧序列参数。
5. Celery worker 执行 FFmpeg/OpenCV/pydicom 拆帧,视频帧`frame_%06d.jpg``frame_000000.jpg` 连续命名,按目标宽度缩放。 5. Celery worker 执行 FFmpeg/OpenCV/pydicom 拆帧DICOM 在前端选择、后端上传、worker 下载和 pydicom 读取时都按文件名自然顺序排序;视频/DICOM 解析结果都`frame_%06d.jpg``frame_000000.jpg` 连续命名,视频帧按目标宽度缩放。
6. worker 写入 `frames.timestamp_ms``frames.source_frame_number`,并在任务 `result.frame_sequence` 中记录 FPS、帧数、时长、尺寸和对象存储前缀。 6. worker 写入 `frames.timestamp_ms``frames.source_frame_number`,并在任务 `result.frame_sequence` 中记录 FPS、帧数、时长、尺寸和对象存储前缀。
7. worker 持续更新 `processing_tasks`,并发布 Redis `seg:progress` 7. worker 持续更新 `processing_tasks`,并发布 Redis `seg:progress`
8. 刷新项目列表;项目卡片右上角 FPS 徽标显示生成关键帧序列时选择的 `parse_fps`,原始视频 FPS 仅作为底部“原 xx fps”辅助信息显示。 8. 刷新项目列表;项目卡片右上角 FPS 徽标显示生成关键帧序列时选择的 `parse_fps`,原始视频 FPS 仅作为底部“原 xx fps”辅助信息显示。
9. 导入视频、生成帧、上传 DICOM 和失败反馈使用 `TransientNotice`,不再使用浏览器 `alert()` 阻塞操作;提示默认数秒后自动消失。 9. 导入视频、生成帧、上传 DICOM 和失败反馈使用 `TransientNotice`,不再使用浏览器 `alert()` 阻塞操作;提示默认数秒后自动消失。视频和 DICOM 上传阶段额外显示项目库内的导入进度面板DICOM 面板显示有效文件数量,并在上传完成后切换为解析任务进度,轮询 `GET /api/tasks/{task_id}` 直到成功、失败或取消。
10. DICOM 和视频帧序列写入同一 `frames` 表并共用工作区、时间轴、AI 传播、标注保存、GT 导入和导出链路,差异只存在于项目库导入入口和后端解析器。
### 任务控制 ### 任务控制
@@ -123,14 +124,14 @@
6. `CanvasArea` 会把全局 `selectedMaskIds` 中仍存在于当前帧的 id 同步回本地选区,避免帧初始化时的临时清空覆盖 AI 页推送过来的选中态;如果切换到另一帧时原 id 不存在,但目标帧存在同一自动传播链的结果,前端会用 `source_annotation_id``source_mask_id``propagation_seed_key` 匹配对应传播 mask 并自动选中。 6. `CanvasArea` 会把全局 `selectedMaskIds` 中仍存在于当前帧的 id 同步回本地选区,避免帧初始化时的临时清空覆盖 AI 页推送过来的选中态;如果切换到另一帧时原 id 不存在,但目标帧存在同一自动传播链的结果,前端会用 `source_annotation_id``source_mask_id``propagation_seed_key` 匹配对应传播 mask 并自动选中。
7. `CanvasArea` 根据容器和帧尺寸按 86% 适配比例计算初始 scale/position使底图默认居中且尽量大但保留画布边距滚轮缩放和拖拽平移仍由用户后续控制。 7. `CanvasArea` 根据容器和帧尺寸按 86% 适配比例计算初始 scale/position使底图默认居中且尽量大但保留画布边距滚轮缩放和拖拽平移仍由用户后续控制。
8. `CanvasArea` 未选中特定 mask 时,会按 `classZIndex` 从低到高渲染当前帧 mask该值来自右侧“语义分类树”的拖拽排序因此高优先级类别会后渲染并覆盖低优先级类别。有选中 mask 时,编辑态可保留选中区域置顶,方便拖点、换类和布尔操作。 8. `CanvasArea` 未选中特定 mask 时,会按 `classZIndex` 从低到高渲染当前帧 mask该值来自右侧“语义分类树”的拖拽排序因此高优先级类别会后渲染并覆盖低优先级类别。有选中 mask 时,编辑态可保留选中区域置顶,方便拖点、换类和布尔操作。
9. `FrameTimeline` 顶部播放进度条显示当前播放位置;其下方视频处理进度条根据 `Mask.metadata.source` / `propagated_from_frame_id` 计算自动传播帧并显示蓝色区段,对人工绘制或 AI 智能分割等非传播 mask 帧显示红色竖线。当前帧另用白色竖线贯穿播放进度条和视频处理进度条,和青色播放进度、红色标注、蓝色传播状态区分。普通状态下,视频处理进度条可点击跳转到对应帧,红色人工/AI 标注帧和蓝色自动传播帧标识本身也可点击跳转。处理条未处理背景使用中性灰,和红色/蓝色标记保持明显区分。`VideoWorkspace` 会记录当前会话最近 8 次成功处理过的自动传播范围,并通过 `propagationHistory` 传给 `FrameTimeline`;时间轴会把这些片段叠加为同一蓝色系的纯色条,按距最新传播的时间顺序逐次变暗,且第 5 次及更早统一为阈值旧记录色,不再在单个片段内部使用渐变。清空片段遮罩时,`VideoWorkspace` 会按清空范围移除或裁剪本地传播历史片段,避免已清空的处理范围仍显示最近传播条。底部缩略图导航轴对非当前帧使用红色边框标识人工/AI 标注帧,使用蓝色边框标识自动传播/推理帧;如果同一帧同时存在人工/AI 标注和自动传播结果,红色人工/AI 标注边框优先保留,自动传播状态只作为蓝色内描边。当前帧使用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则以青色外框加红色内描边同时表达两个状态,外层当前帧框和内层人工/AI 框的顺序固定。工作区进入自动传播、清空片段遮罩或特定范围帧导出选择模式时,播放进度条和视频处理进度条显示 amber 覆盖层,并额外用洋红色起始线和黄绿色结束线贯穿两条进度条,表达待处理或待导出范围边界,可点击/拖拽设置起止帧。 9. `FrameTimeline` 顶部播放进度条显示当前播放位置;其下方视频处理进度条根据 `Mask.metadata.source` / `propagated_from_frame_id` 计算自动传播帧并显示蓝色区段,对人工绘制或 AI 智能分割等非传播 mask 帧显示红色竖线。当前帧另用白色竖线贯穿播放进度条和视频处理进度条,和青色播放进度、红色标注、蓝色传播状态区分。普通状态下,视频处理进度条可点击跳转到对应帧,红色人工/AI 标注帧和蓝色自动传播帧标识本身也可点击跳转。处理条未处理背景使用中性灰,和红色/蓝色标记保持明显区分。`VideoWorkspace` 会记录当前会话最近 8 次成功处理过的自动传播范围,并通过 `propagationHistory` 传给 `FrameTimeline`;时间轴会把这些片段叠加为同一蓝色系的纯色条,按距最新传播的时间顺序逐次变暗,且第 5 次及更早统一为阈值旧记录色,不再在单个片段内部使用渐变。传播历史条只显示当前仍有自动传播 mask 的帧,`VideoWorkspace` 会在 mask 变化时按剩余传播 mask 裁剪本地传播历史;`FrameTimeline` 渲染时也会按当前传播 mask 再次拆分/过滤,避免单独删除传播 mask 后空帧仍显示红/蓝颜色。底部缩略图导航轴对非当前帧使用红色边框标识人工/AI 标注帧,使用蓝色边框标识自动传播/推理帧;如果同一帧同时存在人工/AI 标注和自动传播结果,红色人工/AI 标注边框优先保留,自动传播状态只作为蓝色内描边。当前帧使用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则以青色外框加红色内描边同时表达两个状态,外层当前帧框和内层人工/AI 框的顺序固定。工作区进入自动传播、清空片段遮罩或特定范围帧导出选择模式时,播放进度条和视频处理进度条显示 amber 覆盖层,并额外用洋红色起始线和黄绿色结束线贯穿两条进度条,表达待处理或待导出范围边界,可点击/拖拽设置起止帧。
10. 当前帧传入 `CanvasArea` 10. 当前帧传入 `CanvasArea`
11. 工作区顶栏短状态文本会在空闲状态下自动消失;保存、导出、导入 GT 和传播任务运行中仍保留进度状态,无帧项目提示也会保留。 11. 工作区顶栏短状态文本会在空闲状态下自动消失;保存、导出、导入 GT 和传播任务运行中仍保留进度状态,无帧项目提示也会保留。
12. 左侧工具栏和右侧本体/语义分类面板使用 `seg-scrollbar` 定制纵向滚动条;默认滚动条 thumb 低透明度融入深色背景hover/focus 时增强为青色提示,避免系统默认滚动条在工具区中过于突兀。左侧工具栏额外保留右侧滚动条槽位,按钮列仍按原 48px 布局,避免滚动条和图标抢空间。 12. 左侧工具栏和右侧本体/语义分类面板使用 `seg-scrollbar` 定制纵向滚动条;默认滚动条 thumb 低透明度融入深色背景hover/focus 时增强为青色提示,避免系统默认滚动条在工具区中过于突兀。左侧工具栏额外保留右侧滚动条槽位,按钮列仍按原 48px 布局,避免滚动条和图标抢空间。
12. 右侧面板不再显示“本体论与属性分类管理树”固定说明栏,直接展示实际可操作内容。 12. 右侧面板不再显示“本体论与属性分类管理树”固定说明栏,直接展示实际可操作内容。
13. 右侧“遮罩透明度”滑杆写入 Zustand `maskPreviewOpacity``CanvasArea``AISegmentation` 都用该值计算 mask group opacity选中 mask 在基础透明度上加亮或按基础透明度显示,方便保留选中反馈。 13. 右侧“遮罩透明度”滑杆写入 Zustand `maskPreviewOpacity``CanvasArea``AISegmentation` 都用该值计算 mask group opacity选中 mask 在基础透明度上加亮或按基础透明度显示,方便保留选中反馈。
14. Canvas 点击 mask 后,全局 `selectedMaskIds` 会同步到 `OntologyInspector`;本体面板按选中 mask 的 `classId``className/label` 和颜色匹配模板分类,自动设置 active class并把分类按钮滚动/聚焦到可见区域。 14. Canvas 点击 mask 后,全局 `selectedMaskIds` 会同步到 `OntologyInspector`;本体面板按选中 mask 的 `classId``className/label` 和颜色匹配模板分类,自动设置 active class并把分类按钮滚动/聚焦到可见区域。
15. 工作区顶栏“清空片段遮罩”和“自动传播”共用时间轴范围选择交互;第一次点击“清空片段遮罩”会进入范围选择模式,按钮变为“确认清空”,用户可在播放进度条或视频处理进度条上点击/拖拽选择起止帧;进入清空模式后顶栏显示“清空全部 / 保留人工/AI”两段式模式选择默认“清空全部”。“清空全部”会对范围内已保存 mask 调用 `DELETE /api/ai/annotations/{id}`,同时移除范围内本地 draft mask、被清空的选区和与清空范围重叠的本地传播历史条若范围内存在非自动传播来源的 mask也就是时间轴红色“人工/AI 标注帧”,执行前会弹出“是否清除“人工/AI标注帧””确认,取消则不删除任何 mask。“保留人工/AI”只删除范围内自动传播/推理 mask不删除人工绘制或 AI 智能分割生成的红色标注帧,不弹出人工帧确认;范围外 mask 和传播历史片段保持不变。 15. 工作区顶栏“清空片段遮罩”和“自动传播”共用时间轴范围选择交互;第一次点击“清空片段遮罩”会进入范围选择模式,按钮变为“确认清空”,用户可在播放进度条或视频处理进度条上点击/拖拽选择起止帧;进入清空模式后顶栏显示“清空全部 / 保留人工/AI”两段式模式选择默认“清空全部”。“清空全部”会对范围内已保存 mask 调用 `DELETE /api/ai/annotations/{id}`,同时移除范围内本地 draft mask、被清空的选区和与清空范围重叠的本地传播历史条若范围内存在非自动传播来源的 mask也就是时间轴红色“人工/AI 标注帧”,执行前会显示站内确认弹窗,取消则不删除任何 mask。“保留人工/AI”只删除范围内自动传播/推理 mask不删除人工绘制或 AI 智能分割生成的红色标注帧,不弹出人工帧确认;范围外 mask 和传播历史片段保持不变。
### AI 点/框推理 ### AI 点/框推理
@@ -191,12 +192,13 @@
1. 用户选择“调整多边形”或“拖拽/选择”后点击 Canvas 上的 mask path`CanvasArea` 记录 `selectedMaskId` 并显示该 mask 第一条 polygon 的顶点控制点和边中点插入手柄。 1. 用户选择“调整多边形”或“拖拽/选择”后点击 Canvas 上的 mask path`CanvasArea` 记录 `selectedMaskId` 并显示该 mask 第一条 polygon 的顶点控制点和边中点插入手柄。
2. 顶点 `mousedown/dragstart` 会立即设置当前顶点选择;拖动过程中通过 `dragMove` 实时重算 `pathData`、像素 `segmentation``bbox``area`,不需要先单击顶点再拖动。 2. 顶点 `mousedown/dragstart` 会立即设置当前顶点选择;拖动过程中通过 `dragMove` 实时重算 `pathData`、像素 `segmentation``bbox``area`,不需要先单击顶点再拖动。
3. Stage 的 `onDragEnd` 只处理 Stage 自身拖拽polygon 顶点、GT seed point 等子节点拖拽结束事件会被忽略,避免子节点坐标误写入 Canvas `position` 导致视口跳动。 3. Stage 的 `onDragEnd` 只处理 Stage 自身拖拽polygon 顶点等子节点拖拽结束事件会被忽略,避免子节点坐标误写入 Canvas `position` 导致视口跳动。
4. 点击边中点手柄会在该边中点插入新顶点;在“调整多边形”工具下双击 polygon path 会在最接近的线段上按双击位置插入新顶点。 4. 点击边中点手柄会在该边中点插入新顶点;在“调整多边形”工具下双击 polygon path 会在最接近的线段上按双击位置插入新顶点。
5. 如果 mask 已有 `annotationId`,编辑会把 `saveStatus` 标成 `dirty``saved=false` 5. 如果 mask 已有 `annotationId`,编辑会把 `saveStatus` 标成 `dirty``saved=false`
6. 归档保存时复用现有 `PATCH /api/ai/annotations/{annotation_id}` 链路,把更新后的 normalized polygon 写回后端。 6. 归档保存时复用现有 `PATCH /api/ai/annotations/{annotation_id}` 链路,把更新后的 normalized polygon 写回后端。
7. 选中顶点后 Delete/Backspace 可删除顶点;前端保持 polygon 至少三点。 7. 选中顶点后 Delete/Backspace 可删除顶点;前端保持 polygon 至少三点。
8. 未选中具体顶点但选中了 mask 时Delete/Backspace 从前端 store 删除该 mask如果包含 `annotationId`,通过工作区回调调用后端删除接口。 8. 未选中具体顶点但选中了 mask 时Delete/Backspace 从前端 store 删除该 mask如果包含 `annotationId`,通过工作区回调调用后端删除接口;删除对象属于传播链或传播 seed 时,删除范围会扩展到同链自动传播 mask但不移除其他帧独立 AI 推理/人工 mask
9. 普通 mask 和导入 mask 都不显示黄色 seed point也不提供 seed point 拖动;保存 payload 仍可保留已有 `points` 数据兼容,但画布体验统一为区域选择和 polygon 顶点编辑。
### 区域合并与去除 ### 区域合并与去除
@@ -205,7 +207,7 @@
3. Canvas 左上角提示布尔选择顺序:第一个选中的是主区域,后续区域参与合并或扣除。 3. Canvas 左上角提示布尔选择顺序:第一个选中的是主区域,后续区域参与合并或扣除。
4. 布尔选择态按选择顺序区分角色:第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓;所有已选区域填充透明度保持一致,避免被误解为阴影模式异常。 4. 布尔选择态按选择顺序区分角色:第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓;所有已选区域填充透明度保持一致,避免被误解为阴影模式异常。
5. `CanvasArea``Mask.segmentation` 转为 `polygon-clipping` 的 MultiPolygon。 5. `CanvasArea``Mask.segmentation` 转为 `polygon-clipping` 的 MultiPolygon。
6. `area_merge` 使用 union更新第一个选中的主 mask并从前端 store 移除后续被合并 mask如果被移除 mask 已保存,会调用工作区传入的删除回调删除后端标注。 6. `area_merge` 使用 union更新第一个选中的主 mask并从前端 store 移除后续被合并 mask如果被移除 mask 已保存,会调用工作区传入的删除回调删除后端标注;被移除 mask 的同链自动传播结果也会一并删除
7. `area_remove` 使用 difference从第一个选中的主 mask 中扣除后续选中 mask扣除对象本身保留如果 difference 产生内洞,`segmentation` 保留外圈和 hole ring渲染时使用 even-odd fill。 7. `area_remove` 使用 difference从第一个选中的主 mask 中扣除后续选中 mask扣除对象本身保留如果 difference 产生内洞,`segmentation` 保留外圈和 hole ring渲染时使用 even-odd fill。
8. 结果会重算 `pathData``segmentation``bbox``area`,已保存主 mask 会进入 dirty 状态并复用归档 PATCH 链路;带洞结果的面积按外圈减内洞计算。 8. 结果会重算 `pathData``segmentation``bbox``area`,已保存主 mask 会进入 dirty 状态并复用归档 PATCH 链路;带洞结果的面积按外圈减内洞计算。
@@ -215,10 +217,10 @@
2. 前端 `importGtMask()` 以 multipart form-data 调用 `POST /api/ai/import-gt-mask`,携带 `project_id``frame_id` 2. 前端 `importGtMask()` 以 multipart form-data 调用 `POST /api/ai/import-gt-mask`,携带 `project_id``frame_id`
3. 后端验证项目、帧、模板后使用 OpenCV 读取灰度 mask。 3. 后端验证项目、帧、模板后使用 OpenCV 读取灰度 mask。
4. 后端按非零像素值拆分多类别标签。 4. 后端按非零像素值拆分多类别标签。
5. 后端对每个类别的前景做 contour 提取,每个连通域保存为一个 `Annotation` 5. 后端对每个类别的前景做高精度 contour 提取,每个连通域保存为一个 `Annotation`;轮廓使用未压缩链提取并以较小 `approxPolyDP` epsilon 保留细节,超过点数上限时才逐步增加简化强度或抽样
6. `points` 字段保存距离变换中心 seed point`mask_data.polygons` 保存 normalized polygon`mask_data.gt_label_value` 保存原始像素类别值。 6. `points` 字段保存距离变换中心 seed point 供数据兼容`mask_data.polygons` 保存 normalized polygon`mask_data.gt_label_value` 保存原始像素类别值;导入后的 polygon 与普通 mask 走同一套拓扑锚点统计、边缘平滑、编辑和保存链路
7. 前端重新读取项目标注并回显。 7. 前端重新读取项目标注并回显。
8. `annotationToMask()` 会把 normalized seed point 转成像素坐标Canvas 以可拖拽点显示;拖动后 `buildAnnotationPayload()` 会把点再归一化写回后端 8. `annotationToMask()` 仍可把后端 `points` 转成像素坐标保存在 mask 数据中,但 Canvas 不显示 seed point也不提供拖动普通 polygon 若没有后端 seed point保存逻辑可按 polygon 自动计算内部代表点写入,以保持数据兼容
### 模板管理 ### 模板管理
@@ -227,18 +229,19 @@
3. 保存时调用 `createTemplate()``updateTemplate()` 3. 保存时调用 `createTemplate()``updateTemplate()`
4. 后端把 `classes``rules` 打包进 `mapping_rules` 4. 后端把 `classes``rules` 打包进 `mapping_rules`
5. 返回时再解包给前端。 5. 返回时再解包给前端。
6. `CanvasArea` 把当前选中的 mask id 同步到全局 `selectedMaskIds`;切换工具、切换帧或卸载 Canvas 时会清空选择 6. 模板详情页和编辑弹窗都支持拖拽调整语义类别层级顺序;拖拽后重算 `zIndex`,保存到后端模板并刷新当前详情页,`maskId` 保持不变。所有模板都会归一化包含黑色 `maskId: 0` 的“待分类”保留类,该类固定在语义分类树最后,不参与删除和拖拽上移。编辑弹窗中的 JSON 批量导入会先显示分类数量、maskid 分配起点和缺失颜色提示,语法或结构错误以内联错误展示,确认导入后进入编辑态,保存模板时落库
7. `AISegmentation` 生成 mask 后会写入全局 `masks` 并把生成的 mask id 写入 `selectedMaskIds`点击 AI 页预览 mask 也会更新 `selectedMaskIds` 7. `CanvasArea` 把当前选中的 mask id 同步到全局 `selectedMaskIds`切换工具、切换帧或卸载 Canvas 时会清空选择
8. AI 页“推送至工作区编辑”会先检查待推送 AI 候选 mask 是否具备 `classId``className`;缺少语义分类时清空普通推理反馈,并通过 `TransientNotice` 右上角 error toast 提示用户先点右侧语义分类树,不切换模块、不修改工具状态 8. `AISegmentation` 生成 mask 后会写入全局 `masks` 并把生成的 mask id 写入 `selectedMaskIds`;点击 AI 页预览 mask 也会更新 `selectedMaskIds`
9. `AISegmentation` 卸载时会清理仍缺少 `classId/className` 的本页 AI 候选,并同步移除对应 `selectedMaskIds`,避免用户绕过推送按钮从侧栏切到工作区时带入无语义 mask 9. AI 页“推送至工作区编辑”会先检查待推送 AI 候选 mask 是否具备 `classId``className`;缺少语义分类时清空普通推理反馈,并通过 `TransientNotice` 右上角 error toast 提示用户先点右侧语义分类树,不切换模块、不修改工具状态
10. AI 页语义校验通过后会切换到工作区并把 `activeTool` 设为 `edit_polygon``CanvasArea` 初始读取全局 `selectedMaskIds`,让 AI 页选中的 mask 在工作区继续保持选中 10. `AISegmentation` 卸载时会清理仍缺少 `classId/className` 的本页 AI 候选,并同步移除对应 `selectedMaskIds`,避免用户绕过推送按钮从侧栏切到工作区时带入无语义 mask
11. 工作区帧/标注异步加载完成后,`hydrateSavedAnnotations()` 会合并本地未保存 draft mask 和后端已保存 mask不会用后端回显结果直接覆盖整个 `masks` store 11. AI 页语义校验通过后会切换到工作区并把 `activeTool` 设为 `edit_polygon``CanvasArea` 初始读取全局 `selectedMaskIds`,让 AI 页选中的 mask 在工作区继续保持选中
12. `OntologyInspector` 可以选择具体分类;选择结果进入全局 store`CanvasArea``AISegmentation` 新建/更新 mask 时使用 12. 工作区帧/标注异步加载完成后,`hydrateSavedAnnotations()` 会合并本地未保存 draft mask 和后端已保存 mask不会用后端回显结果直接覆盖整个 `masks` store
13. 如果 `selectedMaskIds` 中存在当前 store 的 mask点击分类时会立即更新这些 mask 的 `templateId``classId``className``classZIndex``label``color` 13. `OntologyInspector` 可以选择具体分类;选择结果进入全局 store`CanvasArea``AISegmentation` 新建/更新 mask 时使用
14. 对属于自动传播链的 mask分类更新会复用 `source_annotation_id``source_mask_id``propagation_seed_key` 查找同一目标实例在前后帧中的传播结果,并同步更新这些传播 mask 的分类元数据,避免同一物体跨帧语义不一致 14. 如果 `selectedMaskIds` 中存在当前 store 的 mask点击分类时会立即更新这些 mask 的 `templateId``classId``className``classZIndex``label``color`
15. 同一次点击会把这些已选 mask 移动到前端 `masks` 数组末尾;`CanvasArea` 按数组顺序渲染,后渲染的 Path 显示在最上层,方便用户继续编辑刚换标签的区域。该显示置顶不改变模板 `zIndex` 或后端导出语义覆盖规则 15. 对属于自动传播链的 mask分类更新会复用 `source_annotation_id``source_mask_id``propagation_seed_key` 查找同一目标实例在前后帧中的传播结果,并同步更新这些传播 mask 的分类元数据,避免同一物体跨帧语义不一致
16. 已保存 mask 被重新分类后进入 `dirty``saved=false`,同传播链被同步更新的已保存 mask 也进入 `dirty`,继续复用工作区归档保存的 PATCH 链路 16. 同一次点击会把这些已选 mask 移动到前端 `masks` 数组末尾;`CanvasArea` 按数组顺序渲染,后渲染的 Path 显示在最上层,方便用户继续编辑刚换标签的区域。该显示置顶不改变模板 `zIndex` 或后端导出语义覆盖规则
16. 模板保存、删除和 JSON 导入失败使用 `TransientNotice` 非阻塞提示,默认数秒后自动消失 17. 已保存 mask 被重新分类后进入 `dirty``saved=false`,同传播链被同步更新的已保存 mask 也进入 `dirty`,继续复用工作区归档保存的 PATCH 链路
18. 模板保存、删除和 JSON 导入失败使用 `TransientNotice` 非阻塞提示,默认数秒后自动消失。
### 导出 ### 导出
@@ -247,7 +250,7 @@
3. PNG mask 导出还会按 `mask_data.class.zIndex` 或模板 `z_index` 从低到高覆盖,生成每帧语义融合 mask。 3. PNG mask 导出还会按 `mask_data.class.zIndex` 或模板 `z_index` 从低到高覆盖,生成每帧语义融合 mask。
4. ZIP 内写入 `semantic_classes.json`,记录语义值到类别、颜色和 zIndex 的映射。 4. ZIP 内写入 `semantic_classes.json`,记录语义值到类别、颜色和 zIndex 的映射。
5. 前端使用“分割结果导出”统一入口替代原 JSON/PNG 两个按钮;点击后在下拉栏选择整体视频、特定范围帧或当前图片,默认选中当前图片,并勾选分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图。选择“特定范围帧”时,导出起止帧输入框和 `FrameTimeline` 的范围拖拽选择共用同一组导出范围状态;选择 Mix_label 时显示透明度滑杆,默认 0.3,并用当前/待导出第一帧做遮罩预览。提交前会保存待归档标注,然后下载统一 ZIP。下载文件名使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;项目名来自 `currentProject.name`,起止帧按当前导出范围取首尾帧,时间戳格式为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based 顺序,项目名中的文件系统不安全字符会替换为 `_` 5. 前端使用“分割结果导出”统一入口替代原 JSON/PNG 两个按钮;点击后在下拉栏选择整体视频、特定范围帧或当前图片,默认选中当前图片,并勾选分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图。选择“特定范围帧”时,导出起止帧输入框和 `FrameTimeline` 的范围拖拽选择共用同一组导出范围状态;选择 Mix_label 时显示透明度滑杆,默认 0.3,并用当前/待导出第一帧做遮罩预览。提交前会保存待归档标注,然后下载统一 ZIP。下载文件名使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;项目名来自 `currentProject.name`,起止帧按当前导出范围取首尾帧,时间戳格式为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based 顺序,项目名中的文件系统不安全字符会替换为 `_`
6. 统一导出 ZIP 固定包含 `annotations_coco.json``maskid_GT像素值_类别映射.json``原始图片/`;原始图片文件名使用 `视频名称_时间戳_项目帧序号`。导出会保留类别真实 maskidGT_label 像素值与 maskid 相同并跨图一致;缺失 maskid 的旧标注才补下一个可用正整数并写入映射 JSON。选择分开 mask 时包含 `分开Mask分割结果/`,每帧建立 `{视频名称_时间戳_项目帧序号}_分别导出` 子文件夹,并按“同一帧同一类别合并一张图”的方式输出 `{视频名称_时间戳_项目帧序号}_{类别名称}_maskid{maskid}.png`。选择 GT_label 图时包含 `GT_label图/{视频名称_时间戳_项目帧序号}.png`;选择 Pro_label 图时包含 `Pro_label彩色分割结果/{视频名称_时间戳_项目帧序号}.png`;选择 Mix_label 图时包含 `Mix_label重叠覆盖彩色分割结果/{视频名称_时间戳_项目帧序号}.png`。GT_label、Pro_label 和 Mix_label 的重叠区域按内部拖拽排序从低到高覆盖和未选中状态下的画布显示顺序一致maskid 不参与排序。后端直接下载接口的 `Content-Disposition` 使用同一 ZIP 命名规则,并用 `filename*` 支持中文项目名。 6. 统一导出 ZIP 固定包含 `annotations_coco.json``maskid_GT像素值_类别映射.json``原始图片/`;原始图片文件名使用 `视频名称_时间戳_项目帧序号`。导出会保留类别真实 maskidGT_label 固定为 8-bit uint8 PNG像素值与 maskid 相同并跨图一致;`maskId: 0` 的“待分类”保持 0和背景同为黑色Pro_label 中也输出为 `[0,0,0]`;缺失 maskid 的旧标注才补下一个可用正整数并写入映射 JSON;正整数 maskid 超出 1-255 会拒绝导出。选择分开 mask 时包含 `分开Mask分割结果/`,每帧建立 `{视频名称_时间戳_项目帧序号}_分别导出` 子文件夹,并按“同一帧同一类别合并一张图”的方式输出 `{视频名称_时间戳_项目帧序号}_{类别名称}_maskid{maskid}.png`。选择 GT_label 图时包含 `GT_label图/{视频名称_时间戳_项目帧序号}.png`;选择 Pro_label 图时包含 `Pro_label彩色分割结果/{视频名称_时间戳_项目帧序号}.png`;选择 Mix_label 图时包含 `Mix_label重叠覆盖彩色分割结果/{视频名称_时间戳_项目帧序号}.png`。GT_label、Pro_label 和 Mix_label 的重叠区域按内部拖拽排序从低到高覆盖和未选中状态下的画布显示顺序一致maskid 不参与排序。后端直接下载接口的 `Content-Disposition` 使用同一 ZIP 命名规则,并用 `filename*` 支持中文项目名。
7. 右侧 `OntologyInspector` 的语义分类树支持拖拽调整内部覆盖顺序;拖拽后保存到模板并同步当前工作区同类 mask 的 `classZIndex`,但保留类别 maskid 不变。 7. 右侧 `OntologyInspector` 的语义分类树支持拖拽调整内部覆盖顺序;拖拽后保存到模板并同步当前工作区同类 mask 的 `classZIndex`,但保留类别 maskid 不变。
## 接口契约 ## 接口契约
@@ -264,7 +267,7 @@
- `propagateMasks()` 使用 `POST /api/ai/propagate`,请求体为 `project_id``frame_id``model``seed``direction``max_frames`,作为单 seed 同步兼容接口保留。 - `propagateMasks()` 使用 `POST /api/ai/propagate`,请求体为 `project_id``frame_id``model``seed``direction``max_frames`,作为单 seed 同步兼容接口保留。
- `queuePropagationTask()` 使用 `POST /api/ai/propagate/task`,请求体为 `project_id``frame_id``model``steps``include_source``save_annotations`,返回 `ProcessingTask` - `queuePropagationTask()` 使用 `POST /api/ai/propagate/task`,请求体为 `project_id``frame_id``model``steps``include_source``save_annotations`,返回 `ProcessingTask`
- `saveAnnotation()` 使用 `POST /api/ai/annotate` - `saveAnnotation()` 使用 `POST /api/ai/annotate`
- `importGtMask()` 使用 `POST /api/ai/import-gt-mask` multipart form-data并传入 `unknown_color_policy=discard|undefined`。前端上传前弹出导入结果预览和未知 maskid 策略选择;后端使用 `cv2.IMREAD_UNCHANGED` 保留低数值/16-bit GT_label 像素值。合法 GT mask 限定为灰度图或 RGB 三通道完全相同的 `[X,X,X]` maskid 图0 为背景、X 为 maskid灰度/RGB 等通道图按模板 `maskId` 匹配类别,普通彩色 RGB 类别图不再按颜色匹配并会返回格式错误;未知类别按策略舍弃或保存为 `gt_unknown_class` 未定义类别。若 GT mask 尺寸和当前帧不同,后端用最近邻插值拉伸到当前帧尺寸后再生成 polygon。 - `importGtMask()` 使用 `POST /api/ai/import-gt-mask` multipart form-data并传入 `unknown_color_policy=discard|undefined`。前端上传前弹出导入结果预览和未知 maskid 策略选择;后端使用 `cv2.IMREAD_UNCHANGED` 读取后校验 dtype。合法 GT mask 限定为 8-bit 灰度图或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图0 为背景、X 为 1-255 的 maskid灰度/RGB 等通道图按模板 `maskId` 匹配类别,16-bit/uint16 GT_label、全背景 0 图和普通彩色 RGB 类别图不再按颜色匹配并会返回格式错误全背景图提示为“GT Mask 图片中没有非背景 maskid 区域。”;未知类别按策略舍弃或保存为 `gt_unknown_class` 未定义类别。若 GT mask 尺寸和当前帧不同,后端用最近邻插值拉伸到当前帧尺寸后再生成高精度 polygon。
- `getProjectAnnotations()` 使用 `GET /api/ai/annotations` - `getProjectAnnotations()` 使用 `GET /api/ai/annotations`
- `updateAnnotation()` 使用 `PATCH /api/ai/annotations/{annotationId}` - `updateAnnotation()` 使用 `PATCH /api/ai/annotations/{annotationId}`
- `deleteAnnotation()` 使用 `DELETE /api/ai/annotations/{annotationId}` - `deleteAnnotation()` 使用 `DELETE /api/ai/annotations/{annotationId}`
@@ -294,8 +297,8 @@
以下能力属于当前冻结版本的占位或半可用功能: 以下能力属于当前冻结版本的占位或半可用功能:
- Dashboard 初始快照来自 `GET /api/dashboard/overview`;任务进度区由 `processing_tasks` queued/running/success/failed/cancelled 任务生成,处理中统计只计算 queued/running。 - Dashboard 初始快照来自 `GET /api/dashboard/overview`;任务进度区由 `processing_tasks` queued/running/success/failed/cancelled 任务生成,处理中统计只计算 queued/running。
- 已保存标注支持通过“应用分类”、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑和区域合并/去除进入 dirty 状态并归档更新;选中整块 mask 可用 Delete/Backspace 删除并同步后端;复杂洞结构编辑尚未实现。 - 已保存标注支持通过“应用分类”、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑和区域合并/去除进入 dirty 状态并归档更新;选中整块 mask 可用 Delete/Backspace 删除并同步后端,同传播链自动传播结果会随传播 seed/传播结果删除而一并清理,独立 AI 推理/人工 mask 保留;复杂洞结构编辑尚未实现。
- SAM 3 文本语义分割已从当前产品路径中禁用相关源码保留恢复时需要重新接入前端入口、registry、状态接口和测试。 - SAM 3 文本语义分割已从当前产品路径中禁用相关源码保留恢复时需要重新接入前端入口、registry、状态接口和测试。
- 自定义分类通过 `PATCH /api/templates/{id}` 写入当前激活模板的 `mapping_rules.classes` - 自定义分类通过 `PATCH /api/templates/{id}` 写入当前激活模板的 `mapping_rules.classes`
- 选中 mask 后,本体面板的“特定目标实例属性追踪”标题值来自当前 mask 的 `className/label`,不使用全局 active class面板不再展示长期为 1 的“当前选中区域”计数;面板调用 `POST /api/ai/analyze-mask` 自动显示拓扑锚点数量等属性,`topology_anchor_count` 是真实 polygon 顶点数量,`topology_anchors` 只保留最多 64 个抽样点用于调试展示;`OntologyInspector` 会为分析请求维护递增序号,旧请求返回时不再回写状态,并静默忽略 Axios abort/cancel 错误,避免快速切换、平滑预览或组件卸载时把正常中止误报成失败;不再提供“重新提取拓扑锚点”调试按钮;“边缘平滑强度”滑杆会即时更新数值,但 `POST /api/ai/smooth-mask` 预览请求经过约 220ms 防抖后才发送,返回 polygon 作为临时预览写入当前 mask 显示,预览不改变保存状态;点击“应用边缘平滑”后,前端把平滑 polygon 作为新的实际几何写入当前 mask并按传播 lineage 同步写入传播链前后对应 mask相关 mask 标记为 dirty/draft整次操作通过一次 `setMasks()` 进入撤销/重做历史;应用后不保留 `geometry_smoothing` 参数,平滑强度重置为 0。前端不再展示“后端模型置信度”。 - 选中 mask 后,本体面板的“特定目标实例属性追踪”标题值来自当前 mask 的 `className/label`,不使用全局 active class面板不再展示长期为 1 的“当前选中区域”计数;面板调用 `POST /api/ai/analyze-mask` 自动显示拓扑锚点数量等属性,`topology_anchor_count` 是真实 polygon 顶点数量,`topology_anchors` 只保留最多 64 个抽样点用于调试展示;`OntologyInspector` 会为分析请求维护递增序号,旧请求返回时不再回写状态,并静默忽略 Axios abort/cancel 错误,避免快速切换、平滑预览或组件卸载时把正常中止误报成失败;不再提供“重新提取拓扑锚点”调试按钮;“边缘平滑强度”滑杆会即时更新数值,但 `POST /api/ai/smooth-mask` 预览请求经过约 220ms 防抖后才发送,返回 polygon 作为临时预览写入当前 mask 显示,预览不改变保存状态;点击“应用边缘平滑”后,前端把平滑 polygon 作为新的实际几何写入当前 mask并按传播 lineage 同步写入传播链前后对应 mask相关 mask 标记为 dirty/draft整次操作通过一次 `setMasks()` 进入撤销/重做历史;应用后不保留 `geometry_smoothing` 参数,平滑强度重置为 0。前端不再展示“后端模型置信度”。
- GT mask 导入已完成多类别像素值拆分、contourdistance transform seed point 和前端 seed point 拖拽编辑骨架提取、HDBSCAN 聚类和模板自动映射尚未实现。 - GT mask 导入已完成多类别像素值拆分、contourdistance transform seed point 数据兼容;前端不显示或拖动 seed point导入 mask 与普通 mask 共享拓扑统计、边缘平滑、顶点编辑、分类和保存体验骨架提取、HDBSCAN 聚类和模板自动映射尚未实现。

View File

@@ -14,17 +14,17 @@
| 需求 | 测试文件 | 覆盖点 | | 需求 | 测试文件 | 覆盖点 |
|------|----------|--------| |------|----------|--------|
| R1 登录与会话 | `src/components/Login.test.tsx`, `src/components/Sidebar.test.tsx`, `src/components/UserAdmin.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_auth.py`, `backend/tests/test_admin.py` | 成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口、用户 CRUD、角色权限、审计日志、viewer 读写权限边界、演示出厂设置二次确认和重置结果 | | R1 登录与会话 | `src/components/Login.test.tsx`, `src/components/Sidebar.test.tsx`, `src/components/UserAdmin.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_auth.py`, `backend/tests/test_admin.py` | 成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口、用户 CRUD、角色权限、审计日志、viewer 读写权限边界、改密码/删除用户站内确认、演示出厂设置站内二次确认和重置结果 |
| R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片删除、DELETE 契约、后端 CRUD、删除级联、帧列表、项目按当前 JWT 用户隔离 | | R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片复制/删除、修改项目名称时隐藏生成帧、DICOM 项目不显示生成帧、复制项目 reset/full 契约、DELETE 契约、后端 CRUD、删除级联、帧列表、项目按当前 JWT 用户隔离 |
| R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `src/components/TransientNotice.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、显式生成帧 FPS 选择、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 | | R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `src/components/TransientNotice.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式生成帧 FPS 选择、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 |
| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、清空片段遮罩进入时间轴范围选择并按选区批量清空、清空全部模式、保留人工/AI 模式只清传播 mask、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、清空后裁剪/移除重叠传播历史条、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧通过 source/lineage metadata 识别为蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进纯色显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/清空范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播/清空范围、左右方向键切帧、播放、按项目 FPS 显示当前/总时长 | | R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、清空片段遮罩进入时间轴范围选择并按选区批量清空、清空全部模式、保留人工/AI 模式只清传播 mask、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、清空后裁剪/移除重叠传播历史条、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧通过 source/lineage metadata 识别为蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进纯色显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/清空范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播/清空范围、左右方向键切帧、播放、按项目 FPS 显示当前/总时长 |
| R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/store/useStore.test.ts` | 工具切换、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、GT Mask 导入位于重叠区域去除之后且使用紫色底色、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制、画笔/橡皮擦尺寸控制、画笔新建当前类别 mask、画笔与选中 mask 连通时自动合并、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、整块 mask 删除、区域合并/去除、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 hole 渲染、合并模式隐藏编辑手柄、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键和输入框快捷键跳过、撤销/重做历史栈 | | R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/store/useStore.test.ts` | 工具切换、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、GT Mask 导入位于重叠区域去除之后且使用紫色底色、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔新建当前类别 mask、画笔与选中 mask 连通时自动合并、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、整块 mask 删除、传播链自动传播 mask 随 seed/传播结果删除、独立 AI 推理 mask 不被误删、区域合并/去除、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 hole 渲染、合并模式隐藏编辑手柄、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键和输入框快捷键跳过、撤销/重做历史栈 |
| R6 AI 推理 | `src/lib/api.test.ts`, `src/components/CanvasArea.test.tsx`, `src/components/AISegmentation.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/components/ModelStatusBadge.test.tsx`, `backend/tests/test_ai.py`, `backend/tests/test_sam2_engine.py` | SAM 2.1 变体选择、点/框/interactive 契约、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、SAM 2.1 框选后正负点细化同一候选 mask、AI 页框选发送 box prompt、AI 页框选后加点发送 interactive prompt、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可单点删除提示点并删除最近锚点、AI 页可删除选中候选且不删除工作区 mask、AI 页清空只移除本页候选、AI 页参数开关可读性文案且 options 字段不变、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频以当前参考帧全部 mask 和起止帧范围自动传播、传播前自动保存 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、传播中轮询任务进度、传播任务取消/重试、传播来源 metadata 回显、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry | | R6 AI 推理 | `src/lib/api.test.ts`, `src/components/CanvasArea.test.tsx`, `src/components/AISegmentation.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/components/ModelStatusBadge.test.tsx`, `backend/tests/test_ai.py`, `backend/tests/test_sam2_engine.py` | SAM 2.1 变体选择、点/框/interactive 契约、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、SAM 2.1 框选后正负点细化同一候选 mask、AI 页框选发送 box prompt、AI 页框选后加点发送 interactive prompt、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可单点删除提示点并删除最近锚点、AI 页可删除选中候选且不删除工作区 mask、AI 页清空只移除本页候选、AI 页参数开关可读性文案且 options 字段不变、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频以当前参考帧全部 mask 和起止帧范围自动传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、传播中轮询任务进度、传播任务取消/重试、传播来源 metadata 回显、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry |
| R7 标注保存 | `src/components/VideoWorkspace.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_ai.py` | 保存状态按钮“保存 X 个改动/已全部保存”、保存标注、保存后用后端 saved annotation 替换已提交 draft、加载回显、更新 dirty 标注、清空删除已保存标注、GT mask 多类别导入、seed point 回显/归一化、项目不存在、帧不存在 | | R7 标注保存 | `src/components/VideoWorkspace.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_ai.py` | 保存状态按钮“保存 X 个改动/已全部保存”、保存标注、保存后用后端 saved annotation 替换已提交 draft、加载回显、更新 dirty 标注、清空删除已保存标注、GT mask 多类别导入、高精度 GT contour、导入 mask 可直接拓扑统计和边缘平滑、后端 seed point 归一化兼容但前端不显示或拖动、缺失 seed point 的普通 polygon 保存时自动写入代表点、项目不存在、帧不存在 |
| R8 模板库 | `src/components/TemplateRegistry.test.tsx`, `src/components/TransientNotice.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_templates.py` | 前端模板加载/新建/编辑/删除、JSON 分类导入、JSON/保存错误非阻塞提示、mapping_rules 解包/打包、后端模板 CRUD | | R8 模板库 | `src/components/TemplateRegistry.test.tsx`, `src/components/TransientNotice.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_templates.py` | 前端模板加载/新建/编辑/删除、删除模板站内确认、鼠标复制模板为私有副本、所有模板归一化包含黑色 `maskid:0`“待分类”保留类、保留类固定最后且不可删除/拖拽上移、详情页“语义分类树(拖拽调层级)”标题、详情页“+ 新建分类”、详情页垃圾桶删除 label 且不显示来源标签、编辑后详情页刷新、详情页和编辑弹窗拖拽语义层级顺序、拖拽保存 `zIndex` 且不改变 maskid、JSON 分类导入预览、JSON 错误内联提示、保存错误非阻塞提示、mapping_rules 解包/打包、后端模板 CRUD |
| R9 本体检查面板 | `src/components/OntologyInspector.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_ai.py` | 模板选择、面板标题简化、面板低对比滚动条、工作区遮罩透明度滑杆、分类展示、具体分类选择、Canvas 选区同步、点击 Canvas mask 后自动聚焦对应语义分类、点击分类给已选 mask 换标签并移动到前端渲染最上层、分类变更同步同一传播链前后帧对应 mask、自定义分类 PATCH 后端模板、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、选中 mask 后端拓扑属性分析、拓扑锚点数量按真实 polygon 顶点数显示、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览不标 dirty、应用边缘平滑后将 mask 标记为 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑保存时保留传播 lineage 而不把传播帧变成人工/AI 标注帧、平滑撤销/重做、平滑应用后强度归零 | | R9 本体检查面板 | `src/components/OntologyInspector.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_ai.py` | 模板选择、面板标题简化、面板低对比滚动条、工作区遮罩透明度滑杆、分类展示、具体分类选择、模板类别删除后项目旧 mask 回显为 `maskid:0` 待分类、Canvas 选区同步、点击 Canvas mask 后自动聚焦对应语义分类、点击分类给已选 mask 换标签并移动到前端渲染最上层、分类变更同步同一传播链前后帧对应 mask、自定义分类 PATCH 后端模板、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、选中 mask 后端拓扑属性分析、拓扑锚点数量按真实 polygon 顶点数显示、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览不标 dirty、应用边缘平滑后将 mask 标记为 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑保存时保留传播 lineage 而不把传播帧变成人工/AI 标注帧、平滑撤销/重做、平滑应用后强度归零 |
| R10 Dashboard 与 WebSocket | `src/lib/api.test.ts`, `src/lib/websocket.test.ts`, `src/components/Dashboard.test.tsx`, `backend/tests/test_dashboard.py`, `backend/tests/test_main.py`, `backend/tests/test_progress_events.py`, `backend/tests/test_tasks.py` | 后端概览接口、任务表驱动进度区、最近完成任务保留显示、任务取消/重试/详情、cancelled 事件、Redis 进度事件 payload/发布、地址推导、消息订阅、连接状态回调、队列更新、heartbeat、主动断开不重连 | | R10 Dashboard 与 WebSocket | `src/lib/api.test.ts`, `src/lib/websocket.test.ts`, `src/components/Dashboard.test.tsx`, `backend/tests/test_dashboard.py`, `backend/tests/test_main.py`, `backend/tests/test_progress_events.py`, `backend/tests/test_tasks.py` | 后端概览接口、任务表驱动进度区、最近完成任务保留显示、任务取消/重试/详情、cancelled 事件、Redis 进度事件 payload/发布、地址推导、消息订阅、连接状态回调、队列更新、heartbeat、主动断开不重连 |
| R11 导出 | `src/components/VideoWorkspace.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_export.py` | 统一分割结果导出下拉、导出前自动保存、整体/范围/当前帧范围参数、特定范围帧可通过播放进度条/视频处理进度条拖拽选择、下载 ZIP 按项目名/`0h00m00s000ms` 起止时间戳/起止项目帧序号命名、导出内容 outputs 参数、Mix_label 透明度参数和预览、兼容 COCO/PNG 路径、JSON 结构、maskid/GT 像素值映射 JSON、原始图片文件夹、按帧/按类别合并的分开 Mask 文件夹、GT_label 黑白图文件夹、Pro_label 彩色图文件夹、Mix_label 原图叠加图文件夹、GT/Pro/Mix 按内部优先级覆盖且和语义分类树顺序一致、GT_label 背景 0、保留类别真实 maskid、导出 GT_label 再导入保持类别一致 | | R11 导出 | `src/components/VideoWorkspace.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_export.py` | 统一分割结果导出下拉、导出前自动保存、整体/范围/当前帧范围参数、特定范围帧可通过播放进度条/视频处理进度条拖拽选择、下载 ZIP 按项目名/`0h00m00s000ms` 起止时间戳/起止项目帧序号命名、导出内容 outputs 参数、Mix_label 透明度参数和预览、兼容 COCO/PNG 路径、JSON 结构、maskid/GT 像素值映射 JSON、原始图片文件夹、按帧/按类别合并的分开 Mask 文件夹、GT_label 黑白图文件夹、Pro_label 彩色图文件夹、Mix_label 原图叠加图文件夹、GT/Pro/Mix 按内部优先级覆盖且和语义分类树顺序一致、GT_label 固定 uint8、GT_label 背景 0、保留类别真实 maskid、`maskid:0` 待分类在 GT_label/Pro_label 中与背景同为黑色 0、正整数 maskid 超出 1-255 拒绝导出、导出 GT_label 再导入保持类别一致 |
| R12 配置 | `src/lib/config.test.ts` | env 优先、hostname 推导、WS 推导 | | R12 配置 | `src/lib/config.test.ts` | env 优先、hostname 推导、WS 推导 |
| R13 文档与测试 | `doc/09-test-plan.md` | 测试覆盖矩阵 | | R13 文档与测试 | `doc/09-test-plan.md` | 测试覆盖矩阵 |
@@ -32,18 +32,18 @@
| 需求 | 功能点 | 对应测试 | 当前状态 | | 需求 | 功能点 | 对应测试 | 当前状态 |
|------|--------|----------|----------| |------|--------|----------|----------|
| R1 | 登录页、默认开发管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin 和未生成帧演示视频项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 | | R1 | 登录页、默认开发管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、演示视频项目和已生成帧的自然排序演示 DICOM 项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 |
| R2 | 项目列表/创建/选择、项目按用户隔离、视频导入、DICOM 导入、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 | | R2 | 项目列表/创建/选择/重命名/复制、重命名时不触发生成帧、DICOM 不显示生成帧、项目复制 reset/full、项目按用户隔离、视频导入、DICOM 导入、DICOM 前端选择自然排序、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 |
| R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、显式 FPS 生成帧、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 | | R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式 FPS 生成帧、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、DICOM 上传/下载/读取自然排序、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 |
| R4 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 draft mask、Canvas/AI 底图居中适配且保留边距、工作区 mask 透明度、选中 mask 后跨帧自动跟随同一传播链结果、清空片段遮罩进入时间轴范围选择并按选区批量清空、清空全部模式、保留人工/AI 模式只清传播 mask、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、清空后裁剪/移除重叠传播历史条、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/清空范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播/清空范围、Canvas/AI 画布拖拽平移回写 position state、左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 | | R4 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 draft mask、Canvas/AI 底图居中适配且保留边距、工作区 mask 透明度、选中 mask 后跨帧自动跟随同一传播链结果、清空片段遮罩进入时间轴范围选择并按选区批量清空、清空全部模式、保留人工/AI 模式只清传播 mask、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、清空后裁剪/移除重叠传播历史条、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/清空范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播/清空范围、Canvas/AI 画布拖拽平移回写 position state、左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 |
| R5 | 工具切换、工具栏紧凑滚动布局、低对比滚动条、外扩滚动条槽位、调整多边形入口、GT Mask 导入入口位置和紫色底色、工作区工具栏隐藏 AI 正/反点和框选、左侧工具栏不重复撤销/重做、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制、多边形和布尔工具上下文提示、Canvas 上下文提示数秒后自动隐藏 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 | | R5 | 工具切换、工具栏紧凑滚动布局、低对比滚动条、外扩滚动条槽位、调整多边形入口、GT Mask 导入入口位置和紫色底色、工作区工具栏隐藏 AI 正/反点和框选、左侧工具栏不重复撤销/重做、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制、多边形和布尔工具上下文提示、Canvas 上下文提示数秒后自动隐藏 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 |
| R5 | 顶点直接拖动编辑、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、顶点删除、整块删除、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键、区域合并、区域去除、布尔选择主区域黄色实线/扣除区域红色虚线、布尔选择顺序提示、hole even-odd 渲染 | `CanvasArea.test.tsx`, `VideoWorkspace.test.tsx`, `useStore.test.ts` | 已覆盖 | | R5 | 顶点直接拖动编辑、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、顶点删除、整块删除、删除传播链自动传播 mask 且保留独立 AI 推理 mask、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键、区域合并、区域去除、布尔选择主区域黄色实线/扣除区域红色虚线、布尔选择顺序提示、hole even-odd 渲染 | `CanvasArea.test.tsx`, `VideoWorkspace.test.tsx`, `useStore.test.ts` | 已覆盖 |
| R6 | SAM 2.1 变体选择、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页框选/框选后加点、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可删除提示点、AI 页可删除选中候选、AI 页清空只移除本页候选、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频按参考帧全部 mask 和范围自动传播、传播前自动保存 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播 Celery 任务入队、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、前端任务轮询进度、传播任务 runner 保存标注和结果权重 id、传播任务重试、传播空结果提示、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.py`, `test_tasks.py`, `test_sam2_engine.py` | 已覆盖 | | R6 | SAM 2.1 变体选择、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页框选/框选后加点、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可删除提示点、AI 页可删除选中候选、AI 页清空只移除本页候选、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频按参考帧全部 mask 和范围自动传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播 Celery 任务入队、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、前端任务轮询进度、传播任务 runner 保存标注和结果权重 id、传播任务重试、传播空结果提示、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.py`, `test_tasks.py`, `test_sam2_engine.py` | 已覆盖 |
| R7 | 保存状态按钮“保存 X 个改动/已全部保存”、保存、保存后替换已提交 draft、查询、更新、删除标注、工作区回显、清空已保存标注、GT mask 导入和 seed point 回写、低数值/16-bit GT_label 图导入、RGB 等通道 maskid 图导入、导入预览、未知 maskid 导入策略、非法彩色 GT mask 拒绝、尺寸不一致自动最近邻拉伸 | `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `api.test.ts`, `test_ai.py` | 已覆盖 | | R7 | 保存状态按钮“保存 X 个改动/已全部保存”、保存、保存后替换已提交 draft、查询、更新、删除标注、工作区回显、清空已保存标注、GT mask 导入和 seed point 数据兼容、导入 mask 不显示黄色 seed point、高精度 GT contour、导入 mask 拓扑统计和边缘平滑、8-bit 低数值 GT_label 图导入、16-bit/uint16 GT_label 图拒绝、全背景 0 GT_label 图拒绝并保留“没有非背景 maskid 区域”提示、RGB 等通道 maskid 图导入、导入预览、未知 maskid 导入策略、非法彩色 GT mask 拒绝、尺寸不一致自动最近邻拉伸 | `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `api.test.ts`, `test_ai.py` | 已覆盖 |
| R8 | 模板加载、新建、编辑、删除、JSON 分类导入、JSON/保存错误非阻塞提示、mapping_rules 映射、后端 CRUD | `TemplateRegistry.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_templates.py` | 已覆盖 | | R8 | 模板加载、新建、编辑、删除、删除模板站内确认、鼠标复制模板为私有副本并保留 maskid/颜色/层级/规则、所有模板归一化包含黑色 `maskid:0`“待分类”保留类、保留类固定最后且不可删除/拖拽上移、详情页标题/新建分类/垃圾桶删 label、默认模板“腹腔镜胆囊切除术”和“头颈部CT分割”幂等 seed、恢复出厂设置保留系统模板、编辑后详情页刷新、详情页和编辑弹窗拖拽语义层级顺序、拖拽保存 `zIndex` 且不改变 maskid、JSON 分类导入预览、JSON 错误内联提示、保存错误非阻塞提示、mapping_rules 映射、后端 CRUD | `TemplateRegistry.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_templates.py`, `test_admin.py` | 已覆盖 |
| R9 | 模板选择、面板标题简化、工作区遮罩透明度滑杆、分类展示、分类选择、分类树拖拽调整内部覆盖顺序且不改变 maskid、拖拽后同步同类 mask 层级并标记待保存、点击 mask 自动聚焦对应分类、已选 mask 换标签并置顶显示、分类变更同步同一传播链前后帧对应 mask、自定义分类写入后端模板、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、后端拓扑属性分析、拓扑锚点真实顶点计数、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览、边缘平滑应用后确认 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑撤销/重做、平滑应用后强度归零、占位状态 | `OntologyInspector.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts`, `test_ai.py` | 已覆盖 | | R9 | 模板选择、面板标题简化、工作区遮罩透明度滑杆、分类展示、分类选择、模板类别删除后项目旧 mask 回显为 `maskid:0` 待分类、分类树拖拽调整内部覆盖顺序且不改变 maskid、拖拽后同步同类 mask 层级并标记待保存、点击 mask 自动聚焦对应分类、已选 mask 换标签并置顶显示、分类变更同步同一传播链前后帧对应 mask、自定义分类写入后端模板、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、后端拓扑属性分析、拓扑锚点真实顶点计数、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览、边缘平滑应用后确认 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑撤销/重做、平滑应用后强度归零、占位状态 | `OntologyInspector.test.tsx`, `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts`, `test_ai.py` | 已覆盖 |
| R10 | Dashboard 概览、任务进度区、最近完成任务保留显示、活动日志、WebSocket progress/complete/error/status/cancelled、取消/重试/详情、连接状态回调、heartbeat | `Dashboard.test.tsx`, `websocket.test.ts`, `test_dashboard.py`, `test_main.py`, `test_progress_events.py`, `test_tasks.py` | 已覆盖 | | R10 | Dashboard 概览、任务进度区、最近完成任务保留显示、活动日志、WebSocket progress/complete/error/status/cancelled、取消/重试/详情、连接状态回调、heartbeat | `Dashboard.test.tsx`, `websocket.test.ts`, `test_dashboard.py`, `test_main.py`, `test_progress_events.py`, `test_tasks.py` | 已覆盖 |
| R11 | 统一“分割结果导出”下拉、整体视频/特定范围帧/当前图片导出、特定范围帧时间轴拖拽选择、ZIP 文件名 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`、时间戳 `0h00m00s000ms` 格式、项目帧序号使用抽帧后 1-based 顺序、分开 Mask/GT_label/Pro_label/Mix_label outputs、Mix_label 透明度、导出前保存、兼容 COCO/PNG ZIP 路径、JSON/ZIP 结构、maskid/GT 像素值映射、原始图片导出、分开 Mask 按帧子目录与同类合并命名、GT_label/Pro_label/Mix_label 命名、GT/Pro/Mix 内部优先级融合且和语义分类树顺序一致、GT_label 背景 0、保留类别真实 maskid、导出的 GT_label 可按同一模板导回 | `VideoWorkspace.test.tsx`, `api.test.ts`, `test_export.py` | 已覆盖 | | R11 | 统一“分割结果导出”下拉、整体视频/特定范围帧/当前图片导出、特定范围帧时间轴拖拽选择、ZIP 文件名 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`、时间戳 `0h00m00s000ms` 格式、项目帧序号使用抽帧后 1-based 顺序、分开 Mask/GT_label/Pro_label/Mix_label outputs、Mix_label 透明度、导出前保存、兼容 COCO/PNG ZIP 路径、JSON/ZIP 结构、maskid/GT 像素值映射、原始图片导出、分开 Mask 按帧子目录与同类合并命名、GT_label/Pro_label/Mix_label 命名、GT/Pro/Mix 内部优先级融合且和语义分类树顺序一致、GT_label 固定 uint8、GT_label 背景 0、保留类别真实 maskid、`maskid:0` 待分类导出为黑色 0、正整数 maskid 超出 1-255 拒绝导出、导出的 GT_label 可按同一模板导回 | `VideoWorkspace.test.tsx`, `api.test.ts`, `test_export.py` | 已覆盖 |
| R12 | API/WS 地址 env 优先和 hostname 推导 | `config.test.ts` | 已覆盖 | | R12 | API/WS 地址 env 优先和 hostname 推导 | `config.test.ts` | 已覆盖 |
| R13 | 文档测试矩阵与功能点追踪 | `doc/09-test-plan.md` | 已覆盖 | | R13 | 文档测试矩阵与功能点追踪 | `doc/09-test-plan.md` | 已覆盖 |
@@ -66,6 +66,7 @@
- R6补充传播去重回归测试验证前端传播前会先保存 draft seed mask 并用稳定 `source_annotation_id` 入队;后端在 seed 来源由前端临时 id 迁移到后端 annotation id、用户换用其他 SAM 2.1 权重、未编辑传播结果再次作为 seed、已编辑传播结果重新作为 seed、中间帧人工新增替代 seed 时,会分别跳过或清理旧传播标注再保存新结果。 - R6补充传播去重回归测试验证前端传播前会先保存 draft seed mask 并用稳定 `source_annotation_id` 入队;后端在 seed 来源由前端临时 id 迁移到后端 annotation id、用户换用其他 SAM 2.1 权重、未编辑传播结果再次作为 seed、已编辑传播结果重新作为 seed、中间帧人工新增替代 seed 时,会分别跳过或清理旧传播标注再保存新结果。
- R6`backend/tests/test_sam3_engine.py` 已标记跳过,仅作为历史保留实现的参考测试,不计入当前产品功能覆盖。 - R6`backend/tests/test_sam3_engine.py` 已标记跳过,仅作为历史保留实现的参考测试,不计入当前产品功能覆盖。
- R3补充 `parseMedia()` 查询参数和后端拆帧任务 payload 测试,验证 `parse_fps``max_frames``target_width` 会进入任务。 - R3补充 `parseMedia()` 查询参数和后端拆帧任务 payload 测试,验证 `parse_fps``max_frames``target_width` 会进入任务。
- R3补充 `ProjectLibrary.test.tsx``api.test.ts` 中上传进度测试,验证视频/DICOM 上传通过 Axios `onUploadProgress` 回调更新项目库导入进度条,并显示 DICOM 文件数量和解析任务轮询进度。
- R3补充 worker 注册标准帧序列测试,验证帧 `timestamp_ms``source_frame_number``result.frame_sequence` 元数据。 - R3补充 worker 注册标准帧序列测试,验证帧 `timestamp_ms``source_frame_number``result.frame_sequence` 元数据。
- R8补充 `TemplateRegistry.test.tsx` 中模板编辑、删除测试,验证前端调用真实 API 封装并更新全局 store。 - R8补充 `TemplateRegistry.test.tsx` 中模板编辑、删除测试,验证前端调用真实 API 封装并更新全局 store。
- R9补充 Canvas 选中 mask id 全局同步、本体树点击分类给已选 mask 换标签并移到渲染最上层的测试,验证已保存 mask 会进入 dirty 状态。 - R9补充 Canvas 选中 mask id 全局同步、本体树点击分类给已选 mask 换标签并移到渲染最上层的测试,验证已保存 mask 会进入 dirty 状态。

View File

@@ -192,6 +192,7 @@ access_token_expire_minutes=1440
default_admin_username=admin default_admin_username=admin
default_admin_password=123456 default_admin_password=123456
demo_video_path=/home/wkmgc/Desktop/Seg_Server/Data_MyVideo_1.mp4 demo_video_path=/home/wkmgc/Desktop/Seg_Server/Data_MyVideo_1.mp4
demo_dicom_dir=/home/wkmgc/Desktop/Seg_Server/2024_2_5_王芳/※2F458C45CFAA4C7CB76A39AA2BFE436B
EOF EOF
``` ```
@@ -313,7 +314,7 @@ admin / 123456
首次启动会自动创建默认管理员,密码以哈希形式写入 `users` 表;登录返回签名 JWT业务接口会校验 `Authorization: Bearer <token>`。生产环境必须修改 `jwt_secret_key` 和默认管理员密码。 首次启动会自动创建默认管理员,密码以哈希形式写入 `users` 表;登录返回签名 JWT业务接口会校验 `Authorization: Bearer <token>`。生产环境必须修改 `jwt_secret_key` 和默认管理员密码。
默认管理员登录后会看到“用户管理”后台,可新增用户、停用/启用用户、修改角色、重置密码、删除无项目用户并查看登录与用户管理审计日志。角色分为 `admin``annotator``viewer``admin/annotator` 可以执行写入类业务操作,`viewer` 只读。演示部署可在该后台使用“恢复演示出厂设置”,二次确认后只保留默认 admin 和一个尚未生成帧的演示视频项目;视频来自 `demo_video_path` 默认管理员登录后会看到“用户管理”后台,可新增用户、停用/启用用户、修改角色、重置密码、删除无项目用户并查看登录与用户管理审计日志。角色分为 `admin``annotator``viewer``admin/annotator` 可以执行写入类业务操作,`viewer` 只读。演示部署可在该后台使用“恢复演示出厂设置”,二次确认后只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目;视频来自 `demo_video_path`DICOM 序列来自 `demo_dicom_dir`
--- ---

View File

@@ -466,7 +466,7 @@ describe('CanvasArea', () => {
expect(screen.getByText('当前图层: 胆囊 #21')).toBeInTheDocument(); expect(screen.getByText('当前图层: 胆囊 #21')).toBeInTheDocument();
}); });
it('renders imported GT seed points for editable point regions', () => { it('does not render stored GT seed points as visible editable handles', () => {
useStore.setState({ useStore.setState({
masks: [ masks: [
{ {
@@ -482,7 +482,28 @@ describe('CanvasArea', () => {
render(<CanvasArea activeTool="move" frame={frame} />); render(<CanvasArea activeTool="move" frame={frame} />);
expect(screen.getAllByTestId('konva-circle')).toHaveLength(2); expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
});
it('does not derive visible seed points for ordinary polygon masks', () => {
useStore.setState({
masks: [
{
id: 'manual-1',
frameId: 'frame-1',
pathData: 'M 10 10 L 90 10 L 90 40 Z',
label: 'Manual',
color: '#06b6d4',
segmentation: [[10, 10, 90, 10, 90, 40]],
},
],
});
render(<CanvasArea activeTool="move" frame={frame} />);
expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
}); });
it('selects a polygon mask and drags a vertex into dirty saved state', () => { it('selects a polygon mask and drags a vertex into dirty saved state', () => {
@@ -668,6 +689,79 @@ describe('CanvasArea', () => {
expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(['99']); expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(['99']);
}); });
it('deletes linked propagated masks while keeping independent AI inference masks', () => {
const onDeleteMaskAnnotations = vi.fn();
const propagatedFrame = { ...frame, id: 'frame-2', index: 1, url: '/frame-2.jpg' };
useStore.setState({
masks: [
{
id: 'annotation-99',
annotationId: '99',
frameId: 'frame-1',
pathData: 'M 10 10 L 90 10 L 90 40 Z',
label: 'Seed',
color: '#06b6d4',
saveStatus: 'saved',
saved: true,
segmentation: [[10, 10, 90, 10, 90, 40]],
},
{
id: 'annotation-100',
annotationId: '100',
frameId: 'frame-2',
pathData: 'M 12 10 L 92 10 L 92 40 Z',
label: 'Propagated A',
color: '#06b6d4',
saveStatus: 'saved',
saved: true,
segmentation: [[12, 10, 92, 10, 92, 40]],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 99,
source_mask_id: 'annotation-99',
propagation_seed_key: 'annotation:99',
},
},
{
id: 'annotation-101',
annotationId: '101',
frameId: 'frame-3',
pathData: 'M 14 10 L 94 10 L 94 40 Z',
label: 'Propagated B',
color: '#06b6d4',
saveStatus: 'saved',
saved: true,
segmentation: [[14, 10, 94, 10, 94, 40]],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 99,
source_mask_id: 'annotation-99',
propagation_seed_key: 'annotation:99',
},
},
{
id: 'annotation-102',
annotationId: '102',
frameId: 'frame-3',
pathData: 'M 200 10 L 260 10 L 260 40 Z',
label: 'AI Candidate',
color: '#22c55e',
saveStatus: 'saved',
saved: true,
segmentation: [[200, 10, 260, 10, 260, 40]],
metadata: { source: 'ai_segmentation' },
},
],
});
render(<CanvasArea activeTool="move" frame={propagatedFrame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
fireEvent.click(screen.getByTestId('konva-path'));
fireEvent.keyDown(window, { key: 'Delete' });
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-99', 'annotation-102']);
expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(['100', '101']);
});
it('inserts a polygon vertex from an edge midpoint handle', () => { it('inserts a polygon vertex from an edge midpoint handle', () => {
useStore.setState({ useStore.setState({
masks: [ masks: [
@@ -784,7 +878,8 @@ describe('CanvasArea', () => {
const paths = screen.getAllByTestId('konva-path'); const paths = screen.getAllByTestId('konva-path');
fireEvent.click(paths[0]); fireEvent.click(paths[0]);
expect(screen.getByText('已选 1')).toBeInTheDocument(); expect(screen.getByText('已选 1')).toBeInTheDocument();
expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0); expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(0);
fireEvent.click(paths[1]); fireEvent.click(paths[1]);
expect(screen.getByText('已选 2')).toBeInTheDocument(); expect(screen.getByText('已选 2')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '合并选中' })); fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
@@ -1069,7 +1164,8 @@ describe('CanvasArea', () => {
shape: '多边形', shape: '多边形',
}), }),
})); }));
expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0); expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
}); });
it('shows contextual guidance for boolean selection ordering', () => { it('shows contextual guidance for boolean selection ordering', () => {

View File

@@ -121,6 +121,16 @@ function findPropagationChainMaskIds(selectedIds: string[], allMasks: Mask[]): S
); );
} }
function expandedPropagationDeletionMaskIds(selectedIds: string[], allMasks: Mask[]): Set<string> {
const selectedIdSet = new Set(selectedIds);
const chainIds = findPropagationChainMaskIds(selectedIds, allMasks);
return new Set(
allMasks
.filter((mask) => selectedIdSet.has(mask.id) || (chainIds.has(mask.id) && isPropagationMask(mask)))
.map((mask) => mask.id),
);
}
function maskLayerPriority(mask: Mask): number { function maskLayerPriority(mask: Mask): number {
const parsed = Number(mask.classZIndex ?? mask.metadata?.classZIndex ?? 0); const parsed = Number(mask.classZIndex ?? mask.metadata?.classZIndex ?? 0);
return Number.isFinite(parsed) ? parsed : 0; return Number.isFinite(parsed) ? parsed : 0;
@@ -958,7 +968,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
const deleteMasksById = useCallback((maskIds: string[]) => { const deleteMasksById = useCallback((maskIds: string[]) => {
if (maskIds.length === 0) return; if (maskIds.length === 0) return;
const idSet = new Set(maskIds); const idSet = expandedPropagationDeletionMaskIds(maskIds, masks);
const deletingMasks = masks.filter((mask) => idSet.has(mask.id)); const deletingMasks = masks.filter((mask) => idSet.has(mask.id));
if (deletingMasks.length === 0) return; if (deletingMasks.length === 0) return;
setMasks(masks.filter((mask) => !idSet.has(mask.id))); setMasks(masks.filter((mask) => !idSet.has(mask.id)));
@@ -1278,18 +1288,6 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
return null; return null;
}, [cursorPos, effectiveTool, manualCurrent, manualStart, polygonPoints]); }, [cursorPos, effectiveTool, manualCurrent, manualStart, polygonPoints]);
const handleSeedPointDragEnd = (mask: Mask, pointIndex: number, event: any) => {
const x = event.target.x();
const y = event.target.y();
const nextPoints = [...(mask.points || [])];
nextPoints[pointIndex] = [x, y];
updateMask(mask.id, {
points: nextPoints,
saveStatus: mask.annotationId ? 'dirty' : 'draft',
saved: mask.annotationId ? false : mask.saved,
});
};
const handleMaskSelect = (mask: Mask, event: any, polygonIndex = 0) => { const handleMaskSelect = (mask: Mask, event: any, polygonIndex = 0) => {
if (!isPolygonEditTool && !isBooleanTool) return; if (!isPolygonEditTool && !isBooleanTool) return;
event.cancelBubble = true; event.cancelBubble = true;
@@ -1390,8 +1388,12 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
const resultSegmentation = multiPolygonToSegmentation(resultGeometry); const resultSegmentation = multiPolygonToSegmentation(resultGeometry);
if (resultSegmentation.length === 0) { if (resultSegmentation.length === 0) {
const deleteIds = primary.annotationId ? [primary.annotationId] : []; const deleteMaskIds = expandedPropagationDeletionMaskIds([primary.id], masks);
setMasks(masks.filter((mask) => mask.id !== primary.id)); const deleteIds = masks
.filter((mask) => deleteMaskIds.has(mask.id))
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId));
setMasks(masks.filter((mask) => !deleteMaskIds.has(mask.id)));
if (deleteIds.length > 0) await onDeleteMaskAnnotations?.(deleteIds); if (deleteIds.length > 0) await onDeleteMaskAnnotations?.(deleteIds);
setSelectedMaskId(null); setSelectedMaskId(null);
setSelectedMaskIds([]); setSelectedMaskIds([]);
@@ -1404,11 +1406,11 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
hasHoles: multiPolygonHasHoles(resultGeometry), hasHoles: multiPolygonHasHoles(resultGeometry),
}); });
const secondaryIds = effectiveTool === 'area_merge' const secondaryIds = effectiveTool === 'area_merge'
? new Set(booleanSelectedMasks.slice(1).map((mask) => mask.id)) ? expandedPropagationDeletionMaskIds(booleanSelectedMasks.slice(1).map((mask) => mask.id), masks)
: new Set<string>(); : new Set<string>();
const secondaryAnnotationIds = effectiveTool === 'area_merge' const secondaryAnnotationIds = effectiveTool === 'area_merge'
? booleanSelectedMasks ? masks
.slice(1) .filter((mask) => secondaryIds.has(mask.id))
.map((mask) => mask.annotationId) .map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId)) .filter((annotationId): annotationId is string => Boolean(annotationId))
: []; : [];
@@ -1584,21 +1586,6 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
/> />
))} ))}
{/* Imported GT seed points / editable point regions */}
{frameMasks.flatMap((mask) => (mask.points || []).map(([x, y], index) => (
<Group key={`${mask.id}-seed-${index}`} x={x} y={y}>
<Circle
radius={5 / scale}
fill="#facc15"
stroke="#111827"
strokeWidth={2 / scale}
draggable
onDragEnd={(event: any) => handleSeedPointDragEnd(mask, index, event)}
/>
<Circle radius={1.5 / scale} fill="#111827" />
</Group>
)))}
{/* Polygon edge insertion handles */} {/* Polygon edge insertion handles */}
{isPolygonEditTool && selectedMask && selectedMaskPoints.map((point, index) => { {isPolygonEditTool && selectedMask && selectedMaskPoints.map((point, index) => {
const next = selectedMaskPoints[(index + 1) % selectedMaskPoints.length]; const next = selectedMaskPoints[(index + 1) % selectedMaskPoints.length];

View File

@@ -98,6 +98,14 @@ describe('FrameTimeline', () => {
{ id: 'f6', projectId: 'p1', index: 5, url: '/6.jpg', width: 640, height: 360 }, { id: 'f6', projectId: 'p1', index: 5, url: '/6.jpg', width: 640, height: 360 },
{ id: 'f7', projectId: 'p1', index: 6, url: '/7.jpg', width: 640, height: 360 }, { id: 'f7', projectId: 'p1', index: 6, url: '/7.jpg', width: 640, height: 360 },
], ],
masks: Array.from({ length: 7 }, (_, index) => ({
id: `tracked-${index + 1}`,
frameId: `f${index + 1}`,
pathData: 'M 0 0 Z',
label: 'Tracked',
color: '#3b82f6',
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
})),
}); });
render( render(
@@ -133,6 +141,73 @@ describe('FrameTimeline', () => {
expect(segments[6].style.backgroundColor).not.toBe(segments[0].style.backgroundColor); expect(segments[6].style.backgroundColor).not.toBe(segments[0].style.backgroundColor);
}); });
it('does not color propagation history frames after all masks on those frames are gone', () => {
useStore.setState({
frames: [
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
],
masks: [],
});
render(
<FrameTimeline
propagationHistory={[
{ id: 'history-empty', startFrame: 1, endFrame: 3, colorIndex: 0, label: '已删除传播' },
]}
/>,
);
expect(screen.queryByTestId('propagation-history-segment')).not.toBeInTheDocument();
expect(screen.queryByTestId('propagated-frame-segment')).not.toBeInTheDocument();
expect(screen.queryByTestId('annotated-frame-marker')).not.toBeInTheDocument();
expect(screen.getByText('人工/AI 0 帧 · 自动传播 0 帧')).toBeInTheDocument();
});
it('splits propagation history around frames that no longer have propagated masks', () => {
useStore.setState({
frames: [
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
{ id: 'f4', projectId: 'p1', index: 3, url: '/4.jpg', width: 640, height: 360 },
{ id: 'f5', projectId: 'p1', index: 4, url: '/5.jpg', width: 640, height: 360 },
],
masks: [
{
id: 'tracked-2',
frameId: 'f2',
pathData: 'M 0 0 Z',
label: 'Tracked',
color: '#3b82f6',
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
},
{
id: 'tracked-4',
frameId: 'f4',
pathData: 'M 0 0 Z',
label: 'Tracked',
color: '#3b82f6',
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
},
],
});
render(
<FrameTimeline
propagationHistory={[
{ id: 'history-sparse', startFrame: 1, endFrame: 5, colorIndex: 0, label: '稀疏传播' },
]}
/>,
);
const segments = screen.getAllByTestId('propagation-history-segment');
expect(segments).toHaveLength(2);
expect(segments[0]).toHaveStyle({ left: '20%', width: '20%' });
expect(segments[1]).toHaveStyle({ left: '60%', width: '20%' });
});
it('jumps from the processing progress bar and frame status markers', () => { it('jumps from the processing progress bar and frame status markers', () => {
useStore.setState({ useStore.setState({
frames: [ frames: [

View File

@@ -71,6 +71,10 @@ export function FrameTimeline({
() => new Set(propagatedFrameMarkers.map(({ frame }) => frame.id)), () => new Set(propagatedFrameMarkers.map(({ frame }) => frame.id)),
[propagatedFrameMarkers], [propagatedFrameMarkers],
); );
const propagatedFrameNumbers = useMemo(
() => new Set(propagatedFrameMarkers.map(({ index }) => index + 1)),
[propagatedFrameMarkers],
);
const annotatedFrameMarkers = useMemo(() => { const annotatedFrameMarkers = useMemo(() => {
const frameIds = new Set(frames.map((frame) => frame.id)); const frameIds = new Set(frames.map((frame) => frame.id));
const annotatedIds = new Set( const annotatedIds = new Set(
@@ -128,13 +132,44 @@ export function FrameTimeline({
}; };
const visiblePropagationHistory = useMemo(() => ( const visiblePropagationHistory = useMemo(() => (
propagationHistory propagationHistory
.map((segment, order) => { .flatMap((segment, order) => {
const range = normalizeRange(segment.startFrame, segment.endFrame); const range = normalizeRange(segment.startFrame, segment.endFrame);
const ageFromNewest = Math.min(Math.max(propagationHistory.length - 1 - order, 0), 4); const ageFromNewest = Math.min(Math.max(propagationHistory.length - 1 - order, 0), 4);
return { ...segment, ...range, order, ageFromNewest }; const chunks: Array<typeof segment & { startFrame: number; endFrame: number; order: number; ageFromNewest: number }> = [];
let chunkStart: number | null = null;
for (let frameNumber = range.startFrame; frameNumber <= range.endFrame; frameNumber += 1) {
if (propagatedFrameNumbers.has(frameNumber)) {
chunkStart ??= frameNumber;
continue;
}
if (chunkStart !== null) {
chunks.push({
...segment,
id: chunkStart === range.startFrame && frameNumber - 1 === range.endFrame
? segment.id
: `${segment.id}-${chunkStart}-${frameNumber - 1}`,
startFrame: chunkStart,
endFrame: frameNumber - 1,
order,
ageFromNewest,
});
chunkStart = null;
}
}
if (chunkStart !== null) {
chunks.push({
...segment,
id: chunkStart === range.startFrame ? segment.id : `${segment.id}-${chunkStart}-${range.endFrame}`,
startFrame: chunkStart,
endFrame: range.endFrame,
order,
ageFromNewest,
});
}
return chunks;
}) })
.filter((segment) => totalFrames > 0 && segment.endFrame >= 1 && segment.startFrame <= totalFrames) .filter((segment) => totalFrames > 0 && segment.endFrame >= 1 && segment.startFrame <= totalFrames)
), [propagationHistory, totalFrames]); ), [propagatedFrameNumbers, propagationHistory, totalFrames]);
const frameFromPointerEvent = (event: React.PointerEvent<HTMLElement>) => { const frameFromPointerEvent = (event: React.PointerEvent<HTMLElement>) => {
const rect = event.currentTarget.getBoundingClientRect(); const rect = event.currentTarget.getBoundingClientRect();

View File

@@ -307,6 +307,7 @@ describe('OntologyInspector', () => {
classes: [ classes: [
expect.objectContaining({ id: 'c2', zIndex: 20, maskId: 2 }), expect.objectContaining({ id: 'c2', zIndex: 20, maskId: 2 }),
expect.objectContaining({ id: 'c1', zIndex: 10, maskId: 1 }), expect.objectContaining({ id: 'c1', zIndex: 10, maskId: 1 }),
expect.objectContaining({ name: '待分类', zIndex: 0, maskId: 0 }),
], ],
}))); })));
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({

View File

@@ -5,7 +5,7 @@ import type { Mask, TemplateClass } from '../store/useStore';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { getActiveTemplate } from '../lib/templateSelection'; import { getActiveTemplate } from '../lib/templateSelection';
import { analyzeMask, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult, type SmoothMaskGeometryResult } from '../lib/api'; import { analyzeMask, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult, type SmoothMaskGeometryResult } from '../lib/api';
import { nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds'; import { isReservedUnclassifiedClass, nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
const SMOOTHING_PREVIEW_DEBOUNCE_MS = 220; const SMOOTHING_PREVIEW_DEBOUNCE_MS = 220;
@@ -458,7 +458,8 @@ export function OntologyInspector() {
setClassSaveMessage('请先选择一个模板'); setClassSaveMessage('请先选择一个模板');
return; return;
} }
const maxZ = templateClasses.length > 0 ? Math.max(...templateClasses.map((c) => c.zIndex)) : 0; const activeClasses = templateClasses.filter((templateClass) => !isReservedUnclassifiedClass(templateClass));
const maxZ = activeClasses.length > 0 ? Math.max(...activeClasses.map((c) => c.zIndex)) : 0;
const newClass: TemplateClass = { const newClass: TemplateClass = {
id: `custom-${Date.now()}`, id: `custom-${Date.now()}`,
name: newClassName.trim(), name: newClassName.trim(),
@@ -501,14 +502,20 @@ export function OntologyInspector() {
setDragClassId(null); setDragClassId(null);
return; return;
} }
if (isReservedUnclassifiedClass(allClasses[sourceIndex]) || isReservedUnclassifiedClass(allClasses[targetIndex])) {
setDragClassId(null);
return;
}
const reordered = [...allClasses]; const reordered = [...allClasses];
const [source] = reordered.splice(sourceIndex, 1); const [source] = reordered.splice(sourceIndex, 1);
reordered.splice(targetIndex, 0, source); reordered.splice(targetIndex, 0, source);
const nextClasses = normalizeClassMaskIds( const nextClasses = normalizeClassMaskIds(
reordered.map((item, index) => ({ reordered
.filter((item) => !isReservedUnclassifiedClass(item))
.map((item, index, activeItems) => ({
...item, ...item,
zIndex: (reordered.length - index) * 10, zIndex: (activeItems.length - index) * 10,
})), })),
); );
@@ -606,7 +613,7 @@ export function OntologyInspector() {
<div key={cls.id} className="flex flex-col gap-1"> <div key={cls.id} className="flex flex-col gap-1">
<button <button
type="button" type="button"
draggable={Boolean(activeTemplate) && !isSavingClass} draggable={Boolean(activeTemplate) && !isSavingClass && !isReservedUnclassifiedClass(cls)}
ref={(node) => { ref={(node) => {
if (node) { if (node) {
classButtonRefs.current.set(cls.id, node); classButtonRefs.current.set(cls.id, node);
@@ -616,12 +623,13 @@ export function OntologyInspector() {
}} }}
onClick={() => handleSelectClass(cls)} onClick={() => handleSelectClass(cls)}
onDragStart={(event) => { onDragStart={(event) => {
if (isReservedUnclassifiedClass(cls)) return;
setDragClassId(cls.id); setDragClassId(cls.id);
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', cls.id); event.dataTransfer.setData('text/plain', cls.id);
}} }}
onDragOver={(event) => { onDragOver={(event) => {
if (!dragClassId || dragClassId === cls.id) return; if (!dragClassId || dragClassId === cls.id || isReservedUnclassifiedClass(cls)) return;
event.preventDefault(); event.preventDefault();
event.dataTransfer.dropEffect = 'move'; event.dataTransfer.dropEffect = 'move';
}} }}
@@ -636,10 +644,11 @@ export function OntologyInspector() {
'flex items-center justify-between p-2 rounded bg-white/5 hover:bg-white/10 cursor-pointer group transition-colors text-left border', 'flex items-center justify-between p-2 rounded bg-white/5 hover:bg-white/10 cursor-pointer group transition-colors text-left border',
activeClassId === cls.id ? 'border-cyan-500/50 bg-cyan-500/10' : 'border-transparent', activeClassId === cls.id ? 'border-cyan-500/50 bg-cyan-500/10' : 'border-transparent',
dragClassId === cls.id && 'opacity-50', dragClassId === cls.id && 'opacity-50',
isReservedUnclassifiedClass(cls) && 'cursor-default',
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<GripVertical size={13} className="text-gray-600 group-hover:text-gray-400" aria-hidden="true" /> <GripVertical size={13} className={cn("text-gray-600 group-hover:text-gray-400", isReservedUnclassifiedClass(cls) && "text-gray-800 group-hover:text-gray-800")} aria-hidden="true" />
<span className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: cls.color }} /> <span className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: cls.color }} />
<span className="text-xs font-medium text-gray-200">{cls.name}</span> <span className="text-xs font-medium text-gray-200">{cls.name}</span>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetStore } from '../test/storeTestUtils'; import { resetStore } from '../test/storeTestUtils';
import { useStore } from '../store/useStore'; import { useStore } from '../store/useStore';
@@ -7,19 +7,25 @@ import { ProjectLibrary } from './ProjectLibrary';
const apiMock = vi.hoisted(() => ({ const apiMock = vi.hoisted(() => ({
getProjects: vi.fn(), getProjects: vi.fn(),
createProject: vi.fn(), createProject: vi.fn(),
updateProject: vi.fn(),
copyProject: vi.fn(),
uploadMedia: vi.fn(), uploadMedia: vi.fn(),
parseMedia: vi.fn(), parseMedia: vi.fn(),
uploadDicomBatch: vi.fn(), uploadDicomBatch: vi.fn(),
deleteProject: vi.fn(), deleteProject: vi.fn(),
getTask: vi.fn(),
})); }));
vi.mock('../lib/api', () => ({ vi.mock('../lib/api', () => ({
getProjects: apiMock.getProjects, getProjects: apiMock.getProjects,
createProject: apiMock.createProject, createProject: apiMock.createProject,
updateProject: apiMock.updateProject,
copyProject: apiMock.copyProject,
uploadMedia: apiMock.uploadMedia, uploadMedia: apiMock.uploadMedia,
parseMedia: apiMock.parseMedia, parseMedia: apiMock.parseMedia,
uploadDicomBatch: apiMock.uploadDicomBatch, uploadDicomBatch: apiMock.uploadDicomBatch,
deleteProject: apiMock.deleteProject, deleteProject: apiMock.deleteProject,
getTask: apiMock.getTask,
})); }));
describe('ProjectLibrary', () => { describe('ProjectLibrary', () => {
@@ -93,11 +99,38 @@ describe('ProjectLibrary', () => {
await waitFor(() => expect(apiMock.createProject).toHaveBeenCalledWith(expect.objectContaining({ await waitFor(() => expect(apiMock.createProject).toHaveBeenCalledWith(expect.objectContaining({
name: 'clip.mp4', name: 'clip.mp4',
}))); })));
expect(apiMock.uploadMedia).toHaveBeenCalledWith(file, 'p3'); expect(apiMock.uploadMedia).toHaveBeenCalledWith(file, 'p3', expect.objectContaining({
onProgress: expect.any(Function),
}));
expect(apiMock.parseMedia).not.toHaveBeenCalled(); expect(apiMock.parseMedia).not.toHaveBeenCalled();
expect(await screen.findByRole('status')).toHaveTextContent('视频导入成功'); expect(await screen.findByRole('status')).toHaveTextContent('视频导入成功');
}); });
it('visualizes video upload progress while importing media', async () => {
let resolveUpload: ((value: { url: string; id: string }) => void) | undefined;
apiMock.createProject.mockResolvedValueOnce({ id: 'p-progress', name: 'large.mp4', status: 'pending' });
apiMock.uploadMedia.mockImplementationOnce((_file, _projectId, options) => {
options.onProgress({ loaded: 50, total: 100, percent: 50 });
return new Promise((resolve) => {
resolveUpload = resolve;
});
});
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
const input = container.querySelector('input[accept="video/*"]') as HTMLInputElement;
const file = new File(['video'], 'large.mp4', { type: 'video/mp4' });
fireEvent.change(input, { target: { files: [file] } });
fireEvent.click(await screen.findByRole('button', { name: '开始导入' }));
expect(await screen.findByText('正在上传视频文件')).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: '导入进度' })).toHaveAttribute('aria-valuenow', '50');
await act(async () => {
resolveUpload?.({ url: 'http://file', id: 'object' });
});
expect(await screen.findByText('视频导入完成')).toBeInTheDocument();
});
it('generates frames from an imported video with the selected FPS', async () => { it('generates frames from an imported video with the selected FPS', async () => {
apiMock.getProjects apiMock.getProjects
.mockResolvedValueOnce([{ id: 'p4', name: 'clip.mp4', status: 'pending', frames: 0, video_path: 'uploads/clip.mp4', parse_fps: 30 }]) .mockResolvedValueOnce([{ id: 'p4', name: 'clip.mp4', status: 'pending', frames: 0, video_path: 'uploads/clip.mp4', parse_fps: 30 }])
@@ -115,6 +148,31 @@ describe('ProjectLibrary', () => {
expect(await screen.findByText('12FPS')).toBeInTheDocument(); expect(await screen.findByText('12FPS')).toBeInTheDocument();
}); });
it('hides frame generation while editing a project name', async () => {
apiMock.getProjects.mockResolvedValueOnce([
{ id: 'p-edit', name: 'Editable Clip', status: 'pending', frames: 0, video_path: 'uploads/editable.mp4', parse_fps: 30, source_type: 'video' },
]);
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
expect(await screen.findByRole('button', { name: '生成帧' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '修改项目名称 Editable Clip' }));
expect(screen.queryByRole('button', { name: '生成帧' })).not.toBeInTheDocument();
});
it('does not show frame generation for DICOM projects', async () => {
apiMock.getProjects.mockResolvedValueOnce([
{ id: 'p-dicom', name: 'DICOM Series', status: 'ready', frames: 0, video_path: 'uploads/dicom', source_type: 'dicom' },
]);
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
expect(await screen.findByText('DICOM Series')).toBeInTheDocument();
expect(screen.getByText('DICOM')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '生成帧' })).not.toBeInTheDocument();
});
it('deletes a project from the project card without entering the workspace', async () => { it('deletes a project from the project card without entering the workspace', async () => {
const onProjectSelect = vi.fn(); const onProjectSelect = vi.fn();
apiMock.getProjects.mockResolvedValueOnce([ apiMock.getProjects.mockResolvedValueOnce([
@@ -131,6 +189,7 @@ describe('ProjectLibrary', () => {
render(<ProjectLibrary onProjectSelect={onProjectSelect} />); render(<ProjectLibrary onProjectSelect={onProjectSelect} />);
fireEvent.click(await screen.findByRole('button', { name: '删除项目 Delete Me' })); fireEvent.click(await screen.findByRole('button', { name: '删除项目 Delete Me' }));
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
await waitFor(() => expect(apiMock.deleteProject).toHaveBeenCalledWith('p5')); await waitFor(() => expect(apiMock.deleteProject).toHaveBeenCalledWith('p5'));
expect(onProjectSelect).not.toHaveBeenCalled(); expect(onProjectSelect).not.toHaveBeenCalled();
@@ -141,18 +200,129 @@ describe('ProjectLibrary', () => {
expect(useStore.getState().selectedMaskIds).toEqual([]); expect(useStore.getState().selectedMaskIds).toEqual([]);
}); });
it('imports only valid DICOM files and parses the returned project', async () => { it('renames a project from the project card without entering the workspace', async () => {
apiMock.uploadDicomBatch.mockResolvedValueOnce({ project_id: 77, uploaded_count: 1, message: 'ok' }); const onProjectSelect = vi.fn();
apiMock.parseMedia.mockResolvedValueOnce({ frames_extracted: 1 }); apiMock.getProjects.mockResolvedValueOnce([
{ id: 'p7', name: 'Old Name', status: 'ready', frames: 3, fps: '30FPS' },
]);
apiMock.updateProject.mockResolvedValueOnce({ id: 'p7', name: 'New Name', status: 'ready', frames: 3, fps: '30FPS' });
useStore.setState({
currentProject: { id: 'p7', name: 'Old Name', status: 'ready' },
});
render(<ProjectLibrary onProjectSelect={onProjectSelect} />);
fireEvent.click(await screen.findByRole('button', { name: '修改项目名称 Old Name' }));
fireEvent.change(screen.getByDisplayValue('Old Name'), { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: '保存项目名称 Old Name' }));
await waitFor(() => expect(apiMock.updateProject).toHaveBeenCalledWith('p7', { name: 'New Name' }));
expect(onProjectSelect).not.toHaveBeenCalled();
expect(useStore.getState().projects[0]).toEqual(expect.objectContaining({ id: 'p7', name: 'New Name' }));
expect(useStore.getState().currentProject).toEqual(expect.objectContaining({ id: 'p7', name: 'New Name' }));
expect(await screen.findByRole('status')).toHaveTextContent('项目名称已更新');
});
it('copies a project as a reset project from the project card', async () => {
const onProjectSelect = vi.fn();
apiMock.getProjects
.mockResolvedValueOnce([
{ id: 'p8', name: 'Source Project', status: 'ready', frames: 3, fps: '30FPS' },
])
.mockResolvedValueOnce([
{ id: 'p9', name: 'Source Project 副本', status: 'ready', frames: 3, fps: '30FPS' },
{ id: 'p8', name: 'Source Project', status: 'ready', frames: 3, fps: '30FPS' },
]);
apiMock.copyProject.mockResolvedValueOnce({ id: 'p9', name: 'Source Project 副本', status: 'ready', frames: 3, fps: '30FPS' });
render(<ProjectLibrary onProjectSelect={onProjectSelect} />);
fireEvent.click(await screen.findByRole('button', { name: '复制项目 Source Project' }));
fireEvent.click(screen.getByRole('button', { name: /新项目重置/ }));
await waitFor(() => expect(apiMock.copyProject).toHaveBeenCalledWith('p8', { mode: 'reset' }));
expect(onProjectSelect).not.toHaveBeenCalled();
expect(useStore.getState().projects.map((project) => project.id)).toEqual(['p9', 'p8']);
expect(await screen.findByRole('status')).toHaveTextContent('已复制为重置项目Source Project 副本');
});
it('copies a project with all content from the project card', async () => {
apiMock.getProjects
.mockResolvedValueOnce([
{ id: 'p10', name: 'Annotated Project', status: 'ready', frames: 2, fps: '30FPS' },
])
.mockResolvedValueOnce([
{ id: 'p11', name: 'Annotated Project 副本', status: 'ready', frames: 2, fps: '30FPS' },
{ id: 'p10', name: 'Annotated Project', status: 'ready', frames: 2, fps: '30FPS' },
]);
apiMock.copyProject.mockResolvedValueOnce({ id: 'p11', name: 'Annotated Project 副本', status: 'ready', frames: 2, fps: '30FPS' });
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: '复制项目 Annotated Project' }));
fireEvent.click(screen.getByRole('button', { name: /全内容复制/ }));
await waitFor(() => expect(apiMock.copyProject).toHaveBeenCalledWith('p10', { mode: 'full' }));
expect(await screen.findByRole('status')).toHaveTextContent('已全内容复制项目Annotated Project 副本');
});
it('imports valid DICOM files in natural filename order and parses the returned project', async () => {
apiMock.uploadDicomBatch.mockResolvedValueOnce({ project_id: 77, uploaded_count: 3, message: 'ok' });
apiMock.parseMedia.mockResolvedValueOnce({ frames_extracted: 3 });
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />); const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
const input = container.querySelector('input[accept=".dcm"]') as HTMLInputElement; const input = container.querySelector('input[accept=".dcm"]') as HTMLInputElement;
const dcm = new File(['dcm'], 'scan.dcm', { type: 'application/dicom' }); const ten = new File(['dcm10'], '10.dcm', { type: 'application/dicom' });
const two = new File(['dcm2'], '2.dcm', { type: 'application/dicom' });
const one = new File(['dcm1'], '1.dcm', { type: 'application/dicom' });
const ignored = new File(['txt'], 'notes.txt', { type: 'text/plain' }); const ignored = new File(['txt'], 'notes.txt', { type: 'text/plain' });
fireEvent.change(input, { target: { files: [dcm, ignored] } }); fireEvent.change(input, { target: { files: [ten, ignored, two, one] } });
await waitFor(() => expect(apiMock.uploadDicomBatch).toHaveBeenCalledWith([dcm])); await waitFor(() => expect(apiMock.uploadDicomBatch).toHaveBeenCalledWith([one, two, ten], undefined, expect.objectContaining({
onProgress: expect.any(Function),
})));
expect(apiMock.parseMedia).toHaveBeenCalledWith('77'); expect(apiMock.parseMedia).toHaveBeenCalledWith('77');
expect(await screen.findByRole('status')).toHaveTextContent('DICOM 上传成功: 1 个文件'); expect(await screen.findByRole('status')).toHaveTextContent('DICOM 导入完成: 3 个文件');
});
it('visualizes DICOM upload progress and parsing queue handoff', async () => {
let resolveDicomUpload: ((value: { project_id: number; uploaded_count: number; message: string }) => void) | undefined;
apiMock.uploadDicomBatch.mockImplementationOnce(() => new Promise((resolve) => {
resolveDicomUpload = resolve;
}));
apiMock.parseMedia.mockResolvedValueOnce({ id: 44, status: 'queued', progress: 0 });
apiMock.getTask
.mockResolvedValueOnce({ id: 44, status: 'running', progress: 55, message: '正在写入帧索引' })
.mockResolvedValueOnce({ id: 44, status: 'success', progress: 100, message: '解析完成' });
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
const input = container.querySelector('input[accept=".dcm"]') as HTMLInputElement;
const one = new File(['dcm1'], '1.dcm', { type: 'application/dicom' });
const two = new File(['dcm2'], '2.dcm', { type: 'application/dicom' });
fireEvent.change(input, { target: { files: [two, one] } });
await waitFor(() => expect(apiMock.uploadDicomBatch).toHaveBeenCalled());
const progressOptions = apiMock.uploadDicomBatch.mock.calls[0][2];
await act(async () => {
progressOptions.onProgress({ loaded: 80, total: 100, percent: 80 });
});
expect(await screen.findByText('正在上传 DICOM 序列')).toBeInTheDocument();
expect(screen.getByText('2 文件')).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: '导入进度' })).toHaveAttribute('aria-valuenow', '80');
vi.useFakeTimers();
await act(async () => {
resolveDicomUpload?.({ project_id: 78, uploaded_count: 2, message: 'ok' });
});
expect(screen.getByText('正在解析 DICOM 序列')).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: '导入进度' })).toHaveAttribute('aria-valuenow', '0');
await act(async () => {
vi.advanceTimersByTime(1200);
});
expect(apiMock.getTask).toHaveBeenCalledWith(44);
expect(screen.getByText('正在写入帧索引')).toBeInTheDocument();
await act(async () => {
vi.advanceTimersByTime(1200);
});
expect(screen.getByText('DICOM 导入完成')).toBeInTheDocument();
vi.useRealTimers();
}); });
}); });

View File

@@ -1,15 +1,31 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { UploadCloud, Film, Settings2, Plus, Loader2, Activity, Images, Trash2 } from 'lucide-react'; import { UploadCloud, Film, Settings2, Plus, Loader2, Activity, Images, Trash2, Pencil, Check, X, Copy } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { useStore } from '../store/useStore'; import { useStore } from '../store/useStore';
import { getProjects, createProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject } from '../lib/api'; import { getProjects, createProject, updateProject, copyProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject, getTask } from '../lib/api';
import type { UploadProgress } from '../lib/api';
import type { Project } from '../store/useStore'; import type { Project } from '../store/useStore';
import { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice'; import { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice';
const naturalFilenameCompare = (left: File, right: File) => left.name.localeCompare(
right.name,
undefined,
{ numeric: true, sensitivity: 'base' },
);
interface ProjectLibraryProps { interface ProjectLibraryProps {
onProjectSelect: () => void; onProjectSelect: () => void;
} }
interface ImportProgressState {
kind: 'video' | 'dicom';
phase: 'preparing' | 'uploading' | 'queueing' | 'parsing' | 'done' | 'error';
title: string;
detail: string;
percent?: number;
fileCount?: number;
}
export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) { export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
const projects = useStore((state) => state.projects); const projects = useStore((state) => state.projects);
const setProjects = useStore((state) => state.setProjects); const setProjects = useStore((state) => state.setProjects);
@@ -32,7 +48,14 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
const [frameParseFps, setFrameParseFps] = useState(30); const [frameParseFps, setFrameParseFps] = useState(30);
const [isGeneratingFrames, setIsGeneratingFrames] = useState(false); const [isGeneratingFrames, setIsGeneratingFrames] = useState(false);
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null); const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const [deleteProjectTarget, setDeleteProjectTarget] = useState<Project | null>(null);
const [copyingProjectId, setCopyingProjectId] = useState<string | null>(null);
const [copyProjectTarget, setCopyProjectTarget] = useState<Project | null>(null);
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
const [editingProjectName, setEditingProjectName] = useState('');
const [renamingProjectId, setRenamingProjectId] = useState<string | null>(null);
const [notice, setNotice] = useState<NoticeState | null>(null); const [notice, setNotice] = useState<NoticeState | null>(null);
const [importProgress, setImportProgress] = useState<ImportProgressState | null>(null);
const videoInputRef = useRef<HTMLInputElement>(null); const videoInputRef = useRef<HTMLInputElement>(null);
const dicomInputRef = useRef<HTMLInputElement>(null); const dicomInputRef = useRef<HTMLInputElement>(null);
@@ -40,6 +63,105 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
setNotice({ id: Date.now(), message, tone }); setNotice({ id: Date.now(), message, tone });
}; };
const formatUploadBytes = (value: number) => {
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
const amount = value / (1024 ** index);
return `${amount >= 10 || index === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[index]}`;
};
const scheduleProgressDismiss = () => {
window.setTimeout(() => setImportProgress((current) => (
current?.phase === 'done' ? null : current
)), 1400);
};
const uploadProgressDetail = (progress: UploadProgress, fallback: string) => {
if (progress.total) {
return `${formatUploadBytes(progress.loaded)} / ${formatUploadBytes(progress.total)}`;
}
return `${fallback},已上传 ${formatUploadBytes(progress.loaded)}`;
};
const waitForTaskDone = async (
taskId: string | number,
onProgress: (progress: { progress?: number; message?: string | null; status?: string }) => void,
) => {
for (;;) {
await new Promise((resolve) => window.setTimeout(resolve, 1200));
const task = await getTask(taskId);
onProgress(task);
if (['success', 'failed', 'cancelled'].includes(task.status)) return task;
}
};
const ImportProgressPanel = () => {
if (!importProgress) return null;
const percent = typeof importProgress.percent === 'number'
? Math.min(100, Math.max(0, importProgress.percent))
: undefined;
const toneClass = importProgress.phase === 'error'
? 'border-red-500/25 bg-red-950/20'
: importProgress.kind === 'dicom'
? 'border-emerald-500/25 bg-emerald-950/15'
: 'border-cyan-500/25 bg-cyan-950/15';
const barClass = importProgress.phase === 'error'
? 'bg-red-400'
: importProgress.kind === 'dicom'
? 'bg-emerald-400'
: 'bg-cyan-400';
return (
<div
aria-live="polite"
aria-label="导入进度"
className={cn('mb-6 rounded-lg border px-4 py-3 shadow-lg shadow-black/20', toneClass)}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-medium text-gray-100">
{importProgress.phase === 'done' ? (
<Check size={16} className="text-emerald-300" />
) : importProgress.phase === 'error' ? (
<X size={16} className="text-red-300" />
) : (
<Loader2 size={16} className="animate-spin text-cyan-300" />
)}
<span>{importProgress.title}</span>
{importProgress.fileCount && (
<span className="rounded border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] font-mono text-gray-400">
{importProgress.fileCount}
</span>
)}
</div>
<div className="mt-1 truncate text-xs text-gray-400">{importProgress.detail}</div>
</div>
{percent !== undefined && (
<div className="shrink-0 font-mono text-sm text-gray-200">{percent}%</div>
)}
</div>
<div
role="progressbar"
aria-label="导入进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
className="mt-3 h-2 overflow-hidden rounded-full bg-black/35"
>
<div
className={cn(
'h-full rounded-full transition-all duration-200',
barClass,
percent === undefined && 'w-1/3 animate-pulse',
)}
style={percent !== undefined ? { width: `${percent}%` } : undefined}
/>
</div>
</div>
);
};
const frameSequenceLabel = (project: Project) => { const frameSequenceLabel = (project: Project) => {
if (project.source_type === 'dicom') return 'DICOM'; if (project.source_type === 'dicom') return 'DICOM';
if (project.video_path && (project.frames ?? 0) === 0 && project.status !== 'parsing') return '待生成帧'; if (project.video_path && (project.frames ?? 0) === 0 && project.status !== 'parsing') return '待生成帧';
@@ -50,6 +172,14 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
return project.fps || '30FPS'; return project.fps || '30FPS';
}; };
const canGenerateFrames = (project: Project) => (
project.source_type !== 'dicom'
&& Boolean(project.video_path)
&& (project.frames ?? 0) === 0
&& project.status !== 'parsing'
&& editingProjectId !== project.id
);
useEffect(() => { useEffect(() => {
setIsLoading(true); setIsLoading(true);
getProjects() getProjects()
@@ -79,12 +209,15 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
onProjectSelect(); onProjectSelect();
}; };
const handleDeleteProject = async (project: Project, event: React.MouseEvent) => { const openDeleteProject = (project: Project, event: React.MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
if (deletingProjectId) return; if (deletingProjectId) return;
const confirmed = window.confirm(`确认删除项目“${project.name}”?\n该操作会删除项目帧、标注、任务记录和相关 mask 元数据,无法撤销。`); setDeleteProjectTarget(project);
if (!confirmed) return; };
const handleDeleteProject = async () => {
const project = deleteProjectTarget;
if (!project || deletingProjectId) return;
setDeletingProjectId(project.id); setDeletingProjectId(project.id);
try { try {
await deleteProject(project.id); await deleteProject(project.id);
@@ -95,6 +228,7 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
setMasks([]); setMasks([]);
setSelectedMaskIds([]); setSelectedMaskIds([]);
} }
setDeleteProjectTarget(null);
} catch (err) { } catch (err) {
console.error('Delete project failed:', err); console.error('Delete project failed:', err);
showNotice('删除项目失败,请检查后端服务', 'error'); showNotice('删除项目失败,请检查后端服务', 'error');
@@ -103,6 +237,73 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
} }
}; };
const openCopyProject = (project: Project, event: React.MouseEvent) => {
event.stopPropagation();
setCopyProjectTarget(project);
};
const handleCopyProject = async (mode: 'reset' | 'full') => {
if (!copyProjectTarget || copyingProjectId) return;
setCopyingProjectId(copyProjectTarget.id);
try {
const copied = await copyProject(copyProjectTarget.id, { mode });
const data = await getProjects();
setProjects(data);
setCopyProjectTarget(null);
showNotice(mode === 'full'
? `已全内容复制项目:${copied.name}`
: `已复制为重置项目:${copied.name}`, 'success');
} catch (err) {
console.error('Copy project failed:', err);
showNotice('复制项目失败,请检查后端服务', 'error');
} finally {
setCopyingProjectId(null);
}
};
const beginRenameProject = (project: Project, event: React.MouseEvent) => {
event.stopPropagation();
setEditingProjectId(project.id);
setEditingProjectName(project.name);
};
const cancelRenameProject = (event: React.MouseEvent) => {
event.stopPropagation();
setEditingProjectId(null);
setEditingProjectName('');
};
const commitRenameProject = async (project: Project, event?: React.SyntheticEvent) => {
event?.preventDefault();
event?.stopPropagation();
const nextName = editingProjectName.trim();
if (!nextName) {
showNotice('项目名称不能为空', 'error');
return;
}
if (nextName === project.name) {
setEditingProjectId(null);
setEditingProjectName('');
return;
}
setRenamingProjectId(project.id);
try {
const updated = await updateProject(project.id, { name: nextName });
setProjects(projects.map((item) => (item.id === updated.id ? updated : item)));
if (currentProject?.id === updated.id) {
setCurrentProject(updated);
}
setEditingProjectId(null);
setEditingProjectName('');
showNotice('项目名称已更新', 'success');
} catch (err) {
console.error('Rename project failed:', err);
showNotice('项目名称修改失败,请检查后端服务', 'error');
} finally {
setRenamingProjectId(null);
}
};
const handleVideoSelect = (file: File) => { const handleVideoSelect = (file: File) => {
setPendingFile(file); setPendingFile(file);
setShowVideoConfig(true); setShowVideoConfig(true);
@@ -112,17 +313,54 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
if (!pendingFile) return; if (!pendingFile) return;
setShowVideoConfig(false); setShowVideoConfig(false);
setIsLoading(true); setIsLoading(true);
setImportProgress({
kind: 'video',
phase: 'preparing',
title: '正在准备视频导入',
detail: `创建项目:${pendingFile.name}`,
percent: 2,
});
try { try {
const newProject = await createProject({ const newProject = await createProject({
name: pendingFile.name, name: pendingFile.name,
description: `导入于 ${new Date().toLocaleString()}`, description: `导入于 ${new Date().toLocaleString()}`,
}); });
const result = await uploadMedia(pendingFile, String(newProject.id)); setImportProgress({
kind: 'video',
phase: 'uploading',
title: '正在上传视频文件',
detail: pendingFile.name,
percent: 5,
});
const result = await uploadMedia(pendingFile, String(newProject.id), {
onProgress: (progress) => setImportProgress({
kind: 'video',
phase: 'uploading',
title: '正在上传视频文件',
detail: uploadProgressDetail(progress, pendingFile.name),
percent: progress.percent,
}),
});
setImportProgress({
kind: 'video',
phase: 'done',
title: '视频导入完成',
detail: pendingFile.name,
percent: 100,
});
showNotice(`视频导入成功: ${pendingFile.name}\n已保存至: ${result.url}\n需要生成帧时请在项目卡片点击“生成帧”。`, 'success'); showNotice(`视频导入成功: ${pendingFile.name}\n已保存至: ${result.url}\n需要生成帧时请在项目卡片点击“生成帧”。`, 'success');
const data = await getProjects(); const data = await getProjects();
setProjects(data); setProjects(data);
scheduleProgressDismiss();
} catch (err) { } catch (err) {
console.error('Upload failed:', err); console.error('Upload failed:', err);
setImportProgress({
kind: 'video',
phase: 'error',
title: '视频导入失败',
detail: pendingFile.name,
percent: 100,
});
showNotice('上传失败,请检查后端服务', 'error'); showNotice('上传失败,请检查后端服务', 'error');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -158,20 +396,90 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
const handleDicomUpload = async (files: FileList | null) => { const handleDicomUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
const dcmFiles = Array.from(files).filter((f) => f.name.toLowerCase().endsWith('.dcm')); const dcmFiles = Array.from(files)
.filter((f) => f.name.toLowerCase().endsWith('.dcm'))
.sort(naturalFilenameCompare);
if (dcmFiles.length === 0) { if (dcmFiles.length === 0) {
showNotice('未选择有效的 .dcm 文件', 'error'); showNotice('未选择有效的 .dcm 文件', 'error');
return; return;
} }
setIsLoading(true); setIsLoading(true);
setImportProgress({
kind: 'dicom',
phase: 'uploading',
title: '正在上传 DICOM 序列',
detail: `${dcmFiles.length} 个文件,按文件名自然顺序上传`,
percent: 0,
fileCount: dcmFiles.length,
});
try { try {
const result = await uploadDicomBatch(dcmFiles); const result = await uploadDicomBatch(dcmFiles, undefined, {
await parseMedia(String(result.project_id)); onProgress: (progress) => setImportProgress({
showNotice(`DICOM 上传成功: ${result.uploaded_count} 个文件`, 'success'); kind: 'dicom',
phase: 'uploading',
title: '正在上传 DICOM 序列',
detail: uploadProgressDetail(progress, `${dcmFiles.length} 个文件`),
percent: progress.percent,
fileCount: dcmFiles.length,
}),
});
setImportProgress({
kind: 'dicom',
phase: 'queueing',
title: 'DICOM 上传完成,正在创建解析任务',
detail: `${result.uploaded_count} 个文件已上传`,
percent: 92,
fileCount: result.uploaded_count,
});
const task = await parseMedia(String(result.project_id));
setImportProgress({
kind: 'dicom',
phase: 'parsing',
title: '正在解析 DICOM 序列',
detail: task.message || '解析任务已入队',
percent: task.progress ?? 0,
fileCount: result.uploaded_count,
});
if (task.id) {
const completed = await waitForTaskDone(task.id, (progress) => {
setImportProgress({
kind: 'dicom',
phase: 'parsing',
title: '正在解析 DICOM 序列',
detail: progress.message || `任务状态: ${progress.status || 'running'}`,
percent: progress.progress,
fileCount: result.uploaded_count,
});
});
if (completed.status === 'failed') {
throw new Error(completed.error || completed.message || 'DICOM 解析失败');
}
if (completed.status === 'cancelled') {
throw new Error('DICOM 解析任务已取消');
}
}
setImportProgress({
kind: 'dicom',
phase: 'done',
title: 'DICOM 导入完成',
detail: `${result.uploaded_count} 个文件已上传并完成解析`,
percent: 100,
fileCount: result.uploaded_count,
});
showNotice(`DICOM 导入完成: ${result.uploaded_count} 个文件`, 'success');
const data = await getProjects(); const data = await getProjects();
setProjects(data); setProjects(data);
scheduleProgressDismiss();
} catch (err) { } catch (err) {
console.error('DICOM upload failed:', err); console.error('DICOM upload failed:', err);
setImportProgress({
kind: 'dicom',
phase: 'error',
title: 'DICOM 导入失败',
detail: `${dcmFiles.length} 个文件`,
percent: 100,
fileCount: dcmFiles.length,
});
showNotice('DICOM 上传失败,请检查后端服务', 'error'); showNotice('DICOM 上传失败,请检查后端服务', 'error');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -253,6 +561,8 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
</div> </div>
</div> </div>
<ImportProgressPanel />
{isLoading && projects.length === 0 ? ( {isLoading && projects.length === 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
@@ -297,17 +607,75 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
</div> </div>
<div className="p-4 flex flex-col gap-1"> <div className="p-4 flex flex-col gap-1">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<h3 className="text-sm font-medium text-gray-200 truncate pr-4" title={proj.name}>{proj.name}</h3> {editingProjectId === proj.id ? (
<button <form
type="button" className="flex min-w-0 flex-1 items-center gap-1 pr-2"
aria-label={`删除项目 ${proj.name}`} onClick={(event) => event.stopPropagation()}
title="删除项目" onSubmit={(event) => void commitRenameProject(proj, event)}
disabled={deletingProjectId === proj.id} >
onClick={(event) => handleDeleteProject(proj, event)} <input
className="text-gray-500 hover:text-red-400 disabled:opacity-50 disabled:cursor-wait transition-colors" value={editingProjectName}
> onChange={(event) => setEditingProjectName(event.target.value)}
{deletingProjectId === proj.id ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />} autoFocus
</button> className="min-w-0 flex-1 rounded border border-cyan-400/40 bg-black/30 px-2 py-1 text-sm text-gray-100 outline-none focus:border-cyan-300"
/>
<button
type="button"
aria-label={`保存项目名称 ${proj.name}`}
title="保存名称"
disabled={renamingProjectId === proj.id}
onClick={(event) => void commitRenameProject(proj, event)}
className="text-cyan-300 hover:text-cyan-100 disabled:cursor-wait disabled:opacity-50"
>
{renamingProjectId === proj.id ? <Loader2 size={15} className="animate-spin" /> : <Check size={15} />}
</button>
<button
type="button"
aria-label={`取消修改项目名称 ${proj.name}`}
title="取消"
onClick={cancelRenameProject}
disabled={renamingProjectId === proj.id}
className="text-gray-500 hover:text-gray-200 disabled:opacity-50"
>
<X size={15} />
</button>
</form>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2 pr-2">
<h3 className="truncate text-sm font-medium text-gray-200" title={proj.name}>{proj.name}</h3>
<button
type="button"
aria-label={`修改项目名称 ${proj.name}`}
title="修改项目名称"
onClick={(event) => beginRenameProject(proj, event)}
className="shrink-0 text-gray-500 opacity-0 transition-colors hover:text-cyan-300 group-hover:opacity-100 focus:opacity-100"
>
<Pencil size={14} />
</button>
</div>
)}
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label={`复制项目 ${proj.name}`}
title="复制项目"
disabled={copyingProjectId === proj.id || deletingProjectId === proj.id || renamingProjectId === proj.id}
onClick={(event) => openCopyProject(proj, event)}
className="text-gray-500 hover:text-emerald-400 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{copyingProjectId === proj.id ? <Loader2 size={16} className="animate-spin" /> : <Copy size={16} />}
</button>
<button
type="button"
aria-label={`删除项目 ${proj.name}`}
title="删除项目"
disabled={deletingProjectId === proj.id || renamingProjectId === proj.id}
onClick={(event) => openDeleteProject(proj, event)}
className="text-gray-500 hover:text-red-400 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{deletingProjectId === proj.id ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
</button>
</div>
</div> </div>
<div className="flex items-center gap-4 text-xs text-gray-500 font-mono mt-2"> <div className="flex items-center gap-4 text-xs text-gray-500 font-mono mt-2">
<span className="flex items-center gap-1.5"><Settings2 size={12} /> {proj.frames ?? 0} </span> <span className="flex items-center gap-1.5"><Settings2 size={12} /> {proj.frames ?? 0} </span>
@@ -315,7 +683,7 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
<span className="flex items-center gap-1.5 text-cyan-400/80"><Activity size={12} /> {proj.original_fps.toFixed(1)}fps</span> <span className="flex items-center gap-1.5 text-cyan-400/80"><Activity size={12} /> {proj.original_fps.toFixed(1)}fps</span>
)} )}
</div> </div>
{proj.video_path && (proj.frames ?? 0) === 0 && proj.status !== 'parsing' && ( {canGenerateFrames(proj) && (
<button <button
onClick={(event) => openFrameConfig(proj, event)} onClick={(event) => openFrameConfig(proj, event)}
className="mt-3 inline-flex items-center justify-center gap-2 rounded-md border border-cyan-500/30 bg-cyan-500/10 px-3 py-2 text-xs font-medium text-cyan-200 hover:bg-cyan-500/20 transition-colors" className="mt-3 inline-flex items-center justify-center gap-2 rounded-md border border-cyan-500/30 bg-cyan-500/10 px-3 py-2 text-xs font-medium text-cyan-200 hover:bg-cyan-500/20 transition-colors"
@@ -330,6 +698,92 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
</div> </div>
)} )}
{/* Delete project confirmation */}
{deleteProjectTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div
className="w-full max-w-md rounded-2xl border border-red-500/20 bg-[#111] p-6 shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-3 text-sm leading-6 text-gray-400">
<span className="text-gray-100">{deleteProjectTarget.name}</span>
mask
</p>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => setDeleteProjectTarget(null)}
disabled={deletingProjectId === deleteProjectTarget.id}
className="rounded-lg px-4 py-2 text-sm text-gray-400 transition-colors hover:text-white disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => void handleDeleteProject()}
disabled={deletingProjectId === deleteProjectTarget.id}
className="inline-flex items-center gap-2 rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-400 disabled:cursor-wait disabled:opacity-60"
>
{deletingProjectId === deleteProjectTarget.id && <Loader2 size={14} className="animate-spin" />}
</button>
</div>
</div>
</div>
)}
{/* Copy project modal */}
{copyProjectTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div
className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<h2 className="text-lg font-semibold text-white mb-2"></h2>
<p className="text-sm text-gray-400 mb-5">
{copyProjectTarget.name}
</p>
<div className="space-y-3">
<button
type="button"
onClick={() => void handleCopyProject('reset')}
disabled={copyingProjectId === copyProjectTarget.id}
className="w-full rounded-lg border border-cyan-500/25 bg-cyan-500/10 px-4 py-3 text-left transition-colors hover:bg-cyan-500/20 disabled:cursor-wait disabled:opacity-60"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-cyan-100"></span>
{copyingProjectId === copyProjectTarget.id && <Loader2 size={16} className="animate-spin text-cyan-200" />}
</div>
<p className="mt-1 text-xs leading-5 text-gray-500"> mask </p>
</button>
<button
type="button"
onClick={() => void handleCopyProject('full')}
disabled={copyingProjectId === copyProjectTarget.id}
className="w-full rounded-lg border border-emerald-500/25 bg-emerald-500/10 px-4 py-3 text-left transition-colors hover:bg-emerald-500/20 disabled:cursor-wait disabled:opacity-60"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-emerald-100"></span>
{copyingProjectId === copyProjectTarget.id && <Loader2 size={16} className="animate-spin text-emerald-200" />}
</div>
<p className="mt-1 text-xs leading-5 text-gray-500"> mask </p>
</button>
</div>
<div className="flex justify-end mt-5">
<button
type="button"
onClick={() => setCopyProjectTarget(null)}
disabled={copyingProjectId === copyProjectTarget.id}
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
{/* Video parse FPS config modal */} {/* Video parse FPS config modal */}
{showVideoConfig && pendingFile && ( {showVideoConfig && pendingFile && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">

View File

@@ -19,6 +19,16 @@ vi.mock('../lib/api', () => ({
})); }));
describe('TemplateRegistry', () => { describe('TemplateRegistry', () => {
const makeDataTransfer = () => {
const store = new Map<string, string>();
return {
effectAllowed: '',
dropEffect: '',
setData: vi.fn((key: string, value: string) => store.set(key, value)),
getData: vi.fn((key: string) => store.get(key) || ''),
};
};
beforeEach(() => { beforeEach(() => {
resetStore(); resetStore();
vi.clearAllMocks(); vi.clearAllMocks();
@@ -40,6 +50,8 @@ describe('TemplateRegistry', () => {
expect(await screen.findAllByText('腹腔镜胆囊切除术')).toHaveLength(2); expect(await screen.findAllByText('腹腔镜胆囊切除术')).toHaveLength(2);
expect(screen.getByText('胆囊')).toBeInTheDocument(); expect(screen.getByText('胆囊')).toBeInTheDocument();
expect(screen.getAllByText(/maskid: ?1/).length).toBeGreaterThan(0); expect(screen.getAllByText(/maskid: ?1/).length).toBeGreaterThan(0);
expect(screen.getByText('待分类')).toBeInTheDocument();
expect(screen.getAllByText(/maskid: ?0/).length).toBeGreaterThan(0);
expect(screen.queryByText(/Z-Level/)).not.toBeInTheDocument(); expect(screen.queryByText(/Z-Level/)).not.toBeInTheDocument();
}); });
@@ -49,7 +61,7 @@ describe('TemplateRegistry', () => {
id: 't2', id: 't2',
name: 'New Template', name: 'New Template',
description: 'desc', description: 'desc',
classes: [], classes: [expect.objectContaining({ name: '待分类', maskId: 0, color: '#000000' })],
rules: [], rules: [],
}); });
@@ -62,7 +74,15 @@ describe('TemplateRegistry', () => {
await waitFor(() => expect(apiMock.createTemplate).toHaveBeenCalledWith(expect.objectContaining({ await waitFor(() => expect(apiMock.createTemplate).toHaveBeenCalledWith(expect.objectContaining({
name: 'New Template', name: 'New Template',
description: 'desc', description: 'desc',
classes: [], classes: [
expect.objectContaining({
id: 'reserved-unclassified',
name: '待分类',
color: '#000000',
zIndex: 0,
maskId: 0,
}),
],
rules: [], rules: [],
color: '#06b6d4', color: '#06b6d4',
z_index: 0, z_index: 0,
@@ -77,15 +97,17 @@ describe('TemplateRegistry', () => {
fireEvent.click(screen.getByText('新建方案')); fireEvent.click(screen.getByText('新建方案'));
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: 'With Classes' } }); fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: 'With Classes' } });
fireEvent.click(screen.getByText('批量导入')); fireEvent.click(screen.getByText('批量导入'));
expect(screen.queryByText('📋 载入腹腔镜胆囊切除术模板')).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), { fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
target: { value: '{"colors":[[255,0,0]],"names":["分类A"]}' }, target: { value: '{"colors":[[255,0,0]],"names":["分类A"]}' },
}); });
expect(screen.getByText(/将导入 1 个分类maskid 从 1 开始分配/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '导入' })); fireEvent.click(screen.getByRole('button', { name: '导入' }));
expect(screen.getByText('分类A')).toBeInTheDocument(); expect(screen.getByText('分类A')).toBeInTheDocument();
}); });
it('shows JSON import errors as transient notices instead of blocking alerts', async () => { it('shows JSON import errors inline instead of blocking alerts', async () => {
apiMock.getTemplates.mockResolvedValueOnce([]); apiMock.getTemplates.mockResolvedValueOnce([]);
render(<TemplateRegistry />); render(<TemplateRegistry />);
@@ -94,9 +116,9 @@ describe('TemplateRegistry', () => {
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), { fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
target: { value: '{broken-json' }, target: { value: '{broken-json' },
}); });
fireEvent.click(screen.getByRole('button', { name: '导入' }));
expect(await screen.findByRole('status')).toHaveTextContent('JSON 解析失败'); expect(screen.getByText('JSON 解析失败')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '导入' })).toBeDisabled();
}); });
it('shows template save errors as transient notices', async () => { it('shows template save errors as transient notices', async () => {
@@ -132,7 +154,7 @@ describe('TemplateRegistry', () => {
}); });
render(<TemplateRegistry />); render(<TemplateRegistry />);
fireEvent.click(await screen.findByRole('button', { name: /修改库视图结构/ })); fireEvent.click(await screen.findByRole('button', { name: '编辑模板 旧模板' }));
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: '新模板' } }); fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: '新模板' } });
fireEvent.change(screen.getAllByRole('textbox')[1], { target: { value: 'new desc' } }); fireEvent.change(screen.getAllByRole('textbox')[1], { target: { value: 'new desc' } });
fireEvent.click(screen.getByRole('button', { name: '保存' })); fireEvent.click(screen.getByRole('button', { name: '保存' }));
@@ -140,7 +162,10 @@ describe('TemplateRegistry', () => {
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({ await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
name: '新模板', name: '新模板',
description: 'new desc', description: 'new desc',
classes: [expect.objectContaining({ id: 'c1', name: '胆囊' })], classes: [
expect.objectContaining({ id: 'c1', name: '胆囊' }),
expect.objectContaining({ name: '待分类', maskId: 0 }),
],
rules: [], rules: [],
color: '#06b6d4', color: '#06b6d4',
z_index: 3, z_index: 3,
@@ -149,6 +174,205 @@ describe('TemplateRegistry', () => {
id: 't1', id: 't1',
name: '新模板', name: '新模板',
})); }));
expect(await screen.findAllByText('新模板')).toHaveLength(2);
});
it('shows the semantic tree title and opens the add-class modal from the detail view', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '模板',
description: 'desc',
classes: [{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' }],
rules: [],
color: '#06b6d4',
z_index: 3,
},
]);
apiMock.updateTemplate.mockResolvedValueOnce({
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
{ id: 'new-class', name: '新类别', color: '#00ff00', zIndex: 20, maskId: 2, category: '未分类' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
});
render(<TemplateRegistry />);
expect(await screen.findByText('语义分类树(拖拽调层级)')).toBeInTheDocument();
expect(screen.queryByText(/Painter's Algorithm Weight/)).not.toBeInTheDocument();
expect(screen.queryByText('器官')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /新建分类/ }));
expect(screen.getByDisplayValue('新类别')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
classes: [
expect.objectContaining({ id: 'c1', name: '胆囊', maskId: 1 }),
expect.objectContaining({ name: '新类别', maskId: 2, category: '未分类' }),
expect.objectContaining({ name: '待分类', maskId: 0, color: '#000000' }),
],
})));
});
it('deletes a class directly from the semantic tree', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 20, maskId: 2, category: '器官' },
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
},
]);
apiMock.updateTemplate.mockResolvedValueOnce({
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
{ id: 'reserved-unclassified', name: '待分类', color: '#000000', zIndex: 0, maskId: 0, category: '系统保留' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
});
render(<TemplateRegistry />);
fireEvent.click(await screen.findByRole('button', { name: '删除分类 肝脏' }));
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
classes: [
expect.objectContaining({ id: 'c1', name: '胆囊', zIndex: 10, maskId: 1 }),
expect.objectContaining({ name: '待分类', zIndex: 0, maskId: 0 }),
],
color: '#06b6d4',
z_index: 3,
})));
await waitFor(() => expect(useStore.getState().templates[0].classes).toEqual([
expect.objectContaining({ id: 'c1', name: '胆囊' }),
expect.objectContaining({ name: '待分类', maskId: 0 }),
]));
expect(await screen.findByRole('status')).toHaveTextContent('分类已删除');
});
it('copies a template from the active template list into a new editable template', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '头颈部CT分割',
description: 'desc',
classes: [
{ id: 'c1', name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
{ id: 'c2', name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' },
],
rules: [{ id: 'r1', name: 'rule', sourceKey: 'a', targetKey: 'b', operation: 'copy' }],
color: '#ef4444',
z_index: 10,
},
{
id: 't2',
name: '头颈部CT分割 副本',
description: 'existing copy',
classes: [],
rules: [],
},
]);
apiMock.createTemplate.mockResolvedValueOnce({
id: 't3',
name: '头颈部CT分割 副本 2',
description: 'desc',
classes: [
{ id: 'copy-c1', name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
{ id: 'copy-c2', name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' },
],
rules: [{ id: 'r1', name: 'rule', sourceKey: 'a', targetKey: 'b', operation: 'copy' }],
color: '#ef4444',
z_index: 10,
});
render(<TemplateRegistry />);
fireEvent.click(await screen.findByRole('button', { name: '复制模板 头颈部CT分割' }));
await waitFor(() => expect(apiMock.createTemplate).toHaveBeenCalledWith(expect.objectContaining({
name: '头颈部CT分割 副本 2',
description: 'desc',
color: '#ef4444',
z_index: 10,
rules: [expect.objectContaining({ id: 'r1' })],
classes: [
expect.objectContaining({ name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' }),
expect.objectContaining({ name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' }),
expect.objectContaining({ name: '待分类', color: '#000000', zIndex: 0, maskId: 0 }),
],
})));
const payload = apiMock.createTemplate.mock.calls[0][0];
expect(payload.classes[0].id).toMatch(/^cls-copy-/);
expect(payload.classes[0].id).not.toBe('c1');
expect(useStore.getState().templates.some((template) => template.id === 't3')).toBe(true);
expect(await screen.findByText('已复制模板头颈部CT分割 副本 2')).toBeInTheDocument();
});
it('persists dragged class layer order directly from the template detail view', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 20, maskId: 2, category: '器官' },
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
},
]);
apiMock.updateTemplate.mockResolvedValueOnce({
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 10, maskId: 2, category: '器官' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
});
render(<TemplateRegistry />);
const gallbladderRow = (await screen.findByText('胆囊')).closest('[draggable="true"]') as HTMLElement;
const liverRow = screen.getByText('肝脏').closest('[draggable="true"]') as HTMLElement;
const dataTransfer = makeDataTransfer();
fireEvent.dragStart(gallbladderRow, { dataTransfer });
fireEvent.dragOver(liverRow, { dataTransfer });
fireEvent.drop(liverRow, { dataTransfer });
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
classes: [
expect.objectContaining({ id: 'c1', zIndex: 20, maskId: 1 }),
expect.objectContaining({ id: 'c2', zIndex: 10, maskId: 2 }),
expect.objectContaining({ name: '待分类', zIndex: 0, maskId: 0 }),
],
color: '#06b6d4',
z_index: 3,
})));
await waitFor(() => expect(useStore.getState().templates[0].classes[0]).toEqual(
expect.objectContaining({ id: 'c1', zIndex: 20 }),
));
}); });
it('deletes an existing template after confirmation', async () => { it('deletes an existing template after confirmation', async () => {
@@ -165,8 +389,8 @@ describe('TemplateRegistry', () => {
const { container } = render(<TemplateRegistry />); const { container } = render(<TemplateRegistry />);
await screen.findAllByText('待删除模板'); await screen.findAllByText('待删除模板');
const buttons = Array.from(container.querySelectorAll('button')); fireEvent.click(container.querySelector('button[title="删除模板"]') as HTMLElement);
fireEvent.click(buttons[2]); fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
await waitFor(() => expect(apiMock.deleteTemplate).toHaveBeenCalledWith('t1')); await waitFor(() => expect(apiMock.deleteTemplate).toHaveBeenCalledWith('t1'));
expect(useStore.getState().templates).toEqual([]); expect(useStore.getState().templates).toEqual([]);

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Settings, Database, Trash2, Edit3, Plus, Loader2, X, GripVertical, Import } from 'lucide-react'; import { Database, Trash2, Edit3, Plus, Loader2, X, GripVertical, Import, Copy } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { useStore } from '../store/useStore'; import { useStore } from '../store/useStore';
import { getTemplates, createTemplate, updateTemplate, deleteTemplate } from '../lib/api'; import { getTemplates, createTemplate, updateTemplate, deleteTemplate } from '../lib/api';
import { nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds'; import { RESERVED_UNCLASSIFIED_CLASS, isReservedUnclassifiedClass, nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
import type { Template, TemplateClass } from '../store/useStore'; import type { Template, TemplateClass } from '../store/useStore';
import { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice'; import { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice';
@@ -24,35 +24,21 @@ function generateColor(index: number, total: number): string {
return hslToHex(hue, 75, 55); return hslToHex(hue, 75, 55);
} }
const LAPAROSCOPIC_COLORS = [
[134, 124, 118], [0, 157, 142], [245, 161, 0], [255, 172, 159], [146, 175, 236], [155, 62, 0],
[255, 91, 0], [255, 234, 0], [85, 111, 181], [155, 132, 0], [181, 227, 14], [72, 0, 255],
[255, 0, 255], [29, 32, 136], [240, 16, 116], [160, 15, 95], [0, 155, 33], [0, 160, 233],
[52, 184, 178], [66, 115, 82], [90, 120, 41], [255, 0, 0], [117, 0, 0], [167, 24, 233],
[42, 8, 66], [112, 113, 150], [0, 255, 0], [255, 255, 255], [0, 255, 255], [181, 85, 105],
[113, 102, 140], [202, 202, 200], [197, 83, 181], [136, 162, 196], [138, 251, 213],
];
const LAPAROSCOPIC_NAMES = [
'针', '线', '肿瘤', '血管阻断夹', '棉球', '双极电凝',
'肝脏', '胆囊', '分离钳', '脂肪', '止血海绵', '肝总管',
'吸引器', '剪刀', '超声刀', '止血纱布', '胆总管', '生物夹',
'无损伤钳', '钳夹', '喷洒', '胆囊管', '动脉', '电凝',
'静脉', '标本袋', '引流管', '纱布', '金属钛夹', '韧带',
'肝蒂', '推结器', '乳胶管-血管阻断', '吻合器', '术中超声',
];
export function TemplateRegistry() { export function TemplateRegistry() {
const templates = useStore((state) => state.templates); const templates = useStore((state) => state.templates);
const setTemplates = useStore((state) => state.setTemplates); const setTemplates = useStore((state) => state.setTemplates);
const addTemplate = useStore((state) => state.addTemplate); const addTemplate = useStore((state) => state.addTemplate);
const updateTemplateStore = useStore((state) => state.updateTemplate); const updateTemplateStore = useStore((state) => state.updateTemplate);
const removeTemplateStore = useStore((state) => state.removeTemplate); const removeTemplateStore = useStore((state) => state.removeTemplate);
const setMasks = useStore((state) => state.setMasks);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null); const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isSavingOrder, setIsSavingOrder] = useState(false);
const [copyingTemplateId, setCopyingTemplateId] = useState<string | null>(null);
const [deleteTemplateTarget, setDeleteTemplateTarget] = useState<Template | null>(null);
const [showImport, setShowImport] = useState(false); const [showImport, setShowImport] = useState(false);
const [importText, setImportText] = useState(''); const [importText, setImportText] = useState('');
@@ -61,6 +47,8 @@ export function TemplateRegistry() {
const [editClasses, setEditClasses] = useState<TemplateClass[]>([]); const [editClasses, setEditClasses] = useState<TemplateClass[]>([]);
const [editingClassId, setEditingClassId] = useState<string | null>(null); const [editingClassId, setEditingClassId] = useState<string | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [detailDragClassId, setDetailDragClassId] = useState<string | null>(null);
const [detailDragOverClassId, setDetailDragOverClassId] = useState<string | null>(null);
const [notice, setNotice] = useState<NoticeState | null>(null); const [notice, setNotice] = useState<NoticeState | null>(null);
const showNotice = (message: string, tone: NoticeTone = 'info') => { const showNotice = (message: string, tone: NoticeTone = 'info') => {
@@ -79,7 +67,7 @@ export function TemplateRegistry() {
setSelectedTemplate(null); setSelectedTemplate(null);
setEditName(''); setEditName('');
setEditDesc(''); setEditDesc('');
setEditClasses([]); setEditClasses(normalizeClassMaskIds([]));
setShowModal(true); setShowModal(true);
}; };
@@ -91,6 +79,76 @@ export function TemplateRegistry() {
setShowModal(true); setShowModal(true);
}; };
const buildNewClass = (classes: TemplateClass[]): TemplateClass => ({
id: `cls-${Date.now()}`,
name: '新类别',
color: generateColor(classes.length, Math.max(classes.length + 1, 8)),
zIndex: classes.length > 0 ? Math.max(...classes.map((c) => c.zIndex)) + 10 : 10,
maskId: nextClassMaskId(classes),
category: '未分类',
});
const openAddClass = (template: Template) => {
const classes = normalizeClassMaskIds(template.classes ? [...template.classes] : []);
const newClass = buildNewClass(classes);
setSelectedTemplate(template);
setEditName(template.name);
setEditDesc(template.description || '');
setEditClasses([...classes, newClass]);
setEditingClassId(newClass.id);
setShowModal(true);
};
const buildTemplatePayload = (template: Template | null, classes: TemplateClass[]) => ({
name: template ? template.name : editName.trim(),
description: template ? template.description || undefined : editDesc.trim() || undefined,
classes: normalizeClassMaskIds(classes),
rules: template ? template.rules || [] : [],
color: template ? template.color || '#06b6d4' : '#06b6d4',
z_index: template ? template.z_index ?? 0 : 0,
});
const nextCopyName = (name: string) => {
const baseName = `${name} 副本`;
const existingNames = new Set(templates.map((template) => template.name));
if (!existingNames.has(baseName)) return baseName;
let suffix = 2;
while (existingNames.has(`${baseName} ${suffix}`)) {
suffix += 1;
}
return `${baseName} ${suffix}`;
};
const copyTemplateClasses = (template: Template) => {
const timestamp = Date.now();
return normalizeClassMaskIds(template.classes || []).map((templateClass, index) => ({
...templateClass,
id: isReservedUnclassifiedClass(templateClass) ? RESERVED_UNCLASSIFIED_CLASS.id : `cls-copy-${timestamp}-${index}`,
}));
};
const recalculateClassOrder = (classes: TemplateClass[]) => (
normalizeClassMaskIds(classes)
.filter((templateClass) => !isReservedUnclassifiedClass(templateClass))
.map((templateClass, index, activeClasses) => ({ ...templateClass, zIndex: (activeClasses.length - index) * 10 }))
.concat(normalizeClassMaskIds(classes).filter(isReservedUnclassifiedClass))
);
const syncMaskClassOrder = (classes: TemplateClass[]) => {
const zIndexByClassId = new Map(classes.map((templateClass) => [templateClass.id, templateClass.zIndex]));
setMasks(useStore.getState().masks.map((mask) => (
mask.classId && zIndexByClassId.has(mask.classId)
? {
...mask,
classZIndex: zIndexByClassId.get(mask.classId),
saveStatus: mask.annotationId ? 'dirty' as const : 'draft' as const,
saved: mask.annotationId ? false : mask.saved,
}
: mask
)));
};
const handleSave = async () => { const handleSave = async () => {
if (!editName.trim()) return; if (!editName.trim()) return;
setIsSaving(true); setIsSaving(true);
@@ -100,15 +158,18 @@ export function TemplateRegistry() {
description: editDesc.trim() || undefined, description: editDesc.trim() || undefined,
classes: normalizeClassMaskIds(editClasses), classes: normalizeClassMaskIds(editClasses),
rules: [], rules: [],
color: selectedTemplate ? (selectedTemplate as any).color || '#06b6d4' : '#06b6d4', color: selectedTemplate ? selectedTemplate.color || '#06b6d4' : '#06b6d4',
z_index: selectedTemplate ? (selectedTemplate as any).z_index ?? 0 : 0, z_index: selectedTemplate ? selectedTemplate.z_index ?? 0 : 0,
}; };
if (selectedTemplate) { if (selectedTemplate) {
const updated = await updateTemplate(selectedTemplate.id, basePayload); const updated = await updateTemplate(selectedTemplate.id, basePayload);
updateTemplateStore(updated); updateTemplateStore(updated);
setSelectedTemplate(updated);
syncMaskClassOrder(normalizeClassMaskIds(updated.classes || []));
} else { } else {
const created = await createTemplate(basePayload); const created = await createTemplate(basePayload);
addTemplate(created); addTemplate(created);
setSelectedTemplate(created);
} }
setShowModal(false); setShowModal(false);
} catch (err) { } catch (err) {
@@ -119,14 +180,38 @@ export function TemplateRegistry() {
} }
}; };
const handleDelete = async (id: string) => { const handleCopy = async (template: Template) => {
if (!confirm('确定要删除此模板吗?')) return; setCopyingTemplateId(template.id);
try { try {
await deleteTemplate(id); const created = await createTemplate({
removeTemplateStore(id); name: nextCopyName(template.name),
if (selectedTemplate?.id === id) { description: template.description || undefined,
classes: copyTemplateClasses(template),
rules: template.rules || [],
color: template.color || '#06b6d4',
z_index: template.z_index ?? 0,
});
addTemplate(created);
setSelectedTemplate(created);
showNotice(`已复制模板:${created.name}`, 'success');
} catch (err) {
console.error('Failed to copy template:', err);
showNotice('复制失败,请检查后端服务', 'error');
} finally {
setCopyingTemplateId(null);
}
};
const handleDelete = async () => {
const target = deleteTemplateTarget;
if (!target) return;
try {
await deleteTemplate(target.id);
removeTemplateStore(target.id);
if (selectedTemplate?.id === target.id) {
setSelectedTemplate(null); setSelectedTemplate(null);
} }
setDeleteTemplateTarget(null);
} catch (err) { } catch (err) {
console.error('Failed to delete template:', err); console.error('Failed to delete template:', err);
showNotice('删除失败,请检查后端服务', 'error'); showNotice('删除失败,请检查后端服务', 'error');
@@ -134,15 +219,8 @@ export function TemplateRegistry() {
}; };
const addClass = () => { const addClass = () => {
const newClass: TemplateClass = { const newClass = buildNewClass(editClasses);
id: `cls-${Date.now()}`, setEditClasses(recalculateClassOrder([...editClasses, newClass]));
name: '新类别',
color: generateColor(editClasses.length, Math.max(editClasses.length + 1, 8)),
zIndex: editClasses.length > 0 ? Math.max(...editClasses.map((c) => c.zIndex)) + 10 : 10,
maskId: nextClassMaskId(editClasses),
category: '未分类',
};
setEditClasses([...editClasses, newClass]);
setEditingClassId(newClass.id); setEditingClassId(newClass.id);
}; };
@@ -151,76 +229,141 @@ export function TemplateRegistry() {
}; };
const removeClass = (id: string) => { const removeClass = (id: string) => {
setEditClasses(editClasses.filter((c) => c.id !== id)); setEditClasses(editClasses.filter((c) => c.id !== id || isReservedUnclassifiedClass(c)));
}; };
const reorderClasses = (fromIndex: number, toIndex: number) => { const reorderClasses = (fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex) return; if (fromIndex === toIndex) return;
const items = [...editClasses]; const items = [...editClasses];
if (isReservedUnclassifiedClass(items[fromIndex]) || isReservedUnclassifiedClass(items[toIndex])) return;
const [moved] = items.splice(fromIndex, 1); const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved); items.splice(toIndex, 0, moved);
// Recalculate z-index based on new order (top = highest) setEditClasses(recalculateClassOrder(items));
const recalculated = items.map((c, i) => ({ ...c, zIndex: (items.length - i) * 10 }));
setEditClasses(recalculated);
}; };
const handleImport = () => { const saveDetailClassOrder = async (sourceId: string, targetId: string) => {
if (!activeTemplate || sourceId === targetId || isSavingOrder) return;
const classes = normalizeClassMaskIds(activeTemplate.classes || []).sort((a, b) => b.zIndex - a.zIndex);
if (classes.some((templateClass) => (
(templateClass.id === sourceId || templateClass.id === targetId) && isReservedUnclassifiedClass(templateClass)
))) return;
const sourceIndex = classes.findIndex((templateClass) => templateClass.id === sourceId);
const targetIndex = classes.findIndex((templateClass) => templateClass.id === targetId);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return;
const reordered = [...classes];
const [source] = reordered.splice(sourceIndex, 1);
reordered.splice(targetIndex, 0, source);
const nextClasses = recalculateClassOrder(reordered);
setIsSavingOrder(true);
try { try {
const data = JSON.parse(importText); const updated = await updateTemplate(activeTemplate.id, buildTemplatePayload(activeTemplate, nextClasses));
let colors: number[][] = []; updateTemplateStore(updated);
let names: string[] = []; setSelectedTemplate(updated);
syncMaskClassOrder(nextClasses);
if (Array.isArray(data) && data.length === 2 && Array.isArray(data[0]) && Array.isArray(data[1])) { } catch (err) {
colors = data[0]; console.error('Failed to save template class order:', err);
names = data[1]; showNotice('层级顺序保存失败,请检查后端服务', 'error');
} else if (Array.isArray(data.colors) && Array.isArray(data.names)) { } finally {
colors = data.colors; setIsSavingOrder(false);
names = data.names; setDetailDragClassId(null);
} else { setDetailDragOverClassId(null);
showNotice('格式错误:请提供 [[colors...], [names...]] 或 {colors, names}', 'error');
return;
}
const firstMaskId = nextClassMaskId(editClasses);
const imported: TemplateClass[] = names.map((name: string, i: number) => {
const rgb = colors[i] || [100, 100, 100];
const hex = `#${rgb[0].toString(16).padStart(2, '0')}${rgb[1].toString(16).padStart(2, '0')}${rgb[2].toString(16).padStart(2, '0')}`;
return {
id: `cls-import-${Date.now()}-${i}`,
name,
color: hex,
zIndex: (names.length - i) * 10,
maskId: firstMaskId + i,
category: '批量导入',
};
});
setEditClasses([...editClasses, ...imported]);
setShowImport(false);
setImportText('');
} catch (e) {
showNotice('JSON 解析失败', 'error');
} }
}; };
const loadLaparoscopic = () => { const deleteDetailClass = async (classId: string) => {
const imported: TemplateClass[] = LAPAROSCOPIC_NAMES.map((name, i) => { if (!activeTemplate || isSavingOrder) return;
const rgb = LAPAROSCOPIC_COLORS[i]; const currentClasses = normalizeClassMaskIds(activeTemplate.classes || []);
const hex = `#${rgb[0].toString(16).padStart(2, '0')}${rgb[1].toString(16).padStart(2, '0')}${rgb[2].toString(16).padStart(2, '0')}`; const targetClass = currentClasses.find((templateClass) => templateClass.id === classId);
return { if (!targetClass || isReservedUnclassifiedClass(targetClass)) return;
id: `cls-lap-${Date.now()}-${i}`, const nextClasses = recalculateClassOrder(
name, currentClasses
color: hex, .filter((templateClass) => templateClass.id !== classId)
zIndex: (LAPAROSCOPIC_NAMES.length - i) * 10, .sort((a, b) => b.zIndex - a.zIndex),
maskId: i + 1, );
category: '腹腔镜胆囊切除术', if (nextClasses.length === currentClasses.length) return;
};
}); setIsSavingOrder(true);
setEditClasses(imported); try {
setShowImport(false); const updated = await updateTemplate(activeTemplate.id, buildTemplatePayload(activeTemplate, nextClasses));
updateTemplateStore(updated);
setSelectedTemplate(updated);
syncMaskClassOrder(nextClasses);
showNotice('分类已删除', 'success');
} catch (err) {
console.error('Failed to delete template class:', err);
showNotice('分类删除失败,请检查后端服务', 'error');
} finally {
setIsSavingOrder(false);
}
}; };
const activeTemplate = selectedTemplate || templates[0] || null; const parseImportClasses = () => {
let data: any;
try {
data = JSON.parse(importText);
} catch {
throw new Error('JSON 解析失败');
}
let colors: number[][] = [];
let names: string[] = [];
if (Array.isArray(data) && data.length === 2 && Array.isArray(data[0]) && Array.isArray(data[1])) {
colors = data[0];
names = data[1];
} else if (Array.isArray(data.colors) && Array.isArray(data.names)) {
colors = data.colors;
names = data.names;
} else {
throw new Error('格式错误:请提供 [[colors...], [names...]] 或 {colors, names}');
}
const firstMaskId = nextClassMaskId(editClasses);
const classes: TemplateClass[] = names.map((name: string, i: number) => {
const rgb = colors[i] || [100, 100, 100];
const hex = `#${rgb[0].toString(16).padStart(2, '0')}${rgb[1].toString(16).padStart(2, '0')}${rgb[2].toString(16).padStart(2, '0')}`;
return {
id: `cls-import-${Date.now()}-${i}`,
name,
color: hex,
zIndex: (names.length - i) * 10,
maskId: firstMaskId + i,
category: '批量导入',
};
});
return {
classes,
firstMaskId,
missingColorCount: Math.max(0, names.length - colors.length),
};
};
const importPreview = useMemo(() => {
if (!showImport || !importText.trim()) return null;
try {
const parsed = parseImportClasses();
return { status: 'ready' as const, ...parsed };
} catch (err: any) {
return { status: 'error' as const, message: err?.message || 'JSON 解析失败' };
}
}, [showImport, importText, editClasses]);
const handleImport = () => {
try {
const imported = parseImportClasses();
setEditClasses(recalculateClassOrder([...editClasses, ...imported.classes]));
setShowImport(false);
setImportText('');
} catch (err: any) {
showNotice(err?.message || 'JSON 解析失败', 'error');
}
};
const activeTemplate = selectedTemplate
? templates.find((template) => template.id === selectedTemplate.id) || selectedTemplate
: templates[0] || null;
const activeTemplateClasses = normalizeClassMaskIds(activeTemplate?.classes || []).sort((a, b) => b.zIndex - a.zIndex);
return ( return (
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]"> <div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
@@ -266,13 +409,28 @@ export function TemplateRegistry() {
<h3 className="font-medium text-gray-200 mb-1">{t.name}</h3> <h3 className="font-medium text-gray-200 mb-1">{t.name}</h3>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
title="复制模板"
aria-label={`复制模板 ${t.name}`}
disabled={copyingTemplateId === t.id}
onClick={(e) => { e.stopPropagation(); void handleCopy(t); }}
className={cn(
"p-1 rounded text-gray-500 hover:text-emerald-400 transition-colors disabled:cursor-wait disabled:text-gray-600",
)}
>
{copyingTemplateId === t.id ? <Loader2 size={14} className="animate-spin" /> : <Copy size={14} />}
</button>
<button
title="编辑模板"
aria-label={`编辑模板 ${t.name}`}
onClick={(e) => { e.stopPropagation(); openEdit(t); }} onClick={(e) => { e.stopPropagation(); openEdit(t); }}
className="p-1 rounded text-gray-500 hover:text-cyan-400 transition-colors" className="p-1 rounded text-gray-500 hover:text-cyan-400 transition-colors"
> >
<Edit3 size={14} /> <Edit3 size={14} />
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); handleDelete(t.id); }} title="删除模板"
aria-label={`删除模板 ${t.name}`}
onClick={(e) => { e.stopPropagation(); setDeleteTemplateTarget(t); }}
className="p-1 rounded text-gray-500 hover:text-red-400 transition-colors" className="p-1 rounded text-gray-500 hover:text-red-400 transition-colors"
> >
<Trash2 size={14} /> <Trash2 size={14} />
@@ -298,10 +456,10 @@ export function TemplateRegistry() {
</h2> </h2>
{activeTemplate && ( {activeTemplate && (
<button <button
onClick={() => openEdit(activeTemplate)} onClick={() => openAddClass(activeTemplate)}
className="bg-white/5 hover:bg-white/10 border border-white/10 px-4 py-1.5 rounded text-sm text-gray-300 transition-colors flex items-center gap-2" className="bg-white/5 hover:bg-white/10 border border-white/10 px-4 py-1.5 rounded text-sm text-gray-300 transition-colors flex items-center gap-2"
> >
<Settings size={14} /> (Schema) <Plus size={14} />
</button> </button>
)} )}
</div> </div>
@@ -310,18 +468,62 @@ export function TemplateRegistry() {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-4"> <h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-4">
(Painter's Algorithm Weight)
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
{normalizeClassMaskIds(activeTemplate.classes || []).sort((a, b) => b.zIndex - a.zIndex).map((cls) => ( {activeTemplateClasses.map((cls) => (
<div key={cls.id} className="grid grid-cols-4 gap-4 p-3 bg-[#0d0d0d] border border-white/5 rounded items-center"> <div
<div className="col-span-1 flex items-center gap-2"> key={cls.id}
draggable={!isSavingOrder && !isReservedUnclassifiedClass(cls)}
onDragStart={(event) => {
if (isReservedUnclassifiedClass(cls)) return;
setDetailDragClassId(cls.id);
event.dataTransfer.setData('text/plain', cls.id);
event.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(event) => {
if (!detailDragClassId || detailDragClassId === cls.id || isSavingOrder || isReservedUnclassifiedClass(cls)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setDetailDragOverClassId(cls.id);
}}
onDragLeave={() => setDetailDragOverClassId(null)}
onDrop={(event) => {
event.preventDefault();
const sourceId = event.dataTransfer.getData('text/plain') || detailDragClassId;
if (sourceId) void saveDetailClassOrder(sourceId, cls.id);
}}
onDragEnd={() => {
setDetailDragClassId(null);
setDetailDragOverClassId(null);
}}
className={cn(
"grid grid-cols-4 gap-4 p-3 bg-[#0d0d0d] border rounded items-center transition-all",
detailDragOverClassId === cls.id ? "border-cyan-500/50 bg-cyan-500/5" : "border-white/5",
detailDragClassId === cls.id && "opacity-50",
isSavingOrder ? "cursor-wait" : isReservedUnclassifiedClass(cls) ? "cursor-default" : "cursor-grab active:cursor-grabbing",
)}
>
<div className="col-span-1 flex items-center gap-2 min-w-0">
<GripVertical size={14} className={cn("shrink-0", isReservedUnclassifiedClass(cls) ? "text-gray-800" : "text-gray-600")} />
<div className="w-3 h-3 rounded" style={{ backgroundColor: cls.color }}></div> <div className="w-3 h-3 rounded" style={{ backgroundColor: cls.color }}></div>
<span className="font-medium text-sm text-gray-300">{cls.name}</span> <span className="font-medium text-sm text-gray-300 truncate">{cls.name}</span>
</div> </div>
<div className="col-span-1 font-mono text-xs text-gray-500">maskid: {cls.maskId}</div> <div className="col-span-1 font-mono text-xs text-gray-500">maskid: {cls.maskId}</div>
<div className="col-span-2 flex justify-end"> <div className="col-span-2 flex justify-end">
<span className="bg-white/5 text-gray-400 text-xs px-2 py-1 rounded border border-white/10">{cls.category || ''}</span> <button
type="button"
aria-label={`删除分类 ${cls.name}`}
title="删除分类"
disabled={isSavingOrder || isReservedUnclassifiedClass(cls)}
onClick={(event) => {
event.stopPropagation();
void deleteDetailClass(cls.id);
}}
className="rounded p-1 text-gray-500 transition-colors hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-30"
>
<Trash2 size={14} />
</button>
</div> </div>
</div> </div>
))} ))}
@@ -391,12 +593,14 @@ export function TemplateRegistry() {
{editClasses.map((cls, idx) => ( {editClasses.map((cls, idx) => (
<div <div
key={cls.id} key={cls.id}
draggable draggable={!isReservedUnclassifiedClass(cls)}
onDragStart={(e) => { onDragStart={(e) => {
if (isReservedUnclassifiedClass(cls)) return;
e.dataTransfer.setData('text/plain', String(idx)); e.dataTransfer.setData('text/plain', String(idx));
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
}} }}
onDragOver={(e) => { onDragOver={(e) => {
if (isReservedUnclassifiedClass(cls)) return;
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
setDragOverIndex(idx); setDragOverIndex(idx);
@@ -405,6 +609,7 @@ export function TemplateRegistry() {
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10); const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
if (isReservedUnclassifiedClass(cls) || Number.isNaN(fromIndex)) return;
reorderClasses(fromIndex, idx); reorderClasses(fromIndex, idx);
setDragOverIndex(null); setDragOverIndex(null);
}} }}
@@ -414,14 +619,15 @@ export function TemplateRegistry() {
dragOverIndex === idx ? "border-cyan-500/50 bg-cyan-500/5" : "border-white/5" dragOverIndex === idx ? "border-cyan-500/50 bg-cyan-500/5" : "border-white/5"
)} )}
> >
<div className="text-gray-600 cursor-grab active:cursor-grabbing shrink-0"> <div className={cn("text-gray-600 shrink-0", isReservedUnclassifiedClass(cls) ? "cursor-default opacity-30" : "cursor-grab active:cursor-grabbing")}>
<GripVertical size={14} /> <GripVertical size={14} />
</div> </div>
<input <input
type="color" type="color"
value={cls.color} value={cls.color}
onChange={(e) => updateClass(cls.id, { color: e.target.value })} onChange={(e) => updateClass(cls.id, { color: e.target.value })}
className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer shrink-0" disabled={isReservedUnclassifiedClass(cls)}
className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer shrink-0 disabled:cursor-not-allowed disabled:opacity-50"
/> />
{editingClassId === cls.id ? ( {editingClassId === cls.id ? (
<> <>
@@ -432,6 +638,7 @@ export function TemplateRegistry() {
onBlur={() => setEditingClassId(null)} onBlur={() => setEditingClassId(null)}
onKeyDown={(e) => e.key === 'Enter' && setEditingClassId(null)} onKeyDown={(e) => e.key === 'Enter' && setEditingClassId(null)}
autoFocus autoFocus
readOnly={isReservedUnclassifiedClass(cls)}
className="flex-1 bg-[#1a1a1a] border border-white/10 rounded px-2 py-1 text-sm text-white" className="flex-1 bg-[#1a1a1a] border border-white/10 rounded px-2 py-1 text-sm text-white"
/> />
<input <input
@@ -445,15 +652,21 @@ export function TemplateRegistry() {
) : ( ) : (
<> <>
<span <span
className="flex-1 text-sm text-gray-300 cursor-pointer" className={cn("flex-1 text-sm text-gray-300", isReservedUnclassifiedClass(cls) ? "cursor-default" : "cursor-pointer")}
onClick={() => setEditingClassId(cls.id)} onClick={() => {
if (!isReservedUnclassifiedClass(cls)) setEditingClassId(cls.id);
}}
> >
{cls.name} {cls.name}
</span> </span>
<span className="w-24 text-sm text-gray-500 font-mono text-right">maskid:{cls.maskId}</span> <span className="w-24 text-sm text-gray-500 font-mono text-right">maskid:{cls.maskId}</span>
</> </>
)} )}
<button onClick={() => removeClass(cls.id)} className="text-gray-500 hover:text-red-400 transition-colors"> <button
onClick={() => removeClass(cls.id)}
disabled={isReservedUnclassifiedClass(cls)}
className="text-gray-500 hover:text-red-400 transition-colors disabled:cursor-not-allowed disabled:opacity-30"
>
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
</div> </div>
@@ -500,13 +713,20 @@ export function TemplateRegistry() {
placeholder='[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]' placeholder='[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'
className="w-full h-32 bg-[#1a1a1a] border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 font-mono focus:outline-none focus:border-cyan-500/50 resize-none" className="w-full h-32 bg-[#1a1a1a] border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 font-mono focus:outline-none focus:border-cyan-500/50 resize-none"
/> />
<div className="flex justify-between items-center mt-4"> {importPreview?.status === 'ready' && (
<button <div className="mt-3 rounded-lg border border-cyan-500/20 bg-cyan-950/15 px-3 py-2 text-xs text-cyan-100">
onClick={loadLaparoscopic} {importPreview.classes.length} maskid {importPreview.firstMaskId}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors" {importPreview.missingColorCount > 0 && (
> <span className="ml-1 text-amber-200">{importPreview.missingColorCount} 使</span>
📋 载入腹腔镜胆囊切除术模板 )}
</button> </div>
)}
{importPreview?.status === 'error' && (
<div className="mt-3 rounded-lg border border-red-500/20 bg-red-950/20 px-3 py-2 text-xs text-red-100">
{importPreview.message}
</div>
)}
<div className="flex justify-end items-center mt-4">
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={() => { setShowImport(false); setImportText(''); }} onClick={() => { setShowImport(false); setImportText(''); }}
@@ -516,7 +736,8 @@ export function TemplateRegistry() {
</button> </button>
<button <button
onClick={handleImport} onClick={handleImport}
className="px-4 py-2 rounded-lg text-sm font-medium bg-cyan-500 hover:bg-cyan-400 text-black transition-all" disabled={importPreview?.status === 'error'}
className="px-4 py-2 rounded-lg text-sm font-medium bg-cyan-500 hover:bg-cyan-400 text-black transition-all disabled:cursor-not-allowed disabled:opacity-50"
> >
</button> </button>
@@ -525,6 +746,32 @@ export function TemplateRegistry() {
</div> </div>
</div> </div>
)} )}
{deleteTemplateTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-md rounded-2xl border border-red-500/20 bg-[#111] p-6 shadow-2xl">
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-3 text-sm leading-6 text-gray-400">
<span className="text-gray-100">{deleteTemplateTarget.name}</span> mask
</p>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => setDeleteTemplateTarget(null)}
className="rounded-lg px-4 py-2 text-sm text-gray-400 transition-colors hover:text-white"
>
</button>
<button
type="button"
onClick={() => void handleDelete()}
className="rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-400"
>
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -81,8 +81,6 @@ describe('UserAdmin', () => {
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 }); apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 });
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 }); apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 });
apiMock.deleteAdminUser.mockResolvedValueOnce(undefined); apiMock.deleteAdminUser.mockResolvedValueOnce(undefined);
vi.spyOn(window, 'prompt').mockReturnValueOnce('nextsecret');
vi.spyOn(window, 'confirm').mockReturnValueOnce(true);
render(<UserAdmin />); render(<UserAdmin />);
await screen.findByText('doctor'); await screen.findByText('doctor');
@@ -95,9 +93,12 @@ describe('UserAdmin', () => {
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { is_active: false })); await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { is_active: false }));
fireEvent.click(screen.getAllByTitle('修改密码')[1]); fireEvent.click(screen.getAllByTitle('修改密码')[1]);
fireEvent.change(screen.getByPlaceholderText('至少 6 位'), { target: { value: 'nextsecret' } });
fireEvent.click(screen.getByRole('button', { name: '确认修改' }));
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { password: 'nextsecret' })); await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { password: 'nextsecret' }));
fireEvent.click(screen.getAllByTitle('删除用户')[1]); fireEvent.click(screen.getAllByTitle('删除用户')[1]);
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
await waitFor(() => expect(apiMock.deleteAdminUser).toHaveBeenCalledWith(2)); await waitFor(() => expect(apiMock.deleteAdminUser).toHaveBeenCalledWith(2));
}); });
@@ -106,12 +107,33 @@ describe('UserAdmin', () => {
admin_user: { id: 1, username: 'admin', role: 'admin', is_active: 1 }, admin_user: { id: 1, username: 'admin', role: 'admin', is_active: 1 },
project: { project: {
id: '8', id: '8',
name: 'Data_MyVideo_1', name: '演示DICOM序列',
status: 'pending', status: 'ready',
frames: 0, frames: 300,
fps: '30FPS', fps: '30FPS',
video_path: 'uploads/8/Data_MyVideo_1.mp4', source_type: 'dicom',
video_path: 'uploads/8/dicom',
}, },
projects: [
{
id: '7',
name: 'Data_MyVideo_1',
status: 'pending',
frames: 0,
fps: '30FPS',
source_type: 'video',
video_path: 'uploads/7/Data_MyVideo_1.mp4',
},
{
id: '8',
name: '演示DICOM序列',
status: 'ready',
frames: 300,
fps: '30FPS',
source_type: 'dicom',
video_path: 'uploads/8/dicom',
},
],
deleted_counts: { users: 1 }, deleted_counts: { users: 1 },
message: '演示环境已恢复出厂设置', message: '演示环境已恢复出厂设置',
}); });
@@ -126,29 +148,34 @@ describe('UserAdmin', () => {
created_at: '2026-05-02T00:00:00Z', created_at: '2026-05-02T00:00:00Z',
}, },
]); ]);
vi.spyOn(window, 'confirm').mockReturnValueOnce(true);
vi.spyOn(window, 'prompt').mockReturnValueOnce('RESET_DEMO_FACTORY');
render(<UserAdmin />); render(<UserAdmin />);
await screen.findByText('doctor'); await screen.findByText('doctor');
fireEvent.click(screen.getByRole('button', { name: '恢复演示出厂设置' })); fireEvent.click(screen.getByRole('button', { name: '恢复演示出厂设置' }));
fireEvent.change(screen.getByLabelText('输入 RESET_DEMO_FACTORY 确认'), {
target: { value: 'RESET_DEMO_FACTORY' },
});
fireEvent.click(screen.getByRole('button', { name: '确认恢复' }));
await waitFor(() => expect(apiMock.resetDemoFactory).toHaveBeenCalledWith('RESET_DEMO_FACTORY')); await waitFor(() => expect(apiMock.resetDemoFactory).toHaveBeenCalledWith('RESET_DEMO_FACTORY'));
expect(await screen.findByText('演示环境已恢复出厂设置')).toBeInTheDocument(); expect(await screen.findByText('演示环境已恢复出厂设置')).toBeInTheDocument();
expect(useStore.getState().projects).toEqual([expect.objectContaining({ name: 'Data_MyVideo_1' })]); expect(useStore.getState().projects).toEqual([
expect.objectContaining({ name: 'Data_MyVideo_1', source_type: 'video' }),
expect.objectContaining({ name: '演示DICOM序列', source_type: 'dicom' }),
]);
expect(useStore.getState().frames).toEqual([]); expect(useStore.getState().frames).toEqual([]);
expect(useStore.getState().masks).toEqual([]); expect(useStore.getState().masks).toEqual([]);
}); });
it('does not reset demo data when confirmation text does not match', async () => { it('does not reset demo data when confirmation text does not match', async () => {
vi.spyOn(window, 'confirm').mockReturnValueOnce(true);
vi.spyOn(window, 'prompt').mockReturnValueOnce('wrong');
render(<UserAdmin />); render(<UserAdmin />);
await screen.findByText('doctor'); await screen.findByText('doctor');
fireEvent.click(screen.getByRole('button', { name: '恢复演示出厂设置' })); fireEvent.click(screen.getByRole('button', { name: '恢复演示出厂设置' }));
fireEvent.change(screen.getByLabelText('输入 RESET_DEMO_FACTORY 确认'), {
target: { value: 'wrong' },
});
expect(apiMock.resetDemoFactory).not.toHaveBeenCalled(); expect(apiMock.resetDemoFactory).not.toHaveBeenCalled();
expect(await screen.findByText('确认文本不匹配,未执行恢复出厂设置')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '确认恢复' })).toBeDisabled();
}); });
}); });

View File

@@ -48,6 +48,11 @@ export function UserAdmin() {
const [newUsername, setNewUsername] = useState(''); const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState('annotator'); const [newRole, setNewRole] = useState('annotator');
const [passwordTarget, setPasswordTarget] = useState<AdminUser | null>(null);
const [nextPassword, setNextPassword] = useState('');
const [deleteUserTarget, setDeleteUserTarget] = useState<AdminUser | null>(null);
const [showFactoryResetConfirm, setShowFactoryResetConfirm] = useState(false);
const [factoryResetText, setFactoryResetText] = useState('');
const activeCount = useMemo(() => users.filter((user) => user.is_active).length, [users]); const activeCount = useMemo(() => users.filter((user) => user.is_active).length, [users]);
const showNotice = (message: string, tone: NoticeTone = 'info') => { const showNotice = (message: string, tone: NoticeTone = 'info') => {
@@ -106,25 +111,41 @@ export function UserAdmin() {
setUsers((prev) => prev.map((item) => (item.id === user.id ? updated : item))); setUsers((prev) => prev.map((item) => (item.id === user.id ? updated : item)));
showNotice('用户已更新', 'success'); showNotice('用户已更新', 'success');
setAuditLogs(await getAuditLogs(100)); setAuditLogs(await getAuditLogs(100));
return true;
} catch (err: any) { } catch (err: any) {
showNotice(err?.response?.data?.detail || '更新用户失败', 'error'); showNotice(err?.response?.data?.detail || '更新用户失败', 'error');
return false;
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}; };
const handleChangePassword = async (user: AdminUser) => { const handleChangePassword = (user: AdminUser) => {
const password = window.prompt(`${user.username} 设置新密码(至少 6 位)`); setPasswordTarget(user);
if (password === null) return; setNextPassword('');
await handlePatchUser(user, { password });
}; };
const handleDeleteUser = async (user: AdminUser) => { const submitPasswordChange = async () => {
if (!window.confirm(`确定删除用户 ${user.username} 吗?已有项目的用户建议先停用。`)) return; if (!passwordTarget) return;
if (nextPassword.length < 6) {
showNotice('新密码至少需要 6 位', 'error');
return;
}
const updated = await handlePatchUser(passwordTarget, { password: nextPassword });
if (updated) {
setPasswordTarget(null);
setNextPassword('');
}
};
const handleDeleteUser = async () => {
const user = deleteUserTarget;
if (!user) return;
setIsSaving(true); setIsSaving(true);
try { try {
await deleteAdminUser(user.id); await deleteAdminUser(user.id);
setUsers((prev) => prev.filter((item) => item.id !== user.id)); setUsers((prev) => prev.filter((item) => item.id !== user.id));
setDeleteUserTarget(null);
showNotice('用户已删除', 'success'); showNotice('用户已删除', 'success');
setAuditLogs(await getAuditLogs(100)); setAuditLogs(await getAuditLogs(100));
} catch (err: any) { } catch (err: any) {
@@ -135,27 +156,23 @@ export function UserAdmin() {
}; };
const handleFactoryReset = async () => { const handleFactoryReset = async () => {
const firstConfirmed = window.confirm( if (factoryResetText !== 'RESET_DEMO_FACTORY') {
'恢复演示出厂设置会删除除默认 admin 外的所有用户、项目帧、标注、任务和私有模板,只保留一个未生成帧的演示视频项目。确定继续吗?',
);
if (!firstConfirmed) return;
const typed = window.prompt('请输入 RESET_DEMO_FACTORY 以确认恢复演示出厂设置');
if (typed === null) return;
if (typed !== 'RESET_DEMO_FACTORY') {
showNotice('确认文本不匹配,未执行恢复出厂设置', 'error'); showNotice('确认文本不匹配,未执行恢复出厂设置', 'error');
return; return;
} }
setIsResetting(true); setIsResetting(true);
try { try {
const result = await resetDemoFactory(typed); const result = await resetDemoFactory(factoryResetText);
setUsers([result.admin_user]); setUsers([result.admin_user]);
setProjects([result.project]); setProjects(result.projects?.length ? result.projects : [result.project]);
setCurrentProject(null); setCurrentProject(null);
setFrames([]); setFrames([]);
setCurrentFrame(0); setCurrentFrame(0);
setMasks([]); setMasks([]);
setSelectedMaskIds([]); setSelectedMaskIds([]);
setAuditLogs(await getAuditLogs(100)); setAuditLogs(await getAuditLogs(100));
setShowFactoryResetConfirm(false);
setFactoryResetText('');
showNotice(result.message || '演示环境已恢复出厂设置', 'success'); showNotice(result.message || '演示环境已恢复出厂设置', 'success');
} catch (err: any) { } catch (err: any) {
showNotice(err?.response?.data?.detail || '恢复演示出厂设置失败', 'error'); showNotice(err?.response?.data?.detail || '恢复演示出厂设置失败', 'error');
@@ -275,7 +292,7 @@ export function UserAdmin() {
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
type="button" type="button"
onClick={() => void handleChangePassword(user)} onClick={() => handleChangePassword(user)}
className="rounded border border-white/10 p-2 text-gray-300 hover:border-cyan-400/40 hover:text-cyan-200" className="rounded border border-white/10 p-2 text-gray-300 hover:border-cyan-400/40 hover:text-cyan-200"
title="修改密码" title="修改密码"
> >
@@ -283,7 +300,7 @@ export function UserAdmin() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => void handleDeleteUser(user)} onClick={() => setDeleteUserTarget(user)}
disabled={user.id === currentUser?.id} disabled={user.id === currentUser?.id}
className="rounded border border-white/10 p-2 text-gray-300 hover:border-red-400/40 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-40" className="rounded border border-white/10 p-2 text-gray-300 hover:border-red-400/40 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-40"
title="删除用户" title="删除用户"
@@ -332,12 +349,15 @@ export function UserAdmin() {
<div> <div>
<div className="text-sm font-semibold text-red-100"></div> <div className="text-sm font-semibold text-red-100"></div>
<p className="mt-1 text-xs leading-relaxed text-red-200/70"> <p className="mt-1 text-xs leading-relaxed text-red-200/70">
admin admin DICOM
</p> </p>
</div> </div>
<button <button
type="button" type="button"
onClick={() => void handleFactoryReset()} onClick={() => {
setFactoryResetText('');
setShowFactoryResetConfirm(true);
}}
disabled={isResetting || isSaving} disabled={isResetting || isSaving}
className="shrink-0 rounded border border-red-400/40 bg-red-500/15 px-3 py-2 text-xs font-semibold text-red-100 transition-colors hover:bg-red-500/25 disabled:cursor-wait disabled:opacity-50" className="shrink-0 rounded border border-red-400/40 bg-red-500/15 px-3 py-2 text-xs font-semibold text-red-100 transition-colors hover:bg-red-500/25 disabled:cursor-wait disabled:opacity-50"
> >
@@ -347,6 +367,119 @@ export function UserAdmin() {
</div> </div>
</section> </section>
</main> </main>
{passwordTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
<div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#151515] p-5 shadow-2xl">
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-2 text-sm leading-relaxed text-gray-400">
<span className="text-gray-100">{passwordTarget.username}</span>
</p>
<input
type="password"
value={nextPassword}
onChange={(event) => setNextPassword(event.target.value)}
autoComplete="new-password"
placeholder="至少 6 位"
className="mt-4 w-full rounded border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
/>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setPasswordTarget(null);
setNextPassword('');
}}
disabled={isSaving}
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => void submitPasswordChange()}
disabled={isSaving || nextPassword.length < 6}
className="inline-flex items-center gap-2 rounded bg-cyan-500 px-3 py-2 text-xs font-semibold text-black hover:bg-cyan-400 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSaving && <Loader2 size={14} className="animate-spin" />}
</button>
</div>
</div>
</div>
)}
{deleteUserTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
<div className="w-full max-w-sm rounded-lg border border-red-400/20 bg-[#151515] p-5 shadow-2xl">
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-2 text-sm leading-relaxed text-gray-400">
<span className="text-gray-100">{deleteUserTarget.username}</span>
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => setDeleteUserTarget(null)}
disabled={isSaving}
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => void handleDeleteUser()}
disabled={isSaving}
className="inline-flex items-center gap-2 rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-wait disabled:opacity-50"
>
{isSaving && <Loader2 size={14} className="animate-spin" />}
</button>
</div>
</div>
</div>
)}
{showFactoryResetConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4">
<div className="w-full max-w-lg rounded-lg border border-red-400/25 bg-[#151515] p-5 shadow-2xl">
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-2 text-sm leading-relaxed text-red-100/80">
admin DICOM
</p>
<label className="mt-4 block text-xs text-gray-400" htmlFor="factory-reset-confirm">
RESET_DEMO_FACTORY
</label>
<input
id="factory-reset-confirm"
value={factoryResetText}
onChange={(event) => setFactoryResetText(event.target.value)}
className="mt-2 w-full rounded border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:border-red-400/50"
/>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setShowFactoryResetConfirm(false);
setFactoryResetText('');
}}
disabled={isResetting}
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => void handleFactoryReset()}
disabled={isResetting || factoryResetText !== 'RESET_DEMO_FACTORY'}
className="inline-flex items-center gap-2 rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-not-allowed disabled:opacity-50"
>
{isResetting && <Loader2 size={14} className="animate-spin" />}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -200,6 +200,56 @@ describe('VideoWorkspace', () => {
])); ]));
}); });
it('downgrades masks whose saved class no longer exists in the template to maskid 0 pending classification', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations.mockResolvedValueOnce([{ id: 100, frame_id: 10, template_id: 2 }]);
apiMock.annotationToMask.mockReturnValueOnce({
id: 'annotation-100',
annotationId: '100',
frameId: '10',
templateId: '2',
classId: 'deleted-class',
className: '已删除类别',
classMaskId: 7,
saved: true,
saveStatus: 'saved',
pathData: 'M 0 0 Z',
label: '已删除类别',
color: '#ff0000',
segmentation: [[0, 0, 10, 0, 10, 10]],
});
useStore.setState({
templates: [{
id: '2',
name: '当前模板',
classes: [{ id: 'c1', name: '胆囊', color: '#00ff00', zIndex: 10, maskId: 1 }],
rules: [],
}],
});
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().masks).toEqual([
expect.objectContaining({
id: 'annotation-100',
label: '待分类',
className: '待分类',
classMaskId: 0,
classId: undefined,
color: '#9ca3af',
saved: false,
saveStatus: 'dirty',
metadata: expect.objectContaining({
needs_classification: true,
stale_class: expect.objectContaining({ id: 'deleted-class', maskId: 7 }),
}),
}),
]));
expect(screen.getByRole('button', { name: '保存 1 个改动' })).toBeInTheDocument();
});
it('preserves unsaved AI masks when hydrating saved annotations after entering the workspace', async () => { it('preserves unsaved AI masks when hydrating saved annotations after entering the workspace', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([ apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 }, { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
@@ -361,9 +411,13 @@ describe('VideoWorkspace', () => {
segmentation: [[0, 0, 10, 0, 10, 10]], segmentation: [[0, 0, 10, 0, 10, 10]],
bbox: [0, 0, 10, 10], bbox: [0, 0, 10, 10],
metadata: { metadata: {
source: 'sam2.1_hiera_tiny_propagation',
propagated_from_frame_id: 10,
propagation_seed_key: 'annotation:7',
source_annotation_id: 7, source_annotation_id: 7,
source_mask_id: 'annotation-7', source_mask_id: 'annotation-7',
propagation_seed_signature: 'old-signature', propagation_seed_signature: 'old-signature',
geometry_smoothing_preview: { strength: 35, method: 'chaikin' },
}, },
}], }],
}); });
@@ -377,12 +431,17 @@ describe('VideoWorkspace', () => {
mask_data: { mask_data: {
polygons: [], polygons: [],
label: '胆囊', label: '胆囊',
source: 'sam2.1_hiera_tiny_propagation',
propagated_from_frame_id: 10,
propagation_seed_key: 'annotation:7',
source_annotation_id: 7, source_annotation_id: 7,
source_mask_id: 'annotation-7', source_mask_id: 'annotation-7',
propagation_seed_signature: 'old-signature',
}, },
points: undefined, points: undefined,
bbox: undefined, bbox: undefined,
})); }));
expect(apiMock.updateAnnotation.mock.calls[0][1].mask_data).not.toHaveProperty('geometry_smoothing_preview');
expect(apiMock.saveAnnotation).not.toHaveBeenCalled(); expect(apiMock.saveAnnotation).not.toHaveBeenCalled();
}); });
@@ -425,7 +484,6 @@ describe('VideoWorkspace', () => {
}); });
it('clears masks across the selected frame range', async () => { it('clears masks across the selected frame range', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
apiMock.getProjectFrames.mockResolvedValueOnce([ apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 }, { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 }, { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
@@ -470,18 +528,82 @@ describe('VideoWorkspace', () => {
expect(screen.getByLabelText('传播结束帧')).toHaveValue(2); expect(screen.getByLabelText('传播结束帧')).toHaveValue(2);
fireEvent.click(screen.getByRole('button', { name: '确认清空' })); fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
expect(screen.getByText('清除人工/AI 标注帧')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '确认清除人工/AI 标注' }));
expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('是否清除“人工/AI标注帧”'));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99')); await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('100'); expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('100');
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-100']); expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-100']);
expect(useStore.getState().selectedMaskIds).not.toContain('draft-1'); expect(useStore.getState().selectedMaskIds).not.toContain('draft-1');
expect(screen.getByText('已清空第 1-2 帧的 2 个遮罩,其中后端标注 1 个')).toBeInTheDocument(); expect(screen.getByText('已清空第 1-2 帧的 2 个遮罩,其中后端标注 1 个')).toBeInTheDocument();
confirmSpy.mockRestore(); });
it('clears a range after undo restores a mask whose backend annotation was already deleted', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockRejectedValueOnce({ response: { status: 404 } });
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
const restoredMask = {
id: 'annotation-99',
annotationId: '99',
frameId: '10',
pathData: 'M 0 0 Z',
label: 'Restored',
color: '#06b6d4',
saved: true,
saveStatus: 'saved' as const,
};
act(() => {
useStore.setState({ masks: [restoredMask], selectedMaskIds: ['annotation-99'] });
useStore.getState().setMasks([]);
useStore.getState().undoMasks();
});
expect(useStore.getState().masks).toEqual([restoredMask]);
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
fireEvent.click(screen.getByRole('button', { name: '确认清除人工/AI 标注' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
expect(useStore.getState().masks).toEqual([]);
expect(screen.getByText('已清空第 1-2 帧的 1 个遮罩,其中后端标注 1 个')).toBeInTheDocument();
});
it('continues clearing a range when one of several annotation deletes returns 404', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation
.mockRejectedValueOnce({ status: 404 })
.mockResolvedValueOnce(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{ id: 'annotation-10149', annotationId: '10149', frameId: '10', pathData: 'M 0 0 Z', label: 'Missing', color: '#06b6d4', saved: true, saveStatus: 'saved' },
{ id: 'annotation-10150', annotationId: '10150', frameId: '11', pathData: 'M 1 1 Z', label: 'Saved', color: '#22c55e', saved: true, saveStatus: 'saved' },
],
});
});
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
fireEvent.click(screen.getByRole('button', { name: '确认清除人工/AI 标注' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10149'));
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10150');
expect(useStore.getState().masks).toEqual([]);
expect(screen.getByText('已清空第 1-2 帧的 2 个遮罩,其中后端标注 2 个')).toBeInTheDocument();
}); });
it('can clear only propagated masks while preserving manual or AI annotated frames', async () => { it('can clear only propagated masks while preserving manual or AI annotated frames', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
apiMock.getProjectFrames.mockResolvedValueOnce([ apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 }, { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 }, { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
@@ -515,17 +637,15 @@ describe('VideoWorkspace', () => {
expect(screen.getByRole('button', { name: '保留人工/AI' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: '保留人工/AI' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: '确认清空' })); fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
expect(confirmSpy).not.toHaveBeenCalled(); expect(screen.queryByText('清除人工/AI 标注帧')).not.toBeInTheDocument();
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99')); await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('98'); expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('98');
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['manual-1']); expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['manual-1']);
expect(useStore.getState().selectedMaskIds).toEqual(['manual-1']); expect(useStore.getState().selectedMaskIds).toEqual(['manual-1']);
expect(screen.getByText('已清空第 1-2 帧的 1 个自动传播遮罩,其中后端标注 1 个,人工/AI 标注帧已保留')).toBeInTheDocument(); expect(screen.getByText('已清空第 1-2 帧的 1 个自动传播遮罩,其中后端标注 1 个,人工/AI 标注帧已保留')).toBeInTheDocument();
confirmSpy.mockRestore();
}); });
it('cancels range clearing when manual or AI annotated frames are not confirmed', async () => { it('cancels range clearing when manual or AI annotated frames are not confirmed', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
apiMock.getProjectFrames.mockResolvedValueOnce([ apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 }, { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 }, { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
@@ -543,16 +663,16 @@ describe('VideoWorkspace', () => {
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' })); fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
fireEvent.click(screen.getByRole('button', { name: '确认清空' })); fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
expect(screen.getByText('清除人工/AI 标注帧')).toBeInTheDocument();
const modal = screen.getByText('清除人工/AI 标注帧').closest('.fixed') as HTMLElement;
fireEvent.click(within(modal).getByRole('button', { name: '取消' }));
expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('是否清除“人工/AI标注帧”'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalled(); expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-99']); expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-99']);
expect(screen.getByText('已取消清空片段遮罩')).toBeInTheDocument(); expect(screen.getByText('已取消清空片段遮罩')).toBeInTheDocument();
confirmSpy.mockRestore();
}); });
it('does not ask for manual-frame confirmation when clearing propagated-only frames', async () => { it('does not ask for manual-frame confirmation when clearing propagated-only frames', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
apiMock.getProjectFrames.mockResolvedValueOnce([ apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 }, { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 }, { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
@@ -582,9 +702,8 @@ describe('VideoWorkspace', () => {
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' })); fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
fireEvent.click(screen.getByRole('button', { name: '确认清空' })); fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
expect(confirmSpy).not.toHaveBeenCalled(); expect(screen.queryByText('清除人工/AI 标注帧')).not.toBeInTheDocument();
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99')); await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
confirmSpy.mockRestore();
}); });
it('auto-saves pending masks before exporting segmentation results', async () => { it('auto-saves pending masks before exporting segmentation results', async () => {
@@ -832,6 +951,111 @@ describe('VideoWorkspace', () => {
})); }));
}); });
it('blocks propagation with a clear message when the current reference frame has no masks', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [{
id: 'stale-other-frame',
annotationId: '10369',
frameId: '11',
pathData: 'M 0 0 Z',
label: '旧帧遮罩',
color: '#ff0000',
saveStatus: 'dirty',
}],
});
});
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
expect(await screen.findByText('当前参考帧无遮罩')).toBeInTheDocument();
expect(apiMock.saveAnnotation).not.toHaveBeenCalled();
expect(apiMock.updateAnnotation).not.toHaveBeenCalled();
expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
expect(apiMock.queuePropagationTask).not.toHaveBeenCalled();
});
it('only saves masks on the current reference frame before propagation', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 8, frame_id: 10 }])
.mockResolvedValue([{ id: 8, frame_id: 10 }]);
apiMock.annotationToMask.mockImplementation((annotation) => ({
id: `annotation-${annotation.id}`,
annotationId: String(annotation.id),
frameId: String(annotation.frame_id),
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
saved: true,
saveStatus: 'saved',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
}));
apiMock.buildAnnotationPayload.mockReturnValue({
project_id: 1,
frame_id: 10,
mask_data: {
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
label: '胆囊',
color: '#ff0000',
},
bbox: [0.1, 0.1, 0.2, 0.2],
});
apiMock.updateAnnotation.mockResolvedValueOnce({ id: 8 });
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{
id: 'annotation-8',
annotationId: '8',
frameId: '10',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
saveStatus: 'dirty',
},
{
id: 'stale-other-frame',
annotationId: '10369',
frameId: '11',
pathData: 'M 1 1 Z',
label: '旧帧遮罩',
color: '#00ff00',
segmentation: [[10, 10, 20, 10, 20, 20]],
bbox: [10, 10, 10, 10],
saveStatus: 'dirty',
},
],
});
});
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledTimes(1));
expect(apiMock.updateAnnotation).toHaveBeenCalledWith('8', expect.any(Object));
expect(apiMock.updateAnnotation).not.toHaveBeenCalledWith('10369', expect.any(Object));
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledTimes(1));
});
it('auto-propagates reference-frame masks through the configured frame range', async () => { it('auto-propagates reference-frame masks through the configured frame range', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([ apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 }, { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
@@ -852,13 +1076,17 @@ describe('VideoWorkspace', () => {
}; };
apiMock.getProjectAnnotations apiMock.getProjectAnnotations
.mockResolvedValueOnce([]) .mockResolvedValueOnce([])
.mockResolvedValue([{ id: 5, frame_id: 10 }]); .mockResolvedValueOnce([{ id: 5, frame_id: 10 }])
.mockResolvedValue([
{ id: 5, frame_id: 10 },
{ id: 6, frame_id: 11 },
]);
apiMock.buildAnnotationPayload.mockReturnValue(seedPayload); apiMock.buildAnnotationPayload.mockReturnValue(seedPayload);
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 5 }); apiMock.saveAnnotation.mockResolvedValueOnce({ id: 5 });
apiMock.annotationToMask.mockReturnValue({ apiMock.annotationToMask.mockImplementation((annotation) => ({
id: 'annotation-5', id: `annotation-${annotation.id}`,
annotationId: '5', annotationId: String(annotation.id),
frameId: '10', frameId: String(annotation.frame_id),
saved: true, saved: true,
saveStatus: 'saved', saveStatus: 'saved',
pathData: 'M 0 0 Z', pathData: 'M 0 0 Z',
@@ -866,7 +1094,10 @@ describe('VideoWorkspace', () => {
color: '#ff0000', color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]], segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72], bbox: [64, 36, 128, 72],
}); metadata: annotation.frame_id === 11
? { source: 'sam2.1_hiera_tiny_propagation', propagated_from_frame_id: 10, source_annotation_id: 5 }
: undefined,
}));
render(<VideoWorkspace />); render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2)); await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
@@ -1119,6 +1350,24 @@ describe('VideoWorkspace', () => {
}, },
bbox: [0.1, 0.1, 0.2, 0.2], bbox: [0.1, 0.1, 0.2, 0.2],
}); });
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValue([
{ id: 101, frame_id: 11 },
{ id: 102, frame_id: 12 },
]);
apiMock.annotationToMask.mockImplementation((annotation) => ({
id: `annotation-${annotation.id}`,
annotationId: String(annotation.id),
frameId: String(annotation.frame_id),
pathData: 'M 0 0 Z',
label: 'Propagated',
color: '#ff0000',
saved: true,
saveStatus: 'saved',
segmentation: [[64, 36, 192, 36, 192, 108]],
metadata: { source: 'sam2_propagation', propagated_from_frame_id: 10 },
}));
apiMock.deleteAnnotation.mockResolvedValue(undefined); apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />); render(<VideoWorkspace />);

View File

@@ -22,7 +22,7 @@ import { ToolsPalette } from './ToolsPalette';
import { OntologyInspector } from './OntologyInspector'; import { OntologyInspector } from './OntologyInspector';
import { FrameTimeline } from './FrameTimeline'; import { FrameTimeline } from './FrameTimeline';
import { ModelStatusBadge } from './ModelStatusBadge'; import { ModelStatusBadge } from './ModelStatusBadge';
import { DEFAULT_AI_MODEL_ID, SAM2_MODEL_OPTIONS, type AiModelId, type Frame, type Mask, type TemplateClass } from '../store/useStore'; import { DEFAULT_AI_MODEL_ID, SAM2_MODEL_OPTIONS, type AiModelId, type Frame, type Mask, type Template, type TemplateClass } from '../store/useStore';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { normalizeClassMaskIds } from '../lib/maskIds'; import { normalizeClassMaskIds } from '../lib/maskIds';
@@ -44,6 +44,14 @@ type PropagationHistorySegment = {
}; };
type RangeSelectionMode = 'propagation' | 'clear' | 'export' | null; type RangeSelectionMode = 'propagation' | 'clear' | 'export' | null;
type ClearRangeMode = 'all' | 'propagated_only'; type ClearRangeMode = 'all' | 'propagated_only';
type ClearRangeConfirmState = {
frameIdsToClear: string[];
annotationIds: string[];
maskCount: number;
rangeStartIndex: number;
rangeEndIndex: number;
mode: ClearRangeMode;
};
type GtUnknownPolicy = 'discard' | 'undefined'; type GtUnknownPolicy = 'discard' | 'undefined';
type ExportScope = 'all' | 'range' | 'current'; type ExportScope = 'all' | 'range' | 'current';
type ExportPreviewPolygon = { type ExportPreviewPolygon = {
@@ -66,7 +74,7 @@ type GtMaskPreviewState = {
validationSkipped?: boolean; validationSkipped?: boolean;
}; };
const GT_MASK_REQUIREMENT_MESSAGE = 'GT Mask 图片不符合要求:请上传灰度图,或 RGB 三通道完全相同的 maskid 图(背景 0像素值为 maskid。'; const GT_MASK_REQUIREMENT_MESSAGE = 'GT Mask 图片不符合要求:请上传 8-bit 灰度图,或 8-bit RGB 三通道完全相同的 maskid 图(背景 0像素值为 1-255 的 maskid。';
const flatPolygonToSvgPoints = (polygon: number[]) => { const flatPolygonToSvgPoints = (polygon: number[]) => {
const points: string[] = []; const points: string[] = [];
@@ -115,6 +123,66 @@ const classByMaskId = (classes: TemplateClass[]) => new Map(
normalizeClassMaskIds(classes).map((templateClass) => [Number(templateClass.maskId), templateClass]), normalizeClassMaskIds(classes).map((templateClass) => [Number(templateClass.maskId), templateClass]),
); );
const UNCLASSIFIED_MASK_LABEL = '待分类';
const UNCLASSIFIED_MASK_COLOR = '#9ca3af';
const normalizeMaskAgainstTemplates = (mask: Mask, templates: Template[]): Mask => {
const hasClassReference = Boolean(mask.classId || mask.className || mask.classMaskId !== undefined);
if (!hasClassReference || mask.classMaskId === 0) return mask;
const template = mask.templateId
? templates.find((item) => String(item.id) === String(mask.templateId))
: null;
if (!template) return mask;
const classes = normalizeClassMaskIds(template.classes || []);
let matchedClass: TemplateClass | undefined;
if (mask.classId) {
matchedClass = classes.find((templateClass) => templateClass.id === mask.classId);
} else if (mask.classMaskId !== undefined) {
matchedClass = classes.find((templateClass) => Number(templateClass.maskId) === Number(mask.classMaskId));
} else if (mask.className) {
matchedClass = classes.find((templateClass) => (
templateClass.name === mask.className
&& (!mask.color || templateClass.color.toLowerCase() === mask.color.toLowerCase())
)) || classes.find((templateClass) => templateClass.name === mask.className);
}
if (matchedClass) {
return {
...mask,
classId: matchedClass.id,
className: matchedClass.name,
classZIndex: matchedClass.zIndex,
classMaskId: matchedClass.maskId,
label: matchedClass.name,
color: matchedClass.color,
};
}
return {
...mask,
classId: undefined,
className: UNCLASSIFIED_MASK_LABEL,
classZIndex: undefined,
classMaskId: 0,
label: UNCLASSIFIED_MASK_LABEL,
color: UNCLASSIFIED_MASK_COLOR,
saveStatus: mask.annotationId ? 'dirty' : 'draft',
saved: mask.annotationId ? false : mask.saved,
metadata: {
...(mask.metadata || {}),
needs_classification: true,
stale_class: {
id: mask.classId,
name: mask.className || mask.label,
maskId: mask.classMaskId,
color: mask.color,
},
},
};
};
const trimPropagationHistoryByClearedRange = ( const trimPropagationHistoryByClearedRange = (
segments: PropagationHistorySegment[], segments: PropagationHistorySegment[],
clearStartFrame: number, clearStartFrame: number,
@@ -147,6 +215,62 @@ const trimPropagationHistoryByClearedRange = (
}); });
}; };
const prunePropagationHistoryByActiveFrames = (
segments: PropagationHistorySegment[],
activeFrameNumbers: Set<number>,
totalFrames: number,
): PropagationHistorySegment[] => (
segments.flatMap((segment) => {
const start = Math.max(1, Math.min(segment.startFrame, segment.endFrame));
const end = Math.min(totalFrames, Math.max(segment.startFrame, segment.endFrame));
const chunks: PropagationHistorySegment[] = [];
let chunkStart: number | null = null;
for (let frameNumber = start; frameNumber <= end; frameNumber += 1) {
if (activeFrameNumbers.has(frameNumber)) {
chunkStart ??= frameNumber;
continue;
}
if (chunkStart !== null) {
const chunkEnd = frameNumber - 1;
chunks.push({
...segment,
id: chunkStart === start && chunkEnd === end ? segment.id : `${segment.id}-${chunkStart}-${chunkEnd}`,
startFrame: chunkStart,
endFrame: chunkEnd,
});
chunkStart = null;
}
}
if (chunkStart !== null) {
chunks.push({
...segment,
id: chunkStart === start ? segment.id : `${segment.id}-${chunkStart}-${end}`,
startFrame: chunkStart,
endFrame: end,
});
}
return chunks;
})
);
const propagationHistoryEqual = (
left: PropagationHistorySegment[],
right: PropagationHistorySegment[],
) => (
left.length === right.length
&& left.every((segment, index) => {
const other = right[index];
return other
&& segment.id === other.id
&& segment.startFrame === other.startFrame
&& segment.endFrame === other.endFrame
&& segment.colorIndex === other.colorIndex
&& segment.label === other.label;
})
);
const isPropagatedMask = (mask: Mask) => { const isPropagatedMask = (mask: Mask) => {
const source = typeof mask.metadata?.source === 'string' ? mask.metadata.source : ''; const source = typeof mask.metadata?.source === 'string' ? mask.metadata.source : '';
return source.includes('_propagation') return source.includes('_propagation')
@@ -156,6 +280,43 @@ const isPropagatedMask = (mask: Mask) => {
|| mask.metadata?.propagation_seed_key !== undefined; || mask.metadata?.propagation_seed_key !== undefined;
}; };
const persistentMaskMetadata = (metadata?: Record<string, unknown>) => {
if (!metadata) return {};
const {
geometry_smoothing: _geometrySmoothing,
geometry_smoothing_preview: _geometrySmoothingPreview,
...rest
} = metadata;
return rest;
};
const isNotFoundError = (error: unknown) => (
typeof error === 'object'
&& error !== null
&& (
('response' in error
&& typeof (error as { response?: { status?: unknown } }).response === 'object'
&& (error as { response?: { status?: unknown } }).response?.status === 404)
|| ('status' in error && (error as { status?: unknown }).status === 404)
)
);
const deleteAnnotationIfExists = async (annotationId: string) => {
try {
await deleteAnnotation(annotationId);
} catch (error) {
if (!isNotFoundError(error)) throw error;
}
};
const deleteAnnotationsIfExist = async (annotationIds: string[]) => {
const results = await Promise.allSettled(annotationIds.map((annotationId) => deleteAnnotationIfExists(annotationId)));
const firstFailure = results.find((result): result is PromiseRejectedResult => (
result.status === 'rejected' && !isNotFoundError(result.reason)
));
if (firstFailure) throw firstFailure.reason;
};
const PROPAGATION_POLL_INTERVAL_MS = 250; const PROPAGATION_POLL_INTERVAL_MS = 250;
const STATUS_MESSAGE_TTL_MS = 3600; const STATUS_MESSAGE_TTL_MS = 3600;
@@ -272,6 +433,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const [isPropagationRangeSelecting, setIsPropagationRangeSelecting] = useState(false); const [isPropagationRangeSelecting, setIsPropagationRangeSelecting] = useState(false);
const [rangeSelectionMode, setRangeSelectionMode] = useState<RangeSelectionMode>(null); const [rangeSelectionMode, setRangeSelectionMode] = useState<RangeSelectionMode>(null);
const [clearRangeMode, setClearRangeMode] = useState<ClearRangeMode>('all'); const [clearRangeMode, setClearRangeMode] = useState<ClearRangeMode>('all');
const [pendingClearRangeConfirm, setPendingClearRangeConfirm] = useState<ClearRangeConfirmState | null>(null);
const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false); const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
const [propagationProgress, setPropagationProgress] = useState<PropagationProgress>(null); const [propagationProgress, setPropagationProgress] = useState<PropagationProgress>(null);
const [propagationTaskId, setPropagationTaskId] = useState<number | null>(null); const [propagationTaskId, setPropagationTaskId] = useState<number | null>(null);
@@ -317,6 +479,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
return () => window.removeEventListener('keydown', handleWorkspaceShortcuts); return () => window.removeEventListener('keydown', handleWorkspaceShortcuts);
}, [redoMasks, undoMasks]); }, [redoMasks, undoMasks]);
const templates = useStore((state) => state.templates);
const setTemplates = useStore((state) => state.setTemplates);
const hydrateSavedAnnotations = useCallback(async ( const hydrateSavedAnnotations = useCallback(async (
projectId: string, projectId: string,
projectFrames: Frame[], projectFrames: Frame[],
@@ -326,11 +491,17 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const frameById = new Map(projectFrames.map((frame) => [frame.id, frame])); const frameById = new Map(projectFrames.map((frame) => [frame.id, frame]));
const projectFrameIds = new Set(projectFrames.map((frame) => frame.id)); const projectFrameIds = new Set(projectFrames.map((frame) => frame.id));
const excludedDraftIds = new Set(excludeUnsavedMaskIds); const excludedDraftIds = new Set(excludeUnsavedMaskIds);
let latestTemplates = useStore.getState().templates;
if (latestTemplates.length === 0) {
latestTemplates = await getTemplates();
setTemplates(latestTemplates);
}
const annotations = await getProjectAnnotations(projectId); const annotations = await getProjectAnnotations(projectId);
const savedMasks = annotations const savedMasks = annotations
.map((annotation) => { .map((annotation) => {
const frame = annotation.frame_id ? frameById.get(String(annotation.frame_id)) : null; const frame = annotation.frame_id ? frameById.get(String(annotation.frame_id)) : null;
return frame ? annotationToMask(annotation, frame) : null; const mask = frame ? annotationToMask(annotation, frame) : null;
return mask ? normalizeMaskAgainstTemplates(mask, latestTemplates) : null;
}) })
.filter((mask): mask is NonNullable<typeof mask> => Boolean(mask)); .filter((mask): mask is NonNullable<typeof mask> => Boolean(mask));
const currentMasks = useStore.getState().masks; const currentMasks = useStore.getState().masks;
@@ -346,7 +517,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setSelectedMaskIds(nextSelectedIds); setSelectedMaskIds(nextSelectedIds);
} }
} }
}, [setMasks, setSelectedMaskIds]); }, [setMasks, setSelectedMaskIds, setTemplates]);
useEffect(() => { useEffect(() => {
if (!currentProject?.id) return; if (!currentProject?.id) return;
@@ -408,9 +579,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [currentProject?.id, currentProject?.video_path, hydrateSavedAnnotations, setFrames, setCurrentFrame]); }, [currentProject?.id, currentProject?.video_path, hydrateSavedAnnotations, setFrames, setCurrentFrame]);
const templates = useStore((state) => state.templates);
const setTemplates = useStore((state) => state.setTemplates);
useEffect(() => { useEffect(() => {
if (templates.length === 0) { if (templates.length === 0) {
getTemplates().then((data) => setTemplates(data)).catch(console.error); getTemplates().then((data) => setTemplates(data)).catch(console.error);
@@ -451,6 +619,25 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
}))); })));
}, [exportPreviewFrame, masks]); }, [exportPreviewFrame, masks]);
useEffect(() => {
if (propagationHistory.length === 0 || frames.length === 0) return;
const frameNumberById = new Map(frames.map((frame, index) => [String(frame.id), index + 1]));
const activePropagatedFrameNumbers = new Set<number>();
masks.forEach((mask) => {
if (!isPropagatedMask(mask)) return;
const frameNumber = frameNumberById.get(String(mask.frameId));
if (frameNumber) activePropagatedFrameNumbers.add(frameNumber);
});
const nextHistory = prunePropagationHistoryByActiveFrames(
propagationHistory,
activePropagatedFrameNumbers,
totalFrames,
);
if (!propagationHistoryEqual(propagationHistory, nextHistory)) {
setPropagationHistory(nextHistory);
}
}, [frames, masks, propagationHistory, totalFrames]);
useEffect(() => { useEffect(() => {
if (!statusMessage || isWorkspaceBusy || totalFrames === 0) return undefined; if (!statusMessage || isWorkspaceBusy || totalFrames === 0) return undefined;
const timer = window.setTimeout(() => setStatusMessage(''), STATUS_MESSAGE_TTL_MS); const timer = window.setTimeout(() => setStatusMessage(''), STATUS_MESSAGE_TTL_MS);
@@ -474,9 +661,13 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setHasExplicitPropagationRange(false); setHasExplicitPropagationRange(false);
}, [currentFrameNumber, totalFrames]); }, [currentFrameNumber, totalFrames]);
const savePendingAnnotations = useCallback(async ({ silent = false } = {}) => { const savePendingAnnotations = useCallback(async ({ silent = false, frameId }: { silent?: boolean; frameId?: string } = {}) => {
if (!currentProject?.id) return 0; if (!currentProject?.id) return 0;
const projectMasks = masks.filter((mask) => projectFrameIds.has(mask.frameId)); const latestMasks = useStore.getState().masks;
const projectMasks = latestMasks.filter((mask) => (
projectFrameIds.has(mask.frameId)
&& (!frameId || String(mask.frameId) === String(frameId))
));
const pendingMasks = projectMasks.filter((mask) => !mask.annotationId); const pendingMasks = projectMasks.filter((mask) => !mask.annotationId);
const dirtyMasks = projectMasks.filter((mask) => mask.annotationId && mask.saveStatus === 'dirty'); const dirtyMasks = projectMasks.filter((mask) => mask.annotationId && mask.saveStatus === 'dirty');
if (pendingMasks.length === 0 && dirtyMasks.length === 0) { if (pendingMasks.length === 0 && dirtyMasks.length === 0) {
@@ -500,13 +691,10 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const frame = frameById.get(mask.frameId); const frame = frameById.get(mask.frameId);
const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null; const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null;
if (!payload || !mask.annotationId) return null; if (!payload || !mask.annotationId) return null;
const propagationLineage = { const savedMetadata = persistentMaskMetadata(mask.metadata);
...(mask.metadata?.source_annotation_id !== undefined ? { source_annotation_id: mask.metadata.source_annotation_id } : {}),
...(mask.metadata?.source_mask_id !== undefined ? { source_mask_id: mask.metadata.source_mask_id } : {}),
};
const updatePayload = { const updatePayload = {
template_id: payload.template_id, template_id: payload.template_id,
mask_data: { ...payload.mask_data, ...propagationLineage }, mask_data: { ...savedMetadata, ...payload.mask_data },
points: payload.points, points: payload.points,
bbox: payload.bbox, bbox: payload.bbox,
}; };
@@ -539,7 +727,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [activeTemplateId, currentProject?.id, frameById, frames, hydrateSavedAnnotations, masks, projectFrameIds]); }, [activeTemplateId, currentProject?.id, frameById, frames, hydrateSavedAnnotations, projectFrameIds]);
const handleClearCurrentFrameMasks = useCallback(async () => { const handleClearCurrentFrameMasks = useCallback(async () => {
if (!currentFrame) return; if (!currentFrame) return;
@@ -551,7 +739,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setIsSaving(true); setIsSaving(true);
setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注...' : '正在清空本帧遮罩...'); setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注...' : '正在清空本帧遮罩...');
try { try {
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId))); await deleteAnnotationsIfExist(annotationIds);
setMasks(masks.filter((mask) => mask.frameId !== currentFrame.id)); setMasks(masks.filter((mask) => mask.frameId !== currentFrame.id));
setStatusMessage(annotationIds.length > 0 setStatusMessage(annotationIds.length > 0
? `已删除 ${annotationIds.length} 个后端标注` ? `已删除 ${annotationIds.length} 个后端标注`
@@ -564,6 +752,39 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
} }
}, [currentFrame, masks, setMasks]); }, [currentFrame, masks, setMasks]);
const executeClearFrameRange = useCallback(async (request: ClearRangeConfirmState) => {
const frameIdsToClear = new Set(request.frameIdsToClear);
setIsSaving(true);
setStatusMessage(request.annotationIds.length > 0
? `正在删除第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的已保存标注...`
: `正在清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的本地遮罩...`);
try {
await deleteAnnotationsIfExist(request.annotationIds);
const latestMasks = useStore.getState().masks;
const clearedMaskIds = new Set(
latestMasks
.filter((mask) => frameIdsToClear.has(String(mask.frameId)))
.filter((mask) => request.mode === 'all' || isPropagatedMask(mask))
.map((mask) => mask.id),
);
setMasks(latestMasks.filter((mask) => !clearedMaskIds.has(mask.id)));
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !clearedMaskIds.has(id)));
setPropagationHistory((previous) => trimPropagationHistoryByClearedRange(previous, request.rangeStartIndex + 1, request.rangeEndIndex + 1));
setStatusMessage(request.mode === 'propagated_only'
? `已清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的 ${request.maskCount} 个自动传播遮罩,其中后端标注 ${request.annotationIds.length} 个,人工/AI 标注帧已保留`
: `已清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的 ${request.maskCount} 个遮罩,其中后端标注 ${request.annotationIds.length}`);
setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false);
setPendingClearRangeConfirm(null);
} catch (err) {
console.error('Delete range annotations failed:', err);
setStatusMessage('批量清空失败,请检查后端服务');
} finally {
setIsSaving(false);
}
}, [setMasks, setSelectedMaskIds]);
const handleClearFrameRangeMasks = useCallback(async () => { const handleClearFrameRangeMasks = useCallback(async () => {
if (rangeSelectionMode !== 'clear') { if (rangeSelectionMode !== 'clear') {
setIsPropagationRangeSelecting(true); setIsPropagationRangeSelecting(true);
@@ -595,57 +816,34 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
return; return;
} }
const hasManualOrAiAnnotatedFrames = clearRangeMode === 'all' && rangeMasks.some((mask) => !isPropagatedMask(mask)); const hasManualOrAiAnnotatedFrames = clearRangeMode === 'all' && rangeMasks.some((mask) => !isPropagatedMask(mask));
if (hasManualOrAiAnnotatedFrames) {
const confirmed = window.confirm('是否清除“人工/AI标注帧”\n该范围包含人工绘制或 AI 智能分割生成的 mask确认后这些 mask 也会被删除。');
if (!confirmed) {
setStatusMessage('已取消清空片段遮罩');
return;
}
}
const annotationIds = Array.from(new Set( const annotationIds = Array.from(new Set(
rangeMasks rangeMasks
.map((mask) => mask.annotationId) .map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId)), .filter((annotationId): annotationId is string => Boolean(annotationId)),
)); ));
const request = {
setIsSaving(true); frameIdsToClear: Array.from(frameIdsToClear),
setStatusMessage(annotationIds.length > 0 annotationIds,
? `正在删除第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的已保存标注...` maskCount: rangeMasks.length,
: `正在清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的本地遮罩...`); rangeStartIndex,
try { rangeEndIndex,
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId))); mode: clearRangeMode,
const latestMasks = useStore.getState().masks; };
const clearedMaskIds = new Set( if (hasManualOrAiAnnotatedFrames) {
latestMasks setPendingClearRangeConfirm(request);
.filter((mask) => frameIdsToClear.has(String(mask.frameId))) return;
.filter((mask) => clearRangeMode === 'all' || isPropagatedMask(mask))
.map((mask) => mask.id),
);
setMasks(latestMasks.filter((mask) => !clearedMaskIds.has(mask.id)));
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !clearedMaskIds.has(id)));
setPropagationHistory((previous) => trimPropagationHistoryByClearedRange(previous, rangeStartIndex + 1, rangeEndIndex + 1));
setStatusMessage(clearRangeMode === 'propagated_only'
? `已清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的 ${rangeMasks.length} 个自动传播遮罩,其中后端标注 ${annotationIds.length} 个,人工/AI 标注帧已保留`
: `已清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的 ${rangeMasks.length} 个遮罩,其中后端标注 ${annotationIds.length}`);
setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false);
} catch (err) {
console.error('Delete range annotations failed:', err);
setStatusMessage('批量清空失败,请检查后端服务');
} finally {
setIsSaving(false);
} }
}, [clearRangeMode, frames, masks, propagationEndFrame, propagationStartFrame, rangeSelectionMode, setMasks, setSelectedMaskIds, totalFrames]); await executeClearFrameRange(request);
}, [clearRangeMode, executeClearFrameRange, frames, masks, propagationEndFrame, propagationStartFrame, rangeSelectionMode, totalFrames]);
const handleDeleteMaskAnnotations = useCallback(async (annotationIds: string[]) => { const handleDeleteMaskAnnotations = useCallback(async (annotationIds: string[]) => {
if (annotationIds.length === 0) return; if (annotationIds.length === 0) return;
try { try {
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId))); await deleteAnnotationsIfExist(annotationIds);
setStatusMessage(`已删除 ${annotationIds.length}被合并标注`); setStatusMessage(`已删除 ${annotationIds.length} 个标注`);
} catch (err) { } catch (err) {
console.error('Delete merged annotations failed:', err); console.error('Delete annotations failed:', err);
setStatusMessage('合并后删除标注失败,请检查后端服务'); setStatusMessage('删除标注失败,请检查后端服务');
throw err; throw err;
} }
}, []); }, []);
@@ -990,21 +1188,21 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const runAutoPropagate = async () => { const runAutoPropagate = async () => {
if (!currentProject?.id || !currentFrame?.id) return; if (!currentProject?.id || !currentFrame?.id) return;
const initialSeedMasks = masks.filter((mask) => String(mask.frameId) === String(currentFrame.id)); const initialSeedMasks = useStore.getState().masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
if (initialSeedMasks.length === 0) { if (initialSeedMasks.length === 0) {
setStatusMessage('请先在当前参考帧创建或保存至少一个 mask'); setStatusMessage('当前参考帧无遮罩');
return; return;
} }
const hasUnstableSeedMasks = initialSeedMasks.some((mask) => !mask.annotationId || mask.saveStatus === 'dirty'); const hasUnstableSeedMasks = initialSeedMasks.some((mask) => !mask.annotationId || mask.saveStatus === 'dirty');
if (hasUnstableSeedMasks) { if (hasUnstableSeedMasks) {
setStatusMessage('正在先保存参考帧 mask确保二次传播可以替换旧结果...'); setStatusMessage('正在先保存参考帧 mask确保二次传播可以替换旧结果...');
await savePendingAnnotations({ silent: true }); await savePendingAnnotations({ silent: true, frameId: currentFrame.id });
} }
const seedMasks = useStore.getState().masks.filter((mask) => String(mask.frameId) === String(currentFrame.id)); const seedMasks = useStore.getState().masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
if (seedMasks.length === 0) { if (seedMasks.length === 0) {
setStatusMessage('参考帧 mask 保存后未能回显,请先检查归档保存是否成功'); setStatusMessage('当前参考帧无遮罩');
return; return;
} }
@@ -1285,7 +1483,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
<div className="w-full max-w-xl rounded-md border border-white/10 bg-[#151515] p-4 shadow-2xl shadow-black/60"> <div className="w-full max-w-xl rounded-md border border-white/10 bg-[#151515] p-4 shadow-2xl shadow-black/60">
<div className="text-sm font-semibold text-white"> GT Mask</div> <div className="text-sm font-semibold text-white"> GT Mask</div>
<div className="mt-2 text-xs leading-5 text-gray-400"> <div className="mt-2 text-xs leading-5 text-gray-400">
GT maskid RGB [X,X,X] maskid 0 X maskid GT 8-bit maskid 8-bit RGB [X,X,X] maskid 0 X 1-255 maskid
</div> </div>
<div className="mt-3 rounded border border-white/10 bg-black/20 px-3 py-2 text-[11px] text-gray-500"> <div className="mt-3 rounded border border-white/10 bg-black/20 px-3 py-2 text-[11px] text-gray-500">
{pendingGtImportFile.name} {pendingGtImportFile.name}
@@ -1663,6 +1861,42 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
<OntologyInspector /> <OntologyInspector />
</div> </div>
{pendingClearRangeConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
<div className="w-full max-w-md rounded-lg border border-red-400/25 bg-[#151515] p-5 shadow-2xl">
<h2 className="text-lg font-semibold text-white">/AI </h2>
<p className="mt-2 text-sm leading-relaxed text-gray-300">
{pendingClearRangeConfirm.rangeStartIndex + 1}-{pendingClearRangeConfirm.rangeEndIndex + 1} AI mask
{pendingClearRangeConfirm.maskCount}
</p>
<p className="mt-2 text-xs leading-relaxed text-red-200/70">
/AI
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setPendingClearRangeConfirm(null);
setStatusMessage('已取消清空片段遮罩');
}}
disabled={isSaving}
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => void executeClearFrameRange(pendingClearRangeConfirm)}
disabled={isSaving}
className="rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-wait disabled:opacity-50"
>
/AI
</button>
</div>
</div>
</div>
)}
{/* Bottom Timeline */} {/* Bottom Timeline */}
<FrameTimeline <FrameTimeline
propagationRange={visibleTimelineRange} propagationRange={visibleTimelineRange}

View File

@@ -75,6 +75,19 @@ describe('api client contracts', () => {
expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/projects/3', { name: 'Renamed' }); expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/projects/3', { name: 'Renamed' });
}); });
it('copies projects through the copy endpoint', async () => {
const { copyProject } = await import('./api');
axiosMock.client.post.mockResolvedValueOnce({ data: { id: 4, name: 'Copied', status: 'ready', frame_count: 3 } });
await expect(copyProject('3', { mode: 'full' })).resolves.toEqual(expect.objectContaining({
id: '4',
name: 'Copied',
frames: 3,
}));
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/projects/3/copy', { mode: 'full' });
});
it('deletes projects through DELETE', async () => { it('deletes projects through DELETE', async () => {
const { deleteProject } = await import('./api'); const { deleteProject } = await import('./api');
axiosMock.client.delete.mockResolvedValueOnce({ data: null }); axiosMock.client.delete.mockResolvedValueOnce({ data: null });
@@ -84,6 +97,47 @@ describe('api client contracts', () => {
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/projects/3'); expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/projects/3');
}); });
it('reports upload progress for video media imports', async () => {
const { uploadMedia } = await import('./api');
const onProgress = vi.fn();
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
axiosMock.client.post.mockResolvedValueOnce({ data: { file_url: 'http://file', object_name: 'uploads/clip.mp4' } });
await expect(uploadMedia(file, '9', { onProgress })).resolves.toEqual({
url: 'http://file',
id: 'uploads/clip.mp4',
});
const [, , config] = axiosMock.client.post.mock.calls.at(-1);
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/media/upload', expect.any(FormData), expect.objectContaining({
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: expect.any(Function),
}));
config.onUploadProgress({ loaded: 25, total: 100 });
expect(onProgress).toHaveBeenCalledWith({ loaded: 25, total: 100, percent: 25 });
});
it('reports upload progress for DICOM batch imports', async () => {
const { uploadDicomBatch } = await import('./api');
const onProgress = vi.fn();
const file = new File(['dcm'], '1.dcm', { type: 'application/dicom' });
axiosMock.client.post.mockResolvedValueOnce({ data: { project_id: 10, uploaded_count: 1, message: 'ok' } });
await expect(uploadDicomBatch([file], undefined, { onProgress })).resolves.toEqual({
project_id: 10,
uploaded_count: 1,
message: 'ok',
});
const [, , config] = axiosMock.client.post.mock.calls.at(-1);
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/media/upload/dicom', expect.any(FormData), expect.objectContaining({
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: expect.any(Function),
}));
config.onUploadProgress({ loaded: 10, total: 20 });
expect(onProgress).toHaveBeenCalledWith({ loaded: 10, total: 20, percent: 50 });
});
it('normalizes missing template class maskids without using priority as the public id', async () => { it('normalizes missing template class maskids without using priority as the public id', async () => {
const { getTemplates } = await import('./api'); const { getTemplates } = await import('./api');
axiosMock.client.get.mockResolvedValueOnce({ axiosMock.client.get.mockResolvedValueOnce({
@@ -107,6 +161,7 @@ describe('api client contracts', () => {
expect.objectContaining({ id: 'c1', maskId: 1, zIndex: 100 }), expect.objectContaining({ id: 'c1', maskId: 1, zIndex: 100 }),
expect.objectContaining({ id: 'c2', maskId: 7, zIndex: 10 }), expect.objectContaining({ id: 'c2', maskId: 7, zIndex: 10 }),
expect.objectContaining({ id: 'c3', maskId: 2, zIndex: 50 }), expect.objectContaining({ id: 'c3', maskId: 2, zIndex: 50 }),
expect.objectContaining({ id: 'reserved-unclassified', name: '待分类', maskId: 0, color: '#000000', zIndex: 0 }),
], ],
}), }),
]); ]);
@@ -148,14 +203,22 @@ describe('api client contracts', () => {
axiosMock.client.post.mockResolvedValueOnce({ axiosMock.client.post.mockResolvedValueOnce({
data: { data: {
admin_user: { id: 1, username: 'admin', role: 'admin', is_active: 1 }, admin_user: { id: 1, username: 'admin', role: 'admin', is_active: 1 },
project: { id: 8, name: 'Data_MyVideo_1', status: 'pending', frame_count: 0, video_path: 'uploads/8/Data_MyVideo_1.mp4' }, project: { id: 8, name: '演示DICOM序列', status: 'ready', source_type: 'dicom', frame_count: 300, video_path: 'uploads/8/dicom' },
projects: [
{ id: 7, name: 'Data_MyVideo_1', status: 'pending', source_type: 'video', frame_count: 0, video_path: 'uploads/7/Data_MyVideo_1.mp4' },
{ id: 8, name: '演示DICOM序列', status: 'ready', source_type: 'dicom', frame_count: 300, video_path: 'uploads/8/dicom' },
],
deleted_counts: { users: 1 }, deleted_counts: { users: 1 },
message: '演示环境已恢复出厂设置', message: '演示环境已恢复出厂设置',
}, },
}); });
await expect(resetDemoFactory('RESET_DEMO_FACTORY')).resolves.toEqual(expect.objectContaining({ await expect(resetDemoFactory('RESET_DEMO_FACTORY')).resolves.toEqual(expect.objectContaining({
admin_user: expect.objectContaining({ username: 'admin' }), admin_user: expect.objectContaining({ username: 'admin' }),
project: expect.objectContaining({ id: '8', name: 'Data_MyVideo_1', frames: 0 }), project: expect.objectContaining({ id: '8', name: '演示DICOM序列', frames: 300, source_type: 'dicom' }),
projects: [
expect.objectContaining({ id: '7', name: 'Data_MyVideo_1', frames: 0, source_type: 'video' }),
expect.objectContaining({ id: '8', name: '演示DICOM序列', frames: 300, source_type: 'dicom' }),
],
})); }));
expect(axiosMock.client.post).toHaveBeenLastCalledWith('/api/admin/demo-factory-reset', { expect(axiosMock.client.post).toHaveBeenLastCalledWith('/api/admin/demo-factory-reset', {
confirmation: 'RESET_DEMO_FACTORY', confirmation: 'RESET_DEMO_FACTORY',
@@ -338,6 +401,10 @@ describe('api client contracts', () => {
await expect(deleteAnnotation('1')).resolves.toBeUndefined(); await expect(deleteAnnotation('1')).resolves.toBeUndefined();
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/ai/annotations/1'); expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/ai/annotations/1');
axiosMock.client.delete.mockRejectedValueOnce({ response: { status: 404 } });
await expect(deleteAnnotation('missing')).resolves.toBeUndefined();
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/ai/annotations/missing');
axiosMock.client.post.mockResolvedValueOnce({ axiosMock.client.post.mockResolvedValueOnce({
data: { data: {
model: 'sam2.1_hiera_tiny', model: 'sam2.1_hiera_tiny',
@@ -468,6 +535,7 @@ describe('api client contracts', () => {
geometry_smoothing: { strength: 35, method: 'chaikin' }, geometry_smoothing: { strength: 35, method: 'chaikin' },
}, },
bbox: [0.1, 0.2, 0.8, 0.6], bbox: [0.1, 0.2, 0.8, 0.6],
points: [[0.6333333333333333, 0.4]],
}); });
expect(annotationToMask({ expect(annotationToMask({

View File

@@ -1,4 +1,4 @@
import axios, { AxiosError } from 'axios'; import axios, { AxiosError, type AxiosProgressEvent } from 'axios';
import { DEFAULT_AI_MODEL_ID, type AiModelId, type Frame, type Mask, type Project, type Template, type UserProfile } from '../store/useStore'; import { DEFAULT_AI_MODEL_ID, type AiModelId, type Frame, type Mask, type Project, type Template, type UserProfile } from '../store/useStore';
import { API_BASE_URL } from './config'; import { API_BASE_URL } from './config';
import { normalizeClassMaskIds } from './maskIds'; import { normalizeClassMaskIds } from './maskIds';
@@ -65,6 +65,7 @@ export interface AuditLog {
export interface DemoFactoryResetResult { export interface DemoFactoryResetResult {
admin_user: AdminUser; admin_user: AdminUser;
project: Project; project: Project;
projects?: Project[];
deleted_counts: Record<string, number>; deleted_counts: Record<string, number>;
message: string; message: string;
} }
@@ -108,6 +109,7 @@ export async function resetDemoFactory(confirmation: string): Promise<DemoFactor
return { return {
...response.data, ...response.data,
project: mapProject(response.data.project), project: mapProject(response.data.project),
projects: Array.isArray(response.data.projects) ? response.data.projects.map(mapProject) : undefined,
}; };
} }
@@ -163,6 +165,14 @@ export async function updateProject(id: string, payload: Partial<Project>): Prom
return mapProject(response.data); return mapProject(response.data);
} }
export async function copyProject(
id: string,
payload: { mode: 'reset' | 'full'; name?: string },
): Promise<Project> {
const response = await apiClient.post(`/api/projects/${id}/copy`, payload);
return mapProject(response.data);
}
export async function deleteProject(id: string): Promise<void> { export async function deleteProject(id: string): Promise<void> {
await apiClient.delete(`/api/projects/${id}`); await apiClient.delete(`/api/projects/${id}`);
} }
@@ -174,6 +184,8 @@ function _mapTemplate(t: any): Template {
id: String(t.id), id: String(t.id),
name: t.name, name: t.name,
description: t.description, description: t.description,
color: t.color,
z_index: t.z_index,
classes: normalizeClassMaskIds(mapping.classes || []), classes: normalizeClassMaskIds(mapping.classes || []),
rules: mapping.rules || [], rules: mapping.rules || [],
createdAt: t.created_at, createdAt: t.created_at,
@@ -191,7 +203,7 @@ export async function createTemplate(payload: {
description?: string; description?: string;
color: string; color: string;
z_index: number; z_index: number;
classes?: { name: string; color: string; zIndex: number; maskId?: number; category?: string }[]; classes?: Template['classes'];
rules?: any[]; rules?: any[];
}): Promise<Template> { }): Promise<Template> {
const response = await apiClient.post('/api/templates', payload); const response = await apiClient.post('/api/templates', payload);
@@ -208,7 +220,26 @@ export async function deleteTemplate(id: string): Promise<void> {
} }
// Media // Media
export async function uploadMedia(file: File, projectId?: string): Promise<{ url: string; id: string }> { export interface UploadProgress {
loaded: number;
total?: number;
percent?: number;
}
export interface UploadOptions {
onProgress?: (progress: UploadProgress) => void;
}
const toUploadProgress = (event: AxiosProgressEvent): UploadProgress => {
const total = typeof event.total === 'number' && event.total > 0 ? event.total : undefined;
return {
loaded: event.loaded,
total,
percent: total ? Math.min(100, Math.max(0, Math.round((event.loaded / total) * 100))) : undefined,
};
};
export async function uploadMedia(file: File, projectId?: string, options: UploadOptions = {}): Promise<{ url: string; id: string }> {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
if (projectId) { if (projectId) {
@@ -218,6 +249,7 @@ export async function uploadMedia(file: File, projectId?: string): Promise<{ url
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
onUploadProgress: options.onProgress ? (event) => options.onProgress?.(toUploadProgress(event)) : undefined,
}); });
const { file_url, object_name } = response.data; const { file_url, object_name } = response.data;
return { url: file_url, id: object_name }; return { url: file_url, id: object_name };
@@ -237,12 +269,13 @@ export async function getProjectFrames(projectId: string): Promise<Array<{
return response.data; return response.data;
} }
export async function uploadDicomBatch(files: File[], projectId?: string): Promise<{ project_id: number; uploaded_count: number; message: string }> { export async function uploadDicomBatch(files: File[], projectId?: string, options: UploadOptions = {}): Promise<{ project_id: number; uploaded_count: number; message: string }> {
const formData = new FormData(); const formData = new FormData();
files.forEach((file) => formData.append('files', file)); files.forEach((file) => formData.append('files', file));
if (projectId) formData.append('project_id', projectId); if (projectId) formData.append('project_id', projectId);
const response = await apiClient.post('/api/media/upload/dicom', formData, { const response = await apiClient.post('/api/media/upload/dicom', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: options.onProgress ? (event) => options.onProgress?.(toUploadProgress(event)) : undefined,
}); });
return response.data; return response.data;
} }
@@ -567,6 +600,44 @@ function polygonAreaPixels(points: number[][], width: number, height: number): n
return Math.abs(total) / 2; return Math.abs(total) / 2;
} }
function polygonRepresentativePointPixels(polygon: number[] | undefined): [number, number] | null {
if (!polygon || polygon.length < 6) return null;
const points: number[][] = [];
for (let index = 0; index < polygon.length - 1; index += 2) {
points.push([polygon[index], polygon[index + 1]]);
}
if (points.length < 3) return null;
let twiceArea = 0;
let centroidX = 0;
let centroidY = 0;
points.forEach(([x, y], index) => {
const [nextX, nextY] = points[(index + 1) % points.length];
const cross = x * nextY - nextX * y;
twiceArea += cross;
centroidX += (x + nextX) * cross;
centroidY += (y + nextY) * cross;
});
if (Math.abs(twiceArea) > 1e-6) {
return [centroidX / (3 * twiceArea), centroidY / (3 * twiceArea)];
}
const xs = points.map(([x]) => x);
const ys = points.map(([, y]) => y);
return [
(Math.min(...xs) + Math.max(...xs)) / 2,
(Math.min(...ys) + Math.max(...ys)) / 2,
];
}
function maskSeedPointsPixels(mask: Mask): number[][] {
if (mask.points && mask.points.length > 0) return mask.points;
return (mask.segmentation || [])
.map(polygonRepresentativePointPixels)
.filter((point): point is [number, number] => Boolean(point));
}
function normalizeGeometrySmoothing(value: unknown): GeometrySmoothingOptions | undefined { function normalizeGeometrySmoothing(value: unknown): GeometrySmoothingOptions | undefined {
if (!value || typeof value !== 'object') return undefined; if (!value || typeof value !== 'object') return undefined;
const source = value as Record<string, unknown>; const source = value as Record<string, unknown>;
@@ -651,8 +722,9 @@ export function buildAnnotationPayload(
: undefined, : undefined,
}; };
if (mask.points) { const seedPoints = maskSeedPointsPixels(mask);
payload.points = mask.points.map(([x, y]) => [ if (seedPoints.length > 0) {
payload.points = seedPoints.map(([x, y]) => [
clamp01(x / Math.max(frame.width, 1)), clamp01(x / Math.max(frame.width, 1)),
clamp01(y / Math.max(frame.height, 1)), clamp01(y / Math.max(frame.height, 1)),
]); ]);
@@ -864,7 +936,12 @@ export async function updateAnnotation(annotationId: string, payload: UpdateAnno
} }
export async function deleteAnnotation(annotationId: string): Promise<void> { export async function deleteAnnotation(annotationId: string): Promise<void> {
await apiClient.delete(`/api/ai/annotations/${annotationId}`); try {
await apiClient.delete(`/api/ai/annotations/${annotationId}`);
} catch (error) {
if ((error as AxiosError).response?.status === 404) return;
throw error;
}
} }
export async function importGtMask( export async function importGtMask(

View File

@@ -1,8 +1,35 @@
import type { TemplateClass } from '../store/useStore'; import type { TemplateClass } from '../store/useStore';
export const RESERVED_UNCLASSIFIED_CLASS: TemplateClass = {
id: 'reserved-unclassified',
name: '待分类',
color: '#000000',
zIndex: 0,
maskId: 0,
category: '系统保留',
};
export function isReservedUnclassifiedClass(templateClass: Pick<TemplateClass, 'id' | 'maskId' | 'name'>): boolean {
return Number(templateClass.maskId) === 0 || templateClass.id === RESERVED_UNCLASSIFIED_CLASS.id || templateClass.name === RESERVED_UNCLASSIFIED_CLASS.name;
}
function reservedUnclassifiedClass(source?: Partial<TemplateClass>): TemplateClass {
return {
...RESERVED_UNCLASSIFIED_CLASS,
...source,
id: RESERVED_UNCLASSIFIED_CLASS.id,
name: RESERVED_UNCLASSIFIED_CLASS.name,
color: RESERVED_UNCLASSIFIED_CLASS.color,
zIndex: RESERVED_UNCLASSIFIED_CLASS.zIndex,
maskId: RESERVED_UNCLASSIFIED_CLASS.maskId,
category: RESERVED_UNCLASSIFIED_CLASS.category,
};
}
export function normalizeClassMaskIds(classes: TemplateClass[] = []): TemplateClass[] { export function normalizeClassMaskIds(classes: TemplateClass[] = []): TemplateClass[] {
const used = new Set<number>(); const used = new Set<number>();
let nextMaskId = 1; let nextMaskId = 1;
let reservedClass: TemplateClass | undefined;
const nextAvailableMaskId = () => { const nextAvailableMaskId = () => {
while (used.has(nextMaskId)) nextMaskId += 1; while (used.has(nextMaskId)) nextMaskId += 1;
@@ -12,7 +39,15 @@ export function normalizeClassMaskIds(classes: TemplateClass[] = []): TemplateCl
return value; return value;
}; };
return classes.map((templateClass) => { const normalized = classes
.filter((templateClass) => {
if (isReservedUnclassifiedClass(templateClass)) {
reservedClass ??= reservedUnclassifiedClass(templateClass);
return false;
}
return true;
})
.map((templateClass) => {
const parsed = Number(templateClass.maskId); const parsed = Number(templateClass.maskId);
if (Number.isInteger(parsed) && parsed > 0 && !used.has(parsed)) { if (Number.isInteger(parsed) && parsed > 0 && !used.has(parsed)) {
used.add(parsed); used.add(parsed);
@@ -20,6 +55,7 @@ export function normalizeClassMaskIds(classes: TemplateClass[] = []): TemplateCl
} }
return { ...templateClass, maskId: nextAvailableMaskId() }; return { ...templateClass, maskId: nextAvailableMaskId() };
}); });
return [...normalized, reservedClass || reservedUnclassifiedClass()];
} }
export function nextClassMaskId(classes: TemplateClass[] = []): number { export function nextClassMaskId(classes: TemplateClass[] = []): number {

View File

@@ -84,6 +84,8 @@ export interface Template {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
color?: string;
z_index?: number;
classes: TemplateClass[]; classes: TemplateClass[];
rules?: TemplateRule[]; rules?: TemplateRule[];
createdAt?: string; createdAt?: string;