diff --git a/README.md b/README.md index 820fffb..c55b6ab 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ AnyImage 是一个 Blender 扩展,可以把一张照片、一段视频或一 - 在 3D View、Node Editor 与绘画模式中按 `Ctrl+V`(macOS 为 `Cmd+V`)直接粘贴并 Pack 剪贴板图片 - 不运行 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 编辑会直接更新未共享的图片;图片被多个 Image Empty 共用时,只为当前 Empty 分离编辑结果 - 用 Image Perspective 勾勒四个角点,把透视四边形矫正为矩形裁切图,并沿用源 Image Empty 的空间朝向 - 一键把 Image Empty 转换为立体面片:让 AI 估算深度,或直接用原图作为深度 - 把单张 Image Empty 转换为透视纹理网格,并自动匹配原对象的 Transform 与 Offset -- 一键去除图片背景,生成透明前景图 +- 一键去除图片背景,成成透明前景图 - 用三档 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) 2. 右键打开 `AnyImage` 子菜单,选择 `Convert to Depth Plane` 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 的倍数 - `Remove Background` 默认关闭;开启后在转换期间使用 BEN2 删除背景 - - `Shadeless` 使用 `O Shadeless` 节点组显示不受场景灯光影响的贴图颜色 + - `Shadeless` 使用 `O Shadeless` 节点组显示不受场景灯光影响的贴图色色 - `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,切换其他工具会取消当前绘制。 -绘制中按 `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 都可以使用。 -第四个角点按下时会在 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。 @@ -80,25 +80,25 @@ Image Perspective 是 deform 工具,不提供 Refine 或反选。它与其他 ### 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)。 `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。 -按住左键沿目标轮廓绘制,松开后生成一个带原图 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 -- `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 的交互圆,数值越小网格越密 +- `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 的交互圆,数值越小网格越密 `Resolution (m)` 会根据 Scene Unit Scale 换算为网格使用的世界距离,因此不同单位设置下仍代表相同的物理采样间距。Poisson 模式使用尺度无关的求解判断,支持 `0.02 m` 等较密采样。两个 AI 选项默认关闭;`Contour` 使用 BEN2 Alpha 严格大于 `0.5` 的最大连通前景重建轮廓,`Depth` 使用所选 MoGe-2 模型。MoGe 的物理距离会先减去稳健拟合的基面,再换算为不受绝对拍摄距离影响的相对起伏;近侧与远侧信号都保留,不削顶、不截平。 -Depth Balloon 使用局部 X 作为深度轴,并创建 ` 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 作为深度轴,并创建 ` 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,后续套索会把自己的有效区域合并进去,重叠区域采用最新结果。旧材质仍保留节点识别回退。当前只支持静态图片。 @@ -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 与边拉伸 数据。两者再由独立的 Geometry Nodes 修改器完成剔面、快速重拓扑、Relax 与 UV 重投;顶层 `Iterations` 可设为 `0`。开启 Remove Background -时,BEN2 Alpha 只要大于 `0` 就视为前景;三角形三个顶点必须全部为前景,任一顶点 Alpha 为 `0` -都会删除该面并且不再交给 Geometry Nodes;Surface 材质使用生成的颜色图片。匹配相机默认不创建;开启后会作为 Surface 的子级。 +时,BEN2 Alpha 大于等于 `0.5` 才视为前景;三角形三个顶点必须全部为前景,任一顶点 Alpha 小于 `0.5` +都会删除该面并且不再交给 Geometry Nodes;Surface 材质使用成成的色色图片。匹配相机默认不创建;开启后会作为 Surface 的子级。 MoGe-2 的像素级 normal 会作为 object-space normal 贴图接入 `O Image Layer`,并与原图 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。模型执行、相机与深度恢复、 -点云重投影和网格生成均不依赖 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。 转换对话框提供模型与 `Create Matching Camera`、`Remove Background`、`Shadeless`;DA3 显示 `Resolution Scale`,MoGe-2 显示 `Resolution Level`, 以及分割线下方的 `Keep Original`。 界面会显示计算所得的实际 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 的 Layer Color 同时驱动 Principled BSDF 的 Base Color、Subsurface Radius 和 Emission Color,Layer Normal 同时驱动 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 -Session 逐帧推理,显存峰值不随视频帧数平方增长。Upscale 也会逐帧生成受输出上限约束的 4× 颜色序列并写回 Empty。Debug 只保存模型 API 的直接返回:DA3 多帧输入 +Session 逐帧推理,显存峰值不随视频帧数平方增长。Upscale 也会逐帧成成受输出上限约束的 4× 色色序列并写回 Empty。Debug 只保存模型 API 的直接返回:DA3 多帧输入 保留联合推理,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 自身异常。 ## 常见问题 diff --git a/docs/cutout.md b/docs/cutout.md index c855d20..b05ef60 100644 --- a/docs/cutout.md +++ b/docs/cutout.md @@ -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,因此内部点保持均匀,外缘不会退化成规则栅格的阶梯锯齿。 @@ -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 和对应模型。 diff --git a/docs/depth-plane.md b/docs/depth-plane.md index e087a70..78ba346 100644 --- a/docs/depth-plane.md +++ b/docs/depth-plane.md @@ -38,13 +38,13 @@ DA3 和 MoGe-2 都以未经抠图的 RGB 估计深度。启用 Remove Background 转换会创建以下数据: - 与源 Image Empty 对齐的面片 -- `.depth` 与沿用源图名称的颜色 Image datablock +- 使用 `_depth.exr` 与 `_color.png` 后缀的 Image datablock - 使用 `O DA3 Depth Plane` 或 `O MoGe2 Depth Plane` 的 Geometry Nodes 修改器 - 连接颜色、Alpha、Bump 与材质位移的材质 Geometry Nodes 提供深度范围、细分、重拓扑、平滑、背景深度和位移强度等控制。深度 Mask 在几何和材质路径中使用相同阈值,以保持轮廓一致。 -材质与图片名称以源 Image datablock 为基名。对象 Transform、显示尺寸和 Offset 会转换为面片的空间位置与比例。 +材质与图片名称以源 Image datablock 为基名。生成贴图将业务后缀放在真实文件扩展名前,避免 Blender 将其误判为源图数据块。对象 Transform、显示尺寸和 Offset 会转换为面片的空间位置与比例。 ## 单图与动画文件 diff --git a/docs/development.md b/docs/development.md index 7e7bc0c..1429f8f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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`。 -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..anyimage` 命名空间;不得按第一个点截断,否则会错误查找 `bl_ext` 并静默回退默认值。Preferences 的设备枚举和面板数据按需导入,避免循环依赖。 diff --git a/docs/features.md b/docs/features.md index 1c3cf39..698e9ae 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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`。 -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 固定正面,只向背面挤出。 -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 是否保留由转换选项决定。 diff --git a/docs/image-tools-technical.md b/docs/image-tools-technical.md index 0ee2879..6a84d83 100644 --- a/docs/image-tools-technical.md +++ b/docs/image-tools-technical.md @@ -52,6 +52,8 @@ Box / Lasso 的顺序固定为 **Refine → Invert**。BEN2 只提供内容 Alph 未形成有效区域的单击直接返回 `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` 完成;完成时显式闭合,不把当前游标位置作为额外顶点。 ## Perspective 管线 diff --git a/docs/outputs.md b/docs/outputs.md index 7bdbd6e..2ef9b8d 100644 --- a/docs/outputs.md +++ b/docs/outputs.md @@ -11,7 +11,7 @@ | `remove-background` | `image.0001.png` 起 | 带 Alpha 的图片层 | | `upscale-image` | `upscale.0001.png` 起 | 所选高清模型在输出上限内生成的 4× 高清层,并保留源图 Alpha | | `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 深度、法线 | | `debug-da3` | `prediction.npz` | DA3 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`。 -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 使用的网格属性。 -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 像素;材质的空间选择只负责匹配已经生成好的贴图。 diff --git a/docs/server.md b/docs/server.md index 1fb7e68..ac5314e 100644 --- a/docs/server.md +++ b/docs/server.md @@ -71,7 +71,7 @@ Image Box 与 Lasso 的 `Refine Selection` 使用 `refine-image-mask` Job。它 - BEN2 Base - 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。 diff --git a/docs/upscale.md b/docs/upscale.md index ab2bca2..eea91ff 100644 --- a/docs/upscale.md +++ b/docs/upscale.md @@ -8,7 +8,7 @@ Upscale 用 ONNX 模型把 Image Empty 的图片放大 4 倍,不创建网格 2. 按需要设置 **Maximum Upscale Size**;默认 4096 px,最高 8192 px。 3. 选中 Image Empty,在右键 **AnyImage** 菜单中点击 **Upscale**。 -较大的输入会先按比例缩小,使输出不超过尺寸和总像素上限。完成后,当前 Empty 会改用名称以 `.upscale` 结尾的结果图片。 +较大的输入会先按比例缩小,使输出不超过尺寸和总像素上限。完成后,当前 Empty 会改用名称以 `_upscale.png` 结尾的结果图片。 ## 模型选择 diff --git a/src/anyimage/operators/balloon/operator.py b/src/anyimage/operators/balloon/operator.py index 5c5e859..e32c5ca 100644 --- a/src/anyimage/operators/balloon/operator.py +++ b/src/anyimage/operators/balloon/operator.py @@ -267,11 +267,12 @@ def extract_balloon_object( depth_data=None, normal_path=None, material_image=None, + local_bounds=None, bake_normal_to_tangent=False, ): image = material_image or source_object.data 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) point_spacing, inflation = balloon_geometry_parameters( thickness, diff --git a/src/anyimage/operators/cutout.py b/src/anyimage/operators/cutout.py index f812d9e..49cd935 100644 --- a/src/anyimage/operators/cutout.py +++ b/src/anyimage/operators/cutout.py @@ -26,9 +26,9 @@ CUTOUT_DETAIL_MIN = 4 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) - 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)) @@ -69,9 +69,9 @@ def _open_shape_pie( source_name = source_object.name selection_data = json.dumps(polygon, separators=(",", ":")) 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)) - 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): pie = menu.layout.menu_pie() @@ -147,6 +147,7 @@ class SelectCutoutRegion(ImageMaskGesture, bpy.types.Operator): gesture: gesture_property() full_image: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) + preview_fill_rule = 'NONZERO' _pie_event = None def _set_status(self, context): @@ -250,6 +251,6 @@ class CutoutTool(bpy.types.WorkSpaceTool): def draw_settings(context, layout, _tool): settings = context.scene.anyimage_settings - layout.prop(settings, "cutout_pixel_size") + layout.prop(settings, "cutout_point_spacing") layout.prop(settings, "cutout_normal_analysis") draw_image_mask_settings(context, layout) diff --git a/src/anyimage/operators/image.py b/src/anyimage/operators/image.py index fc8eb6b..17def9f 100644 --- a/src/anyimage/operators/image.py +++ b/src/anyimage/operators/image.py @@ -141,11 +141,11 @@ def image_base_name(image): 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): - 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): @@ -155,8 +155,8 @@ def copy_depth_image(source_object): return depth_image -def color_data_name(source_object, suffix=".color"): - return f"{image_base_name(source_object.data)}{suffix}" +def color_data_name(source_object): + return f"{image_base_name(source_object.data)}_color.png" def material_data_name(source_object): diff --git a/src/anyimage/operators/image_edit/common.py b/src/anyimage/operators/image_edit/common.py index 3aa364b..5ed3dc5 100644 --- a/src/anyimage/operators/image_edit/common.py +++ b/src/anyimage/operators/image_edit/common.py @@ -286,11 +286,16 @@ def dashed_line_segments(vertices, dash_length=4.0, gap_length=4.0): 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 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: return 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") -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) + 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 = [] - for index in range(1, len(vertices) - 1): - triangles.extend((vertices[0], vertices[index], vertices[index + 1])) + epsilon = 1e-8 + 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) -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 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: return shader = gpu.shader.from_builtin("UNIFORM_COLOR") diff --git a/src/anyimage/operators/image_edit/mask.py b/src/anyimage/operators/image_edit/mask.py index 808fba8..7452e22 100644 --- a/src/anyimage/operators/image_edit/mask.py +++ b/src/anyimage/operators/image_edit/mask.py @@ -13,7 +13,6 @@ from ..balloon.operator import ( hidden_string_property, screen_path_to_image_pixels, serialize_matrix, - simplify_screen_path, ) from ..common import HIDDEN, cleanup_temporary_input, push_undo from ..image import ( @@ -28,12 +27,14 @@ from .common import ( active_view3d_tool_id, apply_image_mask_settings, crop_pixels_to_alpha, + dashed_line_segments, draw_dashed_line, draw_image_mask_operator_settings, draw_polygon_fill, image_edit_poll, image_mask_refine_request, polygon_bounds, + preview_fill_triangles, replace_empty_image_pixels, report_image_edit_exception, resolve_image_edit_click, @@ -47,6 +48,45 @@ GESTURE_TOOL_IDS = { } LINE_SNAP_RADIANS = math.radians(5.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): @@ -265,9 +305,14 @@ def image_line_half_plane_polygon(line, image_size): class ImageMaskGesture: active_tool_id = None + preview_fill_rule = 'EVEN_ODD' _path = None _cursor = None _handle = None + _fill_polygon = None + _fill_triangles = () + _outline_polygon = None + _outline_segments = () _area_pointer = 0 _region_pointer = 0 _region_size = (0, 0) @@ -283,7 +328,7 @@ class ImageMaskGesture: if self.gesture == "BOX": return box_screen_path(self._path[0], self._cursor) if self.gesture == "LASSO": - return tuple(simplify_screen_path(self._path)) + return tuple(self._path) if self.gesture == "LINE": return line_half_plane_polygon( self._path[0], self._cursor, self._region_size @@ -299,16 +344,29 @@ class ImageMaskGesture: ): return 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 polygon != self._fill_polygon: + self._fill_polygon = polygon + self._fill_triangles = preview_fill_triangles( + polygon, self.preview_fill_rule + ) draw_polygon_fill( polygon, + self._fill_triangles, (1.0, 0.25, 0.2, 0.16) if self.invert else (1.0, 1.0, 1.0, 0.16), ) if self.gesture == "LINE": - draw_dashed_line((self._path[0], self._cursor)) + draw_dashed_line((), segments=self._outline_segments) return if len(polygon) > 1: - draw_dashed_line((*polygon, polygon[0])) + draw_dashed_line((), segments=self._outline_segments) if ( self.gesture == "POLYLINE" and len(self._path) >= 3 @@ -382,6 +440,10 @@ class ImageMaskGesture: point = (float(event.mouse_region_x), float(event.mouse_region_y)) self._path = [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._region_pointer = context.region.as_pointer() self._region_size = (context.region.width, context.region.height) @@ -447,8 +509,7 @@ class ImageMaskGesture: if event.type == "MOUSEMOVE": point = (float(event.mouse_region_x), float(event.mouse_region_y)) if self.gesture == "LASSO": - if math.dist(point, self._path[-1]) >= 2.0: - self._path.append(point) + append_lasso_point(self._path, point) self._cursor = point elif self.gesture == "LINE": 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": point = (float(event.mouse_region_x), float(event.mouse_region_y)) if self.gesture == "LASSO": - if math.dist(point, self._path[-1]) >= 2.0: - self._path.append(point) + append_lasso_point(self._path, point) self._cursor = point elif self.gesture == "LINE": self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl) diff --git a/src/anyimage/operators/mesh.py b/src/anyimage/operators/mesh.py index 01a4904..cbaddf3 100644 --- a/src/anyimage/operators/mesh.py +++ b/src/anyimage/operators/mesh.py @@ -27,13 +27,16 @@ from .common import ( ) from .image import ( analysis_source_image, + color_data_name, create_color_image, image_base_name, image_user_settings, is_image_empty, material_data_name, + normal_data_name, plane_bounds, prepare_image_analysis_inputs, + prepare_image_input, remove_temporary_input, scaled_process_resolution, video_total_frames, @@ -114,11 +117,11 @@ def _depth_field_image(source_object, anchored): 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 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_pixels, image_size, @@ -168,12 +171,12 @@ def _load_field(path): 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 points, intrinsics, valid, image_size, reference_depth = field 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_height = y_max - y_min fx, fy = float(intrinsics[0, 0]), float(intrinsics[1, 1]) @@ -950,17 +953,6 @@ def _mesh_node_group(shape): 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): source = source_object.data @@ -976,7 +968,7 @@ def _copy_color_image(source_object): source.pixels.foreach_get(pixels) image.pixels.foreach_set(pixels) image.update() - image.name = _unique_color_name(source_object, image) + image.name = color_data_name(source_object) return image @@ -989,9 +981,81 @@ def _image_rgba(image): 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)) - return alpha_regions(alpha, max_resolution=contour_resolution) + return alpha_regions( + alpha, + max_resolution=contour_resolution, + threshold=threshold, + ) def _antialias_selection(selection): @@ -1089,7 +1153,7 @@ def _local_cutout_rgba(source_object, polygon, invert=False): def _rename_color_image(image, source_object): - image.name = _unique_color_name(source_object, image) + image.name = color_data_name(source_object) return image @@ -1120,18 +1184,29 @@ def _build_mesh_object( field=None, regions=None, image_size=None, + bounds=None, keep_original=False, ): 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) domain = ( _plane_domain(bounds, detail, world_metric) 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 face_discontinuity = None if shape in {PLANE, DEPTH_PLANE}: @@ -1223,13 +1298,13 @@ def _build_mesh_object( def _load_normal_image(path, source_object): 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.pack() return image -def _balloon_resolution(source_object, detail): +def _cutout_point_spacing(source_object, detail): import math 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 if not isinstance(polygon, list) or len(polygon) < 3: raise ValueError("The Cutout selection is invalid") - rgba, selection = _local_cutout_rgba(source_object, polygon, invert) - regions, image_size = _geometry_cutout_regions( - rgba[:, :, 3], - polygon, + _rgba, selection = _local_cutout_rgba(source_object, polygon, invert) + source_size = tuple(source_object.data.size) + if invert: + 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, - invert, - refine_selection=False, - selection=selection, + threshold=0.5, + ) + local_bounds = _crop_plane_bounds( + source_object, + crop_bounds, + source_size, ) color_image = None material = None try: - color_image = _copy_color_image(source_object) + color_image = _cropped_color_image(source_object, crop_bounds) if shape == BALLOON: result_object = extract_balloon_object( context, @@ -1273,12 +1369,15 @@ def _build_local_cutout( source_object.matrix_world.copy(), "POISSON", thickness=1.0, - resolution=_balloon_resolution(source_object, operator.mesh_detail), + resolution=_cutout_point_spacing( + source_object, operator.mesh_detail + ), select_result=True, reuse_material=False, depth_data=None, normal_path=None, material_image=color_image, + local_bounds=local_bounds, ) result_object["anyimage_mesh_shape"] = shape else: @@ -1291,6 +1390,7 @@ def _build_local_cutout( material, regions=regions, image_size=image_size, + bounds=local_bounds, keep_original=True, ) except Exception: @@ -1305,27 +1405,38 @@ def _build_response(context, result, operator): source_object = bpy.data.objects.get(operator.source_object_name) if not is_image_empty(source_object): 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: - 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, result.directory, 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: color_image = _rename_color_image( create_color_image( @@ -1350,12 +1461,13 @@ def _build_response(context, result, operator): source_object.matrix_world.copy(), "POISSON", thickness=1.0, - resolution=_balloon_resolution(source_object, operator.mesh_detail), + resolution=_cutout_point_spacing(source_object, operator.mesh_detail), select_result=True, reuse_material=False, depth_data=depth_data, normal_path=normal_path if normal_path.is_file() else None, material_image=color_image, + local_bounds=local_bounds, bake_normal_to_tangent=normal_path.is_file(), ) balloon["anyimage_mesh_shape"] = operator.shape @@ -1390,6 +1502,7 @@ def _build_response(context, result, operator): field=field, regions=regions, image_size=image_size, + bounds=local_bounds, keep_original=operator.shape in CUTOUT_SHAPES, ) 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()}") - def _depth_settings(context, shape): preferences = addon_preferences(context) model = getattr(preferences, "mesh_depth_model", DEFAULT_MOGE2_MODEL_KEY) @@ -1528,16 +1640,25 @@ def _start_job( if setup_result is not None: return setup_result try: - ( - source_path, - delete_input, - analysis_path, - delete_analysis_input, - ) = prepare_image_analysis_inputs( - source_object.data, context.scene - ) + if shape in CUTOUT_SHAPES: + source_path, delete_input = prepare_image_input( + source_object.data, + context.scene, + ) + analysis_path = source_path + delete_analysis_input = False + analysis_image = source_object.data + else: + ( + source_path, + 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) - analysis_image = analysis_source_image(source_object.data) process_res = ( scaled_process_resolution(analysis_image, process_factor) if shape in DEPTH_SHAPES and not model.startswith("MOGE2_") diff --git a/src/anyimage/operators/upscale.py b/src/anyimage/operators/upscale.py index 832666e..ef1b011 100644 --- a/src/anyimage/operators/upscale.py +++ b/src/anyimage/operators/upscale.py @@ -147,7 +147,7 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator): replaced_image.use_fake_user = False if int(replaced_image.users) == 0: 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 model_label = upscale_model_label(self.model) if getattr(self, "_rebuild_from_original", False): diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py index d76f7d0..c71594e 100644 --- a/src/anyimage/properties.py +++ b/src/anyimage/properties.py @@ -332,11 +332,11 @@ def cutout_normal_analysis_property(): default=False, ) -def cutout_pixel_size_property(): +def cutout_point_spacing_property(): return bpy.props.IntProperty( - name="Pixel Size", + name="Point Spacing", description=( - "Approximate source image pixels per mesh step; smaller values " + "Source image pixels between neighboring mesh points; smaller values " "create denser geometry" ), default=32, @@ -372,7 +372,7 @@ def balloon_radial_zoom_property(scale): 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() image_edit_refine_selection: image_edit_refine_selection_property() balloon_mode: balloon_mode_property() diff --git a/src/anyimage/server/geometry/cutout.py b/src/anyimage/server/geometry/cutout.py index f0d1cd7..28e4ec6 100644 --- a/src/anyimage/server/geometry/cutout.py +++ b/src/anyimage/server/geometry/cutout.py @@ -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) if sampled_size != source_size: values = _resize_alpha(values, sampled_size) - mask = values > round(255.0 * threshold) + mask = values >= 255.0 * threshold regions = mask_regions(mask) scale_x = source_size[0] / sampled_size[0] scale_y = source_size[1] / sampled_size[1] diff --git a/src/anyimage/server/geometry/grid_mesh.py b/src/anyimage/server/geometry/grid_mesh.py index 36a4464..305485e 100644 --- a/src/anyimage/server/geometry/grid_mesh.py +++ b/src/anyimage/server/geometry/grid_mesh.py @@ -116,7 +116,7 @@ def foreground_mesh_mask(foreground_path, depth_shape): resample=Image.Resampling.BILINEAR, ) 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): diff --git a/src/anyimage/server/jobs/mesh.py b/src/anyimage/server/jobs/mesh.py index d39a4ee..03e8c7e 100644 --- a/src/anyimage/server/jobs/mesh.py +++ b/src/anyimage/server/jobs/mesh.py @@ -1,3 +1,5 @@ +import json + from ..media import copy_color_images from ..models import geometry_adapter, moge2 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): import numpy as np - from PIL import Image, ImageChops, ImageFilter, ImageOps + from PIL import Image, ImageFilter, ImageOps with Image.open(input_path) as opened: 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)) if invert: selection = ImageOps.invert(selection) - alpha = ImageChops.multiply(source.getchannel("A"), selection) 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 Image.fromarray(pixels, mode="RGBA").save( output_path, @@ -67,6 +68,37 @@ def _write_masked_color(input_path, polygon, output_path, invert=False): 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): from PIL import Image, ImageChops @@ -100,6 +132,77 @@ def _write_refined_color(context, parameters, input_path, polygon, 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): import numpy as np from PIL import Image @@ -148,25 +251,15 @@ def _write_balloon_depth( context, parameters, input_path, - polygon, - selection_size=None, include_normal=False, ): + import numpy as np from PIL import Image with Image.open(input_path) as source: + rgba = np.asarray(source.convert("RGBA"), dtype=np.uint8) source_size = source.size - selection_size = selection_size or source_size - 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", - ) + selection_mask = rgba[:, :, 3] >= 128 prediction = moge2.infer( context.resource("model_manager"), parameters, @@ -190,7 +283,6 @@ def _write_balloon_depth( ) return depth_path, normal_path - def _write_surface_depth( 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") shape = parameters["shape"] polygon = parameters.get("polygon") + analysis_path = None + result = {} if polygon: + selection_bounds = None + full_selection_path = context.directory / "selection-full.png" if parameters.get("refine_selection"): - color_path = _write_refined_color( + _write_refined_color( context, parameters, images[0], polygon, - context.directory / "image.0001.png", + full_selection_path, ) else: - color_path = _write_masked_color( + _selection_path, selection_bounds = _write_unbounded_selection( images[0], polygon, - context.directory / "image.0001.png", + full_selection_path, 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: copy_color_images(images, context.directory) color_path = min(context.directory.glob("image.0001.*")) - result = {} + normal_analysis = bool(parameters.get("normal_analysis")) with analysis_frames(context, parameters, images) as model_images: if len(model_images) != 1: 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: context.progress(0.4, "Estimating surface normal") result["normal"] = _write_camera_normal( - context, parameters, analysis_path + context, parameters, model_path ).name 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") depth_path, normal_path = _write_balloon_depth( context, parameters, - analysis_path, - polygon, - selection_size, + model_path, include_normal=normal_analysis, ) result["depth"] = depth_path.name @@ -283,7 +385,7 @@ def run(context, parameters): field_path, normal_path = _write_surface_depth( context, parameters, - analysis_path, + model_path, include_normal=normal_analysis, ) result["field"] = field_path.name @@ -293,7 +395,7 @@ def run(context, parameters): and shape == "DEPTH_SURFACE" ): normal_path = _write_camera_normal( - context, parameters, analysis_path + context, parameters, model_path ) if normal_path is not None: result["normal"] = normal_path.name diff --git a/tests/test_addon.py b/tests/test_addon.py index 0489e24..f2d4fe4 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -908,13 +908,8 @@ class AddonTest(unittest.TestCase): ) as setup, patch.object( self.mesh, - "prepare_image_analysis_inputs", - return_value=( - Path("input.png"), - False, - Path("input.png"), - False, - ), + "prepare_image_input", + return_value=(Path("input.png"), False), ), patch.object( self.mesh, @@ -936,6 +931,10 @@ class AddonTest(unittest.TestCase): setup.assert_called_once_with() 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["analysis_input_path"], + "input.png", + ) self.assertEqual( generate_mesh.call_args.kwargs["normal_model"], self.addon.properties.DEFAULT_MOGE2_MODEL_KEY, @@ -1311,7 +1310,7 @@ class AddonTest(unittest.TestCase): result.directory, frame_prefix="upscale.", ) - self.assertEqual(result_image.name, "Original.upscale") + self.assertEqual(result_image.name, "Original_upscale.png") self.assertIs( result_image.anyimage_upscale_source, original_image, @@ -1466,13 +1465,13 @@ class AddonTest(unittest.TestCase): self.assertIs(created, result_image) 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) def test_background_result_replaces_only_an_unshared_image(self): for remaining_users, removed_source, result_name in ( (0, True, "Source.png"), - (1, False, "Source.color"), + (1, False, "Source_color.png"), ): with self.subTest(remaining_users=remaining_users): source_image = SimpleNamespace( @@ -1486,7 +1485,7 @@ class AddonTest(unittest.TestCase): empty_display_type="IMAGE", data=source_image, ) - result_image = SimpleNamespace(name="Source.color") + result_image = SimpleNamespace(name="Source_color.png") removed = [] self.fake_bpy.data = SimpleNamespace( objects={"Source": source_object}, @@ -1574,6 +1573,25 @@ class AddonTest(unittest.TestCase): self.assertFalse(even_odd[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): import numpy as np @@ -1857,9 +1875,9 @@ class AddonTest(unittest.TestCase): self.assertNotIn("padding", operator.__annotations__) settings = self.addon.properties.AnyImageSettings.__annotations__ self.assertFalse(settings["image_edit_refine_selection"]["default"]) - self.assertEqual(settings["cutout_pixel_size"]["default"], 32) - self.assertEqual(settings["cutout_pixel_size"]["min"], 4) - self.assertEqual(settings["cutout_pixel_size"]["max"], 32) + self.assertEqual(settings["cutout_point_spacing"]["default"], 32) + self.assertEqual(settings["cutout_point_spacing"]["min"], 4) + self.assertEqual(settings["cutout_point_spacing"]["max"], 32) self.assertFalse(settings["cutout_normal_analysis"]["default"]) self.assertNotIn("image_extract_continuous", 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))) - 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.image_edit_common.triangle_fan_vertices( - ((0, 0), (2, 0), (2, 2), (0, 2)) - ), - ( - (0.0, 0.0), - (2.0, 0.0), - (2.0, 2.0), - (0.0, 0.0), - (2.0, 2.0), - (0.0, 2.0), - ), + triangles, + ((0.0, 0.0), (2.0, 0.0), (0.0, 2.0)), + ) + + def test_image_mask_fill_falls_back_when_blender_tessellation_rejects_path(self): + mathutils = ModuleType('mathutils') + mathutils.Vector = lambda values: values + mathutils.geometry = SimpleNamespace( + tessellate_polygon=lambda _loops: (_ for _ in ()).throw(TypeError()) + ) + 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): @@ -2225,6 +2318,62 @@ class AddonTest(unittest.TestCase): ((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): import inspect diff --git a/tests/test_cutout.py b/tests/test_cutout.py index a8c5297..a641576 100644 --- a/tests/test_cutout.py +++ b/tests/test_cutout.py @@ -31,9 +31,9 @@ class WindowManager: 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( - cutout_pixel_size=pixel_size, + cutout_point_spacing=point_spacing, cutout_normal_analysis=normal_analysis, ) 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(): window_manager = WindowManager() 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)) diff --git a/tests/test_image_depth.py b/tests/test_image_depth.py index e547714..6c5adee 100644 --- a/tests/test_image_depth.py +++ b/tests/test_image_depth.py @@ -383,7 +383,7 @@ class ImageDepthTest(unittest.TestCase): self.assertEqual( self.image_depth.color_data_name(source_object), - "clip.color", + "clip_color.png", ) def test_force_sequence_source_keeps_loaded_sequence(self): @@ -684,12 +684,12 @@ class ImageDepthTest(unittest.TestCase): self.assertEqual( self.image_depth.depth_data_name(source_object), - "img.depth", + "img_depth.exr", ) source_object.data.name = "img.png.001" self.assertEqual( 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): @@ -700,7 +700,7 @@ class ImageDepthTest(unittest.TestCase): self.assertEqual( self.image_depth.normal_data_name(source_object), - "img.normal", + "img_normal.png", ) 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) - 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): source_object = SimpleNamespace( @@ -724,7 +724,7 @@ class ImageDepthTest(unittest.TestCase): self.assertEqual( self.image_depth.color_data_name(source_object), - "img.color", + "img_color.png", ) self.assertEqual( self.image_depth.material_data_name(source_object), @@ -733,7 +733,7 @@ class ImageDepthTest(unittest.TestCase): source_object.data.name = "img.png.001" self.assertEqual( self.image_depth.color_data_name(source_object), - "img.001.color", + "img.001_color.png", ) self.assertEqual( self.image_depth.material_data_name(source_object), diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 0eac52f..6297bc4 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -2,6 +2,7 @@ import builtins import sys import tempfile from pathlib import Path +from types import SimpleNamespace from unittest.mock import patch import numpy as np @@ -169,6 +170,34 @@ def test_alpha_regions_accepts_blender_float_alpha_with_a_hole(): 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(): 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[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(): regions = ( (((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, (4096, 4096), (-1.0, 1.0, -1.0, 1.0), - 4, + 0.5, ((1.0, 0.0), (0.0, 1.0)), ) @@ -278,7 +322,7 @@ def test_cutout_domain_builds_a_full_image_at_minimum_detail(): regions, (4096, 4096), (-1.0, 1.0, -1.0, 1.0), - 4, + 0.5, ((1.0, 0.0), (0.0, 1.0)), ) @@ -299,7 +343,7 @@ def test_cutout_domain_keeps_overlapping_regions_disconnected(): regions, (120, 80), (0.0, 1.0, 0.0, 1.0), - 40, + 0.025, ((1.0, 0.0), (0.0, 1.0)), ) diff --git a/tests/test_mesh_blender.py b/tests/test_mesh_blender.py index a5d245e..bbf0a1f 100644 --- a/tests/test_mesh_blender.py +++ b/tests/test_mesh_blender.py @@ -497,7 +497,20 @@ def test_depth_surface_protected_smooth_evaluates(): 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) image = bpy.data.images.new("Poster", width=16, height=16) 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 second is not image assert first is not second - assert first.name.endswith(".color") - assert second.name.endswith(".color") + assert first.name == "Poster_color.png" + assert second.name == "Poster_color.png.001" 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" ) 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) - 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") diff --git a/tests/test_server.py b/tests/test_server.py index d95a7e8..9b506bd 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -562,7 +562,85 @@ class ServerTest(unittest.TestCase): self.assertEqual(tuple(result[1, 1]), (20, 40, 60, 255)) 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 from PIL import Image @@ -577,7 +655,7 @@ class ServerTest(unittest.TestCase): check_cancelled=lambda: None, 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 prediction = SimpleNamespace(normal=normal) received = [] @@ -594,7 +672,7 @@ class ServerTest(unittest.TestCase): { "input": str(source_path), "shape": "BALLOON", - "polygon": ((2, 1), (7, 1), (7, 5), (2, 5)), + "polygon": ((-2, 1), (10, 1), (10, 5), (-2, 5)), "mesh_detail": 64, "invert": False, "device": "cpu", @@ -609,16 +687,36 @@ class ServerTest(unittest.TestCase): normal_pixels = np.asarray( 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.assertFalse((job_directory / "contour.json").exists()) self.assertEqual(received[0]["model"], "MOGE2_VITS_NORMAL") - self.assertEqual(received_images, [source_path]) - self.assertEqual(tuple(color_pixels[3, 4, :3]), (20, 40, 60)) - self.assertGreater(int(color_pixels[3, 4, 3]), 240) - self.assertLess(int(color_pixels[0, 0, 3]), 5) - self.assertEqual(tuple(normal_pixels[0, 0]), (255, 127, 127)) - self.assertEqual(tuple(normal_pixels[3, 4]), (255, 127, 127)) + self.assertEqual(received_images, [job_directory / "analysis.png"]) + analysis_pixels = np.asarray( + Image.open(received_images[0]).convert("RGBA") + ) + self.assertEqual( + 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): 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() 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 from PIL import Image mesh_job = sys.modules["server.jobs.mesh"] 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.mkdir() context = SimpleNamespace( @@ -684,8 +787,6 @@ class ServerTest(unittest.TestCase): context, {"device": "cpu"}, source_path, - ((2, 2), (10, 2), (2, 10)), - selection_size=(12, 12), include_normal=True, ) @@ -696,7 +797,7 @@ class ServerTest(unittest.TestCase): self.assertFalse(bool(data["valid"][0, 0])) self.assertTrue(bool(data["valid"][2, 2])) 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)) def test_mesh_job_uses_upscale_source_only_for_analysis(self): diff --git a/tests/test_worker.py b/tests/test_worker.py index 5c34e77..d784492 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -1428,12 +1428,17 @@ class WorkerTest(unittest.TestCase): 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 with tempfile.TemporaryDirectory() as 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" Image.new("RGBA", (2, 2), (10, 20, 30, alpha)).save(path) mask = self.geometry.foreground_mesh_mask(path, (2, 2))