From b7de163054379817caca02702d32e09ca6d2ea5c Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Mon, 4 May 2026 03:45:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BB=98=E5=88=B6=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E9=80=89=E5=8C=BA=E5=92=8C=E5=88=86=E7=B1=BB=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 切换到创建多边形、创建矩形或创建圆时同步清空本地和全局 mask 选区,避免右侧语义分类树误改旧 mask。 - 手工创建多边形、矩形、圆后自动选中新创建的 mask,方便继续改类、编辑或保存。 - Esc 统一清空当前 mask 选区和临时绘制状态,让画笔可以在无选区状态下新建独立 mask。 - 画笔合并、新建和橡皮擦扣除后同步更新全局选区,保持右侧面板状态一致。 - 补充 CanvasArea 和 OntologyInspector 回归测试,覆盖创建工具清选区、新建自动选中、Esc 后画笔新建、无选区分类不改已有 mask。 - 更新前端审计、需求冻结、测试计划和 AGENTS 文档,记录无选区语义分类与绘制选区规则。 --- AGENTS.md | 4 +- doc/03-frontend-element-audit.md | 6 +- doc/07-current-requirements-freeze.md | 6 +- doc/09-test-plan.md | 4 +- src/components/CanvasArea.test.tsx | 75 +++++++++++++++++++++++ src/components/CanvasArea.tsx | 51 +++++++++++---- src/components/OntologyInspector.test.tsx | 33 ++++++++++ 7 files changed, 158 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0e4504e..d1ad4b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`,会自动消失。 6. worker 执行:Celery worker 用 FFmpeg 优先拆视频帧,失败后用 OpenCV fallback,DICOM 使用 pydicom;worker 下载和读取 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` 会保留后端返回的帧序列时间戳和源帧号。 -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 点选时会按 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 或 `propagation_seed_signature` 联动高亮;对旧传播结果缺少这些稳定 lineage 的情况,会用传播来源、来源帧、分类/标签/颜色构造兼容分组,使同一传播 mask 拆出的不连通片段仍能一起高亮;从参考帧手工 mask 执行区域合并/去除同步到旧传播帧时,如果稳定 lineage 缺失,会在同来源帧且同语义/颜色的传播结果中选取空间最近者作为对应实例,避免漏合并同类不同实例;区域合并支持跨语义链路,当前帧把 A mask 合并进 B mask 时,传播帧中的 A 对应结果会并入 B 对应结果;若某个传播帧没有 B 对应结果但有 A 对应结果,则把该 A 结果转换为 B 语义并标记为 dirty;带中空洞的 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 规则渲染 hole;Zustand 维护 `maskHistory/maskFuture` 支持撤销/重做。 +8. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、画笔和橡皮擦生成/编辑 polygon mask;多边形可按 Enter 或点击首节点闭合;多边形/矩形/圆在右侧语义分类树未选中类别时会自动归入黑色 `maskid:0` 的“待分类”;切换到创建多边形、创建矩形或创建圆时会清空旧 mask 选区,创建完成后自动选中新 mask;按 `Esc` 会清空当前 mask 选区和临时绘制状态;画笔/橡皮擦可在左侧工具栏调整大小,画笔要求右侧语义分类树已有选中类别,画出的圆形连续笔触会在鼠标松开时一次性 union 成 mask,若与当前选中 mask 连通则自动合并到该 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 点选时会按 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 或 `propagation_seed_signature` 联动高亮;对旧传播结果缺少这些稳定 lineage 的情况,会用传播来源、来源帧、分类/标签/颜色构造兼容分组,使同一传播 mask 拆出的不连通片段仍能一起高亮;从参考帧手工 mask 执行区域合并/去除同步到旧传播帧时,如果稳定 lineage 缺失,会在同来源帧且同语义/颜色的传播结果中选取空间最近者作为对应实例,避免漏合并同类不同实例;区域合并支持跨语义链路,当前帧把 A mask 合并进 B mask 时,传播帧中的 A 对应结果会并入 B 对应结果;若某个传播帧没有 B 对应结果但有 A 对应结果,则把该 A 结果转换为 B 语义并标记为 dirty;带中空洞的 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 规则渲染 hole;Zustand 维护 `maskHistory/maskFuture` 支持撤销/重做。 9. AI 分割:侧栏和工作区工具栏的 AI 智能分割入口使用 Bot + Sparkles 组合图标强化 AI 识别;前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选;AI 画布会按容器和当前帧尺寸默认居中放大底图并保留边距;工作区和 AI 页面都可点击已有提示点删除单点,AI 页面也可删除最近锚点、删除选中候选或清空本页锚点;这些删除入口会限制在当前提示点/本页 AI 候选范围内,避免误删工作区已有 mask。SAM 2.1 框选会建立候选 mask,后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 mask;AI 页面框选会先固化 `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/result;Celery 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`,同一个 seed 在同一目标帧得到的多个不连通外轮廓会保存到同一个 annotation 的 `mask_data.polygons` 中,而不是拆成多个 mask;传播结果轮廓用 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 并校验 dtype;GT 图片必须是 8-bit 灰度 maskid 图,或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,0 为背景、X 为 1-255 的 maskid,16-bit/uint16 GT_label、普通彩色类别图和全背景 0 图都会返回明确错误;全背景图错误信息固定为“GT Mask 图片中没有非背景 maskid 区域。”;灰度/RGB 等通道图按模板 `maskId` 匹配类别,超出现有类别时按 `unknown_color_policy` 处理;如果 mask 图片尺寸和当前帧不同,会按当前帧长宽最近邻拉伸后再提取区域;每个连通域用高精度 contour 生成 polygon 标注,保留更多边界点并设置点数上限避免拖慢前端;导入结果与普通 mask 共用拓扑锚点统计、边缘平滑、顶点编辑、分类和保存链路;后端仍可写入 distance transform seed point 供数据兼容,但前端不显示或拖动 seed point。 @@ -262,7 +262,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload - 区域合并/去除会重算主 mask 的几何;合并已保存的次级 mask 时会通过工作区回调删除对应后端标注;若主区域和参与区域存在传播链对应 mask,会先弹窗选择当前帧、所有传播帧或按帧范围选择;按帧范围选择复用工作区时间轴范围选择和确认弹窗,处理时同一布尔操作只同步应用到所选范围内的对应主区域和参与区域,保留传播来源 metadata,避免时间轴帧属性变色;用户在范围确认前重新点击合并/去除开始新的布尔操作时,会取消旧的顶栏范围请求,避免当前帧操作和旧范围操作被重复执行。 - 前端 `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`;工作区“分割结果导出”按钮带 `FileDown` 图标和绿色强调背景,会先保存当前待归档 mask,再按所选范围、outputs 和 Mix_label 透明度下载统一 ZIP;特定范围帧导出可用帧号输入框或时间轴拖拽选择范围;下载文件名按项目库项目名、导出范围首尾时间戳和首尾项目帧序号生成;统一 ZIP 包含 maskid/GT 像素值映射 JSON、原始图片文件夹、按帧/类别合并的分开 Mask 文件夹、GT_label 图文件夹、Pro_label 彩色图文件夹和 Mix_label 叠加图文件夹;GT_label 固定为 uint8 PNG,像素值使用类别真实 `maskid` 并跨图一致。 -- 右侧语义分类树点击分类会把分类变更同步到同一传播链前后帧对应 mask;识别依据为 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 和 `propagation_seed_signature`,被同步更新的已保存 mask 会进入 dirty 状态,等待工作区归档保存 PATCH 到后端;保存 dirty mask 时会保留 `source`、传播 seed 和来源 id 等 metadata,避免传播帧在时间轴上变成人工/AI 标注帧。 +- 右侧语义分类树点击分类时,如果当前没有选中任何 mask,只设置后续新建 mask 的 active class,不修改已有 mask;如果当前选中 mask,则把分类变更同步到同一传播链前后帧对应 mask;识别依据为 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 和 `propagation_seed_signature`,被同步更新的已保存 mask 会进入 dirty 状态,等待工作区归档保存 PATCH 到后端;保存 dirty mask 时会保留 `source`、传播 seed 和来源 id 等 metadata,避免传播帧在时间轴上变成人工/AI 标注帧。 - 工作区保存状态按钮会按当前项目待保存数量显示“保存 X 个改动”或“已全部保存”,并已接入 `POST /api/ai/annotate` 和 `PATCH /api/ai/annotations/{id}`;dirty mask 更新前会预检后端标注 id,已知缺失的本地旧 annotationId 直接用 `POST` 重新创建;如果预检后 `PATCH` 仍返回 404,前端也会保留同一几何、分类和传播 lineage metadata,改用 `POST` 重新创建并在回显时替换本地旧 id,避免保存或开始传播被陈旧 annotationId 中断;加载工作区时会通过 `GET /api/ai/annotations` 回显已保存标注。 - 右侧实例属性面板“边缘平滑强度/应用边缘平滑”已接入 `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 兼容接口保留。 diff --git a/doc/03-frontend-element-audit.md b/doc/03-frontend-element-audit.md index 26e328f..b016131 100644 --- a/doc/03-frontend-element-audit.md +++ b/doc/03-frontend-element-audit.md @@ -95,13 +95,13 @@ | 正向/反向选点 | 真实可用 | UI 能加点,并按当前帧 `frame.id` 调用 `/api/ai/predict`;结果需点击归档保存才持久化 | | 框选 | 真实可用 | UI 能画框,并把框坐标归一化后调用后端推理;结果需点击归档保存才持久化 | | AI 推理中提示 | 真实可用 | 请求期间会显示 | -| 手工多边形/矩形/圆/画笔/橡皮擦 | 真实可用 | 多边形点击取点后可按 Enter 完成,也可在三点后点击首节点闭合;矩形/圆拖拽生成 polygon;多边形/矩形/圆未选语义分类时自动归入黑色 `maskid:0` 的“待分类”;画笔按当前语义分类生成连续圆形笔触并在松开时 union 成 mask,若与选中 mask 连通则自动合并;橡皮擦从选中 mask 中扣除笔触区域;均写入 `Mask.segmentation`,可归档保存 | +| 手工多边形/矩形/圆/画笔/橡皮擦 | 真实可用 | 多边形点击取点后可按 Enter 完成,也可在三点后点击首节点闭合;矩形/圆拖拽生成 polygon;切换到多边形/矩形/圆会清空旧 mask 选区,创建完成后自动选中新 mask;多边形/矩形/圆未选语义分类时自动归入黑色 `maskid:0` 的“待分类”;画笔按当前语义分类生成连续圆形笔触并在松开时 union 成 mask,若与选中 mask 连通则自动合并;按 `Esc` 可清空选区后用画笔创建新的不合并 mask;橡皮擦从选中 mask 中扣除笔触区域;均写入 `Mask.segmentation`,可归档保存 | | 画布上下文提示 | 真实可用 | 切换到多边形、矩形、圆、画笔、橡皮擦、区域合并/去除、调整多边形等隐性操作工具时,画布左上角显示当前工具的完成/取消/选择顺序提示;提示会在数秒后自动隐藏,避免长期遮挡待编辑图像,工具或操作状态变化时会重新出现 | | Mask 渲染 | 真实可用 | 前端会把推理、手工绘制、GT 导入和已保存标注转成 Konva `pathData` 渲染;普通 mask 和导入 mask 都不显示黄色 seed point;未选中特定 mask 时,当前帧 mask 会按右侧“语义分类树”拖拽得到的内部覆盖优先级从低到高渲染,使高优先级类别显示在上层;有选中 mask 时保留编辑态置顶行为,方便操作 | | Mask 透明度 | 真实可用 | 右侧语义分类树上方的“遮罩透明度”滑杆写入全局 `maskPreviewOpacity`,工作区 Canvas 和 AI 智能分割页都会使用该值调整 mask 预览透明度,选中 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 | -| 应用分类 | 真实可用 | Canvas 右下角不再提供“应用分类”快捷按钮,避免没选区时误改整帧;右侧语义分类树点击分类时会优先改当前已选 mask,并通过 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key` 同步更新同一传播链上的前后传播 mask,同时把已选 mask 移到前端渲染最上层方便继续编辑;已保存 mask 会标为 dirty,归档保存时更新后端 | +| 应用分类 | 真实可用 | Canvas 右下角不再提供“应用分类”快捷按钮,避免没选区时误改整帧;右侧语义分类树点击分类时,无选中 mask 只设置后续新建 mask 的 active class,不修改已有 mask;有选中 mask 时才改当前已选 mask,并通过 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key` 同步更新同一传播链上的前后传播 mask,同时把已选 mask 移到前端渲染最上层方便继续编辑;已保存 mask 会标为 dirty,归档保存时更新后端 | | 清空遮罩 | 真实可用 | 工作区只通过左侧工具栏触发清空;当前帧有选中 mask 时清选中 mask,没有选中时清当前帧全部 mask;无传播链结果时直接执行,存在传播链结果时弹窗选择取消、只清当前帧、按帧范围选择或清空所有传播帧;按帧范围选择复用时间轴范围选择和最终确认;按范围清空或清空所有传播帧时若目标范围包含人工/AI 标注帧,会二次确认是否删除,选择否会整帧保留 | | 保存状态计数 | 真实可用 | 底部显示已保存、未保存、待更新数量 | | 当前图层信息 | 真实可用 | 根据当前选中 mask 显示真实标签/后端 annotation id;未保存 mask 显示“未保存”,未选中时显示“未选择” | @@ -141,7 +141,7 @@ | 模板选择 | 真实可用 | 读取全局 templates,可切换 activeTemplateId,并会驱动分类树、mask 分类和导出类别信息 | | 面板滚动条 | 真实可用 | 右侧本体/语义分类面板内容过长时自身滚动;滚动条使用 `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;如果 Canvas 已选 mask,则同步更新已选 mask 及同一传播链前后帧对应 mask 的标签、颜色和 class 元数据,并把已选 mask 移到前端渲染最上层;当用户在 Canvas 点击已有 mask 时,本面板会按 mask 的 class id / 名称自动切换模板、设置 active class,并滚动/聚焦到对应分类按钮 | | 添加自定义分类 | 真实可用 | 需要先选择模板;新增分类通过 `PATCH /api/templates/{id}` 写入后端模板 `mapping_rules.classes`,并同步全局模板 store | | 目标实例属性标题 | 真实可用 | “特定目标实例属性追踪”下方显示当前选中 mask 的 `className/label`,不再跟随全局 active class,避免点过其他分类后标题固定成旧分类 | | 当前选中区域计数 | 已移除 | 当前交互以单选 mask 为主,计数长期为 1,属于低价值信息,已从实例属性面板删除 | diff --git a/doc/07-current-requirements-freeze.md b/doc/07-current-requirements-freeze.md index cb2c5a0..e5a17cb 100644 --- a/doc/07-current-requirements-freeze.md +++ b/doc/07-current-requirements-freeze.md @@ -75,11 +75,11 @@ - 侧栏“AI智能分割”和工作区工具栏 AI 跳转入口必须使用带明确 AI 语义的图标,而不是普通魔法棒等泛化工具图标。 - 工作区 AI 智能分割入口切换到 AI 页面。 - 多边形、矩形、圆、画笔、橡皮擦工具会在 Canvas 上生成或编辑可保存的 polygon mask;左侧工具栏不再提供创建点和创建线段入口。 -- 多边形通过点击取点并按 Enter 完成,也支持三点后点击首节点闭合;矩形、圆通过拖拽生成;画笔和橡皮擦支持调整大小。 +- 多边形通过点击取点并按 Enter 完成,也支持三点后点击首节点闭合;矩形、圆通过拖拽生成;点击创建多边形、创建矩形或创建圆工具时必须清空当前 mask 选区;新建完成后必须自动选中新创建的 mask;画笔和橡皮擦支持调整大小。 - 画笔工具只在语义分类树有选中类别时可用,按住拖动时以圆形笔触采样,鼠标松开后一次性 union 成连续区域;如果笔触与当前选中 mask 连通,默认合并到该 mask,否则生成新的当前类别 mask;笔触只在当前图像范围内采样,最终几何也必须裁剪到当前帧边界内。 - 橡皮擦工具只在当前帧已选中 mask 时可用,按住拖动时以圆形笔触采样,鼠标松开后从选中 mask 中 difference 扣除;扣空时删除该 mask,已保存 mask 仍需同步后端删除;进入画笔或橡皮擦模式后,当前选中 mask 的顶点提示仍保持可见,但这些顶点在笔触模式下只读不可拖动。 - 创建多边形、创建矩形、区域合并/去除、调整多边形等 Canvas 左上角上下文提示只作为短提示,切换工具或操作状态变化时显示,数秒后自动隐藏,避免长期遮挡待编辑图像;再次切换工具或操作状态变化会重新显示。 -- 绘制工具点击已有 mask 时应继续执行当前绘制动作,不应被 mask 选择逻辑吞掉。 +- 绘制工具点击已有 mask 时应继续执行当前绘制动作,不应被 mask 选择逻辑吞掉;按 `Esc` 必须清空当前 mask 选区和正在绘制的临时点/笔触,使用户可以重新选择语义分类并用画笔创建一个新 mask。 - 所有 polygon mask 都不显示黄色 seed point,也不提供 seed point 拖动;普通手工/AI/GT mask 在画布上应保持一致的区域渲染、选择、顶点编辑、拓扑统计、边缘平滑和保存体验。 - 工具栏提供“调整多边形”工具,用户可以点击 mask 进入 polygon 顶点编辑态;按住顶点即可直接拖动并实时更新 mask 几何,不需要先单击选中顶点,已保存 mask 会标记为 dirty;顶点拖拽不能冒泡成画布拖拽,编辑结束后 Canvas 当前缩放和平移视口必须保持不变。 - 工具栏按浅灰分隔线分组:拖拽/选择到创建圆为基础绘制组,画笔/橡皮擦为局部笔触组,区域合并/重叠区域去除/DEL/清空遮罩为布尔与删除组,导入 GT Mask/AI 智能分割为外部动作组;`data-testid="tool-group-separator"` 位于清空遮罩下方并分隔外部动作组。工作区在“清空遮罩”上方提供 `DEL` 按钮,语义等同键盘 Delete/Backspace;“导入 GT Mask”入口使用区别于普通编辑工具的紫色底色,不切换 activeTool。 @@ -177,7 +177,7 @@ - 面板显示模板分类;新增自定义分类会写入当前激活模板的后端 `mapping_rules.classes`。 - 右侧面板修改激活模板时,如果当前项目已有任意 mask,必须弹窗提示用户确认;确认后清空当前项目所有本地 mask 和已保存后端标注,再切换激活模板。当前项目没有任何 mask 时切换激活模板不需要提示。 - 用户可以选择具体分类;新 AI mask 会记录 `classId`、`className`、`classZIndex`,并在保存时写入 `mask_data.class`。 -- 如果 Canvas 当前已经选中一个或多个 mask,点击语义分类树会把这些 mask 的 `label`、`color` 和 class 元数据改为该分类;如果这些 mask 属于自动传播链,还必须通过 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 和 `propagation_seed_signature` 同步更新同一传播链前后帧的对应 mask;已保存 mask 会进入 `dirty` 状态,归档保存时更新后端。 +- 如果 Canvas 当前已经选中一个或多个 mask,点击语义分类树会把这些 mask 的 `label`、`color` 和 class 元数据改为该分类;如果 Canvas 当前没有选中任何 mask,点击语义分类树只设置后续新建 mask 使用的 active class,不能修改已有 mask;如果这些 mask 属于自动传播链,还必须通过 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 和 `propagation_seed_signature` 同步更新同一传播链前后帧的对应 mask;已保存 mask 会进入 `dirty` 状态,归档保存时更新后端。 - 打开工作区回显项目标注时,如果已保存 mask 的 class 不再存在于其所属模板中,前端必须把该 mask 转为 `maskid: 0` 的“待分类”mask,保留几何,标记为 dirty,等待用户重新分类并保存。 - 添加自定义分类需要先选择模板,保存时调用 `PATCH /api/templates/{id}` 并同步全局模板 store。 - “特定目标实例属性追踪”下方显示当前选中 mask 的 `className/label`,不显示全局 active class 的旧值。 diff --git a/doc/09-test-plan.md b/doc/09-test-plan.md index 9fd093c..42525e5 100644 --- a/doc/09-test-plan.md +++ b/doc/09-test-plan.md @@ -18,11 +18,11 @@ | 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 停止 | | R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选取消/只清当前帧/按帧范围选择/清空所有传播帧且按范围清空需最终确认、按范围清空或清空所有传播帧遇到人工/AI 标注帧时二次询问并支持保留人工帧、顶栏不显示重复的清空片段遮罩、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 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、靠边画笔生成几何裁剪到当前帧边界内、橡皮擦从选中 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` | 工具切换、切换到多边形/矩形/圆会清空旧 mask 选区、手工新建 mask 后自动选中新 mask、Esc 清空当前 mask 选区和临时绘制状态、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、清空遮罩唯一左侧工具栏入口、清空遮罩上方 DEL 删除按钮、橡皮擦下方彩色 AI 自动传播入口、Canvas 右下角不再重复显示清空遮罩或应用分类按钮、GT Mask 导入位于清空遮罩分隔线之后且使用紫色底色、工具栏分隔线位于创建圆后、自动传播后和清空遮罩后、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制且未选分类时默认待分类、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔新建当前类别 mask、画笔与选中 mask 连通时自动合并、画笔/橡皮擦模式下保留当前选中 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 | | 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 保存时自动写入代表点、项目不存在、帧不存在 | | R8 模板库 | `src/components/TemplateRegistry.test.tsx`, `src/components/TransientNotice.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_templates.py` | 前端模板加载/新建/编辑/删除、删除模板站内确认、鼠标复制模板为私有副本、所有模板归一化包含黑色 `maskid:0`“待分类”保留类、保留类固定最后且不可删除/拖拽上移、详情页“语义分类树(拖拽调层级)”标题、详情页“编辑模板”按钮和编辑图标、详情页垃圾桶删除 label 且不显示来源标签、编辑弹窗分类编辑不显示旧 category 来源元信息、编辑后详情页刷新、详情页和编辑弹窗拖拽语义层级顺序、拖拽保存 `zIndex` 且不改变 maskid、JSON 分类导入预览、`[[colors],[names]]` 数组格式、`{colors,names}` 对象格式、带前缀/宽松 keys/中文标点粘贴格式、JSON 错误内联提示、保存错误非阻塞提示、mapping_rules 解包/打包、后端模板 CRUD | -| 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 时切换激活模板需确认并清空所有 mask/标注、无 mask 时直接切换、面板标题简化、面板低对比滚动条、工作区遮罩透明度滑杆、分类展示、具体分类选择、模板类别删除后项目旧 mask 回显为 `maskid:0` 待分类、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 时切换激活模板需确认并清空所有 mask/标注、无 mask 时直接切换、面板标题简化、面板低对比滚动条、工作区遮罩透明度滑杆、分类展示、具体分类选择、无选中 mask 时点击分类只设置后续新建类别且不改已有 mask、模板类别删除后项目旧 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、主动断开不重连 | | 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 推导 | diff --git a/src/components/CanvasArea.test.tsx b/src/components/CanvasArea.test.tsx index e3e846c..1b07d48 100644 --- a/src/components/CanvasArea.test.tsx +++ b/src/components/CanvasArea.test.tsx @@ -1562,6 +1562,7 @@ describe('CanvasArea', () => { segmentation: [[120, 80, 260, 80, 260, 200, 120, 200]], bbox: [120, 80, 140, 120], })); + expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]); useStore.getState().undoMasks(); expect(useStore.getState().masks).toEqual([]); @@ -1592,6 +1593,34 @@ describe('CanvasArea', () => { }), })); expect(useStore.getState().masks[0].segmentation?.[0]).toHaveLength(64); + expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]); + }); + + it('clears the selected mask when switching to manual creation tools', async () => { + useStore.setState({ + selectedMaskIds: ['m1'], + masks: [ + { + id: 'm1', + frameId: 'frame-1', + pathData: 'M 10 10 L 80 10 L 80 80 L 10 80 Z', + label: 'Existing', + color: '#06b6d4', + segmentation: [[10, 10, 80, 10, 80, 80, 10, 80]], + }, + ], + }); + + const { rerender } = render(); + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([])); + + act(() => useStore.getState().setSelectedMaskIds(['m1'])); + rerender(); + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([])); + + act(() => useStore.getState().setSelectedMaskIds(['m1'])); + rerender(); + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([])); }); it('creates a brush mask when a semantic class is selected', () => { @@ -1777,6 +1806,7 @@ describe('CanvasArea', () => { source: 'manual', shape: '多边形', })); + expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]); }); it('closes a clicked polygon by clicking the first node again', () => { @@ -1803,6 +1833,51 @@ describe('CanvasArea', () => { .filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0); }); + it('clears selected masks with Escape so brush can create a new unmerged mask', async () => { + useStore.setState({ + activeTemplateId: '2', + activeClass: { id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 30, maskId: 2 }, + activeClassId: 'c2', + selectedMaskIds: ['m1'], + masks: [ + { + id: 'm1', + frameId: 'frame-1', + pathData: 'M 100 70 L 150 70 L 150 120 L 100 120 Z', + label: '旧标签', + color: '#ff0000', + classId: 'c1', + segmentation: [[100, 70, 150, 70, 150, 120, 100, 120]], + area: 2500, + }, + ], + }); + + const { rerender } = render(); + fireEvent.keyDown(window, { key: 'Escape' }); + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([])); + + rerender(); + const stage = screen.getByTestId('konva-stage'); + fireEvent.mouseDown(stage, { clientX: 300, clientY: 220 }); + fireEvent.mouseMove(stage, { clientX: 330, clientY: 240 }); + fireEvent.mouseUp(stage, { clientX: 360, clientY: 260 }); + + expect(useStore.getState().masks).toHaveLength(2); + expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ + id: 'm1', + label: '旧标签', + color: '#ff0000', + })); + expect(useStore.getState().masks[1]).toEqual(expect.objectContaining({ + label: '肝脏', + color: '#00ff00', + classId: 'c2', + classMaskId: 2, + })); + expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[1].id]); + }); + it('shows contextual guidance for boolean selection ordering', () => { useStore.setState({ masks: [ diff --git a/src/components/CanvasArea.tsx b/src/components/CanvasArea.tsx index 4675443..cffd797 100644 --- a/src/components/CanvasArea.tsx +++ b/src/components/CanvasArea.tsx @@ -602,6 +602,7 @@ export function CanvasArea({ const isBooleanTool = BOOLEAN_TOOLS.has(effectiveTool); const isPaintTool = PAINT_TOOLS.has(effectiveTool); const isPolygonEditTool = effectiveTool === 'move' || effectiveTool === EDIT_POLYGON_TOOL; + const canKeepMaskSelection = isPolygonEditTool || isBooleanTool || isPaintTool; const showSelectedMaskVertices = Boolean(selectedMask && (isPolygonEditTool || isPaintTool)); const activePaintSize = effectiveTool === ERASER_TOOL ? eraserSize : brushSize; const activePaintRadius = Math.max(2, activePaintSize / 2); @@ -751,13 +752,14 @@ export function CanvasArea({ lastPaintPointRef.current = null; setPolygonPoints([]); setSelectedVertexIndex(null); - if (!isPolygonEditTool && !isBooleanTool && !isPaintTool) { + if (!canKeepMaskSelection) { setSelectedMaskId(null); setSelectedMaskIds([]); + setGlobalSelectedMaskIds([]); setSelectedPolygonIndex(0); } if (!isBooleanTool) setPendingBooleanFrameIds(null); - }, [effectiveTool, isBooleanTool, isPaintTool, isPolygonEditTool, setPaintStrokePoints]); + }, [canKeepMaskSelection, effectiveTool, isBooleanTool, setGlobalSelectedMaskIds, setPaintStrokePoints]); useEffect(() => { if (previousFrameIdRef.current === frame?.id) return; @@ -784,6 +786,9 @@ export function CanvasArea({ useEffect(() => { const currentGlobalSelectedIds = useStore.getState().selectedMaskIds; + if (!canKeepMaskSelection) { + return; + } const validLocalSelectedIds = selectedMaskIds.filter((id) => ( frameMasks.some((mask) => mask.id === id) )); @@ -802,11 +807,14 @@ export function CanvasArea({ if (!isSameSelection) { setGlobalSelectedMaskIds(nextSelectedMaskIds); } - }, [frameMasks, selectedMaskIds, setGlobalSelectedMaskIds]); + }, [canKeepMaskSelection, frameMasks, selectedMaskIds, setGlobalSelectedMaskIds]); useEffect(() => () => setGlobalSelectedMaskIds([]), [setGlobalSelectedMaskIds]); useEffect(() => { + if (!canKeepMaskSelection) { + return; + } if (!selectedMaskId) { const validGlobalSelectedIds = useStore.getState().selectedMaskIds.filter((id) => ( frameMasks.some((mask) => mask.id === id) @@ -833,7 +841,7 @@ export function CanvasArea({ setSelectedPolygonIndex(0); setSelectedVertexIndex(null); } - }, [frame?.id, frameMasks, masks, selectedMaskId, selectedMaskIds, setGlobalSelectedMaskIds]); + }, [canKeepMaskSelection, frame?.id, frameMasks, masks, selectedMaskId, selectedMaskIds, setGlobalSelectedMaskIds]); const handleWheel = (e: any) => { e.evt.preventDefault(); @@ -903,7 +911,12 @@ export function CanvasArea({ metadata: { source: 'manual', shape }, }; addMask(mask); - }, [activeClass, activeTemplateId, addMask, frame?.id]); + setSelectedMaskId(mask.id); + setSelectedMaskIds([mask.id]); + setGlobalSelectedMaskIds([mask.id]); + setSelectedPolygonIndex(0); + setSelectedVertexIndex(null); + }, [activeClass, activeTemplateId, addMask, frame?.id, setGlobalSelectedMaskIds]); const createManualMaskFromGeometry = useCallback((shape: string, geometry: MultiPolygon): Mask | null => { if (!frame?.id || !activeClass) return null; @@ -1113,9 +1126,10 @@ export function CanvasArea({ } setSelectedMaskId(null); setSelectedMaskIds([]); + setGlobalSelectedMaskIds([]); setSelectedPolygonIndex(0); setSelectedVertexIndex(null); - }, [masks, onDeleteMaskAnnotations, samCandidateMaskId, setMasks]); + }, [masks, onDeleteMaskAnnotations, samCandidateMaskId, setGlobalSelectedMaskIds, setMasks]); const applyPaintStroke = useCallback((tool: string | null, strokePoints: CanvasPoint[]) => { if (!frame?.id || strokePoints.length === 0) return; @@ -1159,6 +1173,7 @@ export function CanvasArea({ setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask))); setSelectedMaskId(selectedMask.id); setSelectedMaskIds([selectedMask.id]); + setGlobalSelectedMaskIds([selectedMask.id]); setSelectedVertexIndex(null); return; } @@ -1167,6 +1182,7 @@ export function CanvasArea({ if (nextMask) { setSelectedMaskId(nextMask.id); setSelectedMaskIds([nextMask.id]); + setGlobalSelectedMaskIds([nextMask.id]); setSelectedPolygonIndex(0); setSelectedVertexIndex(null); } @@ -1194,6 +1210,7 @@ export function CanvasArea({ setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask))); setSelectedMaskId(selectedMask.id); setSelectedMaskIds([selectedMask.id]); + setGlobalSelectedMaskIds([selectedMask.id]); setSelectedVertexIndex(null); } }, [ @@ -1212,6 +1229,7 @@ export function CanvasArea({ image?.width, masks, selectedMask, + setGlobalSelectedMaskIds, setMasks, stageSize.height, stageSize.width, @@ -1392,6 +1410,21 @@ export function CanvasArea({ useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setPolygonPoints([]); + setManualStart(null); + setManualCurrent(null); + setPaintStrokePoints([]); + paintToolRef.current = null; + lastPaintPointRef.current = null; + setSelectedMaskId(null); + setSelectedMaskIds([]); + setGlobalSelectedMaskIds([]); + setSelectedPolygonIndex(0); + setSelectedVertexIndex(null); + return; + } if ((event.key === 'Delete' || event.key === 'Backspace') && selectedMask && selectedVertexIndex !== null) { const currentPoints = segmentationToPoints(selectedMask.segmentation, selectedPolygonIndex); if (currentPoints.length > 3) { @@ -1417,15 +1450,11 @@ export function CanvasArea({ event.preventDefault(); finishPolygon(); } - if (event.key === 'Escape') { - event.preventDefault(); - setPolygonPoints([]); - } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, onRequestDeleteMasks, polygonPoints, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, updatePolygonMask]); + }, [deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, onRequestDeleteMasks, polygonPoints, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, setGlobalSelectedMaskIds, setPaintStrokePoints, updatePolygonMask]); const boxRect = React.useMemo(() => { if (!boxStart || !boxCurrent) return null; diff --git a/src/components/OntologyInspector.test.tsx b/src/components/OntologyInspector.test.tsx index 4522f68..bdc7daf 100644 --- a/src/components/OntologyInspector.test.tsx +++ b/src/components/OntologyInspector.test.tsx @@ -160,6 +160,39 @@ describe('OntologyInspector', () => { })); }); + it('does not change existing masks when selecting a class without a selected mask', () => { + useStore.setState({ + selectedMaskIds: [], + masks: [ + { + id: 'm1', + frameId: 'frame-1', + pathData: 'M 0 0 Z', + label: '旧标签', + color: '#06b6d4', + classId: 'old-class', + saveStatus: 'saved', + saved: true, + }, + ], + }); + + render(); + fireEvent.click(screen.getByText('肝脏')); + + expect(useStore.getState().activeClassId).toBe('c2'); + expect(useStore.getState().masks).toEqual([ + expect.objectContaining({ + id: 'm1', + label: '旧标签', + color: '#06b6d4', + classId: 'old-class', + saveStatus: 'saved', + saved: true, + }), + ]); + }); + it('applies the selected class to currently selected masks', () => { useStore.setState({ selectedMaskIds: ['m1'],