diff --git a/AGENTS.md b/AGENTS.md index 318da80..11974ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -260,7 +260,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload - 前端 `predictMask()` 已按后端 `PredictRequest` 发送 `image_id`、`prompt_type`、`prompt_data`、`model`,并将后端 `polygons` 转成 Konva 可渲染的 `pathData`。 - 手工绘制工具会生成可保存的 `Mask.segmentation`;未选分类时的多边形/矩形/圆会自动归入 `maskid:0` 的“待分类”;撤销/重做通过 `maskHistory/maskFuture` 工作,工作区在 window capture 阶段处理 `Ctrl/Cmd+Z`、`Ctrl/Cmd+Shift+Z` 和 `Ctrl/Cmd+Y`,并通过 `src/lib/keyboardShortcuts.ts` 兼容 `event.key` 与 `event.code=KeyZ/KeyY`。 - Polygon 顶点编辑和新增顶点会重算 `pathData/segmentation/bbox/area`;多 polygon/分离区域和中空 mask 的外圈、内洞都可显示顶点与插点手柄,保存时通过 `mask_data.holes` 和 `metadata.polygonRingCounts` 保留 ring 分组;已保存 mask 进入 dirty 状态后复用归档 PATCH 链路。 -- 区域合并/去除会重算主 mask 的几何;合并已保存的次级 mask 时会通过工作区回调删除对应后端标注;若主区域和参与区域存在传播链对应 mask,会先弹窗选择当前帧、所有传播帧或按帧范围选择;按帧范围选择复用工作区时间轴范围选择和确认弹窗,处理时同一布尔操作只同步应用到所选范围内的对应主区域和参与区域,保留传播来源 metadata,避免时间轴帧属性变色;用户在范围确认前重新点击合并/去除开始新的布尔操作时,会取消旧的顶栏范围请求,避免当前帧操作和旧范围操作被重复执行。 +- 区域合并/去除会重算主 mask 的几何;合并已保存的次级 mask 时会通过工作区回调删除对应后端标注;若主区域和参与区域存在传播链对应 mask,会先弹窗选择当前帧、所有传播帧或按帧范围选择;按帧范围选择复用工作区时间轴范围选择和确认弹窗,处理时同一布尔操作只同步应用到所选范围内的对应主区域和参与区域,保留传播来源 metadata,避免时间轴帧属性变色;布尔同步使用严格实例匹配:优先 `source_annotation_id/source_mask_id` 等可靠 lineage,旧传播结果缺少可靠 id 时只为每个已选 mask 选择空间最近的一个同语义对应实例,不会把同类别其它实例一起合并或扣除;用户在范围确认前重新点击合并/去除开始新的布尔操作时,会取消旧的顶栏范围请求,避免当前帧操作和旧范围操作被重复执行。 - 前端 `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,只设置后续新建 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 标注帧。 diff --git a/doc/08-current-design-freeze.md b/doc/08-current-design-freeze.md index 351f849..3fdcdaa 100644 --- a/doc/08-current-design-freeze.md +++ b/doc/08-current-design-freeze.md @@ -207,7 +207,7 @@ 3. Canvas 左上角提示布尔选择顺序:第一个选中的是主区域,后续区域参与合并或扣除。 4. 布尔选择态按选择顺序区分角色:第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓;所有已选区域填充透明度保持一致,避免被误解为阴影模式异常。 5. `CanvasArea` 把 `Mask.segmentation` 转为 `polygon-clipping` 的 MultiPolygon。 -6. `area_merge` 使用 union,更新第一个选中的主 mask,并从前端 store 移除后续被合并 mask;如果被移除 mask 已保存,会调用工作区传入的删除回调删除后端标注。执行前会按 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key` 计算可同步的传播帧;若存在其它传播帧,先弹出范围选择,让用户选择只处理当前帧、处理所有传播帧或按帧范围选择。按帧范围选择会把本次布尔操作交给 `VideoWorkspace`,复用底部时间轴范围选择和最终确认弹窗;确认后只在范围内且具备对应关系的帧上执行同一次 union,只删除该帧参与合并的次级 mask,避免把同链但未参与同步或范围外的区域整链误删。用户在顶栏范围确认前重新点击“合并选中”开始新的布尔选择时,旧的范围请求必须立即取消。 +6. `area_merge` 使用 union,更新第一个选中的主 mask,并从前端 store 移除后续被合并 mask;如果被移除 mask 已保存,会调用工作区传入的删除回调删除后端标注。执行前会按 `source_annotation_id`、`source_mask_id` 和可靠的 `propagation_seed_key` 计算可同步的传播帧;若存在其它传播帧,先弹出范围选择,让用户选择只处理当前帧、处理所有传播帧或按帧范围选择。布尔同步使用严格实例匹配:优先可靠 lineage,旧传播结果缺少可靠 id 时只为每个已选 mask 选取空间最近的一个同语义传播结果,不使用宽泛同类别 legacy 分组批量合并,避免同类其它实例被一起卷入。按帧范围选择会把本次布尔操作交给 `VideoWorkspace`,复用底部时间轴范围选择和最终确认弹窗;确认后只在范围内且具备对应关系的帧上执行同一次 union,只删除该帧参与合并的次级 mask,避免把同链但未参与同步或范围外的区域整链误删。用户在顶栏范围确认前重新点击“合并选中”开始新的布尔选择时,旧的范围请求必须立即取消。 7. `area_remove` 使用 difference,从第一个选中的主 mask 中扣除后续选中 mask,扣除对象本身保留;同样会在执行前计算可同步的传播帧并弹出当前帧/所有传播帧/按帧范围选择。按帧范围选择确认后,会在范围内其它传播帧中找到对应主区域和扣除区域并执行 difference,扣除区域本身继续保留;如果 difference 产生内洞,`segmentation` 保留外圈和 hole ring,`metadata.polygonRingCounts` 记录每个 polygon 的 ring 数,渲染时使用 even-odd fill。 8. 结果会重算 `pathData`、`segmentation`、`bbox`、`area`,已保存主 mask 会进入 dirty 状态并复用归档 PATCH 链路;同步到传播帧时保留传播来源和 lineage metadata,避免自动传播帧在时间轴上变成人工/AI 标注帧;带洞结果的面积按外圈减内洞计算;进入调整多边形时,外圈和内洞 ring 都会显示顶点和边中点插入手柄,内洞拖动、插点、保存与回显继续保持中空结构。 @@ -297,7 +297,7 @@ 以下能力属于当前冻结版本的占位或半可用功能: - Dashboard 初始快照来自 `GET /api/dashboard/overview`;任务进度区由 `processing_tasks` queued/running/success/failed/cancelled 任务生成,处理中统计只计算 queued/running。 -- 已保存标注支持通过右侧语义分类树换标签、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑、中空 mask 内洞 ring 编辑和区域合并/去除进入 dirty 状态并归档更新;多 polygon/分离区域选中后所有子区域都显示编辑手柄,同帧同传播链的分散 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;Canvas 右下角不再提供旧的“应用分类”按钮,避免没选区时误改整帧;区域合并/去除会在存在传播帧时弹窗选择当前帧、所有传播帧或按帧范围选择,范围选择复用时间轴和确认弹窗,并保留传播帧来源 metadata;选中整块 mask 可用 Delete/Backspace 或左侧 `DEL` 删除,同步后端前会预检 id,同传播链自动传播结果会随传播 seed/传播结果删除而一并清理,独立 AI 推理/人工 mask 保留。 +- 已保存标注支持通过右侧语义分类树换标签、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑、中空 mask 内洞 ring 编辑和区域合并/去除进入 dirty 状态并归档更新;多 polygon/分离区域选中后所有子区域都显示编辑手柄,同帧同传播链的分散 mask 会按 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 或 `propagation_seed_signature` 联动高亮;旧传播结果缺少稳定 lineage 时,会用传播来源、来源帧、方向、分类/标签/颜色构造兼容分组,保证同一传播 mask 拆出的不连通片段仍一起高亮;区域合并/去除同步传播帧时不复用这类宽泛高亮分组,而是优先可靠 lineage,缺少可靠 lineage 时为每个已选 mask 在同来源帧且同语义/颜色的候选传播结果中选取空间最近的单个对应实例,避免把同类别其它实例一起合并或扣除;区域合并支持跨语义链路,当前帧把 A mask 合并进 B mask 时,传播帧中的 A 对应结果会并入 B 对应结果;若某个传播帧没有 B 对应结果但有 A 对应结果,则把该 A 结果转换为 B 语义并标记为 dirty;Canvas 右下角不再提供旧的“应用分类”按钮,避免没选区时误改整帧;区域合并/去除会在存在传播帧时弹窗选择当前帧、所有传播帧或按帧范围选择,范围选择复用时间轴和确认弹窗,并保留传播帧来源 metadata;选中整块 mask 可用 Delete/Backspace 或左侧 `DEL` 删除,同步后端前会预检 id,同传播链自动传播结果会随传播 seed/传播结果删除而一并清理,独立 AI 推理/人工 mask 保留。 - SAM 3 文本语义分割已从当前产品路径中禁用;相关源码保留,恢复时需要重新接入前端入口、registry、状态接口和测试。 - 自定义分类通过 `PATCH /api/templates/{id}` 写入当前激活模板的 `mapping_rules.classes`。 - 选中 mask 后,本体面板的“特定目标实例属性追踪”标题值来自当前 mask 的 `className/label`,不使用全局 active class;面板不再展示长期为 1 的“当前选中区域”计数;面板调用 `POST /api/ai/analyze-mask` 自动显示拓扑锚点数量等属性,`topology_anchor_count` 是真实 polygon 顶点数量,`topology_anchors` 只保留最多 64 个抽样点用于调试展示;`OntologyInspector` 会为分析请求维护递增序号,旧请求返回时不再回写状态,并静默忽略 Axios abort/cancel 错误,避免快速切换、平滑预览或组件卸载时把正常中止误报成失败;不再提供“重新提取拓扑锚点”调试按钮;“边缘平滑强度”滑杆会即时更新数值,但 `POST /api/ai/smooth-mask` 预览请求经过约 220ms 防抖后才发送,返回 polygon 作为临时预览写入当前 mask 显示,预览不改变保存状态;点击“应用边缘平滑”后,前端把平滑 polygon 作为新的实际几何写入当前 mask,并按传播 lineage 同步写入传播链前后对应 mask,相关 mask 标记为 dirty/draft,整次操作通过一次 `setMasks()` 进入撤销/重做历史;应用后不保留 `geometry_smoothing` 参数,平滑强度重置为 0。前端不再展示“后端模型置信度”。 diff --git a/doc/09-test-plan.md b/doc/09-test-plan.md index 9a3ba39..7b2c16b 100644 --- a/doc/09-test-plan.md +++ b/doc/09-test-plan.md @@ -18,7 +18,7 @@ | 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 标注帧时二次询问并支持保留人工帧、顶栏不显示重复的清空片段遮罩、传播进度存在时任务 message 只显示在蓝色进度面板内且不重复出现在灰色状态文字里、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 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` | 工具切换、切换到多边形/矩形/圆会保留旧 mask 选区、有选中 mask 时多边形/矩形/圆/画笔新几何会并入选中 mask 且不要求重叠、无选中 mask 时手工新建 mask 后自动选中新 mask 并显示创建后边界点、Esc 和左侧“取消选中”按钮清空当前 mask 选区和临时绘制状态、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、清空遮罩唯一左侧工具栏入口、清空遮罩上方 DEL 删除按钮、橡皮擦下方彩色 AI自动推理入口、Canvas 右下角不再重复显示清空遮罩或应用分类按钮、GT Mask 导入位于清空遮罩分隔线之后且使用紫色底色、工具栏分隔线位于创建圆后、AI自动推理后和清空遮罩后、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制且未选分类时默认待分类、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔无选中时新建当前类别 mask、画笔/橡皮擦模式下保留当前选中 mask 顶点提示且只读、画笔从图外落笔不创建 mask、靠边画笔生成几何裁剪到当前帧边界内、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、多 polygon/分离区域全部显示编辑顶点、中空 mask 与中空画笔 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 且不要求重叠、无选中 mask 时手工新建 mask 后自动选中新 mask 并显示创建后边界点、Esc 和左侧“取消选中”按钮清空当前 mask 选区和临时绘制状态、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、清空遮罩唯一左侧工具栏入口、清空遮罩上方 DEL 删除按钮、橡皮擦下方彩色 AI自动推理入口、Canvas 右下角不再重复显示清空遮罩或应用分类按钮、GT Mask 导入位于清空遮罩分隔线之后且使用紫色底色、工具栏分隔线位于创建圆后、AI自动推理后和清空遮罩后、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制且未选分类时默认待分类、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔无选中时新建当前类别 mask、画笔/橡皮擦模式下保留当前选中 mask 顶点提示且只读、画笔从图外落笔不创建 mask、靠边画笔生成几何裁剪到当前帧边界内、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、多 polygon/分离区域全部显示编辑顶点、中空 mask 与中空画笔 mask 内洞 ring 顶点和插点可编辑、整块 mask 删除、DEL 和 Delete/Backspace 删除共用传播链范围确认、同帧传播链分散 mask 点选联动高亮、传播链自动传播 mask 随 seed/传播结果删除、独立 AI 推理 mask 不被误删、区域合并/去除存在传播帧时弹窗选择当前帧/所有传播帧/按帧范围选择、范围确认前重新开始当前帧布尔操作会取消旧顶栏范围请求、区域合并/去除按帧范围同步到对应传播帧且保留传播 metadata、旧传播缺可靠 lineage 时布尔同步只选每个已选 mask 的空间最近对应实例而不批量处理同类其它实例、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 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 | diff --git a/src/components/CanvasArea.test.tsx b/src/components/CanvasArea.test.tsx index 1d2cc25..8407a2d 100644 --- a/src/components/CanvasArea.test.tsx +++ b/src/components/CanvasArea.test.tsx @@ -1220,6 +1220,113 @@ describe('CanvasArea', () => { })); }); + it('does not merge every same-class legacy propagation instance on later frames', async () => { + const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined); + const frame2 = { ...frame, id: 'frame-2', index: 1 }; + const legacyMetadata = { + source: 'sam2_propagation', + propagated_from_frame_id: 'frame-1', + propagation_seed_key: '{"label":"A","color":"#06b6d4"}', + }; + useStore.setState({ + masks: [ + { + id: 'annotation-10', + annotationId: '10', + frameId: 'frame-2', + pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z', + label: 'A', + color: '#06b6d4', + segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]], + saved: true, + saveStatus: 'saved', + metadata: legacyMetadata, + }, + { + id: 'annotation-20', + annotationId: '20', + frameId: 'frame-2', + pathData: 'M 72 72 L 122 72 L 122 122 L 72 122 Z', + label: 'A', + color: '#06b6d4', + segmentation: [[72, 72, 122, 72, 122, 122, 72, 122]], + saved: true, + saveStatus: 'saved', + metadata: legacyMetadata, + }, + { + id: 'annotation-30', + annotationId: '30', + frameId: 'frame-2', + pathData: 'M 180 180 L 230 180 L 230 230 L 180 230 Z', + label: 'A', + color: '#06b6d4', + segmentation: [[180, 180, 230, 180, 230, 230, 180, 230]], + saved: true, + saveStatus: 'saved', + metadata: legacyMetadata, + }, + { + id: 'annotation-110', + annotationId: '110', + frameId: 'frame-3', + pathData: 'M 14 14 L 64 14 L 64 64 L 14 64 Z', + label: 'A', + color: '#06b6d4', + segmentation: [[14, 14, 64, 14, 64, 64, 14, 64]], + saved: true, + saveStatus: 'saved', + metadata: legacyMetadata, + }, + { + id: 'annotation-120', + annotationId: '120', + frameId: 'frame-3', + pathData: 'M 74 74 L 124 74 L 124 124 L 74 124 Z', + label: 'A', + color: '#06b6d4', + segmentation: [[74, 74, 124, 74, 124, 124, 74, 124]], + saved: true, + saveStatus: 'saved', + metadata: legacyMetadata, + }, + { + id: 'annotation-130', + annotationId: '130', + frameId: 'frame-3', + pathData: 'M 182 182 L 232 182 L 232 232 L 182 232 Z', + label: 'A', + color: '#06b6d4', + segmentation: [[182, 182, 232, 182, 232, 232, 182, 232]], + saved: true, + saveStatus: 'saved', + metadata: legacyMetadata, + }, + ], + }); + + render(); + const paths = screen.getAllByTestId('konva-path'); + fireEvent.click(paths[0]); + fireEvent.click(paths[1]); + fireEvent.click(screen.getByRole('button', { name: '合并选中' })); + expect(screen.getByText('选择操作范围')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '处理所有传播帧' })); + + await waitFor(() => expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(expect.arrayContaining(['20', '120']))); + const masks = useStore.getState().masks; + expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-10', 'annotation-110', 'annotation-130', 'annotation-30']); + expect(onDeleteMaskAnnotations).not.toHaveBeenCalledWith(expect.arrayContaining(['30', '130'])); + expect(masks.find((mask) => mask.id === 'annotation-130')).toEqual(expect.objectContaining({ + saveStatus: 'saved', + })); + expect(masks.find((mask) => mask.id === 'annotation-110')).toEqual(expect.objectContaining({ + saveStatus: 'dirty', + saved: false, + bbox: [14, 14, 110, 110], + })); + }); + it('merges propagated A masks into propagated B masks when merging A into B on the reference frame', async () => { const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined); useStore.setState({ diff --git a/src/components/CanvasArea.tsx b/src/components/CanvasArea.tsx index fb845d0..ca43ec4 100644 --- a/src/components/CanvasArea.tsx +++ b/src/components/CanvasArea.tsx @@ -66,6 +66,27 @@ function propagationSourceMaskTokens(value: unknown): string[] { return tokens; } +function reliablePropagationLineageTokens(mask: Mask): Set { + const metadata = mask.metadata || {}; + const tokens = new Set([`mask:${mask.id}`]); + if (mask.annotationId) { + tokens.add(`annotation:${mask.annotationId}`); + } + const sourceAnnotationId = metadataNumber(metadata.source_annotation_id); + if (sourceAnnotationId !== null) { + tokens.add(`annotation:${sourceAnnotationId}`); + } + propagationSourceMaskTokens(metadata.source_mask_id).forEach((token) => tokens.add(token)); + if (typeof metadata.propagation_seed_key === 'string') { + const seedKey = metadata.propagation_seed_key.trim(); + if (/^(annotation|mask):/.test(seedKey)) { + tokens.add(`seed-key:${seedKey}`); + tokens.add(seedKey); + } + } + return tokens; +} + function isPropagationMask(mask: Mask): boolean { const metadata = mask.metadata || {}; const source = typeof metadata.source === 'string' ? metadata.source.toLowerCase() : ''; @@ -141,40 +162,125 @@ function propagatedFromFrame(mask: Mask, sourceFrameId: string): boolean { && String(metadata.propagated_from_frame_id) === String(sourceFrameId); } -function findLinkedMasksOnFrame(selectedIds: string[], allMasks: Mask[], targetFrameId?: string): string[] { +function propagationFallbackCompatible(selectedMask: Mask, candidate: Mask): boolean { + if (!isPropagationMask(candidate) || maskSemanticKey(candidate) !== maskSemanticKey(selectedMask)) return false; + const selectedMetadata = selectedMask.metadata || {}; + const candidateMetadata = candidate.metadata || {}; + if (!isPropagationMask(selectedMask)) { + return propagatedFromFrame(candidate, String(selectedMask.frameId)); + } + const selectedOriginFrameId = selectedMetadata.propagated_from_frame_id; + const candidateOriginFrameId = candidateMetadata.propagated_from_frame_id; + if ( + selectedOriginFrameId !== undefined + && candidateOriginFrameId !== undefined + && String(selectedOriginFrameId) !== String(candidateOriginFrameId) + ) { + return false; + } + const selectedOriginFrameIndex = selectedMetadata.propagated_from_frame_index; + const candidateOriginFrameIndex = candidateMetadata.propagated_from_frame_index; + if ( + selectedOriginFrameIndex !== undefined + && candidateOriginFrameIndex !== undefined + && String(selectedOriginFrameIndex) !== String(candidateOriginFrameIndex) + ) { + return false; + } + const selectedSource = typeof selectedMetadata.source === 'string' ? selectedMetadata.source : ''; + const candidateSource = typeof candidateMetadata.source === 'string' ? candidateMetadata.source : ''; + if (selectedSource && candidateSource && selectedSource !== candidateSource) return false; + const selectedDirection = selectedMetadata.propagation_direction; + const candidateDirection = candidateMetadata.propagation_direction; + if ( + selectedDirection !== undefined + && candidateDirection !== undefined + && String(selectedDirection) !== String(candidateDirection) + ) { + return false; + } + return true; +} + +function findLinkedMasksOnFrame( + selectedIds: string[], + allMasks: Mask[], + targetFrameId?: string, + options: { strictInstanceMatch?: boolean } = {}, +): string[] { if (!targetFrameId || selectedIds.length === 0) return []; const selectedMasks = selectedIds .map((id) => allMasks.find((mask) => mask.id === id)) .filter((mask): mask is Mask => Boolean(mask)); if (selectedMasks.length === 0) return []; - const selectedTokens = new Set(); - const selectedHasPropagation = selectedMasks.some(isPropagationMask); - selectedMasks.forEach((mask) => { - propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token)); - }); + if (!options.strictInstanceMatch) { + const selectedTokens = new Set(); + const selectedHasPropagation = selectedMasks.some(isPropagationMask); + selectedMasks.forEach((mask) => { + propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token)); + }); - const linkedIds = allMasks - .filter((mask) => String(mask.frameId) === String(targetFrameId)) - .filter((mask) => { - const candidateHasPropagation = isPropagationMask(mask); - if (!selectedHasPropagation && !candidateHasPropagation) return false; - const candidateTokens = propagationLineageTokens(mask); - return [...candidateTokens].some((token) => selectedTokens.has(token)); - }) - .map((mask) => mask.id); - - const linkedIdSet = new Set(linkedIds); - selectedMasks.forEach((selectedMask) => { - if (isPropagationMask(selectedMask)) return; - const selectedCenter = maskBboxCenter(selectedMask); - const selectedSemanticKey = maskSemanticKey(selectedMask); - const candidates = allMasks + const linkedIds = allMasks .filter((mask) => String(mask.frameId) === String(targetFrameId)) + .filter((mask) => { + const candidateHasPropagation = isPropagationMask(mask); + if (!selectedHasPropagation && !candidateHasPropagation) return false; + const candidateTokens = propagationLineageTokens(mask); + return [...candidateTokens].some((token) => selectedTokens.has(token)); + }) + .map((mask) => mask.id); + + const linkedIdSet = new Set(linkedIds); + selectedMasks.forEach((selectedMask) => { + if (isPropagationMask(selectedMask)) return; + const selectedCenter = maskBboxCenter(selectedMask); + const selectedSemanticKey = maskSemanticKey(selectedMask); + const candidates = allMasks + .filter((mask) => String(mask.frameId) === String(targetFrameId)) + .filter((mask) => !linkedIdSet.has(mask.id)) + .filter((mask) => isPropagationMask(mask)) + .filter((mask) => propagatedFromFrame(mask, String(selectedMask.frameId))) + .filter((mask) => maskSemanticKey(mask) === selectedSemanticKey); + if (candidates.length === 0) return; + const best = candidates.reduce<{ mask: Mask; distance: number } | null>((currentBest, candidate) => { + const candidateCenter = maskBboxCenter(candidate); + const distance = selectedCenter && candidateCenter ? pointDistance(selectedCenter, candidateCenter) : 0; + if (!currentBest || distance < currentBest.distance) return { mask: candidate, distance }; + return currentBest; + }, null); + if (best) { + linkedIds.push(best.mask.id); + linkedIdSet.add(best.mask.id); + } + }); + + return linkedIds; + } + + const targetMasks = allMasks.filter((mask) => String(mask.frameId) === String(targetFrameId)); + const linkedIds: string[] = []; + const linkedIdSet = new Set(); + + selectedMasks.forEach((selectedMask) => { + const selectedTokens = reliablePropagationLineageTokens(selectedMask); + const exactMatches = targetMasks.filter((mask) => { + if (!isPropagationMask(selectedMask) && !isPropagationMask(mask)) return false; + const candidateTokens = reliablePropagationLineageTokens(mask); + return [...candidateTokens].some((token) => selectedTokens.has(token)); + }); + exactMatches.forEach((mask) => { + if (!linkedIdSet.has(mask.id)) { + linkedIds.push(mask.id); + linkedIdSet.add(mask.id); + } + }); + if (exactMatches.length > 0) return; + + const selectedCenter = maskBboxCenter(selectedMask); + const candidates = targetMasks .filter((mask) => !linkedIdSet.has(mask.id)) - .filter((mask) => isPropagationMask(mask)) - .filter((mask) => propagatedFromFrame(mask, String(selectedMask.frameId))) - .filter((mask) => maskSemanticKey(mask) === selectedSemanticKey); + .filter((mask) => propagationFallbackCompatible(selectedMask, mask)); if (candidates.length === 0) return; const best = candidates.reduce<{ mask: Mask; distance: number } | null>((currentBest, candidate) => { const candidateCenter = maskBboxCenter(candidate); @@ -1622,9 +1728,9 @@ export function CanvasArea({ masks.forEach((mask) => { const targetFrameId = String(mask.frameId); if (targetFrameId === currentFrameId) return; - const hasPrimary = findLinkedMasksOnFrame([primary.id], masks, targetFrameId).length > 0; + const hasPrimary = findLinkedMasksOnFrame([primary.id], masks, targetFrameId, { strictInstanceMatch: true }).length > 0; const hasSecondary = secondaryMasks.some((secondary) => ( - findLinkedMasksOnFrame([secondary.id], masks, targetFrameId).length > 0 + findLinkedMasksOnFrame([secondary.id], masks, targetFrameId, { strictInstanceMatch: true }).length > 0 )); if (hasSecondary && (hasPrimary || effectiveTool === 'area_merge')) targetFrameIds.add(targetFrameId); }); @@ -1642,13 +1748,13 @@ export function CanvasArea({ const applyOperationForFrame = (targetFrameId: string) => { const linkedPrimaryTargetId = targetFrameId === currentFrameId ? primary.id - : findLinkedMasksOnFrame([primary.id], masks, targetFrameId)[0]; + : findLinkedMasksOnFrame([primary.id], masks, targetFrameId, { strictInstanceMatch: true })[0]; const secondaryTargetIds = Array.from(new Set( secondaryMasks.flatMap((secondary) => ( targetFrameId === currentFrameId ? [secondary.id] - : findLinkedMasksOnFrame([secondary.id], masks, targetFrameId) + : findLinkedMasksOnFrame([secondary.id], masks, targetFrameId, { strictInstanceMatch: true }) )), )).filter((maskId) => maskId !== linkedPrimaryTargetId && !deletedMaskIds.has(maskId)); const secondaryTargets = secondaryTargetIds