Refactor image processing and cutout functionalities

- Updated the naming convention for upscaled images to include underscores and file extensions.
- Renamed properties related to cutout pixel size to point spacing for clarity.
- Adjusted alpha region calculations to use a half-alpha threshold for better accuracy.
- Enhanced the handling of foreground mesh masks to consider half-alpha values.
- Introduced new functions for unbounded selections and cutout images, ensuring proper handling of source and selection images.
- Improved test coverage for cutout functionalities, including bounds mapping and alpha preservation.
- Updated tests to reflect changes in naming conventions and functionality adjustments.
This commit is contained in:
2026-08-27 12:01:47 +08:00
parent 2b79a342b0
commit 6c8358f92f
27 changed files with 1058 additions and 223 deletions

View File

@@ -10,13 +10,13 @@ AnyImage 是一个 Blender 扩展,可以把一张照片、一段视频或一
- 在 3D View、Node Editor 与绘画模式中按 `Ctrl+V`(macOS 为 `Cmd+V`)直接粘贴并 Pack 剪贴板图片 - 在 3D View、Node Editor 与绘画模式中按 `Ctrl+V`(macOS 为 `Cmd+V`)直接粘贴并 Pack 剪贴板图片
- 不运行 AI,把 Image Empty 转换为可实时调整 Thickness 与 Subdivide 的 Mesh Plane - 不运行 AI,把 Image Empty 转换为可实时调整 Thickness 与 Subdivide 的 Mesh Plane
- 从 Image Empty 套索提取不规则区域,通过 Poisson 或 Teddy / Chordal Axis 生成封闭 Balloon,并可用 BEN2 分析轮廓、MoGe-2 添加可调深度;源 Empty 保持不变 - 从 Image Empty 套索提取不规则区域,通过 Poisson 或 Teddy / Chordal Axis 成成封闭 Balloon,并可用 BEN2 分析轮廓、MoGe-2 添加可调深度;源 Empty 保持不变
- 用 Image Box / Lasso / Line / Polyline 直接清理 Image Empty;按 `F` 反选,也可用 BEN2 细化选区 - 用 Image Box / Lasso / Line / Polyline 直接清理 Image Empty;按 `F` 反选,也可用 BEN2 细化选区
- Image 编辑会直接更新未共享的图片;图片被多个 Image Empty 共用时,只为当前 Empty 分离编辑结果 - Image 编辑会直接更新未共享的图片;图片被多个 Image Empty 共用时,只为当前 Empty 分离编辑结果
- 用 Image Perspective 勾勒四个角点,把透视四边形矫正为矩形裁切图,并沿用源 Image Empty 的空间朝向 - 用 Image Perspective 勾勒四个角点,把透视四边形矫正为矩形裁切图,并沿用源 Image Empty 的空间朝向
- 一键把 Image Empty 转换为立体面片:让 AI 估算深度,或直接用原图作为深度 - 一键把 Image Empty 转换为立体面片:让 AI 估算深度,或直接用原图作为深度
- 把单张 Image Empty 转换为透视纹理网格,并自动匹配原对象的 Transform 与 Offset - 把单张 Image Empty 转换为透视纹理网格,并自动匹配原对象的 Transform 与 Offset
- 一键去除图片背景,生成透明前景图 - 一键去除图片背景,成成透明前景图
- 用三档 ONNX 模型将图片、视频或序列帧逐帧放大 4 倍 - 用三档 ONNX 模型将图片、视频或序列帧逐帧放大 4 倍
- 支持视频与序列帧输入,逐帧处理,动画也能得到逐帧对应的立体面片 - 支持视频与序列帧输入,逐帧处理,动画也能得到逐帧对应的立体面片
- 主要转换操作集中在右键菜单,剪贴板图片可直接用系统粘贴快捷键导入 - 主要转换操作集中在右键菜单,剪贴板图片可直接用系统粘贴快捷键导入
@@ -46,31 +46,31 @@ Environment Bootstrap 使用连续的 0–100%:uv 为 1–20%、Python 为 20
### 转换为普通图片平面 ### 转换为普通图片平面
选中 Image Empty 后,右键选择 `AnyImage > Convert to Mesh Plane`。该操作不运行 AI,会创建带图片材质和 `O Mesh Plane` Geometry Nodes 修改器的基础四边形。`Thickness` 与 `Subdivide` 都在修改器中实时生成;`Subdivide` 使用二分级别(`3` 为短边 `8` 段),`Thickness = 0` 时结果为真正的单面。操作会立即执行;创建后可在 Last Operation 中调整 `Shadeless` 和 `Keep Original`,调整时保持源图片的世界位置、旋转与缩放。完整说明见 [Mesh Plane](docs/mesh-plane.md)。 选中 Image Empty 后,右键选择 `AnyImage > Convert to Mesh Plane`。该操作不运行 AI,会创建带图片材质和 `O Mesh Plane` Geometry Nodes 修改器的基础四边形。`Thickness` 与 `Subdivide` 都在修改器中实时成成;`Subdivide` 使用二分级别(`3` 为短边 `8` 段),`Thickness = 0` 时结果为真正的单面。操作会立即执行;创建后可在 Last Operation 中调整 `Shadeless` 和 `Keep Original`,调整时保持源图片的世界位置、旋转与缩放。完整说明见 [Mesh Plane](docs/mesh-plane.md)。
### 转换为立体面片 ### 转换为立体面片
1. 在 3D 视图中选中一个 Image Empty(空物体,显示方式为 Image) 1. 在 3D 视图中选中一个 Image Empty(空物体,显示方式为 Image)
2. 右键打开 `AnyImage` 子菜单,选择 `Convert to Depth Plane` 2. 右键打开 `AnyImage` 子菜单,选择 `Convert to Depth Plane`
3. 在对话框中选择: 3. 在对话框中选择:
- `Depth from`:`Original` 直接用原图,`DA3` 使用现有 DA3 流程,`MoGe-2` 生成 metric depth、normal 与有效区域 mask - `Depth from`:`Original` 直接用原图,`DA3` 使用现有 DA3 流程,`MoGe-2` 成成 metric depth、normal 与有效区域 mask
- `Resolution Scale` 使用 `1–100%`,按原图长边计算 Process Res;结果会限制在 `252–1008` 并自动对齐到 14 的倍数 - `Resolution Scale` 使用 `1–100%`,按原图长边计算 Process Res;结果会限制在 `252–1008` 并自动对齐到 14 的倍数
- `Remove Background` 默认关闭;开启后在转换期间使用 BEN2 删除背景 - `Remove Background` 默认关闭;开启后在转换期间使用 BEN2 删除背景
- `Shadeless` 使用 `O Shadeless` 节点组显示不受场景灯光影响的贴图颜色 - `Shadeless` 使用 `O Shadeless` 节点组显示不受场景灯光影响的贴图色色
- `Keep Original` 默认关闭,位于分割线下方;开启后保留源 Image Empty - `Keep Original` 默认关闭,位于分割线下方;开启后保留源 Image Empty
4. 点击确认,稍等片刻就得到带位移的立体面片。`O DA3 Depth Plane` 与 `O MoGe2 Depth Plane` 都使用颜色图片的 `Alpha > 0.5` 划分前景范围,范围外由 `Background Depth` 控制。 4. 点击确认,稍等片刻就得到带位移的立体面片。`O DA3 Depth Plane` 与 `O MoGe2 Depth Plane` 都使用色色图片的 `Alpha > 0.5` 划分前景范围,范围外由 `Background Depth` 控制。
### 编辑图片选区 ### 编辑图片选区
在 3D Viewport 左侧工具栏选择 Image Mask 工具组。Box 拖出矩形,Lasso 手绘轮廓,Line 清理有向直线的一侧,Polyline 逐点构建多边形。点击另一张 Image Empty 时只切换选择;点击当前 Active 图片、空白处或非图片物体时,才开始编辑当前图片。没有 Active 图片时会提示先选择 Image Empty,切换其他工具会取消当前绘制。 在 3D Viewport 左侧工具栏选择 Image Mask 工具组。Box 拖出矩形,Lasso 手绘轮廓,Line 清理有向直线的一侧,Polyline 逐点构建多边形。点击另一张 Image Empty 时只切换选择;点击当前 Active 图片、空白处或非图片物体时,才开始编辑当前图片。没有 Active 图片时会提示先选择 Image Empty,切换其他工具会取消当前绘制。
绘制中按 `F` 切换反选:默认保留选区内,反选后保留选区外。Line 按住 `Ctrl` 以 5° 吸附;Polyline 可单击起点圆环或按 `Enter` 完成。`Refine Selection` 只用于 Box / Lasso:先用 BEN2 将几何选区收紧到其中的前景,再应用反选。结果直接替换当前 Empty 的图片,按最终非零 Alpha 紧裁切,并更新显示尺寸与 Image Offset;对象 Transform 不变。透明区保留源 RGB,避免纹理过滤产生黑边。未形成有效区域的单击会静默结束;有效选区没有可见像素时只显示警告,不修改图片。 Lasso 绘制时与 Box、Line、Polyline 一样显示白色虚线轮廓和淡灰半透明填充;凹形选区不显示内部投射线,自交选区则按最终操作的填充规则显示交叠区域。Cutout 预览与最终几何都使用非零环绕规则:同方向重叠保留,反方向抵消成孔。Lasso 只有在鼠标移动至少 2 px 时才采点,停留不会持续增加点数;近似共线的连续点会在线合并,填充和虚线段也只在轨迹变化时重算。绘制中按 `F` 切换反选:默认保留选区内,反选后保留选区外。Line 按住 `Ctrl` 以 5° 吸附;Polyline 可单击起点圆环或按 `Enter` 完成。`Refine Selection` 只用于 Box / Lasso:先用 BEN2 将几何选区收紧到其中的前景,再应用反选。结果直接替换当前 Empty 的图片,按最终非零 Alpha 紧裁切,并更新显示尺寸与 Image Offset;对象 Transform 不变。透明区保留源 RGB,避免纹理过滤产成黑边。未形成有效区域的单击会静默结束;有效选区没有可见像素时只显示警告,不修改图片。
### 四点透视矫正 ### 四点透视矫正
`Image Perspective` 是 Image Mask 工具组之后的独立工具,位于该组与 Balloon Lasso 之间。四个角可以按任意顺序点击,完成后会在源图片坐标中自动求出不交错的凸四边形,并以原图 X/Y 方向确定输出旋转。绘制时显示白色虚线和半透明选区面,`Backspace` 删除最近一个角点,右键或 `Esc` 取消;切换其他工具也会立即退出。首次点击遵循同一选择规则。Perspective 不依赖当前 View 的相机内参,因此透视和正交 View 都可以使用。 `Image Perspective` 是 Image Mask 工具组之后的独立工具,位于该组与 Balloon Lasso 之间。四个角可以按任意顺序点击,完成后会在源图片坐标中自动求出不交错的凸四边形,并以原图 X/Y 方向确定输出旋转。绘制时显示白色虚线和半透明选区面,`Backspace` 删除最近一个角点,右键或 `Esc` 取消;切换其他工具也会立即退出。首次点击遵循同一选择规则。Perspective 不依赖当前 View 的相机内参,因此透视和正交 View 都可以使用。
第四个角点按下时会在 Viewport 中央显示带蓝色边框的矫正图片预览,中央显示当前比例,下方显示鼠标操作提示;鼠标同时移到 Viewport 中心,为左右两个方向保留相同的调节空间。水平移动连续调整宽高比,按住 `Ctrl` 吸附到 `1:1`、`4:3`、`3:4`、`16:9`、`9:16`,下一次左键确认并创建;`Backspace` 返回第三点,右键或 `Esc` 取消。初始比例取四边形两组对边的平均长度比。预览使用一次生成的 512 × 512 临时图片,移动鼠标时只改变 UI 显示比例。 第四个角点按下时会在 Viewport 中央显示带蓝色边框的矫正图片预览,中央显示当前比例,下方显示鼠标操作提示;鼠标同时移到 Viewport 中心,为左右两个方向保留相同的调节空间。水平移动连续调整宽高比,按住 `Ctrl` 吸附到 `1:1`、`4:3`、`3:4`、`16:9`、`9:16`,下一次左键确认并创建;`Backspace` 返回第三点,右键或 `Esc` 取消。初始比例取四边形两组对边的平均长度比。预览使用一次成成的 512 × 512 临时图片,移动鼠标时只改变 UI 显示比例。
确认后,工具把选区透视矫正为矩形图片,按最终非零 Alpha 范围裁切并 Pack 到 `.blend`。结果 Empty 保持源图的朝向和深度,以四边形在源图上的 bounding box 近似确定中心与显示范围,不再尝试从当前 View 反解真实空间矩形。四边形完全位于源图范围外,或矫正结果没有可见 Alpha 像素时,只显示警告且不创建结果。输出最长边不超过 8192 px,总像素不超过约 16 MP。 确认后,工具把选区透视矫正为矩形图片,按最终非零 Alpha 范围裁切并 Pack 到 `.blend`。结果 Empty 保持源图的朝向和深度,以四边形在源图上的 bounding box 近似确定中心与显示范围,不再尝试从当前 View 反解真实空间矩形。四边形完全位于源图范围外,或矫正结果没有可见 Alpha 像素时,只显示警告且不创建结果。输出最长边不超过 8192 px,总像素不超过约 16 MP。
@@ -80,25 +80,25 @@ Image Perspective 是 deform 工具,不提供 Refine 或反选。它与其他
### Cutout 基础面与四种形状 ### Cutout 基础面与四种形状
在 Object Mode 的 3D Viewport 左侧工具栏选择 `Cutout`,沿 Image Empty 绘制后可从四个方向选择 Cutout、Balloon、Depth Balloon 或 Depth Surface;双击会选择完整图片。`Pixel Size` 控制规则三角格点密度。未启用 `Refine Selection` 时,几何只跟随套索,忽略原图 Alpha;启用后才使用 BEN2 Alpha 收紧几何选区。自交轨迹按非零环绕规则填充:同方向的重叠区域保留,只有反方向抵消的内环形成孔洞。未 Refine 的普通选区可以越过图片边框继续生成网格,但必须与原图可见像素相交;启用 Refine 后,框外部分会随最终 AI Alpha 被裁掉。基础面内部使用均匀六角格点,平滑轮廓作为约束边界;小选区会自动缩短边界采样间距,避免在较大的 `Pixel Size` 下被误判为空,同时不增加内部格点密度。Cutout 的 Thickness 以场景距离沿图片平面法线对称加厚,前盖、背面和轮廓侧壁均保持朝外。套索和 Refine Alpha 只决定几何轮廓;四种结果的材质颜色使用当前 Empty Image 的未裁切副本,深度与法线也分析完整图片,因此选区抗锯齿不会再次降低材质边缘 Alpha。若当前图片来自 Upscale,AI 分析会回溯到放大前的原图,颜色仍保留高清版本。未启用 Refine 且关闭 `Normal` 时,Cutout 与 Balloon 会直接在 Blender 内完成遮罩、轮廓提取和建面,不启动 BlendJob。`Normal` 默认关闭;启用后才通过 MoGe-2 生成 normal 纹理,并改用 Job。Depth Surface 直接使用 MoGe-2 原生的 object-space normal;Cutout、Balloon 与 Depth Balloon 会依据成品网格的平滑 TBN 另行烘制像素内容不同的 tangent-space normal。启用 Refine 时,Job 提供 BEN2 修正后的几何 Alpha;轮廓提取与建面仍由 Blender 完成。Depth Balloon 与 Depth Surface 因需要深度分析继续使用 Job,是否额外写入 normal 同样由 `Normal` 控制。 在 Object Mode 的 3D Viewport 左侧工具栏选择 `Cutout`,沿 Image Empty 绘制后可从四个方向选择 Cutout、Balloon、Depth Balloon 或 Depth Surface;双击会选择完整图片。`Point Spacing` 以原图像素为单位控制规则三角格点间距,裁切范围大小不会改变密度。未启用 `Refine Selection` 时,几何只跟随套索,忽略原图 Alpha;即使启用 `Normal` 并改走 Job,图片边框外的选区也会保留。启用 Refine 后才使用 BEN2 Alpha 收紧几何选区,框外部分会随最终 AI Alpha 被裁掉。自交轨迹按非零环绕规则填充:同方向的重叠区域保留,只有反方向抵消的内环形成孔洞。未 Refine 的普通选区必须与原图可见像素相交。基础面内部使用均匀六角格点,平滑轮廓作为约束边界;小选区会自动缩短边界采样间距,避免在较大的 `Point Spacing` 下被误判为空,同时不增加内部格点密度。Cutout 的 Thickness 以场景距离沿图片平面法线对称加厚,前盖、背面和轮廓侧壁均保持朝外。套索或 Refine 的最终 Alpha 会确定紧凑矩形裁剪范围和几何轮廓,也用于构造 MoGe-2 局部分析图:选区内保留当前图片的原始 RGB 与有效 Alpha,图片边框外和选区外 RGBA 全部置零。材质 Color 使用同一裁剪范围内未乘选区 Alpha 的当前图片像素,只保留图片原有 Alpha;因此不会因几何 mask 再次产生透明边。当前图片若为 Upscale 结果,颜色、深度和法线都基于高清图的所选区域。裁剪后的 Color 与 normal 可以是非正方形,网格位置和 UV 会映射回原 Image Empty 中对应区域。几何只保留最终 Alpha 大于等于 `0.5` 的区域,边缘低置信度面会被剔除。未启用 Refine 且关闭 `Normal` 时,Cutout 与 Balloon 会直接在 Blender 内完成遮罩、轮廓提取和建面,不启动 BlendJob。`Normal` 默认关闭;启用后才通过 MoGe-2 成成 normal 纹理,并改用 Job。Depth Surface 直接使用 MoGe-2 原成的 object-space normal;Cutout、Balloon 与 Depth Balloon 会依据成品网格的平滑 TBN 另行烘制 tangent-space normal。
完整说明见 [Cutout](docs/cutout.md)。 完整说明见 [Cutout](docs/cutout.md)。
`O Image Depth Surface` 将 `O Smooth` 的 Repeat Zone、双域边界采样和法线权重节点直接展开在主节点图中,并固定按 `Pin Sharp = 1`、`Pin Boundary = 1` 计算,不暴露这两个参数;`Iterations` 默认 `1`、最小可设为 `0`。`Depth Continuity` 默认 `0`,采用最宽松的断层阈值。Thickness 保持正面的深度表面不动,只向背面挤出。 `O Image Depth Surface` 将 `O Smooth` 的 Repeat Zone、双域边界采样和法线权重节点直接展开在主节点图中,并固定按 `Pin Sharp = 1`、`Pin Boundary = 1` 计算,不暴露这两个参数;`Iterations` 默认 `1`、最小可设为 `0`。`Depth Continuity` 默认 `0`,采用最宽松的断层阈值。Thickness 保持正面的深度表面不动,只向背面挤出。
### 套索生成 Balloon ### 套索成成 Balloon
选中单张图片的 Image Empty,右键打开 `AnyImage` 子菜单并选择 `Balloon Lasso Tool`。Blender 会激活 3D Viewport 左侧工具栏底部、位于独立 Image Perspective 之后的 Balloon Lasso;Image Mask 工具组与 Add Cube 之间保留一个间隔。Balloon 使用 Poly Build 的绿色图标、Annotate 同款铅笔光标,Tool Header 与状态栏共同提示当前工具状态,不再用黄色外框或强制修改 Shading、Overlay。Blender 的公开 Workspace Tool API 不支持按 Active Object 动态隐藏工具,因此图标在 Object Mode 中保持可见,但只有 Active Object 是 Image Empty 时才能启动 Stroke。 选中单张图片的 Image Empty,右键打开 `AnyImage` 子菜单并选择 `Balloon Lasso Tool`。Blender 会激活 3D Viewport 左侧工具栏底部、位于独立 Image Perspective 之后的 Balloon Lasso;Image Mask 工具组与 Add Cube 之间保留一个间隔。Balloon 使用 Poly Build 的绿色图标、Annotate 同款铅笔光标,Tool Header 与状态栏共同提示当前工具状态,不再用黄色外框或强制修改 Shading、Overlay。Blender 的公开 Workspace Tool API 不支持按 Active Object 动态隐藏工具,因此图标在 Object Mode 中保持可见,但只有 Active Object 是 Image Empty 时才能启动 Stroke。
按住左键沿目标轮廓绘制,松开后生成一个带原图 UV 的封闭网格。每次拖动都是一个独立完成的顶层 `Extract Balloon` Operator,工具本身继续保持激活,下一次拖动会创建另一个 Balloon;因此 `Ctrl+Z` 只撤销最近一次生成,`Ctrl+Shift+Z` 重做。每次生成后源 Image Empty 都保持选中和 Active。未绘制时没有持续 Modal 拦截事件,视图导航和其他原生快捷键由 Blender 正常处理;绘制中可用右键或 `Esc` 取消当前轨迹。选择左侧工具栏中的其他工具即可离开 Balloon Lasso。 按住左键沿目标轮廓绘制,松开后成成一个带原图 UV 的封闭网格。每次拖动都是一个独立完成的顶层 `Extract Balloon` Operator,工具本身继续保持激活,下一次拖动会创建另一个 Balloon;因此 `Ctrl+Z` 只撤销最近一次成成,`Ctrl+Shift+Z` 重做。每次成成后源 Image Empty 都保持选中和 Active。未绘制时没有持续 Modal 拦截事件,视图导航和其他原成快捷键由 Blender 正常处理;绘制中可用右键或 `Esc` 取消当前轨迹。选择左侧工具栏中的其他工具即可离开 Balloon Lasso。
- `Mode`、`Thickness`、`Resolution (m)`、`Reuse Material`、`Contour`、`Depth` 和启用 `Depth` 后出现的 `Depth Model` 显示在 Tool Header / Tool Settings - `Mode`、`Thickness`、`Resolution (m)`、`Reuse Material`、`Contour`、`Depth` 和启用 `Depth` 后出现的 `Depth Model` 显示在 Tool Header / Tool Settings
- `S`:调用 Blender 原生 Radial Control 调节 Thickness;通过 `zoom_path` 把默认 `1.0` 映射为约 100 px 的交互圆,软上限为 `5.0`,不设硬上限 - `S`:调用 Blender 原成 Radial Control 调节 Thickness;通过 `zoom_path` 把默认 `1.0` 映射为约 100 px 的交互圆,软上限为 `5.0`,不设硬上限
- `D`:调用 Blender 原生 Radial Control 调节 Resolution;通过独立 `zoom_path` 把默认 `0.1 m` 映射为约 100 px 的交互圆,数值越小网格越密 - `D`:调用 Blender 原成 Radial Control 调节 Resolution;通过独立 `zoom_path` 把默认 `0.1 m` 映射为约 100 px 的交互圆,数值越小网格越密
`Resolution (m)` 会根据 Scene Unit Scale 换算为网格使用的世界距离,因此不同单位设置下仍代表相同的物理采样间距。Poisson 模式使用尺度无关的求解判断,支持 `0.02 m` 等较密采样。两个 AI 选项默认关闭;`Contour` 使用 BEN2 Alpha 严格大于 `0.5` 的最大连通前景重建轮廓,`Depth` 使用所选 MoGe-2 模型。MoGe 的物理距离会先减去稳健拟合的基面,再换算为不受绝对拍摄距离影响的相对起伏;近侧与远侧信号都保留,不削顶、不截平。 `Resolution (m)` 会根据 Scene Unit Scale 换算为网格使用的世界距离,因此不同单位设置下仍代表相同的物理采样间距。Poisson 模式使用尺度无关的求解判断,支持 `0.02 m` 等较密采样。两个 AI 选项默认关闭;`Contour` 使用 BEN2 Alpha 严格大于 `0.5` 的最大连通前景重建轮廓,`Depth` 使用所选 MoGe-2 模型。MoGe 的物理距离会先减去稳健拟合的基面,再换算为不受绝对拍摄距离影响的相对起伏;近侧与远侧信号都保留,不削顶、不截平。
Depth Balloon 使用局部 X 作为深度轴,并创建 `<Balloon> Base Plane` Single Arrow Empty。Empty 的旋转定义基面朝向,沿自身局部 Z 移动则调整 middle level。默认深度校准为 `修正深度 = (0.5 × 相对基面深度 + 原始 Balloon 最大半厚度) × Depth Strength`;原始最大半厚度来自生成后的固定网格,不受修改器 `Thickness` 影响。这在 `Thickness = 1` 时等价于旧参数的 `Depth Strength = 0.5` 和两倍半厚度基面偏移。节点再以 Smoother Step 中心程度插值修正厚度与修正深度;`Thickness` 只控制厚度分支,`Depth Strength` 只控制深度分支。`Double Sided Depth` 默认关闭,使背面维持稳定厚度。 Depth Balloon 使用局部 X 作为深度轴,并创建 `<Balloon> Base Plane` Single Arrow Empty。Empty 的旋转定义基面朝向,沿自身局部 Z 移动则调整 middle level。默认深度校准为 `修正深度 = (0.5 × 相对基面深度 + 原始 Balloon 最大半厚度) × Depth Strength`;原始最大半厚度来自成成后的固定网格,不受修改器 `Thickness` 影响。这在 `Thickness = 1` 时等价于旧参数的 `Depth Strength = 0.5` 和两倍半厚度基面偏移。节点再以 Smoother Step 中心程度插值修正厚度与修正深度;`Thickness` 只控制厚度分支,`Depth Strength` 只控制深度分支。`Double Sided Depth` 默认关闭,使背面维持稳定厚度。
MoGe normal 会转换成适配 Balloon UV 的 tangent-space `normal.png`,由统一的 `O Image Layer` 材质补回网格分辨率未能表达的细节;`Normal Scale` 默认 `1`,`Bump Scale` 默认 `0`,未连接 normal 时使用 `[0.5, 0.5, 1.0, 1.0]` 中性值。Balloon 材质通过 Custom Properties 直接保存源 Image datablock 引用,不依赖材质名或节点组名识别;勾选 `Reuse Material` 时优先复用同一 Image、同一材质类型的标记材质。Depth Balloon 共用一张原图尺寸的 normal,后续套索会把自己的有效区域合并进去,重叠区域采用最新结果。旧材质仍保留节点识别回退。当前只支持静态图片。 MoGe normal 会转换成适配 Balloon UV 的 tangent-space `normal.png`,由统一的 `O Image Layer` 材质补回网格分辨率未能表达的细节;`Normal Scale` 默认 `1`,`Bump Scale` 默认 `0`,未连接 normal 时使用 `[0.5, 0.5, 1.0, 1.0]` 中性值。Balloon 材质通过 Custom Properties 直接保存源 Image datablock 引用,不依赖材质名或节点组名识别;勾选 `Reuse Material` 时优先复用同一 Image、同一材质类型的标记材质。Depth Balloon 共用一张原图尺寸的 normal,后续套索会把自己的有效区域合并进去,重叠区域采用最新结果。旧材质仍保留节点识别回退。当前只支持静态图片。
@@ -122,21 +122,21 @@ ViT-S、ViT-B、ViT-L 或 DA3 Small、Base,其中 MoGe-2 ViT-S 为默认模型
Surface 输出流程构建 DA3 与 MoGe-2 源网格:MoGe-2 使用深度断层 mask,DA3 保留 confidence 与边拉伸 Surface 输出流程构建 DA3 与 MoGe-2 源网格:MoGe-2 使用深度断层 mask,DA3 保留 confidence 与边拉伸
数据。两者再由独立的 Geometry Nodes 数据。两者再由独立的 Geometry Nodes
修改器完成剔面、快速重拓扑、Relax 与 UV 重投;顶层 `Iterations` 可设为 `0`。开启 Remove Background 修改器完成剔面、快速重拓扑、Relax 与 UV 重投;顶层 `Iterations` 可设为 `0`。开启 Remove Background
时,BEN2 Alpha 只要大于 `0` 就视为前景;三角形三个顶点必须全部为前景,任一顶点 Alpha 为 `0` 时,BEN2 Alpha 大于等于 `0.5` 才视为前景;三角形三个顶点必须全部为前景,任一顶点 Alpha 小于 `0.5`
都会删除该面并且不再交给 Geometry Nodes;Surface 材质使用生成的颜色图片。匹配相机默认不创建;开启后会作为 Surface 的子级。 都会删除该面并且不再交给 Geometry Nodes;Surface 材质使用成成的色色图片。匹配相机默认不创建;开启后会作为 Surface 的子级。
MoGe-2 的像素级 normal 会作为 object-space normal 贴图接入 `O Image Layer`,并与原图 MoGe-2 的像素级 normal 会作为 object-space normal 贴图接入 `O Image Layer`,并与原图
Bump 合并;网格本身不写入 custom normal,因此重拓扑降低顶点数后仍能保留细腻的法线细节。 Bump 合并;网格本身不写入 custom normal,因此重拓扑降低顶点数后仍能保留细腻的法线细节。
当前此功能仅支持单张图片。 当前此功能仅支持单张图片。
MoGe-2 ViT-S、ViT-B、ViT-L、DA3 Small、DA3 Base、BEN2 与 Upscale 使用的 Real-ESRGAN General WDN x4v3、SAFMN Real x4、Real-ESRGAN x4plus 全部使用 ONNX。模型执行、相机与深度恢复、 MoGe-2 ViT-S、ViT-B、ViT-L、DA3 Small、DA3 Base、BEN2 与 Upscale 使用的 Real-ESRGAN General WDN x4v3、SAFMN Real x4、Real-ESRGAN x4plus 全部使用 ONNX。模型执行、相机与深度恢复、
点云重投影和网格生成均不依赖 PyTorch。Windows 使用 DirectML 加速;Apple M4 或更新芯片可使用 CoreML 的 `MLProgram` 模型格式,较早或无法识别的 Mac 只提供 CPU;provider 不可用时会回退到 ONNX Runtime CPU, 点云重投影和网格成成均不依赖 PyTorch。Windows 使用 DirectML 加速;Apple M4 或更新芯片可使用 CoreML 的 `MLProgram` 模型格式,较早或无法识别的 Mac 只提供 CPU;provider 不可用时会回退到 ONNX Runtime CPU,
并在任务状态中显示实际 provider。 并在任务状态中显示实际 provider。
转换对话框提供模型与 `Create Matching Camera`、`Remove Background`、`Shadeless`;DA3 显示 转换对话框提供模型与 `Create Matching Camera`、`Remove Background`、`Shadeless`;DA3 显示
`Resolution Scale`,MoGe-2 显示 `Resolution Level`, `Resolution Scale`,MoGe-2 显示 `Resolution Level`,
以及分割线下方的 `Keep Original`。 以及分割线下方的 `Keep Original`。
界面会显示计算所得的实际 Process Res。剔除和重拓扑参数直接在 界面会显示计算所得的实际 Process Res。剔除和重拓扑参数直接在
Geometry Nodes 修改器中调整;`Shadeless` 与 Depth Plane 共用 `O Shadeless` 材质节点组。颜色贴图始终使用统一的图片转换产物;Remove Background 只决定是否由 BEN2 修改 Alpha。颜色贴图会按当前场景的 View Transform 选择对应输入色彩空间:AgX、ACES 1.3、ACES 2.0、Filmic、Khronos PBR Neutral 分别使用 `AgX Base sRGB`、`ACES 1.3 sRGB`、`ACES 2.0 sRGB`、`Filmic sRGB`、`Khronos PBR Neutral sRGB`;无法匹配当前 View Transform 时回退 `sRGB`。普通材质的 Geometry Nodes 修改器中调整;`Shadeless` 与 Depth Plane 共用 `O Shadeless` 材质节点组。色色贴图始终使用统一的图片转换产物;Remove Background 只决定是否由 BEN2 修改 Alpha。色色贴图会按当前场景的 View Transform 选择对应输入色彩空间:AgX、ACES 1.3、ACES 2.0、Filmic、Khronos PBR Neutral 分别使用 `AgX Base sRGB`、`ACES 1.3 sRGB`、`ACES 2.0 sRGB`、`Filmic sRGB`、`Khronos PBR Neutral sRGB`;无法匹配当前 View Transform 时回退 `sRGB`。普通材质的
Bump Scale 会在材质组内乘以 `0.001`,并按透视表面的深度空间缩放。Surface 与 Depth Plane 的 Bump Scale 会在材质组内乘以 `0.001`,并按透视表面的深度空间缩放。Surface 与 Depth Plane 的
Layer Color 同时驱动 Principled BSDF 的 Base Color、Subsurface Radius 和 Emission Color,Layer Normal Layer Color 同时驱动 Principled BSDF 的 Base Color、Subsurface Radius 和 Emission Color,Layer Normal
同时驱动 Normal 与 Coat Normal;Subsurface、Emission、Coat 的其他设置保持默认。Surface attributes 同时驱动 Normal 与 Coat Normal;Subsurface、Emission、Coat 的其他设置保持默认。Surface attributes
@@ -145,16 +145,18 @@ Layer Color 同时驱动 Principled BSDF 的 Base Color、Subsurface Radius 和
### 视频与序列帧 ### 视频与序列帧
AnyImage 支持视频(mp4、mkv、mov 等)和序列帧,会逐帧处理并生成逐帧对应的立体面片。颜色图片与 `.depth` 按源 Empty Image 的类型载入:普通单图为 `IMAGE`,Movie/Sequence 即使只处理一帧也仍为 `SEQUENCE`。处理范围默认 生成的颜色、深度和法线 Image datablock 分别使用 `_color.png`、`_depth.exr`、`_normal.png` 和 `_upscale.png` 后缀,让 Blender 保持对真实图片格式的识别。
AnyImage 支持视频(mp4、mkv、mov 等)和序列帧,会逐帧处理并成成逐帧对应的立体面片。色色图片与 `.depth` 按源 Empty Image 的类型载入:普通单图为 `IMAGE`,Movie/Sequence 即使只处理一帧也仍为 `SEQUENCE`。处理范围默认
跟随 Empty 上设置的 `Image Frames` 与 `Offset`,不需要额外配置。DA3 的转换任务复用同一个 ONNX 跟随 Empty 上设置的 `Image Frames` 与 `Offset`,不需要额外配置。DA3 的转换任务复用同一个 ONNX
Session 逐帧推理,显存峰值不随视频帧数平方增长。Upscale 也会逐帧生成受输出上限约束的 4× 颜色序列并写回 Empty。Debug 只保存模型 API 的直接返回:DA3 多帧输入 Session 逐帧推理,显存峰值不随视频帧数平方增长。Upscale 也会逐帧成成受输出上限约束的 4× 色色序列并写回 Empty。Debug 只保存模型 API 的直接返回:DA3 多帧输入
保留联合推理,MoGe-2、BEN2 与三档高清模型按各自的单图 API 逐帧保存。高清 Debug 输出 4× RGB `prediction*.png`,不做强度混合或图片替换;三个模型都由适配层自动分块,因此接受任意图片宽高。 保留联合推理,MoGe-2、BEN2 与三档高清模型按各自的单图 API 逐帧保存。高清 Debug 输出 4× RGB `prediction*.png`,不做强度混合或图片替换;三个模型都由适配层自动分块,因此接受任意图片宽高。
## 任务结果保存在哪里 ## 任务结果保存在哪里
任务结果保存在 Runtime Storage Root 的 `jobs/` 下,默认是 `~/.anyimage/jobs/`。Job Server 接受任务时自动生成 opaque Job ID,并创建同名文件夹;Blender 不预先申请 ID 或目录。Packed Image 和序列帧的临时输入由 Blender 在任务结束后清理,Server 产物直接写入该 Job 目录。普通图片层使用 `image.0001.*` 起的文件名,导入后沿用源 Image 名称并由 Blender 自动添加 `.001`;Upscale 单独使用 `upscale.0001.png` 和 `.upscale` 数据块后缀。视频或序列帧生成的面片会引用其中的序列帧,请保留对应文件夹,不要随意删除。 任务结果保存在 Runtime Storage Root 的 `jobs/` 下,默认是 `~/.anyimage/jobs/`。Job Server 接受任务时自动成成 opaque Job ID,并创建同名文件夹;Blender 不预先申请 ID 或目录。Packed Image 和序列帧的临时输入由 Blender 在任务结束后清理,Server 产物直接写入该 Job 目录。普通图片层使用 `image.0001.*` 起的文件名,导入后沿用源 Image 名称并由 Blender 自动添加 `.001`;Upscale 单独使用 `upscale.0001.png` 和 `_upscale.png` 数据块后缀。视频或序列帧成成的面片会引用其中的序列帧,请保留对应文件夹,不要随意删除。
每个任务目录中的 `job.log` 保存任务细节和完整 traceback;Storage Root 下的 `server.log` 只保留带时间、Job ID 与 Job Type 的生命周期主线,以及 Server 自身异常。 每个任务目录中的 `job.log` 保存任务细节和完整 traceback;Storage Root 下的 `server.log` 只保留带时间、Job ID 与 Job Type 的成命周期主线,以及 Server 自身异常。
## 常见问题 ## 常见问题

View File

@@ -10,7 +10,7 @@ Cutout 用套索从 Image Empty 中选择范围,再从四个方向选择生成
## 基础面密度 ## 基础面密度
**Pixel Size** 表示基础面相邻采样点大约对应多少源图片像素,可设为 `4`–`32`,默认 `32`。数值越小,规则三角格点越密,后续形状能表达的细节越多,同时网格点数和约束三角化成本也越高。最长边的采样级别限制在 `4`–`1024`。 **Point Spacing** 表示基础面相邻采样点大约对应多少源图片像素,可设为 `4`–`32`,默认 `32`。数值越小,规则三角格点越密,后续形状能表达的细节越多,同时网格点数和约束三角化成本也越高。点间距始终根据完整源图和 Image Empty 尺寸换算;局部裁切只改变位置、UV 与贴图范围,不会重新归一化网格密度。最长边的采样级别限制在 `4`–`1024`。
基础面先从最终 Alpha 提取外边界与孔洞,再以接近等边三角形的六角格点填充内部。轮廓按目标间距重采样和平滑,边界点仍作为约束参与 Delaunay,因此内部点保持均匀,外缘不会退化成规则栅格的阶梯锯齿。 基础面先从最终 Alpha 提取外边界与孔洞,再以接近等边三角形的六角格点填充内部。轮廓按目标间距重采样和平滑,边界点仍作为约束参与 Delaunay,因此内部点保持均匀,外缘不会退化成规则栅格的阶梯锯齿。
@@ -22,9 +22,9 @@ Depth Surface 的 `O Image Depth Surface` 按“深度 → 断层剔除 → 向
## 计算说明 ## 计算说明
未启用 **Refine Selection** 时,几何范围只使用抗锯齿套索 mask,原图 Alpha 不参与轮廓;原图 Alpha 只用于确认选区内至少存在可见像素,并继续控制材质透明度。套索或 Refine 产生的 Alpha 只用于几何,不再写进材质颜色贴图:四种结果的 Color 都使用当前 Image Empty 的未裁切 Image 副本,避免选区边缘 Alpha 被材质再次相乘,同时不与源数据块共享可变状态。深度和法线分析同样读取完整图片;若当前图片来自 Upscale,AI 分析读取放大前的原图,材质颜色仍使用当前高清图。Depth Balloon 在整图推理后才用按分析分辨率缩放的套索 mask 限定有效深度。关闭 **Normal** 时,Cutout 与 Balloon 直接在 Blender 内处理,不准备临时输入、不启动 BlendJob;启用 **Normal** 后才提交 Job。MoGe 返回的相机轴法线先按源 Image Empty 的对象坐标编码。Depth Surface 原样保留这张 object-space 贴图;Cutout、Balloon 与 Depth Balloon 则在最终几何节点结果可用后,使用成品网格的 UV、平滑角法线和切线基底重新烘成像素内容不同的 tangent-space 贴图。启用 Refine 时,Job 先计算 BEN2 Alpha 并返回几何 mask,此时几何才从最终 Alpha 提取轮廓。Depth 类型始终通过 Job 取得深度,但也只在启用 **Normal** 时写入 normal。四种形状的网格都在 Blender 主线程构建,不再通过 `contour.json` 传递边界。 未启用 **Refine Selection** 时,几何范围只使用抗锯齿套索 mask,原图 Alpha 不参与轮廓;原图 Alpha 只用于确认选区内至少存在可见像素,并继续控制材质透明度。无论是否启用 **Normal**,Job 都会为真实出框的套索建立可带负坐标的扩展选区画布,因此图片边框外的几何不会被原图尺寸裁掉。套索或 Refine 的最终 Alpha 会确定紧凑矩形裁剪范围,并以 `0.5` 为几何边界:小于 `0.5` 的区域不建面。Color 贴图使用当前 Image Empty 在同一矩形内的原始像素,不乘套索或 Refine Alpha,只保留图片本身已有的 Alpha,图片外像素保持透明。深度和法线分析使用裁剪后的局部分析图:最终选区内保留当前图片的原始 RGB 与有效 Alpha,图片外和选区外 RGBA 全部置零。若当前图片来自 Upscale,材质、深度和法线都直接使用高清图所选区域,不再回溯放大前原图。Color 与 normal 允许保持裁剪后的非正方形尺寸;Blender 按裁剪 bounds 调整局部平面范围和 UV,使结果仍落在源 Image Empty 的原位置。关闭 **Normal** 时,Cutout 与 Balloon 直接在 Blender 内处理,不准备临时输入、不启动 BlendJob;启用 **Normal** 后才提交 Job。MoGe 返回的相机轴法线先按源 Image Empty 的对象坐标编码。Depth Surface 原样保留这张 object-space 贴图;Cutout、Balloon 与 Depth Balloon 则在最终几何节点结果可用后,使用成品网格的 UV、平滑角法线和切线基底重新烘成 tangent-space 贴图。启用 Refine 时,Job 先计算 BEN2 Alpha 并返回裁剪后的几何 mask。Depth 类型始终通过 Job 取得深度,但也只在启用 **Normal** 时写入 normal。四种形状的网格都在 Blender 主线程构建,不再通过 `contour.json` 传递边界。
最终 Alpha 先由 NumPy 批量找出四向边界,只沿实际周长追踪轮廓,并按包含关系把孔洞归入外轮廓。未 Refine 时直接复用套索 mask;只有真实出框时才建立扩展几何画布。启用 Refine 后完全使用原图画布内的最终 AI Alpha,所有框外套索区域都会被裁掉。格点准备使用批量数组生成;点内判断按 Y 范围分桶,只比较当前扫描带可能相交的轮廓边;边界安全距离也只检查每条边附近的少量格点。短轮廓会自动使用不大于周长八分之一的边界采样间距,避免有效小区域因当前 `Pixel Size` 较大而消失;内部格点仍严格使用用户设置的 `Pixel Size`。UV 与封边边界使用 Blender `foreach_*` 和 NumPy 批量写入,不逐元素遍历。Pillow 只会加速占比较小的 Alpha 模糊和缩放,因此 Blender 本地路径保持纯 NumPy,不新增 wheel。最终约束 Delaunay 和网格创建仍随实际点数增长,极密网格应按成品需要使用。 最终 Alpha 先由 NumPy 批量找出四向边界,只沿实际周长追踪轮廓,并按包含关系把孔洞归入外轮廓。未 Refine 时直接复用套索 mask;只有真实出框时才建立扩展几何画布。启用 Refine 后完全使用原图画布内的最终 AI Alpha,所有框外套索区域都会被裁掉。格点准备使用批量数组生成;点内判断按 Y 范围分桶,只比较当前扫描带可能相交的轮廓边;边界安全距离也只检查每条边附近的少量格点。短轮廓会自动使用不大于周长八分之一的边界采样间距,避免有效小区域因当前 `Point Spacing` 较大而消失;内部格点仍严格使用用户设置的 `Point Spacing`。UV 与封边边界使用 Blender `foreach_*` 和 NumPy 批量写入,不逐元素遍历。Pillow 只会加速占比较小的 Alpha 模糊和缩放,因此 Blender 本地路径保持纯 NumPy,不新增 wheel。最终约束 Delaunay 和网格创建仍随实际点数增长,极密网格应按成品需要使用。
Cutout 当前只支持静态图片。**Refine Selection**、Depth 与 normal 分析需要已配置的 AI Environment 和对应模型。 Cutout 当前只支持静态图片。**Refine Selection**、Depth 与 normal 分析需要已配置的 AI Environment 和对应模型。

View File

@@ -38,13 +38,13 @@ DA3 和 MoGe-2 都以未经抠图的 RGB 估计深度。启用 Remove Background
转换会创建以下数据: 转换会创建以下数据:
- 与源 Image Empty 对齐的面片 - 与源 Image Empty 对齐的面片
- `.depth` 与沿用源图名称的颜色 Image datablock - 使用 `_depth.exr` 与 `_color.png` 后缀的 Image datablock
- 使用 `O DA3 Depth Plane` 或 `O MoGe2 Depth Plane` 的 Geometry Nodes 修改器 - 使用 `O DA3 Depth Plane` 或 `O MoGe2 Depth Plane` 的 Geometry Nodes 修改器
- 连接颜色、Alpha、Bump 与材质位移的材质 - 连接颜色、Alpha、Bump 与材质位移的材质
Geometry Nodes 提供深度范围、细分、重拓扑、平滑、背景深度和位移强度等控制。深度 Mask 在几何和材质路径中使用相同阈值,以保持轮廓一致。 Geometry Nodes 提供深度范围、细分、重拓扑、平滑、背景深度和位移强度等控制。深度 Mask 在几何和材质路径中使用相同阈值,以保持轮廓一致。
材质与图片名称以源 Image datablock 为基名。对象 Transform、显示尺寸和 Offset 会转换为面片的空间位置与比例。 材质与图片名称以源 Image datablock 为基名。生成贴图将业务后缀放在真实文件扩展名前,避免 Blender 将其误判为源图数据块。对象 Transform、显示尺寸和 Offset 会转换为面片的空间位置与比例。
## 单图与动画文件 ## 单图与动画文件

View File

@@ -35,7 +35,7 @@ Blender 交互层管理 UI、RNA 状态、任务参数和结果导入。Runtime
Mesh Plane、Depth Plane、Surface、Background 和 Balloon 按功能组织。一个功能的入口、请求和 Blender 回写放在一起;跨功能的图片、材质和参数处理放在 `operators/common.py`、`operators/image.py` 与 `operators/material.py`。 Mesh Plane、Depth Plane、Surface、Background 和 Balloon 按功能组织。一个功能的入口、请求和 Blender 回写放在一起;跨功能的图片、材质和参数处理放在 `operators/common.py`、`operators/image.py` 与 `operators/material.py`。
Server 的模型模块返回 Prediction,几何模块处理数组与网格,输出模块写文件。Blender 数据只在 Operator Response 的主线程阶段创建。Cutout 与 Balloon 在未启用 Refine 和 `Normal` 时不提交 Job:Operator 直接在内存中合成最终 Alpha,并调用 `server/geometry/cutout.py` 的纯数据轮廓函数后创建网格。Refine、Normal 与 Depth Job 只返回最终图片 Alpha 和被选中的 AI 数组或纹理;`normal_analysis` 为假时不请求 MoGe-2 normal 模型,也不写入 `normal.png`。外轮廓、孔洞及基础面始终在 Blender 主线程生成,不使用 `contour.json` 作为中间结果。 Server 的模型模块返回 Prediction,几何模块处理数组与网格,输出模块写文件。Blender 数据只在 Operator Response 的主线程阶段创建。Cutout 与 Balloon 在未启用 Refine 和 `Normal` 时不提交 Job:Operator 直接在内存中合成最终 Alpha,并调用 `server/geometry/cutout.py` 的纯数据轮廓函数后创建网格。Refine、Normal 与 Depth Job 只返回最终图片 Alpha 和被选中的 AI 数组或纹理;`normal_analysis` 为假时不请求 MoGe-2 normal 模型,也不写入 `normal.png`。外轮廓、孔洞及基础面始终在 Blender 主线程生成,不使用 `contour.json` 作为中间结果。Cutout 的 Point Spacing 由完整源图尺寸换算到平面空间;裁切 bounds 只参与轮廓坐标、局部平面和 UV 映射。
扩展级设置统一通过 `preferences.addon_preferences()` 读取,不归属 BlendJob Runtime;Storage Root 路径回调也由 Preferences 模块提供,Runtime 只消费回调结果。扩展包标识直接使用根模块中的 `__package__`,以兼容 Blender 实际的 `bl_ext.<repository>.anyimage` 命名空间;不得按第一个点截断,否则会错误查找 `bl_ext` 并静默回退默认值。Preferences 的设备枚举和面板数据按需导入,避免循环依赖。 扩展级设置统一通过 `preferences.addon_preferences()` 读取,不归属 BlendJob Runtime;Storage Root 路径回调也由 Preferences 模块提供,Runtime 只消费回调结果。扩展包标识直接使用根模块中的 `__package__`,以兼容 Blender 实际的 `bl_ext.<repository>.anyimage` 命名空间;不得按第一个点截断,否则会错误查找 `bl_ext` 并静默回退默认值。Preferences 的设备枚举和面板数据按需导入,避免循环依赖。

View File

@@ -28,11 +28,11 @@ Depth Plane 与 Surface 可使用 DA3 或 MoGe-2。DA3 提供相对深度和相
`Convert to Mesh Plane` 位于 `Convert to Depth Plane` 上方,不需要 AI Environment。它生成基础四边形,并由 `O Mesh Plane` 修改器实时处理 `Thickness` 与 `Subdivide`;`Thickness` 为 `0` 时保持单面。操作会立即执行;创建后可在 Last Operation 中调整 `Shadeless` 与 `Keep Original`。 `Convert to Mesh Plane` 位于 `Convert to Depth Plane` 上方,不需要 AI Environment。它生成基础四边形,并由 `O Mesh Plane` 修改器实时处理 `Thickness` 与 `Subdivide`;`Thickness` 为 `0` 时保持单面。操作会立即执行;创建后可在 Last Operation 中调整 `Shadeless` 与 `Keep Original`。
Cutout 复用 Image Lasso 的反选与 Refine 流程,但自交选区使用非零环绕填充:同方向重叠保留,反方向内环形成孔洞。基础面以均匀六角格点填充平滑约束轮廓,`Pixel Size` 控制内部密度,短轮廓会自适应边界采样以免有效小区域消失。未启用 Refine 的 Cutout 与 Balloon 只按套索建面,不把原图 Alpha 计入几何;同时关闭 `Normal` 时在 Blender 内完成遮罩、轮廓和建面,不启动 BlendJob。套索或 Refine Alpha 只作为几何 mask,材质颜色使用当前 Empty Image 的未裁切副本,MoGe 深度与法线始终分析完整图片;若图片来自 Upscale,AI 分析回溯到放大前的原图,避免放大不必要地增加计算量和伪细节。`Normal` 默认关闭,启用后才用 MoGe-2 写入 normal;Depth Surface 保留 MoGe 原生 object-space 像素,Cutout、Balloon 与 Depth Balloon 根据成品网格 TBN 烘成不同的 tangent-space 像素。Depth 类型始终使用 Job 生成深度,但同样由该选项决定是否写入 normal。Refine 启用后由 BEN2 返回原图画布内的最终 Alpha,Blender 再从该 Alpha 建面,框外套索区域不会保留。详细操作与计算边界见 [Cutout](cutout.md)。 Cutout 复用 Image Lasso 的反选与 Refine 流程,但自交选区使用非零环绕填充:同方向重叠保留,反方向内环形成孔洞。基础面以均匀六角格点填充平滑约束轮廓,`Point Spacing` 以完整源图像素为单位控制内部密度,不随局部裁切范围重新归一化;短轮廓会自适应边界采样以免有效小区域消失。未启用 Refine 的 Cutout 与 Balloon 只按套索建面,不把原图 Alpha 计入几何;同时关闭 `Normal` 时在 Blender 内完成遮罩、轮廓和建面,不启动 BlendJob。套索或 Refine Alpha 以 `0.5` 为几何剔除阈值,并确定紧凑矩形裁剪范围。材质 Color 使用当前 Empty Image 在该范围内的原始像素,不乘选择 Alpha,只保留图片原有 Alpha;MoGe 深度与法线则使用同一裁剪范围的局部分析图,选区内保留当前图片 RGB,范围外 RGBA 置零。Upscale 图片直接使用高清图的所选区域进行材质与 AI 分析。裁剪贴图可以是非正方形,局部平面范围和 UV 会映射回源图对应区域。`Normal` 默认关闭,启用后才用 MoGe-2 写入 normal;Depth Surface 保留 MoGe 原生 object-space 像素,Cutout、Balloon 与 Depth Balloon 根据成品网格 TBN 烘成 tangent-space 像素。Depth 类型始终使用 Job 生成深度,但同样由该选项决定是否写入 normal。Refine 启用后由 BEN2 返回裁剪后的最终 Alpha,Blender 再从该 Alpha 建面。详细操作与计算边界见 [Cutout](cutout.md)。
`O Image Depth Surface` 的平滑阶段直接展开 O Essentials `O Smooth` 的 Repeat Zone 与双域边界判断结构,不嵌套组节点;内部固定按 `Pin Sharp = 1`、`Pin Boundary = 1` 计算,不暴露这两个参数,`Iterations` 默认 `1`、最小可设为 `0`。`Depth Continuity` 默认 `0`;Thickness 固定正面,只向背面挤出。 `O Image Depth Surface` 的平滑阶段直接展开 O Essentials `O Smooth` 的 Repeat Zone 与双域边界判断结构,不嵌套组节点;内部固定按 `Pin Sharp = 1`、`Pin Boundary = 1` 计算,不暴露这两个参数,`Iterations` 默认 `1`、最小可设为 `0`。`Depth Continuity` 默认 `0`;Thickness 固定正面,只向背面挤出。
Balloon 作为不依赖 AI 的基础工具单独排列;Mesh Plane、Depth Plane 与 Surface 属于转换组,Remove Background 与 Upscale 由分割线归入图片处理组。Depth Plane、Surface、Remove Background 与 Upscale 四个 AI 功能在缺少 Environment 或默认模型时仍可点击:缺 Environment 时转入 `Set Up AI Environment…`,只缺默认模型时转入 `Download Required Models…`。全部就绪后,菜单底部显示 `AI Environment Settings…`,用于打开 AnyImage 扩展偏好设置。Upscale 菜单项同时显示当前图片分辨率,不弹出属性对话框;模型与输出长边上限统一读取 Preferences 的 `Default Upscale Model` 和 `Maximum Upscale Size`,Operator 不注册 Last Operation 重做面板。高清结果再次执行时从保存的原图重新生成并替换当前版本,不级联处理已有结果;名称始终基于原图,透明图片保留放大后的 Alpha。Depth Plane、Surface 与 Cutout 的深度或法线推理也会沿用该原图引用;高清结果继续用于颜色材质和选区显示。 `Depth Plane` 与普通 `Surface` 的推理仍可沿用 Upscale 保存的原图引用;Cutout 四种形状则以当前高清 Image 的所选区域作为 Color、深度和法线输入。
转换任务完成后,AnyImage 在一个 Undo 步骤中创建网格、图片、材质和修改器。源 Image Empty 是否保留由转换选项决定。 转换任务完成后,AnyImage 在一个 Undo 步骤中创建网格、图片、材质和修改器。源 Image Empty 是否保留由转换选项决定。

View File

@@ -52,6 +52,8 @@ Box / Lasso 的顺序固定为 **Refine → Invert**。BEN2 只提供内容 Alph
未形成有效区域的单击直接返回 `CANCELLED`,不报告消息;最终 Alpha 全空时,Operator 报告 `WARNING` 并保持源图不变。其他数据、状态或运行时异常仍报告 `ERROR`。 未形成有效区域的单击直接返回 `CANCELLED`,不报告消息;最终 Alpha 全空时,Operator 报告 `WARNING` 并保持源图不变。其他数据、状态或运行时异常仍报告 `ERROR`。
Box、Lasso、Line 与 Polyline 的屏幕预览统一绘制白色虚线轮廓和淡灰半透明填充。简单选区使用 Blender 的凹多边形三角剖分,不使用从首点展开的 Triangle Fan;通过屏幕网格快速检测到自交后改用 2 px 扫描填充,并读取 Operator 的最终填充规则。Image Mask 使用偶奇规则,Cutout 使用与最终几何一致的非零环绕规则。Lasso 只在游标相对末点移动至少 2 px 时采点,停留产生的重复 `MOUSEMOVE` 不增加轨迹;新点与前一线段的偏差不超过 0.75 px 时直接替换末点,在线消除近似共线点。填充三角形和闭合虚线段都按当前屏幕 polygon 缓存,额外重绘不重复计算。松开鼠标后使用这条已简化的闭合 polygon 生成几何 mask。
Line 的屏幕坐标 Y 轴与图片坐标 Y 轴方向相反,因此像素阶段会反转线段端点,使半透明预览侧与实际保留侧一致;`Ctrl` 只影响屏幕端点的 5° 吸附。零长度点击在投影前直接取消,投影结果不足两个点时也会安全终止。Polyline 接近起点时绘制闭合圆环,并支持单击起点或 `Enter` 完成;完成时显式闭合,不把当前游标位置作为额外顶点。 Line 的屏幕坐标 Y 轴与图片坐标 Y 轴方向相反,因此像素阶段会反转线段端点,使半透明预览侧与实际保留侧一致;`Ctrl` 只影响屏幕端点的 5° 吸附。零长度点击在投影前直接取消,投影结果不足两个点时也会安全终止。Polyline 接近起点时绘制闭合圆环,并支持单击起点或 `Enter` 完成;完成时显式闭合,不把当前游标位置作为额外顶点。
## Perspective 管线 ## Perspective 管线

View File

@@ -11,7 +11,7 @@
| `remove-background` | `image.0001.png` 起 | 带 Alpha 的图片层 | | `remove-background` | `image.0001.png` 起 | 带 Alpha 的图片层 |
| `upscale-image` | `upscale.0001.png` 起 | 所选高清模型在输出上限内生成的 4× 高清层,并保留源图 Alpha | | `upscale-image` | `upscale.0001.png` 起 | 所选高清模型在输出上限内生成的 4× 高清层,并保留源图 Alpha |
| `refine-image-mask` | `selection-input.png`、`image.png`、`bounds.json` | BEN2 细化后的局部内容 mask 与原图坐标 bounds | | `refine-image-mask` | `selection-input.png`、`image.png`、`bounds.json` | BEN2 细化后的局部内容 mask 与原图坐标 bounds |
| `generate-mesh` | 几何 mask `image.0001.png`,按形状输出 `normal.png`、`depth.npz` 或 `field.npz` | 套索或 Refine Alpha 只供 Blender 生成轮廓;材质使用当前 Empty Image 的未裁切副本,Upscale 结果的深度与法线使用放大前原图 | | `generate-mesh` | `image.0001.png`、`selection.png`、`bounds.json`,按形状输出 `normal.png`、`depth.npz` 或 `field.npz` | 当前图片所选区域的矩形 Color、几何 mask、原图坐标 bounds,以及同尺寸的局部深度或法线结果 |
| `analyze-balloon` | `lasso-input.png`,按选项输出 `foreground.png`、`contour.json`、`depth.npz`、`normal.png` | 套索约束输入、BEN2 轮廓与 MoGe-2 深度、法线 | | `analyze-balloon` | `lasso-input.png`,按选项输出 `foreground.png`、`contour.json`、`depth.npz`、`normal.png` | 套索约束输入、BEN2 轮廓与 MoGe-2 深度、法线 |
| `debug-da3` | `prediction.npz` | DA3 API 返回 | | `debug-da3` | `prediction.npz` | DA3 API 返回 |
| `debug-moge2` | `prediction*.npz` | MoGe-2 API 返回 | | `debug-moge2` | `prediction*.npz` | MoGe-2 API 返回 |
@@ -22,14 +22,14 @@
DA3 Depth 结果包含 `depth`、`conf`、`extrinsics` 与 `intrinsics`。MoGe-2 Depth 结果包含 `depth`、`normal`、`mask` 与 `intrinsics`。 DA3 Depth 结果包含 `depth`、`conf`、`extrinsics` 与 `intrinsics`。MoGe-2 Depth 结果包含 `depth`、`normal`、`mask` 与 `intrinsics`。
Balloon Depth 的 `depth.npz` 包含相对 `depth_signal`、归一化拟合平面 `baseline_vector`、`valid`、`reference_depth`、取样原点与输入尺寸。Cutout 的 Depth Balloon 对完整原图推理,再以套索 mask 限定深度 `valid`;Geometry Nodes 将归一化原 X 距离经 Smoother Step 后作为 Thickness 与 Depth 的混合系数。Job 返回的 `normal.png` 是原图尺寸的 MoGe 相机轴法线中间结果,不按套索清空;只有原图透明或模型无效区域使用中性法线。Blender 创建并评估成品网格后,再用其 UV、平滑角法线与切线基底烘成实际使用的 tangent-space Image。 Balloon Depth 的 `depth.npz` 包含相对 `depth_signal`、归一化拟合平面 `baseline_vector`、`valid`、`reference_depth`、取样原点与输入尺寸。Cutout Job 先按套索或 Refine 最终 Alpha 紧裁出局部矩形:`image.0001.png` 保留当前图片在矩形内的原始 RGB 与原有 Alpha,不乘选择 mask;`selection.png` 保存几何 Alpha;供 MoGe 使用的内部分析图仅在选区内保留像素,范围外 RGBA 为零。深度、normal 与 Color 使用相同的非正方形局部尺寸,Blender 根据 `bounds.json` 恢复位置和 UV。若输入是 Upscale 图片,推理直接使用高清图的所选区域。几何与 Surface 前景均以 Alpha `0.5` 为边界,小于 `0.5` 的顶点或区域会被剔除。Blender 创建并评估成品网格后,再用其 UV、平滑角法线与切线基底烘成实际使用的 tangent-space Image。
Surface 的 `mesh.npz` 包含 `vertices`、`faces`、`uv`、`intrinsics`、`image_size` 与 `reference_depth`,以及 Geometry Nodes 使用的网格属性。 Surface 的 `mesh.npz` 包含 `vertices`、`faces`、`uv`、`intrinsics`、`image_size` 与 `reference_depth`,以及 Geometry Nodes 使用的网格属性。
Debug 文件保持模型 API 的数据形态,用于比较推理结果;正式 Job 则提供 Blender 导入所需的派生数据。三档高清模型的浮点 RGB 返回只做 `[0, 1]` 限制和 8-bit PNG 序列化,不做色调保护或额外画质处理;三个模型都使用 256×256、16 像素上下文的重叠分块,外边界以镜像像素提供模型上下文。输入含 Alpha 时,Alpha 不进入 RGB 模型,而是在相同输入限制后独立进行 4× 高质量插值并合并回 RGBA 结果。正式 `upscale-image` 还会在推理前应用输出长边与总像素上限,再把结果命名为 `upscale.0001.png` 起的序列并交给 Blender 写回 Image Empty;结果 Image 数据块始终基于原图名称使用 `.upscale` 后缀,重复执行时替换当前高清版本。 Debug 文件保持模型 API 的数据形态,用于比较推理结果;正式 Job 则提供 Blender 导入所需的派生数据。三档高清模型的浮点 RGB 返回只做 `[0, 1]` 限制和 8-bit PNG 序列化,不做色调保护或额外画质处理;三个模型都使用 256×256、16 像素上下文的重叠分块,外边界以镜像像素提供模型上下文。输入含 Alpha 时,Alpha 不进入 RGB 模型,而是在相同输入限制后独立进行 4× 高质量插值并合并回 RGBA 结果。正式 `upscale-image` 还会在推理前应用输出长边与总像素上限,再把结果命名为 `upscale.0001.png` 起的序列并交给 Blender 写回 Image Empty;结果 Image 数据块始终基于原图名称使用 `_upscale.png` 后缀,重复执行时替换当前高清版本。
## 图片序列 ## 图片序列
正式图片层使用 `image.0001.*` 起始的文件名,Upscale 层单独使用 `upscale.0001.png`。导入 Blender 后沿用源 Image 名称,由 Blender 在同名冲突时自动添加 `.001`;不再添加 `.color`。深度动画使用 EXR 序列以保存浮点值。源 Empty 为 Movie 或 Sequence 时,Blender 按序列载入这些文件,即使任务只输出一帧。 正式图片层使用 `image.0001.*` 起始的文件名,Upscale 层单独使用 `upscale.0001.png`。导入 Blender 后,颜色、深度与法线 Image datablock 分别使用 `_color.png`、`_depth.exr` 与 `_normal.png` 后缀;重名时由 Blender 自动添加 `.001`。深度动画使用 EXR 序列以保存浮点值。源 Empty 为 Movie 或 Sequence 时,Blender 按序列载入这些文件,即使任务只输出一帧。
MoGe-2 Surface 的原生相机法线单独保存为 `normal.png`,Blender 以 object-space 解码。Depth Surface 同样直接保留该像素编码。Cutout、Balloon 与 Depth Balloon 不会把同一张图直接当作 tangent-space 解码,而是在 Blender 端依据各自最终评估网格的 TBN 重新生成 tangent-space 像素;材质的空间选择只负责匹配已经生成好的贴图。 MoGe-2 Surface 的原生相机法线单独保存为 `normal.png`,Blender 以 object-space 解码。Depth Surface 同样直接保留该像素编码。Cutout、Balloon 与 Depth Balloon 不会把同一张图直接当作 tangent-space 解码,而是在 Blender 端依据各自最终评估网格的 TBN 重新生成 tangent-space 像素;材质的空间选择只负责匹配已经生成好的贴图。

View File

@@ -71,7 +71,7 @@ Image Box 与 Lasso 的 `Refine Selection` 使用 `refine-image-mask` Job。它
- BEN2 Base - BEN2 Base
- Real-ESRGAN General WDN x4v3、SAFMN Real x4、Real-ESRGAN x4plus - Real-ESRGAN General WDN x4v3、SAFMN Real x4、Real-ESRGAN x4plus
Upscale 使用上述三档 4× RGB 模型。正式 `upscale-image` Job 把结果写成 `upscale.0001.png` 起的序列并回写为 `.upscale` Image 数据块;对应 Debug Job 保留模型直通输出,便于横向比较。 Upscale 使用上述三档 4× RGB 模型。正式 `upscale-image` Job 把结果写成 `upscale.0001.png` 起的序列并回写为 `_upscale.png` Image 数据块;对应 Debug Job 保留模型直通输出,便于横向比较。
首次安装会下载 MoGe-2 ViT-S Normal、BEN2 Base 与默认的 Real-ESRGAN General WDN x4v3。SAFMN Real x4 和 Real-ESRGAN x4plus 可在扩展偏好设置按需下载。模型保存在 `models/`,可由多个 Blender 版本共享。Server 会复用连续任务所需的同一 Session;切换模型族时释放旧 Session,推理资源异常时立即卸载当前高清 Session。 首次安装会下载 MoGe-2 ViT-S Normal、BEN2 Base 与默认的 Real-ESRGAN General WDN x4v3。SAFMN Real x4 和 Real-ESRGAN x4plus 可在扩展偏好设置按需下载。模型保存在 `models/`,可由多个 Blender 版本共享。Server 会复用连续任务所需的同一 Session;切换模型族时释放旧 Session,推理资源异常时立即卸载当前高清 Session。

View File

@@ -8,7 +8,7 @@ Upscale 用 ONNX 模型把 Image Empty 的图片放大 4 倍,不创建网格
2. 按需要设置 **Maximum Upscale Size**;默认 4096 px,最高 8192 px。 2. 按需要设置 **Maximum Upscale Size**;默认 4096 px,最高 8192 px。
3. 选中 Image Empty,在右键 **AnyImage** 菜单中点击 **Upscale**。 3. 选中 Image Empty,在右键 **AnyImage** 菜单中点击 **Upscale**。
较大的输入会先按比例缩小,使输出不超过尺寸和总像素上限。完成后,当前 Empty 会改用名称以 `.upscale` 结尾的结果图片。 较大的输入会先按比例缩小,使输出不超过尺寸和总像素上限。完成后,当前 Empty 会改用名称以 `_upscale.png` 结尾的结果图片。
## 模型选择 ## 模型选择

View File

@@ -267,11 +267,12 @@ def extract_balloon_object(
depth_data=None, depth_data=None,
normal_path=None, normal_path=None,
material_image=None, material_image=None,
local_bounds=None,
bake_normal_to_tangent=False, bake_normal_to_tangent=False,
): ):
image = material_image or source_object.data image = material_image or source_object.data
image_size = tuple(image.size) image_size = tuple(image.size)
local_bounds = plane_bounds(source_object) local_bounds = local_bounds or plane_bounds(source_object)
world_metric, world_z_scale = world_plane_metric(matrix_world) world_metric, world_z_scale = world_plane_metric(matrix_world)
point_spacing, inflation = balloon_geometry_parameters( point_spacing, inflation = balloon_geometry_parameters(
thickness, thickness,

View File

@@ -26,9 +26,9 @@ CUTOUT_DETAIL_MIN = 4
CUTOUT_DETAIL_MAX = 1024 CUTOUT_DETAIL_MAX = 1024
def cutout_mesh_detail(image_size, pixel_size): def cutout_mesh_detail(image_size, point_spacing):
longest_edge = max(int(value) for value in image_size) longest_edge = max(int(value) for value in image_size)
detail = round(longest_edge / max(int(pixel_size), 1)) detail = round(longest_edge / max(int(point_spacing), 1))
return max(CUTOUT_DETAIL_MIN, min(CUTOUT_DETAIL_MAX, detail)) return max(CUTOUT_DETAIL_MIN, min(CUTOUT_DETAIL_MAX, detail))
@@ -69,9 +69,9 @@ def _open_shape_pie(
source_name = source_object.name source_name = source_object.name
selection_data = json.dumps(polygon, separators=(",", ":")) selection_data = json.dumps(polygon, separators=(",", ":"))
settings = context.scene.anyimage_settings settings = context.scene.anyimage_settings
pixel_size = settings.cutout_pixel_size point_spacing = settings.cutout_point_spacing
normal_analysis = bool(getattr(settings, "cutout_normal_analysis", False)) normal_analysis = bool(getattr(settings, "cutout_normal_analysis", False))
mesh_detail = cutout_mesh_detail(source_object.data.size, pixel_size) mesh_detail = cutout_mesh_detail(source_object.data.size, point_spacing)
def draw(menu, _context): def draw(menu, _context):
pie = menu.layout.menu_pie() pie = menu.layout.menu_pie()
@@ -147,6 +147,7 @@ class SelectCutoutRegion(ImageMaskGesture, bpy.types.Operator):
gesture: gesture_property() gesture: gesture_property()
full_image: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) full_image: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
preview_fill_rule = 'NONZERO'
_pie_event = None _pie_event = None
def _set_status(self, context): def _set_status(self, context):
@@ -250,6 +251,6 @@ class CutoutTool(bpy.types.WorkSpaceTool):
def draw_settings(context, layout, _tool): def draw_settings(context, layout, _tool):
settings = context.scene.anyimage_settings settings = context.scene.anyimage_settings
layout.prop(settings, "cutout_pixel_size") layout.prop(settings, "cutout_point_spacing")
layout.prop(settings, "cutout_normal_analysis") layout.prop(settings, "cutout_normal_analysis")
draw_image_mask_settings(context, layout) draw_image_mask_settings(context, layout)

View File

@@ -141,11 +141,11 @@ def image_base_name(image):
def depth_data_name(source_object): def depth_data_name(source_object):
return f"{image_base_name(source_object.data)}.depth" return f"{image_base_name(source_object.data)}_depth.exr"
def normal_data_name(source_object): def normal_data_name(source_object):
return f"{image_base_name(source_object.data)}.normal" return f"{image_base_name(source_object.data)}_normal.png"
def copy_depth_image(source_object): def copy_depth_image(source_object):
@@ -155,8 +155,8 @@ def copy_depth_image(source_object):
return depth_image return depth_image
def color_data_name(source_object, suffix=".color"): def color_data_name(source_object):
return f"{image_base_name(source_object.data)}{suffix}" return f"{image_base_name(source_object.data)}_color.png"
def material_data_name(source_object): def material_data_name(source_object):

View File

@@ -286,10 +286,15 @@ def dashed_line_segments(vertices, dash_length=4.0, gap_length=4.0):
return tuple(segments) return tuple(segments)
def draw_dashed_line(vertices, color=(1.0, 1.0, 1.0, 0.9)): def draw_dashed_line(
vertices,
color=(1.0, 1.0, 1.0, 0.9),
segments=None,
):
import gpu import gpu
from gpu_extras.batch import batch_for_shader from gpu_extras.batch import batch_for_shader
if segments is None:
segments = dashed_line_segments(vertices) segments = dashed_line_segments(vertices)
if not segments: if not segments:
return return
@@ -303,19 +308,244 @@ def draw_dashed_line(vertices, color=(1.0, 1.0, 1.0, 0.9)):
gpu.state.blend_set("NONE") gpu.state.blend_set("NONE")
def triangle_fan_vertices(vertices): def _signed_area(vertices):
return sum(
start[0] * end[1] - end[0] * start[1]
for start, end in zip(vertices, (*vertices[1:], vertices[0]))
) * 0.5
def _triangle_cross(start, middle, end):
return (middle[0] - start[0]) * (end[1] - start[1]) - (
middle[1] - start[1]
) * (end[0] - start[0])
def _point_in_triangle(point, start, middle, end, orientation, epsilon):
return all(
orientation * cross >= -epsilon
for cross in (
_triangle_cross(start, middle, point),
_triangle_cross(middle, end, point),
_triangle_cross(end, start, point),
)
)
def _triangulated_polygon_vertices_python(vertices):
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices) vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
if len(vertices) < 3:
return ()
area = _signed_area(vertices)
if abs(area) <= 1e-8:
return ()
orientation = 1.0 if area > 0.0 else -1.0
remaining = list(range(len(vertices)))
triangles = [] triangles = []
for index in range(1, len(vertices) - 1): epsilon = 1e-8
triangles.extend((vertices[0], vertices[index], vertices[index + 1])) while len(remaining) > 3:
for position, current in enumerate(remaining):
previous = remaining[position - 1]
following = remaining[(position + 1) % len(remaining)]
start = vertices[previous]
middle = vertices[current]
end = vertices[following]
if orientation * _triangle_cross(start, middle, end) <= epsilon:
continue
if any(
_point_in_triangle(
vertices[index],
start,
middle,
end,
orientation,
epsilon,
)
for index in remaining
if index not in {previous, current, following}
):
continue
triangles.extend((start, middle, end))
del remaining[position]
break
else:
return ()
triangles.extend(vertices[index] for index in remaining)
return tuple(triangles) return tuple(triangles)
def draw_polygon_fill(vertices, color=(1.0, 1.0, 1.0, 0.16)): def triangulated_polygon_vertices(vertices):
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
try:
import mathutils
points = [
mathutils.Vector((vertex[0], vertex[1], 0.0)) for vertex in vertices
]
triangles = mathutils.geometry.tessellate_polygon([points])
return tuple(
(float(point[0]), float(point[1]))
for triangle in triangles
for point in triangle
)
except (AttributeError, ImportError, RuntimeError, TypeError, ValueError):
return _triangulated_polygon_vertices_python(vertices)
def scanline_fill_triangles(vertices, fill_rule='EVEN_ODD', step=2.0):
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
if len(vertices) < 3:
return ()
if fill_rule not in {'EVEN_ODD', 'NONZERO'}:
raise ValueError(f'Unknown polygon fill rule: {fill_rule}')
minimum_y = min(vertex[1] for vertex in vertices)
maximum_y = max(vertex[1] for vertex in vertices)
band_bottom = minimum_y
triangles = []
edges = tuple(zip(vertices, (*vertices[1:], vertices[0])))
while band_bottom < maximum_y:
band_top = min(band_bottom + step, maximum_y)
sample_y = (band_bottom + band_top) * 0.5
events = []
for start, end in edges:
if (start[1] <= sample_y < end[1]) or (
end[1] <= sample_y < start[1]
):
factor = (sample_y - start[1]) / (end[1] - start[1])
events.append(
(
start[0] + (end[0] - start[0]) * factor,
1 if end[1] > start[1] else -1,
)
)
events.sort(key=lambda event: event[0])
intervals = []
winding = 0
interval_start = None
index = 0
while index < len(events):
x = events[index][0]
delta = 0
count = 0
while index < len(events) and abs(events[index][0] - x) <= 1e-8:
delta += events[index][1]
count += 1
index += 1
was_inside = winding != 0
if fill_rule == 'EVEN_ODD':
if count % 2:
winding = 0 if was_inside else 1
else:
winding += delta
is_inside = winding != 0
if not was_inside and is_inside:
interval_start = x
elif was_inside and not is_inside and interval_start is not None:
intervals.append((interval_start, x))
interval_start = None
for left, right in intervals:
if right - left <= 1e-8:
continue
triangles.extend(
(
(left, band_bottom),
(right, band_bottom),
(right, band_top),
(left, band_bottom),
(right, band_top),
(left, band_top),
)
)
band_bottom = band_top
return tuple(triangles)
def _point_on_segment(point, start, end, epsilon=1e-8):
return (
min(start[0], end[0]) - epsilon
<= point[0]
<= max(start[0], end[0]) + epsilon
and min(start[1], end[1]) - epsilon
<= point[1]
<= max(start[1], end[1]) + epsilon
)
def _segments_intersect(first_start, first_end, second_start, second_end):
crosses = (
_triangle_cross(first_start, first_end, second_start),
_triangle_cross(first_start, first_end, second_end),
_triangle_cross(second_start, second_end, first_start),
_triangle_cross(second_start, second_end, first_end),
)
if (crosses[0] > 1e-8) != (crosses[1] > 1e-8) and (
crosses[2] > 1e-8
) != (crosses[3] > 1e-8):
return True
return any(
abs(cross) <= 1e-8 and _point_on_segment(point, start, end)
for cross, point, start, end in (
(crosses[0], second_start, first_start, first_end),
(crosses[1], second_end, first_start, first_end),
(crosses[2], first_start, second_start, second_end),
(crosses[3], first_end, second_start, second_end),
)
)
def polygon_self_intersects(vertices, cell_size=32.0):
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
if len(vertices) < 4:
return False
edges = tuple(zip(vertices, (*vertices[1:], vertices[0])))
cells = {}
last_edge = len(edges) - 1
for edge_index, (start, end) in enumerate(edges):
left = math.floor(min(start[0], end[0]) / cell_size)
right = math.floor(max(start[0], end[0]) / cell_size)
bottom = math.floor(min(start[1], end[1]) / cell_size)
top = math.floor(max(start[1], end[1]) / cell_size)
edge_cells = tuple(
(x, y)
for x in range(left, right + 1)
for y in range(bottom, top + 1)
)
candidates = {
candidate
for cell in edge_cells
for candidate in cells.get(cell, ())
}
for candidate in candidates:
if abs(edge_index - candidate) <= 1 or {
edge_index,
candidate,
} == {0, last_edge}:
continue
if _segments_intersect(start, end, *edges[candidate]):
return True
for cell in edge_cells:
cells.setdefault(cell, []).append(edge_index)
return False
def preview_fill_triangles(vertices, fill_rule='EVEN_ODD'):
if not polygon_self_intersects(vertices):
triangles = triangulated_polygon_vertices(vertices)
if triangles:
return triangles
return scanline_fill_triangles(vertices, fill_rule)
def draw_polygon_fill(
vertices,
triangles=None,
color=(1.0, 1.0, 1.0, 0.16),
):
import gpu import gpu
from gpu_extras.batch import batch_for_shader from gpu_extras.batch import batch_for_shader
triangles = triangle_fan_vertices(vertices) if triangles is None:
triangles = triangulated_polygon_vertices(vertices)
if not triangles: if not triangles:
return return
shader = gpu.shader.from_builtin("UNIFORM_COLOR") shader = gpu.shader.from_builtin("UNIFORM_COLOR")

View File

@@ -13,7 +13,6 @@ from ..balloon.operator import (
hidden_string_property, hidden_string_property,
screen_path_to_image_pixels, screen_path_to_image_pixels,
serialize_matrix, serialize_matrix,
simplify_screen_path,
) )
from ..common import HIDDEN, cleanup_temporary_input, push_undo from ..common import HIDDEN, cleanup_temporary_input, push_undo
from ..image import ( from ..image import (
@@ -28,12 +27,14 @@ from .common import (
active_view3d_tool_id, active_view3d_tool_id,
apply_image_mask_settings, apply_image_mask_settings,
crop_pixels_to_alpha, crop_pixels_to_alpha,
dashed_line_segments,
draw_dashed_line, draw_dashed_line,
draw_image_mask_operator_settings, draw_image_mask_operator_settings,
draw_polygon_fill, draw_polygon_fill,
image_edit_poll, image_edit_poll,
image_mask_refine_request, image_mask_refine_request,
polygon_bounds, polygon_bounds,
preview_fill_triangles,
replace_empty_image_pixels, replace_empty_image_pixels,
report_image_edit_exception, report_image_edit_exception,
resolve_image_edit_click, resolve_image_edit_click,
@@ -47,6 +48,45 @@ GESTURE_TOOL_IDS = {
} }
LINE_SNAP_RADIANS = math.radians(5.0) LINE_SNAP_RADIANS = math.radians(5.0)
POLYLINE_CLOSE_RADIUS = 8.0 POLYLINE_CLOSE_RADIUS = 8.0
LASSO_POINT_DISTANCE = 2.0
LASSO_SIMPLIFY_TOLERANCE = 0.75
def append_lasso_point(path, point):
point = (float(point[0]), float(point[1]))
if not path:
path.append(point)
return True
if math.dist(point, path[-1]) < LASSO_POINT_DISTANCE:
return False
if len(path) >= 2:
anchor = path[-2]
previous = path[-1]
segment_x = point[0] - anchor[0]
segment_y = point[1] - anchor[1]
segment_length_squared = segment_x * segment_x + segment_y * segment_y
if segment_length_squared > 0.0:
factor = (
(previous[0] - anchor[0]) * segment_x
+ (previous[1] - anchor[1]) * segment_y
) / segment_length_squared
projected = (
anchor[0] + segment_x * factor,
anchor[1] + segment_y * factor,
)
forward = (
(previous[0] - anchor[0]) * (point[0] - previous[0])
+ (previous[1] - anchor[1]) * (point[1] - previous[1])
)
if (
0.0 <= factor <= 1.0
and forward >= 0.0
and math.dist(previous, projected) <= LASSO_SIMPLIFY_TOLERANCE
):
path[-1] = point
return True
path.append(point)
return True
def points_inside_polygon(points, polygon): def points_inside_polygon(points, polygon):
@@ -265,9 +305,14 @@ def image_line_half_plane_polygon(line, image_size):
class ImageMaskGesture: class ImageMaskGesture:
active_tool_id = None active_tool_id = None
preview_fill_rule = 'EVEN_ODD'
_path = None _path = None
_cursor = None _cursor = None
_handle = None _handle = None
_fill_polygon = None
_fill_triangles = ()
_outline_polygon = None
_outline_segments = ()
_area_pointer = 0 _area_pointer = 0
_region_pointer = 0 _region_pointer = 0
_region_size = (0, 0) _region_size = (0, 0)
@@ -283,7 +328,7 @@ class ImageMaskGesture:
if self.gesture == "BOX": if self.gesture == "BOX":
return box_screen_path(self._path[0], self._cursor) return box_screen_path(self._path[0], self._cursor)
if self.gesture == "LASSO": if self.gesture == "LASSO":
return tuple(simplify_screen_path(self._path)) return tuple(self._path)
if self.gesture == "LINE": if self.gesture == "LINE":
return line_half_plane_polygon( return line_half_plane_polygon(
self._path[0], self._cursor, self._region_size self._path[0], self._cursor, self._region_size
@@ -299,16 +344,29 @@ class ImageMaskGesture:
): ):
return return
polygon = self._screen_polygon() polygon = self._screen_polygon()
if polygon != self._outline_polygon:
self._outline_polygon = polygon
if self.gesture == 'LINE':
outline = (self._path[0], self._cursor)
else:
outline = (*polygon, polygon[0]) if len(polygon) > 1 else ()
self._outline_segments = dashed_line_segments(outline)
if len(polygon) >= 3: if len(polygon) >= 3:
if polygon != self._fill_polygon:
self._fill_polygon = polygon
self._fill_triangles = preview_fill_triangles(
polygon, self.preview_fill_rule
)
draw_polygon_fill( draw_polygon_fill(
polygon, polygon,
self._fill_triangles,
(1.0, 0.25, 0.2, 0.16) if self.invert else (1.0, 1.0, 1.0, 0.16), (1.0, 0.25, 0.2, 0.16) if self.invert else (1.0, 1.0, 1.0, 0.16),
) )
if self.gesture == "LINE": if self.gesture == "LINE":
draw_dashed_line((self._path[0], self._cursor)) draw_dashed_line((), segments=self._outline_segments)
return return
if len(polygon) > 1: if len(polygon) > 1:
draw_dashed_line((*polygon, polygon[0])) draw_dashed_line((), segments=self._outline_segments)
if ( if (
self.gesture == "POLYLINE" self.gesture == "POLYLINE"
and len(self._path) >= 3 and len(self._path) >= 3
@@ -382,6 +440,10 @@ class ImageMaskGesture:
point = (float(event.mouse_region_x), float(event.mouse_region_y)) point = (float(event.mouse_region_x), float(event.mouse_region_y))
self._path = [point] self._path = [point]
self._cursor = point self._cursor = point
self._fill_polygon = None
self._fill_triangles = ()
self._outline_polygon = None
self._outline_segments = ()
self._area_pointer = context.area.as_pointer() self._area_pointer = context.area.as_pointer()
self._region_pointer = context.region.as_pointer() self._region_pointer = context.region.as_pointer()
self._region_size = (context.region.width, context.region.height) self._region_size = (context.region.width, context.region.height)
@@ -447,8 +509,7 @@ class ImageMaskGesture:
if event.type == "MOUSEMOVE": if event.type == "MOUSEMOVE":
point = (float(event.mouse_region_x), float(event.mouse_region_y)) point = (float(event.mouse_region_x), float(event.mouse_region_y))
if self.gesture == "LASSO": if self.gesture == "LASSO":
if math.dist(point, self._path[-1]) >= 2.0: append_lasso_point(self._path, point)
self._path.append(point)
self._cursor = point self._cursor = point
elif self.gesture == "LINE": elif self.gesture == "LINE":
self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl) self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl)
@@ -459,8 +520,7 @@ class ImageMaskGesture:
if event.type == "LEFTMOUSE" and event.value == "RELEASE": if event.type == "LEFTMOUSE" and event.value == "RELEASE":
point = (float(event.mouse_region_x), float(event.mouse_region_y)) point = (float(event.mouse_region_x), float(event.mouse_region_y))
if self.gesture == "LASSO": if self.gesture == "LASSO":
if math.dist(point, self._path[-1]) >= 2.0: append_lasso_point(self._path, point)
self._path.append(point)
self._cursor = point self._cursor = point
elif self.gesture == "LINE": elif self.gesture == "LINE":
self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl) self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl)

View File

@@ -27,13 +27,16 @@ from .common import (
) )
from .image import ( from .image import (
analysis_source_image, analysis_source_image,
color_data_name,
create_color_image, create_color_image,
image_base_name, image_base_name,
image_user_settings, image_user_settings,
is_image_empty, is_image_empty,
material_data_name, material_data_name,
normal_data_name,
plane_bounds, plane_bounds,
prepare_image_analysis_inputs, prepare_image_analysis_inputs,
prepare_image_input,
remove_temporary_input, remove_temporary_input,
scaled_process_resolution, scaled_process_resolution,
video_total_frames, video_total_frames,
@@ -114,11 +117,11 @@ def _depth_field_image(source_object, anchored):
return image return image
def _cutout_domain(regions_pixels, image_size, bounds, detail, world_metric): def _cutout_domain(regions_pixels, image_size, bounds, spacing, world_metric):
import numpy as np import numpy as np
x_min, x_max, y_min, y_max = bounds x_min, x_max, y_min, y_max = bounds
spacing = max(x_max - x_min, y_max - y_min) / max(int(detail), 1)
regions, spacing = prepare_planar_regions( regions, spacing = prepare_planar_regions(
regions_pixels, regions_pixels,
image_size, image_size,
@@ -168,12 +171,12 @@ def _load_field(path):
return points, intrinsics, valid, image_size, reference_depth return points, intrinsics, valid, image_size, reference_depth
def _anchored_point_field(source_object, field): def _anchored_point_field(source_object, field, bounds=None):
import numpy as np import numpy as np
points, intrinsics, valid, image_size, reference_depth = field points, intrinsics, valid, image_size, reference_depth = field
width, height = image_size width, height = image_size
x_min, x_max, y_min, y_max = plane_bounds(source_object) x_min, x_max, y_min, y_max = bounds or plane_bounds(source_object)
plane_width = x_max - x_min plane_width = x_max - x_min
plane_height = y_max - y_min plane_height = y_max - y_min
fx, fy = float(intrinsics[0, 0]), float(intrinsics[1, 1]) fx, fy = float(intrinsics[0, 0]), float(intrinsics[1, 1])
@@ -950,17 +953,6 @@ def _mesh_node_group(shape):
return group return group
def _unique_color_name(source_object, current=None):
base = image_base_name(source_object.data)
index = 0
while True:
number = "" if index == 0 else f".{index:03d}"
candidate = f"{base}{number}.color"
existing = bpy.data.images.get(candidate)
if existing is None or existing is current:
return candidate
index += 1
def _copy_color_image(source_object): def _copy_color_image(source_object):
source = source_object.data source = source_object.data
@@ -976,7 +968,7 @@ def _copy_color_image(source_object):
source.pixels.foreach_get(pixels) source.pixels.foreach_get(pixels)
image.pixels.foreach_set(pixels) image.pixels.foreach_set(pixels)
image.update() image.update()
image.name = _unique_color_name(source_object, image) image.name = color_data_name(source_object)
return image return image
@@ -989,9 +981,81 @@ def _image_rgba(image):
return np.flipud(pixels.reshape((height, width, 4))) return np.flipud(pixels.reshape((height, width, 4)))
def _cutout_regions(alpha, mesh_detail): def _mask_crop_bounds(alpha):
import numpy as np
values = np.asarray(alpha)
visible_y, visible_x = np.nonzero(values > 1.0 / 255.0)
if not len(visible_x):
raise ValueError("The Cutout selection contains no visible pixels")
return (
int(visible_x.min()),
int(visible_y.min()),
int(visible_x.max()) + 1,
int(visible_y.max()) + 1,
)
def _crop_plane_bounds(source_object, crop_bounds, source_size):
source_width, source_height = (float(value) for value in source_size)
left, top, right, bottom = (float(value) for value in crop_bounds)
x_min, x_max, y_min, y_max = plane_bounds(source_object)
width = x_max - x_min
height = y_max - y_min
return (
x_min + left * width / source_width,
x_min + right * width / source_width,
y_max - bottom * height / source_height,
y_max - top * height / source_height,
)
def _cropped_color_image(source_object, crop_bounds):
import numpy as np
source = source_object.data
rgba = _image_rgba(source)
left, top, right, bottom = crop_bounds
width = right - left
height = bottom - top
if width <= 0 or height <= 0:
raise ValueError("The Cutout crop dimensions must be positive")
cropped = np.zeros((height, width, 4), dtype=np.float32)
source_height, source_width = rgba.shape[:2]
source_left = max(0, left)
source_top = max(0, top)
source_right = min(source_width, right)
source_bottom = min(source_height, bottom)
if source_right > source_left and source_bottom > source_top:
cropped[
source_top - top : source_bottom - top,
source_left - left : source_right - left,
] = rgba[source_top:source_bottom, source_left:source_right]
image = bpy.data.images.new(
color_data_name(source_object),
width=width,
height=height,
alpha=True,
)
try:
image.colorspace_settings.name = source.colorspace_settings.name
image.alpha_mode = source.alpha_mode
image.pixels.foreach_set(np.flipud(cropped).ravel())
image.pack()
image.update()
except Exception:
bpy.data.images.remove(image, do_unlink=True)
raise
return image
def _cutout_regions(alpha, mesh_detail, threshold=0.5):
contour_resolution = max(512, min(int(mesh_detail) * 8, 1024)) contour_resolution = max(512, min(int(mesh_detail) * 8, 1024))
return alpha_regions(alpha, max_resolution=contour_resolution) return alpha_regions(
alpha,
max_resolution=contour_resolution,
threshold=threshold,
)
def _antialias_selection(selection): def _antialias_selection(selection):
@@ -1089,7 +1153,7 @@ def _local_cutout_rgba(source_object, polygon, invert=False):
def _rename_color_image(image, source_object): def _rename_color_image(image, source_object):
image.name = _unique_color_name(source_object, image) image.name = color_data_name(source_object)
return image return image
@@ -1120,18 +1184,29 @@ def _build_mesh_object(
field=None, field=None,
regions=None, regions=None,
image_size=None, image_size=None,
bounds=None,
keep_original=False, keep_original=False,
): ):
import numpy as np import numpy as np
bounds = plane_bounds(source_object) bounds = bounds or plane_bounds(source_object)
world_metric, _world_z_scale = _world_plane_metric(source_object.matrix_world) world_metric, _world_z_scale = _world_plane_metric(source_object.matrix_world)
domain = ( domain = (
_plane_domain(bounds, detail, world_metric) _plane_domain(bounds, detail, world_metric)
if shape in {PLANE, DEPTH_PLANE} if shape in {PLANE, DEPTH_PLANE}
else _cutout_domain(regions, image_size, bounds, detail, world_metric) else _cutout_domain(
regions,
image_size,
bounds,
_cutout_point_spacing(source_object, detail),
world_metric,
)
)
anchored = (
_anchored_point_field(source_object, field, bounds)
if field
else None
) )
anchored = _anchored_point_field(source_object, field) if field else None
depth_image = None depth_image = None
face_discontinuity = None face_discontinuity = None
if shape in {PLANE, DEPTH_PLANE}: if shape in {PLANE, DEPTH_PLANE}:
@@ -1223,13 +1298,13 @@ def _build_mesh_object(
def _load_normal_image(path, source_object): def _load_normal_image(path, source_object):
image = bpy.data.images.load(str(path), check_existing=False) image = bpy.data.images.load(str(path), check_existing=False)
image.name = f"{image_base_name(source_object.data)}.normal" image.name = normal_data_name(source_object)
image.colorspace_settings.name = "Non-Color" image.colorspace_settings.name = "Non-Color"
image.pack() image.pack()
return image return image
def _balloon_resolution(source_object, detail): def _cutout_point_spacing(source_object, detail):
import math import math
x_min, x_max, y_min, y_max = plane_bounds(source_object) x_min, x_max, y_min, y_max = plane_bounds(source_object)
@@ -1252,19 +1327,40 @@ def _build_local_cutout(
polygon = json.loads(selection_data) if selection_data else None polygon = json.loads(selection_data) if selection_data else None
if not isinstance(polygon, list) or len(polygon) < 3: if not isinstance(polygon, list) or len(polygon) < 3:
raise ValueError("The Cutout selection is invalid") raise ValueError("The Cutout selection is invalid")
rgba, selection = _local_cutout_rgba(source_object, polygon, invert) _rgba, selection = _local_cutout_rgba(source_object, polygon, invert)
regions, image_size = _geometry_cutout_regions( source_size = tuple(source_object.data.size)
rgba[:, :, 3], if invert:
polygon, crop_bounds = _mask_crop_bounds(selection)
left, top, right, bottom = crop_bounds
cropped_selection = selection[top:bottom, left:right]
else:
crop_bounds = tuple(int(value) for value in polygon_bounds(polygon))
left, top, right, bottom = crop_bounds
relative_polygon = [
(x - left, y - top)
for x, y in polygon
]
cropped_selection = _antialias_selection(
polygon_mask(
(right - left, bottom - top),
relative_polygon,
"NONZERO",
)
)
regions, image_size = _cutout_regions(
cropped_selection,
operator.mesh_detail, operator.mesh_detail,
invert, threshold=0.5,
refine_selection=False, )
selection=selection, local_bounds = _crop_plane_bounds(
source_object,
crop_bounds,
source_size,
) )
color_image = None color_image = None
material = None material = None
try: try:
color_image = _copy_color_image(source_object) color_image = _cropped_color_image(source_object, crop_bounds)
if shape == BALLOON: if shape == BALLOON:
result_object = extract_balloon_object( result_object = extract_balloon_object(
context, context,
@@ -1273,12 +1369,15 @@ def _build_local_cutout(
source_object.matrix_world.copy(), source_object.matrix_world.copy(),
"POISSON", "POISSON",
thickness=1.0, thickness=1.0,
resolution=_balloon_resolution(source_object, operator.mesh_detail), resolution=_cutout_point_spacing(
source_object, operator.mesh_detail
),
select_result=True, select_result=True,
reuse_material=False, reuse_material=False,
depth_data=None, depth_data=None,
normal_path=None, normal_path=None,
material_image=color_image, material_image=color_image,
local_bounds=local_bounds,
) )
result_object["anyimage_mesh_shape"] = shape result_object["anyimage_mesh_shape"] = shape
else: else:
@@ -1291,6 +1390,7 @@ def _build_local_cutout(
material, material,
regions=regions, regions=regions,
image_size=image_size, image_size=image_size,
bounds=local_bounds,
keep_original=True, keep_original=True,
) )
except Exception: except Exception:
@@ -1305,27 +1405,38 @@ def _build_response(context, result, operator):
source_object = bpy.data.objects.get(operator.source_object_name) source_object = bpy.data.objects.get(operator.source_object_name)
if not is_image_empty(source_object): if not is_image_empty(source_object):
raise RuntimeError("The source Image Empty no longer exists") raise RuntimeError("The source Image Empty no longer exists")
regions = image_size = None regions = image_size = local_bounds = None
if operator.shape in CUTOUT_SHAPES: if operator.shape in CUTOUT_SHAPES:
geometry_image = create_color_image( source_size = tuple(source_object.data.size)
crop_bounds = tuple(
int(value)
for value in json.loads(
result.file("bounds").read_text(encoding="utf-8")
)
)
local_bounds = _crop_plane_bounds(
source_object,
crop_bounds,
source_size,
)
selection_image = bpy.data.images.load(
str(result.file("selection")),
check_existing=False,
)
try:
selection_alpha = _image_rgba(selection_image)[:, :, 3]
regions, image_size = _cutout_regions(
selection_alpha,
operator.mesh_detail,
threshold=0.5,
)
finally:
bpy.data.images.remove(selection_image)
color_image = create_color_image(
source_object, source_object,
result.directory, result.directory,
assign_to_source=False, assign_to_source=False,
) )
try:
rgba = _image_rgba(geometry_image)
finally:
if geometry_image is not source_object.data:
bpy.data.images.remove(geometry_image)
polygon = json.loads(operator.selection_data)
regions, image_size = _geometry_cutout_regions(
rgba[:, :, 3],
polygon,
operator.mesh_detail,
operator.invert,
refine_selection=operator.refine_selection,
)
color_image = _copy_color_image(source_object)
else: else:
color_image = _rename_color_image( color_image = _rename_color_image(
create_color_image( create_color_image(
@@ -1350,12 +1461,13 @@ def _build_response(context, result, operator):
source_object.matrix_world.copy(), source_object.matrix_world.copy(),
"POISSON", "POISSON",
thickness=1.0, thickness=1.0,
resolution=_balloon_resolution(source_object, operator.mesh_detail), resolution=_cutout_point_spacing(source_object, operator.mesh_detail),
select_result=True, select_result=True,
reuse_material=False, reuse_material=False,
depth_data=depth_data, depth_data=depth_data,
normal_path=normal_path if normal_path.is_file() else None, normal_path=normal_path if normal_path.is_file() else None,
material_image=color_image, material_image=color_image,
local_bounds=local_bounds,
bake_normal_to_tangent=normal_path.is_file(), bake_normal_to_tangent=normal_path.is_file(),
) )
balloon["anyimage_mesh_shape"] = operator.shape balloon["anyimage_mesh_shape"] = operator.shape
@@ -1390,6 +1502,7 @@ def _build_response(context, result, operator):
field=field, field=field,
regions=regions, regions=regions,
image_size=image_size, image_size=image_size,
bounds=local_bounds,
keep_original=operator.shape in CUTOUT_SHAPES, keep_original=operator.shape in CUTOUT_SHAPES,
) )
if normal_image is not None and operator.shape == CUTOUT: if normal_image is not None and operator.shape == CUTOUT:
@@ -1401,7 +1514,6 @@ def _build_response(context, result, operator):
) )
push_undo(f"Convert to {operator.shape.replace('_', ' ').title()}") push_undo(f"Convert to {operator.shape.replace('_', ' ').title()}")
def _depth_settings(context, shape): def _depth_settings(context, shape):
preferences = addon_preferences(context) preferences = addon_preferences(context)
model = getattr(preferences, "mesh_depth_model", DEFAULT_MOGE2_MODEL_KEY) model = getattr(preferences, "mesh_depth_model", DEFAULT_MOGE2_MODEL_KEY)
@@ -1528,6 +1640,15 @@ def _start_job(
if setup_result is not None: if setup_result is not None:
return setup_result return setup_result
try: try:
if shape in CUTOUT_SHAPES:
source_path, delete_input = prepare_image_input(
source_object.data,
context.scene,
)
analysis_path = source_path
delete_analysis_input = False
analysis_image = source_object.data
else:
( (
source_path, source_path,
delete_input, delete_input,
@@ -1536,8 +1657,8 @@ def _start_job(
) = prepare_image_analysis_inputs( ) = prepare_image_analysis_inputs(
source_object.data, context.scene source_object.data, context.scene
) )
model, process_factor, resolution_level = _depth_settings(context, shape)
analysis_image = analysis_source_image(source_object.data) analysis_image = analysis_source_image(source_object.data)
model, process_factor, resolution_level = _depth_settings(context, shape)
process_res = ( process_res = (
scaled_process_resolution(analysis_image, process_factor) scaled_process_resolution(analysis_image, process_factor)
if shape in DEPTH_SHAPES and not model.startswith("MOGE2_") if shape in DEPTH_SHAPES and not model.startswith("MOGE2_")

View File

@@ -147,7 +147,7 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator):
replaced_image.use_fake_user = False replaced_image.use_fake_user = False
if int(replaced_image.users) == 0: if int(replaced_image.users) == 0:
bpy.data.images.remove(replaced_image, do_unlink=True) bpy.data.images.remove(replaced_image, do_unlink=True)
result_image.name = f"{image_base_name(source_image)}.upscale" result_image.name = f"{image_base_name(source_image)}_upscale.png"
result_image.anyimage_upscale_source = source_image result_image.anyimage_upscale_source = source_image
model_label = upscale_model_label(self.model) model_label = upscale_model_label(self.model)
if getattr(self, "_rebuild_from_original", False): if getattr(self, "_rebuild_from_original", False):

View File

@@ -332,11 +332,11 @@ def cutout_normal_analysis_property():
default=False, default=False,
) )
def cutout_pixel_size_property(): def cutout_point_spacing_property():
return bpy.props.IntProperty( return bpy.props.IntProperty(
name="Pixel Size", name="Point Spacing",
description=( description=(
"Approximate source image pixels per mesh step; smaller values " "Source image pixels between neighboring mesh points; smaller values "
"create denser geometry" "create denser geometry"
), ),
default=32, default=32,
@@ -372,7 +372,7 @@ def balloon_radial_zoom_property(scale):
class AnyImageSettings(bpy.types.PropertyGroup): class AnyImageSettings(bpy.types.PropertyGroup):
cutout_pixel_size: cutout_pixel_size_property() cutout_point_spacing: cutout_point_spacing_property()
cutout_normal_analysis: cutout_normal_analysis_property() cutout_normal_analysis: cutout_normal_analysis_property()
image_edit_refine_selection: image_edit_refine_selection_property() image_edit_refine_selection: image_edit_refine_selection_property()
balloon_mode: balloon_mode_property() balloon_mode: balloon_mode_property()

View File

@@ -186,7 +186,7 @@ def alpha_regions(alpha, max_resolution=512, threshold=0.5):
sampled_size = tuple(max(1, round(value * scale)) for value in source_size) sampled_size = tuple(max(1, round(value * scale)) for value in source_size)
if sampled_size != source_size: if sampled_size != source_size:
values = _resize_alpha(values, sampled_size) values = _resize_alpha(values, sampled_size)
mask = values > round(255.0 * threshold) mask = values >= 255.0 * threshold
regions = mask_regions(mask) regions = mask_regions(mask)
scale_x = source_size[0] / sampled_size[0] scale_x = source_size[0] / sampled_size[0]
scale_y = source_size[1] / sampled_size[1] scale_y = source_size[1] / sampled_size[1]

View File

@@ -116,7 +116,7 @@ def foreground_mesh_mask(foreground_path, depth_shape):
resample=Image.Resampling.BILINEAR, resample=Image.Resampling.BILINEAR,
) )
alpha = alpha.filter(ImageFilter.GaussianBlur(radius=0.75)) alpha = alpha.filter(ImageFilter.GaussianBlur(radius=0.75))
return np.asarray(alpha) > 0 return np.asarray(alpha) >= round(255.0 * 0.5)
def confidence_percentile_threshold(depth, confidence, percentile): def confidence_percentile_threshold(depth, confidence, percentile):

View File

@@ -1,3 +1,5 @@
import json
from ..media import copy_color_images from ..media import copy_color_images
from ..models import geometry_adapter, moge2 from ..models import geometry_adapter, moge2
from .background import remove_image_background from .background import remove_image_background
@@ -45,7 +47,7 @@ def _write_field(frame, output_path):
def _write_masked_color(input_path, polygon, output_path, invert=False): def _write_masked_color(input_path, polygon, output_path, invert=False):
import numpy as np import numpy as np
from PIL import Image, ImageChops, ImageFilter, ImageOps from PIL import Image, ImageFilter, ImageOps
with Image.open(input_path) as opened: with Image.open(input_path) as opened:
source = opened.convert("RGBA") source = opened.convert("RGBA")
@@ -55,9 +57,8 @@ def _write_masked_color(input_path, polygon, output_path, invert=False):
).filter(ImageFilter.GaussianBlur(radius=0.75)) ).filter(ImageFilter.GaussianBlur(radius=0.75))
if invert: if invert:
selection = ImageOps.invert(selection) selection = ImageOps.invert(selection)
alpha = ImageChops.multiply(source.getchannel("A"), selection)
pixels = np.asarray(source, dtype=np.uint8).copy() pixels = np.asarray(source, dtype=np.uint8).copy()
pixels[:, :, 3] = np.asarray(alpha, dtype=np.uint8) pixels[:, :, 3] = np.asarray(selection, dtype=np.uint8)
pixels[pixels[:, :, 3] == 0, :3] = 0 pixels[pixels[:, :, 3] == 0, :3] = 0
Image.fromarray(pixels, mode="RGBA").save( Image.fromarray(pixels, mode="RGBA").save(
output_path, output_path,
@@ -67,6 +68,37 @@ def _write_masked_color(input_path, polygon, output_path, invert=False):
return output_path return output_path
def _write_unbounded_selection(
input_path,
polygon,
output_path,
invert=False,
):
import numpy as np
from PIL import Image, ImageFilter
if invert:
_write_masked_color(input_path, polygon, output_path, invert=True)
with Image.open(input_path) as source:
return output_path, (0, 0, source.width, source.height)
canvas, selection_mask, bounds = create_selection_canvas(
input_path, polygon, 'NONZERO'
)
selection = Image.fromarray(
selection_mask.astype(np.uint8) * 255,
mode='L',
).filter(ImageFilter.GaussianBlur(radius=0.75))
pixels = np.asarray(canvas, dtype=np.uint8).copy()
pixels[:, :, 3] = np.asarray(selection, dtype=np.uint8)
pixels[pixels[:, :, 3] == 0, :3] = 0
Image.fromarray(pixels, mode='RGBA').save(
output_path,
format='PNG',
compress_level=1,
)
return output_path, bounds
def _write_refined_color(context, parameters, input_path, polygon, output_path): def _write_refined_color(context, parameters, input_path, polygon, output_path):
from PIL import Image, ImageChops from PIL import Image, ImageChops
@@ -100,6 +132,77 @@ def _write_refined_color(context, parameters, input_path, polygon, output_path):
return output_path return output_path
def _write_cutout_images(
input_path,
selection_path,
output_directory,
selection_bounds=None,
):
import numpy as np
from PIL import Image
with Image.open(input_path) as opened:
source = opened.convert("RGBA")
with Image.open(selection_path) as opened:
selection = opened.convert("RGBA")
if selection_bounds is None and selection.size != source.size:
raise ValueError("The Cutout selection does not match the source image")
if selection_bounds is None:
selection_bounds = (0, 0, source.width, source.height)
selection_pixels = np.asarray(selection, dtype=np.uint8).copy()
alpha = selection_pixels[:, :, 3]
alpha[alpha <= 1] = 0
selection_pixels[alpha == 0, :3] = 0
selection = Image.fromarray(selection_pixels, mode="RGBA")
visible_y, visible_x = np.nonzero(alpha >= 128)
if not len(visible_x):
raise ValueError("The Cutout selection contains no visible pixels")
bounds = (
int(visible_x.min()),
int(visible_y.min()),
int(visible_x.max()) + 1,
int(visible_y.max()) + 1,
)
content_bounds = bounds
selection_left, selection_top, _selection_right, _selection_bottom = (
selection_bounds
)
bounds = (
selection_left + content_bounds[0],
selection_top + content_bounds[1],
selection_left + content_bounds[2],
selection_top + content_bounds[3],
)
color_canvas = Image.new('RGBA', selection.size, (0, 0, 0, 0))
color_canvas.paste(source, (-selection_left, -selection_top))
color = color_canvas.crop(content_bounds)
color_path = output_directory / "image.0001.png"
color.save(color_path, format="PNG", compress_level=1)
selection_crop = selection.crop(content_bounds)
selection_path = output_directory / "selection.png"
selection_crop.save(selection_path, format="PNG", compress_level=1)
analysis_pixels = np.asarray(color, dtype=np.uint8).copy()
mask_alpha = np.asarray(selection_crop, dtype=np.uint8)[:, :, 3]
analysis_pixels[:, :, 3] = np.minimum(
analysis_pixels[:, :, 3],
mask_alpha,
)
analysis_pixels[mask_alpha < 128] = 0
analysis_path = output_directory / "analysis.png"
Image.fromarray(analysis_pixels, mode="RGBA").save(
analysis_path,
format="PNG",
compress_level=1,
)
bounds_path = output_directory / "bounds.json"
bounds_path.write_text(
json.dumps(bounds, separators=(",", ":")),
encoding="utf-8",
)
return color_path, selection_path, analysis_path, bounds_path
def _write_camera_normal_from_prediction(prediction, color_path, output_path): def _write_camera_normal_from_prediction(prediction, color_path, output_path):
import numpy as np import numpy as np
from PIL import Image from PIL import Image
@@ -148,25 +251,15 @@ def _write_balloon_depth(
context, context,
parameters, parameters,
input_path, input_path,
polygon,
selection_size=None,
include_normal=False, include_normal=False,
): ):
import numpy as np
from PIL import Image from PIL import Image
with Image.open(input_path) as source: with Image.open(input_path) as source:
rgba = np.asarray(source.convert("RGBA"), dtype=np.uint8)
source_size = source.size source_size = source.size
selection_size = selection_size or source_size selection_mask = rgba[:, :, 3] >= 128
scale_x = source_size[0] / selection_size[0]
scale_y = source_size[1] / selection_size[1]
analysis_polygon = tuple(
(point[0] * scale_x, point[1] * scale_y) for point in polygon
)
selection_mask = polygon_selection_mask(
source_size,
analysis_polygon,
"NONZERO",
)
prediction = moge2.infer( prediction = moge2.infer(
context.resource("model_manager"), context.resource("model_manager"),
parameters, parameters,
@@ -190,7 +283,6 @@ def _write_balloon_depth(
) )
return depth_path, normal_path return depth_path, normal_path
def _write_surface_depth( def _write_surface_depth(
context, parameters, color_path, include_normal=False context, parameters, color_path, include_normal=False
): ):
@@ -230,49 +322,59 @@ def run(context, parameters):
raise ValueError("Empty Image to Mesh currently supports one image") raise ValueError("Empty Image to Mesh currently supports one image")
shape = parameters["shape"] shape = parameters["shape"]
polygon = parameters.get("polygon") polygon = parameters.get("polygon")
analysis_path = None
result = {}
if polygon: if polygon:
selection_bounds = None
full_selection_path = context.directory / "selection-full.png"
if parameters.get("refine_selection"): if parameters.get("refine_selection"):
color_path = _write_refined_color( _write_refined_color(
context, context,
parameters, parameters,
images[0], images[0],
polygon, polygon,
context.directory / "image.0001.png", full_selection_path,
) )
else: else:
color_path = _write_masked_color( _selection_path, selection_bounds = _write_unbounded_selection(
images[0], images[0],
polygon, polygon,
context.directory / "image.0001.png", full_selection_path,
parameters.get("invert", False), parameters.get("invert", False),
) )
color_path, selection_path, analysis_path, bounds_path = (
_write_cutout_images(
images[0],
full_selection_path,
context.directory,
selection_bounds,
)
)
result.update(
selection=selection_path.name,
bounds=bounds_path.name,
)
else: else:
copy_color_images(images, context.directory) copy_color_images(images, context.directory)
color_path = min(context.directory.glob("image.0001.*")) color_path = min(context.directory.glob("image.0001.*"))
result = {}
normal_analysis = bool(parameters.get("normal_analysis")) normal_analysis = bool(parameters.get("normal_analysis"))
with analysis_frames(context, parameters, images) as model_images: with analysis_frames(context, parameters, images) as model_images:
if len(model_images) != 1: if len(model_images) != 1:
raise ValueError("Empty Image to Mesh currently supports one image") raise ValueError("Empty Image to Mesh currently supports one image")
analysis_path = model_images[0] model_path = analysis_path or model_images[0]
if shape in {"CUTOUT", "BALLOON"} and normal_analysis: if shape in {"CUTOUT", "BALLOON"} and normal_analysis:
context.progress(0.4, "Estimating surface normal") context.progress(0.4, "Estimating surface normal")
result["normal"] = _write_camera_normal( result["normal"] = _write_camera_normal(
context, parameters, analysis_path context, parameters, model_path
).name ).name
elif shape == "DEPTH_BALLOON": elif shape == "DEPTH_BALLOON":
from PIL import Image
with Image.open(images[0]) as selection_source:
selection_size = selection_source.size
context.progress(0.4, "Estimating Balloon depth") context.progress(0.4, "Estimating Balloon depth")
depth_path, normal_path = _write_balloon_depth( depth_path, normal_path = _write_balloon_depth(
context, context,
parameters, parameters,
analysis_path, model_path,
polygon,
selection_size,
include_normal=normal_analysis, include_normal=normal_analysis,
) )
result["depth"] = depth_path.name result["depth"] = depth_path.name
@@ -283,7 +385,7 @@ def run(context, parameters):
field_path, normal_path = _write_surface_depth( field_path, normal_path = _write_surface_depth(
context, context,
parameters, parameters,
analysis_path, model_path,
include_normal=normal_analysis, include_normal=normal_analysis,
) )
result["field"] = field_path.name result["field"] = field_path.name
@@ -293,7 +395,7 @@ def run(context, parameters):
and shape == "DEPTH_SURFACE" and shape == "DEPTH_SURFACE"
): ):
normal_path = _write_camera_normal( normal_path = _write_camera_normal(
context, parameters, analysis_path context, parameters, model_path
) )
if normal_path is not None: if normal_path is not None:
result["normal"] = normal_path.name result["normal"] = normal_path.name

View File

@@ -908,13 +908,8 @@ class AddonTest(unittest.TestCase):
) as setup, ) as setup,
patch.object( patch.object(
self.mesh, self.mesh,
"prepare_image_analysis_inputs", "prepare_image_input",
return_value=( return_value=(Path("input.png"), False),
Path("input.png"),
False,
Path("input.png"),
False,
),
), ),
patch.object( patch.object(
self.mesh, self.mesh,
@@ -936,6 +931,10 @@ class AddonTest(unittest.TestCase):
setup.assert_called_once_with() setup.assert_called_once_with()
self.assertEqual(generate_mesh.call_args.kwargs["shape"], self.mesh.BALLOON) self.assertEqual(generate_mesh.call_args.kwargs["shape"], self.mesh.BALLOON)
self.assertEqual(generate_mesh.call_args.kwargs["mesh_detail"], 64) self.assertEqual(generate_mesh.call_args.kwargs["mesh_detail"], 64)
self.assertEqual(
generate_mesh.call_args.kwargs["analysis_input_path"],
"input.png",
)
self.assertEqual( self.assertEqual(
generate_mesh.call_args.kwargs["normal_model"], generate_mesh.call_args.kwargs["normal_model"],
self.addon.properties.DEFAULT_MOGE2_MODEL_KEY, self.addon.properties.DEFAULT_MOGE2_MODEL_KEY,
@@ -1311,7 +1310,7 @@ class AddonTest(unittest.TestCase):
result.directory, result.directory,
frame_prefix="upscale.", frame_prefix="upscale.",
) )
self.assertEqual(result_image.name, "Original.upscale") self.assertEqual(result_image.name, "Original_upscale.png")
self.assertIs( self.assertIs(
result_image.anyimage_upscale_source, result_image.anyimage_upscale_source,
original_image, original_image,
@@ -1466,13 +1465,13 @@ class AddonTest(unittest.TestCase):
self.assertIs(created, result_image) self.assertIs(created, result_image)
self.assertEqual(result_image.colorspace_settings.name, "ACEScg") self.assertEqual(result_image.colorspace_settings.name, "ACEScg")
self.assertEqual(result_image.name, "source.color") self.assertEqual(result_image.name, "source_color.png")
self.assertIs(source_object.data, result_image) self.assertIs(source_object.data, result_image)
def test_background_result_replaces_only_an_unshared_image(self): def test_background_result_replaces_only_an_unshared_image(self):
for remaining_users, removed_source, result_name in ( for remaining_users, removed_source, result_name in (
(0, True, "Source.png"), (0, True, "Source.png"),
(1, False, "Source.color"), (1, False, "Source_color.png"),
): ):
with self.subTest(remaining_users=remaining_users): with self.subTest(remaining_users=remaining_users):
source_image = SimpleNamespace( source_image = SimpleNamespace(
@@ -1486,7 +1485,7 @@ class AddonTest(unittest.TestCase):
empty_display_type="IMAGE", empty_display_type="IMAGE",
data=source_image, data=source_image,
) )
result_image = SimpleNamespace(name="Source.color") result_image = SimpleNamespace(name="Source_color.png")
removed = [] removed = []
self.fake_bpy.data = SimpleNamespace( self.fake_bpy.data = SimpleNamespace(
objects={"Source": source_object}, objects={"Source": source_object},
@@ -1574,6 +1573,25 @@ class AddonTest(unittest.TestCase):
self.assertFalse(even_odd[3, 5]) self.assertFalse(even_odd[3, 5])
self.assertTrue(nonzero[3, 5]) self.assertTrue(nonzero[3, 5])
def preview_area(fill_rule):
triangles = self.image_edit_common.scanline_fill_triangles(
polygon, fill_rule
)
return sum(
abs(
self.image_edit_common._triangle_cross(
triangles[index],
triangles[index + 1],
triangles[index + 2],
)
)
* 0.5
for index in range(0, len(triangles), 3)
)
self.assertAlmostEqual(preview_area('EVEN_ODD'), 36.0)
self.assertAlmostEqual(preview_area('NONZERO'), 54.0)
def test_image_mask_preserves_source_rgb_outside_the_selection(self): def test_image_mask_preserves_source_rgb_outside_the_selection(self):
import numpy as np import numpy as np
@@ -1857,9 +1875,9 @@ class AddonTest(unittest.TestCase):
self.assertNotIn("padding", operator.__annotations__) self.assertNotIn("padding", operator.__annotations__)
settings = self.addon.properties.AnyImageSettings.__annotations__ settings = self.addon.properties.AnyImageSettings.__annotations__
self.assertFalse(settings["image_edit_refine_selection"]["default"]) self.assertFalse(settings["image_edit_refine_selection"]["default"])
self.assertEqual(settings["cutout_pixel_size"]["default"], 32) self.assertEqual(settings["cutout_point_spacing"]["default"], 32)
self.assertEqual(settings["cutout_pixel_size"]["min"], 4) self.assertEqual(settings["cutout_point_spacing"]["min"], 4)
self.assertEqual(settings["cutout_pixel_size"]["max"], 32) self.assertEqual(settings["cutout_point_spacing"]["max"], 32)
self.assertFalse(settings["cutout_normal_analysis"]["default"]) self.assertFalse(settings["cutout_normal_analysis"]["default"])
self.assertNotIn("image_extract_continuous", settings) self.assertNotIn("image_extract_continuous", settings)
self.assertNotIn("image_extract_padding", settings) self.assertNotIn("image_extract_padding", settings)
@@ -2083,19 +2101,94 @@ class AddonTest(unittest.TestCase):
self.assertTrue(overlaps(((5, 5), (15, 5), (15, 15), (5, 15)), (10, 10))) self.assertTrue(overlaps(((5, 5), (15, 5), (15, 15), (5, 15)), (10, 10)))
self.assertTrue(overlaps(((-5, -5), (15, -5), (15, 15), (-5, 15)), (10, 10))) self.assertTrue(overlaps(((-5, -5), (15, -5), (15, 15), (-5, 15)), (10, 10)))
def test_image_perspective_builds_polyline_mask_fill_triangles(self): def test_image_mask_fill_triangulates_a_concave_polygon_without_overlap(self):
polygon = (
(0, 0),
(4, 0),
(4, 4),
(3, 4),
(3, 1),
(1, 1),
(1, 4),
(0, 4),
)
triangles = self.image_edit_common.triangulated_polygon_vertices(polygon)
area = sum(
abs(
self.image_edit_common._triangle_cross(
triangles[index],
triangles[index + 1],
triangles[index + 2],
)
)
* 0.5
for index in range(0, len(triangles), 3)
)
self.assertEqual(len(triangles), 18)
self.assertAlmostEqual(area, 10.0)
def test_image_mask_fill_uses_blender_geometry_without_stopping_the_draw(self):
mathutils = ModuleType('mathutils')
mathutils.Vector = lambda values: values
mathutils.geometry = SimpleNamespace(
tessellate_polygon=lambda _loops: (
((0.0, 0.0, 0.0), (2.0, 0.0, 0.0), (0.0, 2.0, 0.0)),
)
)
with patch.dict(sys.modules, {'mathutils': mathutils}):
triangles = self.image_edit_common.triangulated_polygon_vertices(
((0, 0), (2, 0), (0, 2))
)
self.assertEqual( self.assertEqual(
self.image_edit_common.triangle_fan_vertices( triangles,
((0, 0), (2, 0), (2, 2), (0, 2)) ((0.0, 0.0), (2.0, 0.0), (0.0, 2.0)),
), )
(
(0.0, 0.0), def test_image_mask_fill_falls_back_when_blender_tessellation_rejects_path(self):
(2.0, 0.0), mathutils = ModuleType('mathutils')
(2.0, 2.0), mathutils.Vector = lambda values: values
(0.0, 0.0), mathutils.geometry = SimpleNamespace(
(2.0, 2.0), tessellate_polygon=lambda _loops: (_ for _ in ()).throw(TypeError())
(0.0, 2.0), )
), with patch.dict(sys.modules, {'mathutils': mathutils}):
triangles = self.image_edit_common.triangulated_polygon_vertices(
((0, 0), (2, 0), (0, 2))
)
self.assertEqual(len(triangles), 3)
def test_image_mask_preview_fills_a_self_intersecting_lasso(self):
polygon = ((0, 0), (4, 4), (0, 4), (4, 0))
triangles = self.image_edit_common.preview_fill_triangles(
polygon, 'NONZERO'
)
area = sum(
abs(
self.image_edit_common._triangle_cross(
triangles[index],
triangles[index + 1],
triangles[index + 2],
)
)
* 0.5
for index in range(0, len(triangles), 3)
)
self.assertTrue(triangles)
self.assertAlmostEqual(area, 8.0)
def test_image_mask_preview_detects_only_actual_self_intersections(self):
self.assertTrue(
self.image_edit_common.polygon_self_intersects(
((0, 0), (4, 4), (0, 4), (4, 0))
)
)
self.assertFalse(
self.image_edit_common.polygon_self_intersects(
((0, 0), (4, 0), (2, 2), (4, 4), (0, 4))
)
) )
def test_image_perspective_limits_output_pixel_count(self): def test_image_perspective_limits_output_pixel_count(self):
@@ -2225,6 +2318,62 @@ class AddonTest(unittest.TestCase):
((0.0, 0.0), (3.0, 0.0), (5.0, 0.0), (8.0, 0.0)), ((0.0, 0.0), (3.0, 0.0), (5.0, 0.0), (8.0, 0.0)),
) )
def test_image_lasso_preview_uses_fill_and_a_closed_dashed_outline(self):
polygon = ((1.0, 2.0), (8.0, 3.0), (4.0, 9.0))
operator = SimpleNamespace(
gesture='LASSO',
invert=False,
_path=[polygon[0]],
_area_pointer=1,
_region_pointer=2,
preview_fill_rule='NONZERO',
_fill_polygon=None,
_fill_triangles=(),
_outline_polygon=None,
_outline_segments=(),
_screen_polygon=lambda: polygon,
)
with (
patch.object(self.image_mask, 'drawing_in_region', return_value=True),
patch.object(
self.image_mask,
'dashed_line_segments',
return_value=('segments',),
) as build_outline,
patch.object(
self.image_mask,
'preview_fill_triangles',
return_value=('triangles',),
) as build_fill,
patch.object(self.image_mask, 'draw_polygon_fill') as draw_fill,
patch.object(self.image_mask, 'draw_dashed_line') as draw_outline,
):
self.image_mask.ImageMaskGesture._draw_selection(operator)
self.image_mask.ImageMaskGesture._draw_selection(operator)
build_fill.assert_called_once_with(polygon, 'NONZERO')
build_outline.assert_called_once_with((*polygon, polygon[0]))
self.assertEqual(draw_fill.call_count, 2)
draw_fill.assert_called_with(
polygon,
('triangles',),
(1.0, 1.0, 1.0, 0.16),
)
self.assertEqual(draw_outline.call_count, 2)
draw_outline.assert_called_with((), segments=('segments',))
def test_image_lasso_does_not_add_stationary_or_nearly_collinear_points(self):
path = [(0.0, 0.0)]
self.assertFalse(self.image_mask.append_lasso_point(path, (0.5, 0.5)))
self.assertEqual(path, [(0.0, 0.0)])
self.assertTrue(self.image_mask.append_lasso_point(path, (3.0, 0.0)))
self.assertTrue(self.image_mask.append_lasso_point(path, (6.0, 0.2)))
self.assertEqual(len(path), 2)
self.assertEqual(path[-1], (6.0, 0.2))
self.assertTrue(self.image_mask.append_lasso_point(path, (6.0, 4.0)))
self.assertEqual(len(path), 3)
def test_image_edit_activation_defaults_to_box(self): def test_image_edit_activation_defaults_to_box(self):
import inspect import inspect

View File

@@ -31,9 +31,9 @@ class WindowManager:
draw(menu, None) draw(menu, None)
def cutout_context(window_manager, pixel_size=32, normal_analysis=False): def cutout_context(window_manager, point_spacing=32, normal_analysis=False):
settings = SimpleNamespace( settings = SimpleNamespace(
cutout_pixel_size=pixel_size, cutout_point_spacing=point_spacing,
cutout_normal_analysis=normal_analysis, cutout_normal_analysis=normal_analysis,
) )
return SimpleNamespace( return SimpleNamespace(
@@ -71,7 +71,7 @@ def test_cutout_pie_uses_four_semantic_shapes_in_cardinal_order():
def test_cutout_pie_passes_image_lasso_options_to_every_shape(): def test_cutout_pie_passes_image_lasso_options_to_every_shape():
window_manager = WindowManager() window_manager = WindowManager()
context = cutout_context( context = cutout_context(
window_manager, pixel_size=16, normal_analysis=True window_manager, point_spacing=16, normal_analysis=True
) )
source = cutout_source(size=(4096, 2048)) source = cutout_source(size=(4096, 2048))

View File

@@ -383,7 +383,7 @@ class ImageDepthTest(unittest.TestCase):
self.assertEqual( self.assertEqual(
self.image_depth.color_data_name(source_object), self.image_depth.color_data_name(source_object),
"clip.color", "clip_color.png",
) )
def test_force_sequence_source_keeps_loaded_sequence(self): def test_force_sequence_source_keeps_loaded_sequence(self):
@@ -684,12 +684,12 @@ class ImageDepthTest(unittest.TestCase):
self.assertEqual( self.assertEqual(
self.image_depth.depth_data_name(source_object), self.image_depth.depth_data_name(source_object),
"img.depth", "img_depth.exr",
) )
source_object.data.name = "img.png.001" source_object.data.name = "img.png.001"
self.assertEqual( self.assertEqual(
self.image_depth.depth_data_name(source_object), self.image_depth.depth_data_name(source_object),
"img.001.depth", "img.001_depth.exr",
) )
def test_moge_normal_name_uses_source_image_name(self): def test_moge_normal_name_uses_source_image_name(self):
@@ -700,7 +700,7 @@ class ImageDepthTest(unittest.TestCase):
self.assertEqual( self.assertEqual(
self.image_depth.normal_data_name(source_object), self.image_depth.normal_data_name(source_object),
"img.normal", "img_normal.png",
) )
def test_copy_depth_image_renames_still_source(self): def test_copy_depth_image_renames_still_source(self):
@@ -715,7 +715,7 @@ class ImageDepthTest(unittest.TestCase):
depth_image = self.image_depth.copy_depth_image(source_object) depth_image = self.image_depth.copy_depth_image(source_object)
self.assertEqual(depth_image.name, "img.depth") self.assertEqual(depth_image.name, "img_depth.exr")
def test_color_and_material_names_use_source_image_base_name(self): def test_color_and_material_names_use_source_image_base_name(self):
source_object = SimpleNamespace( source_object = SimpleNamespace(
@@ -724,7 +724,7 @@ class ImageDepthTest(unittest.TestCase):
self.assertEqual( self.assertEqual(
self.image_depth.color_data_name(source_object), self.image_depth.color_data_name(source_object),
"img.color", "img_color.png",
) )
self.assertEqual( self.assertEqual(
self.image_depth.material_data_name(source_object), self.image_depth.material_data_name(source_object),
@@ -733,7 +733,7 @@ class ImageDepthTest(unittest.TestCase):
source_object.data.name = "img.png.001" source_object.data.name = "img.png.001"
self.assertEqual( self.assertEqual(
self.image_depth.color_data_name(source_object), self.image_depth.color_data_name(source_object),
"img.001.color", "img.001_color.png",
) )
self.assertEqual( self.assertEqual(
self.image_depth.material_data_name(source_object), self.image_depth.material_data_name(source_object),

View File

@@ -2,6 +2,7 @@ import builtins
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
import numpy as np import numpy as np
@@ -169,6 +170,34 @@ def test_alpha_regions_accepts_blender_float_alpha_with_a_hole():
assert len(regions[0]) == 2 assert len(regions[0]) == 2
def test_cutout_crop_bounds_map_to_the_source_plane():
source = SimpleNamespace(
data=SimpleNamespace(size=(200, 100)),
empty_display_size=2.0,
empty_image_offset=(-0.5, -0.5),
)
bounds = mesh._crop_plane_bounds(
source,
(50, 25, 150, 75),
(200, 100),
)
np.testing.assert_allclose(bounds, (-0.5, 0.5, -0.25, 0.25))
def test_depth_surface_cutout_uses_half_alpha_boundary():
alpha = np.zeros((12, 16), dtype=np.float32)
alpha[2:10, 2:14] = 0.49
alpha[4:8, 5:11] = 0.51
regions, size = mesh._cutout_regions(alpha, 64, threshold=0.5)
contour = np.asarray(regions[0][0])
assert size == (16, 12)
np.testing.assert_allclose(contour.min(axis=0), (5.0, 4.0))
np.testing.assert_allclose(contour.max(axis=0), (11.0, 8.0))
def test_blender_cutout_alpha_pipeline_does_not_import_pillow(): def test_blender_cutout_alpha_pipeline_does_not_import_pillow():
original_import = builtins.__import__ original_import = builtins.__import__
@@ -254,6 +283,21 @@ def test_blender_cutout_contour_resolution_tracks_mesh_detail():
assert trace.call_args_list[0].kwargs["max_resolution"] == 512 assert trace.call_args_list[0].kwargs["max_resolution"] == 512
assert trace.call_args_list[1].kwargs["max_resolution"] == 1024 assert trace.call_args_list[1].kwargs["max_resolution"] == 1024
def test_cutout_point_spacing_uses_the_complete_source_plane():
source = SimpleNamespace(matrix_world=object())
with (
patch.object(mesh, "plane_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)),
patch.object(
mesh,
"_world_plane_metric",
return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0),
),
):
spacing = mesh._cutout_point_spacing(source, 200)
assert spacing == 0.02
def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_region(): def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_region():
regions = ( regions = (
(((2046, 2046), (2050, 2046), (2050, 2050), (2046, 2050)),), (((2046, 2046), (2050, 2046), (2050, 2050), (2046, 2050)),),
@@ -263,7 +307,7 @@ def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_region():
regions, regions,
(4096, 4096), (4096, 4096),
(-1.0, 1.0, -1.0, 1.0), (-1.0, 1.0, -1.0, 1.0),
4, 0.5,
((1.0, 0.0), (0.0, 1.0)), ((1.0, 0.0), (0.0, 1.0)),
) )
@@ -278,7 +322,7 @@ def test_cutout_domain_builds_a_full_image_at_minimum_detail():
regions, regions,
(4096, 4096), (4096, 4096),
(-1.0, 1.0, -1.0, 1.0), (-1.0, 1.0, -1.0, 1.0),
4, 0.5,
((1.0, 0.0), (0.0, 1.0)), ((1.0, 0.0), (0.0, 1.0)),
) )
@@ -299,7 +343,7 @@ def test_cutout_domain_keeps_overlapping_regions_disconnected():
regions, regions,
(120, 80), (120, 80),
(0.0, 1.0, 0.0, 1.0), (0.0, 1.0, 0.0, 1.0),
40, 0.025,
((1.0, 0.0), (0.0, 1.0)), ((1.0, 0.0), (0.0, 1.0)),
) )

View File

@@ -497,7 +497,20 @@ def test_depth_surface_protected_smooth_evaluates():
assert np.allclose(internal_positions, external_positions, atol=1e-6) assert np.allclose(internal_positions, external_positions, atol=1e-6)
def test_converted_color_images_are_new_and_keep_color_as_terminal_suffix(): def test_loaded_normal_image_uses_normal_texture_name(tmp_path):
from PIL import Image
path = tmp_path / "normal.png"
Image.new("RGB", (1, 1), (128, 128, 255)).save(path)
source = SimpleNamespace(data=SimpleNamespace(name="Poster.png"))
image = mesh._load_normal_image(path, source)
assert image.name == "Poster_normal.png"
assert image.colorspace_settings.name == "Non-Color"
assert image.packed_file is not None
def test_converted_color_images_are_new_and_use_blender_duplicate_suffix():
bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Poster", width=16, height=16) image = bpy.data.images.new("Poster", width=16, height=16)
source = bpy.data.objects.new("Poster", None) source = bpy.data.objects.new("Poster", None)
@@ -511,8 +524,8 @@ def test_converted_color_images_are_new_and_keep_color_as_terminal_suffix():
assert first is not image assert first is not image
assert second is not image assert second is not image
assert first is not second assert first is not second
assert first.name.endswith(".color") assert first.name == "Poster_color.png"
assert second.name.endswith(".color") assert second.name == "Poster_color.png.001"
def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
@@ -556,7 +569,11 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
if node.bl_idname == "ShaderNodeTexImage" if node.bl_idname == "ShaderNodeTexImage"
) )
assert color_texture.image is not image assert color_texture.image is not image
copied = np.empty(48 * 64 * 4, dtype=np.float32) assert tuple(color_texture.image.size) == (80, 32)
copied = np.empty(32 * 80 * 4, dtype=np.float32)
color_texture.image.pixels.foreach_get(copied) color_texture.image.pixels.foreach_get(copied)
assert np.allclose(copied.reshape((48, 64, 4)), pixels) copied = copied.reshape((32, 80, 4))
assert np.allclose(copied[:, 8:72], 1.0)
assert np.allclose(copied[:, :8], 0.0)
assert np.allclose(copied[:, 72:], 0.0)
undo.assert_called_once_with("Convert to Cutout") undo.assert_called_once_with("Convert to Cutout")

View File

@@ -562,7 +562,85 @@ class ServerTest(unittest.TestCase):
self.assertEqual(tuple(result[1, 1]), (20, 40, 60, 255)) self.assertEqual(tuple(result[1, 1]), (20, 40, 60, 255))
self.assertEqual(tuple(result[5, 7]), (0, 0, 0, 0)) self.assertEqual(tuple(result[5, 7]), (0, 0, 0, 0))
def test_mesh_job_keeps_original_ai_input_separate_from_geometry_mask(self): def test_mesh_cutout_images_crop_color_and_preserve_source_alpha(self):
import json
import numpy as np
from PIL import Image
mesh_job = sys.modules["server.jobs.mesh"]
source_path = self.storage_root / "analysis-source.png"
selection_path = self.storage_root / "selection-source.png"
output_directory = self.storage_root / "cutout-images"
output_directory.mkdir()
source = np.full((6, 8, 4), (10, 80, 160, 255), dtype=np.uint8)
source[2, 4, 3] = 64
Image.fromarray(source, mode="RGBA").save(source_path)
selection = np.zeros((6, 8, 4), dtype=np.uint8)
selection[1:5, 2:7] = (200, 20, 40, 255)
selection[1, 2] = (200, 20, 40, 0)
Image.fromarray(selection, mode="RGBA").save(selection_path)
color_path, mask_path, analysis_path, bounds_path = (
mesh_job._write_cutout_images(
source_path,
selection_path,
output_directory,
)
)
color = np.asarray(Image.open(color_path).convert("RGBA"))
mask = np.asarray(Image.open(mask_path).convert("RGBA"))
analysis = np.asarray(Image.open(analysis_path).convert("RGBA"))
self.assertEqual(json.loads(bounds_path.read_text()), [2, 1, 7, 5])
self.assertEqual(color.shape, (4, 5, 4))
self.assertEqual(tuple(color[1, 2]), (10, 80, 160, 64))
self.assertEqual(int(mask[0, 0, 3]), 0)
self.assertEqual(tuple(color[0, 0]), (10, 80, 160, 255))
self.assertEqual(tuple(analysis[0, 0]), (0, 0, 0, 0))
self.assertEqual(tuple(analysis[1, 2]), (10, 80, 160, 64))
def test_mesh_unrefined_cutout_keeps_selection_outside_the_source(self):
import json
import numpy as np
from PIL import Image
mesh_job = sys.modules['server.jobs.mesh']
source_path = self.storage_root / 'outside-source.png'
full_selection_path = self.storage_root / 'outside-selection-full.png'
output_directory = self.storage_root / 'outside-cutout'
output_directory.mkdir()
Image.new('RGBA', (8, 6), (10, 80, 160, 255)).save(source_path)
polygon = ((-2, 1), (10, 1), (10, 5), (-2, 5))
_selection_path, selection_bounds = (
mesh_job._write_unbounded_selection(
source_path,
polygon,
full_selection_path,
)
)
color_path, mask_path, analysis_path, bounds_path = (
mesh_job._write_cutout_images(
source_path,
full_selection_path,
output_directory,
selection_bounds,
)
)
color = np.asarray(Image.open(color_path).convert('RGBA'))
mask = np.asarray(Image.open(mask_path).convert('RGBA'))
analysis = np.asarray(Image.open(analysis_path).convert('RGBA'))
self.assertEqual(selection_bounds, (-2, 1, 10, 5))
self.assertEqual(json.loads(bounds_path.read_text()), [-2, 1, 10, 5])
self.assertEqual(color.shape, (4, 12, 4))
self.assertEqual(tuple(color[1, 0]), (0, 0, 0, 0))
self.assertEqual(tuple(color[1, 2]), (10, 80, 160, 255))
self.assertGreater(int(mask[1, 0, 3]), 127)
self.assertEqual(tuple(analysis[1, 0]), (0, 0, 0, 0))
def test_mesh_job_keeps_unrefined_outside_selection_with_normal(self):
import json
import numpy as np import numpy as np
from PIL import Image from PIL import Image
@@ -577,7 +655,7 @@ class ServerTest(unittest.TestCase):
check_cancelled=lambda: None, check_cancelled=lambda: None,
resource=lambda _name: object(), resource=lambda _name: object(),
) )
normal = np.zeros((1, 6, 8, 3), dtype=np.float32) normal = np.zeros((1, 4, 12, 3), dtype=np.float32)
normal[..., 0] = 1.0 normal[..., 0] = 1.0
prediction = SimpleNamespace(normal=normal) prediction = SimpleNamespace(normal=normal)
received = [] received = []
@@ -594,7 +672,7 @@ class ServerTest(unittest.TestCase):
{ {
"input": str(source_path), "input": str(source_path),
"shape": "BALLOON", "shape": "BALLOON",
"polygon": ((2, 1), (7, 1), (7, 5), (2, 5)), "polygon": ((-2, 1), (10, 1), (10, 5), (-2, 5)),
"mesh_detail": 64, "mesh_detail": 64,
"invert": False, "invert": False,
"device": "cpu", "device": "cpu",
@@ -609,16 +687,36 @@ class ServerTest(unittest.TestCase):
normal_pixels = np.asarray( normal_pixels = np.asarray(
Image.open(job_directory / result["normal"]).convert("RGB") Image.open(job_directory / result["normal"]).convert("RGB")
) )
self.assertEqual(result, {"normal": "normal.png"}) self.assertEqual(
result,
{
"selection": "selection.png",
"bounds": "bounds.json",
"normal": "normal.png",
},
)
self.assertTrue(color_path.is_file()) self.assertTrue(color_path.is_file())
self.assertFalse((job_directory / "contour.json").exists()) self.assertFalse((job_directory / "contour.json").exists())
self.assertEqual(received[0]["model"], "MOGE2_VITS_NORMAL") self.assertEqual(received[0]["model"], "MOGE2_VITS_NORMAL")
self.assertEqual(received_images, [source_path]) self.assertEqual(received_images, [job_directory / "analysis.png"])
self.assertEqual(tuple(color_pixels[3, 4, :3]), (20, 40, 60)) analysis_pixels = np.asarray(
self.assertGreater(int(color_pixels[3, 4, 3]), 240) Image.open(received_images[0]).convert("RGBA")
self.assertLess(int(color_pixels[0, 0, 3]), 5) )
self.assertEqual(tuple(normal_pixels[0, 0]), (255, 127, 127)) self.assertEqual(
self.assertEqual(tuple(normal_pixels[3, 4]), (255, 127, 127)) json.loads((job_directory / "bounds.json").read_text()),
[-2, 1, 10, 5],
)
self.assertEqual(tuple(analysis_pixels[2, 2, :3]), (20, 40, 60))
self.assertGreater(int(analysis_pixels[2, 2, 3]), 240)
self.assertEqual(tuple(analysis_pixels[2, 0]), (0, 0, 0, 0))
self.assertEqual(tuple(color_pixels[2, 2, :3]), (20, 40, 60))
self.assertGreater(int(color_pixels[2, 2, 3]), 240)
self.assertEqual(tuple(color_pixels[2, 0]), (0, 0, 0, 0))
self.assertEqual(color_pixels.shape[:2], (4, 12))
self.assertEqual(analysis_pixels.shape[:2], (4, 12))
self.assertEqual(normal_pixels.shape[:2], (4, 12))
self.assertEqual(tuple(normal_pixels[0, 0]), (128, 128, 255))
self.assertEqual(tuple(normal_pixels[2, 2]), (255, 127, 127))
def test_mesh_job_skips_moge_normal_when_option_is_disabled(self): def test_mesh_job_skips_moge_normal_when_option_is_disabled(self):
from PIL import Image from PIL import Image
@@ -648,17 +746,22 @@ class ServerTest(unittest.TestCase):
}, },
) )
self.assertEqual(result, {}) self.assertEqual(
result,
{"selection": "selection.png", "bounds": "bounds.json"},
)
write_normal.assert_not_called() write_normal.assert_not_called()
self.assertFalse((job_directory / "normal.png").exists()) self.assertFalse((job_directory / "normal.png").exists())
def test_mesh_depth_balloon_analyzes_full_source_then_applies_selection(self): def test_mesh_depth_balloon_uses_cropped_analysis_alpha(self):
import numpy as np import numpy as np
from PIL import Image from PIL import Image
mesh_job = sys.modules["server.jobs.mesh"] mesh_job = sys.modules["server.jobs.mesh"]
source_path = self.storage_root / "mesh-depth-balloon-source.png" source_path = self.storage_root / "mesh-depth-balloon-source.png"
Image.new("RGBA", (6, 6), (20, 40, 60, 255)).save(source_path) pixels = np.full((6, 6, 4), (20, 40, 60, 0), dtype=np.uint8)
pixels[2:5, 2:5, 3] = 255
Image.fromarray(pixels, mode="RGBA").save(source_path)
job_directory = self.storage_root / "mesh-depth-balloon-job" job_directory = self.storage_root / "mesh-depth-balloon-job"
job_directory.mkdir() job_directory.mkdir()
context = SimpleNamespace( context = SimpleNamespace(
@@ -684,8 +787,6 @@ class ServerTest(unittest.TestCase):
context, context,
{"device": "cpu"}, {"device": "cpu"},
source_path, source_path,
((2, 2), (10, 2), (2, 10)),
selection_size=(12, 12),
include_normal=True, include_normal=True,
) )
@@ -696,7 +797,7 @@ class ServerTest(unittest.TestCase):
self.assertFalse(bool(data["valid"][0, 0])) self.assertFalse(bool(data["valid"][0, 0]))
self.assertTrue(bool(data["valid"][2, 2])) self.assertTrue(bool(data["valid"][2, 2]))
normal = np.asarray(Image.open(normal_path).convert("RGB")) normal = np.asarray(Image.open(normal_path).convert("RGB"))
self.assertEqual(tuple(normal[0, 0]), (255, 127, 127)) self.assertEqual(tuple(normal[0, 0]), (128, 128, 255))
self.assertEqual(tuple(normal[2, 2]), (255, 127, 127)) self.assertEqual(tuple(normal[2, 2]), (255, 127, 127))
def test_mesh_job_uses_upscale_source_only_for_analysis(self): def test_mesh_job_uses_upscale_source_only_for_analysis(self):

View File

@@ -1428,12 +1428,17 @@ class WorkerTest(unittest.TestCase):
np.testing.assert_array_equal(filtered, faces[[0]]) np.testing.assert_array_equal(filtered, faces[[0]])
def test_surface_background_mask_keeps_any_nonzero_alpha(self): def test_surface_background_mask_uses_half_alpha_threshold(self):
from PIL import Image from PIL import Image
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary) root = Path(temporary)
for alpha, expected in ((0, False), (1, True), (127, True)): for alpha, expected in (
(0, False),
(127, False),
(128, True),
(255, True),
):
path = root / f"alpha-{alpha}.png" path = root / f"alpha-{alpha}.png"
Image.new("RGBA", (2, 2), (10, 20, 30, alpha)).save(path) Image.new("RGBA", (2, 2), (10, 20, 30, alpha)).save(path)
mask = self.geometry.foreground_mesh_mask(path, (2, 2)) mask = self.geometry.foreground_mesh_mask(path, (2, 2))