保持传播多区域结果为单个遮罩
- 后端传播落库时将同一 seed 在同一目标帧的多个不连通 polygon 保存到同一 annotation - 同步任务传播和兼容同步传播接口的多 polygon 保存逻辑 - 传播结果 bbox 改为覆盖全部不连通 polygon,并保留多 polygon scores 与 holes - 前端回显单条多 polygon annotation 时使用组合 bbox 和真实 polygon 面积 - 补充后端传播 worker 回归测试,验证不连通结果只生成一个 annotation - 补充前端 API 回归测试,验证多 polygon annotation 回显为一个 mask - 更新项目指南和设计冻结文档
This commit is contained in:
@@ -246,7 +246,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
|||||||
7. 帧展示:`VideoWorkspace.tsx` 调用 `/api/projects/{id}/frames`,`CanvasArea.tsx` 和 `FrameTimeline.tsx` 显示当前帧与时间轴缩略图;`CanvasArea` 会按容器和帧尺寸默认居中放大底图并保留边距,右下角显示“当前帧:XX/XXX”;`FrameTimeline` 会根据已保存标注回显到 `Mask.metadata` 的传播来源,把自动传播生成的帧在视频处理进度条显示为蓝色区段,人工/AI 标注帧显示红色竖线;每次自动传播成功处理帧后,`VideoWorkspace` 会把本次传播范围作为当前会话历史片段传给 `FrameTimeline`,在视频处理进度条上叠加同一蓝色系、最新传播最亮、旧传播逐次变暗且第 5 次及更早统一为阈值旧记录色的纯色条;传播历史条只显示当前仍有自动传播 mask 的帧,删除 mask 或清空范围后会按剩余传播 mask 自动裁剪,空帧不保留红/蓝颜色;视频处理进度条和红/蓝标识可点击跳转到对应帧;底部缩略图中人工/AI 标注帧用红色边框、自动传播/推理帧用蓝色边框,同一帧同时具备两种状态时红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍以青色外框高亮优先;若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注;进入自动传播、布尔操作或特定范围帧导出选择模式时,播放进度条和视频处理进度条会显示黄色范围框,并可点击/拖拽选择起止帧;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。
|
7. 帧展示:`VideoWorkspace.tsx` 调用 `/api/projects/{id}/frames`,`CanvasArea.tsx` 和 `FrameTimeline.tsx` 显示当前帧与时间轴缩略图;`CanvasArea` 会按容器和帧尺寸默认居中放大底图并保留边距,右下角显示“当前帧:XX/XXX”;`FrameTimeline` 会根据已保存标注回显到 `Mask.metadata` 的传播来源,把自动传播生成的帧在视频处理进度条显示为蓝色区段,人工/AI 标注帧显示红色竖线;每次自动传播成功处理帧后,`VideoWorkspace` 会把本次传播范围作为当前会话历史片段传给 `FrameTimeline`,在视频处理进度条上叠加同一蓝色系、最新传播最亮、旧传播逐次变暗且第 5 次及更早统一为阈值旧记录色的纯色条;传播历史条只显示当前仍有自动传播 mask 的帧,删除 mask 或清空范围后会按剩余传播 mask 自动裁剪,空帧不保留红/蓝颜色;视频处理进度条和红/蓝标识可点击跳转到对应帧;底部缩略图中人工/AI 标注帧用红色边框、自动传播/推理帧用蓝色边框,同一帧同时具备两种状态时红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍以青色外框高亮优先;若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注;进入自动传播、布尔操作或特定范围帧导出选择模式时,播放进度条和视频处理进度条会显示黄色范围框,并可点击/拖拽选择起止帧;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。
|
||||||
8. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、画笔和橡皮擦生成/编辑 polygon mask;多边形可按 Enter 或点击首节点闭合;多边形/矩形/圆在右侧语义分类树未选中类别时会自动归入黑色 `maskid:0` 的“待分类”;画笔/橡皮擦可在左侧工具栏调整大小,画笔要求右侧语义分类树已有选中类别,画出的圆形连续笔触会在鼠标松开时一次性 union 成 mask,若与当前选中 mask 连通则自动合并到该 mask,橡皮擦要求已选中 mask 并在松开时从该 mask 中 difference 扣除;普通 mask 和导入 mask 都不显示黄色 seed point,也不提供 seed point 拖动;未选中特定 mask 时,Canvas 会按右侧语义分类树拖拽得到的内部覆盖优先级从低到高渲染 mask,使高优先级类别显示在上层;Canvas 左上角工具上下文提示会在切换工具或操作状态变化时短暂显示,数秒后自动隐藏,避免长期遮挡底图;工具栏有“调整多边形”入口,左侧 `ToolsPalette` 使用紧凑垂直布局并在高度不足时自身滚动,基础绘制、画笔/橡皮擦/自动传播、布尔/删除、导入/AI 入口之间用浅灰分隔线区分;橡皮擦下方提供彩色 AI 图标“自动传播”入口,布尔/删除组包含区域合并、重叠区域去除、`DEL` 和“清空遮罩”,其后通过 `data-testid="tool-group-separator"` 分隔紫色“导入 GT Mask”和 AI 智能分割入口;清空遮罩优先作用于当前帧选中 mask,没有选中时作用于当前帧全部 mask;无传播链结果时直接清当前帧,存在传播链结果时弹窗同一行选择取消、只清当前帧、按帧范围选择或清空所有传播帧,按帧范围选择会进入时间轴范围选择并二次确认;Canvas 右下角不再提供旧的“清空遮罩”或“应用分类”按钮,分类改由右侧语义分类树点击完成;工作区左侧工具栏不展示 AI 页的正向选点、反向选点和边界框选,也不重复放置撤销/重做;点击 mask 后可按住顶点直接拖动并实时更新 polygon,顶点拖拽结束不会触发 Stage 平移或重置 Canvas 视口;也可删除 polygon 顶点、通过边中点或双击边界插入新顶点;多 polygon/分离区域组成的同一 mask 进入编辑时所有子区域都会显示顶点和插点手柄,同帧同传播链的分散 mask 点选时会按 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 或 `propagation_seed_signature` 联动高亮;对旧传播结果缺少这些稳定 lineage 的情况,会用传播来源、来源帧、分类/标签/颜色构造兼容分组,使同一传播 mask 拆出的不连通片段仍能一起高亮;带中空洞的 mask 会用 `metadata.polygonRingCounts` 记录外圈与内圈的 ring 分组,调整多边形时外圈和内洞都显示可编辑顶点和插点手柄,保存时把内洞拆到 `mask_data.holes`;选中整块 mask 可用 Delete/Backspace 或左侧 `DEL` 删除,已保存 mask 删除前会预检当前后端 annotation id,只对仍存在的 id 调用后端删除,避免陈旧本地 id 产生 DELETE 404;删除传播 seed 或任一传播结果时会扩展删除同一传播链上的自动传播 mask,但保留其他帧独立 AI 推理或人工标注 mask;区域合并/去除会隐藏编辑手柄并显示已选数量,第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,使用 `polygon-clipping` 做 union/difference,若存在传播帧对应 mask 会先弹窗选择只处理当前帧、处理所有传播帧或按帧范围选择;按帧范围选择会进入时间轴范围选择并二次确认,只把同一布尔操作同步到所选范围内具备对应关系的传播帧;同步后的传播 mask 保留原 `source`/lineage metadata,只进入 dirty 状态等待保存,不会在时间轴上变成人工/AI 标注帧;内含去除结果用 even-odd 规则渲染 hole;Zustand 维护 `maskHistory/maskFuture` 支持撤销/重做。
|
8. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、画笔和橡皮擦生成/编辑 polygon mask;多边形可按 Enter 或点击首节点闭合;多边形/矩形/圆在右侧语义分类树未选中类别时会自动归入黑色 `maskid:0` 的“待分类”;画笔/橡皮擦可在左侧工具栏调整大小,画笔要求右侧语义分类树已有选中类别,画出的圆形连续笔触会在鼠标松开时一次性 union 成 mask,若与当前选中 mask 连通则自动合并到该 mask,橡皮擦要求已选中 mask 并在松开时从该 mask 中 difference 扣除;普通 mask 和导入 mask 都不显示黄色 seed point,也不提供 seed point 拖动;未选中特定 mask 时,Canvas 会按右侧语义分类树拖拽得到的内部覆盖优先级从低到高渲染 mask,使高优先级类别显示在上层;Canvas 左上角工具上下文提示会在切换工具或操作状态变化时短暂显示,数秒后自动隐藏,避免长期遮挡底图;工具栏有“调整多边形”入口,左侧 `ToolsPalette` 使用紧凑垂直布局并在高度不足时自身滚动,基础绘制、画笔/橡皮擦/自动传播、布尔/删除、导入/AI 入口之间用浅灰分隔线区分;橡皮擦下方提供彩色 AI 图标“自动传播”入口,布尔/删除组包含区域合并、重叠区域去除、`DEL` 和“清空遮罩”,其后通过 `data-testid="tool-group-separator"` 分隔紫色“导入 GT Mask”和 AI 智能分割入口;清空遮罩优先作用于当前帧选中 mask,没有选中时作用于当前帧全部 mask;无传播链结果时直接清当前帧,存在传播链结果时弹窗同一行选择取消、只清当前帧、按帧范围选择或清空所有传播帧,按帧范围选择会进入时间轴范围选择并二次确认;Canvas 右下角不再提供旧的“清空遮罩”或“应用分类”按钮,分类改由右侧语义分类树点击完成;工作区左侧工具栏不展示 AI 页的正向选点、反向选点和边界框选,也不重复放置撤销/重做;点击 mask 后可按住顶点直接拖动并实时更新 polygon,顶点拖拽结束不会触发 Stage 平移或重置 Canvas 视口;也可删除 polygon 顶点、通过边中点或双击边界插入新顶点;多 polygon/分离区域组成的同一 mask 进入编辑时所有子区域都会显示顶点和插点手柄,同帧同传播链的分散 mask 点选时会按 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 或 `propagation_seed_signature` 联动高亮;对旧传播结果缺少这些稳定 lineage 的情况,会用传播来源、来源帧、分类/标签/颜色构造兼容分组,使同一传播 mask 拆出的不连通片段仍能一起高亮;带中空洞的 mask 会用 `metadata.polygonRingCounts` 记录外圈与内圈的 ring 分组,调整多边形时外圈和内洞都显示可编辑顶点和插点手柄,保存时把内洞拆到 `mask_data.holes`;选中整块 mask 可用 Delete/Backspace 或左侧 `DEL` 删除,已保存 mask 删除前会预检当前后端 annotation id,只对仍存在的 id 调用后端删除,避免陈旧本地 id 产生 DELETE 404;删除传播 seed 或任一传播结果时会扩展删除同一传播链上的自动传播 mask,但保留其他帧独立 AI 推理或人工标注 mask;区域合并/去除会隐藏编辑手柄并显示已选数量,第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,使用 `polygon-clipping` 做 union/difference,若存在传播帧对应 mask 会先弹窗选择只处理当前帧、处理所有传播帧或按帧范围选择;按帧范围选择会进入时间轴范围选择并二次确认,只把同一布尔操作同步到所选范围内具备对应关系的传播帧;同步后的传播 mask 保留原 `source`/lineage metadata,只进入 dirty 状态等待保存,不会在时间轴上变成人工/AI 标注帧;内含去除结果用 even-odd 规则渲染 hole;Zustand 维护 `maskHistory/maskFuture` 支持撤销/重做。
|
||||||
9. AI 分割:侧栏和工作区工具栏的 AI 智能分割入口使用 Bot + Sparkles 组合图标强化 AI 识别;前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选;AI 画布会按容器和当前帧尺寸默认居中放大底图并保留边距;工作区和 AI 页面都可点击已有提示点删除单点,AI 页面也可删除最近锚点、删除选中候选或清空本页锚点;这些删除入口会限制在当前提示点/本页 AI 候选范围内,避免误删工作区已有 mask。SAM 2.1 框选会建立候选 mask,后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 mask;AI 页面框选会先固化 `promptBox`,执行分割时只框选发送 `box` prompt,框选后继续加正/反点发送 `interactive` prompt;重复执行高精度分割会替换上一次 AI 页候选,只保留最新一个候选。包含反向点时工作区会传 `options.auto_filter_background=true` 和 `min_score=0.05`,如果后端过滤为空则移除旧候选 mask。后端 `ai.py` 期望按 `image_id`、`prompt_type`、`prompt_data`、`model` 和可选 `options` 调用 SAM registry。当前 registry 暴露 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`,并兼容 `sam2` 作为 tiny 别名;`model=sam3` 会被拒绝,`semantic` 文本提示也被禁用。SAM 2.1 支持点/框/interactive/自动分割和 video predictor 传播;多候选默认只采用最高分区域,避免重叠候选同时显示;AI 页面只渲染本页最新生成的候选 mask,不会把工作区已有 mask 带入 AI 画布;AI 页面生成的 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接改标签,推送到工作区会切到“调整多边形”并保留选择和当前帧视角。`options.crop_to_prompt` 可对点/框/interactive prompt 做局部裁剪推理并回映射,`options.auto_filter_background` 可按分数和负向点过滤结果。
|
9. AI 分割:侧栏和工作区工具栏的 AI 智能分割入口使用 Bot + Sparkles 组合图标强化 AI 识别;前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选;AI 画布会按容器和当前帧尺寸默认居中放大底图并保留边距;工作区和 AI 页面都可点击已有提示点删除单点,AI 页面也可删除最近锚点、删除选中候选或清空本页锚点;这些删除入口会限制在当前提示点/本页 AI 候选范围内,避免误删工作区已有 mask。SAM 2.1 框选会建立候选 mask,后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 mask;AI 页面框选会先固化 `promptBox`,执行分割时只框选发送 `box` prompt,框选后继续加正/反点发送 `interactive` prompt;重复执行高精度分割会替换上一次 AI 页候选,只保留最新一个候选。包含反向点时工作区会传 `options.auto_filter_background=true` 和 `min_score=0.05`,如果后端过滤为空则移除旧候选 mask。后端 `ai.py` 期望按 `image_id`、`prompt_type`、`prompt_data`、`model` 和可选 `options` 调用 SAM registry。当前 registry 暴露 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`,并兼容 `sam2` 作为 tiny 别名;`model=sam3` 会被拒绝,`semantic` 文本提示也被禁用。SAM 2.1 支持点/框/interactive/自动分割和 video predictor 传播;多候选默认只采用最高分区域,避免重叠候选同时显示;AI 页面只渲染本页最新生成的候选 mask,不会把工作区已有 mask 带入 AI 画布;AI 页面生成的 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接改标签,推送到工作区会切到“调整多边形”并保留选择和当前帧视角。`options.crop_to_prompt` 可对点/框/interactive prompt 做局部裁剪推理并回映射,`options.auto_filter_background` 可按分数和负向点过滤结果。
|
||||||
10. 视频片段传播:工作区以当前打开帧作为参考帧,使用该帧全部 mask 作为 seed,并用传播起始帧和传播结束帧指定追踪范围;如果当前参考帧没有 mask,点击开始传播会提示“当前参考帧无遮罩”,不会提交任务或保存其它帧标注;用户点击左侧工具栏橡皮擦下方的彩色 AI 图标“自动传播”进入时间轴范围选择模式,在播放进度条或视频处理进度条上点击/拖拽选择范围,也可直接修改数字框,再点击顶栏“开始传播”。传播权重选择器只在进入自动传播选择/执行状态后显示,可为本次传播二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,也不影响 AI 单帧分割权重;进入自动传播范围选择时,顶栏会显示当前传播权重以及相对参考帧的向前/向后帧数;前端提交传播前只保存当前参考帧中的 draft/dirty mask,使 seed 优先带稳定的后端 `source_annotation_id`,再按传播权重 id、seed mask、seed 来源 id 和前/后方向组装 `steps` 并调用 `POST /api/ai/propagate/task` 创建 `propagate_masks` 后台任务;同一参考帧多个同类别 mask 会各自作为独立 seed 传播,后端按 `source_annotation_id/source_mask_id/propagation_seed_key` 区分实例,避免同类不同实例互相删除;中空 seed 会携带和 `polygons` 对齐的 `holes`,后端注入 SAM 2 video predictor 前会先填充外圈再扣除内洞,避免以实心 mask 传播;后端入队时会规范化/校验权重 id 并把规范化后的 id 写入任务 payload/result;Celery worker 顺序执行各 step,避免多个视频 tracker 并发抢占 GPU;每个 step 会根据 seed 来源 id、方向和包含 `holes` 的 seed 签名做幂等判断,同权重且未改变的 seed 直接跳过,已改变或换用其他权重的 seed 会先删除同源旧自动传播标注再重传;旧版本缺少稳定来源 id 的传播标注只在没有可靠 lineage 时走 label/color/class 兼容匹配,写入新结果前仍会通过空间重叠清理同一物体旧结果;中间帧人工新增/修改同一物体后重新传播时,后端会在写入目标帧新结果前按语义和空间重叠清理旧传播结果,且写入前清理不受旧结果传播方向限制;后端按项目帧序列下载片段帧,当前使用所选 SAM 2.1 权重变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,并把后续帧结果保存为 `Annotation`,传播结果轮廓用 CCOMP 层级提取并把内洞写入 `mask_data.holes`;若历史或外部 seed 仍带 `geometry_smoothing`,forward/backward 两个方向的传播结果保存前仍会应用同一参数;当前工作区平滑按钮应用后会直接改写实际 polygon,后续传播以新几何参与签名和追踪。工作区轮询 `GET /api/tasks/{task_id}` 展示进度并刷新标注,Dashboard 也能显示/取消/重试传播任务。
|
10. 视频片段传播:工作区以当前打开帧作为参考帧,使用该帧全部 mask 作为 seed,并用传播起始帧和传播结束帧指定追踪范围;如果当前参考帧没有 mask,点击开始传播会提示“当前参考帧无遮罩”,不会提交任务或保存其它帧标注;用户点击左侧工具栏橡皮擦下方的彩色 AI 图标“自动传播”进入时间轴范围选择模式,在播放进度条或视频处理进度条上点击/拖拽选择范围,也可直接修改数字框,再点击顶栏“开始传播”。传播权重选择器只在进入自动传播选择/执行状态后显示,可为本次传播二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,也不影响 AI 单帧分割权重;进入自动传播范围选择时,顶栏会显示当前传播权重以及相对参考帧的向前/向后帧数;前端提交传播前只保存当前参考帧中的 draft/dirty mask,使 seed 优先带稳定的后端 `source_annotation_id`,再按传播权重 id、seed mask、seed 来源 id 和前/后方向组装 `steps` 并调用 `POST /api/ai/propagate/task` 创建 `propagate_masks` 后台任务;同一参考帧多个同类别 mask 会各自作为独立 seed 传播,后端按 `source_annotation_id/source_mask_id/propagation_seed_key` 区分实例,避免同类不同实例互相删除;中空 seed 会携带和 `polygons` 对齐的 `holes`,后端注入 SAM 2 video predictor 前会先填充外圈再扣除内洞,避免以实心 mask 传播;后端入队时会规范化/校验权重 id 并把规范化后的 id 写入任务 payload/result;Celery worker 顺序执行各 step,避免多个视频 tracker 并发抢占 GPU;每个 step 会根据 seed 来源 id、方向和包含 `holes` 的 seed 签名做幂等判断,同权重且未改变的 seed 直接跳过,已改变或换用其他权重的 seed 会先删除同源旧自动传播标注再重传;旧版本缺少稳定来源 id 的传播标注只在没有可靠 lineage 时走 label/color/class 兼容匹配,写入新结果前仍会通过空间重叠清理同一物体旧结果;中间帧人工新增/修改同一物体后重新传播时,后端会在写入目标帧新结果前按语义和空间重叠清理旧传播结果,且写入前清理不受旧结果传播方向限制;后端按项目帧序列下载片段帧,当前使用所选 SAM 2.1 权重变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,并把后续帧结果保存为 `Annotation`,同一个 seed 在同一目标帧得到的多个不连通外轮廓会保存到同一个 annotation 的 `mask_data.polygons` 中,而不是拆成多个 mask;传播结果轮廓用 CCOMP 层级提取并把内洞写入 `mask_data.holes`;若历史或外部 seed 仍带 `geometry_smoothing`,forward/backward 两个方向的传播结果保存前仍会应用同一参数;当前工作区平滑按钮应用后会直接改写实际 polygon,后续传播以新几何参与签名和追踪。工作区轮询 `GET /api/tasks/{task_id}` 展示进度并刷新标注,Dashboard 也能显示/取消/重试传播任务。
|
||||||
11. GT 导入:工作区左侧工具栏“导入 GT Mask”调用 `/api/ai/import-gt-mask`;选择文件后前端会显示导入结果预览,并让用户决定未知 maskid 处理方式,可舍弃未知类别,也可导入为“未定义类别”等待重新命名。后端用 `cv2.IMREAD_UNCHANGED` 读取 mask 并校验 dtype;GT 图片必须是 8-bit 灰度 maskid 图,或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,0 为背景、X 为 1-255 的 maskid,16-bit/uint16 GT_label、普通彩色类别图和全背景 0 图都会返回明确错误;全背景图错误信息固定为“GT Mask 图片中没有非背景 maskid 区域。”;灰度/RGB 等通道图按模板 `maskId` 匹配类别,超出现有类别时按 `unknown_color_policy` 处理;如果 mask 图片尺寸和当前帧不同,会按当前帧长宽最近邻拉伸后再提取区域;每个连通域用高精度 contour 生成 polygon 标注,保留更多边界点并设置点数上限避免拖慢前端;导入结果与普通 mask 共用拓扑锚点统计、边缘平滑、顶点编辑、分类和保存链路;后端仍可写入 distance transform seed point 供数据兼容,但前端不显示或拖动 seed point。
|
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;模板库详情页的分类区标题为“语义分类树(拖拽调层级)”,右上角提供带 Edit 图标的“编辑模板”按钮,每个分类行右侧用垃圾桶图标删除该 label,不再展示“未分类/批量导入/模板名”等来源标签;编辑模板弹窗点击分类后只编辑分类名称,不展示或编辑旧 `category` 来源元信息;如果项目中的已保存 mask 引用了当前模板里已被删除的类别,工作区打开项目回显时会把该 mask 降级为 `maskid: 0` 的“待分类”mask 并标记为待保存;项目已有任意 mask 时,用户在右侧本体面板修改激活模板必须先确认,确认后删除当前项目所有已有 mask/后端标注再切换;项目没有任何 mask 时可直接切换;模板库详情页和编辑弹窗都支持拖拽调整语义类别层级顺序,拖拽会重算 `zIndex` 并保存到后端,保存后当前详情页会立刻刷新;`OntologyInspector.tsx` 在工作区显示当前模板分类树,也支持拖拽调整内部覆盖顺序。maskid 只作为 GT_label/类别 ID,不参与排序。
|
12. 模板管理:`TemplateRegistry.tsx` 管理分类、颜色、maskid 和内部覆盖顺序;所有新建、复制、批量导入和后端返回的模板都会归一化包含黑色 `[0,0,0]`、`maskid: 0` 的“待分类”保留类,该类固定在语义分类树最后,不能删除,也不能拖拽到更高层级;批量导入 JSON 支持 `[[colors], [names]]` 和 `{colors, names}` 两种格式,也兼容带“批量导入分类:”前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号的粘贴内容,会先预览分类数量、maskid 分配起点和缺失颜色提示,语法或结构错误以内联错误展示;系统默认模板包括“腹腔镜胆囊切除术”和“头颈部CT分割”,头颈部 CT 默认分类名使用纯中文(肿瘤/结节、下颌骨、甲状腺、气管、颈椎、颈动脉、颈静脉、腮腺、下颌下腺、舌骨),恢复演示出厂设置只删除用户私有模板,并会重建缺失的系统默认模板、覆盖恢复被修改或删减的默认语义分类树;模板库“生效中模板架构清单”里的每个模板卡片支持鼠标点击复制,复制会创建当前用户私有副本并保留分类名称、颜色、maskid、内部层级和规则,同时重建类别内部 id;模板库详情页的分类区标题为“语义分类树(拖拽调层级)”,右上角提供带 Edit 图标的“编辑模板”按钮,每个分类行右侧用垃圾桶图标删除该 label,不再展示“未分类/批量导入/模板名”等来源标签;编辑模板弹窗点击分类后只编辑分类名称,不展示或编辑旧 `category` 来源元信息;如果项目中的已保存 mask 引用了当前模板里已被删除的类别,工作区打开项目回显时会把该 mask 降级为 `maskid: 0` 的“待分类”mask 并标记为待保存;项目已有任意 mask 时,用户在右侧本体面板修改激活模板必须先确认,确认后删除当前项目所有已有 mask/后端标注再切换;项目没有任何 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 显示顺序一致。
|
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 显示顺序一致。
|
||||||
|
|||||||
@@ -196,6 +196,17 @@ def _polygon_bbox(polygon: list[list[float]]) -> list[float]:
|
|||||||
return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)]
|
return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def _polygons_bbox(polygons: list[list[list[float]]]) -> list[float]:
|
||||||
|
points = [point for polygon in polygons for point in polygon if len(point) >= 2]
|
||||||
|
if not points:
|
||||||
|
return [0.0, 0.0, 0.0, 0.0]
|
||||||
|
xs = [_clamp01(point[0]) for point in points]
|
||||||
|
ys = [_clamp01(point[1]) for point in points]
|
||||||
|
left, right = min(xs), max(xs)
|
||||||
|
top, bottom = min(ys), max(ys)
|
||||||
|
return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)]
|
||||||
|
|
||||||
|
|
||||||
def _polygon_area(polygon: list[list[float]]) -> float:
|
def _polygon_area(polygon: list[list[float]]) -> float:
|
||||||
if len(polygon) < 3:
|
if len(polygon) < 3:
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -805,32 +816,44 @@ def propagate(
|
|||||||
result_polygons = frame_result.get("polygons") or []
|
result_polygons = frame_result.get("polygons") or []
|
||||||
result_holes = frame_result.get("holes") or []
|
result_holes = frame_result.get("holes") or []
|
||||||
scores = frame_result.get("scores") or []
|
scores = frame_result.get("scores") or []
|
||||||
|
polygons_to_save: list[list[list[float]]] = []
|
||||||
|
holes_to_save: list[list[list[list[float]]]] = []
|
||||||
|
score_values: list[float] = []
|
||||||
for polygon_index, polygon in enumerate(result_polygons):
|
for polygon_index, polygon in enumerate(result_polygons):
|
||||||
if len(polygon) < 3:
|
if len(polygon) < 3:
|
||||||
continue
|
continue
|
||||||
polygon_to_save = _smooth_polygon(polygon, smoothing) if smoothing else polygon
|
polygons_to_save.append(_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 []
|
hole_group = result_holes[polygon_index] if polygon_index < len(result_holes) and isinstance(result_holes[polygon_index], list) else []
|
||||||
annotation = Annotation(
|
holes_to_save.append(hole_group if isinstance(hole_group, list) else [])
|
||||||
project_id=payload.project_id,
|
if polygon_index < len(scores):
|
||||||
frame_id=frame.id,
|
try:
|
||||||
template_id=template_id,
|
score_values.append(float(scores[polygon_index]))
|
||||||
mask_data={
|
except (TypeError, ValueError):
|
||||||
"polygons": [polygon_to_save],
|
pass
|
||||||
**({"holes": [hole_group], "hasHoles": True} if hole_group else {}),
|
if not polygons_to_save:
|
||||||
"label": label,
|
continue
|
||||||
"color": color,
|
annotation = Annotation(
|
||||||
"source": f"{model_id}_propagation",
|
project_id=payload.project_id,
|
||||||
"propagated_from_frame_id": source_frame.id,
|
frame_id=frame.id,
|
||||||
"propagated_from_frame_index": source_frame.frame_index,
|
template_id=template_id,
|
||||||
"score": scores[polygon_index] if polygon_index < len(scores) else None,
|
mask_data={
|
||||||
**({"geometry_smoothing": smoothing} if smoothing else {}),
|
"polygons": polygons_to_save,
|
||||||
**({"class": class_metadata} if class_metadata else {}),
|
**({"holes": holes_to_save, "hasHoles": True} if any(holes_to_save) else {}),
|
||||||
},
|
"label": label,
|
||||||
points=None,
|
"color": color,
|
||||||
bbox=_polygon_bbox(polygon_to_save),
|
"source": f"{model_id}_propagation",
|
||||||
)
|
"propagated_from_frame_id": source_frame.id,
|
||||||
db.add(annotation)
|
"propagated_from_frame_index": source_frame.frame_index,
|
||||||
created.append(annotation)
|
"score": max(score_values) if score_values else None,
|
||||||
|
**({"scores": score_values} if len(score_values) > 1 else {}),
|
||||||
|
**({"geometry_smoothing": smoothing} if smoothing else {}),
|
||||||
|
**({"class": class_metadata} if class_metadata else {}),
|
||||||
|
},
|
||||||
|
points=None,
|
||||||
|
bbox=_polygons_bbox(polygons_to_save),
|
||||||
|
)
|
||||||
|
db.add(annotation)
|
||||||
|
created.append(annotation)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
for annotation in created:
|
for annotation in created:
|
||||||
|
|||||||
@@ -83,6 +83,17 @@ def _polygon_bbox(polygon: list[list[float]]) -> list[float]:
|
|||||||
return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)]
|
return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def _polygons_bbox(polygons: list[list[list[float]]]) -> list[float]:
|
||||||
|
points = [point for polygon in polygons for point in polygon if len(point) >= 2]
|
||||||
|
if not points:
|
||||||
|
return [0.0, 0.0, 0.0, 0.0]
|
||||||
|
xs = [_clamp01(point[0]) for point in points]
|
||||||
|
ys = [_clamp01(point[1]) for point in points]
|
||||||
|
left, right = min(xs), max(xs)
|
||||||
|
top, bottom = min(ys), max(ys)
|
||||||
|
return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)]
|
||||||
|
|
||||||
|
|
||||||
def _normalize_polygon(polygon: list[list[float]]) -> list[list[float]]:
|
def _normalize_polygon(polygon: list[list[float]]) -> list[list[float]]:
|
||||||
return [[_clamp01(point[0]), _clamp01(point[1])] for point in polygon if len(point) >= 2]
|
return [[_clamp01(point[0]), _clamp01(point[1])] for point in polygon if len(point) >= 2]
|
||||||
|
|
||||||
@@ -520,36 +531,49 @@ def _save_propagated_annotations(
|
|||||||
polygon=cleanup_polygon,
|
polygon=cleanup_polygon,
|
||||||
)
|
)
|
||||||
cleaned_frame_ids.add(int(frame.id))
|
cleaned_frame_ids.add(int(frame.id))
|
||||||
|
polygons_to_save: list[list[list[float]]] = []
|
||||||
|
holes_to_save: list[list[list[list[float]]]] = []
|
||||||
|
score_values: list[float] = []
|
||||||
for polygon_index, polygon in prepared_polygons:
|
for polygon_index, polygon in prepared_polygons:
|
||||||
if len(polygon) < 3:
|
if len(polygon) < 3:
|
||||||
continue
|
continue
|
||||||
|
polygons_to_save.append(polygon)
|
||||||
hole_group = result_holes[polygon_index] if polygon_index < len(result_holes) and isinstance(result_holes[polygon_index], list) else []
|
hole_group = result_holes[polygon_index] if polygon_index < len(result_holes) and isinstance(result_holes[polygon_index], list) else []
|
||||||
annotation = Annotation(
|
holes_to_save.append(hole_group if isinstance(hole_group, list) else [])
|
||||||
project_id=int(payload["project_id"]),
|
if polygon_index < len(scores):
|
||||||
frame_id=frame.id,
|
try:
|
||||||
template_id=template_id,
|
score_values.append(float(scores[polygon_index]))
|
||||||
mask_data={
|
except (TypeError, ValueError):
|
||||||
"polygons": [polygon],
|
pass
|
||||||
**({"holes": [hole_group], "hasHoles": True} if hole_group else {}),
|
if not polygons_to_save:
|
||||||
"label": label,
|
continue
|
||||||
"color": color,
|
annotation = Annotation(
|
||||||
"source": f"{model_id}_propagation",
|
project_id=int(payload["project_id"]),
|
||||||
"propagated_from_frame_id": source_frame.id,
|
frame_id=frame.id,
|
||||||
"propagated_from_frame_index": source_frame.frame_index,
|
template_id=template_id,
|
||||||
"propagation_seed_key": seed_key,
|
mask_data={
|
||||||
"propagation_seed_signature": seed_signature,
|
"polygons": polygons_to_save,
|
||||||
"propagation_direction": direction,
|
**({"holes": holes_to_save, "hasHoles": True} if any(holes_to_save) else {}),
|
||||||
"source_annotation_id": source_annotation_id,
|
"label": label,
|
||||||
"source_mask_id": source_mask_id,
|
"color": color,
|
||||||
"score": scores[polygon_index] if polygon_index < len(scores) else None,
|
"source": f"{model_id}_propagation",
|
||||||
**({"geometry_smoothing": smoothing} if smoothing else {}),
|
"propagated_from_frame_id": source_frame.id,
|
||||||
**({"class": class_metadata} if class_metadata else {}),
|
"propagated_from_frame_index": source_frame.frame_index,
|
||||||
},
|
"propagation_seed_key": seed_key,
|
||||||
points=None,
|
"propagation_seed_signature": seed_signature,
|
||||||
bbox=_polygon_bbox(polygon),
|
"propagation_direction": direction,
|
||||||
)
|
"source_annotation_id": source_annotation_id,
|
||||||
db.add(annotation)
|
"source_mask_id": source_mask_id,
|
||||||
created.append(annotation)
|
"score": max(score_values) if score_values else None,
|
||||||
|
**({"scores": score_values} if len(score_values) > 1 else {}),
|
||||||
|
**({"geometry_smoothing": smoothing} if smoothing else {}),
|
||||||
|
**({"class": class_metadata} if class_metadata else {}),
|
||||||
|
},
|
||||||
|
points=None,
|
||||||
|
bbox=_polygons_bbox(polygons_to_save),
|
||||||
|
)
|
||||||
|
db.add(annotation)
|
||||||
|
created.append(annotation)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
for annotation in created:
|
for annotation in created:
|
||||||
|
|||||||
@@ -582,6 +582,79 @@ def test_propagation_task_runner_saves_annotations_and_progress(client, db_sessi
|
|||||||
assert len(stored_polygon) > 3
|
assert len(stored_polygon) > 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_propagation_task_runner_keeps_disconnected_result_polygons_in_one_annotation(client, db_session, monkeypatch):
|
||||||
|
project = client.post("/api/projects", json={"name": "Propagation Disconnected Mask"}).json()
|
||||||
|
frames = [
|
||||||
|
client.post(f"/api/projects/{project['id']}/frames", json={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_index": idx,
|
||||||
|
"image_url": f"frames/{idx}.jpg",
|
||||||
|
"width": 640,
|
||||||
|
"height": 360,
|
||||||
|
}).json()
|
||||||
|
for idx in range(2)
|
||||||
|
]
|
||||||
|
first_piece = [[0.15, 0.15], [0.25, 0.15], [0.25, 0.25], [0.15, 0.25]]
|
||||||
|
second_piece = [[0.70, 0.70], [0.90, 0.70], [0.90, 0.90], [0.70, 0.90]]
|
||||||
|
second_hole = [[[0.76, 0.76], [0.82, 0.76], [0.82, 0.82], [0.76, 0.82]]]
|
||||||
|
task = ProcessingTask(
|
||||||
|
task_type="propagate_masks",
|
||||||
|
status="queued",
|
||||||
|
progress=0,
|
||||||
|
project_id=project["id"],
|
||||||
|
payload={
|
||||||
|
"project_id": project["id"],
|
||||||
|
"frame_id": frames[0]["id"],
|
||||||
|
"model": "sam2.1_hiera_tiny",
|
||||||
|
"include_source": False,
|
||||||
|
"save_annotations": True,
|
||||||
|
"steps": [{
|
||||||
|
"direction": "forward",
|
||||||
|
"max_frames": 2,
|
||||||
|
"seed": {
|
||||||
|
"polygons": [
|
||||||
|
[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]],
|
||||||
|
[[0.6, 0.6], [0.8, 0.6], [0.8, 0.8]],
|
||||||
|
],
|
||||||
|
"label": "多区域",
|
||||||
|
"color": "#ff0000",
|
||||||
|
"source_annotation_id": 7,
|
||||||
|
"source_mask_id": "annotation-7",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db_session.add(task)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(task)
|
||||||
|
|
||||||
|
monkeypatch.setattr("services.propagation_task_runner.download_file", lambda object_name: b"jpeg")
|
||||||
|
monkeypatch.setattr("services.propagation_task_runner.publish_task_progress_event", lambda event_task: None)
|
||||||
|
monkeypatch.setattr("services.propagation_task_runner.sam_registry.propagate_video", lambda *args, **kwargs: [
|
||||||
|
{"frame_index": 0, "polygons": [], "scores": []},
|
||||||
|
{
|
||||||
|
"frame_index": 1,
|
||||||
|
"polygons": [first_piece, second_piece],
|
||||||
|
"holes": [[], second_hole],
|
||||||
|
"scores": [0.72, 0.93],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
result = run_propagate_project_task(db_session, task.id)
|
||||||
|
|
||||||
|
assert result["created_annotation_count"] == 1
|
||||||
|
annotations = db_session.query(Annotation).filter(Annotation.project_id == project["id"]).all()
|
||||||
|
assert len(annotations) == 1
|
||||||
|
annotation = annotations[0]
|
||||||
|
assert annotation.frame_id == frames[1]["id"]
|
||||||
|
assert annotation.bbox == [0.15, 0.15, 0.75, 0.75]
|
||||||
|
assert annotation.mask_data["polygons"] == [first_piece, second_piece]
|
||||||
|
assert annotation.mask_data["holes"] == [[], second_hole]
|
||||||
|
assert annotation.mask_data["hasHoles"] is True
|
||||||
|
assert annotation.mask_data["score"] == 0.93
|
||||||
|
assert annotation.mask_data["scores"] == [0.72, 0.93]
|
||||||
|
|
||||||
|
|
||||||
def test_propagation_task_runner_skips_unchanged_seed_and_replaces_changed_seed(client, db_session, monkeypatch):
|
def test_propagation_task_runner_skips_unchanged_seed_and_replaces_changed_seed(client, db_session, monkeypatch):
|
||||||
project = client.post("/api/projects", json={"name": "Propagation Dedupe"}).json()
|
project = client.post("/api/projects", json={"name": "Propagation Dedupe"}).json()
|
||||||
frames = [
|
frames = [
|
||||||
|
|||||||
@@ -173,7 +173,7 @@
|
|||||||
9. 后端按项目帧序列截取片段,下载对应帧到临时目录,并写成 `000000.jpg` 这类纯数字文件名;这是 `SAM2VideoPredictor` 对视频帧排序的要求,和项目库中持久化的 `frame_%06d.jpg` 对象名无关。
|
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()` 传播;注入 seed 前会把外圈 polygon 栅格化为前景,再按 `holes` 扣除内洞,避免中空参考 mask 以实心形式传播;`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 代码保留但没有接入产品路径。
|
11. `model=sam3` 当前不支持;SAM 3 video tracker 代码保留但没有接入产品路径。
|
||||||
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` 仅在存在时保留用于兼容判断。
|
12. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`,跳过源帧;同一个 seed 在同一目标帧得到的多个不连通外轮廓会保存在同一个 annotation 的 `mask_data.polygons` 中,前端回显为一个含多个分离区域的 mask;传播 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,供时间轴视频处理进度条显示蓝色传播区段。
|
13. 前端轮询到已创建区域后刷新 `GET /api/ai/annotations` 并回显新标注;任务结束后如果后端返回 0 个新区域,工作区会明确提示没有生成新的 mask,若是未改变 seed 被跳过则提示未改变 mask 已跳过。处理过帧次大于 0 的成功任务会追加一条本地传播历史片段,用于视频处理进度条显示最近传播范围;`annotationToMask()` 会保留传播来源 metadata,供时间轴视频处理进度条显示蓝色传播区段。
|
||||||
|
|
||||||
### 手工绘制与历史栈
|
### 手工绘制与历史栈
|
||||||
|
|||||||
@@ -693,6 +693,42 @@ describe('api client contracts', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores disconnected polygons from one saved annotation as one mask with a combined bbox', async () => {
|
||||||
|
const { annotationToMask } = await import('./api');
|
||||||
|
const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 100 };
|
||||||
|
|
||||||
|
const hydrated = annotationToMask({
|
||||||
|
id: 44,
|
||||||
|
project_id: 9,
|
||||||
|
frame_id: 5,
|
||||||
|
template_id: null,
|
||||||
|
mask_data: {
|
||||||
|
polygons: [
|
||||||
|
[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2], [0.1, 0.2]],
|
||||||
|
[[0.7, 0.7], [0.9, 0.7], [0.9, 0.9], [0.7, 0.9]],
|
||||||
|
],
|
||||||
|
label: '多区域',
|
||||||
|
color: '#22c55e',
|
||||||
|
source: 'sam2.1_hiera_tiny_propagation',
|
||||||
|
},
|
||||||
|
points: null,
|
||||||
|
bbox: null,
|
||||||
|
created_at: 'created',
|
||||||
|
updated_at: 'updated',
|
||||||
|
}, frame);
|
||||||
|
|
||||||
|
expect(hydrated).toEqual(expect.objectContaining({
|
||||||
|
id: 'annotation-44',
|
||||||
|
pathData: 'M 10 10 L 20 10 L 20 20 L 10 20 Z M 70 70 L 90 70 L 90 90 L 70 90 Z',
|
||||||
|
segmentation: [
|
||||||
|
[10, 10, 20, 10, 20, 20, 10, 20],
|
||||||
|
[70, 70, 90, 70, 90, 90, 70, 90],
|
||||||
|
],
|
||||||
|
bbox: [10, 10, 80, 80],
|
||||||
|
area: 500,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves propagation metadata when saving edited geometry without persisting preview-only smoothing fields', async () => {
|
it('preserves propagation metadata when saving edited geometry without persisting preview-only smoothing fields', async () => {
|
||||||
const { buildAnnotationPayload } = await import('./api');
|
const { buildAnnotationPayload } = await import('./api');
|
||||||
const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 50 };
|
const frame = { id: '5', projectId: '9', index: 0, url: '/frame.jpg', width: 100, height: 50 };
|
||||||
|
|||||||
@@ -592,6 +592,12 @@ function polygonToBbox(points: number[][], width: number, height: number): [numb
|
|||||||
return [minX, minY, maxX - minX, maxY - minY];
|
return [minX, minY, maxX - minX, maxY - minY];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function polygonsToBbox(polygons: number[][][], width: number, height: number): [number, number, number, number] {
|
||||||
|
const points = polygons.flat();
|
||||||
|
if (points.length === 0) return [0, 0, 0, 0];
|
||||||
|
return polygonToBbox(points, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
function polygonAreaPixels(points: number[][], width: number, height: number): number {
|
function polygonAreaPixels(points: number[][], width: number, height: number): number {
|
||||||
if (points.length < 3) return 0;
|
if (points.length < 3) return 0;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -803,7 +809,7 @@ export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mas
|
|||||||
const segmentationPolygons = mergedGeometry.segmentationPolygons;
|
const segmentationPolygons = mergedGeometry.segmentationPolygons;
|
||||||
const firstPolygon = segmentationPolygons[0];
|
const firstPolygon = segmentationPolygons[0];
|
||||||
if (!firstPolygon || firstPolygon.length === 0) return null;
|
if (!firstPolygon || firstPolygon.length === 0) return null;
|
||||||
const bbox = polygonToBbox(firstPolygon, frame.width, frame.height);
|
const bbox = polygonsToBbox(segmentationPolygons, frame.width, frame.height);
|
||||||
const classMetadata = annotation.mask_data?.class;
|
const classMetadata = annotation.mask_data?.class;
|
||||||
const { polygons: _polygons, holes: _holes, label: _label, color: _color, class: _classMetadata, ...metadata } = annotation.mask_data || {};
|
const { polygons: _polygons, holes: _holes, label: _label, color: _color, class: _classMetadata, ...metadata } = annotation.mask_data || {};
|
||||||
const restoredMetadata = {
|
const restoredMetadata = {
|
||||||
@@ -828,7 +834,7 @@ export function annotationToMask(annotation: SavedAnnotation, frame: Frame): Mas
|
|||||||
segmentation: segmentationPolygons.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]),
|
points: annotation.points?.map(([x, y]) => [x * frame.width, y * frame.height]),
|
||||||
bbox,
|
bbox,
|
||||||
area: bbox[2] * bbox[3],
|
area: segmentationPolygons.reduce((total, polygon) => total + polygonAreaPixels(polygon, frame.width, frame.height), 0),
|
||||||
metadata: hasMetadata ? restoredMetadata : undefined,
|
metadata: hasMetadata ? restoredMetadata : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user