支持中空mask编辑和传播保洞
- 前端按 polygonRingCounts 维护外圈/内洞 ring 分组,中空 mask 在调整多边形时显示内洞顶点和插点手柄。 - 保存与回显标注时将中空结构拆分为 mask_data.polygons 和 mask_data.holes,导入/普通 mask 共享同一编辑体验。 - 自动传播 seed 携带 holes,SAM 2 seed 栅格化时扣除内洞,避免中空 mask 以实心形式传播。 - 传播结果轮廓提取改为保留层级内洞,并在同步传播和 Celery 传播落库时写回 holes 与 hasHoles。 - 传播 seed 签名纳入 holes,并加固保存结果时 holes 与原始 polygon 索引对齐。 - 补充前端保存/回显、Canvas 内洞编辑和后端 SAM 2 hole 处理测试。 - 更新 AGENTS、接口契约、需求冻结、设计冻结和测试计划文档,移除中空结构未实现的旧描述。
This commit is contained in:
@@ -244,9 +244,9 @@ 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` 会按容器和帧尺寸默认居中放大底图并保留边距;`FrameTimeline` 会根据已保存标注回显到 `Mask.metadata` 的传播来源,把自动传播生成的帧在视频处理进度条显示为蓝色区段,人工/AI 标注帧显示红色竖线;每次自动传播成功处理帧后,`VideoWorkspace` 会把本次传播范围作为当前会话历史片段传给 `FrameTimeline`,在视频处理进度条上叠加同一蓝色系、最新传播最亮、旧传播逐次变暗且第 5 次及更早统一为阈值旧记录色的纯色条;传播历史条只显示当前仍有自动传播 mask 的帧,删除 mask 或清空范围后会按剩余传播 mask 自动裁剪,空帧不保留红/蓝颜色;视频处理进度条和红/蓝标识可点击跳转到对应帧;底部缩略图中人工/AI 标注帧用红色边框、自动传播/推理帧用蓝色边框,同一帧同时具备两种状态时红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍以青色外框高亮优先;若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注;进入自动传播、清空遮罩或特定范围帧导出选择模式时,播放进度条和视频处理进度条会显示黄色范围框,并可点击/拖拽选择起止帧;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。
|
||||
8. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、画笔和橡皮擦生成/编辑 polygon mask;多边形可按 Enter 或点击首节点闭合;画笔/橡皮擦可在左侧工具栏调整大小,画笔要求右侧语义分类树已有选中类别,画出的圆形连续笔触会在鼠标松开时一次性 union 成 mask,若与当前选中 mask 连通则自动合并到该 mask,橡皮擦要求已选中 mask 并在松开时从该 mask 中 difference 扣除;普通 mask 和导入 mask 都不显示黄色 seed point,也不提供 seed point 拖动;未选中特定 mask 时,Canvas 会按右侧语义分类树拖拽得到的内部覆盖优先级从低到高渲染 mask,使高优先级类别显示在上层;Canvas 左上角工具上下文提示会在切换工具或操作状态变化时短暂显示,数秒后自动隐藏,避免长期遮挡底图;工具栏有“调整多边形”入口,左侧 `ToolsPalette` 使用紧凑垂直布局并在高度不足时自身滚动,且在“重叠区域去除”之后提供紫色“导入 GT Mask”入口;工作区左侧工具栏不展示 AI 页的正向选点、反向选点和边界框选,也不重复放置撤销/重做;点击 mask 后可按住顶点直接拖动并实时更新 polygon,顶点拖拽结束不会触发 Stage 平移或重置 Canvas 视口;也可删除 polygon 顶点、通过边中点或双击边界插入新顶点,并能选择编辑多 polygon mask 的单个子区域;选中整块 mask 可用 Delete/Backspace 删除,已保存 mask 会同步后端删除;删除传播 seed 或任一传播结果时会扩展删除同一传播链上的自动传播 mask,但保留其他帧独立 AI 推理或人工标注 mask;区域合并/去除会隐藏编辑手柄并显示已选数量,第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,使用 `polygon-clipping` 做 union/difference,内含去除结果用 even-odd 规则渲染 hole;Zustand 维护 `maskHistory/maskFuture` 支持撤销/重做。
|
||||
8. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、画笔和橡皮擦生成/编辑 polygon mask;多边形可按 Enter 或点击首节点闭合;画笔/橡皮擦可在左侧工具栏调整大小,画笔要求右侧语义分类树已有选中类别,画出的圆形连续笔触会在鼠标松开时一次性 union 成 mask,若与当前选中 mask 连通则自动合并到该 mask,橡皮擦要求已选中 mask 并在松开时从该 mask 中 difference 扣除;普通 mask 和导入 mask 都不显示黄色 seed point,也不提供 seed point 拖动;未选中特定 mask 时,Canvas 会按右侧语义分类树拖拽得到的内部覆盖优先级从低到高渲染 mask,使高优先级类别显示在上层;Canvas 左上角工具上下文提示会在切换工具或操作状态变化时短暂显示,数秒后自动隐藏,避免长期遮挡底图;工具栏有“调整多边形”入口,左侧 `ToolsPalette` 使用紧凑垂直布局并在高度不足时自身滚动,且在“重叠区域去除”之后提供紫色“导入 GT Mask”入口;工作区左侧工具栏不展示 AI 页的正向选点、反向选点和边界框选,也不重复放置撤销/重做;点击 mask 后可按住顶点直接拖动并实时更新 polygon,顶点拖拽结束不会触发 Stage 平移或重置 Canvas 视口;也可删除 polygon 顶点、通过边中点或双击边界插入新顶点,并能选择编辑多 polygon mask 的单个子区域;带中空洞的 mask 会用 `metadata.polygonRingCounts` 记录外圈与内圈的 ring 分组,调整多边形时外圈和内洞都显示可编辑顶点和插点手柄,保存时把内洞拆到 `mask_data.holes`;选中整块 mask 可用 Delete/Backspace 删除,已保存 mask 会同步后端删除;删除传播 seed 或任一传播结果时会扩展删除同一传播链上的自动传播 mask,但保留其他帧独立 AI 推理或人工标注 mask;区域合并/去除会隐藏编辑手柄并显示已选数量,第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,使用 `polygon-clipping` 做 union/difference,内含去除结果用 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,点击开始传播会提示“当前参考帧无遮罩”,不会提交任务或保存其它帧标注;用户可直接修改数字框,也可点击“自动传播”进入时间轴范围选择模式,在播放进度条或视频处理进度条上点击/拖拽选择范围,再点击“开始传播”。工作区顶栏有独立“传播权重”选择器,可为本次传播二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,也不影响 AI 单帧分割权重;前端提交传播前只保存当前参考帧中的 draft/dirty mask,使 seed 优先带稳定的后端 `source_annotation_id`,再按传播权重 id、seed mask、seed 来源 id 和前/后方向组装 `steps` 并调用 `POST /api/ai/propagate/task` 创建 `propagate_masks` 后台任务;后端入队时会规范化/校验权重 id 并把规范化后的 id 写入任务 payload/result;Celery worker 顺序执行各 step,避免多个视频 tracker 并发抢占 GPU;每个 step 会根据 seed 来源 id、方向和 seed 签名做幂等判断,同权重且未改变的 seed 直接跳过,已改变或换用其他权重的 seed 会先删除同源旧自动传播标注再重传;旧版本用前端临时 `source_mask_id` 生成的传播标注会按同一参考帧、方向和语义信息兼容清理;中间帧人工新增/修改同一物体后重新传播时,后端会在写入目标帧新结果前按语义和空间重叠清理旧传播结果,且写入前清理不受旧结果传播方向限制;后端按项目帧序列下载片段帧,当前使用所选 SAM 2.1 权重变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,并把后续帧结果保存为 `Annotation`;若历史或外部 seed 仍带 `geometry_smoothing`,forward/backward 两个方向的传播结果保存前仍会应用同一参数;当前工作区平滑按钮应用后会直接改写实际 polygon,后续传播以新几何参与签名和追踪。工作区轮询 `GET /api/tasks/{task_id}` 展示进度并刷新标注,Dashboard 也能显示/取消/重试传播任务。
|
||||
10. 视频片段传播:工作区以当前打开帧作为参考帧,使用该帧全部 mask 作为 seed,并用传播起始帧和传播结束帧指定追踪范围;如果当前参考帧没有 mask,点击开始传播会提示“当前参考帧无遮罩”,不会提交任务或保存其它帧标注;用户可直接修改数字框,也可点击“自动传播”进入时间轴范围选择模式,在播放进度条或视频处理进度条上点击/拖拽选择范围,再点击“开始传播”。工作区顶栏有独立“传播权重”选择器,可为本次传播二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,也不影响 AI 单帧分割权重;前端提交传播前只保存当前参考帧中的 draft/dirty mask,使 seed 优先带稳定的后端 `source_annotation_id`,再按传播权重 id、seed mask、seed 来源 id 和前/后方向组装 `steps` 并调用 `POST /api/ai/propagate/task` 创建 `propagate_masks` 后台任务;中空 seed 会携带和 `polygons` 对齐的 `holes`,后端注入 SAM 2 video predictor 前会先填充外圈再扣除内洞,避免以实心 mask 传播;后端入队时会规范化/校验权重 id 并把规范化后的 id 写入任务 payload/result;Celery worker 顺序执行各 step,避免多个视频 tracker 并发抢占 GPU;每个 step 会根据 seed 来源 id、方向和包含 `holes` 的 seed 签名做幂等判断,同权重且未改变的 seed 直接跳过,已改变或换用其他权重的 seed 会先删除同源旧自动传播标注再重传;旧版本用前端临时 `source_mask_id` 生成的传播标注会按同一参考帧、方向和语义信息兼容清理;中间帧人工新增/修改同一物体后重新传播时,后端会在写入目标帧新结果前按语义和空间重叠清理旧传播结果,且写入前清理不受旧结果传播方向限制;后端按项目帧序列下载片段帧,当前使用所选 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 并校验 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。
|
||||
12. 模板管理:`TemplateRegistry.tsx` 管理分类、颜色、maskid 和内部覆盖顺序;所有新建、复制、批量导入和后端返回的模板都会归一化包含黑色 `[0,0,0]`、`maskid: 0` 的“待分类”保留类,该类固定在语义分类树最后,不能删除,也不能拖拽到更高层级;批量导入 JSON 支持 `[[colors], [names]]` 和 `{colors, names}` 两种格式,也兼容带“批量导入分类:”前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号的粘贴内容,会先预览分类数量、maskid 分配起点和缺失颜色提示,语法或结构错误以内联错误展示;系统默认模板包括“腹腔镜胆囊切除术”和“头颈部CT分割”,头颈部 CT 默认分类名使用纯中文(肿瘤/结节、下颌骨、甲状腺、气管、颈椎、颈动脉、颈静脉、腮腺、下颌下腺、舌骨),恢复演示出厂设置只删除用户私有模板,并会重建缺失的系统默认模板、覆盖恢复被修改或删减的默认语义分类树;模板库“生效中模板架构清单”里的每个模板卡片支持鼠标点击复制,复制会创建当前用户私有副本并保留分类名称、颜色、maskid、内部层级和规则,同时重建类别内部 id;模板库详情页的分类区标题为“语义分类树(拖拽调层级)”,右上角提供“+ 新建分类”,每个分类行右侧用垃圾桶图标删除该 label,不再展示“未分类/批量导入/模板名”等来源标签;编辑模板弹窗点击分类后只编辑分类名称,不展示或编辑旧 `category` 来源元信息;如果项目中的已保存 mask 引用了当前模板里已被删除的类别,工作区打开项目回显时会把该 mask 降级为 `maskid: 0` 的“待分类”mask 并标记为待保存;模板库详情页和编辑弹窗都支持拖拽调整语义类别层级顺序,拖拽会重算 `zIndex` 并保存到后端,保存后当前详情页会立刻刷新;`OntologyInspector.tsx` 在工作区显示当前模板分类树,也支持拖拽调整内部覆盖顺序。maskid 只作为 GT_label/类别 ID,不参与排序。
|
||||
13. 导出:工作区使用统一“分割结果导出”入口,导出前先保存待归档 mask;用户可选择整体视频、特定范围帧或当前图片,默认导出范围为当前图片,并勾选分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图。选择特定范围帧时,可直接修改起止帧输入框,也可在播放进度条或视频处理进度条上点击/拖拽选择导出范围;选择 Mix_label 时可调透明度,默认 0.3,并显示当前/待导出第一帧预览。下载 ZIP 文件名使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`,项目名来自 `Project.name` 并替换文件系统不安全字符,时间戳格式为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based 顺序而非原视频帧号。后端保留兼容的 COCO JSON 和 PNG mask ZIP 接口,同时新增统一结果 ZIP;统一 ZIP 固定包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json` 和 `原始图片/`;导出时 GT_label 固定写 8-bit uint8 PNG,像素值使用类别真实 `maskid`,其中 `maskid: 0` 的“待分类”与背景同为 0,Pro_label 中也与背景同为黑色 `[0,0,0]`,缺失 `maskid` 的旧标注才补下一个可用值,正整数 maskid 超出 1-255 会拒绝导出,保证导出的 GT_label 可按同一模板再导入;选择分开 mask 时输出 `分开Mask分割结果/{视频名称_时间戳_项目帧序号}_分别导出/{视频名称_时间戳_项目帧序号}_{类别名称}_maskid{maskid}.png`,同一帧同一类别合并为一张图;选择 GT_label/Pro_label/Mix_label 时分别输出 `GT_label图/{视频名称_时间戳_项目帧序号}.png`、`Pro_label彩色分割结果/{视频名称_时间戳_项目帧序号}.png`、`Mix_label重叠覆盖彩色分割结果/{视频名称_时间戳_项目帧序号}.png`。maskid 不参与覆盖排序,GT_label/Pro_label/Mix_label 重叠区域覆盖顺序由内部拖拽排序字段决定,并与未选中状态下的 Canvas 显示顺序一致。
|
||||
@@ -258,14 +258,14 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||
- `src/lib/config.ts` 会优先读取 `VITE_API_BASE_URL` 和 `VITE_WS_PROGRESS_URL`;未配置时按当前浏览器 hostname 推导后端 `:8000` 地址。
|
||||
- 前端 `predictMask()` 已按后端 `PredictRequest` 发送 `image_id`、`prompt_type`、`prompt_data`、`model`,并将后端 `polygons` 转成 Konva 可渲染的 `pathData`。
|
||||
- 手工绘制工具会生成可保存的 `Mask.segmentation`;撤销/重做通过 `maskHistory/maskFuture` 工作。
|
||||
- Polygon 顶点编辑和新增顶点会重算 `pathData/segmentation/bbox/area`;已保存 mask 进入 dirty 状态后复用归档 PATCH 链路。
|
||||
- Polygon 顶点编辑和新增顶点会重算 `pathData/segmentation/bbox/area`;中空 mask 的外圈和内洞都可显示顶点与插点手柄,保存时通过 `mask_data.holes` 和 `metadata.polygonRingCounts` 保留 ring 分组;已保存 mask 进入 dirty 状态后复用归档 PATCH 链路。
|
||||
- 区域合并/去除会重算主 mask 的几何;合并已保存的次级 mask 时会通过工作区回调删除对应后端标注,并同步删除次级 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`;工作区“分割结果导出”按钮会先保存当前待归档 mask,再按所选范围、outputs 和 Mix_label 透明度下载统一 ZIP;特定范围帧导出可用帧号输入框或时间轴拖拽选择范围;下载文件名按项目库项目名、导出范围首尾时间戳和首尾项目帧序号生成;统一 ZIP 包含 maskid/GT 像素值映射 JSON、原始图片文件夹、按帧/类别合并的分开 Mask 文件夹、GT_label 图文件夹、Pro_label 彩色图文件夹和 Mix_label 叠加图文件夹;GT_label 固定为 uint8 PNG,像素值使用类别真实 `maskid` 并跨图一致。
|
||||
- 右侧语义分类树和 Canvas “应用分类”都会把分类变更同步到同一传播链前后帧对应 mask;识别依据为 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key`,被同步更新的已保存 mask 会进入 dirty 状态,等待工作区归档保存 PATCH 到后端;保存 dirty mask 时会保留 `source`、传播 seed 和来源 id 等 metadata,避免传播帧在时间轴上变成人工/AI 标注帧。
|
||||
- 工作区保存状态按钮会按当前项目待保存数量显示“保存 X 个改动”或“已全部保存”,并已接入 `POST /api/ai/annotate` 和 `PATCH /api/ai/annotations/{id}`;加载工作区时会通过 `GET /api/ai/annotations` 回显已保存标注。
|
||||
- 右侧实例属性面板“边缘平滑强度/应用边缘平滑”已接入 `POST /api/ai/smooth-mask`;滑杆会即时更新数值,但后端预览请求有短防抖,避免拖动时连续请求卡顿;预览不写入撤销历史也不标 dirty;点击应用后会把返回 polygon 作为新的实际 mask 几何写入当前 mask 和同传播链前后对应 mask,整次应用作为一个撤销/重做历史步骤,相关 mask 标记为 dirty/draft,平滑强度重置为 0,用户可继续用 polygon 编辑工具调整新多边形。
|
||||
- 工作区“自动传播”按钮已接入 `POST /api/ai/propagate/task`;若用户尚未显式设置范围,第一次点击会进入时间轴范围选择模式,第二次点击“开始传播”才提交后台任务;当前启用所选 SAM 2.1 变体的视频 predictor 后台任务,运行中轮询任务进度,完成后刷新后端已保存标注;工作区顶栏模型状态用紧凑 GPU/CPU 徽标,具体 SAM 2.1 传播权重由旁边下拉选择;同步 `POST /api/ai/propagate` 仍作为单 seed 兼容接口保留。
|
||||
- 工作区“自动传播”按钮已接入 `POST /api/ai/propagate/task`;若用户尚未显式设置范围,第一次点击会进入时间轴范围选择模式,第二次点击“开始传播”才提交后台任务;当前启用所选 SAM 2.1 变体的视频 predictor 后台任务,运行中轮询任务进度,完成后刷新后端已保存标注;中空 seed 会把内洞传给后端,SAM 2 seed mask 栅格化时扣除内洞,传播结果保存时也会保留 `holes`;工作区顶栏模型状态用紧凑 GPU/CPU 徽标,具体 SAM 2.1 传播权重由旁边下拉选择;同步 `POST /api/ai/propagate` 仍作为单 seed 兼容接口保留。
|
||||
- 工作区顶栏短状态会自动消失;保存、导出、导入 GT、传播进行中和无帧项目提示会保留到状态变化。
|
||||
- 工作区“清空遮罩”会调用 `DELETE /api/ai/annotations/{id}` 删除当前帧已保存标注,并清空当前帧本地 mask。
|
||||
- 项目状态已统一为 `pending`、`parsing`、`ready`、`error`;前端 `src/lib/api.ts` 会兼容归一化旧库中可能存在的 `Ready`、`Parsing`、`Error`。
|
||||
|
||||
@@ -803,17 +803,20 @@ def propagate(
|
||||
if not payload.include_source and frame.id == source_frame.id:
|
||||
continue
|
||||
result_polygons = frame_result.get("polygons") or []
|
||||
result_holes = frame_result.get("holes") or []
|
||||
scores = frame_result.get("scores") or []
|
||||
for polygon_index, polygon in enumerate(result_polygons):
|
||||
if len(polygon) < 3:
|
||||
continue
|
||||
polygon_to_save = _smooth_polygon(polygon, smoothing) if smoothing else polygon
|
||||
hole_group = result_holes[polygon_index] if polygon_index < len(result_holes) and isinstance(result_holes[polygon_index], list) else []
|
||||
annotation = Annotation(
|
||||
project_id=payload.project_id,
|
||||
frame_id=frame.id,
|
||||
template_id=template_id,
|
||||
mask_data={
|
||||
"polygons": [polygon_to_save],
|
||||
**({"holes": [hole_group], "hasHoles": True} if hole_group else {}),
|
||||
"label": label,
|
||||
"color": color,
|
||||
"source": f"{model_id}_propagation",
|
||||
|
||||
@@ -294,6 +294,7 @@ class SmoothMaskResponse(BaseModel):
|
||||
|
||||
class PropagationSeed(BaseModel):
|
||||
polygons: Optional[list[list[list[float]]]] = None
|
||||
holes: Optional[list[list[list[list[float]]]]] = None
|
||||
bbox: Optional[list[float]] = None
|
||||
points: Optional[list[list[float]]] = None
|
||||
labels: Optional[list[int]] = None
|
||||
|
||||
@@ -207,6 +207,7 @@ def _seed_signature(seed: dict[str, Any]) -> str:
|
||||
return str(inherited_signature)
|
||||
signature_payload = {
|
||||
"polygons": seed.get("polygons") or [],
|
||||
"holes": seed.get("holes") or [],
|
||||
"bbox": seed.get("bbox") or [],
|
||||
"points": seed.get("points") or [],
|
||||
"labels": seed.get("labels") or [],
|
||||
@@ -458,13 +459,14 @@ def _save_propagated_annotations(
|
||||
if not include_source and frame.id == source_frame.id:
|
||||
continue
|
||||
result_polygons = frame_result.get("polygons") or []
|
||||
result_holes = frame_result.get("holes") or []
|
||||
scores = frame_result.get("scores") or []
|
||||
smoothed_polygons = [
|
||||
_smooth_polygon(polygon, smoothing)
|
||||
for polygon in result_polygons
|
||||
prepared_polygons = [
|
||||
(polygon_index, _smooth_polygon(polygon, smoothing))
|
||||
for polygon_index, polygon in enumerate(result_polygons)
|
||||
if len(polygon) >= 3
|
||||
]
|
||||
cleanup_polygon = next((polygon for polygon in smoothed_polygons if len(polygon) >= 3), None)
|
||||
cleanup_polygon = next((polygon for _polygon_index, polygon in prepared_polygons if len(polygon) >= 3), None)
|
||||
if cleanup_polygon is not None and frame.id not in cleaned_frame_ids:
|
||||
deleted_count += _delete_replaced_frame_annotations(
|
||||
db,
|
||||
@@ -475,15 +477,17 @@ def _save_propagated_annotations(
|
||||
polygon=cleanup_polygon,
|
||||
)
|
||||
cleaned_frame_ids.add(int(frame.id))
|
||||
for polygon_index, polygon in enumerate(smoothed_polygons):
|
||||
for polygon_index, polygon in prepared_polygons:
|
||||
if len(polygon) < 3:
|
||||
continue
|
||||
hole_group = result_holes[polygon_index] if polygon_index < len(result_holes) and isinstance(result_holes[polygon_index], list) else []
|
||||
annotation = Annotation(
|
||||
project_id=int(payload["project_id"]),
|
||||
frame_id=frame.id,
|
||||
template_id=template_id,
|
||||
mask_data={
|
||||
"polygons": [polygon],
|
||||
**({"holes": [hole_group], "hasHoles": True} if hole_group else {}),
|
||||
"label": label,
|
||||
"color": color,
|
||||
"source": f"{model_id}_propagation",
|
||||
|
||||
@@ -507,7 +507,7 @@ class SAM2Engine:
|
||||
if source_image is None:
|
||||
raise RuntimeError("Failed to decode source frame for SAM 2 propagation.")
|
||||
height, width = source_image.shape[:2]
|
||||
seed_mask = self._polygons_to_mask(seed.get("polygons") or [], width, height)
|
||||
seed_mask = self._polygons_to_mask(seed.get("polygons") or [], width, height, seed.get("holes") or [])
|
||||
if not seed_mask.any():
|
||||
bbox = seed.get("bbox")
|
||||
if isinstance(bbox, list) and len(bbox) == 4:
|
||||
@@ -543,15 +543,18 @@ class SAM2Engine:
|
||||
if masks.ndim == 4:
|
||||
masks = masks[:, 0]
|
||||
polygons = []
|
||||
holes = []
|
||||
scores = []
|
||||
for mask in masks:
|
||||
polygon = self._mask_to_polygon(mask > 0)
|
||||
if polygon:
|
||||
mask_polygons, mask_holes = self._mask_to_polygon_data(mask > 0)
|
||||
for polygon_index, polygon in enumerate(mask_polygons):
|
||||
polygons.append(polygon)
|
||||
holes.append(mask_holes[polygon_index] if polygon_index < len(mask_holes) else [])
|
||||
scores.append(1.0)
|
||||
results[int(out_frame_idx)] = {
|
||||
"frame_index": int(out_frame_idx),
|
||||
"polygons": polygons,
|
||||
"holes": holes,
|
||||
"scores": scores,
|
||||
"object_ids": [int(obj_id) for obj_id in list(out_obj_ids)],
|
||||
}
|
||||
@@ -574,19 +577,49 @@ class SAM2Engine:
|
||||
@staticmethod
|
||||
def _mask_to_polygon(mask: np.ndarray) -> list[list[float]]:
|
||||
"""Convert a binary mask to a normalized polygon."""
|
||||
polygons, _holes = SAM2Engine._mask_to_polygon_data(mask)
|
||||
return polygons[0] if polygons else []
|
||||
|
||||
@staticmethod
|
||||
def _mask_to_polygon_data(mask: np.ndarray) -> tuple[list[list[list[float]]], list[list[list[list[float]]]]]:
|
||||
"""Convert a binary mask to normalized outer polygons and aligned hole rings."""
|
||||
import cv2
|
||||
|
||||
if mask.dtype != np.uint8:
|
||||
mask = (mask > 0).astype(np.uint8)
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
contours, hierarchy = cv2.findContours(mask, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
|
||||
h, w = mask.shape[:2]
|
||||
largest = []
|
||||
for cnt in contours:
|
||||
if len(cnt) > len(largest):
|
||||
largest = cnt
|
||||
if len(largest) < 3:
|
||||
if hierarchy is None:
|
||||
return [], []
|
||||
|
||||
def contour_to_polygon(contour: np.ndarray) -> list[list[float]]:
|
||||
if len(contour) < 3:
|
||||
return []
|
||||
return [[float(pt[0][0]) / w, float(pt[0][1]) / h] for pt in largest]
|
||||
return [[float(pt[0][0]) / w, float(pt[0][1]) / h] for pt in contour]
|
||||
|
||||
hierarchy_rows = hierarchy[0]
|
||||
outer_indices = [
|
||||
index for index, row in enumerate(hierarchy_rows)
|
||||
if int(row[3]) < 0 and len(contours[index]) >= 3
|
||||
]
|
||||
outer_indices.sort(key=lambda index: cv2.contourArea(contours[index]), reverse=True)
|
||||
|
||||
polygons: list[list[list[float]]] = []
|
||||
holes: list[list[list[list[float]]]] = []
|
||||
for outer_index in outer_indices:
|
||||
outer = contour_to_polygon(contours[outer_index])
|
||||
if not outer:
|
||||
continue
|
||||
child_index = int(hierarchy_rows[outer_index][2])
|
||||
hole_group: list[list[list[float]]] = []
|
||||
while child_index >= 0:
|
||||
hole = contour_to_polygon(contours[child_index])
|
||||
if hole:
|
||||
hole_group.append(hole)
|
||||
child_index = int(hierarchy_rows[child_index][0])
|
||||
polygons.append(outer)
|
||||
holes.append(hole_group)
|
||||
return polygons, holes
|
||||
|
||||
@staticmethod
|
||||
def _dummy_polygons(w: int, h: int) -> list[list[list[float]]]:
|
||||
@@ -601,11 +634,16 @@ class SAM2Engine:
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _polygons_to_mask(polygons: list[list[list[float]]], width: int, height: int) -> np.ndarray:
|
||||
def _polygons_to_mask(
|
||||
polygons: list[list[list[float]]],
|
||||
width: int,
|
||||
height: int,
|
||||
holes_by_polygon: list[list[list[list[float]]]] | None = None,
|
||||
) -> np.ndarray:
|
||||
import cv2
|
||||
|
||||
mask = np.zeros((height, width), dtype=np.uint8)
|
||||
for polygon in polygons:
|
||||
for polygon_index, polygon in enumerate(polygons):
|
||||
if len(polygon) < 3:
|
||||
continue
|
||||
pts = np.array(
|
||||
@@ -619,6 +657,21 @@ class SAM2Engine:
|
||||
dtype=np.int32,
|
||||
)
|
||||
cv2.fillPoly(mask, [pts], 1)
|
||||
holes = holes_by_polygon[polygon_index] if holes_by_polygon and polygon_index < len(holes_by_polygon) else []
|
||||
for hole in holes:
|
||||
if len(hole) < 3:
|
||||
continue
|
||||
hole_pts = np.array(
|
||||
[
|
||||
[
|
||||
int(round(min(max(float(x), 0.0), 1.0) * max(width - 1, 1))),
|
||||
int(round(min(max(float(y), 0.0), 1.0) * max(height - 1, 1))),
|
||||
]
|
||||
for x, y in hole
|
||||
],
|
||||
dtype=np.int32,
|
||||
)
|
||||
cv2.fillPoly(mask, [hole_pts], 0)
|
||||
return mask.astype(bool)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -78,3 +78,27 @@ def test_sam2_status_exposes_selectable_variants(monkeypatch, tmp_path):
|
||||
assert status["label"] == "SAM 2.1 Small"
|
||||
assert status["checkpoint_exists"] is True
|
||||
assert status["checkpoint_path"].endswith("sam2.1_hiera_small.pt")
|
||||
|
||||
|
||||
def test_sam2_seed_mask_subtracts_holes():
|
||||
mask = SAM2Engine._polygons_to_mask(
|
||||
polygons=[[[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]]],
|
||||
width=100,
|
||||
height=100,
|
||||
holes_by_polygon=[[[[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]]]],
|
||||
)
|
||||
|
||||
assert bool(mask[20, 20]) is True
|
||||
assert bool(mask[50, 50]) is False
|
||||
|
||||
|
||||
def test_sam2_mask_to_polygon_data_preserves_holes():
|
||||
mask = np.zeros((100, 100), dtype=np.uint8)
|
||||
mask[10:90, 10:90] = 1
|
||||
mask[40:60, 40:60] = 0
|
||||
|
||||
polygons, holes = SAM2Engine._mask_to_polygon_data(mask)
|
||||
|
||||
assert len(polygons) == 1
|
||||
assert len(holes) == 1
|
||||
assert len(holes[0]) == 1
|
||||
|
||||
@@ -301,10 +301,10 @@ SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2`
|
||||
- `getProjectAnnotations()` 已接入 `GET /api/ai/annotations`。
|
||||
- `updateAnnotation()` 已接入 `PATCH /api/ai/annotations/{annotationId}`。
|
||||
- `deleteAnnotation()` 已接入 `DELETE /api/ai/annotations/{annotationId}`。
|
||||
- `importGtMask()` 已接入 `POST /api/ai/import-gt-mask`,导入后端生成的高精度 polygon 标注、原始 `gt_label_value`、原图尺寸/是否拉伸信息。导入端使用 `cv2.IMREAD_UNCHANGED` 读取后校验 dtype,仅接受 8-bit 灰度图和 8-bit RGB 三通道相等图,并按模板 `maskId` 匹配类别;16-bit/uint16 GT_label、全背景 0 图和普通彩色 RGB 类别图都会返回格式错误,全背景图保留“GT Mask 图片中没有非背景 maskid 区域。”提示;超出现有类别时由 `unknown_color_policy` 决定舍弃或写为 `gt_unknown_class` 未定义类别。导入 mask 与普通 mask 共用拓扑统计、边缘平滑和保存更新接口,前端不显示黄色 seed point。
|
||||
- `importGtMask()` 已接入 `POST /api/ai/import-gt-mask`,导入后端生成的高精度 polygon 标注、原始 `gt_label_value`、原图尺寸/是否拉伸信息。导入端使用 `cv2.IMREAD_UNCHANGED` 读取后校验 dtype,仅接受 8-bit 灰度图和 8-bit RGB 三通道相等图,并按模板 `maskId` 匹配类别;16-bit/uint16 GT_label、全背景 0 图和普通彩色 RGB 类别图都会返回格式错误,全背景图保留“GT Mask 图片中没有非背景 maskid 区域。”提示;超出现有类别时由 `unknown_color_policy` 决定舍弃或写为 `gt_unknown_class` 未定义类别。导入 mask 与普通 mask 共用拓扑统计、边缘平滑和保存更新接口,中空导入结果通过 `mask_data.holes` 和 `metadata.polygonRingCounts` 回显为可编辑内洞,前端不显示黄色 seed point。
|
||||
- `exportMasks()` 已接入 `GET /api/export/{projectId}/masks`。
|
||||
- `parseMedia()` 已改为创建 Celery 后台任务,并返回 `ProcessingTask`。
|
||||
- `queuePropagationTask()` 已接入 `/api/ai/propagate/task`,自动传播不再依赖长时间同步 HTTP 请求。
|
||||
- `queuePropagationTask()` 已接入 `/api/ai/propagate/task`,自动传播不再依赖长时间同步 HTTP 请求;传播 seed 可携带与 `polygons` 对齐的 `holes`,后端 seed 签名、SAM 2 seed mask 栅格化和传播结果保存都会保留内洞。
|
||||
- `getTask()` 已接入 `GET /api/tasks/{taskId}`。
|
||||
- `cancelTask()` 已接入 `POST /api/tasks/{taskId}/cancel`。
|
||||
- `retryTask()` 已接入 `POST /api/tasks/{taskId}/retry`。
|
||||
@@ -318,4 +318,4 @@ SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2`
|
||||
|
||||
- WebSocket 地址已从 `VITE_WS_PROGRESS_URL` 读取,未配置时从 `API_BASE_URL` 推导;部署时仍要确认浏览器能访问该地址。
|
||||
- Celery worker 进度会写 PostgreSQL 任务表,同时发布到 Redis `seg:progress`;FastAPI 订阅后广播到 `/ws/progress`。
|
||||
- 已保存标注目前支持分类级更新、polygon 顶点拖动、顶点删除、边中点插入、多 polygon 子区域选择编辑后的 PATCH 更新和整帧清空删除;复杂洞结构的专业编辑仍未实现。
|
||||
- 已保存标注目前支持分类级更新、polygon 顶点拖动、顶点删除、边中点插入、多 polygon 子区域选择、中空 mask 内洞 ring 编辑后的 PATCH 更新和整帧清空删除;`mask_data.polygons` 保存外圈,`mask_data.holes` 保存与外圈对齐的内洞,`metadata.polygonRingCounts` 支撑前端把外圈/内洞重新组合成可编辑结构。
|
||||
|
||||
@@ -86,12 +86,13 @@
|
||||
- 顶点编辑态显示边中点插入手柄;点击边中点会在该边中间新增顶点。
|
||||
- “调整多边形”工具下双击 polygon 边界时,会在最接近的线段上按双击位置新增顶点。
|
||||
- 顶点编辑态下选中顶点后可用 Delete/Backspace 删除顶点,但不会让 polygon 少于三点。
|
||||
- 中空 mask 必须保留外圈与内洞 ring 分组;进入“调整多边形”后,外圈和内洞都应显示可拖动顶点与边中点插入手柄,内洞顶点拖动、插点和保存后的回显都不能把 mask 变成实心。
|
||||
- 选中整个 mask 且未选中具体顶点时,Delete/Backspace 删除该 mask;已保存 mask 同步调用后端删除接口;如果删除对象属于自动传播链或是传播 seed,应同步删除同一传播链上的自动传播 mask,但不能删除其他帧独立 AI 推理或人工标注 mask。
|
||||
- 撤销、重做绑定全局 `maskHistory/maskFuture`,工作区支持顶栏按钮和 Canvas 快捷键,AI 页支持自己的按钮;左侧工具栏不重复放置撤销/重做入口。
|
||||
- 区域合并工具支持多选当前帧 mask,并使用 polygon union 生成合并后的主 mask;被合并移除的次级 mask 若带传播链,应同步删除其同链自动传播结果。
|
||||
- 区域去除工具支持多选当前帧 mask,并从第一个选中的主 mask 中扣除后续选中 mask。
|
||||
- 区域合并/去除模式显示已选数量,并隐藏 polygon 编辑手柄以避免手柄抢占多选点击;第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓。
|
||||
- 区域去除结果包含内洞时,前端保留 hole ring 并用 even-odd 规则渲染。
|
||||
- 区域去除结果包含内洞时,前端保留 hole ring 并用 even-odd 规则渲染,保存时把外圈写入 `mask_data.polygons`、把每个外圈对应内洞写入 `mask_data.holes`,并用 `metadata.polygonRingCounts` 支撑前端 ring 回显。
|
||||
|
||||
## R6 AI 推理
|
||||
|
||||
@@ -129,7 +130,8 @@
|
||||
- 前端会把多个 seed 或双向范围拆成 `steps`,通过 `POST /api/ai/propagate/task` 创建 `propagate_masks` 后台任务,避免长 HTTP 请求卡在浏览器侧,同时避免并发抢占 GPU。
|
||||
- `POST /api/ai/propagate` 作为单 seed 同步兼容接口保留;`POST /api/ai/propagate/task` 是工作区自动传播使用的任务接口。两者当前支持四个 SAM 2.1 变体;兼容 `model=sam2` 并归一化为 tiny。SAM 2.1 使用官方 `SAM2VideoPredictor.add_new_mask()` 和 `propagate_in_video()`。
|
||||
- 自动传播任务写入 `processing_tasks`,前端轮询 `GET /api/tasks/{task_id}` 显示进度并刷新标注;Dashboard 也能看到该任务,任务可取消和重试。
|
||||
- 传播结果会写入后续帧 `annotations`,`mask_data.source` 标记为 `<model_id>_propagation`,并保留 label、color、class 元数据、seed 来源 id、seed 签名和传播方向;如果历史或外部 seed 带 `geometry_smoothing` 平滑参数,worker 保存前仍必须对传播返回的 polygon 实际应用同一平滑几何,不能只更新拓扑锚点或 metadata。当前工作区平滑按钮应用后会直接改写实际 polygon 并清除平滑参数,后续传播以新几何本身参与签名。
|
||||
- 传播 seed 若包含中空结构,前端必须把内洞按外圈对齐传入 `holes`;后端栅格化 SAM 2 seed mask 时先填充外圈再扣除内洞,不能以实心 mask 注入 video predictor;seed 签名也必须包含 `holes`,避免中空编辑后被误判为未变化。
|
||||
- 传播结果会写入后续帧 `annotations`,`mask_data.source` 标记为 `<model_id>_propagation`,并保留 label、color、class 元数据、seed 来源 id、seed 签名和传播方向;后端从传播二值 mask 提取轮廓时必须保留内洞,保存为与 `polygons` 对齐的 `mask_data.holes`,前端回显后仍能编辑内洞;如果历史或外部 seed 带 `geometry_smoothing` 平滑参数,worker 保存前仍必须对传播返回的 polygon 实际应用同一平滑几何,不能只更新拓扑锚点或 metadata。当前工作区平滑按钮应用后会直接改写实际 polygon 并清除平滑参数,后续传播以新几何本身参与签名。
|
||||
- 自动传播任务必须避免重复叠加:同一目标帧段内,同一参考 seed、同一权重、同一方向且所有目标帧已有未变化结果时,worker 直接跳过;同一参考 seed 已变化、目标帧段只部分覆盖或用户改用其他 SAM 2.1 权重时,worker 先删除本次目标帧段内对应旧自动传播标注,再保存新传播结果;对早期只记录前端临时 `source_mask_id` 的旧传播结果,worker 会按传播方向和语义信息做兼容清理。用户在自动传播链中间帧人工新增或修改同一物体 mask 后重新向前/向后传播时,即使新 seed 缺少旧传播链 source id,也要按语义信息和目标帧空间重叠清理旧传播结果后再写入新结果;写入前清理不受旧结果 `propagation_direction` 限制,因此当前帧向前传播时也会替换原先由更早帧向后传播出来的旧 mask,避免同一物体新旧 mask 堆叠。未编辑的自动传播结果再次作为参考 seed 时,会继承原始 `propagation_seed_signature` 以避免重复传播;被编辑后的传播结果只保留 lineage,不继承旧签名,以便触发删除旧结果并重新传播。历史带 `geometry_smoothing` 的 seed 在 forward/backward 两个方向都会用同一参数平滑保存结果。
|
||||
- AI 页面会对未放置点提示、后端错误和返回 0 个 mask 的情况显示明确反馈。
|
||||
- AI 参数支持 `crop_to_prompt`、`auto_filter_background` 和 `min_score`;点/框 prompt 可以裁剪局部区域推理并回映射结果,背景过滤会移除低分结果和包含负向点的 polygon。
|
||||
|
||||
@@ -166,14 +166,14 @@
|
||||
2. 用户可以直接修改传播起始帧/结束帧数字框,并可通过工作区顶栏“传播权重”下拉独立选择本次传播使用的 SAM 2.1 tiny/small/base+/large 权重;该入口不提供 SAM2/SAM3 家族切换,默认跟随全局 AI 权重,用户手动选择后不再被 AI 页权重切换覆盖。
|
||||
3. `VideoWorkspace` 以当前参考帧为 seed,将起止帧拆成 `backward` 和/或 `forward` 两段;只包含当前帧时不传播。
|
||||
4. `VideoWorkspace` 在提交传播前会先调用现有归档保存链路保存当前项目中的 draft/dirty mask,并重新读取 store 中的回显结果;参考帧 seed 因此优先携带稳定的后端 `source_annotation_id`,避免用前端临时 mask id 生成传播结果后,二次传播无法找到旧结果。
|
||||
5. `VideoWorkspace` 用 `buildAnnotationPayload()` 把每个 seed mask 转成 normalized polygon、bbox、label、color、class 元数据、`source_mask_id` 和可用时的 `source_annotation_id`;如果 seed mask 是未编辑的自动传播结果,会沿用其原始 `source_annotation_id/source_mask_id/propagation_seed_signature`,让后端把它识别为原传播链的同一个 seed;如果该传播结果被编辑并保存,更新 payload 只保留 lineage,不保留旧签名,使后端按“已修改”路径清理旧结果并重传。对历史或外部写入的 `geometry_smoothing` metadata,payload 仍可透传给后端兼容处理;当前前端平滑应用会直接改写 polygon 几何并移除该参数。
|
||||
5. `VideoWorkspace` 用 `buildAnnotationPayload()` 把每个 seed mask 转成 normalized polygon、bbox、label、color、class 元数据、`source_mask_id` 和可用时的 `source_annotation_id`;中空 mask 会按 `metadata.polygonRingCounts` 将外圈写入 `mask_data.polygons`,把与外圈对齐的内洞写入 `mask_data.holes`,传播 seed 同步携带 `holes`;如果 seed mask 是未编辑的自动传播结果,会沿用其原始 `source_annotation_id/source_mask_id/propagation_seed_signature`,让后端把它识别为原传播链的同一个 seed;如果该传播结果被编辑并保存,更新 payload 只保留 lineage,不保留旧签名,使后端按“已修改”路径清理旧结果并重传。对历史或外部写入的 `geometry_smoothing` metadata,payload 仍可透传给后端兼容处理;当前前端平滑应用会直接改写 polygon 几何并移除该参数。
|
||||
6. 前端把传播权重 id、每个 seed、每个方向组装成 `steps`,一次调用 `POST /api/ai/propagate/task`,`include_source=false`、`save_annotations=true`;接口先规范化/校验 `model` 字段中的权重 id,再创建 `processing_tasks.task_type=propagate_masks` 并投递 Celery,避免长 HTTP 请求阻塞前端等待。
|
||||
7. `VideoWorkspace` 记录返回的 `task_id`,轮询 `GET /api/tasks/{task_id}` 显示任务 message、步骤进度、已处理帧次和已保存区域数;任务运行期间提供取消传播按钮,调用通用 `POST /api/tasks/{task_id}/cancel`。
|
||||
8. Celery worker 逐 step 顺序执行传播,避免多个视频 tracker 并发抢占 GPU;每个 step 开始/完成都会写入 `processing_tasks.progress/result/message` 并发布 Redis `seg:progress`,Dashboard 可同步显示。每个 step 开始前,worker 会在本次目标帧段内用 seed 来源 id、传播方向和 seed 签名查找旧传播标注:同权重、签名相同且目标帧都已有结果时跳过该 seed;签名不同、目标帧只部分覆盖或本次使用了其他 SAM 2.1 权重则先删除本次目标帧段内对应方向的旧自动传播标注,再执行新的 video predictor 传播;若历史 seed 签名中包含 `geometry_smoothing`,仍按完整签名参与兼容去重。对旧版本只记录前端临时 `source_mask_id` 的传播标注,worker 会按 label/color/class 做兼容匹配,确保可被后续稳定 `source_annotation_id` 的传播替换;对中间帧人工新增的替代 seed,若缺少旧 source id,worker 仍会用语义信息识别候选旧传播结果,并在写入目标帧新 polygon 前用目标帧 bbox 重叠做二次确认和清理。写入前这层清理不限制旧结果方向,确保 backward 传播可覆盖早先 forward 传播留下的同物体旧 mask。
|
||||
9. 后端按项目帧序列截取片段,下载对应帧到临时目录,并写成 `000000.jpg` 这类纯数字文件名;这是 `SAM2VideoPredictor` 对视频帧排序的要求,和项目库中持久化的 `frame_%06d.jpg` 对象名无关。
|
||||
10. `model` 为任一 SAM 2.1 权重变体时,`sam2_engine` 使用对应 checkpoint/config 加载 `SAM2VideoPredictor.add_new_mask()` 注入 seed mask,再用 `propagate_in_video()` 传播;`model=sam2` 会在入队时规范化为 tiny,任务 payload/result 会保留规范化后的权重 id;单个 SAM2 video predictor 调用内部暂不提供逐帧流式进度。
|
||||
10. `model` 为任一 SAM 2.1 权重变体时,`sam2_engine` 使用对应 checkpoint/config 加载 `SAM2VideoPredictor.add_new_mask()` 注入 seed mask,再用 `propagate_in_video()` 传播;注入 seed 前会把外圈 polygon 栅格化为前景,再按 `holes` 扣除内洞,避免中空参考 mask 以实心形式传播;`model=sam2` 会在入队时规范化为 tiny,任务 payload/result 会保留规范化后的权重 id;单个 SAM2 video predictor 调用内部暂不提供逐帧流式进度。
|
||||
11. `model=sam3` 当前不支持;SAM 3 video tracker 代码保留但没有接入产品路径。
|
||||
12. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`,跳过源帧;如果历史或外部 seed 带 `geometry_smoothing`,保存前仍会用同一平滑参数处理 forward/backward 两个方向的结果:强度先经过缓入曲线映射,低强度使用较小 Chaikin 切角比例和简化阈值,高强度再逐步增加迭代、切角和简化力度;随后按强度对 SAM 密集轮廓做 `approxPolyDP` 去噪简化,再做 Chaikin 平滑,最后二次简化并以平滑后的 polygon 计算 bbox 后落库。当前工作区“应用边缘平滑”会在前端把同传播链对应 mask 直接改写为新的 polygon 并移除 `geometry_smoothing` 参数,因此后续传播通常按新几何本身参与 seed 签名。`mask_data.source` 记录权重传播来源,同时写入 `propagation_seed_key`、`propagation_seed_signature`、`propagation_direction`、`source_annotation_id` 和 `source_mask_id` 供后续幂等传播判断;历史 `geometry_smoothing` 仅在存在时保留用于兼容判断。
|
||||
12. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`,跳过源帧;传播 mask 轮廓提取使用层级信息保留内洞,外圈写入 `mask_data.polygons`,内洞按外圈对齐写入 `mask_data.holes`,并设置 `metadata.hasHoles` 供前端按中空 mask 回显和编辑;如果历史或外部 seed 带 `geometry_smoothing`,保存前仍会用同一平滑参数处理 forward/backward 两个方向的结果:强度先经过缓入曲线映射,低强度使用较小 Chaikin 切角比例和简化阈值,高强度再逐步增加迭代、切角和简化力度;随后按强度对 SAM 密集轮廓做 `approxPolyDP` 去噪简化,再做 Chaikin 平滑,最后二次简化并以平滑后的 polygon 计算 bbox 后落库。当前工作区“应用边缘平滑”会在前端把同传播链对应 mask 直接改写为新的 polygon 并移除 `geometry_smoothing` 参数,因此后续传播通常按新几何本身参与 seed 签名。`mask_data.source` 记录权重传播来源,同时写入 `propagation_seed_key`、`propagation_seed_signature`、`propagation_direction`、`source_annotation_id` 和 `source_mask_id` 供后续幂等传播判断;历史 `geometry_smoothing` 仅在存在时保留用于兼容判断。
|
||||
13. 前端轮询到已创建区域后刷新 `GET /api/ai/annotations` 并回显新标注;任务结束后如果后端返回 0 个新区域,工作区会明确提示没有生成新的 mask,若是未改变 seed 被跳过则提示未改变 mask 已跳过。处理过帧次大于 0 的成功任务会追加一条本地传播历史片段,用于视频处理进度条显示最近传播范围;`annotationToMask()` 会保留传播来源 metadata,供时间轴视频处理进度条显示蓝色传播区段。
|
||||
|
||||
### 手工绘制与历史栈
|
||||
@@ -208,8 +208,8 @@
|
||||
4. 布尔选择态按选择顺序区分角色:第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓;所有已选区域填充透明度保持一致,避免被误解为阴影模式异常。
|
||||
5. `CanvasArea` 把 `Mask.segmentation` 转为 `polygon-clipping` 的 MultiPolygon。
|
||||
6. `area_merge` 使用 union,更新第一个选中的主 mask,并从前端 store 移除后续被合并 mask;如果被移除 mask 已保存,会调用工作区传入的删除回调删除后端标注;被移除 mask 的同链自动传播结果也会一并删除。
|
||||
7. `area_remove` 使用 difference,从第一个选中的主 mask 中扣除后续选中 mask,扣除对象本身保留;如果 difference 产生内洞,`segmentation` 保留外圈和 hole ring,渲染时使用 even-odd fill。
|
||||
8. 结果会重算 `pathData`、`segmentation`、`bbox`、`area`,已保存主 mask 会进入 dirty 状态并复用归档 PATCH 链路;带洞结果的面积按外圈减内洞计算。
|
||||
7. `area_remove` 使用 difference,从第一个选中的主 mask 中扣除后续选中 mask,扣除对象本身保留;如果 difference 产生内洞,`segmentation` 保留外圈和 hole ring,`metadata.polygonRingCounts` 记录每个 polygon 的 ring 数,渲染时使用 even-odd fill。
|
||||
8. 结果会重算 `pathData`、`segmentation`、`bbox`、`area`,已保存主 mask 会进入 dirty 状态并复用归档 PATCH 链路;带洞结果的面积按外圈减内洞计算;进入调整多边形时,外圈和内洞 ring 都会显示顶点和边中点插入手柄,内洞拖动、插点、保存与回显继续保持中空结构。
|
||||
|
||||
### GT Mask 导入
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
3. 后端验证项目、帧、模板后使用 OpenCV 读取灰度 mask。
|
||||
4. 后端按非零像素值拆分多类别标签。
|
||||
5. 后端对每个类别的前景做高精度 contour 提取,每个连通域保存为一个 `Annotation`;轮廓使用未压缩链提取并以较小 `approxPolyDP` epsilon 保留细节,超过点数上限时才逐步增加简化强度或抽样。
|
||||
6. `points` 字段可保存距离变换中心 seed point 供数据兼容,`mask_data.polygons` 保存 normalized polygon,`mask_data.gt_label_value` 保存原始像素类别值;导入后的 polygon 与普通 mask 走同一套拓扑锚点统计、边缘平滑、编辑和保存链路。
|
||||
6. `points` 字段可保存距离变换中心 seed point 供数据兼容,`mask_data.polygons` 保存 normalized polygon,`mask_data.holes` 保存与外圈对齐的内洞,`mask_data.gt_label_value` 保存原始像素类别值;导入后的 polygon 与普通 mask 走同一套拓扑锚点统计、边缘平滑、编辑和保存链路。
|
||||
7. 前端重新读取项目标注并回显。
|
||||
8. `annotationToMask()` 仍可把后端 `points` 转成像素坐标保存在 mask 数据中,但 Canvas 不显示 seed point,也不提供拖动;普通 polygon 若没有后端 seed point,保存逻辑可按 polygon 自动计算内部代表点写入,以保持数据兼容。
|
||||
|
||||
@@ -297,7 +297,7 @@
|
||||
以下能力属于当前冻结版本的占位或半可用功能:
|
||||
|
||||
- Dashboard 初始快照来自 `GET /api/dashboard/overview`;任务进度区由 `processing_tasks` queued/running/success/failed/cancelled 任务生成,处理中统计只计算 queued/running。
|
||||
- 已保存标注支持通过“应用分类”、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑和区域合并/去除进入 dirty 状态并归档更新;选中整块 mask 可用 Delete/Backspace 删除并同步后端,同传播链自动传播结果会随传播 seed/传播结果删除而一并清理,独立 AI 推理/人工 mask 保留;复杂洞结构编辑尚未实现。
|
||||
- 已保存标注支持通过“应用分类”、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑、中空 mask 内洞 ring 编辑和区域合并/去除进入 dirty 状态并归档更新;选中整块 mask 可用 Delete/Backspace 删除并同步后端,同传播链自动传播结果会随传播 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。前端不再展示“后端模型置信度”。
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
| 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 后跨帧自动跟随同一传播链结果、清空片段遮罩进入时间轴范围选择并按选区批量清空、清空全部模式、保留人工/AI 模式只清传播 mask、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、清空后裁剪/移除重叠传播历史条、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧通过 source/lineage metadata 识别为蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进纯色显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/清空范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播/清空范围、左右方向键切帧、播放、按项目 FPS 显示当前/总时长 |
|
||||
| R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/store/useStore.test.ts` | 工具切换、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、GT Mask 导入位于重叠区域去除之后且使用紫色底色、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔新建当前类别 mask、画笔与选中 mask 连通时自动合并、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、整块 mask 删除、传播链自动传播 mask 随 seed/传播结果删除、独立 AI 推理 mask 不被误删、区域合并/去除、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 hole 渲染、合并模式隐藏编辑手柄、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键和输入框快捷键跳过、撤销/重做历史栈 |
|
||||
| R6 AI 推理 | `src/lib/api.test.ts`, `src/components/CanvasArea.test.tsx`, `src/components/AISegmentation.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/components/ModelStatusBadge.test.tsx`, `backend/tests/test_ai.py`, `backend/tests/test_sam2_engine.py` | SAM 2.1 变体选择、点/框/interactive 契约、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、SAM 2.1 框选后正负点细化同一候选 mask、AI 页框选发送 box prompt、AI 页框选后加点发送 interactive prompt、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可单点删除提示点并删除最近锚点、AI 页可删除选中候选且不删除工作区 mask、AI 页清空只移除本页候选、AI 页参数开关可读性文案且 options 字段不变、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频以当前参考帧全部 mask 和起止帧范围自动传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、传播中轮询任务进度、传播任务取消/重试、传播来源 metadata 回显、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry |
|
||||
| R7 标注保存 | `src/components/VideoWorkspace.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_ai.py` | 保存状态按钮“保存 X 个改动/已全部保存”、保存标注、保存后用后端 saved annotation 替换已提交 draft、加载回显、更新 dirty 标注、清空删除已保存标注、GT mask 多类别导入、高精度 GT contour、导入 mask 可直接拓扑统计和边缘平滑、后端 seed point 归一化兼容但前端不显示或拖动、缺失 seed point 的普通 polygon 保存时自动写入代表点、项目不存在、帧不存在 |
|
||||
| R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/store/useStore.test.ts` | 工具切换、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、GT Mask 导入位于重叠区域去除之后且使用紫色底色、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔新建当前类别 mask、画笔与选中 mask 连通时自动合并、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、中空 mask 内洞 ring 顶点和插点可编辑、整块 mask 删除、传播链自动传播 mask 随 seed/传播结果删除、独立 AI 推理 mask 不被误删、区域合并/去除、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 hole 渲染和 ring 分组保存、合并模式隐藏编辑手柄、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键和输入框快捷键跳过、撤销/重做历史栈 |
|
||||
| R6 AI 推理 | `src/lib/api.test.ts`, `src/components/CanvasArea.test.tsx`, `src/components/AISegmentation.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/components/ModelStatusBadge.test.tsx`, `backend/tests/test_ai.py`, `backend/tests/test_sam2_engine.py` | SAM 2.1 变体选择、点/框/interactive 契约、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、SAM 2.1 框选后正负点细化同一候选 mask、AI 页框选发送 box prompt、AI 页框选后加点发送 interactive prompt、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可单点删除提示点并删除最近锚点、AI 页可删除选中候选且不删除工作区 mask、AI 页清空只移除本页候选、AI 页参数开关可读性文案且 options 字段不变、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频以当前参考帧全部 mask 和起止帧范围自动传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空传播 seed 扣除 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 标注、中空 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 回显为 `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、主动断开不重连 |
|
||||
@@ -37,8 +37,8 @@
|
||||
| R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式 FPS 生成帧、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、DICOM 上传/下载/读取自然排序、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 |
|
||||
| R4 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 draft mask、Canvas/AI 底图居中适配且保留边距、工作区 mask 透明度、选中 mask 后跨帧自动跟随同一传播链结果、清空片段遮罩进入时间轴范围选择并按选区批量清空、清空全部模式、保留人工/AI 模式只清传播 mask、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、清空后裁剪/移除重叠传播历史条、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/清空范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播/清空范围、Canvas/AI 画布拖拽平移回写 position state、左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 |
|
||||
| R5 | 工具切换、工具栏紧凑滚动布局、低对比滚动条、外扩滚动条槽位、调整多边形入口、GT Mask 导入入口位置和紫色底色、工作区工具栏隐藏 AI 正/反点和框选、左侧工具栏不重复撤销/重做、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制、多边形和布尔工具上下文提示、Canvas 上下文提示数秒后自动隐藏 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 |
|
||||
| R5 | 顶点直接拖动编辑、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、顶点删除、整块删除、删除传播链自动传播 mask 且保留独立 AI 推理 mask、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键、区域合并、区域去除、布尔选择主区域黄色实线/扣除区域红色虚线、布尔选择顺序提示、hole even-odd 渲染 | `CanvasArea.test.tsx`, `VideoWorkspace.test.tsx`, `useStore.test.ts` | 已覆盖 |
|
||||
| R6 | SAM 2.1 变体选择、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页框选/框选后加点、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可删除提示点、AI 页可删除选中候选、AI 页清空只移除本页候选、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频按参考帧全部 mask 和范围自动传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播 Celery 任务入队、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、前端任务轮询进度、传播任务 runner 保存标注和结果权重 id、传播任务重试、传播空结果提示、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.py`, `test_tasks.py`, `test_sam2_engine.py` | 已覆盖 |
|
||||
| R5 | 顶点直接拖动编辑、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、中空 mask 内洞 ring 顶点和插点可编辑、顶点删除、整块删除、删除传播链自动传播 mask 且保留独立 AI 推理 mask、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键、区域合并、区域去除、布尔选择主区域黄色实线/扣除区域红色虚线、布尔选择顺序提示、hole even-odd 渲染 | `CanvasArea.test.tsx`, `VideoWorkspace.test.tsx`, `useStore.test.ts` | 已覆盖 |
|
||||
| R6 | SAM 2.1 变体选择、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页框选/框选后加点、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可删除提示点、AI 页可删除选中候选、AI 页清空只移除本页候选、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频按参考帧全部 mask 和范围自动传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播 Celery 任务入队、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空 seed holes 栅格化扣除和传播结果 holes 提取、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、前端任务轮询进度、传播任务 runner 保存标注和结果权重 id、传播任务重试、传播空结果提示、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.py`, `test_tasks.py`, `test_sam2_engine.py` | 已覆盖 |
|
||||
| R7 | 保存状态按钮“保存 X 个改动/已全部保存”、保存、保存后替换已提交 draft、查询、更新、删除标注、工作区回显、清空已保存标注、GT mask 导入和 seed point 数据兼容、导入 mask 不显示黄色 seed point、高精度 GT contour、导入 mask 拓扑统计和边缘平滑、8-bit 低数值 GT_label 图导入、16-bit/uint16 GT_label 图拒绝、全背景 0 GT_label 图拒绝并保留“没有非背景 maskid 区域”提示、RGB 等通道 maskid 图导入、导入预览、未知 maskid 导入策略、非法彩色 GT mask 拒绝、尺寸不一致自动最近邻拉伸 | `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `api.test.ts`, `test_ai.py` | 已覆盖 |
|
||||
| R8 | 模板加载、新建、编辑、删除、删除模板站内确认、鼠标复制模板为私有副本并保留 maskid/颜色/层级/规则、所有模板归一化包含黑色 `maskid:0`“待分类”保留类、保留类固定最后且不可删除/拖拽上移、详情页标题/新建分类/垃圾桶删 label、编辑弹窗分类编辑不显示旧 category 来源元信息、默认模板“腹腔镜胆囊切除术”和“头颈部CT分割”幂等 seed、头颈部 CT 默认分类名纯中文且不带括号英文翻译、恢复出厂设置保留并权威恢复系统模板、默认模板缺失后重建、默认语义分类树被修改/删减后覆盖恢复、编辑后详情页刷新、详情页和编辑弹窗拖拽语义层级顺序、拖拽保存 `zIndex` 且不改变 maskid、JSON 分类导入预览、数组/对象/常见粘贴格式导入、JSON 错误内联提示、保存错误非阻塞提示、mapping_rules 映射、后端 CRUD | `TemplateRegistry.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_templates.py`, `test_admin.py` | 已覆盖 |
|
||||
| R9 | 模板选择、面板标题简化、工作区遮罩透明度滑杆、分类展示、分类选择、模板类别删除后项目旧 mask 回显为 `maskid:0` 待分类、分类树拖拽调整内部覆盖顺序且不改变 maskid、拖拽后同步同类 mask 层级并标记待保存、点击 mask 自动聚焦对应分类、已选 mask 换标签并置顶显示、分类变更同步同一传播链前后帧对应 mask、自定义分类写入后端模板、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、后端拓扑属性分析、拓扑锚点真实顶点计数、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览、边缘平滑应用后确认 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑撤销/重做、平滑应用后强度归零、占位状态 | `OntologyInspector.test.tsx`, `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts`, `test_ai.py` | 已覆盖 |
|
||||
@@ -64,6 +64,7 @@
|
||||
- R4:补充清空片段遮罩模式测试,覆盖“清空全部”确认删除、“保留人工/AI”只清传播 mask、取消不删除、仅自动传播帧不弹确认。
|
||||
- R6/R10:补充 `queuePropagationTask()`、`POST /api/ai/propagate/task`、传播 Celery runner 和传播任务重试测试,验证工作区自动传播不再依赖长 HTTP 请求,并验证传给 `SAM2VideoPredictor` 的临时帧文件名是纯数字序列。
|
||||
- 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。
|
||||
- R6:`backend/tests/test_sam3_engine.py` 已标记跳过,仅作为历史保留实现的参考测试,不计入当前产品功能覆盖。
|
||||
- R3:补充 `parseMedia()` 查询参数和后端拆帧任务 payload 测试,验证 `parse_fps`、`max_frames`、`target_width` 会进入任务。
|
||||
- R3:补充 `ProjectLibrary.test.tsx` 和 `api.test.ts` 中上传进度测试,验证视频/DICOM 上传通过 Axios `onUploadProgress` 回调更新项目库导入进度条,并显示 DICOM 文件数量和解析任务轮询进度。
|
||||
|
||||
@@ -506,6 +506,34 @@ describe('CanvasArea', () => {
|
||||
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows editable vertices for inner rings of hollow polygon masks', () => {
|
||||
useStore.setState({
|
||||
selectedMaskIds: ['hollow-1'],
|
||||
masks: [
|
||||
{
|
||||
id: 'hollow-1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 90 10 L 90 90 L 10 90 Z M 35 35 L 65 35 L 65 65 L 35 65 Z',
|
||||
label: 'Hollow',
|
||||
color: '#06b6d4',
|
||||
segmentation: [
|
||||
[10, 10, 90, 10, 90, 90, 10, 90],
|
||||
[35, 35, 65, 35, 65, 65, 35, 65],
|
||||
],
|
||||
metadata: { hasHoles: true, polygonRingCounts: [2] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="edit_polygon" frame={frame} />);
|
||||
|
||||
const editableVertices = screen.queryAllByTestId('konva-circle')
|
||||
.filter((element) => element.getAttribute('data-fill') === '#ffffff');
|
||||
expect(editableVertices).toHaveLength(8);
|
||||
expect(editableVertices.map((element) => [element.getAttribute('data-x'), element.getAttribute('data-y')]))
|
||||
.toContainEqual(['35', '35']);
|
||||
});
|
||||
|
||||
it('selects a polygon mask and drags a vertex into dirty saved state', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
|
||||
@@ -272,12 +272,38 @@ function closeRing(points: CanvasPoint[]): Pair[] {
|
||||
return ring;
|
||||
}
|
||||
|
||||
function segmentationRings(segmentation?: number[][]): Pair[][] {
|
||||
return (segmentation || [])
|
||||
.map((polygon) => closeRing(flatPolygonToPoints(polygon)))
|
||||
.filter((ring) => ring.length >= 4);
|
||||
}
|
||||
|
||||
function maskPolygonRingCounts(mask: Mask, ringCount: number): number[] | null {
|
||||
const rawCounts = mask.metadata?.polygonRingCounts;
|
||||
if (!Array.isArray(rawCounts)) return null;
|
||||
const counts = rawCounts
|
||||
.map((count) => Number(count))
|
||||
.filter((count) => Number.isInteger(count) && count > 0);
|
||||
const total = counts.reduce((sum, count) => sum + count, 0);
|
||||
return total === ringCount ? counts : null;
|
||||
}
|
||||
|
||||
function maskToMultiPolygon(mask: Mask): MultiPolygon | null {
|
||||
const polygons = (mask.segmentation || [])
|
||||
.map((polygon) => flatPolygonToPoints(polygon))
|
||||
.filter((points) => points.length >= 3)
|
||||
.map((points) => [closeRing(points)]);
|
||||
return polygons.length > 0 ? polygons : null;
|
||||
const rings = segmentationRings(mask.segmentation);
|
||||
if (rings.length === 0) return null;
|
||||
const counts = maskPolygonRingCounts(mask, rings.length);
|
||||
if (counts) {
|
||||
let offset = 0;
|
||||
return counts.map((count) => {
|
||||
const polygon = rings.slice(offset, offset + count);
|
||||
offset += count;
|
||||
return polygon;
|
||||
}).filter((polygon) => polygon.length > 0);
|
||||
}
|
||||
if (mask.metadata?.hasHoles && rings.length > 1) {
|
||||
return [rings];
|
||||
}
|
||||
return rings.map((ring) => [ring]);
|
||||
}
|
||||
|
||||
function polygonsToMultiPolygon(polygons: CanvasPoint[][]): MultiPolygon | null {
|
||||
@@ -303,6 +329,12 @@ function multiPolygonToSegmentation(geometry: MultiPolygon): number[][] {
|
||||
.filter((polygon) => polygon.length >= 6);
|
||||
}
|
||||
|
||||
function multiPolygonRingCounts(geometry: MultiPolygon): number[] {
|
||||
return geometry
|
||||
.map((polygon) => polygon.length)
|
||||
.filter((count) => count > 0);
|
||||
}
|
||||
|
||||
function multiPolygonArea(geometry: MultiPolygon): number {
|
||||
return geometry.reduce((sum, polygon) => {
|
||||
const [outerRing, ...holeRings] = polygon;
|
||||
@@ -319,12 +351,14 @@ function multiPolygonHasHoles(geometry: MultiPolygon): boolean {
|
||||
function maskWithSegmentation(
|
||||
mask: Mask,
|
||||
segmentation: number[][],
|
||||
options: { area?: number; hasHoles?: boolean } = {},
|
||||
options: { area?: number; hasHoles?: boolean; polygonRingCounts?: number[] } = {},
|
||||
): Mask {
|
||||
const bbox = segmentationBbox(segmentation);
|
||||
const metadata = { ...(mask.metadata || {}) };
|
||||
if (options.hasHoles === true) metadata.hasHoles = true;
|
||||
if (options.hasHoles === false) delete metadata.hasHoles;
|
||||
if (options.polygonRingCounts && options.polygonRingCounts.length > 0) metadata.polygonRingCounts = options.polygonRingCounts;
|
||||
if (options.hasHoles === false) delete metadata.polygonRingCounts;
|
||||
return {
|
||||
...mask,
|
||||
pathData: segmentationPath(segmentation),
|
||||
@@ -761,6 +795,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const createManualMaskFromGeometry = useCallback((shape: string, geometry: MultiPolygon): Mask | null => {
|
||||
if (!frame?.id || !activeClass) return null;
|
||||
const segmentation = multiPolygonToSegmentation(geometry);
|
||||
const polygonRingCounts = multiPolygonRingCounts(geometry);
|
||||
if (segmentation.length === 0) return null;
|
||||
const area = multiPolygonArea(geometry);
|
||||
if (area <= 1) return null;
|
||||
@@ -784,6 +819,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
source: 'manual',
|
||||
shape,
|
||||
...(multiPolygonHasHoles(geometry) ? { hasHoles: true } : {}),
|
||||
...(multiPolygonHasHoles(geometry) ? { polygonRingCounts } : {}),
|
||||
},
|
||||
};
|
||||
addMask(mask);
|
||||
@@ -1011,6 +1047,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
...maskWithSegmentation(selectedMask, resultSegmentation, {
|
||||
area: multiPolygonArea(resultGeometry),
|
||||
hasHoles: multiPolygonHasHoles(resultGeometry),
|
||||
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
|
||||
}),
|
||||
templateId: activeTemplateId || selectedMask.templateId,
|
||||
classId: activeClass.id,
|
||||
@@ -1053,6 +1090,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const nextMask = maskWithSegmentation(selectedMask, resultSegmentation, {
|
||||
area: multiPolygonArea(resultGeometry),
|
||||
hasHoles: multiPolygonHasHoles(resultGeometry),
|
||||
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
|
||||
});
|
||||
setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask)));
|
||||
setSelectedMaskId(selectedMask.id);
|
||||
@@ -1216,11 +1254,22 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const nextSegmentation = [...(mask.segmentation || [])];
|
||||
nextSegmentation[polygonIndex] = nextPoints.flatMap((point) => [point.x, point.y]);
|
||||
const bbox = segmentationBbox(nextSegmentation) || polygonBbox(nextPoints);
|
||||
const nextGeometry = maskToMultiPolygon({ ...mask, segmentation: nextSegmentation });
|
||||
const hasHoles = nextGeometry ? multiPolygonHasHoles(nextGeometry) : Boolean(mask.metadata?.hasHoles);
|
||||
const metadata = { ...(mask.metadata || {}) };
|
||||
if (hasHoles && nextGeometry) {
|
||||
metadata.hasHoles = true;
|
||||
metadata.polygonRingCounts = multiPolygonRingCounts(nextGeometry);
|
||||
} else {
|
||||
delete metadata.hasHoles;
|
||||
delete metadata.polygonRingCounts;
|
||||
}
|
||||
updateMask(mask.id, {
|
||||
pathData: segmentationPath(nextSegmentation),
|
||||
segmentation: nextSegmentation,
|
||||
bbox,
|
||||
area: segmentationArea(nextSegmentation),
|
||||
area: nextGeometry ? multiPolygonArea(nextGeometry) : segmentationArea(nextSegmentation),
|
||||
metadata,
|
||||
saveStatus: mask.annotationId ? 'dirty' : 'draft',
|
||||
saved: mask.annotationId ? false : mask.saved,
|
||||
});
|
||||
@@ -1229,7 +1278,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const updateMaskFromSegmentation = useCallback((
|
||||
mask: Mask,
|
||||
segmentation: number[][],
|
||||
options: { area?: number; hasHoles?: boolean } = {},
|
||||
options: { area?: number; hasHoles?: boolean; polygonRingCounts?: number[] } = {},
|
||||
): Mask => {
|
||||
return maskWithSegmentation(mask, segmentation, options);
|
||||
}, []);
|
||||
@@ -1288,6 +1337,17 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
return null;
|
||||
}, [cursorPos, effectiveTool, manualCurrent, manualStart, polygonPoints]);
|
||||
|
||||
const selectedMaskEditableRings = React.useMemo(() => {
|
||||
if (!selectedMask?.segmentation) return [];
|
||||
const hasHoles = Boolean(selectedMask.metadata?.hasHoles);
|
||||
if (!hasHoles) {
|
||||
return [{ polygonIndex: selectedPolygonIndex, points: selectedMaskPoints }];
|
||||
}
|
||||
return selectedMask.segmentation
|
||||
.map((_, polygonIndex) => ({ polygonIndex, points: segmentationToPoints(selectedMask.segmentation, polygonIndex) }))
|
||||
.filter((ring) => ring.points.length >= 3);
|
||||
}, [selectedMask, selectedMaskPoints, selectedPolygonIndex]);
|
||||
|
||||
const handleMaskSelect = (mask: Mask, event: any, polygonIndex = 0) => {
|
||||
if (!isPolygonEditTool && !isBooleanTool) return;
|
||||
event.cancelBubble = true;
|
||||
@@ -1308,17 +1368,18 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
setSelectedVertexIndex(null);
|
||||
};
|
||||
|
||||
const handleVertexDragStart = (mask: Mask, vertexIndex: number, event?: any) => {
|
||||
const handleVertexDragStart = (mask: Mask, vertexIndex: number, polygonIndex = selectedPolygonIndex, event?: any) => {
|
||||
if (event) event.cancelBubble = true;
|
||||
setSelectedMaskId(mask.id);
|
||||
setSelectedMaskIds([mask.id]);
|
||||
setSelectedPolygonIndex(polygonIndex);
|
||||
setSelectedVertexIndex(vertexIndex);
|
||||
};
|
||||
|
||||
const handleVertexDrag = (mask: Mask, vertexIndex: number, event: any) => {
|
||||
const handleVertexDrag = (mask: Mask, vertexIndex: number, event: any, polygonIndex = selectedPolygonIndex) => {
|
||||
const imageWidth = frame?.width || image?.naturalWidth || image?.width || stageSize.width;
|
||||
const imageHeight = frame?.height || image?.naturalHeight || image?.height || stageSize.height;
|
||||
const currentPoints = segmentationToPoints(mask.segmentation, selectedPolygonIndex);
|
||||
const currentPoints = segmentationToPoints(mask.segmentation, polygonIndex);
|
||||
if (!currentPoints[vertexIndex]) return;
|
||||
const nextPoints = currentPoints.map((point, index) => (
|
||||
index === vertexIndex
|
||||
@@ -1330,13 +1391,14 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
));
|
||||
setSelectedMaskId(mask.id);
|
||||
setSelectedMaskIds([mask.id]);
|
||||
setSelectedPolygonIndex(polygonIndex);
|
||||
setSelectedVertexIndex(vertexIndex);
|
||||
updatePolygonMask(mask, nextPoints, selectedPolygonIndex);
|
||||
updatePolygonMask(mask, nextPoints, polygonIndex);
|
||||
};
|
||||
|
||||
const handleEdgeInsert = (mask: Mask, edgeIndex: number, event: any) => {
|
||||
const handleEdgeInsert = (mask: Mask, edgeIndex: number, event: any, polygonIndex = selectedPolygonIndex) => {
|
||||
event.cancelBubble = true;
|
||||
const currentPoints = segmentationToPoints(mask.segmentation, selectedPolygonIndex);
|
||||
const currentPoints = segmentationToPoints(mask.segmentation, polygonIndex);
|
||||
const start = currentPoints[edgeIndex];
|
||||
const end = currentPoints[(edgeIndex + 1) % currentPoints.length];
|
||||
if (!start || !end) return;
|
||||
@@ -1347,8 +1409,9 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
...currentPoints.slice(edgeIndex + 1),
|
||||
];
|
||||
setSelectedMaskId(mask.id);
|
||||
setSelectedPolygonIndex(polygonIndex);
|
||||
setSelectedVertexIndex(edgeIndex + 1);
|
||||
updatePolygonMask(mask, nextPoints, selectedPolygonIndex);
|
||||
updatePolygonMask(mask, nextPoints, polygonIndex);
|
||||
};
|
||||
|
||||
const handlePathDoubleClick = (mask: Mask, event: any, polygonIndex = 0) => {
|
||||
@@ -1404,6 +1467,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const nextPrimary = updateMaskFromSegmentation(primary, resultSegmentation, {
|
||||
area: multiPolygonArea(resultGeometry),
|
||||
hasHoles: multiPolygonHasHoles(resultGeometry),
|
||||
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
|
||||
});
|
||||
const secondaryIds = effectiveTool === 'area_merge'
|
||||
? expandedPropagationDeletionMaskIds(booleanSelectedMasks.slice(1).map((mask) => mask.id), masks)
|
||||
@@ -1587,49 +1651,58 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
))}
|
||||
|
||||
{/* Polygon edge insertion handles */}
|
||||
{isPolygonEditTool && selectedMask && selectedMaskPoints.map((point, index) => {
|
||||
const next = selectedMaskPoints[(index + 1) % selectedMaskPoints.length];
|
||||
{isPolygonEditTool && selectedMask && selectedMaskEditableRings.flatMap(({ polygonIndex, points: ringPoints }) => (
|
||||
ringPoints.map((point, index) => {
|
||||
const next = ringPoints[(index + 1) % ringPoints.length];
|
||||
if (!next) return null;
|
||||
return (
|
||||
<Circle
|
||||
key={`${selectedMask.id}-edge-${selectedPolygonIndex}-${index}`}
|
||||
key={`${selectedMask.id}-edge-${polygonIndex}-${index}`}
|
||||
x={(point.x + next.x) / 2}
|
||||
y={(point.y + next.y) / 2}
|
||||
radius={3.5 / scale}
|
||||
fill="#22d3ee"
|
||||
stroke="#111827"
|
||||
strokeWidth={1.5 / scale}
|
||||
onClick={(event: any) => handleEdgeInsert(selectedMask, index, event)}
|
||||
onTap={(event: any) => handleEdgeInsert(selectedMask, index, event)}
|
||||
onClick={(event: any) => handleEdgeInsert(selectedMask, index, event, polygonIndex)}
|
||||
onTap={(event: any) => handleEdgeInsert(selectedMask, index, event, polygonIndex)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
})
|
||||
))}
|
||||
|
||||
{/* Polygon vertex editor */}
|
||||
{isPolygonEditTool && selectedMask && selectedMaskPoints.map((point, index) => (
|
||||
{isPolygonEditTool && selectedMask && selectedMaskEditableRings.flatMap(({ polygonIndex, points: ringPoints }) => (
|
||||
ringPoints.map((point, index) => {
|
||||
const isActiveVertex = selectedPolygonIndex === polygonIndex && selectedVertexIndex === index;
|
||||
return (
|
||||
<Circle
|
||||
key={`${selectedMask.id}-vertex-${selectedPolygonIndex}-${index}`}
|
||||
key={`${selectedMask.id}-vertex-${polygonIndex}-${index}`}
|
||||
x={point.x}
|
||||
y={point.y}
|
||||
radius={(selectedVertexIndex === index ? 6 : 4.5) / scale}
|
||||
fill={selectedVertexIndex === index ? '#22d3ee' : '#ffffff'}
|
||||
radius={(isActiveVertex ? 6 : 4.5) / scale}
|
||||
fill={isActiveVertex ? '#22d3ee' : '#ffffff'}
|
||||
stroke={selectedMask.color}
|
||||
strokeWidth={2 / scale}
|
||||
draggable
|
||||
onMouseDown={(event: any) => handleVertexDragStart(selectedMask, index, event)}
|
||||
onTouchStart={(event: any) => handleVertexDragStart(selectedMask, index, event)}
|
||||
onDragStart={(event: any) => handleVertexDragStart(selectedMask, index, event)}
|
||||
onMouseDown={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
|
||||
onTouchStart={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
|
||||
onDragStart={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
|
||||
onClick={(event: any) => {
|
||||
event.cancelBubble = true;
|
||||
setSelectedPolygonIndex(polygonIndex);
|
||||
setSelectedVertexIndex(index);
|
||||
}}
|
||||
onTap={(event: any) => {
|
||||
event.cancelBubble = true;
|
||||
setSelectedPolygonIndex(polygonIndex);
|
||||
setSelectedVertexIndex(index);
|
||||
}}
|
||||
onDragMove={(event: any) => handleVertexDrag(selectedMask, index, event)}
|
||||
onDragEnd={(event: any) => handleVertexDrag(selectedMask, index, event)}
|
||||
onDragMove={(event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)}
|
||||
onDragEnd={(event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
))}
|
||||
|
||||
{/* AI Prompts Point Regions */}
|
||||
|
||||
@@ -1124,6 +1124,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
: undefined;
|
||||
return {
|
||||
polygons: seedPayload.mask_data?.polygons,
|
||||
holes: seedPayload.mask_data?.holes,
|
||||
bbox: seedPayload.bbox,
|
||||
points: seedPayload.points,
|
||||
label: seedPayload.mask_data?.label,
|
||||
|
||||
@@ -642,6 +642,57 @@ describe('api client contracts', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('preserves hollow mask holes when saving and hydrating annotations', async () => {
|
||||
const { annotationToMask, buildAnnotationPayload } = await import('./api');
|
||||
const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 100 };
|
||||
const hollowMask = {
|
||||
id: 'm-hole',
|
||||
frameId: '5',
|
||||
pathData: 'M 10 10 L 90 10 L 90 90 L 10 90 Z M 35 35 L 65 35 L 65 65 L 35 65 Z',
|
||||
label: '中空区域',
|
||||
color: '#22c55e',
|
||||
segmentation: [
|
||||
[10, 10, 90, 10, 90, 90, 10, 90],
|
||||
[35, 35, 65, 35, 65, 65, 35, 65],
|
||||
],
|
||||
metadata: { hasHoles: true, polygonRingCounts: [2] },
|
||||
};
|
||||
|
||||
const payload = buildAnnotationPayload('9', hollowMask, frame);
|
||||
|
||||
expect(payload?.mask_data).toEqual(expect.objectContaining({
|
||||
polygons: [[[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]]],
|
||||
holes: [[[[0.35, 0.35], [0.65, 0.35], [0.65, 0.65], [0.35, 0.65]]]],
|
||||
hasHoles: true,
|
||||
polygonRingCounts: [2],
|
||||
}));
|
||||
|
||||
const hydrated = annotationToMask({
|
||||
id: 33,
|
||||
project_id: 9,
|
||||
frame_id: 5,
|
||||
template_id: null,
|
||||
mask_data: {
|
||||
polygons: [[[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]]],
|
||||
holes: [[[[0.35, 0.35], [0.65, 0.35], [0.65, 0.65], [0.35, 0.65]]]],
|
||||
label: '中空区域',
|
||||
color: '#22c55e',
|
||||
},
|
||||
points: null,
|
||||
bbox: null,
|
||||
created_at: 'created',
|
||||
updated_at: 'updated',
|
||||
}, frame);
|
||||
|
||||
expect(hydrated).toEqual(expect.objectContaining({
|
||||
segmentation: [
|
||||
[10, 10, 90, 10, 90, 90, 10, 90],
|
||||
[35, 35, 65, 35, 65, 65, 35, 65],
|
||||
],
|
||||
metadata: expect.objectContaining({ hasHoles: true, polygonRingCounts: [2] }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('preserves propagation metadata when saving edited geometry without persisting preview-only smoothing fields', async () => {
|
||||
const { buildAnnotationPayload } = await import('./api');
|
||||
const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 50 };
|
||||
|
||||
@@ -395,6 +395,7 @@ export interface SavedAnnotation {
|
||||
template_id: number | null;
|
||||
mask_data: {
|
||||
polygons?: number[][][];
|
||||
holes?: number[][][][];
|
||||
label?: string;
|
||||
color?: string;
|
||||
class?: {
|
||||
@@ -424,6 +425,7 @@ export interface SaveAnnotationPayload {
|
||||
template_id?: number;
|
||||
mask_data?: {
|
||||
polygons: number[][][];
|
||||
holes?: number[][][][];
|
||||
label?: string;
|
||||
color?: string;
|
||||
class?: {
|
||||
@@ -449,6 +451,7 @@ export interface PropagateMasksPayload {
|
||||
model?: AiModelId;
|
||||
seed: {
|
||||
polygons?: number[][][];
|
||||
holes?: number[][][][];
|
||||
bbox?: number[];
|
||||
points?: number[][];
|
||||
label?: string;
|
||||
@@ -679,13 +682,73 @@ function pixelSegmentationToNormalizedPolygons(
|
||||
.filter((points) => points.length > 0);
|
||||
}
|
||||
|
||||
function metadataNumberArray(value: unknown): number[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const counts = value
|
||||
.map((item) => Number(item))
|
||||
.filter((item) => Number.isInteger(item) && item > 0);
|
||||
return counts.length === value.length ? counts : null;
|
||||
}
|
||||
|
||||
function splitNormalizedPolygonsAndHoles(
|
||||
polygons: number[][][],
|
||||
metadata?: Record<string, unknown>,
|
||||
): { polygons: number[][][]; holes?: number[][][][] } {
|
||||
const counts = metadataNumberArray(metadata?.polygonRingCounts);
|
||||
if (counts && counts.reduce((sum, count) => sum + count, 0) === polygons.length) {
|
||||
const outers: number[][][] = [];
|
||||
const holes: number[][][][] = [];
|
||||
let offset = 0;
|
||||
counts.forEach((count) => {
|
||||
const group = polygons.slice(offset, offset + count);
|
||||
offset += count;
|
||||
if (group[0]) {
|
||||
outers.push(group[0]);
|
||||
holes.push(group.slice(1));
|
||||
}
|
||||
});
|
||||
return holes.some((group) => group.length > 0) ? { polygons: outers, holes } : { polygons: outers };
|
||||
}
|
||||
|
||||
if (metadata?.hasHoles && polygons.length > 1) {
|
||||
return { polygons: [polygons[0]], holes: [polygons.slice(1)] };
|
||||
}
|
||||
return { polygons };
|
||||
}
|
||||
|
||||
function mergeNormalizedPolygonsAndHoles(
|
||||
polygons: number[][][],
|
||||
holes: unknown,
|
||||
): { segmentationPolygons: number[][][]; polygonRingCounts?: number[]; hasHoles: boolean } {
|
||||
if (!Array.isArray(holes) || holes.length === 0) {
|
||||
return { segmentationPolygons: polygons, hasHoles: false };
|
||||
}
|
||||
const segmentationPolygons: number[][][] = [];
|
||||
const polygonRingCounts: number[] = [];
|
||||
let hasHoles = false;
|
||||
|
||||
polygons.forEach((polygon, index) => {
|
||||
const holeGroup = Array.isArray(holes[index]) ? holes[index] as number[][][] : [];
|
||||
segmentationPolygons.push(polygon, ...holeGroup);
|
||||
polygonRingCounts.push(1 + holeGroup.length);
|
||||
if (holeGroup.length > 0) hasHoles = true;
|
||||
});
|
||||
|
||||
return hasHoles
|
||||
? { segmentationPolygons, polygonRingCounts, hasHoles }
|
||||
: { segmentationPolygons: polygons, hasHoles: false };
|
||||
}
|
||||
|
||||
export function buildAnnotationPayload(
|
||||
projectId: string,
|
||||
mask: Mask,
|
||||
frame: Frame,
|
||||
templateId?: string | null,
|
||||
): SaveAnnotationPayload | null {
|
||||
const polygons = pixelSegmentationToNormalizedPolygons(mask.segmentation, frame.width, frame.height);
|
||||
const segmentationPolygons = pixelSegmentationToNormalizedPolygons(mask.segmentation, frame.width, frame.height);
|
||||
if (segmentationPolygons.length === 0) return null;
|
||||
const splitGeometry = splitNormalizedPolygonsAndHoles(segmentationPolygons, mask.metadata);
|
||||
const polygons = splitGeometry.polygons;
|
||||
if (polygons.length === 0) return null;
|
||||
const effectiveTemplateId = mask.templateId || templateId || undefined;
|
||||
const classMetadata = mask.classId || mask.className || mask.classZIndex !== undefined || mask.classMaskId !== undefined
|
||||
@@ -707,6 +770,7 @@ export function buildAnnotationPayload(
|
||||
mask_data: {
|
||||
...metadata,
|
||||
polygons,
|
||||
...(splitGeometry.holes ? { holes: splitGeometry.holes } : {}),
|
||||
label: mask.label,
|
||||
color: mask.color,
|
||||
...(classMetadata ? { class: classMetadata } : {}),
|
||||
@@ -735,12 +799,18 @@ export function buildAnnotationPayload(
|
||||
|
||||
export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mask | null {
|
||||
const polygons = annotation.mask_data?.polygons || [];
|
||||
const firstPolygon = polygons[0];
|
||||
const mergedGeometry = mergeNormalizedPolygonsAndHoles(polygons, annotation.mask_data?.holes);
|
||||
const segmentationPolygons = mergedGeometry.segmentationPolygons;
|
||||
const firstPolygon = segmentationPolygons[0];
|
||||
if (!firstPolygon || firstPolygon.length === 0) return null;
|
||||
const bbox = polygonToBbox(firstPolygon, frame.width, frame.height);
|
||||
const classMetadata = annotation.mask_data?.class;
|
||||
const { polygons: _polygons, label: _label, color: _color, class: _classMetadata, ...metadata } = annotation.mask_data || {};
|
||||
const hasMetadata = Object.keys(metadata).length > 0;
|
||||
const { polygons: _polygons, holes: _holes, label: _label, color: _color, class: _classMetadata, ...metadata } = annotation.mask_data || {};
|
||||
const restoredMetadata = {
|
||||
...metadata,
|
||||
...(mergedGeometry.hasHoles ? { hasHoles: true, polygonRingCounts: mergedGeometry.polygonRingCounts } : {}),
|
||||
};
|
||||
const hasMetadata = Object.keys(restoredMetadata).length > 0;
|
||||
return {
|
||||
id: `annotation-${annotation.id}`,
|
||||
annotationId: String(annotation.id),
|
||||
@@ -752,14 +822,14 @@ export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mas
|
||||
classMaskId: classMetadata?.maskId,
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
pathData: polygonToPath(firstPolygon, frame.width, frame.height),
|
||||
pathData: segmentationPolygons.map((polygon) => polygonToPath(polygon, frame.width, frame.height)).join(' '),
|
||||
label: classMetadata?.name || annotation.mask_data?.label || `Annotation ${annotation.id}`,
|
||||
color: classMetadata?.color || annotation.mask_data?.color || '#06b6d4',
|
||||
segmentation: polygons.map((polygon) => polygon.flatMap(([x, y]) => [x * frame.width, y * frame.height])),
|
||||
segmentation: segmentationPolygons.map((polygon) => polygon.flatMap(([x, y]) => [x * frame.width, y * frame.height])),
|
||||
points: annotation.points?.map(([x, y]) => [x * frame.width, y * frame.height]),
|
||||
bbox,
|
||||
area: bbox[2] * bbox[3],
|
||||
metadata: hasMetadata ? metadata : undefined,
|
||||
metadata: hasMetadata ? restoredMetadata : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user