优化传播范围清空交互

- 将布尔操作的选择范围弹窗改为四个操作按钮同一行展示
- 将 DEL 和清空遮罩的传播链确认改为单层弹窗,提供取消、只清当前帧、按帧范围选择和清空所有传播帧
- 为传播链清空新增时间轴范围选择、顶栏确认清空和最终确认流程,只删除所选范围内同传播链遮罩
- 补充 Canvas 和 VideoWorkspace 回归测试,覆盖按钮布局和按帧范围清空传播链
- 更新前端审计、需求冻结、设计冻结、测试计划和项目指南文档
This commit is contained in:
2026-05-04 00:57:55 +08:00
parent 2fe4623cae
commit f42bf42989
10 changed files with 256 additions and 29 deletions

View File

@@ -244,7 +244,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
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 使用 pydicomworker 下载和读取 DICOM 时也按文件名自然顺序排序;视频/DICOM 解析完成后都按 `frame_%06d.jpg` 连续生成项目帧序列,并记录 `timestamp_ms``source_frame_number` 和任务 `frame_sequence` 元数据后续工作区、时间轴、AI 传播、标注和导出共用同一套帧序列逻辑。 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` 会按容器和帧尺寸默认居中放大底图并保留边距右下角显示“当前帧XX/XXX”`FrameTimeline` 会根据已保存标注回显到 `Mask.metadata` 的传播来源,把自动传播生成的帧在视频处理进度条显示为蓝色区段,人工/AI 标注帧显示红色竖线;每次自动传播成功处理帧后,`VideoWorkspace` 会把本次传播范围作为当前会话历史片段传给 `FrameTimeline`,在视频处理进度条上叠加同一蓝色系、最新传播最亮、旧传播逐次变暗且第 5 次及更早统一为阈值旧记录色的纯色条;传播历史条只显示当前仍有自动传播 mask 的帧,删除 mask 或清空范围后会按剩余传播 mask 自动裁剪,空帧不保留红/蓝颜色;视频处理进度条和红/蓝标识可点击跳转到对应帧;底部缩略图中人工/AI 标注帧用红色边框、自动传播/推理帧用蓝色边框,同一帧同时具备两种状态时红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍以青色外框高亮优先;若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注;进入自动传播、布尔操作或特定范围帧导出选择模式时,播放进度条和视频处理进度条会显示黄色范围框,并可点击/拖拽选择起止帧;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。 7. 帧展示:`VideoWorkspace.tsx` 调用 `/api/projects/{id}/frames``CanvasArea.tsx``FrameTimeline.tsx` 显示当前帧与时间轴缩略图;`CanvasArea` 会按容器和帧尺寸默认居中放大底图并保留边距右下角显示“当前帧XX/XXX”`FrameTimeline` 会根据已保存标注回显到 `Mask.metadata` 的传播来源,把自动传播生成的帧在视频处理进度条显示为蓝色区段,人工/AI 标注帧显示红色竖线;每次自动传播成功处理帧后,`VideoWorkspace` 会把本次传播范围作为当前会话历史片段传给 `FrameTimeline`,在视频处理进度条上叠加同一蓝色系、最新传播最亮、旧传播逐次变暗且第 5 次及更早统一为阈值旧记录色的纯色条;传播历史条只显示当前仍有自动传播 mask 的帧,删除 mask 或清空范围后会按剩余传播 mask 自动裁剪,空帧不保留红/蓝颜色;视频处理进度条和红/蓝标识可点击跳转到对应帧;底部缩略图中人工/AI 标注帧用红色边框、自动传播/推理帧用蓝色边框,同一帧同时具备两种状态时红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍以青色外框高亮优先;若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注;进入自动传播、布尔操作或特定范围帧导出选择模式时,播放进度条和视频处理进度条会显示黄色范围框,并可点击/拖拽选择起止帧;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。
8. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、画笔和橡皮擦生成/编辑 polygon mask多边形可按 Enter 或点击首节点闭合;多边形/矩形/圆在右侧语义分类树未选中类别时会自动归入黑色 `maskid:0` 的“待分类”;画笔/橡皮擦可在左侧工具栏调整大小,画笔要求右侧语义分类树已有选中类别,画出的圆形连续笔触会在鼠标松开时一次性 union 成 mask若与当前选中 mask 连通则自动合并到该 mask橡皮擦要求已选中 mask 并在松开时从该 mask 中 difference 扣除;普通 mask 和导入 mask 都不显示黄色 seed point也不提供 seed point 拖动;未选中特定 mask 时Canvas 会按右侧语义分类树拖拽得到的内部覆盖优先级从低到高渲染 mask使高优先级类别显示在上层Canvas 左上角工具上下文提示会在切换工具或操作状态变化时短暂显示,数秒后自动隐藏,避免长期遮挡底图;工具栏有“调整多边形”入口,左侧 `ToolsPalette` 使用紧凑垂直布局并在高度不足时自身滚动,基础绘制、画笔/橡皮擦/自动传播、布尔/删除、导入/AI 入口之间用浅灰分隔线区分;橡皮擦下方提供彩色 AI 图标“自动传播”入口,布尔/删除组包含区域合并、重叠区域去除、`DEL` 和“清空遮罩”,其后通过 `data-testid="tool-group-separator"` 分隔紫色“导入 GT Mask”和 AI 智能分割入口;清空遮罩优先作用于当前帧选中 mask没有选中时作用于当前帧全部 mask无传播链结果时直接清当前帧存在传播链结果时弹窗选择只清当前帧、清空所有传播帧或取消,不再提供顶栏“清空片段遮罩”和当前清空/DEL 的按帧范围入口Canvas 右下角不再提供旧的“清空遮罩”或“应用分类”按钮,分类改由右侧语义分类树点击完成;工作区左侧工具栏不展示 AI 页的正向选点、反向选点和边界框选,也不重复放置撤销/重做;点击 mask 后可按住顶点直接拖动并实时更新 polygon顶点拖拽结束不会触发 Stage 平移或重置 Canvas 视口;也可删除 polygon 顶点、通过边中点或双击边界插入新顶点;多 polygon/分离区域组成的同一 mask 进入编辑时所有子区域都会显示顶点和插点手柄,同帧同传播链的分散 mask 点选时会联动高亮;带中空洞的 mask 会用 `metadata.polygonRingCounts` 记录外圈与内圈的 ring 分组,调整多边形时外圈和内洞都显示可编辑顶点和插点手柄,保存时把内洞拆到 `mask_data.holes`;选中整块 mask 可用 Delete/Backspace 或左侧 `DEL` 删除,已保存 mask 删除前会预检当前后端 annotation id只对仍存在的 id 调用后端删除,避免陈旧本地 id 产生 DELETE 404删除传播 seed 或任一传播结果时会扩展删除同一传播链上的自动传播 mask但保留其他帧独立 AI 推理或人工标注 mask区域合并/去除会隐藏编辑手柄并显示已选数量,第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,使用 `polygon-clipping` 做 union/difference若存在传播帧对应 mask 会先弹窗选择只处理当前帧、处理所有传播帧或按帧范围选择;按帧范围选择会进入时间轴范围选择并二次确认,只把同一布尔操作同步到所选范围内具备对应关系的传播帧;同步后的传播 mask 保留原 `source`/lineage metadata只进入 dirty 状态等待保存,不会在时间轴上变成人工/AI 标注帧;内含去除结果用 even-odd 规则渲染 holeZustand 维护 `maskHistory/maskFuture` 支持撤销/重做。 8. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、画笔和橡皮擦生成/编辑 polygon mask多边形可按 Enter 或点击首节点闭合;多边形/矩形/圆在右侧语义分类树未选中类别时会自动归入黑色 `maskid:0` 的“待分类”;画笔/橡皮擦可在左侧工具栏调整大小,画笔要求右侧语义分类树已有选中类别,画出的圆形连续笔触会在鼠标松开时一次性 union 成 mask若与当前选中 mask 连通则自动合并到该 mask橡皮擦要求已选中 mask 并在松开时从该 mask 中 difference 扣除;普通 mask 和导入 mask 都不显示黄色 seed point也不提供 seed point 拖动;未选中特定 mask 时Canvas 会按右侧语义分类树拖拽得到的内部覆盖优先级从低到高渲染 mask使高优先级类别显示在上层Canvas 左上角工具上下文提示会在切换工具或操作状态变化时短暂显示,数秒后自动隐藏,避免长期遮挡底图;工具栏有“调整多边形”入口,左侧 `ToolsPalette` 使用紧凑垂直布局并在高度不足时自身滚动,基础绘制、画笔/橡皮擦/自动传播、布尔/删除、导入/AI 入口之间用浅灰分隔线区分;橡皮擦下方提供彩色 AI 图标“自动传播”入口,布尔/删除组包含区域合并、重叠区域去除、`DEL` 和“清空遮罩”,其后通过 `data-testid="tool-group-separator"` 分隔紫色“导入 GT Mask”和 AI 智能分割入口;清空遮罩优先作用于当前帧选中 mask没有选中时作用于当前帧全部 mask无传播链结果时直接清当前帧存在传播链结果时弹窗同一行选择取消、只清当前帧、按帧范围选择或清空所有传播帧,按帧范围选择会进入时间轴范围选择并二次确认Canvas 右下角不再提供旧的“清空遮罩”或“应用分类”按钮,分类改由右侧语义分类树点击完成;工作区左侧工具栏不展示 AI 页的正向选点、反向选点和边界框选,也不重复放置撤销/重做;点击 mask 后可按住顶点直接拖动并实时更新 polygon顶点拖拽结束不会触发 Stage 平移或重置 Canvas 视口;也可删除 polygon 顶点、通过边中点或双击边界插入新顶点;多 polygon/分离区域组成的同一 mask 进入编辑时所有子区域都会显示顶点和插点手柄,同帧同传播链的分散 mask 点选时会联动高亮;带中空洞的 mask 会用 `metadata.polygonRingCounts` 记录外圈与内圈的 ring 分组,调整多边形时外圈和内洞都显示可编辑顶点和插点手柄,保存时把内洞拆到 `mask_data.holes`;选中整块 mask 可用 Delete/Backspace 或左侧 `DEL` 删除,已保存 mask 删除前会预检当前后端 annotation id只对仍存在的 id 调用后端删除,避免陈旧本地 id 产生 DELETE 404删除传播 seed 或任一传播结果时会扩展删除同一传播链上的自动传播 mask但保留其他帧独立 AI 推理或人工标注 mask区域合并/去除会隐藏编辑手柄并显示已选数量,第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,使用 `polygon-clipping` 做 union/difference若存在传播帧对应 mask 会先弹窗选择只处理当前帧、处理所有传播帧或按帧范围选择;按帧范围选择会进入时间轴范围选择并二次确认,只把同一布尔操作同步到所选范围内具备对应关系的传播帧;同步后的传播 mask 保留原 `source`/lineage metadata只进入 dirty 状态等待保存,不会在时间轴上变成人工/AI 标注帧;内含去除结果用 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并用传播起始帧和传播结束帧指定追踪范围如果当前参考帧没有 mask点击开始传播会提示“当前参考帧无遮罩”不会提交任务或保存其它帧标注用户点击左侧工具栏橡皮擦下方的彩色 AI 图标“自动传播”进入时间轴范围选择模式,在播放进度条或视频处理进度条上点击/拖拽选择范围,也可直接修改数字框,再点击顶栏“开始传播”。传播权重选择器只在进入自动传播选择/执行状态后显示,可为本次传播二次选择 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` 后台任务;同一参考帧多个同类别 mask 会各自作为独立 seed 传播,后端按 `source_annotation_id/source_mask_id/propagation_seed_key` 区分实例,避免同类不同实例互相删除;中空 seed 会携带和 `polygons` 对齐的 `holes`,后端注入 SAM 2 video predictor 前会先填充外圈再扣除内洞,避免以实心 mask 传播;后端入队时会规范化/校验权重 id 并把规范化后的 id 写入任务 payload/resultCelery worker 顺序执行各 step避免多个视频 tracker 并发抢占 GPU每个 step 会根据 seed 来源 id、方向和包含 `holes` 的 seed 签名做幂等判断,同权重且未改变的 seed 直接跳过,已改变或换用其他权重的 seed 会先删除同源旧自动传播标注再重传;旧版本缺少稳定来源 id 的传播标注只在没有可靠 lineage 时走 label/color/class 兼容匹配,写入新结果前仍会通过空间重叠清理同一物体旧结果;中间帧人工新增/修改同一物体后重新传播时,后端会在写入目标帧新结果前按语义和空间重叠清理旧传播结果,且写入前清理不受旧结果传播方向限制;后端按项目帧序列下载片段帧,当前使用所选 SAM 2.1 权重变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,并把后续帧结果保存为 `Annotation`,传播结果轮廓用 CCOMP 层级提取并把内洞写入 `mask_data.holes`;若历史或外部 seed 仍带 `geometry_smoothing`forward/backward 两个方向的传播结果保存前仍会应用同一参数;当前工作区平滑按钮应用后会直接改写实际 polygon后续传播以新几何参与签名和追踪。工作区轮询 `GET /api/tasks/{task_id}` 展示进度并刷新标注Dashboard 也能显示/取消/重试传播任务。 10. 视频片段传播:工作区以当前打开帧作为参考帧,使用该帧全部 mask 作为 seed并用传播起始帧和传播结束帧指定追踪范围如果当前参考帧没有 mask点击开始传播会提示“当前参考帧无遮罩”不会提交任务或保存其它帧标注用户点击左侧工具栏橡皮擦下方的彩色 AI 图标“自动传播”进入时间轴范围选择模式,在播放进度条或视频处理进度条上点击/拖拽选择范围,也可直接修改数字框,再点击顶栏“开始传播”。传播权重选择器只在进入自动传播选择/执行状态后显示,可为本次传播二次选择 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` 后台任务;同一参考帧多个同类别 mask 会各自作为独立 seed 传播,后端按 `source_annotation_id/source_mask_id/propagation_seed_key` 区分实例,避免同类不同实例互相删除;中空 seed 会携带和 `polygons` 对齐的 `holes`,后端注入 SAM 2 video predictor 前会先填充外圈再扣除内洞,避免以实心 mask 传播;后端入队时会规范化/校验权重 id 并把规范化后的 id 写入任务 payload/resultCelery worker 顺序执行各 step避免多个视频 tracker 并发抢占 GPU每个 step 会根据 seed 来源 id、方向和包含 `holes` 的 seed 签名做幂等判断,同权重且未改变的 seed 直接跳过,已改变或换用其他权重的 seed 会先删除同源旧自动传播标注再重传;旧版本缺少稳定来源 id 的传播标注只在没有可靠 lineage 时走 label/color/class 兼容匹配,写入新结果前仍会通过空间重叠清理同一物体旧结果;中间帧人工新增/修改同一物体后重新传播时,后端会在写入目标帧新结果前按语义和空间重叠清理旧传播结果,且写入前清理不受旧结果传播方向限制;后端按项目帧序列下载片段帧,当前使用所选 SAM 2.1 权重变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,并把后续帧结果保存为 `Annotation`,传播结果轮廓用 CCOMP 层级提取并把内洞写入 `mask_data.holes`;若历史或外部 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 并校验 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。 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。
@@ -267,7 +267,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
- 右侧实例属性面板“边缘平滑强度/应用边缘平滑”已接入 `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 后台任务,运行中轮询任务进度,完成后刷新后端已保存标注;同一参考帧多个同类别 seed 会按来源 id 分开传播,不会因 label/color 相同互相覆盖;中空 seed 会把内洞传给后端SAM 2 seed mask 栅格化时扣除内洞,传播结果保存时也会保留 `holes`GPU/CPU 模型状态只在左侧 Sidebar 底部用紧凑徽标展示,工作区顶栏不再重复显示,具体 SAM 2.1 传播权重由顶栏下拉选择;同步 `POST /api/ai/propagate` 仍作为单 seed 兼容接口保留。 - 工作区“自动传播”按钮位于左侧工具栏橡皮擦下方,并已接入 `POST /api/ai/propagate/task`;若用户尚未显式设置范围,第一次点击会进入时间轴范围选择模式,顶栏才显示传播权重和向前/向后帧数,第二次点击“开始传播”才提交后台任务;当前启用所选 SAM 2.1 变体的视频 predictor 后台任务,运行中轮询任务进度,完成后刷新后端已保存标注;同一参考帧多个同类别 seed 会按来源 id 分开传播,不会因 label/color 相同互相覆盖;中空 seed 会把内洞传给后端SAM 2 seed mask 栅格化时扣除内洞,传播结果保存时也会保留 `holes`GPU/CPU 模型状态只在左侧 Sidebar 底部用紧凑徽标展示,工作区顶栏不再重复显示,具体 SAM 2.1 传播权重由顶栏下拉选择;同步 `POST /api/ai/propagate` 仍作为单 seed 兼容接口保留。
- 工作区顶栏短状态会自动消失;保存、导出、导入 GT、传播进行中和无帧项目提示会保留到状态变化。 - 工作区顶栏短状态会自动消失;保存、导出、导入 GT、传播进行中和无帧项目提示会保留到状态变化。
- 工作区“清空遮罩”和左侧 `DEL` 删除只从左侧工具栏或键盘触发,会在删除已保存标注前预检当前项目仍存在的 annotation id只对存在的 id 调用 `DELETE /api/ai/annotations/{id}`;如果当前帧有选中 mask 则优先清/删选中 mask没有选中时清当前帧全部 mask如果对象没关联其它传播帧则直接处理当前帧如果存在传播链结果则弹窗选择只处理当前帧、清空所有传播帧或取消;工作区顶栏不再提供重复的“清空片段遮罩”,当前清空/DEL 也不再进入清空片段范围模式;不会删除其它帧独立 AI 推理或人工标注 mask。 - 工作区“清空遮罩”和左侧 `DEL` 删除只从左侧工具栏或键盘触发,会在删除已保存标注前预检当前项目仍存在的 annotation id只对存在的 id 调用 `DELETE /api/ai/annotations/{id}`;如果当前帧有选中 mask 则优先清/删选中 mask没有选中时清当前帧全部 mask如果对象没关联其它传播帧则直接处理当前帧如果存在传播链结果则弹窗在同一行选择取消、只处理当前帧、按帧范围选择或清空所有传播帧;按帧范围选择复用工作区时间轴范围选择和最终确认弹窗;工作区顶栏不再提供重复的“清空片段遮罩”;不会删除其它帧独立 AI 推理或人工标注 mask。
- 项目状态已统一为 `pending``parsing``ready``error`;前端 `src/lib/api.ts` 会兼容归一化旧库中可能存在的 `Ready``Parsing``Error` - 项目状态已统一为 `pending``parsing``ready``error`;前端 `src/lib/api.ts` 会兼容归一化旧库中可能存在的 `Ready``Parsing``Error`
- 项目库的视频导入与生成帧是两个独立动作:导入视频只上传源文件,并通过 Axios `onUploadProgress` 在项目库显示导入进度;生成帧按钮才会带 `parse_fps` 调用 `/api/media/parse`DICOM 批量导入也会显示上传进度和文件数量,上传完成后创建解析任务并轮询显示解析进度。工作区不会再因“有视频但无帧”自动创建拆帧任务。 - 项目库的视频导入与生成帧是两个独立动作:导入视频只上传源文件,并通过 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` 等接口。

View File

@@ -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 多类别导入、标注新增和 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 数据结构;无选中分类的多边形/矩形/圆会默认归入 `maskid:0` 的“待分类”。 - 工作区顶部“分割结果导出”和保存状态按钮、左侧工具栏“导入 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 数据结构;无选中分类的多边形/矩形/圆会默认归入 `maskid:0` 的“待分类”。
- 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

@@ -81,7 +81,7 @@
| “分割结果导出”按钮 | 真实可用 | 原“导出 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 会拒绝导出 | | “分割结果导出”按钮 | 真实可用 | 原“导出 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`,后端仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,不符合 8-bit 灰度/maskid 图要求时返回错误16-bit/uint16 GT_label 会被拒绝;尺寸不同会自动最近邻拉伸到当前帧,再按类别/连通域生成高精度 polygon 标注,最后回显到工作区;导入 mask 与普通 mask 一样不显示黄色 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左侧工具栏橡皮擦下方有彩色 AI 图标“自动传播”入口,点击后进入时间轴范围选择模式,顶栏才显示独立“传播权重”下拉,可在传播前二次选择 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 predictor同一参考帧多个同类别 seed 会按来源 id 分开传播worker 会在本次目标帧段内按 seed 来源和几何/语义签名做幂等判断,未改变且目标帧已有结果的 seed 直接跳过,已改变、目标帧只部分覆盖或换权重时会先删除本次目标帧段内同源旧自动传播标注再重新传播;历史或外部 seed 若仍带边缘平滑参数,后端仍按完整签名兼容处理;当前前端平滑应用会直接改写 polygon因此传播以新几何参与签名中间帧人工新增/修改同一物体后重新传播时,后端会按语义和目标帧空间重叠清理旧传播结果,写入前清理不受旧结果 `propagation_direction` 限制,避免 backward 重传时与旧 forward mask 重叠;传播中顶栏显示任务进度、已处理帧次、删除旧区域数和已保存区域数,前端轮询 `GET /api/tasks/{task_id}` 并刷新已保存标注;任务可取消,若完成后 0 个新区域会明确提示没有生成新 mask 或已跳过未改变 mask | | 参考帧/起止帧/传播权重/自动传播 | 真实可用 | 当前打开帧即参考帧,前端会使用该帧全部 mask 作为 seed左侧工具栏橡皮擦下方有彩色 AI 图标“自动传播”入口,点击后进入时间轴范围选择模式,顶栏才显示独立“传播权重”下拉,可在传播前二次选择 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 predictor同一参考帧多个同类别 seed 会按来源 id 分开传播worker 会在本次目标帧段内按 seed 来源和几何/语义签名做幂等判断,未改变且目标帧已有结果的 seed 直接跳过,已改变、目标帧只部分覆盖或换权重时会先删除本次目标帧段内同源旧自动传播标注再重新传播;历史或外部 seed 若仍带边缘平滑参数,后端仍按完整签名兼容处理;当前前端平滑应用会直接改写 polygon因此传播以新几何参与签名中间帧人工新增/修改同一物体后重新传播时,后端会按语义和目标帧空间重叠清理旧传播结果,写入前清理不受旧结果 `propagation_direction` 限制,避免 backward 重传时与旧 forward mask 重叠;传播中顶栏显示任务进度、已处理帧次、删除旧区域数和已保存区域数,前端轮询 `GET /api/tasks/{task_id}` 并刷新已保存标注;任务可取消,若完成后 0 个新区域会明确提示没有生成新 mask 或已跳过未改变 mask |
| 清空片段遮罩 | 已移除 | 顶栏不再提供重复的“清空片段遮罩”;当前帧清空和 DEL 删除只从左侧工具栏或键盘触发,存在传播链时只弹出当前帧/所有传播帧/取消,不再进入清空片段范围模式 | | 清空片段遮罩 | 已移除 | 顶栏不再提供重复的“清空片段遮罩”;当前帧清空和 DEL 删除只从左侧工具栏或键盘触发,存在传播链时在同一弹窗提供取消/只清当前帧/按帧范围选择/清空所有传播帧 |
| 保存状态按钮 | 真实可用 | 顶栏按钮按当前项目待保存数量显示为“保存 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避免仍显示未保存 |
## CanvasArea 画布 ## CanvasArea 画布
@@ -102,7 +102,7 @@
| 传播链跨帧选区跟随 | 真实可用 | 用户选中某个 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 顶点;多 polygon 或分离区域组成的同一个 mask 会显示所有子区域顶点,不再只显示主区域;按住顶点即可直接拖动并实时重算 `pathData/segmentation/bbox/area`,不需要先单击选中顶点,已保存 mask 标为 dirty顶点拖拽结束不会触发 Stage 平移Canvas 当前缩放和位置保持不变;选中顶点后 Delete/Backspace 可删点但保留至少三点;选中 mask 但未选中顶点时 Delete/Backspace 删除整个 mask左侧 DEL 按钮复用同一链路;已保存 mask 删除前会预检当前后端 annotation id 并只删除仍存在的 id避免陈旧本地 id 产生 DELETE 404若删除对象是传播 seed 或传播结果,前端会按 `source_annotation_id``source_mask_id``propagation_seed_key` 同步删除同链自动传播 mask但不删除其他帧独立 AI 推理/人工 mask | | Polygon 逐点编辑 / 删除 | 真实可用 | 点击 mask 后显示 polygon 顶点;多 polygon 或分离区域组成的同一个 mask 会显示所有子区域顶点,不再只显示主区域;按住顶点即可直接拖动并实时重算 `pathData/segmentation/bbox/area`,不需要先单击选中顶点,已保存 mask 标为 dirty顶点拖拽结束不会触发 Stage 平移Canvas 当前缩放和位置保持不变;选中顶点后 Delete/Backspace 可删点但保留至少三点;选中 mask 但未选中顶点时 Delete/Backspace 删除整个 mask左侧 DEL 按钮复用同一链路;已保存 mask 删除前会预检当前后端 annotation id 并只删除仍存在的 id避免陈旧本地 id 产生 DELETE 404若删除对象是传播 seed 或传播结果,前端会按 `source_annotation_id``source_mask_id``propagation_seed_key` 同步删除同链自动传播 mask但不删除其他帧独立 AI 推理/人工 mask |
| 应用分类 | 真实可用 | Canvas 右下角不再提供“应用分类”快捷按钮,避免没选区时误改整帧;右侧语义分类树点击分类时会优先改当前已选 mask并通过 `source_annotation_id``source_mask_id``propagation_seed_key` 同步更新同一传播链上的前后传播 mask同时把已选 mask 移到前端渲染最上层方便继续编辑;已保存 mask 会标为 dirty归档保存时更新后端 | | 应用分类 | 真实可用 | Canvas 右下角不再提供“应用分类”快捷按钮,避免没选区时误改整帧;右侧语义分类树点击分类时会优先改当前已选 mask并通过 `source_annotation_id``source_mask_id``propagation_seed_key` 同步更新同一传播链上的前后传播 mask同时把已选 mask 移到前端渲染最上层方便继续编辑;已保存 mask 会标为 dirty归档保存时更新后端 |
| 清空遮罩 | 真实可用 | 工作区只通过左侧工具栏触发清空;当前帧有选中 mask 时清选中 mask没有选中时清当前帧全部 mask无传播链结果时直接执行存在传播链结果时弹窗选择只清当前帧、清空所有传播帧或取消;不会进入清空片段范围模式 | | 清空遮罩 | 真实可用 | 工作区只通过左侧工具栏触发清空;当前帧有选中 mask 时清选中 mask没有选中时清当前帧全部 mask无传播链结果时直接执行存在传播链结果时弹窗选择取消、只清当前帧、按帧范围选择或清空所有传播帧;按帧范围选择复用时间轴范围选择和最终确认;不会删除其它帧独立 AI 推理或人工 mask |
| 保存状态计数 | 真实可用 | 底部显示已保存、未保存、待更新数量 | | 保存状态计数 | 真实可用 | 底部显示已保存、未保存、待更新数量 |
| 当前图层信息 | 真实可用 | 根据当前选中 mask 显示真实标签/后端 annotation id未保存 mask 显示“未保存”,未选中时显示“未选择” | | 当前图层信息 | 真实可用 | 根据当前选中 mask 显示真实标签/后端 annotation id未保存 mask 显示“未保存”,未选中时显示“未选择” |

View File

@@ -61,7 +61,7 @@
- Canvas 支持滚轮缩放、移动工具拖拽、鼠标坐标显示。 - Canvas 支持滚轮缩放、移动工具拖拽、鼠标坐标显示。
- Canvas 未选中特定 mask 时mask 显示顺序必须遵循右侧“语义分类树”拖拽得到的内部覆盖优先级:低优先级先渲染,高优先级后渲染并显示在上层;选中 mask 后可以为了编辑交互临时置顶。 - Canvas 未选中特定 mask 时mask 显示顺序必须遵循右侧“语义分类树”拖拽得到的内部覆盖优先级:低优先级先渲染,高优先级后渲染并显示在上层;选中 mask 后可以为了编辑交互临时置顶。
- 时间轴支持缩略图点击切帧、range 拖动切帧、视频处理进度条点击切帧、人工/AI 标注帧和自动传播帧标识点击切帧、键盘左右方向键切帧、播放/暂停顺序推进帧。 - 时间轴支持缩略图点击切帧、range 拖动切帧、视频处理进度条点击切帧、人工/AI 标注帧和自动传播帧标识点击切帧、键盘左右方向键切帧、播放/暂停顺序推进帧。
- 清空片段遮罩进入范围选择后必须提供两种模式:`清空全部` 会清空范围内所有 mask若包含人工绘制或 AI 智能分割生成的“人工/AI 标注帧”必须弹出“是否清除“人工/AI标注帧””确认`保留人工/AI` 只清空范围内自动传播/推理 mask人工/AI 标注帧必须保留且不弹出人工帧确认;用户取消确认时不能删除本地 mask、后端标注或传播历史条。 - 顶栏旧“清空片段遮罩”入口已移除;当前清空/DEL 只在目标 mask 存在传播链结果时进入范围选择。用户选择按帧范围清空后,必须复用时间轴范围选择并最终确认;范围内只清空同一传播链自动传播结果,不能清空无关人工绘制或独立 AI 智能分割 mask。用户取消确认时不能删除本地 mask、后端标注或传播历史条。
- 用户在某帧选中 mask 后,如果切换到同一自动传播结果覆盖的其他帧,工作区应自动识别并选中目标帧中对应的传播 mask匹配依据为传播结果回显到 mask metadata 的 seed 来源和传播链字段,而不是仅凭标签或颜色。 - 用户在某帧选中 mask 后,如果切换到同一自动传播结果覆盖的其他帧,工作区应自动识别并选中目标帧中对应的传播 mask匹配依据为传播结果回显到 mask metadata 的 seed 来源和传播链字段,而不是仅凭标签或颜色。
- 播放帧率使用项目 `parse_fps``original_fps`,限制在 1 到 30 FPS。 - 播放帧率使用项目 `parse_fps``original_fps`,限制在 1 到 30 FPS。
- 时间轴显示当前帧时间和总时长,时间基准使用项目 `parse_fps``original_fps`,格式为 `mm:ss.cc` - 时间轴显示当前帧时间和总时长,时间基准使用项目 `parse_fps``original_fps`,格式为 `mm:ss.cc`
@@ -152,7 +152,7 @@
- 当前前端保存状态按钮会保存当前项目未保存 mask并会更新已标记为 dirty 的已保存 mask。 - 当前前端保存状态按钮会保存当前项目未保存 mask并会更新已标记为 dirty 的已保存 mask。
- 如果 dirty mask 携带的本地旧 `annotationId` 在后端已经不存在,前端保存链路必须先用当前后端标注列表做存在性预检,已知缺失的 id 直接用同一几何和 metadata 重新 `POST` 创建标注;如果预检后发生并发删除导致 `PATCH` 返回 404也必须降级为重新创建并重新拉取后端标注替换本地旧 id点击“开始传播”前的参考帧保存也必须复用该容错逻辑不能因陈旧 id 中断传播。 - 如果 dirty mask 携带的本地旧 `annotationId` 在后端已经不存在,前端保存链路必须先用当前后端标注列表做存在性预检,已知缺失的 id 直接用同一几何和 metadata 重新 `POST` 创建标注;如果预检后发生并发删除导致 `PATCH` 返回 404也必须降级为重新创建并重新拉取后端标注替换本地旧 id点击“开始传播”前的参考帧保存也必须复用该容错逻辑不能因陈旧 id 中断传播。
- 保存成功后,前端会重新拉取后端标注,并用后端 saved annotation 替换本次提交的 draft mask未提交的其他 draft mask 仍保留。 - 保存成功后,前端会重新拉取后端标注,并用后端 saved annotation 替换本次提交的 draft mask未提交的其他 draft mask 仍保留。
- 工作区“清空遮罩”只从左侧工具栏触发;当前帧有选中 mask 时以选中 mask 为对象,没有选中时以当前帧全部 mask 为对象。若目标 mask 没有关联其它传播帧,则直接删除当前帧已保存标注并清空当前帧未保存 mask不弹确认若目标 mask 存在传播链上的其它帧结果,则弹出范围确认,用户可选择“只清当前帧”、“清空所有传播帧”、“按帧范围选择”或取消;按帧范围选择进入和清空片段遮罩一致的时间轴范围选择模式,并提供“清空全部”和“保留人工/AI”。清空所有传播帧只同步清空同传播链自动传播结果,不能删除其它帧独立 AI 推理或人工标注 mask。 - 工作区“清空遮罩”只从左侧工具栏触发;当前帧有选中 mask 时以选中 mask 为对象,没有选中时以当前帧全部 mask 为对象。若目标 mask 没有关联其它传播帧,则直接删除当前帧已保存标注并清空当前帧未保存 mask不弹确认若目标 mask 存在传播链上的其它帧结果,则弹出范围确认,用户可在同一行选择“取消”、“只清当前帧”、“按帧范围选择”或“清空所有传播帧”;按帧范围选择进入和自动传播/布尔操作一致的时间轴范围选择模式,并在顶栏“确认清空”后最终确认。清空所有传播帧或范围帧只同步清空同传播链自动传播结果,不能删除其它帧独立 AI 推理或人工标注 mask。
- 工作区加载项目帧后会查询已保存标注并回显。 - 工作区加载项目帧后会查询已保存标注并回显。
- 工作区支持导入 GT mask 图片,前端调用 `POST /api/ai/import-gt-mask` - 工作区支持导入 GT mask 图片,前端调用 `POST /api/ai/import-gt-mask`
- 导入 GT Mask 时,前端必须让用户选择未知 maskid 处理策略:舍弃未知类别,或导入为“未定义类别”等待后续重新命名。 - 导入 GT Mask 时,前端必须让用户选择未知 maskid 处理策略:舍弃未知类别,或导入为“未定义类别”等待后续重新命名。

View File

@@ -131,7 +131,7 @@
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. 工作区顶栏只在进入自动传播传播链布尔操作时显示对应范围控制;自动传播由左侧工具栏按钮进入范围选择,传播权重下拉旁显示当前权重和相对参考帧的向前/向后帧数,点击“开始传播”后提交后台任务。布尔操作范围选择时,顶栏按钮变为“确认区域合并”或“确认重叠区域去除”点击后弹出最终确认,再只对范围内存在对应传播链的帧执行。顶栏不再提供重复的“清空片段遮罩”,当前清空和 DEL 不再进入清空片段范围模式 15. 工作区顶栏只在进入自动传播传播链布尔操作或传播链清空时显示对应范围控制;自动传播由左侧工具栏按钮进入范围选择,传播权重下拉旁显示当前权重和相对参考帧的向前/向后帧数,点击“开始传播”后提交后台任务。布尔操作范围选择时,顶栏按钮变为“确认区域合并”或“确认重叠区域去除”;清空范围选择时顶栏按钮变为“确认清空”;点击后弹出最终确认,再只对范围内存在对应传播链的帧执行。顶栏不再提供重复的“清空片段遮罩”。
### AI 点/框推理 ### AI 点/框推理
@@ -158,7 +158,7 @@
21. 新 mask 会带上当前选择的模板分类元数据,包括 `classId``className``classZIndex``metadata.source=ai_segmentation` 和保存状态 `draft` 21. 新 mask 会带上当前选择的模板分类元数据,包括 `classId``className``classZIndex``metadata.source=ai_segmentation` 和保存状态 `draft`
20. 顶栏保存状态按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;用户点击保存后,前端将像素 `segmentation` 转成 normalized `mask_data.polygons`;未保存 mask 调用 `POST /api/ai/annotate`dirty mask 会先读取当前后端标注 id 列表,已知存在的 id 调用 `PATCH /api/ai/annotations/{annotation_id}`,已知缺失的本地旧 id 直接保留同一 `mask_data`、几何、分类和传播 lineage metadata 改用 `POST /api/ai/annotate` 重新创建;如果预检后发生并发删除导致 `PATCH` 返回 404也会降级为重新创建并在随后回显时排除本地旧 mask id保存成功后本次提交的 draft mask id 会从本地保留列表中排除,并由后端 saved annotation 回显替换。 20. 顶栏保存状态按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;用户点击保存后,前端将像素 `segmentation` 转成 normalized `mask_data.polygons`;未保存 mask 调用 `POST /api/ai/annotate`dirty mask 会先读取当前后端标注 id 列表,已知存在的 id 调用 `PATCH /api/ai/annotations/{annotation_id}`,已知缺失的本地旧 id 直接保留同一 `mask_data`、几何、分类和传播 lineage metadata 改用 `POST /api/ai/annotate` 重新创建;如果预检后发生并发删除导致 `PATCH` 返回 404也会降级为重新创建并在随后回显时排除本地旧 mask id保存成功后本次提交的 draft mask id 会从本地保留列表中排除,并由后端 saved annotation 回显替换。
21. 工作区加载项目帧后通过 `GET /api/ai/annotations` 取回已保存标注并转成前端 mask。 21. 工作区加载项目帧后通过 `GET /api/ai/annotations` 取回已保存标注并转成前端 mask。
22. 工作区“清空遮罩”只从左侧工具栏触发;如果当前帧存在选中 mask则以当前帧选中 mask 为清空对象,否则以当前帧全部 mask 为清空对象。如果清空对象没有关联其它传播帧,直接删除当前帧已保存标注并清除当前帧本地 mask不弹确认如果存在传播链结果`VideoWorkspace` 弹出范围选择,用户可选择只清当前帧、清空当前帧及同传播链所有自动传播帧或取消。本操作不删除其它帧独立 AI 推理或人工 mask。左侧工具栏的 `DEL` 按钮和键盘 Delete/Backspace 删除整块 mask 时复用同一传播链范围确认;删除已保存标注前会通过 `GET /api/ai/annotations` 预检当前项目仍存在的 annotation id只对存在的 id 发送 `DELETE` 22. 工作区“清空遮罩”只从左侧工具栏触发;如果当前帧存在选中 mask则以当前帧选中 mask 为清空对象,否则以当前帧全部 mask 为清空对象。如果清空对象没有关联其它传播帧,直接删除当前帧已保存标注并清除当前帧本地 mask不弹确认如果存在传播链结果`VideoWorkspace` 弹出范围选择,用户可在同一行选择取消、只清当前帧、按帧范围选择或清空当前帧及同传播链所有自动传播帧;按帧范围选择复用时间轴范围选择并在顶栏“确认清空”后最终确认。本操作不删除其它帧独立 AI 推理或人工 mask。左侧工具栏的 `DEL` 按钮和键盘 Delete/Backspace 删除整块 mask 时复用同一传播链范围确认;删除已保存标注前会通过 `GET /api/ai/annotations` 预检当前项目仍存在的 annotation id只对存在的 id 发送 `DELETE`
### 视频片段传播 ### 视频片段传播

View File

@@ -17,7 +17,7 @@
| 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 更新、项目卡片复制/删除、修改项目名称时隐藏生成帧、DICOM 项目不显示生成帧、复制项目 reset/full 契约、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` | 视频导入不自动拆帧、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式生成帧 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 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选只清当前帧/清空所有传播帧/取消、顶栏不显示重复的清空片段遮罩、当前清空和 DEL 不进入清空片段范围模式、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 annotation id 并跳过本地陈旧 id、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、自动传播范围选择时显示传播权重和向前/向后帧数、缩略图/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 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选取消/只清当前帧/按帧范围选择/清空所有传播帧且按范围清空需最终确认、顶栏不显示重复的清空片段遮罩、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 annotation id 并跳过本地陈旧 id、删除单个传播 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/lib/keyboardShortcuts.test.ts`, `src/store/useStore.test.ts` | 工具切换、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、清空遮罩唯一左侧工具栏入口、清空遮罩上方 DEL 删除按钮、橡皮擦下方彩色 AI 自动传播入口、Canvas 右下角不再重复显示清空遮罩或应用分类按钮、GT Mask 导入位于清空遮罩分隔线之后且使用紫色底色、工具栏分隔线位于创建圆后、自动传播后和清空遮罩后、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制且未选分类时默认待分类、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔新建当前类别 mask、画笔与选中 mask 连通时自动合并、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、多 polygon/分离区域全部显示编辑顶点、中空 mask 内洞 ring 顶点和插点可编辑、整块 mask 删除、DEL 和 Delete/Backspace 删除共用传播链范围确认、同帧传播链分散 mask 点选联动高亮、传播链自动传播 mask 随 seed/传播结果删除、独立 AI 推理 mask 不被误删、区域合并/去除存在传播帧时弹窗选择当前帧/所有传播帧/按帧范围选择、区域合并/去除按帧范围同步到对应传播帧且保留传播 metadata、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 hole 渲染和 ring 分组保存、合并模式隐藏编辑手柄、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z、Ctrl/Cmd+Y、物理键码 fallback 和输入框快捷键跳过、撤销/重做历史栈 | | R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/lib/keyboardShortcuts.test.ts`, `src/store/useStore.test.ts` | 工具切换、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、清空遮罩唯一左侧工具栏入口、清空遮罩上方 DEL 删除按钮、橡皮擦下方彩色 AI 自动传播入口、Canvas 右下角不再重复显示清空遮罩或应用分类按钮、GT Mask 导入位于清空遮罩分隔线之后且使用紫色底色、工具栏分隔线位于创建圆后、自动传播后和清空遮罩后、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制且未选分类时默认待分类、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔新建当前类别 mask、画笔与选中 mask 连通时自动合并、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、多 polygon/分离区域全部显示编辑顶点、中空 mask 内洞 ring 顶点和插点可编辑、整块 mask 删除、DEL 和 Delete/Backspace 删除共用传播链范围确认、同帧传播链分散 mask 点选联动高亮、传播链自动传播 mask 随 seed/传播结果删除、独立 AI 推理 mask 不被误删、区域合并/去除存在传播帧时弹窗选择当前帧/所有传播帧/按帧范围选择、区域合并/去除按帧范围同步到对应传播帧且保留传播 metadata、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 hole 渲染和 ring 分组保存、合并模式隐藏编辑手柄、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z、Ctrl/Cmd+Y、物理键码 fallback 和输入框快捷键跳过、撤销/重做历史栈 |
| 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 和起止帧范围自动传播、同类多实例按来源 id 分开传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空传播 seed 扣除 holes 后注入 SAM 2 且传播结果保留 holes、历史平滑 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 和起止帧范围自动传播、同类多实例按来源 id 分开传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空传播 seed 扣除 holes 后注入 SAM 2 且传播结果保留 holes、历史平滑 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 标注、dirty 本地旧 annotationId 预检缺失时直接重新 POST 创建、预检后 PATCH 404 时重新 POST 创建并回显替换、中空 mask 保存为 `polygons` + `holes` 并可回显为 ring 分组、清空删除已保存标注、GT mask 多类别导入、高精度 GT contour、导入 mask 可直接拓扑统计和边缘平滑、后端 seed point 归一化兼容但前端不显示或拖动、缺失 seed point 的普通 polygon 保存时自动写入代表点、项目不存在、帧不存在 | | 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 标注、dirty 本地旧 annotationId 预检缺失时直接重新 POST 创建、预检后 PATCH 404 时重新 POST 创建并回显替换、中空 mask 保存为 `polygons` + `holes` 并可回显为 ring 分组、清空删除已保存标注、GT mask 多类别导入、高精度 GT contour、导入 mask 可直接拓扑统计和边缘平滑、后端 seed point 归一化兼容但前端不显示或拖动、缺失 seed point 的普通 polygon 保存时自动写入代表点、项目不存在、帧不存在 |
@@ -64,7 +64,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 时,会分别跳过或清理旧传播标注再保存新结果。
- R5/R6/R7补充中空 mask 回归测试,验证保存时拆分 `polygons`/`holes` 并回显为 ring 分组,调整多边形时内洞显示可编辑顶点,以及 SAM 2 seed mask 会扣除 holes、传播结果轮廓提取会保留 holes。 - R5/R6/R7补充中空 mask 回归测试,验证保存时拆分 `polygons`/`holes` 并回显为 ring 分组,调整多边形时内洞显示可编辑顶点,以及 SAM 2 seed mask 会扣除 holes、传播结果轮廓提取会保留 holes。
- R7补充 dirty 本地旧 annotationId 回归测试,验证后端标注 id 预检已缺失时会跳过失败 PATCH、直接 `POST /api/ai/annotate` 重新创建;同时验证预检后 `PATCH /api/ai/annotations/{id}` 返回 404 时,保存链路也会改用 `POST` 重新创建并用回显标注替换本地旧 mask。 - R7补充 dirty 本地旧 annotationId 回归测试,验证后端标注 id 预检已缺失时会跳过失败 PATCH、直接 `POST /api/ai/annotate` 重新创建;同时验证预检后 `PATCH /api/ai/annotations/{id}` 返回 404 时,保存链路也会改用 `POST` 重新创建并用回显标注替换本地旧 mask。
- R4/R5/R8/R9补充模板切换、工具栏清空入口和传播链布尔操作回归测试验证已有 mask 切换模板需确认清空,模板详情按钮改为“编辑模板”,当前帧清空会在传播链存在时提供只清当前帧/清空所有传播帧/按帧范围选择/取消,区域合并/去除会在存在传播帧时先选择当前帧/所有传播帧/按帧范围选择并保留传播 metadata。 - R4/R5/R8/R9补充模板切换、工具栏清空入口和传播链布尔操作回归测试验证已有 mask 切换模板需确认清空,模板详情按钮改为“编辑模板”,当前帧清空会在传播链存在时同一行提供取消/只清当前帧/按帧范围选择/清空所有传播帧,区域合并/去除会在存在传播帧时同一行选择取消/按帧范围选择/当前帧/所有传播帧并保留传播 metadata。
- 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补充 `ProjectLibrary.test.tsx``api.test.ts` 中上传进度测试,验证视频/DICOM 上传通过 Axios `onUploadProgress` 回调更新项目库导入进度条,并显示 DICOM 文件数量和解析任务轮询进度。

View File

@@ -1043,7 +1043,12 @@ describe('CanvasArea', () => {
fireEvent.click(paths[0]); fireEvent.click(paths[0]);
fireEvent.click(paths[1]); fireEvent.click(paths[1]);
fireEvent.click(screen.getByRole('button', { name: '合并选中' })); fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' })); const rangeButton = screen.getByRole('button', { name: '按帧范围选择' });
expect(rangeButton.parentElement).toHaveClass('grid-cols-4');
expect(screen.getByRole('button', { name: '取消' }).parentElement).toBe(rangeButton.parentElement);
expect(screen.getByRole('button', { name: '只处理当前帧' }).parentElement).toBe(rangeButton.parentElement);
expect(screen.getByRole('button', { name: '处理所有传播帧' }).parentElement).toBe(rangeButton.parentElement);
fireEvent.click(rangeButton);
expect(onRequestBooleanFrameRange).toHaveBeenCalledWith(expect.objectContaining({ expect(onRequestBooleanFrameRange).toHaveBeenCalledWith(expect.objectContaining({
operation: 'area_merge', operation: 'area_merge',

View File

@@ -1812,7 +1812,7 @@ export function CanvasArea({
{pendingBooleanFrameIds && frame && ( {pendingBooleanFrameIds && frame && (
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/55 px-4"> <div className="absolute inset-0 z-30 flex items-center justify-center bg-black/55 px-4">
<div className="w-full max-w-sm rounded-lg border border-emerald-400/25 bg-[#151515] p-4 shadow-2xl"> <div className="w-full max-w-lg rounded-lg border border-emerald-400/25 bg-[#151515] p-4 shadow-2xl">
<h2 className="text-sm font-semibold text-white"></h2> <h2 className="text-sm font-semibold text-white"></h2>
<p className="mt-2 text-xs leading-relaxed text-gray-300"> <p className="mt-2 text-xs leading-relaxed text-gray-300">
@@ -1820,11 +1820,11 @@ export function CanvasArea({
<div className="mt-3 rounded-md border border-white/10 bg-white/[0.03] p-2 text-[11px] text-gray-400"> <div className="mt-3 rounded-md border border-white/10 bg-white/[0.03] p-2 text-[11px] text-gray-400">
{pendingBooleanFrameIds.length} {pendingBooleanFrameIds.length}
</div> </div>
<div className="mt-4 flex flex-wrap justify-end gap-2"> <div className="mt-4 grid grid-cols-4 gap-2">
<button <button
type="button" type="button"
onClick={() => setPendingBooleanFrameIds(null)} onClick={() => setPendingBooleanFrameIds(null)}
className="rounded border border-white/10 px-3 py-1.5 text-xs text-gray-300 hover:bg-white/5" className="rounded border border-white/10 px-2 py-1.5 text-xs text-gray-300 hover:bg-white/5"
> >
</button> </button>
@@ -1841,7 +1841,7 @@ export function CanvasArea({
}); });
setPendingBooleanFrameIds(null); setPendingBooleanFrameIds(null);
}} }}
className="rounded border border-amber-400/30 bg-amber-500/10 px-3 py-1.5 text-xs font-semibold text-amber-100 hover:bg-amber-500/20" className="rounded border border-amber-400/30 bg-amber-500/10 px-2 py-1.5 text-xs font-semibold text-amber-100 hover:bg-amber-500/20"
> >
</button> </button>
@@ -1849,14 +1849,14 @@ export function CanvasArea({
<button <button
type="button" type="button"
onClick={() => void runBooleanOperation(new Set([String(frame.id)]))} onClick={() => void runBooleanOperation(new Set([String(frame.id)]))}
className="rounded border border-emerald-400/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold text-emerald-100 hover:bg-emerald-500/20" className="rounded border border-emerald-400/30 bg-emerald-500/10 px-2 py-1.5 text-xs font-semibold text-emerald-100 hover:bg-emerald-500/20"
> >
</button> </button>
<button <button
type="button" type="button"
onClick={() => void runBooleanOperation(new Set(pendingBooleanFrameIds))} onClick={() => void runBooleanOperation(new Set(pendingBooleanFrameIds))}
className="rounded bg-emerald-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-400" className="rounded bg-emerald-500 px-2 py-1.5 text-xs font-semibold text-white hover:bg-emerald-400"
> >
</button> </button>

View File

@@ -810,7 +810,7 @@ describe('VideoWorkspace', () => {
expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument(); expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument();
}); });
it('keeps range clearing out of the top bar and current clear confirmation', async () => { it('offers current, range, and all propagation clear choices in one confirmation', async () => {
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 },
@@ -851,11 +851,81 @@ describe('VideoWorkspace', () => {
expect(screen.queryByRole('button', { name: '清空片段遮罩' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '清空片段遮罩' })).not.toBeInTheDocument();
fireEvent.click(screen.getByTitle('清空遮罩')); fireEvent.click(screen.getByTitle('清空遮罩'));
expect(screen.getByText('选择清空范围')).toBeInTheDocument(); expect(screen.getByText('选择清空范围')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '按帧范围选择' })).not.toBeInTheDocument(); const rangeButton = screen.getByRole('button', { name: '按帧范围选择' });
expect(rangeButton).toBeInTheDocument();
expect(rangeButton.parentElement).toHaveClass('grid-cols-4');
expect(screen.getByRole('button', { name: '取消' }).parentElement).toBe(rangeButton.parentElement);
expect(screen.getByRole('button', { name: '只清当前帧' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '只清当前帧' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '清空所有传播帧' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '清空所有传播帧' })).toBeInTheDocument();
}); });
it('clears propagated masks only within the selected frame range', 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 },
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
act(() => {
useStore.setState({
masks: [
{
id: 'annotation-1',
annotationId: '1',
frameId: '10',
pathData: 'M 0 0 Z',
label: 'Seed',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
},
{
id: 'annotation-10',
annotationId: '10',
frameId: '11',
pathData: 'M 1 1 Z',
label: 'Propagated',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
},
{
id: 'annotation-20',
annotationId: '20',
frameId: '12',
pathData: 'M 2 2 Z',
label: 'Propagated',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
},
],
});
});
fireEvent.click(screen.getByTitle('清空遮罩'));
fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' }));
expect(screen.getByText('请选择清空传播帧范围,再点击“确认清空”')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '2' } });
fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '2' } });
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
expect(screen.getByText(/清空同一传播链上的 1 个遮罩/)).toBeInTheDocument();
const confirmButtons = screen.getAllByRole('button', { name: '确认清空' });
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('1');
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('20');
expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-20']);
});
it('auto-saves pending masks before exporting segmentation results', async () => { it('auto-saves pending masks before exporting segmentation results', 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 },

View File

@@ -42,7 +42,7 @@ type PropagationHistorySegment = {
colorIndex: number; colorIndex: number;
label: string; label: string;
}; };
type RangeSelectionMode = 'propagation' | 'export' | 'boolean' | null; type RangeSelectionMode = 'propagation' | 'export' | 'boolean' | 'clear' | null;
type CurrentClearConfirmState = { type CurrentClearConfirmState = {
currentFrameNumber: number; currentFrameNumber: number;
scopeLabel: string; scopeLabel: string;
@@ -51,6 +51,15 @@ type CurrentClearConfirmState = {
currentMaskCount: number; currentMaskCount: number;
propagatedMaskCount: number; propagatedMaskCount: number;
}; };
type ClearPropagationRangeRequestState = CurrentClearConfirmState & {
candidateFrameIds: string[];
};
type ClearPropagationRangeConfirmState = {
request: ClearPropagationRangeRequestState;
targetMaskIds: string[];
rangeStartIndex: number;
rangeEndIndex: number;
};
type BooleanRangeConfirmState = { type BooleanRangeConfirmState = {
request: BooleanFrameRangeRequest; request: BooleanFrameRangeRequest;
targetFrameIds: string[]; targetFrameIds: string[];
@@ -477,6 +486,8 @@ 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 [pendingCurrentClearConfirm, setPendingCurrentClearConfirm] = useState<CurrentClearConfirmState | null>(null); const [pendingCurrentClearConfirm, setPendingCurrentClearConfirm] = useState<CurrentClearConfirmState | null>(null);
const [pendingClearPropagationRangeRequest, setPendingClearPropagationRangeRequest] = useState<ClearPropagationRangeRequestState | null>(null);
const [pendingClearPropagationRangeConfirm, setPendingClearPropagationRangeConfirm] = useState<ClearPropagationRangeConfirmState | null>(null);
const [pendingBooleanRangeRequest, setPendingBooleanRangeRequest] = useState<BooleanFrameRangeRequest | null>(null); const [pendingBooleanRangeRequest, setPendingBooleanRangeRequest] = useState<BooleanFrameRangeRequest | null>(null);
const [pendingBooleanRangeConfirm, setPendingBooleanRangeConfirm] = useState<BooleanRangeConfirmState | null>(null); const [pendingBooleanRangeConfirm, setPendingBooleanRangeConfirm] = useState<BooleanRangeConfirmState | null>(null);
const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false); const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
@@ -806,6 +817,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
if (masksToClear.length === 0) { if (masksToClear.length === 0) {
setStatusMessage('没有可清空的遮罩'); setStatusMessage('没有可清空的遮罩');
setPendingCurrentClearConfirm(null); setPendingCurrentClearConfirm(null);
setPendingClearPropagationRangeRequest(null);
setPendingClearPropagationRangeConfirm(null);
return; return;
} }
const annotationIds = Array.from(new Set( const annotationIds = Array.from(new Set(
@@ -825,6 +838,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
? `已清空${messageScope}${masksToClear.length} 个遮罩,其中后端标注 ${annotationIds.length}` ? `已清空${messageScope}${masksToClear.length} 个遮罩,其中后端标注 ${annotationIds.length}`
: `已清空${messageScope}${masksToClear.length} 个本地遮罩`); : `已清空${messageScope}${masksToClear.length} 个本地遮罩`);
setPendingCurrentClearConfirm(null); setPendingCurrentClearConfirm(null);
setPendingClearPropagationRangeRequest(null);
setPendingClearPropagationRangeConfirm(null);
} catch (err) { } catch (err) {
console.error('Delete annotations failed:', err); console.error('Delete annotations failed:', err);
setStatusMessage('删除失败,请检查后端服务'); setStatusMessage('删除失败,请检查后端服务');
@@ -904,6 +919,82 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
}); });
}, [currentFrame, currentFrameNumber, executeClearCurrentMasks]); }, [currentFrame, currentFrameNumber, executeClearCurrentMasks]);
const handleStartClearPropagationRange = useCallback((request: CurrentClearConfirmState) => {
const latestMasks = useStore.getState().masks;
const propagatedIdSet = new Set(request.propagatedMaskIds);
const candidateFrameIds = Array.from(new Set(
latestMasks
.filter((mask) => propagatedIdSet.has(mask.id))
.map((mask) => String(mask.frameId)),
));
const candidateFrameNumbers = candidateFrameIds
.map((frameId) => frameNumberById.get(String(frameId)))
.filter((frameNumber): frameNumber is number => Boolean(frameNumber));
if (candidateFrameNumbers.length === 0) {
setStatusMessage('没有可按帧范围清空的传播帧');
return;
}
const startFrame = Math.min(...candidateFrameNumbers);
const endFrame = Math.max(...candidateFrameNumbers);
setPendingCurrentClearConfirm(null);
setPendingClearPropagationRangeRequest({ ...request, candidateFrameIds });
setPendingClearPropagationRangeConfirm(null);
setPropagationStartFrame(startFrame);
setPropagationEndFrame(endFrame);
setHasExplicitPropagationRange(true);
setIsPropagationRangeSelecting(true);
setRangeSelectionMode('clear');
setStatusMessage('请选择清空传播帧范围,再点击“确认清空”');
}, [frameNumberById]);
const handleConfirmClearPropagationFrameRange = useCallback(() => {
if (!pendingClearPropagationRangeRequest || frames.length === 0) return;
const clampRangeFrameNumber = (value: number) => {
if (totalFrames <= 0) return 1;
return Math.min(Math.max(value, 1), totalFrames);
};
const startFrameNumber = clampRangeFrameNumber(propagationStartFrame);
const endFrameNumber = clampRangeFrameNumber(propagationEndFrame);
const rangeStartIndex = Math.min(startFrameNumber, endFrameNumber) - 1;
const rangeEndIndex = Math.max(startFrameNumber, endFrameNumber) - 1;
const rangeFrameIds = new Set(
frames.slice(rangeStartIndex, rangeEndIndex + 1).map((frame) => String(frame.id)),
);
const candidateFrameIdSet = new Set(pendingClearPropagationRangeRequest.candidateFrameIds.map(String));
const latestMasks = useStore.getState().masks;
const targetMaskIds = pendingClearPropagationRangeRequest.propagatedMaskIds
.filter((maskId) => {
const mask = latestMasks.find((item) => item.id === maskId);
return Boolean(mask && rangeFrameIds.has(String(mask.frameId)) && candidateFrameIdSet.has(String(mask.frameId)));
});
if (targetMaskIds.length === 0) {
setStatusMessage(`${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧没有可清空的传播链遮罩`);
return;
}
setPendingClearPropagationRangeConfirm({
request: pendingClearPropagationRangeRequest,
targetMaskIds,
rangeStartIndex,
rangeEndIndex,
});
}, [frames, pendingClearPropagationRangeRequest, propagationEndFrame, propagationStartFrame, totalFrames]);
const executeClearPropagationFrameRange = useCallback(async (confirmState: ClearPropagationRangeConfirmState) => {
await executeClearCurrentMasks(
confirmState.targetMaskIds,
`${confirmState.rangeStartIndex + 1}-${confirmState.rangeEndIndex + 1} 帧传播链`,
);
setPendingClearPropagationRangeConfirm(null);
setPendingClearPropagationRangeRequest(null);
setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false);
}, [executeClearCurrentMasks]);
const handleBooleanFrameRangeRequest = useCallback((request: BooleanFrameRangeRequest) => { const handleBooleanFrameRangeRequest = useCallback((request: BooleanFrameRangeRequest) => {
const candidateFrameNumbers = request.candidateFrameIds const candidateFrameNumbers = request.candidateFrameIds
.map((frameId) => frameNumberById.get(String(frameId))) .map((frameId) => frameNumberById.get(String(frameId)))
@@ -1288,7 +1379,11 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setPropagationStartFrame(nextStart); setPropagationStartFrame(nextStart);
setPropagationEndFrame(nextEnd); setPropagationEndFrame(nextEnd);
setHasExplicitPropagationRange(true); setHasExplicitPropagationRange(true);
const actionLabel = rangeSelectionMode === 'boolean' ? '布尔操作范围' : '自动传播范围'; const actionLabel = rangeSelectionMode === 'boolean'
? '布尔操作范围'
: rangeSelectionMode === 'clear'
? '清空传播帧范围'
: '自动传播范围';
setStatusMessage(`已选择${actionLabel}:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)}`); setStatusMessage(`已选择${actionLabel}:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)}`);
}, [clampFrameNumber, rangeSelectionMode]); }, [clampFrameNumber, rangeSelectionMode]);
@@ -1503,6 +1598,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setRangeSelectionMode(null); setRangeSelectionMode(null);
setPendingBooleanRangeRequest(null); setPendingBooleanRangeRequest(null);
setPendingBooleanRangeConfirm(null); setPendingBooleanRangeConfirm(null);
setPendingClearPropagationRangeRequest(null);
setPendingClearPropagationRangeConfirm(null);
setHasExplicitPropagationRange(false); setHasExplicitPropagationRange(false);
setPropagationStartFrame(currentFrameNumber || 1); setPropagationStartFrame(currentFrameNumber || 1);
setPropagationEndFrame(Math.min(Math.max(totalFrames, 1), (currentFrameNumber || 1) + 29)); setPropagationEndFrame(Math.min(Math.max(totalFrames, 1), (currentFrameNumber || 1) + 29));
@@ -1514,6 +1611,10 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setStatusMessage('已取消布尔操作范围选择'); setStatusMessage('已取消布尔操作范围选择');
return; return;
} }
if (previousMode === 'clear') {
setStatusMessage('已取消清空传播帧范围选择');
return;
}
setStatusMessage('已取消自动传播范围选择'); setStatusMessage('已取消自动传播范围选择');
}; };
@@ -1542,7 +1643,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
? Math.round((propagationProgress.completedSteps / Math.max(propagationProgress.totalSteps, 1)) * 100) ? Math.round((propagationProgress.completedSteps / Math.max(propagationProgress.totalSteps, 1)) * 100)
: 0; : 0;
const showPropagationControls = rangeSelectionMode === 'propagation' || isPropagating || Boolean(propagationTaskId); const showPropagationControls = rangeSelectionMode === 'propagation' || isPropagating || Boolean(propagationTaskId);
const showFrameRangeControls = showPropagationControls || rangeSelectionMode === 'boolean'; const showFrameRangeControls = showPropagationControls || rangeSelectionMode === 'boolean' || rangeSelectionMode === 'clear';
const selectedRangeStartFrame = Math.min(propagationStartFrame, propagationEndFrame); const selectedRangeStartFrame = Math.min(propagationStartFrame, propagationEndFrame);
const selectedRangeEndFrame = Math.max(propagationStartFrame, propagationEndFrame); const selectedRangeEndFrame = Math.max(propagationStartFrame, propagationEndFrame);
const propagationBackwardFrameCount = Math.max(0, currentFrameNumber - selectedRangeStartFrame); const propagationBackwardFrameCount = Math.max(0, currentFrameNumber - selectedRangeStartFrame);
@@ -1772,6 +1873,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
{booleanOperationLabel(pendingBooleanRangeRequest.operation)} {booleanOperationLabel(pendingBooleanRangeRequest.operation)}
</button> </button>
)} )}
{rangeSelectionMode === 'clear' && pendingClearPropagationRangeRequest && (
<button
onClick={handleConfirmClearPropagationFrameRange}
disabled={frames.length === 0 || isSaving || isExporting || isImportingGt || isPropagating}
title="按当前起止帧清空传播链遮罩"
className="px-3 py-1.5 bg-red-500/10 hover:bg-red-500/20 border border-red-500/25 rounded-md text-xs transition-colors text-red-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
)}
{showPropagationControls && ( {showPropagationControls && (
<button <button
onClick={handleAutoPropagate} onClick={handleAutoPropagate}
@@ -2013,20 +2124,20 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
{pendingCurrentClearConfirm && ( {pendingCurrentClearConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"> <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"> <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> <h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-2 text-sm leading-relaxed text-gray-300"> <p className="mt-2 text-sm leading-relaxed text-gray-300">
{pendingCurrentClearConfirm.scopeLabel} {pendingCurrentClearConfirm.scopeLabel}
</p> </p>
<div className="mt-3 rounded-md border border-white/10 bg-white/[0.03] p-3 text-xs leading-relaxed text-gray-400"> <div className="mt-3 rounded-md border border-white/10 bg-white/[0.03] p-3 text-xs leading-relaxed text-gray-400">
{pendingCurrentClearConfirm.currentMaskCount} mask + {pendingCurrentClearConfirm.propagatedMaskCount} mask {pendingCurrentClearConfirm.currentMaskCount} mask + {pendingCurrentClearConfirm.propagatedMaskCount} mask
</div> </div>
<div className="mt-5 flex flex-wrap justify-end gap-2"> <div className="mt-5 grid grid-cols-4 gap-2">
<button <button
type="button" type="button"
onClick={() => setPendingCurrentClearConfirm(null)} onClick={() => setPendingCurrentClearConfirm(null)}
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50" className="rounded border border-white/10 px-2 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
disabled={isSaving} disabled={isSaving}
> >
@@ -2034,15 +2145,23 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
<button <button
type="button" type="button"
onClick={() => void executeClearCurrentMasks(pendingCurrentClearConfirm.currentMaskIds, pendingCurrentClearConfirm.scopeLabel)} onClick={() => void executeClearCurrentMasks(pendingCurrentClearConfirm.currentMaskIds, pendingCurrentClearConfirm.scopeLabel)}
className="rounded border border-red-400/30 bg-red-500/10 px-3 py-2 text-xs font-semibold text-red-100 hover:bg-red-500/20 disabled:opacity-60" className="rounded border border-red-400/30 bg-red-500/10 px-2 py-2 text-xs font-semibold text-red-100 hover:bg-red-500/20 disabled:opacity-60"
disabled={isSaving} disabled={isSaving}
> >
</button> </button>
<button
type="button"
onClick={() => handleStartClearPropagationRange(pendingCurrentClearConfirm)}
className="rounded border border-amber-400/30 bg-amber-500/10 px-2 py-2 text-xs font-semibold text-amber-100 hover:bg-amber-500/20 disabled:opacity-60"
disabled={isSaving}
>
</button>
<button <button
type="button" type="button"
onClick={() => void executeClearCurrentMasks(pendingCurrentClearConfirm.propagatedMaskIds, '当前帧及传播链')} onClick={() => void executeClearCurrentMasks(pendingCurrentClearConfirm.propagatedMaskIds, '当前帧及传播链')}
className="rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:opacity-60" className="rounded bg-red-500 px-2 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:opacity-60"
disabled={isSaving} disabled={isSaving}
> >
@@ -2052,6 +2171,39 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
</div> </div>
)} )}
{pendingClearPropagationRangeConfirm && (
<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"></h2>
<p className="mt-2 text-sm leading-relaxed text-gray-300">
{pendingClearPropagationRangeConfirm.rangeStartIndex + 1}-{pendingClearPropagationRangeConfirm.rangeEndIndex + 1}
{pendingClearPropagationRangeConfirm.targetMaskIds.length}
</p>
<p className="mt-2 text-xs leading-relaxed text-red-100/70">
AI
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => setPendingClearPropagationRangeConfirm(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 executeClearPropagationFrameRange(pendingClearPropagationRangeConfirm)}
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"
>
</button>
</div>
</div>
</div>
)}
{pendingBooleanRangeConfirm && ( {pendingBooleanRangeConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"> <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-emerald-400/25 bg-[#151515] p-5 shadow-2xl"> <div className="w-full max-w-md rounded-lg border border-emerald-400/25 bg-[#151515] p-5 shadow-2xl">