支持跨语义传播链区域合并
- 区域合并同步时允许 A 语义传播链并入 B 语义传播链 - 传播帧同时存在 B/A 对应结果时,将 A 合并进 B 并删除 A 对应标注 - 传播帧缺少 B 对应结果但存在 A 对应结果时,将 A 结果转换为 B 语义并标记为 dirty - 保持稳定 lineage 匹配优先,旧传播结果继续用来源帧、语义/颜色和空间最近候选兜底 - 补充 CanvasArea 回归测试覆盖跨语义 B 吸收 A 以及缺少 B 对应结果场景 - 更新项目指南和设计冻结文档
This commit is contained in:
@@ -244,7 +244,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
|||||||
5. 生成帧入队:用户在项目库点击“生成帧”,选择目标 FPS 后前端调用 `/api/media/parse`;后端创建 `ProcessingTask` 并投递 Celery,接口支持 `parse_fps`、`max_frames` 和 `target_width` 标准帧序列参数;项目库和模板库的成功/失败短反馈使用非阻塞 `TransientNotice`,会自动消失。
|
5. 生成帧入队:用户在项目库点击“生成帧”,选择目标 FPS 后前端调用 `/api/media/parse`;后端创建 `ProcessingTask` 并投递 Celery,接口支持 `parse_fps`、`max_frames` 和 `target_width` 标准帧序列参数;项目库和模板库的成功/失败短反馈使用非阻塞 `TransientNotice`,会自动消失。
|
||||||
6. worker 执行:Celery worker 用 FFmpeg 优先拆视频帧,失败后用 OpenCV fallback,DICOM 使用 pydicom;worker 下载和读取 DICOM 时也按文件名自然顺序排序;视频/DICOM 解析完成后都按 `frame_%06d.jpg` 连续生成项目帧序列,并记录 `timestamp_ms`、`source_frame_number` 和任务 `frame_sequence` 元数据,后续工作区、时间轴、AI 传播、标注和导出共用同一套帧序列逻辑。
|
6. worker 执行:Celery worker 用 FFmpeg 优先拆视频帧,失败后用 OpenCV fallback,DICOM 使用 pydicom;worker 下载和读取 DICOM 时也按文件名自然顺序排序;视频/DICOM 解析完成后都按 `frame_%06d.jpg` 连续生成项目帧序列,并记录 `timestamp_ms`、`source_frame_number` 和任务 `frame_sequence` 元数据,后续工作区、时间轴、AI 传播、标注和导出共用同一套帧序列逻辑。
|
||||||
7. 帧展示:`VideoWorkspace.tsx` 调用 `/api/projects/{id}/frames`,`CanvasArea.tsx` 和 `FrameTimeline.tsx` 显示当前帧与时间轴缩略图;`CanvasArea` 会按容器和帧尺寸默认居中放大底图并保留边距,右下角显示“当前帧:XX/XXX”;`FrameTimeline` 会根据已保存标注回显到 `Mask.metadata` 的传播来源,把自动传播生成的帧在视频处理进度条显示为蓝色区段,人工/AI 标注帧显示红色竖线;每次自动传播成功处理帧后,`VideoWorkspace` 会把本次传播范围作为当前会话历史片段传给 `FrameTimeline`,在视频处理进度条上叠加同一蓝色系、最新传播最亮、旧传播逐次变暗且第 5 次及更早统一为阈值旧记录色的纯色条;传播历史条只显示当前仍有自动传播 mask 的帧,删除 mask 或清空范围后会按剩余传播 mask 自动裁剪,空帧不保留红/蓝颜色;视频处理进度条和红/蓝标识可点击跳转到对应帧;底部缩略图中人工/AI 标注帧用红色边框、自动传播/推理帧用蓝色边框,同一帧同时具备两种状态时红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍以青色外框高亮优先;若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注;进入自动传播、布尔操作或特定范围帧导出选择模式时,播放进度条和视频处理进度条会显示黄色范围框,并可点击/拖拽选择起止帧;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。
|
7. 帧展示:`VideoWorkspace.tsx` 调用 `/api/projects/{id}/frames`,`CanvasArea.tsx` 和 `FrameTimeline.tsx` 显示当前帧与时间轴缩略图;`CanvasArea` 会按容器和帧尺寸默认居中放大底图并保留边距,右下角显示“当前帧:XX/XXX”;`FrameTimeline` 会根据已保存标注回显到 `Mask.metadata` 的传播来源,把自动传播生成的帧在视频处理进度条显示为蓝色区段,人工/AI 标注帧显示红色竖线;每次自动传播成功处理帧后,`VideoWorkspace` 会把本次传播范围作为当前会话历史片段传给 `FrameTimeline`,在视频处理进度条上叠加同一蓝色系、最新传播最亮、旧传播逐次变暗且第 5 次及更早统一为阈值旧记录色的纯色条;传播历史条只显示当前仍有自动传播 mask 的帧,删除 mask 或清空范围后会按剩余传播 mask 自动裁剪,空帧不保留红/蓝颜色;视频处理进度条和红/蓝标识可点击跳转到对应帧;底部缩略图中人工/AI 标注帧用红色边框、自动传播/推理帧用蓝色边框,同一帧同时具备两种状态时红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍以青色外框高亮优先;若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注;进入自动传播、布尔操作或特定范围帧导出选择模式时,播放进度条和视频处理进度条会显示黄色范围框,并可点击/拖拽选择起止帧;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。
|
||||||
8. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、画笔和橡皮擦生成/编辑 polygon mask;多边形可按 Enter 或点击首节点闭合;多边形/矩形/圆在右侧语义分类树未选中类别时会自动归入黑色 `maskid:0` 的“待分类”;画笔/橡皮擦可在左侧工具栏调整大小,画笔要求右侧语义分类树已有选中类别,画出的圆形连续笔触会在鼠标松开时一次性 union 成 mask,若与当前选中 mask 连通则自动合并到该 mask,橡皮擦要求已选中 mask 并在松开时从该 mask 中 difference 扣除;普通 mask 和导入 mask 都不显示黄色 seed point,也不提供 seed point 拖动;未选中特定 mask 时,Canvas 会按右侧语义分类树拖拽得到的内部覆盖优先级从低到高渲染 mask,使高优先级类别显示在上层;Canvas 左上角工具上下文提示会在切换工具或操作状态变化时短暂显示,数秒后自动隐藏,避免长期遮挡底图;工具栏有“调整多边形”入口,左侧 `ToolsPalette` 使用紧凑垂直布局并在高度不足时自身滚动,基础绘制、画笔/橡皮擦/自动传播、布尔/删除、导入/AI 入口之间用浅灰分隔线区分;橡皮擦下方提供彩色 AI 图标“自动传播”入口,布尔/删除组包含区域合并、重叠区域去除、`DEL` 和“清空遮罩”,其后通过 `data-testid="tool-group-separator"` 分隔紫色“导入 GT Mask”和 AI 智能分割入口;清空遮罩优先作用于当前帧选中 mask,没有选中时作用于当前帧全部 mask;无传播链结果时直接清当前帧,存在传播链结果时弹窗同一行选择取消、只清当前帧、按帧范围选择或清空所有传播帧,按帧范围选择会进入时间轴范围选择并二次确认;Canvas 右下角不再提供旧的“清空遮罩”或“应用分类”按钮,分类改由右侧语义分类树点击完成;工作区左侧工具栏不展示 AI 页的正向选点、反向选点和边界框选,也不重复放置撤销/重做;点击 mask 后可按住顶点直接拖动并实时更新 polygon,顶点拖拽结束不会触发 Stage 平移或重置 Canvas 视口;也可删除 polygon 顶点、通过边中点或双击边界插入新顶点;多 polygon/分离区域组成的同一 mask 进入编辑时所有子区域都会显示顶点和插点手柄,同帧同传播链的分散 mask 点选时会按 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 或 `propagation_seed_signature` 联动高亮;对旧传播结果缺少这些稳定 lineage 的情况,会用传播来源、来源帧、分类/标签/颜色构造兼容分组,使同一传播 mask 拆出的不连通片段仍能一起高亮;从参考帧手工 mask 执行区域合并/去除同步到旧传播帧时,如果稳定 lineage 缺失,会在同来源帧且同语义/颜色的传播结果中选取空间最近者作为对应实例,避免漏合并同类不同实例;带中空洞的 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 执行区域合并/去除同步到旧传播帧时,如果稳定 lineage 缺失,会在同来源帧且同语义/颜色的传播结果中选取空间最近者作为对应实例,避免漏合并同类不同实例;区域合并支持跨语义链路,当前帧把 A mask 合并进 B mask 时,传播帧中的 A 对应结果会并入 B 对应结果;若某个传播帧没有 B 对应结果但有 A 对应结果,则把该 A 结果转换为 B 语义并标记为 dirty;带中空洞的 mask 会用 `metadata.polygonRingCounts` 记录外圈与内圈的 ring 分组,调整多边形时外圈和内洞都显示可编辑顶点和插点手柄,保存时把内洞拆到 `mask_data.holes`;选中整块 mask 可用 Delete/Backspace 或左侧 `DEL` 删除,已保存 mask 删除前会预检当前后端 annotation id,只对仍存在的 id 调用后端删除,避免陈旧本地 id 产生 DELETE 404;删除传播 seed 或任一传播结果时会扩展删除同一传播链上的自动传播 mask,但保留其他帧独立 AI 推理或人工标注 mask;区域合并/去除会隐藏编辑手柄并显示已选数量,第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,使用 `polygon-clipping` 做 union/difference,若存在传播帧对应 mask 会先弹窗选择只处理当前帧、处理所有传播帧或按帧范围选择;按帧范围选择会进入时间轴范围选择并二次确认,只把同一布尔操作同步到所选范围内具备对应关系的传播帧;同步后的传播 mask 保留原 `source`/lineage metadata,只进入 dirty 状态等待保存,不会在时间轴上变成人工/AI 标注帧;内含去除结果用 even-odd 规则渲染 hole;Zustand 维护 `maskHistory/maskFuture` 支持撤销/重做。
|
||||||
9. AI 分割:侧栏和工作区工具栏的 AI 智能分割入口使用 Bot + Sparkles 组合图标强化 AI 识别;前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选;AI 画布会按容器和当前帧尺寸默认居中放大底图并保留边距;工作区和 AI 页面都可点击已有提示点删除单点,AI 页面也可删除最近锚点、删除选中候选或清空本页锚点;这些删除入口会限制在当前提示点/本页 AI 候选范围内,避免误删工作区已有 mask。SAM 2.1 框选会建立候选 mask,后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 mask;AI 页面框选会先固化 `promptBox`,执行分割时只框选发送 `box` prompt,框选后继续加正/反点发送 `interactive` prompt;重复执行高精度分割会替换上一次 AI 页候选,只保留最新一个候选。包含反向点时工作区会传 `options.auto_filter_background=true` 和 `min_score=0.05`,如果后端过滤为空则移除旧候选 mask。后端 `ai.py` 期望按 `image_id`、`prompt_type`、`prompt_data`、`model` 和可选 `options` 调用 SAM registry。当前 registry 暴露 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`,并兼容 `sam2` 作为 tiny 别名;`model=sam3` 会被拒绝,`semantic` 文本提示也被禁用。SAM 2.1 支持点/框/interactive/自动分割和 video predictor 传播;多候选默认只采用最高分区域,避免重叠候选同时显示;AI 页面只渲染本页最新生成的候选 mask,不会把工作区已有 mask 带入 AI 画布;AI 页面生成的 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接改标签,推送到工作区会切到“调整多边形”并保留选择和当前帧视角。`options.crop_to_prompt` 可对点/框/interactive prompt 做局部裁剪推理并回映射,`options.auto_filter_background` 可按分数和负向点过滤结果。
|
9. AI 分割:侧栏和工作区工具栏的 AI 智能分割入口使用 Bot + Sparkles 组合图标强化 AI 识别;前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选;AI 画布会按容器和当前帧尺寸默认居中放大底图并保留边距;工作区和 AI 页面都可点击已有提示点删除单点,AI 页面也可删除最近锚点、删除选中候选或清空本页锚点;这些删除入口会限制在当前提示点/本页 AI 候选范围内,避免误删工作区已有 mask。SAM 2.1 框选会建立候选 mask,后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 mask;AI 页面框选会先固化 `promptBox`,执行分割时只框选发送 `box` prompt,框选后继续加正/反点发送 `interactive` prompt;重复执行高精度分割会替换上一次 AI 页候选,只保留最新一个候选。包含反向点时工作区会传 `options.auto_filter_background=true` 和 `min_score=0.05`,如果后端过滤为空则移除旧候选 mask。后端 `ai.py` 期望按 `image_id`、`prompt_type`、`prompt_data`、`model` 和可选 `options` 调用 SAM registry。当前 registry 暴露 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`,并兼容 `sam2` 作为 tiny 别名;`model=sam3` 会被拒绝,`semantic` 文本提示也被禁用。SAM 2.1 支持点/框/interactive/自动分割和 video predictor 传播;多候选默认只采用最高分区域,避免重叠候选同时显示;AI 页面只渲染本页最新生成的候选 mask,不会把工作区已有 mask 带入 AI 画布;AI 页面生成的 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接改标签,推送到工作区会切到“调整多边形”并保留选择和当前帧视角。`options.crop_to_prompt` 可对点/框/interactive prompt 做局部裁剪推理并回映射,`options.auto_filter_background` 可按分数和负向点过滤结果。
|
||||||
10. 视频片段传播:工作区以当前打开帧作为参考帧,使用该帧全部 mask 作为 seed,并用传播起始帧和传播结束帧指定追踪范围;如果当前参考帧没有 mask,点击开始传播会提示“当前参考帧无遮罩”,不会提交任务或保存其它帧标注;用户点击左侧工具栏橡皮擦下方的彩色 AI 图标“自动传播”进入时间轴范围选择模式,在播放进度条或视频处理进度条上点击/拖拽选择范围,也可直接修改数字框,再点击顶栏“开始传播”。传播权重选择器只在进入自动传播选择/执行状态后显示,可为本次传播二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,也不影响 AI 单帧分割权重;进入自动传播范围选择时,顶栏会显示当前传播权重以及相对参考帧的向前/向后帧数;前端提交传播前只保存当前参考帧中的 draft/dirty mask,使 seed 优先带稳定的后端 `source_annotation_id`,再按传播权重 id、seed mask、seed 来源 id 和前/后方向组装 `steps` 并调用 `POST /api/ai/propagate/task` 创建 `propagate_masks` 后台任务;同一参考帧多个同类别 mask 会各自作为独立 seed 传播,后端按 `source_annotation_id/source_mask_id/propagation_seed_key` 区分实例,避免同类不同实例互相删除;中空 seed 会携带和 `polygons` 对齐的 `holes`,后端注入 SAM 2 video predictor 前会先填充外圈再扣除内洞,避免以实心 mask 传播;后端入队时会规范化/校验权重 id 并把规范化后的 id 写入任务 payload/result;Celery worker 顺序执行各 step,避免多个视频 tracker 并发抢占 GPU;每个 step 会根据 seed 来源 id、方向和包含 `holes` 的 seed 签名做幂等判断,同权重且未改变的 seed 直接跳过,已改变或换用其他权重的 seed 会先删除同源旧自动传播标注再重传;旧版本缺少稳定来源 id 的传播标注只在没有可靠 lineage 时走 label/color/class 兼容匹配,写入新结果前仍会通过空间重叠清理同一物体旧结果;中间帧人工新增/修改同一物体后重新传播时,后端会在写入目标帧新结果前按语义和空间重叠清理旧传播结果,且写入前清理不受旧结果传播方向限制;后端按项目帧序列下载片段帧,当前使用所选 SAM 2.1 权重变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,并把后续帧结果保存为 `Annotation`,同一个 seed 在同一目标帧得到的多个不连通外轮廓会保存到同一个 annotation 的 `mask_data.polygons` 中,而不是拆成多个 mask;传播结果轮廓用 CCOMP 层级提取并把内洞写入 `mask_data.holes`;若历史或外部 seed 仍带 `geometry_smoothing`,forward/backward 两个方向的传播结果保存前仍会应用同一参数;当前工作区平滑按钮应用后会直接改写实际 polygon,后续传播以新几何参与签名和追踪。工作区轮询 `GET /api/tasks/{task_id}` 展示进度并刷新标注,Dashboard 也能显示/取消/重试传播任务。
|
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。
|
||||||
|
|||||||
@@ -297,7 +297,7 @@
|
|||||||
以下能力属于当前冻结版本的占位或半可用功能:
|
以下能力属于当前冻结版本的占位或半可用功能:
|
||||||
|
|
||||||
- Dashboard 初始快照来自 `GET /api/dashboard/overview`;任务进度区由 `processing_tasks` queued/running/success/failed/cancelled 任务生成,处理中统计只计算 queued/running。
|
- Dashboard 初始快照来自 `GET /api/dashboard/overview`;任务进度区由 `processing_tasks` queued/running/success/failed/cancelled 任务生成,处理中统计只计算 queued/running。
|
||||||
- 已保存标注支持通过右侧语义分类树换标签、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑、中空 mask 内洞 ring 编辑和区域合并/去除进入 dirty 状态并归档更新;多 polygon/分离区域选中后所有子区域都显示编辑手柄,同帧同传播链的分散 mask 会按 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 或 `propagation_seed_signature` 联动高亮;旧传播结果缺少稳定 lineage 时,会用传播来源、来源帧、方向、分类/标签/颜色构造兼容分组,保证同一传播 mask 拆出的不连通片段仍一起高亮;从参考帧手工 mask 执行区域合并/去除同步到旧传播帧时,如果没有稳定 lineage,会在同来源帧且同语义/颜色的候选传播结果中选取空间最近者作为对应实例,避免漏处理同类不同实例;Canvas 右下角不再提供旧的“应用分类”按钮,避免没选区时误改整帧;区域合并/去除会在存在传播帧时弹窗选择当前帧、所有传播帧或按帧范围选择,范围选择复用时间轴和确认弹窗,并保留传播帧来源 metadata;选中整块 mask 可用 Delete/Backspace 或左侧 `DEL` 删除,同步后端前会预检 id,同传播链自动传播结果会随传播 seed/传播结果删除而一并清理,独立 AI 推理/人工 mask 保留。
|
- 已保存标注支持通过右侧语义分类树换标签、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑、中空 mask 内洞 ring 编辑和区域合并/去除进入 dirty 状态并归档更新;多 polygon/分离区域选中后所有子区域都显示编辑手柄,同帧同传播链的分散 mask 会按 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 或 `propagation_seed_signature` 联动高亮;旧传播结果缺少稳定 lineage 时,会用传播来源、来源帧、方向、分类/标签/颜色构造兼容分组,保证同一传播 mask 拆出的不连通片段仍一起高亮;从参考帧手工 mask 执行区域合并/去除同步到旧传播帧时,如果没有稳定 lineage,会在同来源帧且同语义/颜色的候选传播结果中选取空间最近者作为对应实例,避免漏处理同类不同实例;区域合并支持跨语义链路,当前帧把 A mask 合并进 B mask 时,传播帧中的 A 对应结果会并入 B 对应结果;若某个传播帧没有 B 对应结果但有 A 对应结果,则把该 A 结果转换为 B 语义并标记为 dirty;Canvas 右下角不再提供旧的“应用分类”按钮,避免没选区时误改整帧;区域合并/去除会在存在传播帧时弹窗选择当前帧、所有传播帧或按帧范围选择,范围选择复用时间轴和确认弹窗,并保留传播帧来源 metadata;选中整块 mask 可用 Delete/Backspace 或左侧 `DEL` 删除,同步后端前会预检 id,同传播链自动传播结果会随传播 seed/传播结果删除而一并清理,独立 AI 推理/人工 mask 保留。
|
||||||
- SAM 3 文本语义分割已从当前产品路径中禁用;相关源码保留,恢复时需要重新接入前端入口、registry、状态接口和测试。
|
- SAM 3 文本语义分割已从当前产品路径中禁用;相关源码保留,恢复时需要重新接入前端入口、registry、状态接口和测试。
|
||||||
- 自定义分类通过 `PATCH /api/templates/{id}` 写入当前激活模板的 `mapping_rules.classes`。
|
- 自定义分类通过 `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。前端不再展示“后端模型置信度”。
|
- 选中 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。前端不再展示“后端模型置信度”。
|
||||||
|
|||||||
@@ -1159,6 +1159,161 @@ describe('CanvasArea', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('merges propagated A masks into propagated B masks when merging A into B on the reference frame', async () => {
|
||||||
|
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
|
||||||
|
useStore.setState({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'annotation-1',
|
||||||
|
annotationId: '1',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z',
|
||||||
|
label: 'B',
|
||||||
|
color: '#2563eb',
|
||||||
|
className: 'B',
|
||||||
|
classMaskId: 2,
|
||||||
|
segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]],
|
||||||
|
saved: true,
|
||||||
|
saveStatus: 'saved',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'annotation-2',
|
||||||
|
annotationId: '2',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z',
|
||||||
|
label: 'A',
|
||||||
|
color: '#dc2626',
|
||||||
|
className: 'A',
|
||||||
|
classMaskId: 1,
|
||||||
|
segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]],
|
||||||
|
saved: true,
|
||||||
|
saveStatus: 'saved',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'annotation-10',
|
||||||
|
annotationId: '10',
|
||||||
|
frameId: 'frame-2',
|
||||||
|
pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z',
|
||||||
|
label: 'B',
|
||||||
|
color: '#2563eb',
|
||||||
|
className: 'B',
|
||||||
|
classMaskId: 2,
|
||||||
|
segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]],
|
||||||
|
saved: true,
|
||||||
|
saveStatus: 'saved',
|
||||||
|
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'annotation-20',
|
||||||
|
annotationId: '20',
|
||||||
|
frameId: 'frame-2',
|
||||||
|
pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z',
|
||||||
|
label: 'A',
|
||||||
|
color: '#dc2626',
|
||||||
|
className: 'A',
|
||||||
|
classMaskId: 1,
|
||||||
|
segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]],
|
||||||
|
saved: true,
|
||||||
|
saveStatus: 'saved',
|
||||||
|
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CanvasArea activeTool="area_merge" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
||||||
|
const paths = screen.getAllByTestId('konva-path');
|
||||||
|
fireEvent.click(paths[0]);
|
||||||
|
fireEvent.click(paths[1]);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
|
||||||
|
expect(screen.getByText('选择操作范围')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '处理所有传播帧' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(expect.arrayContaining(['2', '20'])));
|
||||||
|
const masks = useStore.getState().masks;
|
||||||
|
expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10']);
|
||||||
|
const propagatedB = masks.find((mask) => mask.id === 'annotation-10');
|
||||||
|
expect(propagatedB).toEqual(expect.objectContaining({
|
||||||
|
label: 'B',
|
||||||
|
color: '#2563eb',
|
||||||
|
className: 'B',
|
||||||
|
classMaskId: 2,
|
||||||
|
saveStatus: 'dirty',
|
||||||
|
saved: false,
|
||||||
|
}));
|
||||||
|
expect(propagatedB?.bbox).toEqual([12, 12, 90, 90]);
|
||||||
|
expect(propagatedB?.area).toBe(4900);
|
||||||
|
expect(propagatedB?.segmentation?.flat()).toEqual(expect.arrayContaining([12, 12, 102, 102]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns propagated A masks into B masks when merging A into B and no propagated B exists on that frame', async () => {
|
||||||
|
const onDeleteMaskAnnotations = vi.fn().mockResolvedValue(undefined);
|
||||||
|
useStore.setState({
|
||||||
|
masks: [
|
||||||
|
{
|
||||||
|
id: 'annotation-1',
|
||||||
|
annotationId: '1',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z',
|
||||||
|
label: 'B',
|
||||||
|
color: '#2563eb',
|
||||||
|
className: 'B',
|
||||||
|
classMaskId: 2,
|
||||||
|
segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]],
|
||||||
|
saved: true,
|
||||||
|
saveStatus: 'saved',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'annotation-2',
|
||||||
|
annotationId: '2',
|
||||||
|
frameId: 'frame-1',
|
||||||
|
pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z',
|
||||||
|
label: 'A',
|
||||||
|
color: '#dc2626',
|
||||||
|
className: 'A',
|
||||||
|
classMaskId: 1,
|
||||||
|
segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]],
|
||||||
|
saved: true,
|
||||||
|
saveStatus: 'saved',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'annotation-20',
|
||||||
|
annotationId: '20',
|
||||||
|
frameId: 'frame-2',
|
||||||
|
pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z',
|
||||||
|
label: 'A',
|
||||||
|
color: '#dc2626',
|
||||||
|
className: 'A',
|
||||||
|
classMaskId: 1,
|
||||||
|
segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]],
|
||||||
|
saved: true,
|
||||||
|
saveStatus: 'saved',
|
||||||
|
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CanvasArea activeTool="area_merge" frame={frame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
|
||||||
|
const paths = screen.getAllByTestId('konva-path');
|
||||||
|
fireEvent.click(paths[0]);
|
||||||
|
fireEvent.click(paths[1]);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
|
||||||
|
expect(screen.getByText('选择操作范围')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '处理所有传播帧' }));
|
||||||
|
|
||||||
|
const masks = useStore.getState().masks;
|
||||||
|
expect(onDeleteMaskAnnotations).not.toHaveBeenCalledWith(expect.arrayContaining(['20']));
|
||||||
|
expect(masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-20']);
|
||||||
|
expect(masks.find((mask) => mask.id === 'annotation-20')).toEqual(expect.objectContaining({
|
||||||
|
label: 'B',
|
||||||
|
color: '#2563eb',
|
||||||
|
className: 'B',
|
||||||
|
classMaskId: 2,
|
||||||
|
saveStatus: 'dirty',
|
||||||
|
saved: false,
|
||||||
|
metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2 }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('can hand propagated boolean operations to the workspace frame range selector', () => {
|
it('can hand propagated boolean operations to the workspace frame range selector', () => {
|
||||||
const onRequestBooleanFrameRange = vi.fn();
|
const onRequestBooleanFrameRange = vi.fn();
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
|
|||||||
@@ -1516,14 +1516,13 @@ export function CanvasArea({
|
|||||||
const targetFrameId = String(mask.frameId);
|
const targetFrameId = String(mask.frameId);
|
||||||
if (targetFrameId === currentFrameId) return;
|
if (targetFrameId === currentFrameId) return;
|
||||||
const hasPrimary = findLinkedMasksOnFrame([primary.id], masks, targetFrameId).length > 0;
|
const hasPrimary = findLinkedMasksOnFrame([primary.id], masks, targetFrameId).length > 0;
|
||||||
if (!hasPrimary) return;
|
|
||||||
const hasSecondary = secondaryMasks.some((secondary) => (
|
const hasSecondary = secondaryMasks.some((secondary) => (
|
||||||
findLinkedMasksOnFrame([secondary.id], masks, targetFrameId).length > 0
|
findLinkedMasksOnFrame([secondary.id], masks, targetFrameId).length > 0
|
||||||
));
|
));
|
||||||
if (hasSecondary) targetFrameIds.add(targetFrameId);
|
if (hasSecondary && (hasPrimary || effectiveTool === 'area_merge')) targetFrameIds.add(targetFrameId);
|
||||||
});
|
});
|
||||||
return { currentFrameId, targetFrameIds };
|
return { currentFrameId, targetFrameIds };
|
||||||
}, [booleanSelectedMasks, frame, masks]);
|
}, [booleanSelectedMasks, effectiveTool, frame, masks]);
|
||||||
|
|
||||||
const runBooleanOperation = useCallback(async (targetFrameIds: Set<string>) => {
|
const runBooleanOperation = useCallback(async (targetFrameIds: Set<string>) => {
|
||||||
if (!frame || booleanSelectedMasks.length < 2) return;
|
if (!frame || booleanSelectedMasks.length < 2) return;
|
||||||
@@ -1534,13 +1533,9 @@ export function CanvasArea({
|
|||||||
const deletedMaskIds = new Set<string>();
|
const deletedMaskIds = new Set<string>();
|
||||||
|
|
||||||
const applyOperationForFrame = (targetFrameId: string) => {
|
const applyOperationForFrame = (targetFrameId: string) => {
|
||||||
const primaryTargetId = targetFrameId === currentFrameId
|
const linkedPrimaryTargetId = targetFrameId === currentFrameId
|
||||||
? primary.id
|
? primary.id
|
||||||
: findLinkedMasksOnFrame([primary.id], masks, targetFrameId)[0];
|
: findLinkedMasksOnFrame([primary.id], masks, targetFrameId)[0];
|
||||||
const primaryTarget = masks.find((mask) => mask.id === primaryTargetId);
|
|
||||||
if (!primaryTarget || deletedMaskIds.has(primaryTarget.id)) return;
|
|
||||||
const primaryGeometry = maskToMultiPolygon(primaryTarget);
|
|
||||||
if (!primaryGeometry) return;
|
|
||||||
|
|
||||||
const secondaryTargetIds = Array.from(new Set(
|
const secondaryTargetIds = Array.from(new Set(
|
||||||
secondaryMasks.flatMap((secondary) => (
|
secondaryMasks.flatMap((secondary) => (
|
||||||
@@ -1548,14 +1543,33 @@ export function CanvasArea({
|
|||||||
? [secondary.id]
|
? [secondary.id]
|
||||||
: findLinkedMasksOnFrame([secondary.id], masks, targetFrameId)
|
: findLinkedMasksOnFrame([secondary.id], masks, targetFrameId)
|
||||||
)),
|
)),
|
||||||
)).filter((maskId) => maskId !== primaryTarget.id && !deletedMaskIds.has(maskId));
|
)).filter((maskId) => maskId !== linkedPrimaryTargetId && !deletedMaskIds.has(maskId));
|
||||||
const secondaryTargets = secondaryTargetIds
|
const secondaryTargets = secondaryTargetIds
|
||||||
.map((maskId) => masks.find((mask) => mask.id === maskId))
|
.map((maskId) => masks.find((mask) => mask.id === maskId))
|
||||||
.filter((mask): mask is Mask => Boolean(mask));
|
.filter((mask): mask is Mask => Boolean(mask));
|
||||||
|
const fallbackPrimaryTarget = effectiveTool === 'area_merge' ? secondaryTargets[0] : undefined;
|
||||||
|
const rawPrimaryTarget = masks.find((mask) => mask.id === linkedPrimaryTargetId) || fallbackPrimaryTarget;
|
||||||
|
if (!rawPrimaryTarget || deletedMaskIds.has(rawPrimaryTarget.id)) return;
|
||||||
|
const usingSecondaryAsPrimary = !linkedPrimaryTargetId && rawPrimaryTarget.id === fallbackPrimaryTarget?.id;
|
||||||
|
const primaryTarget = usingSecondaryAsPrimary
|
||||||
|
? {
|
||||||
|
...rawPrimaryTarget,
|
||||||
|
templateId: primary.templateId ?? rawPrimaryTarget.templateId,
|
||||||
|
classId: primary.classId,
|
||||||
|
className: primary.className,
|
||||||
|
classZIndex: primary.classZIndex,
|
||||||
|
classMaskId: primary.classMaskId,
|
||||||
|
label: primary.label,
|
||||||
|
color: primary.color,
|
||||||
|
}
|
||||||
|
: rawPrimaryTarget;
|
||||||
|
const primaryGeometry = maskToMultiPolygon(primaryTarget);
|
||||||
|
if (!primaryGeometry) return;
|
||||||
const clipGeometries = secondaryTargets
|
const clipGeometries = secondaryTargets
|
||||||
|
.filter((mask) => mask.id !== primaryTarget.id)
|
||||||
.map(maskToMultiPolygon)
|
.map(maskToMultiPolygon)
|
||||||
.filter((geometry): geometry is MultiPolygon => Boolean(geometry));
|
.filter((geometry): geometry is MultiPolygon => Boolean(geometry));
|
||||||
if (clipGeometries.length === 0) return;
|
if (clipGeometries.length === 0 && !usingSecondaryAsPrimary) return;
|
||||||
|
|
||||||
const resultGeometry = effectiveTool === 'area_merge'
|
const resultGeometry = effectiveTool === 'area_merge'
|
||||||
? polygonClipping.union(primaryGeometry, ...clipGeometries)
|
? polygonClipping.union(primaryGeometry, ...clipGeometries)
|
||||||
@@ -1573,7 +1587,9 @@ export function CanvasArea({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (effectiveTool === 'area_merge') {
|
if (effectiveTool === 'area_merge') {
|
||||||
secondaryTargets.forEach((mask) => deletedMaskIds.add(mask.id));
|
secondaryTargets.forEach((mask) => {
|
||||||
|
if (mask.id !== primaryTarget.id) deletedMaskIds.add(mask.id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user