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:
46
README.md
46
README.md
@@ -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 自身异常。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
|
|||||||
@@ -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 和对应模型。
|
||||||
|
|
||||||
|
|||||||
@@ -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 会转换为面片的空间位置与比例。
|
||||||
|
|
||||||
## 单图与动画文件
|
## 单图与动画文件
|
||||||
|
|
||||||
|
|||||||
@@ -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 的设备枚举和面板数据按需导入,避免循环依赖。
|
||||||
|
|
||||||
|
|||||||
@@ -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 是否保留由转换选项决定。
|
||||||
|
|
||||||
|
|||||||
@@ -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 管线
|
||||||
|
|||||||
@@ -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 像素;材质的空间选择只负责匹配已经生成好的贴图。
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|
||||||
|
|||||||
@@ -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` 结尾的结果图片。
|
||||||
|
|
||||||
## 模型选择
|
## 模型选择
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -286,11 +286,16 @@ 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
|
||||||
|
|
||||||
segments = dashed_line_segments(vertices)
|
if segments is None:
|
||||||
|
segments = dashed_line_segments(vertices)
|
||||||
if not segments:
|
if not segments:
|
||||||
return
|
return
|
||||||
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,16 +1640,25 @@ 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,
|
source_path, delete_input = prepare_image_input(
|
||||||
delete_input,
|
source_object.data,
|
||||||
analysis_path,
|
context.scene,
|
||||||
delete_analysis_input,
|
)
|
||||||
) = prepare_image_analysis_inputs(
|
analysis_path = source_path
|
||||||
source_object.data, context.scene
|
delete_analysis_input = False
|
||||||
)
|
analysis_image = source_object.data
|
||||||
|
else:
|
||||||
|
(
|
||||||
|
source_path,
|
||||||
|
delete_input,
|
||||||
|
analysis_path,
|
||||||
|
delete_analysis_input,
|
||||||
|
) = prepare_image_analysis_inputs(
|
||||||
|
source_object.data, context.scene
|
||||||
|
)
|
||||||
|
analysis_image = analysis_source_image(source_object.data)
|
||||||
model, process_factor, resolution_level = _depth_settings(context, shape)
|
model, process_factor, resolution_level = _depth_settings(context, shape)
|
||||||
analysis_image = analysis_source_image(source_object.data)
|
|
||||||
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_")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user