Add comprehensive tests for cutout and mesh functionalities
- Introduced `test_cutout.py` to validate cutout pie menu behavior, image lasso options, and mesh detail calculations. - Added `test_mesh.py` to ensure correct tangent normal rasterization, plane source topology, and depth surface evaluations. - Created `test_mesh_blender.py` to verify material node groups, tangent baking, and local cutout building from Blender images. - Implemented tests for handling alpha regions and ensuring geometry nodes maintain expected behavior.
This commit is contained in:
13
AGENTS.md
13
AGENTS.md
@@ -74,6 +74,19 @@ uv lock --refresh
|
||||
- 新增或修改 Blender 类型时同步检查 `src/anyimage/__init__.py` 的注册与逆序注销,并补充对应测试
|
||||
- 依赖版本、wheel 或 Manifest 改变时同步检查 `pyproject.toml`、`uv.lock`、`blender_manifest.toml` 和打包测试
|
||||
|
||||
## Blender 节点图规范
|
||||
|
||||
- 不修改节点的 `name`,也不使用 `label` 覆盖节点标题;保留 Blender 为节点类型生成的默认名称和外观,避免读图时无法判断节点原型。用途应通过节点类型、参数、空间布局和必要的少量注释表达
|
||||
|
||||
- 节点图优先依靠清晰的空间层次表达流程,不用五颜六色的 Frame 做分类;仅在重复区、模拟区等确有包裹语义时少量使用 Frame,并保持配色克制
|
||||
- 避免跨越大段节点图的连线;来自 Group Input 的参数可按使用区域放置多个 Group Input,并隐藏未连接的 sockets
|
||||
- 主处理链尽量保持为一根从左到右连续可见的几何主线,辅助字段和控制分支放在主线上下两侧,就近汇入消费者
|
||||
- 避免交错连线、节点重叠和不必要的回折;新增或重建节点组时检查节点坐标唯一性和主要连线方向
|
||||
- Repeat、Simulation、For Each Geometry Element 等具有输入/输出“头尾”的成对结构,应先确定完整包裹范围,再放置内部节点,保证头尾顺序、配对关系和区域边界一眼可辨
|
||||
- 作为修改器使用的 Geometry Nodes,除确实需要动态字段或其他结构的接口外,输入 socket 的 structure_type 应设为 SINGLE
|
||||
- 为接口 socket 设置符合业务含义的 `subtype`;例如表示 Blender 场景实际长度的浮点输入使用 `DISTANCE`,明确限制为 `0–1` 的比例使用 `FACTOR`,以 `0–100` 表示的百分比使用 `PERCENTAGE`,角度使用对应 subtype。无上限倍率等不符合范围语义的数值保留普通 Float,不为显示形式强套 subtype
|
||||
- 调整节点接口或布局后,应补充或更新 Blender 验证,检查 socket 顺序与结构、节点不重叠、Group Input 未使用输出已隐藏,以及几何主线可正常求值
|
||||
|
||||
## 文档与 Markdown 规范
|
||||
|
||||
- `README.md` 面向用户;实现原理放入 `docs/` 对应主题,不在 README 堆叠内部细节
|
||||
|
||||
12
README.md
12
README.md
@@ -78,6 +78,14 @@ Image Perspective 是 deform 工具,不提供 Refine 或反选。它与其他
|
||||
|
||||
完整操作说明见 [Image 编辑工具](docs/image-tools.md)。
|
||||
|
||||
### 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` 控制。
|
||||
|
||||
完整说明见 [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
|
||||
|
||||
选中单张图片的 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。
|
||||
@@ -92,7 +100,7 @@ Image Perspective 是 deform 工具,不提供 Refine 或反选。它与其他
|
||||
|
||||
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 Balloon Layer` 材质补回网格分辨率未能表达的细节;`Normal Strength` 默认 `0.5`,可在材质节点中调整。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,后续套索会把自己的有效区域合并进去,重叠区域采用最新结果。旧材质仍保留节点识别回退。当前只支持静态图片。
|
||||
|
||||
### 去除背景
|
||||
|
||||
@@ -116,7 +124,7 @@ Surface 输出流程构建 DA3 与 MoGe-2 源网格:MoGe-2 使用深度断层
|
||||
修改器完成剔面、快速重拓扑、Relax 与 UV 重投;顶层 `Iterations` 可设为 `0`。开启 Remove Background
|
||||
时,BEN2 Alpha 只要大于 `0` 就视为前景;三角形三个顶点必须全部为前景,任一顶点 Alpha 为 `0`
|
||||
都会删除该面并且不再交给 Geometry Nodes;Surface 材质使用生成的颜色图片。匹配相机默认不创建;开启后会作为 Surface 的子级。
|
||||
MoGe-2 的像素级 normal 会作为 Object Space normal 贴图接入 `O MoGe2 Surface Layer`,并与原图
|
||||
MoGe-2 的像素级 normal 会作为 object-space normal 贴图接入 `O Image Layer`,并与原图
|
||||
Bump 合并;网格本身不写入 custom normal,因此重拓扑降低顶点数后仍能保留细腻的法线细节。
|
||||
当前此功能仅支持单张图片。
|
||||
|
||||
|
||||
31
docs/cutout.md
Normal file
31
docs/cutout.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Cutout
|
||||
|
||||
Cutout 用套索从 Image Empty 中选择范围,再从四个方向选择生成结果:左侧 **Cutout**、右侧 **Balloon**、下方 **Depth Balloon**、上方 **Depth Surface**。双击图片会把完整画面作为选区。
|
||||
|
||||
## 绘制选区
|
||||
|
||||
在 Object Mode 的 3D Viewport 左侧工具栏选择 **Cutout**,按住左键绘制,松开后选择形状。绘制时按 `F` 切换反选,右键或 `Esc` 取消当前轨迹。
|
||||
|
||||
自交轨迹使用非零环绕填充规则。沿相同方向围住的区域即使重叠多次也会保留;只有反方向环绕使计数抵消时才形成孔洞。因此交错部分不会再被当成洞扣除,同时仍能表达中间的反向内环孔洞。普通选区可以越过 Image Empty 边框,框外轨迹仍会生成网格;选区必须与原图中的可见像素相交才有效。反选仍限制在原图范围,避免把框外无限区域截成任意矩形。未启用 **Refine Selection** 时,选区边缘会做轻量抗锯齿;启用后,BEN2 先在同一个几何选区内计算前景 Alpha,再应用反选。
|
||||
|
||||
## 基础面密度
|
||||
|
||||
**Pixel Size** 表示基础面相邻采样点大约对应多少源图片像素,可设为 `4`–`32`,默认 `32`。数值越小,规则三角格点越密,后续形状能表达的细节越多,同时网格点数和约束三角化成本也越高。最长边的采样级别限制在 `4`–`1024`。
|
||||
|
||||
基础面先从最终 Alpha 提取外边界与孔洞,再以接近等边三角形的六角格点填充内部。轮廓按目标间距重采样和平滑,边界点仍作为约束参与 Delaunay,因此内部点保持均匀,外缘不会退化成规则栅格的阶梯锯齿。
|
||||
|
||||
四种结果都启用平滑着色。Cutout 与 Depth Surface 的修改器也在输出阶段显式设置 Smooth Shading;Balloon 与 Depth Balloon 在创建源网格时设置。
|
||||
|
||||
Cutout 的基础三角面统一按图片正面逆时针排列,Thickness 沿图片平面法线向前后各偏移一半;背面与轮廓侧壁使用相反绕序,所有封闭面的法线都朝向几何外部。Depth Surface 则保持正面深度表面不动,只沿法线反方向挤出背面。Thickness 是 Blender 场景中的实际距离,因此节点接口使用 Distance 单位显示。
|
||||
|
||||
Depth Surface 的 `O Image Depth Surface` 按“深度 → 断层剔除 → 向后厚度 → 零厚度清理 → 展开的 O Smooth 结构 → 平滑着色”从左到右排列,使内置平滑与在完整输出后连接 `O Smooth` 获得相同的输入几何。平滑段把 O Essentials 参考资产的 Repeat Zone、两条不同求值域的边界判断、边界采样与法线一致性权重节点直接放入主节点图,每轮重新计算后再移动点,不再嵌套 `O Smooth` 节点组;内部固定按 `Pin Sharp = 1`、`Pin Boundary = 1` 计算,不暴露这两个参数,`Iterations` 默认 `1`、最小可设为 `0`。Depth Continuity 使用与 `O MoGe2 Surface` 相同的百分比阈值曲线,默认 `0`,对应最宽松的断层阈值;提高数值会逐步剔除更多过远的深度跳变面。
|
||||
|
||||
## 计算说明
|
||||
|
||||
未启用 **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` 传递边界。
|
||||
|
||||
最终 Alpha 先由 NumPy 批量找出四向边界,只沿实际周长追踪轮廓,并按包含关系把孔洞归入外轮廓。未 Refine 时直接复用套索 mask;只有真实出框时才建立扩展几何画布。启用 Refine 后完全使用原图画布内的最终 AI Alpha,所有框外套索区域都会被裁掉。格点准备使用批量数组生成;点内判断按 Y 范围分桶,只比较当前扫描带可能相交的轮廓边;边界安全距离也只检查每条边附近的少量格点。短轮廓会自动使用不大于周长八分之一的边界采样间距,避免有效小区域因当前 `Pixel Size` 较大而消失;内部格点仍严格使用用户设置的 `Pixel Size`。UV 与封边边界使用 Blender `foreach_*` 和 NumPy 批量写入,不逐元素遍历。Pillow 只会加速占比较小的 Alpha 模糊和缩放,因此 Blender 本地路径保持纯 NumPy,不新增 wheel。最终约束 Delaunay 和网格创建仍随实际点数增长,极密网格应按成品需要使用。
|
||||
|
||||
Cutout 当前只支持静态图片。**Refine Selection**、Depth 与 normal 分析需要已配置的 AI Environment 和对应模型。
|
||||
|
||||
Cutout、Balloon、Depth Balloon、Depth Surface 以及普通 Surface 的图片材质共用 `O Image Layer`。节点组始终提供 Normal 输入和 Object Space 开关;没有 normal 纹理时使用 `[0.5, 0.5, 1.0, 1.0]` 中性默认值。这个开关只选择 Blender Normal Map 节点的解码空间,不承担 Object-to-Tangent 转换:三种 tangent-space 结果在接入材质前已经完成像素烘制,Depth Surface 才打开 Object Space 并直接使用 MoGe 原图。
|
||||
@@ -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 的主线程阶段创建。
|
||||
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` 作为中间结果。
|
||||
|
||||
扩展级设置统一通过 `preferences.addon_preferences()` 读取,不归属 BlendJob Runtime;Storage Root 路径回调也由 Preferences 模块提供,Runtime 只消费回调结果。扩展包标识直接使用根模块中的 `__package__`,以兼容 Blender 实际的 `bl_ext.<repository>.anyimage` 命名空间;不得按第一个点截断,否则会错误查找 `bl_ext` 并静默回退默认值。Preferences 的设备枚举和面板数据按需导入,避免循环依赖。
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
| Upscale | 在安全输出上限内增加图片像素 | 单图、视频、序列帧 | 三档模型生成的高清图片或序列 |
|
||||
| Image Mask | 用 Box、Lasso、Line 或 Polyline 直接清理图片 | 静态 Image Empty | 原位更新并 Pack 的透明裁切图 |
|
||||
| Image Perspective | 四点透视矫正裁切 | 静态 Image Empty | Pack 的矩形裁切图 |
|
||||
| Cutout | 套索选择区域并生成四种网格形状 | 静态 Image Empty | 带孔洞与原图 UV 的规则三角基础面 |
|
||||
| Balloon | 沿轮廓绘制鼓起造型 | 静态 Image Empty | 带原图 UV 的封闭网格 |
|
||||
| Debug | 检查模型原始输出 | 模型支持的图片或帧序列 | 保存在 Job 目录中的原始 Prediction |
|
||||
|
||||
@@ -27,7 +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`。
|
||||
|
||||
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。
|
||||
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)。
|
||||
|
||||
`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 的深度或法线推理也会沿用该原图引用;高清结果继续用于颜色材质和选区显示。
|
||||
|
||||
转换任务完成后,AnyImage 在一个 Undo 步骤中创建网格、图片、材质和修改器。源 Image Empty 是否保留由转换选项决定。
|
||||
|
||||
@@ -37,7 +42,7 @@ Image Box、Lasso、Line 与 Polyline 组成 Mask 工具组,并共用 `EditIma
|
||||
|
||||
Balloon 默认在 Blender 内部生成,无需 Environment 或模型。Tool Settings 提供模式、厚度、采样距离、材质复用、`Contour`、`Depth` 与 MoGe-2 `Depth Model`;AI 未就绪时只禁用这三个控件,并在 Tool Settings 中显示安装按钮。Depth 会把物理距离相对化并稳健拟合初始基面,保留未截断的正负起伏。Base Plane 子 Empty 的旋转定义基面朝向,沿自身局部 Z 移动定义 middle level。
|
||||
|
||||
`O Balloon Shape` 计算 `修正厚度 = abs(原 X) × Thickness`,并将修正深度校准为 `(0.5 × 相对基面深度 + 原始 Balloon 最大半厚度) × Depth Strength`。固定的原始半厚度不乘 `Thickness`,因此 Thickness 与 Depth Strength 分别只控制各自分支。节点以归一化原 X 距离经 Smoother Step 得到中心程度,再插值厚度与深度。`Double Sided Depth` 默认关闭,背面保持 Balloon 厚度。MoGe normal 会输出为原图尺寸的 tangent-space 纹理,并通过 `O Balloon Layer` 的 `Normal Strength`(默认 `0.5`)补偿网格无法表达的细节。
|
||||
`O Balloon Shape` 计算 `修正厚度 = abs(原 X) × Thickness`,并将修正深度校准为 `(0.5 × 相对基面深度 + 原始 Balloon 最大半厚度) × Depth Strength`。固定的原始半厚度不乘 `Thickness`,因此 Thickness 与 Depth Strength 分别只控制各自分支。节点以归一化原 X 距离经 Smoother Step 得到中心程度,再插值厚度与深度。`Double Sided Depth` 默认关闭,背面保持 Balloon 厚度。MoGe 相机轴 normal 会在 Blender 评估最终 Balloon 后按平滑 TBN 烘为原图尺寸的 tangent-space 纹理,并通过统一 `O Image Layer` 的 `Normal Scale` 补偿网格无法表达的细节;`Normal Scale` 默认 `1`,`Bump Scale` 默认 `0`,未连接 normal 时使用中性默认值。
|
||||
|
||||
新建 Balloon 材质会用 Custom Properties 保存源 Image datablock 和可选 normal Image 的直接引用。`Reuse Material` 优先按这些标记匹配,不依赖材质或节点组名称;普通与 Depth 材质分别复用。Depth Balloon 的共享 normal 会逐次合并新套索的有效区域,避免复用材质时丢失其他套索的法线。
|
||||
|
||||
|
||||
@@ -11,6 +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 结果的深度与法线使用放大前原图 |
|
||||
| `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 返回 |
|
||||
@@ -21,7 +22,7 @@
|
||||
|
||||
DA3 Depth 结果包含 `depth`、`conf`、`extrinsics` 与 `intrinsics`。MoGe-2 Depth 结果包含 `depth`、`normal`、`mask` 与 `intrinsics`。
|
||||
|
||||
Balloon Depth 的 `depth.npz` 包含相对 `depth_signal`、归一化拟合平面 `baseline_vector`、`valid`、`reference_depth`、裁切原点与输入尺寸。Geometry Nodes 将归一化原 X 距离经 Smoother Step 后作为 Thickness 与 Depth 的混合系数。`normal.png` 是铺回原图尺寸的 tangent-space 法线;套索和有效区域外为中性法线。
|
||||
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。
|
||||
|
||||
Surface 的 `mesh.npz` 包含 `vertices`、`faces`、`uv`、`intrinsics`、`image_size` 与 `reference_depth`,以及 Geometry Nodes 使用的网格属性。
|
||||
|
||||
@@ -31,4 +32,4 @@ Debug 文件保持模型 API 的数据形态,用于比较推理结果;正式
|
||||
|
||||
正式图片层使用 `image.0001.*` 起始的文件名,Upscale 层单独使用 `upscale.0001.png`。导入 Blender 后沿用源 Image 名称,由 Blender 在同名冲突时自动添加 `.001`;不再添加 `.color`。深度动画使用 EXR 序列以保存浮点值。源 Empty 为 Movie 或 Sequence 时,Blender 按序列载入这些文件,即使任务只输出一帧。
|
||||
|
||||
MoGe-2 Surface 的法线单独保存为 `normal.png`。材质在 Blender 端创建,不属于 Job 文件格式。
|
||||
MoGe-2 Surface 的原生相机法线单独保存为 `normal.png`,Blender 以 object-space 解码。Depth Surface 同样直接保留该像素编码。Cutout、Balloon 与 Depth Balloon 不会把同一张图直接当作 tangent-space 解码,而是在 Blender 端依据各自最终评估网格的 TBN 重新生成 tangent-space 像素;材质的空间选择只负责匹配已经生成好的贴图。
|
||||
|
||||
@@ -56,7 +56,9 @@ BlendJob 提供 Runtime、HTTP transport、队列、状态和取消协议。AnyI
|
||||
|
||||
Environment 使用 Python 3.12、ONNX Runtime、NumPy、Pillow、PyAV、trimesh、FastAPI 和 Uvicorn。Windows 使用 DirectML 或 CPU;其他平台根据支持情况使用 CoreML、OpenVINO 或 CPU。
|
||||
|
||||
Balloon 的 `analyze-balloon` Job 会裁切套索输入并清零选区外 RGBA。`Contour` 用 BEN2 回传最大连通轮廓;`Depth` 用 MoGe-2 对有效相机距离稳健拟合二维基面,并把物理距离换算为不受绝对拍摄距离影响、未截断的相对深度信号。Job 同时把 MoGe normal 转换为 tangent-space 纹理,铺回原图尺寸并以中性法线填充无效区域。Blender 主线程负责采样深度属性、创建 Base Plane Empty、组装 `O Balloon Shape` 修改器与 `O Balloon Layer` 材质。
|
||||
Balloon 的 `analyze-balloon` Job 会裁切套索输入并清零选区外 RGBA。`Contour` 用 BEN2 回传最大连通轮廓;`Depth` 用 MoGe-2 对有效相机距离稳健拟合二维基面,并把物理距离换算为不受绝对拍摄距离影响、未截断的相对深度信号。Job 同时把 MoGe 相机轴 normal 铺回原图尺寸,并以中性法线填充无效区域。Blender 主线程负责采样深度属性、创建 Base Plane Empty、组装 `O Balloon Shape` 修改器,再根据最终评估网格的 TBN 把相机轴 normal 烘成 tangent-space Image 并接入统一的 `O Image Layer` 材质。
|
||||
|
||||
Cutout 的 `generate-mesh` 不复用上述裁切图作为 AI 输入。套索或 BEN2 结果仅写成几何 mask;MoGe 深度与法线始终分析完整图片。输入为 Upscale 结果时,请求会额外携带放大前的 `analysis_input`,推理读取该输入,颜色与几何 mask 仍读取当前高清图;Depth Balloon 会先把套索坐标缩放到分析分辨率,再限制有效深度。Blender 材质使用当前 Empty Image 的未裁切副本。
|
||||
|
||||
Image Box 与 Lasso 的 `Refine Selection` 使用 `refine-image-mask` Job。它与标准 `remove-background` Job 共用单图 BEN2 函数;Server 在推理前清空 polygon 外的 RGBA,推理后再次应用几何 mask,并返回局部 Alpha 与源图 bounds。Line、Polyline 与 Perspective 不提交 Job。
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ DA3 使用 `O DA3 Surface`,MoGe-2 使用 `O MoGe2 Surface`。两者采用相
|
||||
|
||||
## 材质与文件
|
||||
|
||||
颜色统一写为 `image.0001.*`,导入时沿用源 Image 名称,由 Blender 自动解决同名冲突。MoGe-2 还会输出 `normal.png`,用于材质法线。材质名称以源 Image datablock 为基名。
|
||||
颜色统一写为 `image.0001.*`,导入时沿用源 Image 名称,由 Blender 自动解决同名冲突。MoGe-2 还会输出 `normal.png`,作为 object-space normal 接入统一的 `O Image Layer`;材质会自动打开 Object Space,未提供 normal 的图片材质使用 `[0.5, 0.5, 1.0, 1.0]` 中性默认值。材质名称以源 Image datablock 为基名。
|
||||
|
||||
单图结果可以 Pack 到 `.blend`。模型原始结果与 `mesh.npz` 保留在 Job 目录中,便于调试和重新检查。
|
||||
|
||||
|
||||
@@ -20,4 +20,6 @@ Upscale 用 ONNX 模型把 Image Empty 的图片放大 4 倍,不创建网格
|
||||
|
||||
对高清结果再次执行 Upscale 时,AnyImage 会从最初的原图重新生成并替换当前结果,不会连续放大已有高清图。
|
||||
|
||||
把高清结果继续转换为 Depth Plane、Surface 或 Cutout 时,颜色材质仍使用当前高清图;深度与法线推理会读取保存的放大前原图,以减少计算量并避免凭空增加不必要的几何细节。
|
||||
|
||||
如果显存或内存不足,请降低 **Maximum Upscale Size**,或改用 CPU。
|
||||
|
||||
@@ -16,6 +16,7 @@ nav:
|
||||
- 使用指南:
|
||||
- 选择转换方式: workflows.md
|
||||
- Image 编辑工具: image-tools.md
|
||||
- Cutout: cutout.md
|
||||
- Mesh Plane: mesh-plane.md
|
||||
- Depth Plane: guide-depth-plane.md
|
||||
- Surface: guide-surface.md
|
||||
|
||||
@@ -2,10 +2,9 @@ import bpy
|
||||
|
||||
from . import clipboard_image
|
||||
from .clipboard_image import CLASSES as CLIPBOARD_CLASSES
|
||||
|
||||
from .runtime import runtime
|
||||
from .menu import AnyImageImageMenu, draw_image_context_menu
|
||||
from .operators import CLASSES as OPERATOR_CLASSES
|
||||
from .operators.balloon import BalloonTool
|
||||
from .operators.cutout import CutoutTool
|
||||
from .operators.image_edit import (
|
||||
ImageBoxTool,
|
||||
ImageLassoTool,
|
||||
@@ -13,9 +12,6 @@ from .operators.image_edit import (
|
||||
ImagePerspectiveTool,
|
||||
ImagePolylineTool,
|
||||
)
|
||||
from .preferences import AnyImagePreferences
|
||||
from .properties import AnyImageSettings
|
||||
from .menu import AnyImageImageMenu, draw_image_context_menu
|
||||
from .panel import (
|
||||
Ben2RemovalDebugPanel,
|
||||
Da3OutputDebugPanel,
|
||||
@@ -24,6 +20,9 @@ from .panel import (
|
||||
ServerPanel,
|
||||
UpscaleDebugPanel,
|
||||
)
|
||||
from .preferences import AnyImagePreferences
|
||||
from .properties import AnyImageSettings
|
||||
from .runtime import runtime
|
||||
|
||||
|
||||
CLASSES = (
|
||||
@@ -44,11 +43,7 @@ CLASSES = (
|
||||
def register():
|
||||
for class_type in CLASSES:
|
||||
bpy.utils.register_class(class_type)
|
||||
bpy.utils.register_tool(
|
||||
ImageBoxTool,
|
||||
separator=True,
|
||||
group=True,
|
||||
)
|
||||
bpy.utils.register_tool(ImageBoxTool, separator=True, group=True)
|
||||
bpy.utils.register_tool(
|
||||
ImageLassoTool,
|
||||
after={ImageBoxTool.bl_idname},
|
||||
@@ -64,16 +59,15 @@ def register():
|
||||
after={ImageLineTool.bl_idname},
|
||||
separator=False,
|
||||
)
|
||||
bpy.utils.register_tool(ImagePerspectiveTool, separator=False)
|
||||
bpy.utils.register_tool(
|
||||
ImagePerspectiveTool,
|
||||
separator=False,
|
||||
)
|
||||
bpy.utils.register_tool(BalloonTool, separator=False)
|
||||
bpy.types.Scene.anyimage_settings = bpy.props.PointerProperty(
|
||||
type=AnyImageSettings,
|
||||
CutoutTool,
|
||||
after={ImagePerspectiveTool.bl_idname},
|
||||
separator=True,
|
||||
)
|
||||
bpy.types.Scene.anyimage_settings = bpy.props.PointerProperty(type=AnyImageSettings)
|
||||
bpy.types.Image.anyimage_upscale_source = bpy.props.PointerProperty(
|
||||
type=bpy.types.Image,
|
||||
type=bpy.types.Image
|
||||
)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.prepend(draw_image_context_menu)
|
||||
bpy.types.OUTLINER_MT_object.prepend(draw_image_context_menu)
|
||||
@@ -84,7 +78,7 @@ def register():
|
||||
def unregister():
|
||||
runtime.unregister()
|
||||
clipboard_image.unregister_keymaps()
|
||||
bpy.utils.unregister_tool(BalloonTool)
|
||||
bpy.utils.unregister_tool(CutoutTool)
|
||||
bpy.utils.unregister_tool(ImagePerspectiveTool)
|
||||
bpy.utils.unregister_tool(ImagePolylineTool)
|
||||
bpy.utils.unregister_tool(ImageLineTool)
|
||||
|
||||
@@ -8,28 +8,20 @@ class AnyImageImageMenu(bpy.types.Menu):
|
||||
bl_label = "AnyImage"
|
||||
|
||||
def draw(self, context):
|
||||
from .properties import (
|
||||
ai_setup_label,
|
||||
ai_status,
|
||||
)
|
||||
from .properties import ai_setup_label, ai_status
|
||||
|
||||
layout = self.layout
|
||||
layout.operator_context = "INVOKE_DEFAULT"
|
||||
layout.operator(
|
||||
"anyimage.convert_to_mesh_plane",
|
||||
text="Convert to Mesh Plane…",
|
||||
"anyimage.convert_to_plane",
|
||||
text="Convert to Plane",
|
||||
icon="MESH_PLANE",
|
||||
)
|
||||
layout.operator(
|
||||
"anyimage.convert_to_depth_plane",
|
||||
text="Convert to Depth Plane…",
|
||||
text="Convert to Depth Plane",
|
||||
icon="MOD_DISPLACE",
|
||||
)
|
||||
layout.operator(
|
||||
"anyimage.convert_to_surface",
|
||||
text="Convert to Surface…",
|
||||
icon="MESH_GRID",
|
||||
)
|
||||
layout.separator()
|
||||
layout.operator(
|
||||
"anyimage.remove_image_background",
|
||||
@@ -46,7 +38,7 @@ class AnyImageImageMenu(bpy.types.Menu):
|
||||
if status["ready"]:
|
||||
layout.operator(
|
||||
"anyimage.open_ai_environment_settings",
|
||||
text="AI Environment Settings…",
|
||||
text="AI Environment Settings",
|
||||
icon="PREFERENCES",
|
||||
)
|
||||
else:
|
||||
@@ -72,9 +64,5 @@ def upscale_menu_label(context):
|
||||
def draw_image_context_menu(self, context):
|
||||
if not is_image_empty(context.object):
|
||||
return
|
||||
self.layout.menu(
|
||||
AnyImageImageMenu.bl_idname,
|
||||
text="AnyImage",
|
||||
icon="PLUGIN",
|
||||
)
|
||||
self.layout.menu(AnyImageImageMenu.bl_idname, text="AnyImage", icon="PLUGIN")
|
||||
self.layout.separator()
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
from .background import ProcessImageBackground, RemoveImageBackground
|
||||
from .balloon import (
|
||||
ActivateBalloonTool,
|
||||
ExtractBalloon,
|
||||
AnalyzeBalloon,
|
||||
)
|
||||
from .cutout import ActivateCutoutTool, SelectCutoutRegion
|
||||
from .debug import DebugBen2, DebugDa3, DebugMoge2, DebugUpscale
|
||||
from .image_edit import CLASSES as IMAGE_EDIT_CLASSES
|
||||
from .depth_plane import (
|
||||
from .mesh import (
|
||||
ConvertCutoutShape,
|
||||
ConvertToDepthPlane,
|
||||
ExtractOriginalFrames,
|
||||
GenerateDa3Depth,
|
||||
GenerateMoge2Depth,
|
||||
GenerateOriginalBackground,
|
||||
ConvertToPlane,
|
||||
GenerateMesh,
|
||||
)
|
||||
from .models import (
|
||||
ClearModels,
|
||||
@@ -20,8 +15,6 @@ from .models import (
|
||||
OpenAIEnvironmentSettings,
|
||||
SetupAIEnvironment,
|
||||
)
|
||||
from .mesh_plane import ConvertToMeshPlane
|
||||
from .surface import ConvertToSurface, GenerateDa3Surface, GenerateMoge2Surface
|
||||
from .upscale import UpscaleImage
|
||||
|
||||
|
||||
@@ -34,21 +27,15 @@ CLASSES = (
|
||||
DebugMoge2,
|
||||
DebugBen2,
|
||||
DebugUpscale,
|
||||
GenerateDa3Depth,
|
||||
GenerateMoge2Depth,
|
||||
GenerateDa3Surface,
|
||||
GenerateMoge2Surface,
|
||||
GenerateMesh,
|
||||
SelectCutoutRegion,
|
||||
ConvertCutoutShape,
|
||||
ActivateCutoutTool,
|
||||
ProcessImageBackground,
|
||||
GenerateOriginalBackground,
|
||||
ExtractOriginalFrames,
|
||||
ClearModels,
|
||||
*IMAGE_EDIT_CLASSES,
|
||||
ExtractBalloon,
|
||||
AnalyzeBalloon,
|
||||
ActivateBalloonTool,
|
||||
RemoveImageBackground,
|
||||
UpscaleImage,
|
||||
ConvertToMeshPlane,
|
||||
ConvertToPlane,
|
||||
ConvertToDepthPlane,
|
||||
ConvertToSurface,
|
||||
)
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
from .operator import (
|
||||
ActivateBalloonTool,
|
||||
BalloonTool,
|
||||
ExtractBalloon,
|
||||
AnalyzeBalloon,
|
||||
)
|
||||
"""Balloon profile geometry used by the unified mesh converter."""
|
||||
|
||||
@@ -4,7 +4,6 @@ from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
BALLOON_DEPTH_ATTRIBUTE = "o_balloon_depth"
|
||||
BALLOON_SHAPE_NODE_GROUP_NAME = "O Balloon Shape"
|
||||
BALLOON_SHAPE_NODE_GROUP_VERSION = 16
|
||||
|
||||
@@ -45,8 +45,8 @@ def resample_closed_polygon(polygon, spacing):
|
||||
return points[indices] + segments[indices] * factors[:, None]
|
||||
|
||||
|
||||
def smooth_lasso_contour(polygon, spacing, iterations=4):
|
||||
contour = resample_closed_polygon(polygon, spacing * 0.5)
|
||||
def smooth_lasso_contour(polygon, spacing, iterations=16):
|
||||
contour = resample_closed_polygon(polygon, spacing * 0.25)
|
||||
original_area = abs(polygon_area(contour))
|
||||
for _index in range(iterations):
|
||||
import numpy as np
|
||||
@@ -68,81 +68,182 @@ def smooth_lasso_contour(polygon, spacing, iterations=4):
|
||||
return contour
|
||||
|
||||
|
||||
def _contour_edges(contours):
|
||||
import numpy as np
|
||||
|
||||
starts = []
|
||||
ends = []
|
||||
for contour in contours:
|
||||
contour = np.asarray(contour, dtype=np.float64)
|
||||
following = np.roll(contour, -1, axis=0)
|
||||
usable = np.abs(following[:, 1] - contour[:, 1]) > 1e-12
|
||||
starts.append(contour[usable])
|
||||
ends.append(following[usable])
|
||||
if not starts:
|
||||
empty = np.empty((0, 2), dtype=np.float64)
|
||||
return empty, empty
|
||||
return np.concatenate(starts), np.concatenate(ends)
|
||||
|
||||
|
||||
def _edge_bucket_indices(starts, ends, minimum_y, bucket_height, bucket_count):
|
||||
import numpy as np
|
||||
|
||||
lower = np.minimum(starts[:, 1], ends[:, 1])
|
||||
upper = np.maximum(starts[:, 1], ends[:, 1])
|
||||
first = np.floor((lower - minimum_y) / bucket_height).astype(np.int64)
|
||||
last = np.floor((np.nextafter(upper, lower) - minimum_y) / bucket_height).astype(
|
||||
np.int64
|
||||
)
|
||||
first = np.clip(first, 0, bucket_count - 1)
|
||||
last = np.clip(last, 0, bucket_count - 1)
|
||||
buckets = [[] for _index in range(bucket_count)]
|
||||
for edge_index, (start, stop) in enumerate(zip(first, last)):
|
||||
for bucket_index in range(int(start), int(stop) + 1):
|
||||
buckets[bucket_index].append(edge_index)
|
||||
return buckets
|
||||
|
||||
|
||||
def points_inside_polygon(points, polygon):
|
||||
return points_inside_contours(points, (polygon,))
|
||||
|
||||
|
||||
def points_inside_contours(points, contours):
|
||||
import numpy as np
|
||||
|
||||
points = np.asarray(points, dtype=np.float64)
|
||||
polygon = np.asarray(polygon, dtype=np.float64)
|
||||
inside = np.zeros(len(points), dtype=bool)
|
||||
previous = np.roll(polygon, 1, axis=0)
|
||||
for current, prior in zip(polygon, previous):
|
||||
crosses = (current[1] > points[:, 1]) != (
|
||||
prior[1] > points[:, 1]
|
||||
if not len(points):
|
||||
return inside
|
||||
starts, ends = _contour_edges(contours)
|
||||
if not len(starts):
|
||||
return inside
|
||||
minimum_y = min(float(points[:, 1].min()), float(starts[:, 1].min()))
|
||||
maximum_y = max(float(points[:, 1].max()), float(starts[:, 1].max()))
|
||||
typical_span = float(np.median(np.abs(ends[:, 1] - starts[:, 1])))
|
||||
bucket_height = max(typical_span, (maximum_y - minimum_y) / 4096.0, 1e-9)
|
||||
bucket_count = max(1, int(math.ceil((maximum_y - minimum_y) / bucket_height)))
|
||||
edge_buckets = _edge_bucket_indices(
|
||||
starts, ends, minimum_y, bucket_height, bucket_count
|
||||
)
|
||||
denominator = prior[1] - current[1]
|
||||
if abs(denominator) < 1e-12:
|
||||
point_buckets = np.floor(
|
||||
(points[:, 1] - minimum_y) / bucket_height
|
||||
).astype(np.int64)
|
||||
point_buckets = np.clip(point_buckets, 0, bucket_count - 1)
|
||||
order = np.argsort(point_buckets, kind="stable")
|
||||
ordered_buckets = point_buckets[order]
|
||||
split = np.flatnonzero(np.diff(ordered_buckets)) + 1
|
||||
for indices in np.split(order, split):
|
||||
edge_indices = edge_buckets[int(point_buckets[indices[0]])]
|
||||
if not edge_indices:
|
||||
continue
|
||||
crossing_x = (
|
||||
(prior[0] - current[0])
|
||||
* (points[:, 1] - current[1])
|
||||
/ denominator
|
||||
+ current[0]
|
||||
selected_starts = starts[edge_indices]
|
||||
selected_ends = ends[edge_indices]
|
||||
sample_y = points[indices, 1, None]
|
||||
crosses = (selected_starts[None, :, 1] > sample_y) != (
|
||||
selected_ends[None, :, 1] > sample_y
|
||||
)
|
||||
inside ^= crosses & (points[:, 0] < crossing_x)
|
||||
crossing_x = selected_starts[None, :, 0] + (
|
||||
(sample_y - selected_starts[None, :, 1])
|
||||
* (selected_ends[None, :, 0] - selected_starts[None, :, 0])
|
||||
/ (selected_ends[None, :, 1] - selected_starts[None, :, 1])
|
||||
)
|
||||
inside[indices] = np.count_nonzero(
|
||||
crosses & (points[indices, 0, None] < crossing_x), axis=1
|
||||
) % 2 == 1
|
||||
return inside
|
||||
|
||||
|
||||
def distances_to_polygon(points, polygon):
|
||||
def uniform_interior_points_for_contours(contours, spacing):
|
||||
import numpy as np
|
||||
|
||||
points = np.asarray(points, dtype=np.float64)
|
||||
polygon = np.asarray(polygon, dtype=np.float64)
|
||||
squared = np.full(len(points), np.inf, dtype=np.float64)
|
||||
for start, end in zip(polygon, np.roll(polygon, -1, axis=0)):
|
||||
contours = [np.asarray(contour, dtype=np.float64) for contour in contours]
|
||||
boundary_points = np.concatenate(contours, axis=0)
|
||||
minimum = boundary_points.min(axis=0)
|
||||
maximum = boundary_points.max(axis=0)
|
||||
row_spacing = spacing * math.sqrt(3.0) * 0.5
|
||||
row_count = int(math.ceil((maximum[1] - minimum[1]) / row_spacing)) + 1
|
||||
column_count = int(math.ceil((maximum[0] - minimum[0]) / spacing)) + 1
|
||||
rows = np.arange(row_count, dtype=np.float64)
|
||||
columns = np.arange(column_count, dtype=np.float64)
|
||||
y = minimum[1] + rows * row_spacing
|
||||
x = (
|
||||
minimum[0]
|
||||
+ columns[None, :] * spacing
|
||||
+ (rows.astype(np.int64) % 2)[:, None] * spacing * 0.5
|
||||
)
|
||||
x = np.broadcast_to(x, (row_count, column_count))
|
||||
points = np.column_stack((x.ravel(), np.repeat(y, column_count)))
|
||||
inside = points_inside_contours(points, contours).reshape(
|
||||
row_count, column_count
|
||||
)
|
||||
|
||||
clearance = spacing * 0.4
|
||||
near_boundary = np.zeros_like(inside)
|
||||
for contour in contours:
|
||||
for start, end in zip(contour, np.roll(contour, -1, axis=0)):
|
||||
segment = end - start
|
||||
length_squared = float(segment.dot(segment))
|
||||
if length_squared <= 1e-16:
|
||||
continue
|
||||
first_row = max(
|
||||
0,
|
||||
int(
|
||||
math.ceil(
|
||||
(min(start[1], end[1]) - clearance - minimum[1])
|
||||
/ row_spacing
|
||||
)
|
||||
),
|
||||
)
|
||||
last_row = min(
|
||||
row_count - 1,
|
||||
int(
|
||||
math.floor(
|
||||
(max(start[1], end[1]) + clearance - minimum[1])
|
||||
/ row_spacing
|
||||
)
|
||||
),
|
||||
)
|
||||
for row in range(first_row, last_row + 1):
|
||||
offset = spacing * 0.5 if row % 2 else 0.0
|
||||
first_column = max(
|
||||
0,
|
||||
int(
|
||||
math.ceil(
|
||||
(min(start[0], end[0]) - clearance - minimum[0] - offset)
|
||||
/ spacing
|
||||
)
|
||||
),
|
||||
)
|
||||
last_column = min(
|
||||
column_count - 1,
|
||||
int(
|
||||
math.floor(
|
||||
(max(start[0], end[0]) + clearance - minimum[0] - offset)
|
||||
/ spacing
|
||||
)
|
||||
),
|
||||
)
|
||||
if last_column < first_column:
|
||||
continue
|
||||
selected = np.arange(first_column, last_column + 1)
|
||||
row_points = np.column_stack(
|
||||
(x[row, selected], np.full(len(selected), y[row]))
|
||||
)
|
||||
factor = np.clip(
|
||||
(points - start) @ segment / length_squared,
|
||||
0.0,
|
||||
1.0,
|
||||
(row_points - start) @ segment / length_squared, 0.0, 1.0
|
||||
)
|
||||
nearest = start + factor[:, None] * segment
|
||||
squared = np.minimum(
|
||||
squared,
|
||||
np.sum((points - nearest) ** 2, axis=1),
|
||||
close = np.sum((row_points - nearest) ** 2, axis=1) < clearance**2
|
||||
near_boundary[row, selected[close]] = True
|
||||
usable = inside & ~near_boundary
|
||||
return np.column_stack(
|
||||
(x[usable], np.broadcast_to(y[:, None], x.shape)[usable])
|
||||
)
|
||||
return np.sqrt(squared)
|
||||
|
||||
|
||||
def uniform_interior_points(contour, spacing):
|
||||
import numpy as np
|
||||
return uniform_interior_points_for_contours((contour,), spacing)
|
||||
|
||||
contour = np.asarray(contour, dtype=np.float64)
|
||||
minimum = contour.min(axis=0)
|
||||
maximum = contour.max(axis=0)
|
||||
row_spacing = spacing * math.sqrt(3.0) * 0.5
|
||||
row_count = int(
|
||||
math.ceil((maximum[1] - minimum[1]) / row_spacing)
|
||||
) + 1
|
||||
column_count = int(
|
||||
math.ceil((maximum[0] - minimum[0]) / spacing)
|
||||
) + 1
|
||||
candidates = []
|
||||
for row in range(row_count):
|
||||
y = minimum[1] + row * row_spacing
|
||||
offset = spacing * 0.5 if row % 2 else 0.0
|
||||
for column in range(column_count):
|
||||
candidates.append(
|
||||
(minimum[0] + offset + column * spacing, y)
|
||||
)
|
||||
points = np.asarray(candidates, dtype=np.float64)
|
||||
points = points[points_inside_polygon(points, contour)]
|
||||
if not len(points):
|
||||
return points
|
||||
clearance = distances_to_polygon(points, contour)
|
||||
return points[clearance >= spacing * 0.4]
|
||||
|
||||
|
||||
def metric_transform(world_metric=None):
|
||||
@@ -163,8 +264,8 @@ def metric_transform(world_metric=None):
|
||||
) from error
|
||||
|
||||
|
||||
def prepare_planar_points(
|
||||
polygon,
|
||||
def prepare_planar_contours(
|
||||
polygons,
|
||||
image_size,
|
||||
local_bounds,
|
||||
spacing=POINT_SPACING,
|
||||
@@ -172,39 +273,175 @@ def prepare_planar_points(
|
||||
):
|
||||
import numpy as np
|
||||
|
||||
if spacing <= 0.0:
|
||||
raise ValueError("Balloon point spacing must be greater than zero")
|
||||
image_width, image_height = image_size
|
||||
x_min, x_max, y_min, y_max = local_bounds
|
||||
local_width = x_max - x_min
|
||||
local_height = y_max - y_min
|
||||
transform = metric_transform(world_metric)
|
||||
contours = []
|
||||
for polygon in polygons:
|
||||
pixels = np.asarray(polygon, dtype=np.float64)
|
||||
if pixels.ndim != 2 or pixels.shape[0] < 3 or pixels.shape[1] != 2:
|
||||
continue
|
||||
local = np.empty_like(pixels)
|
||||
local[:, 0] = x_min + local_width * pixels[:, 0] / image_width
|
||||
local[:, 1] = y_max - local_height * pixels[:, 1] / image_height
|
||||
if float(np.ptp(local, axis=0).max()) <= 0.0:
|
||||
raise ValueError("The lasso does not overlap the source image")
|
||||
if spacing <= 0.0:
|
||||
raise ValueError("Balloon point spacing must be greater than zero")
|
||||
planar = local @ transform
|
||||
perimeter = float(
|
||||
np.linalg.norm(np.roll(planar, -1, axis=0) - planar, axis=1).sum()
|
||||
)
|
||||
if perimeter <= 1e-12:
|
||||
continue
|
||||
contour_spacing = min(spacing, perimeter / 8.0)
|
||||
contours.append(smooth_lasso_contour(planar, contour_spacing))
|
||||
if not contours:
|
||||
raise ValueError("The Cutout has no region large enough for this resolution")
|
||||
interior = uniform_interior_points_for_contours(contours, spacing)
|
||||
return contours, interior, spacing
|
||||
|
||||
planar = local @ metric_transform(world_metric)
|
||||
contour = smooth_lasso_contour(planar, spacing)
|
||||
interior = uniform_interior_points(contour, spacing)
|
||||
def prepare_planar_regions(
|
||||
regions,
|
||||
image_size,
|
||||
local_bounds,
|
||||
spacing=POINT_SPACING,
|
||||
world_metric=None,
|
||||
):
|
||||
prepared = []
|
||||
errors = []
|
||||
for region in regions:
|
||||
try:
|
||||
contours, interior, _spacing = prepare_planar_contours(
|
||||
region,
|
||||
image_size,
|
||||
local_bounds,
|
||||
spacing=spacing,
|
||||
world_metric=world_metric,
|
||||
)
|
||||
except ValueError as error:
|
||||
errors.append(error)
|
||||
continue
|
||||
prepared.append((contours, interior))
|
||||
if not prepared:
|
||||
if errors:
|
||||
raise errors[0]
|
||||
raise ValueError("The Cutout has no usable region")
|
||||
return prepared, spacing
|
||||
|
||||
|
||||
|
||||
def prepare_planar_points(
|
||||
polygon,
|
||||
image_size,
|
||||
local_bounds,
|
||||
spacing=POINT_SPACING,
|
||||
world_metric=None,
|
||||
):
|
||||
contours, interior, spacing = prepare_planar_contours(
|
||||
(polygon,),
|
||||
image_size,
|
||||
local_bounds,
|
||||
spacing=spacing,
|
||||
world_metric=world_metric,
|
||||
)
|
||||
contour = contours[0]
|
||||
return contour, interior, spacing
|
||||
|
||||
def boundary_vertices_from_faces(vertex_count, faces):
|
||||
from collections import Counter
|
||||
|
||||
def triangulate_planar_contours(contours, interior, spacing):
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from mathutils.geometry import delaunay_2d_cdt
|
||||
|
||||
input_points = np.concatenate((*contours, interior), axis=0)
|
||||
boundary_edges = []
|
||||
offset = 0
|
||||
for contour in contours:
|
||||
count = len(contour)
|
||||
boundary_edges.extend(
|
||||
(offset + index, offset + (index + 1) % count)
|
||||
for index in range(count)
|
||||
)
|
||||
offset += count
|
||||
result = delaunay_2d_cdt(
|
||||
[Vector(point) for point in input_points],
|
||||
boundary_edges,
|
||||
[],
|
||||
0,
|
||||
max(spacing * 1e-6, 1e-9),
|
||||
False,
|
||||
)
|
||||
planar_points = np.asarray(
|
||||
[tuple(point) for point in result[0]],
|
||||
dtype=np.float64,
|
||||
)
|
||||
faces = np.asarray(
|
||||
[tuple(face) for face in result[2] if len(face) == 3],
|
||||
dtype=np.int32,
|
||||
)
|
||||
if len(faces):
|
||||
centers = planar_points[faces].mean(axis=1)
|
||||
faces = faces[points_inside_contours(centers, contours)]
|
||||
if not len(faces):
|
||||
raise ValueError("Unable to triangulate the Cutout regions")
|
||||
used = np.unique(faces)
|
||||
if len(used) != len(planar_points):
|
||||
remap = np.full(len(planar_points), -1, dtype=np.int32)
|
||||
remap[used] = np.arange(len(used), dtype=np.int32)
|
||||
planar_points = planar_points[used]
|
||||
faces = remap[faces]
|
||||
boundary = boundary_vertices_from_faces(len(planar_points), faces)
|
||||
planar_faces = [tuple(int(value) for value in face) for face in faces]
|
||||
return planar_points, planar_faces, boundary
|
||||
|
||||
|
||||
def boundary_vertices_from_faces(vertex_count, faces):
|
||||
import numpy as np
|
||||
|
||||
edge_users = Counter()
|
||||
for face in faces:
|
||||
for start, end in zip(face, face[1:] + face[:1]):
|
||||
edge_users[tuple(sorted((start, end)))] += 1
|
||||
faces = np.asarray(faces, dtype=np.int32)
|
||||
boundary = np.zeros(vertex_count, dtype=bool)
|
||||
for (start, end), users in edge_users.items():
|
||||
if users == 1:
|
||||
boundary[start] = True
|
||||
boundary[end] = True
|
||||
if not len(faces):
|
||||
return boundary
|
||||
edges = np.concatenate(
|
||||
[
|
||||
faces[:, (index, (index + 1) % faces.shape[1])]
|
||||
for index in range(faces.shape[1])
|
||||
],
|
||||
axis=0,
|
||||
)
|
||||
edges.sort(axis=1)
|
||||
unique_edges, users = np.unique(edges, axis=0, return_counts=True)
|
||||
boundary[unique_edges[users == 1].ravel()] = True
|
||||
return boundary
|
||||
|
||||
|
||||
def triangulate_planar_regions(regions, spacing):
|
||||
import numpy as np
|
||||
|
||||
points = []
|
||||
faces = []
|
||||
boundaries = []
|
||||
offset = 0
|
||||
for contours, interior in regions:
|
||||
region_points, region_faces, region_boundary = triangulate_planar_contours(
|
||||
contours,
|
||||
interior,
|
||||
spacing,
|
||||
)
|
||||
points.append(region_points)
|
||||
faces.extend(
|
||||
tuple(offset + value for value in face) for face in region_faces
|
||||
)
|
||||
boundaries.append(region_boundary)
|
||||
offset += len(region_points)
|
||||
if not points:
|
||||
raise ValueError("Unable to triangulate the Cutout regions")
|
||||
return (
|
||||
np.concatenate(points, axis=0),
|
||||
faces,
|
||||
np.concatenate(boundaries),
|
||||
)
|
||||
|
||||
|
||||
def balloon_heights(
|
||||
@@ -283,7 +520,7 @@ def build_closed_geometry(
|
||||
return vertices, faces, np.asarray(vertex_uv, dtype=np.float32)
|
||||
|
||||
def triangulate_balloon(
|
||||
polygon,
|
||||
regions,
|
||||
image_size,
|
||||
local_bounds,
|
||||
spacing=POINT_SPACING,
|
||||
@@ -292,46 +529,21 @@ def triangulate_balloon(
|
||||
inflation=INFLATION,
|
||||
algorithm="POISSON",
|
||||
):
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from mathutils.geometry import delaunay_2d_cdt
|
||||
|
||||
contour, interior, spacing = prepare_planar_points(
|
||||
polygon,
|
||||
planar_regions, spacing = prepare_planar_regions(
|
||||
regions,
|
||||
image_size,
|
||||
local_bounds,
|
||||
spacing=spacing,
|
||||
world_metric=world_metric,
|
||||
)
|
||||
input_points = np.concatenate((contour, interior), axis=0)
|
||||
boundary_count = len(contour)
|
||||
boundary_edges = [
|
||||
(index, (index + 1) % boundary_count)
|
||||
for index in range(boundary_count)
|
||||
]
|
||||
result = delaunay_2d_cdt(
|
||||
[Vector(point) for point in input_points],
|
||||
boundary_edges,
|
||||
[list(range(boundary_count))],
|
||||
1,
|
||||
max(spacing * 1e-6, 1e-9),
|
||||
)
|
||||
planar_points = np.asarray(
|
||||
[tuple(point) for point in result[0]],
|
||||
dtype=np.float64,
|
||||
)
|
||||
planar_faces = [tuple(face) for face in result[2] if len(face) == 3]
|
||||
if not planar_faces:
|
||||
raise ValueError("Unable to triangulate the lasso region")
|
||||
boundary_mask = boundary_vertices_from_faces(
|
||||
len(planar_points),
|
||||
planar_faces,
|
||||
planar_points, planar_faces, boundary_mask = triangulate_planar_regions(
|
||||
planar_regions, spacing
|
||||
)
|
||||
heights = balloon_heights(
|
||||
algorithm,
|
||||
planar_points,
|
||||
planar_faces,
|
||||
contour,
|
||||
planar_regions[0][0][0],
|
||||
boundary_mask,
|
||||
inflation,
|
||||
)
|
||||
|
||||
@@ -3,25 +3,31 @@ import math
|
||||
|
||||
import bpy
|
||||
|
||||
from .depth import (
|
||||
add_balloon_shape_modifier,
|
||||
assign_balloon_shape_attributes,
|
||||
balloon_depth_fields,
|
||||
create_balloon_base_plane,
|
||||
load_balloon_depth_data,
|
||||
)
|
||||
from .geometry import INFLATION, POINT_SPACING, triangulate_balloon
|
||||
from ...properties import (
|
||||
BEN2_MODEL_KEY,
|
||||
ai_ready,
|
||||
ai_setup_label,
|
||||
ai_status,
|
||||
)
|
||||
from ...properties import (
|
||||
balloon_contour_analysis_property as contour_analysis_property,
|
||||
)
|
||||
from ...properties import (
|
||||
balloon_depth_analysis_property as depth_analysis_property,
|
||||
)
|
||||
from ...properties import (
|
||||
balloon_depth_model_property as depth_model_property,
|
||||
)
|
||||
from ...properties import (
|
||||
balloon_mode_property as mode_property,
|
||||
)
|
||||
from ...properties import (
|
||||
balloon_resolution_property as resolution_property,
|
||||
)
|
||||
from ...properties import (
|
||||
balloon_reuse_material_property as reuse_material_property,
|
||||
)
|
||||
from ...properties import (
|
||||
balloon_thickness_property as thickness_property,
|
||||
)
|
||||
from ...runtime import JobOperatorBase
|
||||
@@ -44,12 +50,19 @@ from ..image import (
|
||||
video_total_frames,
|
||||
)
|
||||
from ..material import (
|
||||
BALLOON_MATERIAL_NODE_GROUP_NAME,
|
||||
TEXTURE_MATERIAL_NODE_GROUP_NAME,
|
||||
IMAGE_MATERIAL_NODE_GROUP_NAME,
|
||||
create_material,
|
||||
)
|
||||
from ..normal_map import bake_tangent_normal_image
|
||||
from ..surface import assign_vertex_uv
|
||||
|
||||
from .depth import (
|
||||
add_balloon_shape_modifier,
|
||||
assign_balloon_shape_attributes,
|
||||
balloon_depth_fields,
|
||||
create_balloon_base_plane,
|
||||
load_balloon_depth_data,
|
||||
)
|
||||
from .geometry import INFLATION, POINT_SPACING, triangulate_balloon
|
||||
|
||||
MIN_PATH_DISTANCE = 2.0
|
||||
BALLOON_MATERIAL_PROPERTY = "anyimage_balloon_material"
|
||||
@@ -151,7 +164,6 @@ def data_name_matches(name, base_name):
|
||||
return suffix != name and suffix.isdigit()
|
||||
|
||||
|
||||
|
||||
def marked_balloon_material(source_image, normal_required=False):
|
||||
return next(
|
||||
(
|
||||
@@ -160,9 +172,7 @@ def marked_balloon_material(source_image, normal_required=False):
|
||||
if callable(getattr(material, "get", None))
|
||||
and bool(material.get(BALLOON_MATERIAL_PROPERTY))
|
||||
and material.get(BALLOON_SOURCE_IMAGE_PROPERTY) is source_image
|
||||
and (
|
||||
material.get(BALLOON_NORMAL_IMAGE_PROPERTY) is not None
|
||||
)
|
||||
and (material.get(BALLOON_NORMAL_IMAGE_PROPERTY) is not None)
|
||||
== normal_required
|
||||
),
|
||||
None,
|
||||
@@ -227,6 +237,7 @@ BALLOON_AXIS_TO_SOURCE = (
|
||||
(0.0, 0.0, 0.0, 1.0),
|
||||
)
|
||||
|
||||
|
||||
def orient_balloon_vertices(vertices):
|
||||
"""Rotate legacy XY/Z Balloon coordinates into a YZ/-X frame."""
|
||||
return [(-z, -x, y) for x, y, z in vertices]
|
||||
@@ -237,14 +248,16 @@ def orient_balloon_world_matrix(source_matrix):
|
||||
|
||||
return source_matrix @ Matrix(BALLOON_AXIS_TO_SOURCE)
|
||||
|
||||
|
||||
def orient_balloon_base_plane(local_plane):
|
||||
slope_x, slope_y, offset = local_plane
|
||||
return (slope_x, -slope_y, -offset)
|
||||
|
||||
|
||||
def extract_balloon_object(
|
||||
context,
|
||||
source_object,
|
||||
polygon,
|
||||
regions,
|
||||
matrix_world,
|
||||
algorithm,
|
||||
thickness=1.0,
|
||||
@@ -253,8 +266,10 @@ def extract_balloon_object(
|
||||
reuse_material=True,
|
||||
depth_data=None,
|
||||
normal_path=None,
|
||||
material_image=None,
|
||||
bake_normal_to_tangent=False,
|
||||
):
|
||||
image = source_object.data
|
||||
image = material_image or source_object.data
|
||||
image_size = tuple(image.size)
|
||||
local_bounds = plane_bounds(source_object)
|
||||
world_metric, world_z_scale = world_plane_metric(matrix_world)
|
||||
@@ -263,7 +278,7 @@ def extract_balloon_object(
|
||||
resolution,
|
||||
)
|
||||
vertices, faces, vertex_uv = triangulate_balloon(
|
||||
polygon,
|
||||
regions,
|
||||
image_size,
|
||||
local_bounds,
|
||||
spacing=point_spacing,
|
||||
@@ -315,7 +330,9 @@ def extract_balloon_object(
|
||||
finally:
|
||||
bpy.data.images.remove(update_normal)
|
||||
if material is None:
|
||||
color_image = reusable_color_image(color_name) if reuse_material else None
|
||||
color_image = material_image
|
||||
if color_image is None and reuse_material:
|
||||
color_image = reusable_color_image(color_name)
|
||||
if color_image is None:
|
||||
color_image = image.copy()
|
||||
color_image.name = color_name
|
||||
@@ -330,12 +347,8 @@ def extract_balloon_object(
|
||||
material = create_material(
|
||||
material_name,
|
||||
color_image,
|
||||
1.0,
|
||||
surface_group_name=(
|
||||
BALLOON_MATERIAL_NODE_GROUP_NAME
|
||||
if normal_image is not None
|
||||
else TEXTURE_MATERIAL_NODE_GROUP_NAME
|
||||
),
|
||||
0.0,
|
||||
surface_group_name=IMAGE_MATERIAL_NODE_GROUP_NAME,
|
||||
normal_image=normal_image,
|
||||
scene=context.scene,
|
||||
)
|
||||
@@ -369,7 +382,18 @@ def extract_balloon_object(
|
||||
vertices,
|
||||
local_base_plane,
|
||||
)
|
||||
add_balloon_shape_modifier(balloon, thickness, base_plane)
|
||||
add_balloon_shape_modifier(
|
||||
balloon,
|
||||
thickness,
|
||||
base_plane,
|
||||
)
|
||||
if bake_normal_to_tangent and normal_image is not None:
|
||||
bake_tangent_normal_image(
|
||||
context,
|
||||
source_object,
|
||||
balloon,
|
||||
normal_image,
|
||||
)
|
||||
if depth_data is not None:
|
||||
balloon["anyimage_balloon_depth_analysis"] = True
|
||||
balloon["anyimage_reference_depth"] = depth_data["reference_depth"]
|
||||
@@ -625,7 +649,7 @@ class ExtractBalloon(bpy.types.Operator):
|
||||
balloon = extract_balloon_object(
|
||||
context,
|
||||
source_object,
|
||||
polygon,
|
||||
((polygon,),),
|
||||
matrix_world,
|
||||
self.mode,
|
||||
thickness=self.thickness,
|
||||
@@ -710,7 +734,7 @@ class AnalyzeBalloon(JobOperatorBase, bpy.types.Operator):
|
||||
balloon = extract_balloon_object(
|
||||
context,
|
||||
source_object,
|
||||
polygon,
|
||||
((polygon,),),
|
||||
matrix_world,
|
||||
self.mode,
|
||||
thickness=self.thickness,
|
||||
@@ -719,6 +743,7 @@ class AnalyzeBalloon(JobOperatorBase, bpy.types.Operator):
|
||||
reuse_material=self.reuse_material,
|
||||
depth_data=depth_data,
|
||||
normal_path=(result.file("normal") if self.depth_analysis else None),
|
||||
bake_normal_to_tangent=self.depth_analysis,
|
||||
)
|
||||
balloon["anyimage_resolution_m"] = self.resolution
|
||||
if self.contour_analysis:
|
||||
|
||||
255
src/anyimage/operators/cutout.py
Normal file
255
src/anyimage/operators/cutout.py
Normal file
@@ -0,0 +1,255 @@
|
||||
import json
|
||||
|
||||
import bpy
|
||||
|
||||
from ..properties import (
|
||||
image_edit_refine_selection_property as refine_selection_property,
|
||||
)
|
||||
from .balloon.operator import hidden_string_property, serialize_matrix
|
||||
from .image import is_image_empty, video_total_frames
|
||||
from .image_edit.common import (
|
||||
ImageEditWarning,
|
||||
apply_image_mask_settings,
|
||||
draw_image_mask_settings,
|
||||
polygon_bounds,
|
||||
report_image_edit_exception,
|
||||
resolve_image_edit_click,
|
||||
)
|
||||
from .image_edit.mask import (
|
||||
ImageMaskGesture,
|
||||
gesture_property,
|
||||
selected_image_rgba,
|
||||
)
|
||||
from .mesh import BALLOON, CUTOUT, DEPTH_BALLOON, DEPTH_SURFACE
|
||||
|
||||
CUTOUT_DETAIL_MIN = 4
|
||||
CUTOUT_DETAIL_MAX = 1024
|
||||
|
||||
|
||||
def cutout_mesh_detail(image_size, pixel_size):
|
||||
longest_edge = max(int(value) for value in image_size)
|
||||
detail = round(longest_edge / max(int(pixel_size), 1))
|
||||
return max(CUTOUT_DETAIL_MIN, min(CUTOUT_DETAIL_MAX, detail))
|
||||
|
||||
|
||||
def _pie_button(
|
||||
pie,
|
||||
shape,
|
||||
label,
|
||||
icon,
|
||||
source_name,
|
||||
selection_data,
|
||||
refine_selection,
|
||||
normal_analysis,
|
||||
invert,
|
||||
mesh_detail,
|
||||
):
|
||||
operator = pie.operator(
|
||||
"anyimage.convert_cutout_shape",
|
||||
text=label,
|
||||
icon=icon,
|
||||
)
|
||||
operator.source_object_name = source_name
|
||||
operator.selection_data = selection_data
|
||||
operator.shape = shape
|
||||
operator.refine_selection = refine_selection
|
||||
operator.normal_analysis = normal_analysis
|
||||
operator.invert = invert
|
||||
operator.mesh_detail = mesh_detail
|
||||
|
||||
|
||||
def _open_shape_pie(
|
||||
context,
|
||||
event,
|
||||
source_object,
|
||||
polygon,
|
||||
refine_selection=False,
|
||||
invert=False,
|
||||
):
|
||||
source_name = source_object.name
|
||||
selection_data = json.dumps(polygon, separators=(",", ":"))
|
||||
settings = context.scene.anyimage_settings
|
||||
pixel_size = settings.cutout_pixel_size
|
||||
normal_analysis = bool(getattr(settings, "cutout_normal_analysis", False))
|
||||
mesh_detail = cutout_mesh_detail(source_object.data.size, pixel_size)
|
||||
|
||||
def draw(menu, _context):
|
||||
pie = menu.layout.menu_pie()
|
||||
_pie_button(
|
||||
pie,
|
||||
CUTOUT,
|
||||
"Cutout",
|
||||
"MESH_GRID",
|
||||
source_name,
|
||||
selection_data,
|
||||
refine_selection,
|
||||
normal_analysis,
|
||||
invert,
|
||||
mesh_detail,
|
||||
)
|
||||
_pie_button(
|
||||
pie,
|
||||
BALLOON,
|
||||
"Balloon",
|
||||
"MESH_UVSPHERE",
|
||||
source_name,
|
||||
selection_data,
|
||||
refine_selection,
|
||||
normal_analysis,
|
||||
invert,
|
||||
mesh_detail,
|
||||
)
|
||||
_pie_button(
|
||||
pie,
|
||||
DEPTH_BALLOON,
|
||||
"Depth Balloon",
|
||||
"META_BALL",
|
||||
source_name,
|
||||
selection_data,
|
||||
refine_selection,
|
||||
normal_analysis,
|
||||
invert,
|
||||
mesh_detail,
|
||||
)
|
||||
_pie_button(
|
||||
pie,
|
||||
DEPTH_SURFACE,
|
||||
"Depth Surface",
|
||||
"SURFACE_NCURVE",
|
||||
source_name,
|
||||
selection_data,
|
||||
refine_selection,
|
||||
normal_analysis,
|
||||
invert,
|
||||
mesh_detail,
|
||||
)
|
||||
|
||||
context.window_manager.popup_menu_pie(
|
||||
event,
|
||||
draw,
|
||||
title="Cutout Shape",
|
||||
icon="MESH_DATA",
|
||||
)
|
||||
|
||||
|
||||
class SelectCutoutRegion(ImageMaskGesture, bpy.types.Operator):
|
||||
bl_idname = "anyimage.select_cutout_region"
|
||||
bl_label = "Select Cutout Region"
|
||||
bl_description = "Lasso an Image Empty region, then choose its mesh shape"
|
||||
bl_options = {"INTERNAL"}
|
||||
active_tool_id = "anyimage.cutout_lasso"
|
||||
|
||||
source_object_name: hidden_string_property()
|
||||
source_matrix_data: hidden_string_property()
|
||||
selection_data: hidden_string_property()
|
||||
refine_selection: refine_selection_property()
|
||||
invert: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
gesture: gesture_property()
|
||||
full_image: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
_pie_event = None
|
||||
|
||||
def _set_status(self, context):
|
||||
invert = "On" if self.invert else "Off"
|
||||
context.workspace.status_text_set(
|
||||
f"Cutout Lasso: drag and release for shapes; "
|
||||
f"F toggles Invert ({invert}); RMB or Esc cancels"
|
||||
)
|
||||
|
||||
def invoke(self, context, event):
|
||||
self._pie_event = event
|
||||
if not self.full_image and event.value != "DOUBLE_CLICK":
|
||||
return super().invoke(context, event)
|
||||
source_object, should_edit = resolve_image_edit_click(
|
||||
context, event, self.report
|
||||
)
|
||||
if not should_edit:
|
||||
return {"FINISHED"} if source_object is not None else {"CANCELLED"}
|
||||
if source_object is None:
|
||||
return {"CANCELLED"}
|
||||
if video_total_frames(source_object.data) > 1:
|
||||
self.report({"WARNING"}, "Image tools only support a still image")
|
||||
return {"CANCELLED"}
|
||||
apply_image_mask_settings(self, context)
|
||||
self.invert = False
|
||||
self.source_object_name = source_object.name
|
||||
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
|
||||
width, height = (int(value) for value in source_object.data.size)
|
||||
self.selection_data = json.dumps(
|
||||
((0, 0), (width, 0), (width, height), (0, height)),
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return self.execute(context)
|
||||
|
||||
def modal(self, context, event):
|
||||
self._pie_event = event
|
||||
return super().modal(context, event)
|
||||
|
||||
def execute(self, context):
|
||||
source_object = bpy.data.objects.get(self.source_object_name)
|
||||
if not is_image_empty(source_object):
|
||||
self.report({"ERROR"}, "The source Image Empty is no longer available")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
polygon = json.loads(self.selection_data)
|
||||
polygon_bounds(polygon)
|
||||
selected_image_rgba(
|
||||
source_object, polygon, self.invert, "NONZERO"
|
||||
)
|
||||
except (ImageEditWarning, json.JSONDecodeError, TypeError, ValueError) as error:
|
||||
report_image_edit_exception(self, error)
|
||||
return {"CANCELLED"}
|
||||
_open_shape_pie(
|
||||
context,
|
||||
self._pie_event,
|
||||
source_object,
|
||||
polygon,
|
||||
self.refine_selection,
|
||||
self.invert,
|
||||
)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ActivateCutoutTool(bpy.types.Operator):
|
||||
bl_idname = "anyimage.activate_cutout_tool"
|
||||
bl_label = "Cutout Tool"
|
||||
bl_description = "Activate the Cutout Lasso tool"
|
||||
bl_options = {"INTERNAL"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return context.area is not None and context.area.type == "VIEW_3D"
|
||||
|
||||
def execute(self, _context):
|
||||
return bpy.ops.wm.tool_set_by_id(
|
||||
name=CutoutTool.bl_idname,
|
||||
space_type="VIEW_3D",
|
||||
)
|
||||
|
||||
|
||||
class CutoutTool(bpy.types.WorkSpaceTool):
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_context_mode = "OBJECT"
|
||||
bl_idname = "anyimage.cutout_lasso"
|
||||
bl_label = "Cutout"
|
||||
bl_description = "Lasso an Image Empty and choose a mesh shape"
|
||||
bl_icon = "ops.sculpt.lasso_mask"
|
||||
bl_operator = SelectCutoutRegion.bl_idname
|
||||
bl_keymap = (
|
||||
(
|
||||
SelectCutoutRegion.bl_idname,
|
||||
{"type": "LEFTMOUSE", "value": "DOUBLE_CLICK"},
|
||||
{"properties": [("gesture", "LASSO"), ("full_image", True)]},
|
||||
),
|
||||
(
|
||||
SelectCutoutRegion.bl_idname,
|
||||
{"type": "LEFTMOUSE", "value": "PRESS"},
|
||||
{"properties": [("gesture", "LASSO")]},
|
||||
),
|
||||
)
|
||||
|
||||
def draw_settings(context, layout, _tool):
|
||||
settings = context.scene.anyimage_settings
|
||||
layout.prop(settings, "cutout_pixel_size")
|
||||
layout.prop(settings, "cutout_normal_analysis")
|
||||
draw_image_mask_settings(context, layout)
|
||||
@@ -1,7 +1 @@
|
||||
from .operator import (
|
||||
ConvertToDepthPlane,
|
||||
ExtractOriginalFrames,
|
||||
GenerateDa3Depth,
|
||||
GenerateMoge2Depth,
|
||||
GenerateOriginalBackground,
|
||||
)
|
||||
"""Depth-plane asset builders retained for asset validation."""
|
||||
|
||||
@@ -17,10 +17,12 @@ from ..common import (
|
||||
)
|
||||
from .build import build_depth_plane, build_image_depth_plane, build_moge2_depth_plane
|
||||
from ..image import (
|
||||
analysis_source_image,
|
||||
default_extract_frames,
|
||||
depth_data_name,
|
||||
draw_process_resolution,
|
||||
is_image_empty,
|
||||
prepare_image_analysis_inputs,
|
||||
prepare_image_input,
|
||||
process_resolution_scale_property,
|
||||
remove_temporary_input,
|
||||
@@ -88,7 +90,11 @@ class ConvertToDepthPlane(bpy.types.Operator):
|
||||
layout = self.layout
|
||||
layout.prop(self, "model")
|
||||
if self.model in {"SMALL", "BASE"}:
|
||||
draw_process_resolution(layout, self, context.object.data)
|
||||
draw_process_resolution(
|
||||
layout,
|
||||
self,
|
||||
analysis_source_image(context.object.data),
|
||||
)
|
||||
elif self.model.startswith("MOGE2_"):
|
||||
layout.prop(self, "resolution_level")
|
||||
layout.prop(self, "remove_background")
|
||||
@@ -131,7 +137,12 @@ class ConvertToDepthPlane(bpy.types.Operator):
|
||||
|
||||
if self.model.startswith("MOGE2_"):
|
||||
try:
|
||||
source_path, delete_input = prepare_image_input(
|
||||
(
|
||||
source_path,
|
||||
delete_input,
|
||||
analysis_path,
|
||||
delete_analysis_input,
|
||||
) = prepare_image_analysis_inputs(
|
||||
source_object.data,
|
||||
context.scene,
|
||||
)
|
||||
@@ -140,11 +151,13 @@ class ConvertToDepthPlane(bpy.types.Operator):
|
||||
return {"CANCELLED"}
|
||||
result = bpy.ops.anyimage.generate_moge2_depth(
|
||||
input_path=str(source_path),
|
||||
analysis_input_path=str(analysis_path),
|
||||
source_object_name=source_object.name,
|
||||
resolution_level=self.resolution_level,
|
||||
model=self.model,
|
||||
delete_source=not self.keep_original,
|
||||
delete_input=delete_input,
|
||||
delete_analysis_input=delete_analysis_input,
|
||||
shadeless=self.shadeless,
|
||||
remove_background=self.remove_background,
|
||||
)
|
||||
@@ -152,9 +165,11 @@ class ConvertToDepthPlane(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
if delete_input:
|
||||
remove_temporary_input(source_path)
|
||||
if delete_analysis_input:
|
||||
remove_temporary_input(analysis_path)
|
||||
return {"CANCELLED"}
|
||||
|
||||
image = source_object.data
|
||||
image = analysis_source_image(source_object.data)
|
||||
try:
|
||||
process_res = scaled_process_resolution(
|
||||
image,
|
||||
@@ -164,8 +179,13 @@ class ConvertToDepthPlane(bpy.types.Operator):
|
||||
self.report({"ERROR"}, str(error))
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
source_path, delete_input = prepare_image_input(
|
||||
image,
|
||||
(
|
||||
source_path,
|
||||
delete_input,
|
||||
analysis_path,
|
||||
delete_analysis_input,
|
||||
) = prepare_image_analysis_inputs(
|
||||
source_object.data,
|
||||
context.scene,
|
||||
)
|
||||
except RuntimeError as error:
|
||||
@@ -174,18 +194,22 @@ class ConvertToDepthPlane(bpy.types.Operator):
|
||||
|
||||
result = bpy.ops.anyimage.generate_da3_depth(
|
||||
input_path=str(source_path),
|
||||
analysis_input_path=str(analysis_path),
|
||||
source_object_name=source_object.name,
|
||||
depth_data_name=depth_data_name(source_object),
|
||||
model=self.model,
|
||||
process_res=process_res,
|
||||
delete_source=not self.keep_original,
|
||||
delete_input=delete_input,
|
||||
delete_analysis_input=delete_analysis_input,
|
||||
shadeless=self.shadeless,
|
||||
remove_background=self.remove_background,
|
||||
)
|
||||
if "CANCELLED" in result:
|
||||
if delete_input:
|
||||
remove_temporary_input(source_path)
|
||||
if delete_analysis_input:
|
||||
remove_temporary_input(analysis_path)
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -310,12 +334,14 @@ class GenerateDa3Depth(JobOperatorBase, bpy.types.Operator):
|
||||
starting_message = "Starting depth estimation"
|
||||
|
||||
input_path: bpy.props.StringProperty(options=HIDDEN)
|
||||
analysis_input_path: bpy.props.StringProperty(options=HIDDEN)
|
||||
source_object_name: bpy.props.StringProperty(options=HIDDEN)
|
||||
depth_data_name: bpy.props.StringProperty(options=HIDDEN)
|
||||
model: bpy.props.StringProperty(default="BASE", options=HIDDEN)
|
||||
process_res: bpy.props.IntProperty(default=1024, min=1, options=HIDDEN)
|
||||
delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN)
|
||||
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
delete_analysis_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
shadeless: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
remove_background: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
|
||||
@@ -325,7 +351,7 @@ class GenerateDa3Depth(JobOperatorBase, bpy.types.Operator):
|
||||
if self.remove_background:
|
||||
require_model(BEN2_MODEL_KEY)
|
||||
settings = context.scene.anyimage_settings
|
||||
return {
|
||||
parameters = {
|
||||
**da3_parameters(
|
||||
settings,
|
||||
input_path(self.input_path),
|
||||
@@ -338,6 +364,9 @@ class GenerateDa3Depth(JobOperatorBase, bpy.types.Operator):
|
||||
BEN2_MODEL_KEY if self.remove_background else ""
|
||||
),
|
||||
}
|
||||
analysis_value = getattr(self, "analysis_input_path", "") or self.input_path
|
||||
parameters["analysis_input"] = str(input_path(analysis_value))
|
||||
return parameters
|
||||
|
||||
def response(self, context, result):
|
||||
build_da3_depth_response(
|
||||
@@ -353,6 +382,10 @@ class GenerateDa3Depth(JobOperatorBase, bpy.types.Operator):
|
||||
|
||||
def cleanup(self):
|
||||
cleanup_temporary_input(self.input_path, self.delete_input)
|
||||
cleanup_temporary_input(
|
||||
getattr(self, "analysis_input_path", ""),
|
||||
getattr(self, "delete_analysis_input", False),
|
||||
)
|
||||
|
||||
|
||||
class GenerateMoge2Depth(JobOperatorBase, bpy.types.Operator):
|
||||
@@ -362,11 +395,13 @@ class GenerateMoge2Depth(JobOperatorBase, bpy.types.Operator):
|
||||
starting_message = "Starting MoGe-2 inference"
|
||||
|
||||
input_path: bpy.props.StringProperty(options=HIDDEN)
|
||||
analysis_input_path: bpy.props.StringProperty(options=HIDDEN)
|
||||
source_object_name: bpy.props.StringProperty(options=HIDDEN)
|
||||
model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=HIDDEN)
|
||||
resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=HIDDEN)
|
||||
delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN)
|
||||
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
delete_analysis_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
shadeless: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
remove_background: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
|
||||
@@ -376,7 +411,7 @@ class GenerateMoge2Depth(JobOperatorBase, bpy.types.Operator):
|
||||
if self.remove_background:
|
||||
require_model(BEN2_MODEL_KEY)
|
||||
settings = context.scene.anyimage_settings
|
||||
return {
|
||||
parameters = {
|
||||
**moge2_parameters(
|
||||
settings,
|
||||
input_path(self.input_path),
|
||||
@@ -388,6 +423,9 @@ class GenerateMoge2Depth(JobOperatorBase, bpy.types.Operator):
|
||||
BEN2_MODEL_KEY if self.remove_background else ""
|
||||
),
|
||||
}
|
||||
analysis_value = getattr(self, "analysis_input_path", "") or self.input_path
|
||||
parameters["analysis_input"] = str(input_path(analysis_value))
|
||||
return parameters
|
||||
|
||||
def response(self, context, result):
|
||||
build_moge2_depth_response(
|
||||
@@ -402,6 +440,10 @@ class GenerateMoge2Depth(JobOperatorBase, bpy.types.Operator):
|
||||
|
||||
def cleanup(self):
|
||||
cleanup_temporary_input(self.input_path, self.delete_input)
|
||||
cleanup_temporary_input(
|
||||
getattr(self, "analysis_input_path", ""),
|
||||
getattr(self, "delete_analysis_input", False),
|
||||
)
|
||||
|
||||
|
||||
class GenerateOriginalBackground(JobOperatorBase, bpy.types.Operator):
|
||||
|
||||
@@ -279,6 +279,42 @@ def prepare_image_input(image, scene):
|
||||
return source_path, False
|
||||
|
||||
|
||||
def analysis_source_image(image):
|
||||
"""Return the oldest available source behind an upscaled image."""
|
||||
current = image
|
||||
visited = set()
|
||||
while current is not None and id(current) not in visited:
|
||||
visited.add(id(current))
|
||||
source = getattr(current, "anyimage_upscale_source", None)
|
||||
if source is None or source is current:
|
||||
break
|
||||
current = source
|
||||
return current
|
||||
|
||||
|
||||
def prepare_image_analysis_inputs(image, scene):
|
||||
"""Prepare display and analysis inputs, reusing one path when possible."""
|
||||
source_path, delete_input = prepare_image_input(image, scene)
|
||||
analysis_image = analysis_source_image(image)
|
||||
if analysis_image is image:
|
||||
return source_path, delete_input, source_path, False
|
||||
try:
|
||||
analysis_path, delete_analysis_input = prepare_image_input(
|
||||
analysis_image,
|
||||
scene,
|
||||
)
|
||||
except Exception:
|
||||
if delete_input:
|
||||
remove_temporary_input(source_path)
|
||||
raise
|
||||
return (
|
||||
source_path,
|
||||
delete_input,
|
||||
analysis_path,
|
||||
delete_analysis_input,
|
||||
)
|
||||
|
||||
|
||||
def configure_image_user(
|
||||
image_user,
|
||||
frame_count=None,
|
||||
|
||||
@@ -24,13 +24,13 @@ from ..image import (
|
||||
video_total_frames,
|
||||
)
|
||||
from .common import (
|
||||
ImageEditWarning,
|
||||
active_view3d_tool_id,
|
||||
apply_image_mask_settings,
|
||||
crop_pixels_to_alpha,
|
||||
draw_dashed_line,
|
||||
draw_image_mask_operator_settings,
|
||||
draw_polygon_fill,
|
||||
ImageEditWarning,
|
||||
image_edit_poll,
|
||||
image_mask_refine_request,
|
||||
polygon_bounds,
|
||||
@@ -39,7 +39,6 @@ from .common import (
|
||||
resolve_image_edit_click,
|
||||
)
|
||||
|
||||
|
||||
GESTURE_TOOL_IDS = {
|
||||
"BOX": "anyimage.image_box",
|
||||
"LASSO": "anyimage.image_lasso",
|
||||
@@ -59,30 +58,55 @@ def points_inside_polygon(points, polygon):
|
||||
previous = polygon[-1]
|
||||
for current in polygon:
|
||||
crosses = (current[1] > points[:, 1]) != (previous[1] > points[:, 1])
|
||||
edge_x = (
|
||||
(previous[0] - current[0])
|
||||
* (points[:, 1] - current[1])
|
||||
/ (previous[1] - current[1] + 1e-300)
|
||||
+ current[0]
|
||||
)
|
||||
edge_x = (previous[0] - current[0]) * (points[:, 1] - current[1]) / (
|
||||
previous[1] - current[1] + 1e-300
|
||||
) + current[0]
|
||||
inside ^= crosses & (points[:, 0] < edge_x)
|
||||
previous = current
|
||||
return inside
|
||||
|
||||
|
||||
def polygon_mask(image_size, polygon):
|
||||
def polygon_mask(image_size, polygon, fill_rule="EVEN_ODD"):
|
||||
import numpy as np
|
||||
|
||||
width, height = (int(value) for value in image_size)
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("The source image has no readable dimensions")
|
||||
polygon_bounds(polygon)
|
||||
y, x = np.indices((height, width), dtype=np.float64)
|
||||
centers = np.column_stack((x.ravel() + 0.5, y.ravel() + 0.5))
|
||||
return points_inside_polygon(centers, polygon).reshape((height, width)).astype(
|
||||
np.float32
|
||||
)
|
||||
if fill_rule not in {"EVEN_ODD", "NONZERO"}:
|
||||
raise ValueError(f"Unknown polygon fill rule: {fill_rule}")
|
||||
left, top, right, bottom = polygon_bounds(polygon)
|
||||
left = max(left, 0)
|
||||
top = max(top, 0)
|
||||
right = min(right, width)
|
||||
bottom = min(bottom, height)
|
||||
mask = np.zeros((height, width), dtype=bool)
|
||||
if right <= left or bottom <= top:
|
||||
return mask
|
||||
|
||||
points = np.asarray(polygon, dtype=np.float64)
|
||||
x = np.arange(left, right, dtype=np.float64) + 0.5
|
||||
for row_start in range(top, bottom, 128):
|
||||
row_end = min(row_start + 128, bottom)
|
||||
y = np.arange(row_start, row_end, dtype=np.float64) + 0.5
|
||||
state = np.zeros(
|
||||
(len(y), len(x)),
|
||||
dtype=bool if fill_rule == "EVEN_ODD" else np.int16,
|
||||
)
|
||||
previous = points[-1]
|
||||
for current in points:
|
||||
crosses = (current[1] > y) != (previous[1] > y)
|
||||
edge_x = (previous[0] - current[0]) * (y - current[1]) / (
|
||||
previous[1] - current[1] + 1e-300
|
||||
) + current[0]
|
||||
selected = crosses[:, None] & (x[None, :] < edge_x[:, None])
|
||||
if fill_rule == "EVEN_ODD":
|
||||
state ^= selected
|
||||
else:
|
||||
winding = 1 if current[1] > previous[1] else -1
|
||||
state += selected * winding
|
||||
previous = current
|
||||
mask[row_start:row_end, left:right] = state != 0
|
||||
return mask
|
||||
|
||||
def refined_mask_from_pixels(mask_pixels, mask_size, bounds, source_size):
|
||||
import numpy as np
|
||||
@@ -90,9 +114,7 @@ def refined_mask_from_pixels(mask_pixels, mask_size, bounds, source_size):
|
||||
mask_width, mask_height = (int(value) for value in mask_size)
|
||||
source_width, source_height = (int(value) for value in source_size)
|
||||
local = np.flipud(
|
||||
np.asarray(mask_pixels, dtype=np.float32).reshape(
|
||||
(mask_height, mask_width, 4)
|
||||
)
|
||||
np.asarray(mask_pixels, dtype=np.float32).reshape((mask_height, mask_width, 4))
|
||||
)[:, :, 3]
|
||||
result = np.zeros((source_height, source_width), dtype=np.float32)
|
||||
left, top, right, bottom = (int(value) for value in bounds)
|
||||
@@ -116,7 +138,7 @@ def edit_image_mask_pixels(source_pixels, source_size, selection_mask, invert=Fa
|
||||
width, height = (int(value) for value in source_size)
|
||||
rgba = np.flipud(
|
||||
np.asarray(source_pixels, dtype=np.float32).reshape((height, width, 4))
|
||||
).copy()
|
||||
)
|
||||
mask = np.asarray(selection_mask, dtype=np.float32)
|
||||
if mask.shape != (height, width):
|
||||
raise ValueError("The refined selection does not match the source image")
|
||||
@@ -146,6 +168,25 @@ def edit_image_object(source_object, selection_mask, invert=False):
|
||||
)
|
||||
|
||||
|
||||
def selected_image_rgba(
|
||||
source_object, polygon, invert=False, fill_rule="EVEN_ODD"
|
||||
):
|
||||
import numpy as np
|
||||
|
||||
image = source_object.data
|
||||
width, height = (int(value) for value in image.size)
|
||||
pixels = np.empty(len(image.pixels), dtype=np.float32)
|
||||
image.pixels.foreach_get(pixels)
|
||||
rgba = np.flipud(pixels.reshape((height, width, 4)))
|
||||
selection = polygon_mask((width, height), polygon, fill_rule)
|
||||
if invert:
|
||||
selection = ~selection
|
||||
rgba[:, :, 3] *= selection
|
||||
if not np.any(rgba[:, :, 3] > 0.0):
|
||||
raise ImageEditWarning("The selection contains no visible image pixels")
|
||||
return rgba
|
||||
|
||||
|
||||
def gesture_property():
|
||||
return bpy.props.EnumProperty(
|
||||
items=(
|
||||
@@ -191,9 +232,9 @@ def line_half_plane_polygon(start, end, rectangle_size):
|
||||
vertices = [(0.0, 0.0), (width, 0.0), (width, height), (0.0, height)]
|
||||
|
||||
def side(point):
|
||||
return (end[0] - start[0]) * (point[1] - start[1]) - (
|
||||
end[1] - start[1]
|
||||
) * (point[0] - start[0])
|
||||
return (end[0] - start[0]) * (point[1] - start[1]) - (end[1] - start[1]) * (
|
||||
point[0] - start[0]
|
||||
)
|
||||
|
||||
result = []
|
||||
previous = vertices[-1]
|
||||
@@ -222,24 +263,8 @@ def image_line_half_plane_polygon(line, image_size):
|
||||
return line_half_plane_polygon(line[1], line[0], image_size)
|
||||
|
||||
|
||||
class EditImageMask(bpy.types.Operator):
|
||||
bl_idname = "anyimage.edit_image_mask"
|
||||
bl_label = "Edit Image Mask"
|
||||
bl_description = "Edit and tightly crop an Image Empty with a gesture"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
source_object_name: hidden_string_property()
|
||||
source_matrix_data: hidden_string_property()
|
||||
selection_data: hidden_string_property()
|
||||
refine_selection: refine_selection_property()
|
||||
invert: bpy.props.BoolProperty(
|
||||
name="Invert",
|
||||
description="Keep the opposite side of the refined gesture mask",
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
gesture: gesture_property()
|
||||
|
||||
class ImageMaskGesture:
|
||||
active_tool_id = None
|
||||
_path = None
|
||||
_cursor = None
|
||||
_handle = None
|
||||
@@ -277,9 +302,7 @@ class EditImageMask(bpy.types.Operator):
|
||||
if len(polygon) >= 3:
|
||||
draw_polygon_fill(
|
||||
polygon,
|
||||
(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":
|
||||
draw_dashed_line((self._path[0], self._cursor))
|
||||
@@ -407,7 +430,8 @@ class EditImageMask(bpy.types.Operator):
|
||||
|
||||
def modal(self, context, event):
|
||||
active_tool = active_view3d_tool_id(context)
|
||||
if active_tool is not None and active_tool != GESTURE_TOOL_IDS[self.gesture]:
|
||||
expected_tool = self.active_tool_id or GESTURE_TOOL_IDS[self.gesture]
|
||||
if active_tool is not None and active_tool != expected_tool:
|
||||
self._finish(context)
|
||||
return {"CANCELLED"}
|
||||
if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS":
|
||||
@@ -423,6 +447,7 @@ class EditImageMask(bpy.types.Operator):
|
||||
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)
|
||||
self._cursor = point
|
||||
elif self.gesture == "LINE":
|
||||
@@ -434,6 +459,7 @@ class EditImageMask(bpy.types.Operator):
|
||||
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)
|
||||
self._cursor = point
|
||||
elif self.gesture == "LINE":
|
||||
@@ -525,6 +551,25 @@ class EditImageMask(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EditImageMask(ImageMaskGesture, bpy.types.Operator):
|
||||
bl_idname = "anyimage.edit_image_mask"
|
||||
bl_label = "Edit Image Mask"
|
||||
bl_description = "Edit and tightly crop an Image Empty with a gesture"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
source_object_name: hidden_string_property()
|
||||
source_matrix_data: hidden_string_property()
|
||||
selection_data: hidden_string_property()
|
||||
refine_selection: refine_selection_property()
|
||||
invert: bpy.props.BoolProperty(
|
||||
name="Invert",
|
||||
description="Keep the opposite side of the refined gesture mask",
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
gesture: gesture_property()
|
||||
|
||||
|
||||
class RefineImageMask(JobOperatorBase, bpy.types.Operator):
|
||||
bl_idname = "anyimage.refine_image_mask"
|
||||
bl_label = "Refine Image Mask"
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
import bpy
|
||||
|
||||
from .image import configure_image_user, is_animated_image
|
||||
|
||||
DISPLACEMENT_STRENGTH = 1.0
|
||||
DISPLACEMENT_MIDLEVEL = 0.0
|
||||
ORIGINAL_DISPLACEMENT_STRENGTH = 1.0
|
||||
@@ -15,10 +16,12 @@ NODE_ASSET_NAME = "O_AnyImage.blend"
|
||||
MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement"
|
||||
BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth"
|
||||
DEPTH_SCALE_ATTRIBUTE = "o_depth_scale"
|
||||
SURFACE_MATERIAL_NODE_GROUP_NAME = "O DA3 Surface Layer"
|
||||
TEXTURE_MATERIAL_NODE_GROUP_NAME = "O Texture Layer"
|
||||
MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME = "O MoGe2 Surface Layer"
|
||||
BALLOON_MATERIAL_NODE_GROUP_NAME = "O Balloon Layer"
|
||||
IMAGE_MATERIAL_NODE_GROUP_NAME = "O Image Layer"
|
||||
IMAGE_MATERIAL_NODE_GROUP_VERSION = 6
|
||||
SURFACE_MATERIAL_NODE_GROUP_NAME = IMAGE_MATERIAL_NODE_GROUP_NAME
|
||||
TEXTURE_MATERIAL_NODE_GROUP_NAME = IMAGE_MATERIAL_NODE_GROUP_NAME
|
||||
MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME = IMAGE_MATERIAL_NODE_GROUP_NAME
|
||||
BALLOON_MATERIAL_NODE_GROUP_NAME = IMAGE_MATERIAL_NODE_GROUP_NAME
|
||||
DEPTH_PLANE_MATERIAL_NODE_GROUP_NAME = "O DA3 Depth Plane Layer"
|
||||
MOGE_MATERIAL_NODE_GROUP_NAME = "O MoGe2 Depth Plane Layer"
|
||||
SHADELESS_NODE_GROUP_NAME = "O Shadeless"
|
||||
@@ -30,6 +33,7 @@ VIEW_TRANSFORM_COLOR_SPACES = (
|
||||
("Khronos PBR Neutral", "Khronos PBR Neutral sRGB"),
|
||||
)
|
||||
|
||||
|
||||
def material_color_space_name(scene):
|
||||
view_settings = getattr(scene, "view_settings", None)
|
||||
view_transform = getattr(view_settings, "view_transform", "")
|
||||
@@ -46,6 +50,18 @@ def set_material_color_space(image, scene=None):
|
||||
settings = image.colorspace_settings
|
||||
preferred = material_color_space_name(scene)
|
||||
original = settings.name
|
||||
pixels = None
|
||||
if (
|
||||
preferred != original
|
||||
and (
|
||||
getattr(image, "source", None) == "GENERATED"
|
||||
or getattr(image, "is_dirty", False)
|
||||
)
|
||||
):
|
||||
import numpy as np
|
||||
|
||||
pixels = np.empty(len(image.pixels), dtype=np.float32)
|
||||
image.pixels.foreach_get(pixels)
|
||||
for color_space in dict.fromkeys((preferred, "sRGB", original)):
|
||||
if not color_space:
|
||||
continue
|
||||
@@ -54,11 +70,13 @@ def set_material_color_space(image, scene=None):
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if settings.name == color_space:
|
||||
if pixels is not None:
|
||||
image.pixels.foreach_set(pixels)
|
||||
image.update()
|
||||
return color_space
|
||||
return settings.name
|
||||
|
||||
|
||||
|
||||
def moge_material_node_group():
|
||||
existing = bpy.data.node_groups.get(MOGE_MATERIAL_NODE_GROUP_NAME)
|
||||
if existing is not None:
|
||||
@@ -235,7 +253,6 @@ def create_moge_material(
|
||||
return material
|
||||
|
||||
|
||||
|
||||
def material_node_group(depth_plane=False, surface_group_name=None):
|
||||
group_name = (
|
||||
DEPTH_PLANE_MATERIAL_NODE_GROUP_NAME
|
||||
@@ -243,22 +260,38 @@ def material_node_group(depth_plane=False, surface_group_name=None):
|
||||
else surface_group_name or SURFACE_MATERIAL_NODE_GROUP_NAME
|
||||
)
|
||||
existing = bpy.data.node_groups.get(group_name)
|
||||
if existing is not None:
|
||||
rebuild_image_layer = (
|
||||
existing is not None
|
||||
and group_name == IMAGE_MATERIAL_NODE_GROUP_NAME
|
||||
and existing.get("anyimage_image_layer_version")
|
||||
!= IMAGE_MATERIAL_NODE_GROUP_VERSION
|
||||
)
|
||||
if existing is not None and not rebuild_image_layer:
|
||||
for item in existing.interface.items_tree:
|
||||
if (
|
||||
item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Normal"
|
||||
):
|
||||
item.default_value = (0.5, 0.5, 1.0, 1.0)
|
||||
return existing
|
||||
if existing is None:
|
||||
group = bpy.data.node_groups.new(group_name, "ShaderNodeTree")
|
||||
else:
|
||||
group = existing
|
||||
group.nodes.clear()
|
||||
group.interface.clear()
|
||||
interface = group.interface
|
||||
input_sockets = [
|
||||
("Color", "NodeSocketColor"),
|
||||
("Alpha", "NodeSocketFloat"),
|
||||
("Bump Scale", "NodeSocketFloat"),
|
||||
]
|
||||
normal_group = group_name in {
|
||||
MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME,
|
||||
BALLOON_MATERIAL_NODE_GROUP_NAME,
|
||||
}
|
||||
normal_group = not depth_plane
|
||||
if normal_group:
|
||||
input_sockets.insert(2, ("Normal", "NodeSocketColor"))
|
||||
input_sockets.insert(3, ("Normal Strength", "NodeSocketFloat"))
|
||||
input_sockets.insert(3, ("Normal Scale", "NodeSocketFloat"))
|
||||
input_sockets.insert(4, ("Object Space", "NodeSocketBool"))
|
||||
if depth_plane:
|
||||
input_sockets.insert(2, ("Depth", "NodeSocketFloat"))
|
||||
for name, socket_type in input_sockets:
|
||||
@@ -273,15 +306,17 @@ def material_node_group(depth_plane=False, surface_group_name=None):
|
||||
socket.max_value = 1.0
|
||||
socket.subtype = "FACTOR"
|
||||
elif name == "Bump Scale":
|
||||
socket.default_value = 1.0
|
||||
socket.default_value = 0.0
|
||||
socket.min_value = -10000.0
|
||||
socket.max_value = 10000.0
|
||||
elif name == "Normal Strength":
|
||||
socket.default_value = (
|
||||
0.5 if group_name == BALLOON_MATERIAL_NODE_GROUP_NAME else 1.0
|
||||
)
|
||||
elif name == "Normal":
|
||||
socket.default_value = (0.5, 0.5, 1.0, 1.0)
|
||||
elif name == "Normal Scale":
|
||||
socket.default_value = 1.0
|
||||
socket.min_value = 0.0
|
||||
socket.max_value = 10.0
|
||||
elif name == "Object Space":
|
||||
socket.default_value = False
|
||||
output_sockets = [
|
||||
("Color", "NodeSocketColor"),
|
||||
("Roughness", "NodeSocketFloat"),
|
||||
@@ -311,21 +346,27 @@ def material_node_group(depth_plane=False, surface_group_name=None):
|
||||
roughness_curve.mapping.update()
|
||||
roughness_curve.location = (-250.0, 350.0)
|
||||
bump = nodes.new("ShaderNodeBump")
|
||||
bump.location = (-250.0, 0.0)
|
||||
bump.location = (100.0, 0.0)
|
||||
if normal_group:
|
||||
normal_map = nodes.new("ShaderNodeNormalMap")
|
||||
normal_map.space = (
|
||||
"TANGENT"
|
||||
if group_name == BALLOON_MATERIAL_NODE_GROUP_NAME
|
||||
else "OBJECT"
|
||||
)
|
||||
normal_map.location = (-500.0, -150.0)
|
||||
tangent_normal = nodes.new("ShaderNodeNormalMap")
|
||||
tangent_normal.space = "TANGENT"
|
||||
tangent_normal.location = (-500.0, -100.0)
|
||||
object_normal = nodes.new("ShaderNodeNormalMap")
|
||||
object_normal.space = "OBJECT"
|
||||
object_normal.location = (-500.0, -350.0)
|
||||
choose_normal = nodes.new("ShaderNodeMix")
|
||||
choose_normal.data_type = "VECTOR"
|
||||
choose_normal.location = (-150.0, -150.0)
|
||||
for normal_map in (tangent_normal, object_normal):
|
||||
links.new(group_input.outputs["Normal"], normal_map.inputs["Color"])
|
||||
links.new(
|
||||
group_input.outputs["Normal Strength"],
|
||||
group_input.outputs["Normal Scale"],
|
||||
normal_map.inputs["Strength"],
|
||||
)
|
||||
links.new(normal_map.outputs["Normal"], bump.inputs["Normal"])
|
||||
links.new(group_input.outputs["Object Space"], choose_normal.inputs[0])
|
||||
links.new(tangent_normal.outputs["Normal"], choose_normal.inputs[4])
|
||||
links.new(object_normal.outputs["Normal"], choose_normal.inputs[5])
|
||||
links.new(choose_normal.outputs["Result"], bump.inputs["Normal"])
|
||||
displacement = None
|
||||
|
||||
processed_alpha = group_input.outputs["Alpha"]
|
||||
@@ -408,6 +449,8 @@ def material_node_group(depth_plane=False, surface_group_name=None):
|
||||
else f"Prepare {group_name} material outputs."
|
||||
)
|
||||
group.use_fake_user = True
|
||||
if group_name == IMAGE_MATERIAL_NODE_GROUP_NAME:
|
||||
group["anyimage_image_layer_version"] = IMAGE_MATERIAL_NODE_GROUP_VERSION
|
||||
return group
|
||||
|
||||
|
||||
@@ -428,6 +471,7 @@ def create_material(
|
||||
use_cyclic=None,
|
||||
surface_group_name=None,
|
||||
normal_image=None,
|
||||
normal_space="TANGENT",
|
||||
scene=None,
|
||||
):
|
||||
material = bpy.data.materials.new(name)
|
||||
@@ -452,14 +496,10 @@ def create_material(
|
||||
output.location = (850.0, 350.0)
|
||||
extracted_frames = frame_count if frame_count > 1 else None
|
||||
color_frames = (
|
||||
color_frame_duration
|
||||
if color_frame_duration is not None
|
||||
else extracted_frames
|
||||
color_frame_duration if color_frame_duration is not None else extracted_frames
|
||||
)
|
||||
depth_frames = (
|
||||
depth_frame_duration
|
||||
if depth_frame_duration is not None
|
||||
else extracted_frames
|
||||
depth_frame_duration if depth_frame_duration is not None else extracted_frames
|
||||
)
|
||||
material_frames = color_frames
|
||||
material_frame_offset = color_frame_offset
|
||||
@@ -481,10 +521,12 @@ def create_material(
|
||||
is_depth_plane,
|
||||
surface_group_name=surface_group_name,
|
||||
)
|
||||
material_inputs.name = material_inputs.node_tree.name
|
||||
material_inputs.label = material_inputs.name
|
||||
material_inputs.location = (-300.0, 350.0)
|
||||
material_inputs.inputs["Bump Scale"].default_value = depth_scale
|
||||
if "Object Space" in material_inputs.inputs:
|
||||
if normal_space not in {"TANGENT", "OBJECT"}:
|
||||
raise ValueError(f"Unsupported normal space: {normal_space}")
|
||||
material_inputs.inputs["Object Space"].default_value = normal_space == "OBJECT"
|
||||
links.new(color_texture.outputs["Color"], material_inputs.inputs["Color"])
|
||||
links.new(alpha, material_inputs.inputs["Alpha"])
|
||||
if normal_image is not None and "Normal" in material_inputs.inputs:
|
||||
@@ -608,6 +650,16 @@ def node_asset_path():
|
||||
def load_asset_node_group(group_name):
|
||||
existing = bpy.data.node_groups.get(group_name)
|
||||
if existing is not None:
|
||||
for item in existing.interface.items_tree:
|
||||
if (
|
||||
item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Normal"
|
||||
):
|
||||
item.default_value = (0.5, 0.5, 1.0, 1.0)
|
||||
for node in existing.nodes:
|
||||
if node.bl_idname == "ShaderNodeNormalMap":
|
||||
node.space = "TANGENT"
|
||||
return existing
|
||||
asset_path = node_asset_path()
|
||||
if not asset_path.is_file():
|
||||
@@ -648,6 +700,7 @@ def load_shadeless_node_group():
|
||||
def load_depth_plane_node_group(group_name=DEPTH_PLANE_NODE_GROUP_NAME):
|
||||
return load_asset_node_group(group_name)
|
||||
|
||||
|
||||
def load_mesh_plane_node_group():
|
||||
return load_asset_node_group(MESH_PLANE_NODE_GROUP_NAME)
|
||||
|
||||
@@ -691,18 +744,13 @@ def get_modifier_input(modifier, identifier):
|
||||
def modifier_input_driver_path(modifier, identifier):
|
||||
"""Return the driver data path for a modifier input socket."""
|
||||
if modifier_input_slots(modifier) is not None:
|
||||
return (
|
||||
f'modifiers["{modifier.name}"]'
|
||||
f".properties.inputs.{identifier}.value"
|
||||
)
|
||||
return f'modifiers["{modifier.name}"].properties.inputs.{identifier}.value'
|
||||
return f'modifiers["{modifier.name}"]["{identifier}"]'
|
||||
|
||||
|
||||
def drive_modifier_input(owner_object, modifier, identifier, expression="frame"):
|
||||
"""Add a scripted driver to a modifier input so it follows the scene frame."""
|
||||
driver = owner_object.driver_add(
|
||||
modifier_input_driver_path(modifier, identifier)
|
||||
)
|
||||
driver = owner_object.driver_add(modifier_input_driver_path(modifier, identifier))
|
||||
driver.driver.type = "SCRIPTED"
|
||||
driver.driver.expression = expression
|
||||
return driver
|
||||
|
||||
1691
src/anyimage/operators/mesh.py
Normal file
1691
src/anyimage/operators/mesh.py
Normal file
File diff suppressed because it is too large
Load Diff
158
src/anyimage/operators/normal_map.py
Normal file
158
src/anyimage/operators/normal_map.py
Normal file
@@ -0,0 +1,158 @@
|
||||
def _normalized(values, fallback):
|
||||
import numpy as np
|
||||
|
||||
values = np.asarray(values, dtype=np.float64)
|
||||
lengths = np.linalg.norm(values, axis=-1)
|
||||
result = np.empty_like(values)
|
||||
usable = lengths > 1e-10
|
||||
result[usable] = values[usable] / lengths[usable, None]
|
||||
result[~usable] = fallback
|
||||
return result
|
||||
|
||||
|
||||
def rasterize_tangent_normal(
|
||||
object_normal,
|
||||
triangle_uv,
|
||||
triangle_normal,
|
||||
triangle_tangent,
|
||||
triangle_sign,
|
||||
return_coverage=False,
|
||||
):
|
||||
"""Convert object-space pixels through a smooth per-corner UV tangent frame."""
|
||||
import numpy as np
|
||||
|
||||
object_normal = _normalized(object_normal, (0.0, 0.0, 1.0))
|
||||
height, width = object_normal.shape[:2]
|
||||
output = np.zeros((height, width, 3), dtype=np.float32)
|
||||
output[..., 2] = 1.0
|
||||
coverage = np.zeros((height, width), dtype=bool)
|
||||
scale = np.asarray((max(width - 1, 1), max(height - 1, 1)))
|
||||
|
||||
for uv, corner_normal, corner_tangent, corner_sign in zip(
|
||||
triangle_uv,
|
||||
triangle_normal,
|
||||
triangle_tangent,
|
||||
triangle_sign,
|
||||
strict=True,
|
||||
):
|
||||
pixel = np.asarray(uv, dtype=np.float64) * scale
|
||||
x_min = max(int(np.ceil(np.min(pixel[:, 0]))), 0)
|
||||
x_max = min(int(np.floor(np.max(pixel[:, 0]))), width - 1)
|
||||
y_min = max(int(np.ceil(np.min(pixel[:, 1]))), 0)
|
||||
y_max = min(int(np.floor(np.max(pixel[:, 1]))), height - 1)
|
||||
if x_min > x_max or y_min > y_max:
|
||||
continue
|
||||
|
||||
a, b, c = pixel
|
||||
denominator = (b[1] - c[1]) * (a[0] - c[0]) + (
|
||||
c[0] - b[0]
|
||||
) * (a[1] - c[1])
|
||||
if abs(denominator) <= 1e-12:
|
||||
continue
|
||||
grid_x, grid_y = np.meshgrid(
|
||||
np.arange(x_min, x_max + 1, dtype=np.float64),
|
||||
np.arange(y_min, y_max + 1, dtype=np.float64),
|
||||
)
|
||||
first = (
|
||||
(b[1] - c[1]) * (grid_x - c[0])
|
||||
+ (c[0] - b[0]) * (grid_y - c[1])
|
||||
) / denominator
|
||||
second = (
|
||||
(c[1] - a[1]) * (grid_x - c[0])
|
||||
+ (a[0] - c[0]) * (grid_y - c[1])
|
||||
) / denominator
|
||||
third = 1.0 - first - second
|
||||
inside = (first >= -1e-6) & (second >= -1e-6) & (third >= -1e-6)
|
||||
if not inside.any():
|
||||
continue
|
||||
|
||||
weights = np.stack((first[inside], second[inside], third[inside]), axis=-1)
|
||||
normal = _normalized(weights @ corner_normal, (0.0, 0.0, 1.0))
|
||||
tangent = weights @ corner_tangent
|
||||
tangent -= normal * np.sum(tangent * normal, axis=-1, keepdims=True)
|
||||
tangent = _normalized(tangent, (1.0, 0.0, 0.0))
|
||||
sign = np.where(weights @ corner_sign >= 0.0, 1.0, -1.0)
|
||||
bitangent = np.cross(normal, tangent) * sign[:, None]
|
||||
|
||||
rows = grid_y[inside].astype(np.int32)
|
||||
columns = grid_x[inside].astype(np.int32)
|
||||
source = object_normal[rows, columns]
|
||||
output[rows, columns, 0] = np.sum(source * tangent, axis=-1)
|
||||
output[rows, columns, 1] = np.sum(source * bitangent, axis=-1)
|
||||
output[rows, columns, 2] = np.sum(source * normal, axis=-1)
|
||||
coverage[rows, columns] = True
|
||||
|
||||
output[coverage] = _normalized(output[coverage], (0.0, 0.0, 1.0))
|
||||
return (output, coverage) if return_coverage else output
|
||||
|
||||
|
||||
def bake_tangent_normal_image(context, source_object, target_object, image):
|
||||
"""Bake a MoGe object-space image against the evaluated target mesh TBN."""
|
||||
import numpy as np
|
||||
|
||||
width, height = (int(value) for value in image.size)
|
||||
rgba = np.empty(width * height * 4, dtype=np.float32)
|
||||
image.pixels.foreach_get(rgba)
|
||||
rgba = rgba.reshape((height, width, 4))
|
||||
source_normal = rgba[..., :3].astype(np.float64) * 2.0 - 1.0
|
||||
|
||||
source_to_target = (
|
||||
target_object.matrix_world.inverted().to_3x3()
|
||||
@ source_object.matrix_world.to_3x3()
|
||||
)
|
||||
normal_matrix = source_to_target.inverted().transposed()
|
||||
transform = np.asarray(normal_matrix, dtype=np.float64)
|
||||
target_normal = source_normal @ transform.T
|
||||
target_normal = _normalized(target_normal, (0.0, 0.0, 1.0))
|
||||
front = _normalized(
|
||||
np.asarray((0.0, 0.0, 1.0), dtype=np.float64)[None, :] @ transform.T,
|
||||
(0.0, 0.0, 1.0),
|
||||
)[0]
|
||||
|
||||
context.view_layer.update()
|
||||
depsgraph = context.evaluated_depsgraph_get()
|
||||
evaluated = target_object.evaluated_get(depsgraph)
|
||||
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
|
||||
try:
|
||||
uv_layer = mesh.uv_layers.active
|
||||
if uv_layer is None:
|
||||
raise ValueError("The target mesh has no active UV map")
|
||||
mesh.calc_loop_triangles()
|
||||
mesh.calc_tangents(uvmap=uv_layer.name)
|
||||
loop_count = len(mesh.loops)
|
||||
uv = np.empty((loop_count, 2), dtype=np.float64)
|
||||
uv_layer.data.foreach_get("uv", uv.ravel())
|
||||
tangent = np.empty((loop_count, 3), dtype=np.float64)
|
||||
sign = np.empty(loop_count, dtype=np.float64)
|
||||
mesh.loops.foreach_get("tangent", tangent.ravel())
|
||||
mesh.loops.foreach_get("bitangent_sign", sign)
|
||||
normal = np.asarray(
|
||||
[tuple(loop.normal) for loop in mesh.loops],
|
||||
dtype=np.float64,
|
||||
)
|
||||
triangles = np.asarray(
|
||||
[tuple(triangle.loops) for triangle in mesh.loop_triangles],
|
||||
dtype=np.int32,
|
||||
)
|
||||
triangle_normal = np.asarray(
|
||||
[tuple(triangle.normal) for triangle in mesh.loop_triangles],
|
||||
dtype=np.float64,
|
||||
)
|
||||
front_facing = triangle_normal @ front > 1e-6
|
||||
triangles = triangles[front_facing]
|
||||
tangent_normal, coverage = rasterize_tangent_normal(
|
||||
target_normal,
|
||||
uv[triangles],
|
||||
normal[triangles],
|
||||
tangent[triangles],
|
||||
sign[triangles],
|
||||
return_coverage=True,
|
||||
)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
rgba[coverage, :3] = tangent_normal[coverage] * 0.5 + 0.5
|
||||
image.pixels.foreach_set(rgba.ravel())
|
||||
image.update()
|
||||
image.pack()
|
||||
return image
|
||||
@@ -18,12 +18,13 @@ from .common import (
|
||||
)
|
||||
|
||||
from .image import (
|
||||
analysis_source_image,
|
||||
draw_process_resolution,
|
||||
is_image_empty,
|
||||
material_data_name,
|
||||
normal_data_name,
|
||||
plane_bounds,
|
||||
prepare_image_input,
|
||||
prepare_image_analysis_inputs,
|
||||
process_resolution_scale_property,
|
||||
remove_temporary_input,
|
||||
scaled_process_resolution,
|
||||
@@ -474,6 +475,7 @@ def build_image_mesh(
|
||||
MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME if moge2 else None
|
||||
),
|
||||
normal_image=normal_image,
|
||||
normal_space="OBJECT" if moge2 and normal_image is not None else "TANGENT",
|
||||
scene=context.scene,
|
||||
)
|
||||
mesh.materials.append(material)
|
||||
@@ -568,7 +570,11 @@ class ConvertToSurface(bpy.types.Operator):
|
||||
if self.model.startswith("MOGE2_"):
|
||||
layout.prop(self, "resolution_level")
|
||||
else:
|
||||
draw_process_resolution(layout, self, context.object.data)
|
||||
draw_process_resolution(
|
||||
layout,
|
||||
self,
|
||||
analysis_source_image(context.object.data),
|
||||
)
|
||||
layout.prop(self, "create_camera")
|
||||
layout.prop(self, "remove_background")
|
||||
layout.prop(self, "shadeless")
|
||||
@@ -581,13 +587,19 @@ class ConvertToSurface(bpy.types.Operator):
|
||||
self.report({"ERROR"}, "Convert to Surface currently supports still images")
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
analysis_image = analysis_source_image(source_object.data)
|
||||
process_res = 1
|
||||
if not self.model.startswith("MOGE2_"):
|
||||
process_res = scaled_process_resolution(
|
||||
source_object.data,
|
||||
analysis_image,
|
||||
self.process_res_factor,
|
||||
)
|
||||
source_path, delete_input = prepare_image_input(
|
||||
(
|
||||
source_path,
|
||||
delete_input,
|
||||
analysis_path,
|
||||
delete_analysis_input,
|
||||
) = prepare_image_analysis_inputs(
|
||||
source_object.data,
|
||||
context.scene,
|
||||
)
|
||||
@@ -596,10 +608,12 @@ class ConvertToSurface(bpy.types.Operator):
|
||||
return {"CANCELLED"}
|
||||
common = {
|
||||
"input_path": str(source_path),
|
||||
"analysis_input_path": str(analysis_path),
|
||||
"source_object_name": source_object.name,
|
||||
"model": self.model,
|
||||
"delete_source": not self.keep_original,
|
||||
"delete_input": delete_input,
|
||||
"delete_analysis_input": delete_analysis_input,
|
||||
"create_camera": self.create_camera,
|
||||
"shadeless": self.shadeless,
|
||||
"remove_background": self.remove_background,
|
||||
@@ -617,6 +631,8 @@ class ConvertToSurface(bpy.types.Operator):
|
||||
if "CANCELLED" in result:
|
||||
if delete_input:
|
||||
remove_temporary_input(source_path)
|
||||
if delete_analysis_input:
|
||||
remove_temporary_input(analysis_path)
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -669,11 +685,13 @@ class GenerateDa3Surface(JobOperatorBase, bpy.types.Operator):
|
||||
starting_message = "Starting perspective surface generation"
|
||||
|
||||
input_path: bpy.props.StringProperty(options=HIDDEN)
|
||||
analysis_input_path: bpy.props.StringProperty(options=HIDDEN)
|
||||
source_object_name: bpy.props.StringProperty(options=HIDDEN)
|
||||
model: bpy.props.StringProperty(default="BASE", options=HIDDEN)
|
||||
process_res: bpy.props.IntProperty(default=1024, min=1, options=HIDDEN)
|
||||
delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN)
|
||||
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
delete_analysis_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
create_camera: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
shadeless: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
remove_background: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
@@ -684,7 +702,7 @@ class GenerateDa3Surface(JobOperatorBase, bpy.types.Operator):
|
||||
if self.remove_background:
|
||||
require_model(BEN2_MODEL_KEY)
|
||||
settings = context.scene.anyimage_settings
|
||||
return {
|
||||
parameters = {
|
||||
**da3_parameters(
|
||||
settings,
|
||||
input_path(self.input_path),
|
||||
@@ -696,6 +714,9 @@ class GenerateDa3Surface(JobOperatorBase, bpy.types.Operator):
|
||||
BEN2_MODEL_KEY if self.remove_background else ""
|
||||
),
|
||||
}
|
||||
analysis_value = getattr(self, "analysis_input_path", "") or self.input_path
|
||||
parameters["analysis_input"] = str(input_path(analysis_value))
|
||||
return parameters
|
||||
|
||||
def response(self, context, result):
|
||||
build_surface_response(
|
||||
@@ -711,6 +732,10 @@ class GenerateDa3Surface(JobOperatorBase, bpy.types.Operator):
|
||||
|
||||
def cleanup(self):
|
||||
cleanup_temporary_input(self.input_path, self.delete_input)
|
||||
cleanup_temporary_input(
|
||||
getattr(self, "analysis_input_path", ""),
|
||||
getattr(self, "delete_analysis_input", False),
|
||||
)
|
||||
|
||||
|
||||
class GenerateMoge2Surface(JobOperatorBase, bpy.types.Operator):
|
||||
@@ -720,11 +745,13 @@ class GenerateMoge2Surface(JobOperatorBase, bpy.types.Operator):
|
||||
starting_message = "Starting MoGe-2 inference"
|
||||
|
||||
input_path: bpy.props.StringProperty(options=HIDDEN)
|
||||
analysis_input_path: bpy.props.StringProperty(options=HIDDEN)
|
||||
source_object_name: bpy.props.StringProperty(options=HIDDEN)
|
||||
model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=HIDDEN)
|
||||
resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=HIDDEN)
|
||||
delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN)
|
||||
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
delete_analysis_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
create_camera: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
shadeless: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
remove_background: bpy.props.BoolProperty(default=False, options=HIDDEN)
|
||||
@@ -735,7 +762,7 @@ class GenerateMoge2Surface(JobOperatorBase, bpy.types.Operator):
|
||||
if self.remove_background:
|
||||
require_model(BEN2_MODEL_KEY)
|
||||
settings = context.scene.anyimage_settings
|
||||
return {
|
||||
parameters = {
|
||||
**moge2_parameters(
|
||||
settings,
|
||||
input_path(self.input_path),
|
||||
@@ -747,6 +774,9 @@ class GenerateMoge2Surface(JobOperatorBase, bpy.types.Operator):
|
||||
BEN2_MODEL_KEY if self.remove_background else ""
|
||||
),
|
||||
}
|
||||
analysis_value = getattr(self, "analysis_input_path", "") or self.input_path
|
||||
parameters["analysis_input"] = str(input_path(analysis_value))
|
||||
return parameters
|
||||
|
||||
def response(self, context, result):
|
||||
build_surface_response(
|
||||
@@ -762,3 +792,7 @@ class GenerateMoge2Surface(JobOperatorBase, bpy.types.Operator):
|
||||
|
||||
def cleanup(self):
|
||||
cleanup_temporary_input(self.input_path, self.delete_input)
|
||||
cleanup_temporary_input(
|
||||
getattr(self, "analysis_input_path", ""),
|
||||
getattr(self, "delete_analysis_input", False),
|
||||
)
|
||||
|
||||
@@ -2,9 +2,12 @@ from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
from .server.model_catalog import DEFAULT_UPSCALE_MODEL_KEY, UPSCALE_MODELS
|
||||
|
||||
from .server.model_catalog import (
|
||||
DEFAULT_UPSCALE_MODEL_KEY,
|
||||
UPSCALE_MODELS,
|
||||
)
|
||||
|
||||
DEFAULT_MOGE2_MODEL_KEY = "MOGE2_VITS_NORMAL"
|
||||
|
||||
ADDON_PACKAGE = __package__
|
||||
DEFAULT_STORAGE_ROOT = Path.home() / ".anyimage"
|
||||
@@ -18,9 +21,7 @@ UPSCALE_MODEL_ITEMS = tuple(
|
||||
|
||||
def addon_preferences(context=None):
|
||||
preference_context = (
|
||||
context
|
||||
if getattr(context, "preferences", None) is not None
|
||||
else bpy.context
|
||||
context if getattr(context, "preferences", None) is not None else bpy.context
|
||||
)
|
||||
addon = preference_context.preferences.addons.get(ADDON_PACKAGE)
|
||||
return getattr(addon, "preferences", None)
|
||||
@@ -39,6 +40,12 @@ def device_items(owner, context):
|
||||
return device_enum_items(owner, context)
|
||||
|
||||
|
||||
def mesh_depth_model_items(owner, context):
|
||||
from .properties import surface_model_enum_items
|
||||
|
||||
return surface_model_enum_items(owner, context)
|
||||
|
||||
|
||||
class AnyImagePreferences(bpy.types.AddonPreferences):
|
||||
bl_idname = ADDON_PACKAGE
|
||||
|
||||
@@ -78,6 +85,29 @@ class AnyImagePreferences(bpy.types.AddonPreferences):
|
||||
step=256,
|
||||
subtype="PIXEL",
|
||||
)
|
||||
mesh_depth_model: bpy.props.EnumProperty(
|
||||
name="Mesh Depth Model",
|
||||
description="Depth model used by Depth Plane, Depth Balloon and Depth Surface",
|
||||
items=mesh_depth_model_items,
|
||||
default=0,
|
||||
)
|
||||
mesh_process_res_factor: bpy.props.FloatProperty(
|
||||
name="DA3 Resolution Scale",
|
||||
description="Source image scale used for Depth Anything 3 mesh conversion",
|
||||
default=100.0,
|
||||
min=1.0,
|
||||
max=100.0,
|
||||
step=100,
|
||||
precision=0,
|
||||
subtype="PERCENTAGE",
|
||||
)
|
||||
mesh_resolution_level: bpy.props.IntProperty(
|
||||
name="MoGe Resolution Level",
|
||||
description="MoGe-2 inference resolution used for mesh conversion",
|
||||
default=5,
|
||||
min=0,
|
||||
max=9,
|
||||
)
|
||||
|
||||
def draw(self, _context):
|
||||
from .properties import ai_setup_label, ai_status, model_catalog
|
||||
@@ -88,6 +118,14 @@ class AnyImagePreferences(bpy.types.AddonPreferences):
|
||||
layout.prop(self, "device")
|
||||
layout.prop(self, "upscale_model")
|
||||
layout.prop(self, "upscale_max_output_size")
|
||||
layout.separator(type="LINE")
|
||||
layout.label(text="Mesh Conversion")
|
||||
layout.prop(self, "mesh_depth_model")
|
||||
mesh_depth_model = getattr(self, "mesh_depth_model", DEFAULT_MOGE2_MODEL_KEY)
|
||||
if mesh_depth_model.startswith("MOGE2_"):
|
||||
layout.prop(self, "mesh_resolution_level")
|
||||
else:
|
||||
layout.prop(self, "mesh_process_res_factor")
|
||||
layout.separator()
|
||||
status = ai_status()
|
||||
layout.label(text=f"Environment: {runtime.environment_directory()}")
|
||||
|
||||
@@ -14,7 +14,6 @@ from .preferences import (
|
||||
from .runtime import runtime
|
||||
from .server import model_catalog as shared_model_catalog
|
||||
|
||||
|
||||
BEN2_MODEL_KEY = "BEN2_BASE"
|
||||
REALESRGAN_GENERAL_WDN_MODEL_KEY = shared_model_catalog.DEFAULT_UPSCALE_MODEL_KEY
|
||||
SAFMN_MODEL_KEY = shared_model_catalog.SAFMN_MODEL.key
|
||||
@@ -121,13 +120,9 @@ def required_models_ready():
|
||||
|
||||
def ai_status():
|
||||
environment_ready = runtime.environment_ready()
|
||||
ready_models = {
|
||||
model["key"] for model in model_catalog() if model.get("ready")
|
||||
}
|
||||
ready_models = {model["key"] for model in model_catalog() if model.get("ready")}
|
||||
missing_models = tuple(
|
||||
model_key
|
||||
for model_key in REQUIRED_MODEL_KEYS
|
||||
if model_key not in ready_models
|
||||
model_key for model_key in REQUIRED_MODEL_KEYS if model_key not in ready_models
|
||||
)
|
||||
return {
|
||||
"environment_ready": environment_ready,
|
||||
@@ -201,6 +196,7 @@ def balloon_depth_model_enum_items(_owner, _context):
|
||||
models.sort(key=lambda model: model.get("key") != DEFAULT_MOGE2_MODEL_KEY)
|
||||
return _model_items(models)
|
||||
|
||||
|
||||
def depth_plane_model_enum_items(_owner, _context):
|
||||
models = [
|
||||
model
|
||||
@@ -329,6 +325,26 @@ def image_edit_refine_selection_property():
|
||||
)
|
||||
|
||||
|
||||
def cutout_normal_analysis_property():
|
||||
return bpy.props.BoolProperty(
|
||||
name="Normal",
|
||||
description="Use MoGe-2 to add a normal texture for the selected shape",
|
||||
default=False,
|
||||
)
|
||||
|
||||
def cutout_pixel_size_property():
|
||||
return bpy.props.IntProperty(
|
||||
name="Pixel Size",
|
||||
description=(
|
||||
"Approximate source image pixels per mesh step; smaller values "
|
||||
"create denser geometry"
|
||||
),
|
||||
default=32,
|
||||
min=4,
|
||||
max=32,
|
||||
)
|
||||
|
||||
|
||||
def balloon_depth_analysis_property():
|
||||
return bpy.props.BoolProperty(
|
||||
name="Depth",
|
||||
@@ -345,6 +361,7 @@ def balloon_depth_model_property():
|
||||
default=0,
|
||||
)
|
||||
|
||||
|
||||
def balloon_radial_zoom_property(scale):
|
||||
return bpy.props.FloatVectorProperty(
|
||||
size=2,
|
||||
@@ -355,6 +372,8 @@ def balloon_radial_zoom_property(scale):
|
||||
|
||||
|
||||
class AnyImageSettings(bpy.types.PropertyGroup):
|
||||
cutout_pixel_size: cutout_pixel_size_property()
|
||||
cutout_normal_analysis: cutout_normal_analysis_property()
|
||||
image_edit_refine_selection: image_edit_refine_selection_property()
|
||||
balloon_mode: balloon_mode_property()
|
||||
balloon_thickness: balloon_thickness_property()
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
|
||||
from blendjob import JobServer
|
||||
|
||||
from .jobs import generate_depth as _generate_depth
|
||||
from .jobs import generate_surface as _generate_surface
|
||||
from .jobs.background import run as _remove_background
|
||||
from .jobs.balloon import run as _analyze_balloon
|
||||
from .jobs.debug_ben2 import run as _debug_ben2
|
||||
from .jobs.debug_da3 import run as _debug_da3
|
||||
from .jobs.debug_moge2 import run as _debug_moge2
|
||||
from .jobs.debug_upscale import run as _debug_upscale
|
||||
from .jobs.frames import run as _extract_frames
|
||||
from .jobs.image_mask import run as _refine_image_mask
|
||||
from .jobs.mesh import run as _generate_mesh
|
||||
from .jobs.upscale import run as _upscale_image
|
||||
from .model_manager import ModelManager
|
||||
|
||||
@@ -36,11 +34,6 @@ def download_required_models(context, parameters):
|
||||
return manager.download_missing(context, parameters["models"])
|
||||
|
||||
|
||||
@server.job("analyze-balloon")
|
||||
def analyze_balloon(context, parameters):
|
||||
return _analyze_balloon(context, parameters)
|
||||
|
||||
|
||||
@server.job("refine-image-mask")
|
||||
def refine_image_mask(context, parameters):
|
||||
return _refine_image_mask(context, parameters)
|
||||
@@ -61,14 +54,9 @@ def extract_frames(context, parameters):
|
||||
return _extract_frames(context, parameters)
|
||||
|
||||
|
||||
@server.job("generate-depth")
|
||||
def generate_depth(context, parameters):
|
||||
return _generate_depth(context, parameters)
|
||||
|
||||
|
||||
@server.job("generate-surface")
|
||||
def generate_surface(context, parameters):
|
||||
return _generate_surface(context, parameters)
|
||||
@server.job("generate-mesh")
|
||||
def generate_mesh(context, parameters):
|
||||
return _generate_mesh(context, parameters)
|
||||
|
||||
|
||||
@server.job("debug-da3")
|
||||
|
||||
211
src/anyimage/server/geometry/cutout.py
Normal file
211
src/anyimage/server/geometry/cutout.py
Normal file
@@ -0,0 +1,211 @@
|
||||
def _choose_boundary_edge(start, direction, candidates):
|
||||
right = (-direction[1], direction[0])
|
||||
left = (direction[1], -direction[0])
|
||||
back = (-direction[0], -direction[1])
|
||||
preference = {right: 0, direction: 1, left: 2, back: 3}
|
||||
return min(
|
||||
candidates,
|
||||
key=lambda end: preference.get(
|
||||
(end[0] - start[0], end[1] - start[1]),
|
||||
4,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _simplify_axis_aligned_contour(points):
|
||||
simplified = []
|
||||
for index, current in enumerate(points):
|
||||
previous = points[index - 1]
|
||||
following = points[(index + 1) % len(points)]
|
||||
first = (current[0] - previous[0], current[1] - previous[1])
|
||||
second = (following[0] - current[0], following[1] - current[1])
|
||||
if first[0] * second[1] - first[1] * second[0] != 0:
|
||||
simplified.append(current)
|
||||
return simplified
|
||||
|
||||
|
||||
def trace_mask_contours(mask):
|
||||
import numpy as np
|
||||
|
||||
mask = np.asarray(mask, dtype=bool)
|
||||
if mask.ndim != 2:
|
||||
raise ValueError("Cutout Alpha must be two-dimensional")
|
||||
height, width = mask.shape
|
||||
edges = {}
|
||||
|
||||
def add_edge(start, end):
|
||||
edges.setdefault(start, []).append(end)
|
||||
|
||||
top = mask.copy()
|
||||
top[1:] &= ~mask[:-1]
|
||||
for y, x in np.argwhere(top):
|
||||
add_edge((int(x), int(y)), (int(x) + 1, int(y)))
|
||||
|
||||
right = mask.copy()
|
||||
right[:, :-1] &= ~mask[:, 1:]
|
||||
for y, x in np.argwhere(right):
|
||||
add_edge((int(x) + 1, int(y)), (int(x) + 1, int(y) + 1))
|
||||
|
||||
bottom = mask.copy()
|
||||
bottom[:-1] &= ~mask[1:]
|
||||
for y, x in np.argwhere(bottom):
|
||||
add_edge((int(x) + 1, int(y) + 1), (int(x), int(y) + 1))
|
||||
|
||||
left = mask.copy()
|
||||
left[:, 1:] &= ~mask[:, :-1]
|
||||
for y, x in np.argwhere(left):
|
||||
add_edge((int(x), int(y) + 1), (int(x), int(y)))
|
||||
if not edges:
|
||||
raise ValueError("The Cutout region has no usable boundary")
|
||||
|
||||
contours = []
|
||||
edge_count = sum(len(values) for values in edges.values())
|
||||
while edges:
|
||||
start = min(edges, key=lambda point: (point[1], point[0]))
|
||||
current = start
|
||||
direction = (1, 0)
|
||||
contour = []
|
||||
for _index in range(edge_count + 1):
|
||||
contour.append(current)
|
||||
candidates = edges.get(current)
|
||||
if not candidates:
|
||||
raise ValueError("The Cutout boundary is open")
|
||||
following = _choose_boundary_edge(current, direction, candidates)
|
||||
candidates.remove(following)
|
||||
if not candidates:
|
||||
del edges[current]
|
||||
direction = (following[0] - current[0], following[1] - current[1])
|
||||
current = following
|
||||
if current == start:
|
||||
break
|
||||
else:
|
||||
raise ValueError("The Cutout boundary could not be closed")
|
||||
contour = _simplify_axis_aligned_contour(contour)
|
||||
if len(contour) >= 3:
|
||||
contours.append(contour)
|
||||
if not contours:
|
||||
raise ValueError("The Cutout region is too small")
|
||||
return contours
|
||||
|
||||
|
||||
def _signed_contour_area(contour):
|
||||
return 0.5 * sum(
|
||||
start[0] * end[1] - end[0] * start[1]
|
||||
for start, end in zip(contour, contour[1:] + contour[:1])
|
||||
)
|
||||
|
||||
|
||||
def _point_inside_contour(point, contour):
|
||||
inside = False
|
||||
previous = contour[-1]
|
||||
for current in contour:
|
||||
if (current[1] > point[1]) != (previous[1] > point[1]):
|
||||
crossing_x = current[0] + (
|
||||
(point[1] - current[1])
|
||||
* (previous[0] - current[0])
|
||||
/ (previous[1] - current[1])
|
||||
)
|
||||
inside ^= point[0] < crossing_x
|
||||
previous = current
|
||||
return inside
|
||||
|
||||
|
||||
def mask_regions(mask):
|
||||
contours = trace_mask_contours(mask)
|
||||
outer = [contour for contour in contours if _signed_contour_area(contour) > 0.0]
|
||||
holes = [contour for contour in contours if _signed_contour_area(contour) < 0.0]
|
||||
if not outer:
|
||||
raise ValueError("The Cutout region has no outer boundary")
|
||||
regions = [[contour] for contour in outer]
|
||||
outer_areas = [abs(_signed_contour_area(contour)) for contour in outer]
|
||||
for hole in holes:
|
||||
start = hole[0]
|
||||
end = hole[1]
|
||||
edge_x = end[0] - start[0]
|
||||
edge_y = end[1] - start[1]
|
||||
length = max((edge_x * edge_x + edge_y * edge_y) ** 0.5, 1e-12)
|
||||
sample = (
|
||||
(start[0] + end[0]) * 0.5 - edge_y * 0.25 / length,
|
||||
(start[1] + end[1]) * 0.5 + edge_x * 0.25 / length,
|
||||
)
|
||||
owners = [
|
||||
index
|
||||
for index, contour in enumerate(outer)
|
||||
if _point_inside_contour(sample, contour)
|
||||
]
|
||||
if not owners:
|
||||
raise ValueError("The Cutout hole has no enclosing region")
|
||||
owner = min(owners, key=outer_areas.__getitem__)
|
||||
regions[owner].append(hole)
|
||||
return regions
|
||||
|
||||
|
||||
def _resize_alpha(values, size):
|
||||
import numpy as np
|
||||
|
||||
target_width, target_height = (int(value) for value in size)
|
||||
source_height, source_width = values.shape
|
||||
if (target_width, target_height) == (source_width, source_height):
|
||||
return values
|
||||
x = (np.arange(target_width, dtype=np.float32) + 0.5) * (
|
||||
source_width / target_width
|
||||
) - 0.5
|
||||
y = (np.arange(target_height, dtype=np.float32) + 0.5) * (
|
||||
source_height / target_height
|
||||
) - 0.5
|
||||
x0_unclipped = np.floor(x).astype(np.int32)
|
||||
y0_unclipped = np.floor(y).astype(np.int32)
|
||||
x_weight = x - x0_unclipped
|
||||
y_weight = y - y0_unclipped
|
||||
x0 = np.clip(x0_unclipped, 0, source_width - 1)
|
||||
y0 = np.clip(y0_unclipped, 0, source_height - 1)
|
||||
x1 = np.clip(x0_unclipped + 1, 0, source_width - 1)
|
||||
y1 = np.clip(y0_unclipped + 1, 0, source_height - 1)
|
||||
horizontal = (
|
||||
values[:, x0] * (1.0 - x_weight)[None, :]
|
||||
+ values[:, x1] * x_weight[None, :]
|
||||
)
|
||||
return (
|
||||
horizontal[y0] * (1.0 - y_weight)[:, None]
|
||||
+ horizontal[y1] * y_weight[:, None]
|
||||
)
|
||||
|
||||
|
||||
def alpha_regions(alpha, max_resolution=512, threshold=0.5):
|
||||
import numpy as np
|
||||
|
||||
values = np.asarray(alpha)
|
||||
if values.ndim != 2:
|
||||
raise ValueError("Cutout Alpha must be two-dimensional")
|
||||
if np.issubdtype(values.dtype, np.floating):
|
||||
values = np.clip(values, 0.0, 1.0) * 255.0
|
||||
else:
|
||||
values = np.clip(values, 0, 255).astype(np.float32)
|
||||
source_size = (values.shape[1], values.shape[0])
|
||||
scale = min(1.0, float(max_resolution) / max(source_size))
|
||||
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)
|
||||
regions = mask_regions(mask)
|
||||
scale_x = source_size[0] / sampled_size[0]
|
||||
scale_y = source_size[1] / sampled_size[1]
|
||||
return (
|
||||
[
|
||||
[
|
||||
[[float(x) * scale_x, float(y) * scale_y] for x, y in contour]
|
||||
for contour in region
|
||||
]
|
||||
for region in regions
|
||||
],
|
||||
source_size,
|
||||
)
|
||||
|
||||
|
||||
def image_alpha_regions(path, max_resolution=512, threshold=0.5):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(path) as source:
|
||||
alpha = np.asarray(source.convert("RGBA").getchannel("A"), dtype=np.uint8)
|
||||
return alpha_regions(alpha, max_resolution=max_resolution, threshold=threshold)
|
||||
@@ -1,5 +1 @@
|
||||
from .depth import run as generate_depth
|
||||
from .surface import run as generate_surface
|
||||
|
||||
|
||||
__all__ = ("generate_depth", "generate_surface")
|
||||
"""Job implementations registered by :mod:`anyimage.server.app`."""
|
||||
|
||||
@@ -5,15 +5,18 @@ from pathlib import Path
|
||||
|
||||
from ..models import moge2
|
||||
from ..models.ben2 import infer_foreground
|
||||
from .image_mask import polygon_selection_mask
|
||||
|
||||
|
||||
ALPHA_THRESHOLD = 0.5
|
||||
|
||||
|
||||
def create_lasso_input(input_path, polygon, output_path):
|
||||
def create_lasso_input(
|
||||
input_path, polygon, output_path, fill_rule="EVEN_ODD"
|
||||
):
|
||||
"""Crop a lasso and clear every RGBA channel outside its polygon."""
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from PIL import Image
|
||||
|
||||
points = np.asarray(polygon, dtype=np.float64)
|
||||
if points.ndim != 2 or points.shape[0] < 3 or points.shape[1] != 2:
|
||||
@@ -33,12 +36,7 @@ def create_lasso_input(input_path, polygon, output_path):
|
||||
|
||||
relative = points - np.asarray((left, top), dtype=np.float64)
|
||||
cropped = source.crop((left, top, right, bottom))
|
||||
selection = Image.new("L", cropped.size, 0)
|
||||
ImageDraw.Draw(selection).polygon(
|
||||
[tuple(point) for point in relative],
|
||||
fill=255,
|
||||
)
|
||||
selection_mask = np.asarray(selection, dtype=np.uint8) > 0
|
||||
selection_mask = polygon_selection_mask(cropped.size, relative, fill_rule)
|
||||
pixels = np.asarray(cropped, dtype=np.uint8).copy()
|
||||
pixels[~selection_mask] = 0
|
||||
masked = Image.fromarray(pixels, mode="RGBA")
|
||||
@@ -285,7 +283,7 @@ def write_depth_analysis(
|
||||
return Path(output_path)
|
||||
|
||||
|
||||
def write_balloon_normal_texture(
|
||||
def write_camera_normal_texture(
|
||||
normal,
|
||||
valid,
|
||||
origin,
|
||||
@@ -293,7 +291,7 @@ def write_balloon_normal_texture(
|
||||
source_size,
|
||||
output_path,
|
||||
):
|
||||
"""Write MoGe camera normals as a full-image tangent-space texture."""
|
||||
"""Encode MoGe camera-axis normals into a full-image RGB texture."""
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
@@ -412,7 +410,7 @@ def run(context, parameters):
|
||||
import numpy as np
|
||||
|
||||
with np.load(depth_path, allow_pickle=False) as data:
|
||||
normal_path = write_balloon_normal_texture(
|
||||
normal_path = write_camera_normal_texture(
|
||||
prediction.normal[0],
|
||||
data["valid"],
|
||||
origin,
|
||||
|
||||
@@ -24,3 +24,14 @@ def input_frames(context, parameters):
|
||||
finally:
|
||||
if temporary_directory is not None:
|
||||
shutil.rmtree(temporary_directory, ignore_errors=True)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def analysis_frames(context, parameters, fallback_images):
|
||||
analysis_input = parameters.get("analysis_input")
|
||||
if not analysis_input or analysis_input == parameters["input"]:
|
||||
yield fallback_images
|
||||
return
|
||||
analysis_parameters = dict(parameters, input=analysis_input)
|
||||
with input_frames(context, analysis_parameters) as images:
|
||||
yield images
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from ..media import copy_color_images
|
||||
from ..models import da3, geometry_adapter, moge2
|
||||
from ..outputs.depth import write_da3_depth, write_moge_depth
|
||||
from .common import frame_progress_message, input_frames
|
||||
from .common import analysis_frames, frame_progress_message, input_frames
|
||||
from .foreground import write_optional
|
||||
|
||||
|
||||
@@ -11,11 +11,12 @@ def run(context, parameters):
|
||||
model_manager = context.resource("model_manager")
|
||||
adapter = geometry_adapter(parameters["model"])
|
||||
with input_frames(context, parameters) as images:
|
||||
with analysis_frames(context, parameters, images) as model_images:
|
||||
if adapter is da3:
|
||||
prediction = adapter.infer(
|
||||
model_manager,
|
||||
parameters,
|
||||
images,
|
||||
model_images,
|
||||
progress_callback=lambda index, total: context.progress(
|
||||
0.15 + 0.65 * index / total,
|
||||
frame_progress_message("Estimating DA3 depth", index, total),
|
||||
@@ -27,7 +28,7 @@ def run(context, parameters):
|
||||
prediction = adapter.infer(
|
||||
model_manager,
|
||||
parameters,
|
||||
images,
|
||||
model_images,
|
||||
include_points=False,
|
||||
progress_callback=lambda index, total: context.progress(
|
||||
0.15 + 0.65 * index / total,
|
||||
|
||||
@@ -22,10 +22,68 @@ def polygon_bounds(polygon):
|
||||
return left, top, right, bottom
|
||||
|
||||
|
||||
def create_selection_canvas(input_path, polygon):
|
||||
def polygon_selection_mask(image_size, polygon, fill_rule="EVEN_ODD"):
|
||||
"""Rasterize a polygon with an explicit fill rule."""
|
||||
import numpy as np
|
||||
|
||||
width, height = (int(value) for value in image_size)
|
||||
points = np.asarray(polygon, dtype=np.float64)
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("The selection canvas has no readable dimensions")
|
||||
if points.ndim != 2 or points.shape[0] < 3 or points.shape[1] != 2:
|
||||
raise ValueError("The selection does not define a valid image region")
|
||||
if not np.isfinite(points).all():
|
||||
raise ValueError("The selection contains invalid image coordinates")
|
||||
if fill_rule not in {"EVEN_ODD", "NONZERO"}:
|
||||
raise ValueError(f"Unknown polygon fill rule: {fill_rule}")
|
||||
|
||||
scanlines = np.arange(height, dtype=np.float64) + 0.5
|
||||
crossings = [[] for _index in range(height)]
|
||||
for start, end in zip(points, np.roll(points, -1, axis=0)):
|
||||
selected = np.flatnonzero(
|
||||
(start[1] > scanlines) != (end[1] > scanlines)
|
||||
)
|
||||
if not len(selected):
|
||||
continue
|
||||
crossing_x = start[0] + (
|
||||
(scanlines[selected] - start[1])
|
||||
* (end[0] - start[0])
|
||||
/ (end[1] - start[1])
|
||||
)
|
||||
winding = 1 if end[1] > start[1] else -1
|
||||
for row, value in zip(selected, crossing_x):
|
||||
crossings[int(row)].append((float(value), winding))
|
||||
|
||||
mask = np.zeros((height, width), dtype=bool)
|
||||
for row, values in enumerate(crossings):
|
||||
values.sort()
|
||||
state = 0
|
||||
index = 0
|
||||
while index < len(values):
|
||||
crossing_x = values[index][0]
|
||||
winding = 0
|
||||
count = 0
|
||||
while index < len(values) and values[index][0] == crossing_x:
|
||||
winding += values[index][1]
|
||||
count += 1
|
||||
index += 1
|
||||
state = (
|
||||
state + winding
|
||||
if fill_rule == "NONZERO"
|
||||
else state ^ (count % 2)
|
||||
)
|
||||
if index >= len(values) or state == 0:
|
||||
continue
|
||||
following_x = values[index][0]
|
||||
first = max(0, int(math.ceil(crossing_x - 0.5)))
|
||||
last = min(width, int(math.floor(following_x - 0.5)) + 1)
|
||||
mask[row, first:last] = True
|
||||
return mask
|
||||
|
||||
def create_selection_canvas(input_path, polygon, fill_rule="EVEN_ODD"):
|
||||
"""Create an unbounded selection canvas with transparent black outside."""
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(input_path) as opened_image:
|
||||
source = opened_image.convert("RGBA")
|
||||
@@ -35,9 +93,7 @@ def create_selection_canvas(input_path, polygon):
|
||||
canvas = Image.new("RGBA", (right - left, bottom - top), (0, 0, 0, 0))
|
||||
canvas.paste(source, (-left, -top))
|
||||
relative = [(float(x) - left, float(y) - top) for x, y in polygon]
|
||||
selection = Image.new("L", canvas.size, 0)
|
||||
ImageDraw.Draw(selection).polygon(relative, fill=255)
|
||||
selection_mask = np.asarray(selection, dtype=np.uint8) > 0
|
||||
selection_mask = polygon_selection_mask(canvas.size, relative, fill_rule)
|
||||
pixels = np.asarray(canvas, dtype=np.uint8).copy()
|
||||
pixels[~selection_mask] = 0
|
||||
return Image.fromarray(pixels, mode="RGBA"), selection_mask, (
|
||||
|
||||
303
src/anyimage/server/jobs/mesh.py
Normal file
303
src/anyimage/server/jobs/mesh.py
Normal file
@@ -0,0 +1,303 @@
|
||||
from ..media import copy_color_images
|
||||
from ..models import geometry_adapter, moge2
|
||||
from .background import remove_image_background
|
||||
from .balloon import (
|
||||
write_camera_normal_texture,
|
||||
write_depth_analysis,
|
||||
)
|
||||
from .common import analysis_frames, input_frames
|
||||
from .image_mask import (
|
||||
apply_selection_mask,
|
||||
create_selection_canvas,
|
||||
polygon_selection_mask,
|
||||
)
|
||||
|
||||
|
||||
def _write_field(frame, output_path):
|
||||
import numpy as np
|
||||
|
||||
depth = np.asarray(frame.depth, dtype=np.float32)
|
||||
points = np.asarray(frame.points, dtype=np.float32)
|
||||
intrinsics = np.asarray(frame.intrinsics, dtype=np.float32)
|
||||
valid = np.asarray(frame.valid_mask, dtype=bool)
|
||||
if depth.ndim != 2 or points.shape != (*depth.shape, 3):
|
||||
raise ValueError("The depth model returned an invalid point field")
|
||||
valid &= np.isfinite(depth) & (depth > 0.0)
|
||||
valid &= np.isfinite(points).all(axis=-1)
|
||||
if not valid.any():
|
||||
raise ValueError("The depth model returned no valid geometry")
|
||||
reference_values = depth[valid]
|
||||
if frame.model_mask is not None:
|
||||
model_mask = np.asarray(frame.model_mask, dtype=bool)
|
||||
masked = depth[valid & model_mask]
|
||||
if len(masked):
|
||||
reference_values = masked
|
||||
np.savez_compressed(
|
||||
output_path,
|
||||
points=points,
|
||||
depth=depth,
|
||||
intrinsics=intrinsics,
|
||||
valid=valid,
|
||||
image_size=np.asarray((depth.shape[1], depth.shape[0]), dtype=np.int32),
|
||||
reference_depth=np.asarray(np.median(reference_values), dtype=np.float32),
|
||||
)
|
||||
|
||||
|
||||
def _write_masked_color(input_path, polygon, output_path, invert=False):
|
||||
import numpy as np
|
||||
from PIL import Image, ImageChops, ImageFilter, ImageOps
|
||||
|
||||
with Image.open(input_path) as opened:
|
||||
source = opened.convert("RGBA")
|
||||
selection = Image.fromarray(
|
||||
polygon_selection_mask(source.size, polygon, "NONZERO").astype(np.uint8) * 255,
|
||||
mode="L",
|
||||
).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[pixels[:, :, 3] == 0, :3] = 0
|
||||
Image.fromarray(pixels, mode="RGBA").save(
|
||||
output_path,
|
||||
format="PNG",
|
||||
compress_level=1,
|
||||
)
|
||||
return output_path
|
||||
|
||||
|
||||
def _write_refined_color(context, parameters, input_path, polygon, output_path):
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
canvas, selection_mask, bounds = create_selection_canvas(
|
||||
input_path, polygon, "NONZERO"
|
||||
)
|
||||
selection_path = context.directory / "selection-input.png"
|
||||
canvas.save(selection_path, format="PNG", compress_level=1)
|
||||
model_manager = context.resource("model_manager")
|
||||
foreground, _load_ms, _inference_ms = remove_image_background(
|
||||
selection_path,
|
||||
model_manager.directory(parameters["refine_model"]),
|
||||
parameters["device"],
|
||||
model_manager,
|
||||
context.check_cancelled,
|
||||
)
|
||||
refined = apply_selection_mask(foreground, selection_mask)
|
||||
with Image.open(input_path) as opened:
|
||||
source = opened.convert("RGBA")
|
||||
if parameters.get("invert"):
|
||||
mask = Image.new("L", source.size, 0)
|
||||
mask.paste(refined.getchannel("A"), bounds[:2])
|
||||
source.putalpha(
|
||||
ImageChops.multiply(source.getchannel("A"), ImageChops.invert(mask))
|
||||
)
|
||||
output = source
|
||||
else:
|
||||
output = Image.new("RGBA", source.size, (0, 0, 0, 0))
|
||||
output.paste(refined, bounds[:2])
|
||||
output.save(output_path, format="PNG", compress_level=1)
|
||||
return output_path
|
||||
|
||||
|
||||
def _write_camera_normal_from_prediction(prediction, color_path, output_path):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
normal = np.asarray(prediction.normal[0], dtype=np.float32)
|
||||
with Image.open(color_path) as source:
|
||||
alpha = source.convert("RGBA").getchannel("A")
|
||||
source_size = source.size
|
||||
valid = (
|
||||
np.asarray(
|
||||
alpha.resize((normal.shape[1], normal.shape[0]), Image.Resampling.BILINEAR)
|
||||
)
|
||||
> 127
|
||||
)
|
||||
return write_camera_normal_texture(
|
||||
normal,
|
||||
valid,
|
||||
(0, 0),
|
||||
source_size,
|
||||
source_size,
|
||||
output_path,
|
||||
)
|
||||
|
||||
|
||||
def _write_camera_normal(context, parameters, color_path):
|
||||
normal_parameters = dict(parameters)
|
||||
normal_parameters.update(
|
||||
model=parameters["normal_model"],
|
||||
resolution_level=parameters["normal_resolution_level"],
|
||||
)
|
||||
prediction = moge2.infer(
|
||||
context.resource("model_manager"),
|
||||
normal_parameters,
|
||||
[color_path],
|
||||
include_points=False,
|
||||
cancel_check=context.check_cancelled,
|
||||
)
|
||||
return _write_camera_normal_from_prediction(
|
||||
prediction,
|
||||
color_path,
|
||||
context.directory / "normal.png",
|
||||
)
|
||||
|
||||
|
||||
def _write_balloon_depth(
|
||||
context,
|
||||
parameters,
|
||||
input_path,
|
||||
polygon,
|
||||
selection_size=None,
|
||||
include_normal=False,
|
||||
):
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(input_path) as source:
|
||||
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",
|
||||
)
|
||||
prediction = moge2.infer(
|
||||
context.resource("model_manager"),
|
||||
parameters,
|
||||
[input_path],
|
||||
include_points=False,
|
||||
cancel_check=context.check_cancelled,
|
||||
)
|
||||
depth_path = write_depth_analysis(
|
||||
prediction,
|
||||
selection_mask,
|
||||
(0, 0),
|
||||
source_size,
|
||||
context.directory / "depth.npz",
|
||||
)
|
||||
normal_path = None
|
||||
if include_normal:
|
||||
normal_path = _write_camera_normal_from_prediction(
|
||||
prediction,
|
||||
input_path,
|
||||
context.directory / "normal.png",
|
||||
)
|
||||
return depth_path, normal_path
|
||||
|
||||
|
||||
def _write_surface_depth(
|
||||
context, parameters, color_path, include_normal=False
|
||||
):
|
||||
model_manager = context.resource("model_manager")
|
||||
adapter = geometry_adapter(parameters["model"])
|
||||
if adapter is moge2:
|
||||
prediction = adapter.infer(
|
||||
model_manager,
|
||||
parameters,
|
||||
[color_path],
|
||||
include_points=True,
|
||||
cancel_check=context.check_cancelled,
|
||||
)
|
||||
else:
|
||||
prediction = adapter.infer(
|
||||
model_manager,
|
||||
parameters,
|
||||
[color_path],
|
||||
cancel_check=context.check_cancelled,
|
||||
)
|
||||
context.check_cancelled()
|
||||
field_path = context.directory / "field.npz"
|
||||
_write_field(adapter.surface_frame(prediction), field_path)
|
||||
normal_path = None
|
||||
if include_normal and adapter is moge2:
|
||||
normal_path = _write_camera_normal_from_prediction(
|
||||
prediction, color_path, context.directory / "normal.png"
|
||||
)
|
||||
return field_path, normal_path
|
||||
|
||||
|
||||
def run(context, parameters):
|
||||
context.progress(0.02, "Preparing mesh input")
|
||||
context.directory.mkdir(parents=True, exist_ok=True)
|
||||
with input_frames(context, parameters) as images:
|
||||
if len(images) != 1:
|
||||
raise ValueError("Empty Image to Mesh currently supports one image")
|
||||
shape = parameters["shape"]
|
||||
polygon = parameters.get("polygon")
|
||||
if polygon:
|
||||
if parameters.get("refine_selection"):
|
||||
color_path = _write_refined_color(
|
||||
context,
|
||||
parameters,
|
||||
images[0],
|
||||
polygon,
|
||||
context.directory / "image.0001.png",
|
||||
)
|
||||
else:
|
||||
color_path = _write_masked_color(
|
||||
images[0],
|
||||
polygon,
|
||||
context.directory / "image.0001.png",
|
||||
parameters.get("invert", False),
|
||||
)
|
||||
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]
|
||||
|
||||
if shape in {"CUTOUT", "BALLOON"} and normal_analysis:
|
||||
context.progress(0.4, "Estimating surface normal")
|
||||
result["normal"] = _write_camera_normal(
|
||||
context, parameters, analysis_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,
|
||||
include_normal=normal_analysis,
|
||||
)
|
||||
result["depth"] = depth_path.name
|
||||
if normal_path is not None:
|
||||
result["normal"] = normal_path.name
|
||||
elif shape in {"DEPTH_PLANE", "DEPTH_SURFACE"}:
|
||||
context.progress(0.4, "Estimating image depth")
|
||||
field_path, normal_path = _write_surface_depth(
|
||||
context,
|
||||
parameters,
|
||||
analysis_path,
|
||||
include_normal=normal_analysis,
|
||||
)
|
||||
result["field"] = field_path.name
|
||||
if (
|
||||
normal_analysis
|
||||
and normal_path is None
|
||||
and shape == "DEPTH_SURFACE"
|
||||
):
|
||||
normal_path = _write_camera_normal(
|
||||
context, parameters, analysis_path
|
||||
)
|
||||
if normal_path is not None:
|
||||
result["normal"] = normal_path.name
|
||||
|
||||
context.check_cancelled()
|
||||
context.progress(1.0, "Mesh data is ready")
|
||||
return result
|
||||
@@ -2,7 +2,7 @@ from ..geometry import build_surface, foreground_mesh_mask
|
||||
from ..media import copy_color_images
|
||||
from ..models import geometry_adapter, moge2
|
||||
from ..outputs.surface import write_normal_texture, write_surface_archive
|
||||
from .common import frame_progress_message, input_frames
|
||||
from .common import analysis_frames, frame_progress_message, input_frames
|
||||
from .foreground import write_optional
|
||||
|
||||
def run(context, parameters):
|
||||
@@ -14,11 +14,14 @@ def run(context, parameters):
|
||||
if len(images) != 1:
|
||||
raise ValueError("Convert to Surface currently supports one image")
|
||||
context.progress(0.12, "Estimating surface geometry")
|
||||
with analysis_frames(context, parameters, images) as model_images:
|
||||
if len(model_images) != 1:
|
||||
raise ValueError("Convert to Surface currently supports one image")
|
||||
if adapter is moge2:
|
||||
prediction = adapter.infer(
|
||||
model_manager,
|
||||
parameters,
|
||||
images,
|
||||
model_images,
|
||||
include_points=True,
|
||||
progress_callback=lambda index, total: context.progress(
|
||||
0.12 + 0.58 * index / total,
|
||||
@@ -30,7 +33,7 @@ def run(context, parameters):
|
||||
prediction = adapter.infer(
|
||||
model_manager,
|
||||
parameters,
|
||||
images,
|
||||
model_images,
|
||||
progress_callback=lambda index, total: context.progress(
|
||||
0.12 + 0.58 * index / total,
|
||||
frame_progress_message("Estimating DA3 geometry", index, total),
|
||||
|
||||
@@ -25,7 +25,7 @@ class ModelSessionCache:
|
||||
key = (str(Path(model_dir).resolve()), requested_device, "onnx")
|
||||
if self.ben2_model is not None and self.ben2_key == key:
|
||||
return self.ben2_model, 0.0
|
||||
self.close()
|
||||
self.release_ben2()
|
||||
return self._load_ben2(model_dir, requested_device, key)
|
||||
|
||||
def _load_ben2(self, model_dir, requested_device, key):
|
||||
@@ -43,7 +43,7 @@ class ModelSessionCache:
|
||||
key = (str(Path(model_dir).resolve()), requested_device, "onnx")
|
||||
if self.da3_model is not None and self.da3_key == key:
|
||||
return self.da3_model
|
||||
self.close()
|
||||
self.release_geometry()
|
||||
return self._load_da3(model_dir, requested_device, key)
|
||||
|
||||
def _load_da3(self, model_dir, requested_device, key):
|
||||
@@ -60,7 +60,7 @@ class ModelSessionCache:
|
||||
key = (str(Path(model_dir).resolve()), requested_device, "onnx")
|
||||
if self.moge_model is not None and self.moge_key == key:
|
||||
return self.moge_model
|
||||
self.close()
|
||||
self.release_geometry()
|
||||
model = create_session(model_dir, requested_device)
|
||||
self.moge_model = model
|
||||
self.moge_key = key
|
||||
@@ -72,7 +72,7 @@ class ModelSessionCache:
|
||||
key = (str(Path(model_dir).resolve()), requested_device, "onnx")
|
||||
if self.upscale_model is not None and self.upscale_key == key:
|
||||
return self.upscale_model
|
||||
self.close()
|
||||
self.release_upscale()
|
||||
model = create_session(model_dir, requested_device)
|
||||
self.upscale_model = model
|
||||
self.upscale_key = key
|
||||
@@ -83,12 +83,9 @@ class ModelSessionCache:
|
||||
self.ben2_key = None
|
||||
self._release_runtime_cache()
|
||||
|
||||
def release_da3(self):
|
||||
def release_geometry(self):
|
||||
self.da3_model = None
|
||||
self.da3_key = None
|
||||
self._release_runtime_cache()
|
||||
|
||||
def release_moge(self):
|
||||
self.moge_model = None
|
||||
self.moge_key = None
|
||||
self._release_runtime_cache()
|
||||
@@ -110,15 +107,11 @@ class ModelSessionCache:
|
||||
self._release_runtime_cache()
|
||||
|
||||
def snapshot(self):
|
||||
geometry_key = self.da3_key or self.moge_key
|
||||
return {
|
||||
"ben2": Path(self.ben2_key[0]).name if self.ben2_key else "",
|
||||
"da3": Path(self.da3_key[0]).name if self.da3_key else "",
|
||||
"moge2": Path(self.moge_key[0]).name if self.moge_key else "",
|
||||
"upscale": (
|
||||
Path(self.upscale_key[0]).name
|
||||
if self.upscale_key
|
||||
else ""
|
||||
),
|
||||
"geometry": Path(geometry_key[0]).name if geometry_key else "",
|
||||
"upscale": (Path(self.upscale_key[0]).name if self.upscale_key else ""),
|
||||
}
|
||||
|
||||
def _release_runtime_cache(self):
|
||||
@@ -176,6 +169,7 @@ class ModelManager:
|
||||
|
||||
total = len(missing)
|
||||
for index, model_key in enumerate(missing):
|
||||
|
||||
def report(value, message, *, offset=index):
|
||||
context.progress((offset + value) / total, message)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_ROOT = PROJECT_ROOT / "src"
|
||||
|
||||
@@ -94,27 +93,18 @@ class AddonTest(unittest.TestCase):
|
||||
cls.depth_build = importlib.import_module(
|
||||
"anyimage.operators.depth_plane.build"
|
||||
)
|
||||
cls.mesh_plane = importlib.import_module(
|
||||
"anyimage.operators.mesh_plane"
|
||||
)
|
||||
cls.mesh_plane = importlib.import_module("anyimage.operators.mesh_plane")
|
||||
cls.mesh = importlib.import_module("anyimage.operators.mesh")
|
||||
cls.image_background = importlib.import_module("anyimage.operators.background")
|
||||
cls.image_upscale = importlib.import_module("anyimage.operators.upscale")
|
||||
cls.image_mesh = importlib.import_module("anyimage.operators.surface")
|
||||
cls.image_edit_common = importlib.import_module(
|
||||
"anyimage.operators.image_edit.common"
|
||||
)
|
||||
cls.image_mask = importlib.import_module(
|
||||
"anyimage.operators.image_edit.mask"
|
||||
)
|
||||
cls.image_box = importlib.import_module(
|
||||
"anyimage.operators.image_edit.box"
|
||||
)
|
||||
cls.image_lasso = importlib.import_module(
|
||||
"anyimage.operators.image_edit.lasso"
|
||||
)
|
||||
cls.image_line = importlib.import_module(
|
||||
"anyimage.operators.image_edit.line"
|
||||
)
|
||||
cls.image_mask = importlib.import_module("anyimage.operators.image_edit.mask")
|
||||
cls.image_box = importlib.import_module("anyimage.operators.image_edit.box")
|
||||
cls.image_lasso = importlib.import_module("anyimage.operators.image_edit.lasso")
|
||||
cls.image_line = importlib.import_module("anyimage.operators.image_edit.line")
|
||||
cls.image_polyline = importlib.import_module(
|
||||
"anyimage.operators.image_edit.polyline"
|
||||
)
|
||||
@@ -251,8 +241,8 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertFalse(name.startswith("anyimage_"), (operator, name))
|
||||
|
||||
def test_concrete_job_operator_is_the_blender_operator(self):
|
||||
operator = self.addon.operators.GenerateMoge2Depth
|
||||
self.assertEqual(operator.bl_idname, "anyimage.generate_moge2_depth")
|
||||
operator = self.addon.operators.GenerateMesh
|
||||
self.assertEqual(operator.bl_idname, "anyimage.generate_mesh")
|
||||
self.assertIn(self.job_runtime.JobOperatorBase, operator.__mro__)
|
||||
self.assertIn(self.fake_bpy.types.Operator, operator.__mro__)
|
||||
|
||||
@@ -265,15 +255,15 @@ class AddonTest(unittest.TestCase):
|
||||
device="CPU",
|
||||
video_frames=3,
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
scene=SimpleNamespace(anyimage_settings=settings)
|
||||
)
|
||||
context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings))
|
||||
for model_key in self.addon.properties.shared_model_catalog.UPSCALE_MODELS:
|
||||
operator = self.debug.DebugUpscale()
|
||||
operator.model = model_key
|
||||
with self.subTest(model=model_key), patch.object(
|
||||
self.debug, "require_environment"
|
||||
), patch.object(self.debug, "require_model") as require_model:
|
||||
with (
|
||||
self.subTest(model=model_key),
|
||||
patch.object(self.debug, "require_environment"),
|
||||
patch.object(self.debug, "require_model") as require_model,
|
||||
):
|
||||
request = operator.request(context)
|
||||
require_model.assert_called_once_with(model_key)
|
||||
self.assertEqual(request["model"], model_key)
|
||||
@@ -618,18 +608,23 @@ class AddonTest(unittest.TestCase):
|
||||
):
|
||||
status = properties.ai_status()
|
||||
self.assertFalse(status["ready"])
|
||||
self.assertEqual(properties.ai_setup_label(status), "Set Up AI Environment…")
|
||||
self.assertEqual(
|
||||
properties.ai_setup_label(status), "Set Up AI Environment…"
|
||||
)
|
||||
|
||||
def test_setup_ai_environment_selects_the_needed_install_step(self):
|
||||
calls = []
|
||||
ops = SimpleNamespace(
|
||||
anyimage=SimpleNamespace(
|
||||
install_environment=lambda *_args: calls.append("official")
|
||||
or {"FINISHED"},
|
||||
install_environment_mirror=lambda *_args: calls.append("mirror")
|
||||
or {"FINISHED"},
|
||||
download_required_models=lambda *_args: calls.append("models")
|
||||
or {"FINISHED"},
|
||||
install_environment=lambda *_args: (
|
||||
calls.append("official") or {"FINISHED"}
|
||||
),
|
||||
install_environment_mirror=lambda *_args: (
|
||||
calls.append("mirror") or {"FINISHED"}
|
||||
),
|
||||
download_required_models=lambda *_args: (
|
||||
calls.append("models") or {"FINISHED"}
|
||||
),
|
||||
)
|
||||
)
|
||||
operator = self.model_operators.SetupAIEnvironment()
|
||||
@@ -696,12 +691,20 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
preferences = preference_module.AnyImagePreferences()
|
||||
preferences.layout = Layout()
|
||||
catalog = tuple({
|
||||
"key": key, "label": key, "family": "upscale",
|
||||
"enabled": True, "ready": False,
|
||||
} for key in (
|
||||
"REALESRGAN_GENERAL_WDN_X4V3", "SAFMN_REAL_X4", "REALESRGAN_X4PLUS",
|
||||
))
|
||||
catalog = tuple(
|
||||
{
|
||||
"key": key,
|
||||
"label": key,
|
||||
"family": "upscale",
|
||||
"enabled": True,
|
||||
"ready": False,
|
||||
}
|
||||
for key in (
|
||||
"REALESRGAN_GENERAL_WDN_X4V3",
|
||||
"SAFMN_REAL_X4",
|
||||
"REALESRGAN_X4PLUS",
|
||||
)
|
||||
)
|
||||
with (
|
||||
patch.object(
|
||||
self.addon.properties,
|
||||
@@ -728,19 +731,21 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertIn("upscale_model", properties)
|
||||
self.assertIn("upscale_max_output_size", properties)
|
||||
self.assertEqual(
|
||||
preference_module.AnyImagePreferences.__annotations__[
|
||||
"upscale_model"
|
||||
]["name"],
|
||||
preference_module.AnyImagePreferences.__annotations__["upscale_model"][
|
||||
"name"
|
||||
],
|
||||
"Default Upscale Model",
|
||||
)
|
||||
|
||||
def test_mesh_plane_operator_is_registered_with_material_options(self):
|
||||
operator = self.mesh_plane.ConvertToMeshPlane
|
||||
def test_semantic_mesh_operators_are_registered_without_dialog_options(self):
|
||||
operator = self.mesh.ConvertToPlane
|
||||
self.assertIn(operator, self.addon.CLASSES)
|
||||
self.assertEqual(operator.bl_idname, "anyimage.convert_to_mesh_plane")
|
||||
self.assertFalse(operator.__annotations__["shadeless"]["default"])
|
||||
self.assertFalse(operator.__annotations__["keep_original"]["default"])
|
||||
self.assertIn("invoke", operator.__dict__)
|
||||
self.assertIn(self.mesh.ConvertToDepthPlane, self.addon.CLASSES)
|
||||
self.assertIn(self.mesh.ConvertCutoutShape, self.addon.CLASSES)
|
||||
self.assertEqual(operator.bl_idname, "anyimage.convert_to_plane")
|
||||
self.assertNotIn("shadeless", operator.__annotations__)
|
||||
self.assertNotIn("keep_original", operator.__annotations__)
|
||||
self.assertNotIn("invoke", operator.__dict__)
|
||||
self.assertNotIn("draw", operator.__dict__)
|
||||
self.assertIn("REGISTER", operator.bl_options)
|
||||
self.assertIn("UNDO", operator.bl_options)
|
||||
@@ -758,9 +763,246 @@ class AddonTest(unittest.TestCase):
|
||||
)
|
||||
plain_mesh = SimpleNamespace(type="MESH", modifiers=[])
|
||||
self.assertTrue(operator.poll(SimpleNamespace(object=image_empty)))
|
||||
self.assertTrue(operator.poll(SimpleNamespace(object=mesh_plane)))
|
||||
self.assertFalse(operator.poll(SimpleNamespace(object=mesh_plane)))
|
||||
self.assertFalse(operator.poll(SimpleNamespace(object=plain_mesh)))
|
||||
|
||||
def test_plane_depth_off_accepts_animated_image_without_server(self):
|
||||
operator = SimpleNamespace(
|
||||
depth_mode="OFF",
|
||||
remove_background=False,
|
||||
shadeless=False,
|
||||
profile="SURFACE",
|
||||
mesh_detail=64,
|
||||
thickness=0.1,
|
||||
inflation=0.1,
|
||||
algorithm="POISSON",
|
||||
keep_original=False,
|
||||
)
|
||||
source = SimpleNamespace(data=SimpleNamespace(name="Sequence"))
|
||||
context = SimpleNamespace(object=source, scene=object())
|
||||
|
||||
with (
|
||||
patch.object(self.mesh, "video_total_frames", return_value=24),
|
||||
patch.object(
|
||||
self.mesh,
|
||||
"image_user_settings",
|
||||
return_value=(1, 0, 24, True),
|
||||
),
|
||||
patch.object(self.mesh, "create_material", return_value="Material"),
|
||||
patch.object(self.mesh, "_copy_color_image", return_value="Color"),
|
||||
patch.object(self.mesh, "_build_mesh_object") as build_mesh,
|
||||
patch.object(
|
||||
self.mesh,
|
||||
"invoke_ai_setup_if_needed",
|
||||
side_effect=AssertionError("local conversion must not start AI setup"),
|
||||
),
|
||||
):
|
||||
result = self.mesh.ConvertToPlane.execute(operator, context)
|
||||
|
||||
self.assertEqual(result, {"FINISHED"})
|
||||
build_mesh.assert_called_once()
|
||||
|
||||
def test_cutout_and_balloon_without_refine_build_locally(self):
|
||||
source = SimpleNamespace(select_set=lambda _selected: None)
|
||||
context = SimpleNamespace(
|
||||
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
|
||||
)
|
||||
|
||||
for shape in (self.mesh.CUTOUT, self.mesh.BALLOON):
|
||||
with self.subTest(shape=shape):
|
||||
operator = SimpleNamespace(
|
||||
source_object_name="Source",
|
||||
selection_data="[[0,0],[10,0],[0,10]]",
|
||||
shape=shape,
|
||||
mesh_detail=64,
|
||||
refine_selection=False,
|
||||
normal_analysis=False,
|
||||
invert=True,
|
||||
report=lambda *_args: None,
|
||||
)
|
||||
with (
|
||||
patch.object(
|
||||
self.mesh.bpy.data.objects,
|
||||
"get",
|
||||
return_value=source,
|
||||
),
|
||||
patch.object(self.mesh, "is_image_empty", return_value=True),
|
||||
patch.object(self.mesh, "_build_local_cutout") as build_local,
|
||||
patch.object(
|
||||
self.mesh,
|
||||
"_start_job",
|
||||
side_effect=AssertionError("local Cutout must not start a Job"),
|
||||
),
|
||||
):
|
||||
result = self.mesh.ConvertCutoutShape.execute(operator, context)
|
||||
|
||||
self.assertEqual(result, {"FINISHED"})
|
||||
build_local.assert_called_once_with(
|
||||
operator,
|
||||
context,
|
||||
source,
|
||||
shape,
|
||||
operator.selection_data,
|
||||
True,
|
||||
)
|
||||
|
||||
def test_refined_cutout_and_balloon_submit_blendjob(self):
|
||||
source = SimpleNamespace(select_set=lambda _selected: None)
|
||||
context = SimpleNamespace(
|
||||
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
|
||||
)
|
||||
|
||||
for shape in (self.mesh.CUTOUT, self.mesh.BALLOON):
|
||||
with self.subTest(shape=shape):
|
||||
operator = SimpleNamespace(
|
||||
source_object_name="Source",
|
||||
selection_data="[[0,0],[10,0],[0,10]]",
|
||||
shape=shape,
|
||||
mesh_detail=64,
|
||||
refine_selection=True,
|
||||
normal_analysis=False,
|
||||
invert=False,
|
||||
report=lambda *_args: None,
|
||||
)
|
||||
with (
|
||||
patch.object(
|
||||
self.mesh.bpy.data.objects,
|
||||
"get",
|
||||
return_value=source,
|
||||
),
|
||||
patch.object(self.mesh, "is_image_empty", return_value=True),
|
||||
patch.object(
|
||||
self.mesh,
|
||||
"_build_local_cutout",
|
||||
side_effect=AssertionError("Refine must run through the Job"),
|
||||
),
|
||||
patch.object(
|
||||
self.mesh, "_start_job", return_value={"FINISHED"}
|
||||
) as start_job,
|
||||
):
|
||||
result = self.mesh.ConvertCutoutShape.execute(operator, context)
|
||||
|
||||
self.assertEqual(result, {"FINISHED"})
|
||||
start_job.assert_called_once_with(
|
||||
operator,
|
||||
context,
|
||||
shape,
|
||||
operator.selection_data,
|
||||
True,
|
||||
False,
|
||||
False,
|
||||
)
|
||||
|
||||
def test_plain_balloon_job_requests_moge_normal(self):
|
||||
source = SimpleNamespace(
|
||||
name="Source",
|
||||
data=SimpleNamespace(size=(2048, 1024)),
|
||||
)
|
||||
context = SimpleNamespace(object=source, scene=SimpleNamespace())
|
||||
operator = SimpleNamespace(mesh_detail=64, report=lambda *_args: None)
|
||||
|
||||
with (
|
||||
patch.object(self.mesh, "video_total_frames", return_value=1),
|
||||
patch.object(
|
||||
self.mesh, "invoke_ai_setup_if_needed", return_value=None
|
||||
) as setup,
|
||||
patch.object(
|
||||
self.mesh,
|
||||
"prepare_image_analysis_inputs",
|
||||
return_value=(
|
||||
Path("input.png"),
|
||||
False,
|
||||
Path("input.png"),
|
||||
False,
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
self.mesh,
|
||||
"_depth_settings",
|
||||
return_value=("MOGE2_VITL", 100.0, 5),
|
||||
),
|
||||
patch.object(
|
||||
self.mesh.bpy.ops.anyimage,
|
||||
"generate_mesh",
|
||||
return_value={"FINISHED"},
|
||||
create=True,
|
||||
) as generate_mesh,
|
||||
):
|
||||
result = self.mesh._start_job(
|
||||
operator, context, self.mesh.BALLOON, normal_analysis=True
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"FINISHED"})
|
||||
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["normal_model"],
|
||||
self.addon.properties.DEFAULT_MOGE2_MODEL_KEY,
|
||||
)
|
||||
self.assertEqual(generate_mesh.call_args.kwargs["normal_resolution_level"], 5)
|
||||
|
||||
def test_all_cutout_shapes_request_moge_normal_when_enabled(self):
|
||||
context = SimpleNamespace(
|
||||
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1))
|
||||
)
|
||||
for shape in self.mesh.CUTOUT_SHAPES:
|
||||
with self.subTest(shape=shape):
|
||||
operator = self.mesh.GenerateMesh()
|
||||
operator.input_path = __file__
|
||||
operator.source_object_name = ""
|
||||
operator.shape = shape
|
||||
operator.selection_data = "[[0,0],[10,0],[0,10]]"
|
||||
operator.model = self.addon.properties.DEFAULT_MOGE2_MODEL_KEY
|
||||
operator.mesh_detail = 64
|
||||
operator.process_res = 1
|
||||
operator.resolution_level = 5
|
||||
operator.normal_model = self.addon.properties.DEFAULT_MOGE2_MODEL_KEY
|
||||
operator.normal_resolution_level = 5
|
||||
operator.normal_analysis = True
|
||||
operator.refine_selection = False
|
||||
operator.invert = False
|
||||
with (
|
||||
patch.object(self.mesh, "require_environment"),
|
||||
patch.object(self.mesh, "require_model") as require_model,
|
||||
patch.object(self.mesh, "production_device", return_value="CPU"),
|
||||
patch.object(self.mesh, "source_start_frame", return_value=1),
|
||||
):
|
||||
parameters = operator.request(context)
|
||||
|
||||
require_model.assert_called_once_with(
|
||||
self.addon.properties.DEFAULT_MOGE2_MODEL_KEY
|
||||
)
|
||||
self.assertEqual(
|
||||
parameters["normal_model"],
|
||||
self.addon.properties.DEFAULT_MOGE2_MODEL_KEY,
|
||||
)
|
||||
self.assertEqual(parameters["normal_resolution_level"], 5)
|
||||
self.assertEqual(parameters["mesh_detail"], 64)
|
||||
|
||||
def test_cutout_job_skips_moge_normal_model_when_disabled(self):
|
||||
operator = self.mesh.GenerateMesh()
|
||||
operator.input_path = __file__
|
||||
operator.source_object_name = ""
|
||||
operator.shape = self.mesh.CUTOUT
|
||||
operator.selection_data = "[[0,0],[10,0],[0,10]]"
|
||||
operator.mesh_detail = 64
|
||||
operator.normal_analysis = False
|
||||
operator.refine_selection = False
|
||||
operator.invert = False
|
||||
|
||||
with (
|
||||
patch.object(self.mesh, "require_environment"),
|
||||
patch.object(self.mesh, "require_model") as require_model,
|
||||
patch.object(self.mesh, "production_device", return_value="CPU"),
|
||||
patch.object(self.mesh, "source_start_frame", return_value=1),
|
||||
):
|
||||
parameters = operator.request(SimpleNamespace(scene=SimpleNamespace()))
|
||||
|
||||
require_model.assert_not_called()
|
||||
self.assertFalse(parameters["normal_analysis"])
|
||||
self.assertNotIn("normal_model", parameters)
|
||||
self.assertNotIn("normal_resolution_level", parameters)
|
||||
def test_mesh_plane_redo_keeps_the_original_world_matrix(self):
|
||||
operator = self.mesh_plane.ConvertToMeshPlane()
|
||||
operator.shadeless = False
|
||||
@@ -818,9 +1060,7 @@ class AddonTest(unittest.TestCase):
|
||||
import inspect
|
||||
|
||||
mesh_source = inspect.getsource(self.mesh_plane.create_mesh_plane_mesh)
|
||||
processing_source = inspect.getsource(
|
||||
self.mesh_plane.add_mesh_plane_processing
|
||||
)
|
||||
processing_source = inspect.getsource(self.mesh_plane.add_mesh_plane_processing)
|
||||
self.assertIn("((0, 1, 2, 3),)", mesh_source)
|
||||
self.assertIn('("Subdivide", max(0, int(subdivisions)))', processing_source)
|
||||
self.assertIn('("Thickness", max(0.0, float(thickness)))', processing_source)
|
||||
@@ -865,9 +1105,8 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
("anyimage.convert_to_mesh_plane", True),
|
||||
("anyimage.convert_to_plane", True),
|
||||
("anyimage.convert_to_depth_plane", True),
|
||||
("anyimage.convert_to_surface", True),
|
||||
("separator", True),
|
||||
("anyimage.remove_image_background", True),
|
||||
("anyimage.upscale_image", True),
|
||||
@@ -884,6 +1123,7 @@ class AddonTest(unittest.TestCase):
|
||||
labels["anyimage.upscale_image"],
|
||||
"Upscale (640 × 480)",
|
||||
)
|
||||
|
||||
def test_image_menu_opens_environment_settings_when_ai_is_ready(self):
|
||||
calls = []
|
||||
|
||||
@@ -933,9 +1173,7 @@ class AddonTest(unittest.TestCase):
|
||||
operator.model = "SAFMN_REAL_X4"
|
||||
operator.max_output_size = 6144
|
||||
context = SimpleNamespace(
|
||||
scene=SimpleNamespace(
|
||||
anyimage_settings=SimpleNamespace(video_frames=7)
|
||||
)
|
||||
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=7))
|
||||
)
|
||||
with (
|
||||
patch.object(self.image_upscale, "require_environment"),
|
||||
@@ -943,12 +1181,8 @@ class AddonTest(unittest.TestCase):
|
||||
patch.object(
|
||||
self.image_upscale, "input_path", return_value=Path("input.png")
|
||||
),
|
||||
patch.object(
|
||||
self.image_upscale, "production_device", return_value="CUDA"
|
||||
),
|
||||
patch.object(
|
||||
self.image_upscale, "source_start_frame", return_value=4
|
||||
),
|
||||
patch.object(self.image_upscale, "production_device", return_value="CUDA"),
|
||||
patch.object(self.image_upscale, "source_start_frame", return_value=4),
|
||||
):
|
||||
request = operator.request(context)
|
||||
|
||||
@@ -979,9 +1213,7 @@ class AddonTest(unittest.TestCase):
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
object=source_object,
|
||||
scene=SimpleNamespace(
|
||||
anyimage_settings=SimpleNamespace(video_frames=1)
|
||||
),
|
||||
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1)),
|
||||
)
|
||||
operator = self.image_upscale.UpscaleImage()
|
||||
with (
|
||||
@@ -1109,6 +1341,22 @@ class AddonTest(unittest.TestCase):
|
||||
):
|
||||
operator.response(None, SimpleNamespace(directory=Path("result")))
|
||||
|
||||
def test_depth_analysis_follows_the_oldest_upscale_source(self):
|
||||
original = SimpleNamespace(name="Original")
|
||||
first_upscale = SimpleNamespace(
|
||||
name="First",
|
||||
anyimage_upscale_source=original,
|
||||
)
|
||||
second_upscale = SimpleNamespace(
|
||||
name="Second",
|
||||
anyimage_upscale_source=first_upscale,
|
||||
)
|
||||
|
||||
self.assertIs(
|
||||
self.image_data.analysis_source_image(second_upscale),
|
||||
original,
|
||||
)
|
||||
|
||||
def test_repeated_upscale_uses_original_image_and_reports_warning(self):
|
||||
original_image = SimpleNamespace(name="Original", source="FILE")
|
||||
current_image = SimpleNamespace(
|
||||
@@ -1123,9 +1371,7 @@ class AddonTest(unittest.TestCase):
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
object=source_object,
|
||||
scene=SimpleNamespace(
|
||||
anyimage_settings=SimpleNamespace(video_frames=1)
|
||||
),
|
||||
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1)),
|
||||
)
|
||||
reports = []
|
||||
operator = self.image_upscale.UpscaleImage()
|
||||
@@ -1287,14 +1533,53 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertGreater(self.balloon_geometry.polygon_area(contour), 0.0)
|
||||
self.assertLess(float(lengths.std() / lengths.mean()), 0.15)
|
||||
|
||||
def test_balloon_contour_smooths_pixel_stairs_before_coarse_resampling(self):
|
||||
import numpy as np
|
||||
|
||||
staircase = [(0.0, 0.0)]
|
||||
for value in range(16):
|
||||
staircase.append((value + 1.0, float(value)))
|
||||
staircase.append((value + 1.0, value + 1.0))
|
||||
staircase.append((0.0, 16.0))
|
||||
smooth = self.balloon_geometry.smooth_lasso_contour(staircase, 2.0)
|
||||
|
||||
def diagonal_noise(points):
|
||||
points = np.asarray(points)
|
||||
selected = (
|
||||
(points[:, 0] > 2.0)
|
||||
& (points[:, 0] < 14.0)
|
||||
& (np.abs(points[:, 1] - points[:, 0]) < 2.0)
|
||||
)
|
||||
return float(np.std(points[selected, 1] - points[selected, 0]))
|
||||
|
||||
self.assertLess(diagonal_noise(smooth), 0.05)
|
||||
|
||||
def test_cutout_polygon_mask_keeps_nonzero_winding_overlap(self):
|
||||
polygon = (
|
||||
(1, 1),
|
||||
(7, 1),
|
||||
(7, 7),
|
||||
(1, 7),
|
||||
(1, 1),
|
||||
(4, 1),
|
||||
(10, 1),
|
||||
(10, 7),
|
||||
(4, 7),
|
||||
(4, 1),
|
||||
(1, 1),
|
||||
)
|
||||
|
||||
even_odd = self.image_mask.polygon_mask((12, 9), polygon)
|
||||
nonzero = self.image_mask.polygon_mask((12, 9), polygon, "NONZERO")
|
||||
|
||||
self.assertFalse(even_odd[3, 5])
|
||||
self.assertTrue(nonzero[3, 5])
|
||||
def test_image_mask_preserves_source_rgb_outside_the_selection(self):
|
||||
import numpy as np
|
||||
|
||||
top_down = np.full((3, 3, 4), (0.2, 0.4, 0.6, 1.0), dtype=np.float32)
|
||||
source = np.flipud(top_down).ravel()
|
||||
selection = self.image_mask.polygon_mask(
|
||||
(3, 3), ((0, 0), (3, 0), (0, 3))
|
||||
)
|
||||
selection = self.image_mask.polygon_mask((3, 3), ((0, 0), (3, 0), (0, 3)))
|
||||
pixels, bounds = self.image_mask.edit_image_mask_pixels(
|
||||
source,
|
||||
(3, 3),
|
||||
@@ -1558,9 +1843,7 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertNotIn(tool, self.addon.CLASSES)
|
||||
self.assertEqual(tool.bl_operator, operator.bl_idname)
|
||||
self.assertEqual(tool.bl_icon, icon)
|
||||
self.assertEqual(
|
||||
tool.bl_keymap[0][2]["properties"], [("gesture", gesture)]
|
||||
)
|
||||
self.assertEqual(tool.bl_keymap[0][2]["properties"], [("gesture", gesture)])
|
||||
self.assertNotIn("bl_cursor", tool.__dict__)
|
||||
self.assertIn("draw_settings", self.image_box.ImageBoxTool.__dict__)
|
||||
self.assertIn("draw_settings", self.image_lasso.ImageLassoTool.__dict__)
|
||||
@@ -1574,10 +1857,16 @@ 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.assertFalse(settings["cutout_normal_analysis"]["default"])
|
||||
self.assertNotIn("image_extract_continuous", settings)
|
||||
self.assertNotIn("image_extract_padding", settings)
|
||||
self.assertNotIn("image_extract_remove_background", settings)
|
||||
source_picker = inspect.getsource(self.image_edit_common.resolve_image_edit_click)
|
||||
source_picker = inspect.getsource(
|
||||
self.image_edit_common.resolve_image_edit_click
|
||||
)
|
||||
self.assertIn("bpy.ops.view3d.select(", source_picker)
|
||||
self.assertIn("Click an Image Empty", source_picker)
|
||||
self.assertIn("_restore_selection(", source_picker)
|
||||
@@ -1585,6 +1874,11 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertIn('event.type == "F"', modal_source)
|
||||
self.assertIn("self.invert = not self.invert", modal_source)
|
||||
|
||||
self.assertIn("math.dist(point, self._path[-1]) >= 2.0", modal_source)
|
||||
mask_source = inspect.getsource(self.image_mask.polygon_mask)
|
||||
self.assertNotIn("np.indices", mask_source)
|
||||
self.assertIn("range(top, bottom, 128)", mask_source)
|
||||
|
||||
def test_image_line_and_polyline_disable_refine_selection(self):
|
||||
settings = SimpleNamespace(image_edit_refine_selection=True)
|
||||
context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings))
|
||||
@@ -1614,9 +1908,7 @@ class AddonTest(unittest.TestCase):
|
||||
def test_image_area_click_is_cancelled_without_a_warning(self):
|
||||
import inspect
|
||||
|
||||
completion_source = inspect.getsource(
|
||||
self.image_mask.EditImageMask._complete
|
||||
)
|
||||
completion_source = inspect.getsource(self.image_mask.EditImageMask._complete)
|
||||
|
||||
self.assertIn("if len(path) < 3", completion_source)
|
||||
self.assertIn('return {"CANCELLED"}', completion_source)
|
||||
@@ -1625,12 +1917,8 @@ class AddonTest(unittest.TestCase):
|
||||
def test_image_polyline_closes_from_start_or_enter(self):
|
||||
import inspect
|
||||
|
||||
modal_source = inspect.getsource(
|
||||
self.image_mask.EditImageMask._modal_polyline
|
||||
)
|
||||
draw_source = inspect.getsource(
|
||||
self.image_mask.EditImageMask._draw_selection
|
||||
)
|
||||
modal_source = inspect.getsource(self.image_mask.EditImageMask._modal_polyline)
|
||||
draw_source = inspect.getsource(self.image_mask.EditImageMask._draw_selection)
|
||||
self.assertIn("return self._confirm_polyline(context)", modal_source)
|
||||
self.assertNotIn("DOUBLE_CLICK", modal_source)
|
||||
self.assertIn('event.type in {"RET", "NUMPAD_ENTER"}', modal_source)
|
||||
@@ -1671,11 +1959,9 @@ class AddonTest(unittest.TestCase):
|
||||
clicked.name: clicked,
|
||||
}
|
||||
try:
|
||||
source, should_edit = (
|
||||
self.image_edit_common.resolve_image_edit_click(
|
||||
source, should_edit = self.image_edit_common.resolve_image_edit_click(
|
||||
context, event, lambda *_args: None
|
||||
)
|
||||
)
|
||||
finally:
|
||||
self.fake_bpy.data.objects = previous_objects
|
||||
if previous_ops is None:
|
||||
@@ -1718,11 +2004,9 @@ class AddonTest(unittest.TestCase):
|
||||
)
|
||||
self.fake_bpy.data.objects = {active.name: active, clicked.name: clicked}
|
||||
try:
|
||||
source, should_edit = (
|
||||
self.image_edit_common.resolve_image_edit_click(
|
||||
source, should_edit = self.image_edit_common.resolve_image_edit_click(
|
||||
context, event, lambda *_args: None
|
||||
)
|
||||
)
|
||||
finally:
|
||||
self.fake_bpy.data.objects = previous_objects
|
||||
if previous_ops is None:
|
||||
@@ -1781,6 +2065,7 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
def test_image_perspective_corner_order_is_click_order_independent(self):
|
||||
import itertools
|
||||
|
||||
import numpy as np
|
||||
|
||||
corners = ((10, 20), (80, 15), (90, 70), (5, 75))
|
||||
@@ -1794,15 +2079,9 @@ class AddonTest(unittest.TestCase):
|
||||
def test_image_perspective_detects_source_image_overlap(self):
|
||||
overlaps = self.image_perspective.perspective_quad_overlaps_image
|
||||
|
||||
self.assertFalse(
|
||||
overlaps(((20, 20), (30, 20), (30, 30), (20, 30)), (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.assertFalse(overlaps(((20, 20), (30, 20), (30, 30), (20, 30)), (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):
|
||||
self.assertEqual(
|
||||
@@ -1825,9 +2104,7 @@ class AddonTest(unittest.TestCase):
|
||||
1.0,
|
||||
)
|
||||
|
||||
self.assertLessEqual(
|
||||
width * height, self.image_perspective.MAX_OUTPUT_PIXELS
|
||||
)
|
||||
self.assertLessEqual(width * height, self.image_perspective.MAX_OUTPUT_PIXELS)
|
||||
|
||||
def test_image_perspective_mouse_adjusts_preview_aspect_exponentially(self):
|
||||
adjust = self.image_perspective.aspect_ratio_from_mouse
|
||||
@@ -1866,9 +2143,7 @@ class AddonTest(unittest.TestCase):
|
||||
def remove(*_args, **_options):
|
||||
raise ReferenceError("StructRNA of type Image has been removed")
|
||||
|
||||
self.fake_bpy.data = SimpleNamespace(
|
||||
images=SimpleNamespace(remove=remove)
|
||||
)
|
||||
self.fake_bpy.data = SimpleNamespace(images=SimpleNamespace(remove=remove))
|
||||
operator._remove_preview()
|
||||
|
||||
self.assertIsNone(operator._preview_image)
|
||||
@@ -1961,18 +2236,12 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
endpoint = self.image_mask.snapped_line_endpoint((0, 0), (10, 1), True)
|
||||
self.assertAlmostEqual(math.degrees(math.atan2(endpoint[1], endpoint[0])), 5.0)
|
||||
preview = self.image_mask.line_half_plane_polygon(
|
||||
(0, 5), (10, 5), (10, 10)
|
||||
)
|
||||
preview = self.image_mask.line_half_plane_polygon((0, 5), (10, 5), (10, 10))
|
||||
image = self.image_mask.image_line_half_plane_polygon(
|
||||
((0, 5), (10, 5)), (10, 10)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.image_edit_common.polygon_bounds(preview), (0, 5, 10, 10)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.image_edit_common.polygon_bounds(image), (0, 0, 10, 5)
|
||||
)
|
||||
self.assertEqual(self.image_edit_common.polygon_bounds(preview), (0, 5, 10, 10))
|
||||
self.assertEqual(self.image_edit_common.polygon_bounds(image), (0, 0, 10, 5))
|
||||
|
||||
def test_balloon_interior_points_have_uniform_spacing(self):
|
||||
import numpy as np
|
||||
@@ -2087,6 +2356,64 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
self.assertGreater(wide_height, narrow_height)
|
||||
|
||||
def test_balloon_keeps_overlapping_regions_disconnected(self):
|
||||
import numpy as np
|
||||
|
||||
regions = (
|
||||
(
|
||||
((20, 0), (90, 0), (90, 70), (20, 70)),
|
||||
((50, 20), (50, 50), (70, 50), (70, 20)),
|
||||
),
|
||||
(
|
||||
((0, 20), (40, 20), (40, 45), (0, 45)),
|
||||
),
|
||||
)
|
||||
vertices, faces, uv = self.balloon_geometry.triangulate_balloon(
|
||||
regions,
|
||||
(100, 100),
|
||||
(0.0, 1.0, 0.0, 1.0),
|
||||
spacing=0.1,
|
||||
world_metric=((1.0, 0.0), (0.0, 1.0)),
|
||||
)
|
||||
centers = uv[np.asarray(faces)].mean(axis=1)
|
||||
in_hole = (
|
||||
(centers[:, 0] > 0.55)
|
||||
& (centers[:, 0] < 0.65)
|
||||
& (centers[:, 1] > 0.57)
|
||||
& (centers[:, 1] < 0.73)
|
||||
)
|
||||
in_left_region = (
|
||||
(centers[:, 0] > 0.02)
|
||||
& (centers[:, 0] < 0.12)
|
||||
& (centers[:, 1] > 0.55)
|
||||
& (centers[:, 1] < 0.8)
|
||||
)
|
||||
heights = np.asarray(vertices)[:, 2]
|
||||
|
||||
self.assertFalse(in_hole.any())
|
||||
self.assertTrue(in_left_region.any())
|
||||
self.assertGreater(float(heights.max()), 0.0)
|
||||
self.assertLess(float(heights.min()), 0.0)
|
||||
adjacency = {}
|
||||
for face in faces:
|
||||
for vertex in face:
|
||||
adjacency.setdefault(int(vertex), set())
|
||||
for start, end in zip(face, np.roll(face, -1)):
|
||||
adjacency[int(start)].add(int(end))
|
||||
adjacency[int(end)].add(int(start))
|
||||
remaining = set(adjacency)
|
||||
component_count = 0
|
||||
while remaining:
|
||||
component_count += 1
|
||||
pending = [remaining.pop()]
|
||||
while pending:
|
||||
vertex = pending.pop()
|
||||
connected = adjacency[vertex] & remaining
|
||||
remaining.difference_update(connected)
|
||||
pending.extend(connected)
|
||||
|
||||
self.assertEqual(component_count, 2)
|
||||
|
||||
def test_poisson_balloon_supports_resolution_002(self):
|
||||
import numpy as np
|
||||
|
||||
@@ -2228,19 +2555,16 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertTrue(edges)
|
||||
self.assertTrue(all(users == 2 for users in edges.values()))
|
||||
|
||||
def test_balloon_tool_operators_are_registered(self):
|
||||
self.assertIn(self.balloon.ExtractBalloon, self.addon.CLASSES)
|
||||
self.assertIn(self.balloon.ActivateBalloonTool, self.addon.CLASSES)
|
||||
def test_balloon_tool_operators_are_not_registered(self):
|
||||
self.assertNotIn(self.balloon.ExtractBalloon, self.addon.CLASSES)
|
||||
self.assertNotIn(self.balloon.ActivateBalloonTool, self.addon.CLASSES)
|
||||
self.assertNotIn(self.balloon.BalloonTool, self.addon.CLASSES)
|
||||
self.assertLess(
|
||||
self.addon.CLASSES.index(self.balloon.ExtractBalloon),
|
||||
self.addon.CLASSES.index(self.balloon.ActivateBalloonTool),
|
||||
)
|
||||
self.assertIn(self.mesh.GenerateMesh, self.addon.CLASSES)
|
||||
|
||||
def test_balloon_ai_analysis_is_registered_and_forwarded(self):
|
||||
import inspect
|
||||
|
||||
self.assertIn(self.balloon.AnalyzeBalloon, self.addon.CLASSES)
|
||||
self.assertNotIn(self.balloon.AnalyzeBalloon, self.addon.CLASSES)
|
||||
operator = self.balloon.ExtractBalloon
|
||||
self.assertIn("contour_analysis", operator.__annotations__)
|
||||
self.assertIn("depth_analysis", operator.__annotations__)
|
||||
@@ -2333,6 +2657,7 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
def test_balloon_depth_fields_and_shape_nodes(self):
|
||||
import inspect
|
||||
|
||||
import numpy as np
|
||||
|
||||
depth_data = {
|
||||
@@ -2379,8 +2704,12 @@ class AddonTest(unittest.TestCase):
|
||||
controller_source = inspect.getsource(
|
||||
self.balloon_depth.create_balloon_base_plane
|
||||
)
|
||||
modifier_source = inspect.getsource(
|
||||
self.balloon_depth.add_balloon_shape_modifier
|
||||
)
|
||||
self.assertIn('"Thickness"', source)
|
||||
self.assertIn('"Depth Strength"', source)
|
||||
self.assertIn('"Depth Strength": 1.0', modifier_source)
|
||||
self.assertNotIn('"Far Clamp"', source)
|
||||
self.assertIn('"Double Sided Depth"', source)
|
||||
self.assertIn('"Base Plane"', source)
|
||||
@@ -2399,7 +2728,7 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertNotIn("o_balloon_depth_mix", source)
|
||||
self.assertIn('front.operation = "LESS_THAN"', source)
|
||||
self.assertIn('depth_residual.operation = "ADD"', source)
|
||||
self.assertIn('depth_calibration.inputs[1].default_value = 0.5', source)
|
||||
self.assertIn("depth_calibration.inputs[1].default_value = 0.5", source)
|
||||
self.assertIn('corrected_depth.operation = "ADD"', source)
|
||||
self.assertIn(
|
||||
'profile_statistics.outputs["Max"], corrected_depth.inputs[1]',
|
||||
@@ -2410,7 +2739,7 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertIn('double_choice.outputs["Output"], combine.inputs["X"]', source)
|
||||
self.assertIn('depth_mix = nodes.new("ShaderNodeMix")', source)
|
||||
self.assertIn('depth_mix.data_type = "FLOAT"', source)
|
||||
self.assertIn('depth_mix.clamp_factor = True', source)
|
||||
self.assertIn("depth_mix.clamp_factor = True", source)
|
||||
self.assertIn('base_magnitude.outputs[0], depth_mix.inputs["A"]', source)
|
||||
self.assertIn('depth_scale.outputs[0], depth_mix.inputs["B"]', source)
|
||||
self.assertNotIn("profile_inverse", source)
|
||||
@@ -2438,7 +2767,6 @@ class AddonTest(unittest.TestCase):
|
||||
"o_balloon_depth",
|
||||
)
|
||||
|
||||
|
||||
def test_balloon_redo_properties_use_mode_and_meter_resolution(self):
|
||||
import inspect
|
||||
|
||||
@@ -2595,11 +2923,11 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertIn("color_image = image.copy()", source)
|
||||
self.assertIn("color_image.name = color_name", source)
|
||||
self.assertIn(
|
||||
"material_name,\n color_image,\n 1.0,",
|
||||
"material_name,\n color_image,\n 0.0,",
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
"else TEXTURE_MATERIAL_NODE_GROUP_NAME",
|
||||
"surface_group_name=IMAGE_MATERIAL_NODE_GROUP_NAME",
|
||||
source,
|
||||
)
|
||||
self.assertIn("owns_material", source)
|
||||
@@ -2608,14 +2936,13 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertIn("assign_balloon_shape_attributes(", source)
|
||||
self.assertIn("inflation=INFLATION", source)
|
||||
self.assertIn("create_balloon_base_plane(", source)
|
||||
self.assertIn(
|
||||
"add_balloon_shape_modifier(balloon, thickness, base_plane)", source
|
||||
)
|
||||
self.assertIn("BALLOON_MATERIAL_NODE_GROUP_NAME", source)
|
||||
self.assertIn("add_balloon_shape_modifier(", source)
|
||||
self.assertNotIn("depth_strength", source)
|
||||
self.assertIn("IMAGE_MATERIAL_NODE_GROUP_NAME", source)
|
||||
self.assertIn("normal_path=", source)
|
||||
self.assertEqual(
|
||||
self.image_depth.TEXTURE_MATERIAL_NODE_GROUP_NAME,
|
||||
"O Texture Layer",
|
||||
"O Image Layer",
|
||||
)
|
||||
|
||||
def test_balloon_material_reuse_uses_explicit_source_markers(self):
|
||||
@@ -2858,9 +3185,12 @@ class AddonTest(unittest.TestCase):
|
||||
},
|
||||
),
|
||||
(
|
||||
self.balloon.BalloonTool,
|
||||
self.addon.CutoutTool,
|
||||
{
|
||||
"separator": False,
|
||||
"after": {
|
||||
self.image_perspective.ImagePerspectiveTool.bl_idname
|
||||
},
|
||||
"separator": True,
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -2875,9 +3205,7 @@ class AddonTest(unittest.TestCase):
|
||||
self.outliner_menu_draws[0],
|
||||
self.addon.draw_image_context_menu,
|
||||
)
|
||||
self.assertTrue(
|
||||
hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source")
|
||||
)
|
||||
self.assertTrue(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source"))
|
||||
|
||||
self.addon.unregister()
|
||||
self.assertEqual(self.registered, [])
|
||||
@@ -2885,9 +3213,7 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertEqual(self.status_bar_draws, [])
|
||||
self.assertEqual(self.context_menu_draws, [existing_context_draw])
|
||||
self.assertEqual(self.outliner_menu_draws, [existing_outliner_draw])
|
||||
self.assertFalse(
|
||||
hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source")
|
||||
)
|
||||
self.assertFalse(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source"))
|
||||
|
||||
def test_blender_image_exports_do_not_use_scene_render_color_management(self):
|
||||
source_root = PROJECT_ROOT / "src" / "anyimage"
|
||||
@@ -2922,7 +3248,7 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertEqual(calls[1], ("operator", "anyimage.cancel_job"))
|
||||
|
||||
def test_moge_depth_request_is_written_on_the_operator(self):
|
||||
operator = self.addon.operators.GenerateMoge2Depth()
|
||||
operator = self.depth_operator.GenerateMoge2Depth()
|
||||
operator.input_path = __file__
|
||||
operator.source_object_name = ""
|
||||
operator.model = "MOGE2_VITS_NORMAL"
|
||||
@@ -2943,7 +3269,7 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertNotIn("job_type", parameters)
|
||||
|
||||
def test_moge_depth_response_calls_an_ordinary_function(self):
|
||||
operator = self.addon.operators.GenerateMoge2Depth()
|
||||
operator = self.depth_operator.GenerateMoge2Depth()
|
||||
operator.source_object_name = "Reference"
|
||||
operator.remove_background = False
|
||||
operator.delete_source = True
|
||||
@@ -2990,8 +3316,13 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
with patch.object(
|
||||
self.depth_operator,
|
||||
"prepare_image_input",
|
||||
return_value=(Path(__file__), False),
|
||||
"prepare_image_analysis_inputs",
|
||||
return_value=(
|
||||
Path(__file__),
|
||||
False,
|
||||
Path(__file__),
|
||||
False,
|
||||
),
|
||||
):
|
||||
self.assertEqual(operator.execute(context), {"FINISHED"})
|
||||
|
||||
|
||||
136
tests/test_cutout.py
Normal file
136
tests/test_cutout.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import MethodType, SimpleNamespace
|
||||
|
||||
import bpy
|
||||
|
||||
SOURCE_ROOT = Path(__file__).parents[1] / "src"
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
from anyimage.operators import cutout, mesh
|
||||
from anyimage.operators.image_edit.mask import ImageMaskGesture
|
||||
|
||||
|
||||
class Pie:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def operator(self, identifier, **options):
|
||||
operator = SimpleNamespace()
|
||||
self.calls.append((identifier, options, operator))
|
||||
return operator
|
||||
|
||||
|
||||
class WindowManager:
|
||||
def __init__(self):
|
||||
self.pie = Pie()
|
||||
|
||||
def popup_menu_pie(self, _event, draw, **_options):
|
||||
menu = SimpleNamespace(layout=SimpleNamespace(menu_pie=lambda: self.pie))
|
||||
draw(menu, None)
|
||||
|
||||
|
||||
def cutout_context(window_manager, pixel_size=32, normal_analysis=False):
|
||||
settings = SimpleNamespace(
|
||||
cutout_pixel_size=pixel_size,
|
||||
cutout_normal_analysis=normal_analysis,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
window_manager=window_manager,
|
||||
scene=SimpleNamespace(anyimage_settings=settings),
|
||||
)
|
||||
|
||||
|
||||
def cutout_source(name="Source", size=(2048, 1024)):
|
||||
return SimpleNamespace(name=name, data=SimpleNamespace(size=size))
|
||||
|
||||
|
||||
def test_cutout_pie_uses_four_semantic_shapes_in_cardinal_order():
|
||||
window_manager = WindowManager()
|
||||
context = cutout_context(window_manager)
|
||||
source = cutout_source()
|
||||
|
||||
cutout._open_shape_pie(context, object(), source, ((0, 0), (10, 0), (0, 10)))
|
||||
|
||||
assert [call[2].shape for call in window_manager.pie.calls] == [
|
||||
mesh.CUTOUT,
|
||||
mesh.BALLOON,
|
||||
mesh.DEPTH_BALLOON,
|
||||
mesh.DEPTH_SURFACE,
|
||||
]
|
||||
assert all(
|
||||
call[0] == "anyimage.convert_cutout_shape" for call in window_manager.pie.calls
|
||||
)
|
||||
assert not any(call[2].refine_selection for call in window_manager.pie.calls)
|
||||
assert not any(call[2].normal_analysis for call in window_manager.pie.calls)
|
||||
assert not any(call[2].invert for call in window_manager.pie.calls)
|
||||
assert all(call[2].mesh_detail == 64 for call in window_manager.pie.calls)
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
source = cutout_source(size=(4096, 2048))
|
||||
|
||||
cutout._open_shape_pie(
|
||||
context,
|
||||
object(),
|
||||
source,
|
||||
((0, 0), (10, 0), (0, 10)),
|
||||
refine_selection=True,
|
||||
invert=True,
|
||||
)
|
||||
|
||||
assert all(call[2].refine_selection for call in window_manager.pie.calls)
|
||||
assert all(call[2].normal_analysis for call in window_manager.pie.calls)
|
||||
assert all(call[2].invert for call in window_manager.pie.calls)
|
||||
assert all(call[2].mesh_detail == 256 for call in window_manager.pie.calls)
|
||||
|
||||
|
||||
def test_cutout_mesh_detail_tracks_the_source_long_edge_and_clamps():
|
||||
assert cutout.cutout_mesh_detail((4096, 2048), 32) == 128
|
||||
assert cutout.cutout_mesh_detail((16, 8), 4) == 4
|
||||
assert cutout.cutout_mesh_detail((65536, 32768), 4) == 1024
|
||||
|
||||
|
||||
def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypatch):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Source", width=320, height=180)
|
||||
source = bpy.data.objects.new("Source", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
bpy.context.collection.objects.link(source)
|
||||
window_manager = WindowManager()
|
||||
context = cutout_context(window_manager)
|
||||
|
||||
monkeypatch.setattr(
|
||||
cutout, "resolve_image_edit_click", lambda *_args: (source, True)
|
||||
)
|
||||
monkeypatch.setattr(cutout, "video_total_frames", lambda _image: 1)
|
||||
monkeypatch.setattr(cutout, "apply_image_mask_settings", lambda *_args: None)
|
||||
monkeypatch.setattr(cutout, "selected_image_rgba", lambda *_args: object())
|
||||
|
||||
operator = SimpleNamespace(
|
||||
full_image=True,
|
||||
refine_selection=False,
|
||||
report=lambda *_args: None,
|
||||
)
|
||||
operator.execute = MethodType(cutout.SelectCutoutRegion.execute, operator)
|
||||
result = cutout.SelectCutoutRegion.invoke(
|
||||
operator, context, SimpleNamespace(value="DOUBLE_CLICK")
|
||||
)
|
||||
|
||||
assert issubclass(cutout.SelectCutoutRegion, ImageMaskGesture)
|
||||
assert result == {"FINISHED"}
|
||||
selection = json.loads(window_manager.pie.calls[0][2].selection_data)
|
||||
assert selection == [[0, 0], [320, 0], [320, 180], [0, 180]]
|
||||
assert window_manager.pie.calls[0][2].mesh_detail == 10
|
||||
assert not hasattr(cutout, "pending_selection")
|
||||
|
||||
|
||||
def test_cutout_tool_prioritizes_double_click_before_lasso_press():
|
||||
assert cutout.CutoutTool.bl_keymap[0][1]["value"] == "DOUBLE_CLICK"
|
||||
assert cutout.CutoutTool.bl_keymap[1][1]["value"] == "PRESS"
|
||||
322
tests/test_mesh.py
Normal file
322
tests/test_mesh.py
Normal file
@@ -0,0 +1,322 @@
|
||||
import builtins
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
SOURCE_ROOT = Path(__file__).parents[1] / "src"
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
from anyimage.operators import mesh
|
||||
from anyimage.operators.normal_map import rasterize_tangent_normal
|
||||
from anyimage.server.geometry.cutout import alpha_regions, image_alpha_regions
|
||||
|
||||
|
||||
def mesh_component_count(faces):
|
||||
adjacency = {}
|
||||
for face in faces:
|
||||
for vertex in face:
|
||||
adjacency.setdefault(int(vertex), set())
|
||||
for start, end in zip(face, np.roll(face, -1)):
|
||||
adjacency[int(start)].add(int(end))
|
||||
adjacency[int(end)].add(int(start))
|
||||
remaining = set(adjacency)
|
||||
count = 0
|
||||
while remaining:
|
||||
count += 1
|
||||
pending = [remaining.pop()]
|
||||
while pending:
|
||||
vertex = pending.pop()
|
||||
connected = adjacency[vertex] & remaining
|
||||
remaining.difference_update(connected)
|
||||
pending.extend(connected)
|
||||
return count
|
||||
|
||||
|
||||
def test_tangent_normal_pixels_use_the_final_surface_frame():
|
||||
object_normal = np.zeros((3, 3, 3), dtype=np.float32)
|
||||
object_normal[..., 2] = 1.0
|
||||
triangle_uv = np.asarray((((0.0, 0.0), (1.0, 0.0), (0.0, 1.0)),))
|
||||
triangle_normal = np.asarray((((1.0, 0.0, 0.0),) * 3,))
|
||||
triangle_tangent = np.asarray((((0.0, 1.0, 0.0),) * 3,))
|
||||
triangle_sign = np.ones((1, 3), dtype=np.float32)
|
||||
|
||||
tangent = rasterize_tangent_normal(
|
||||
object_normal,
|
||||
triangle_uv,
|
||||
triangle_normal,
|
||||
triangle_tangent,
|
||||
triangle_sign,
|
||||
)
|
||||
|
||||
assert np.allclose(tangent[0, 0], (0.0, 1.0, 0.0))
|
||||
assert not np.allclose(tangent[0, 0], object_normal[0, 0])
|
||||
assert np.allclose(tangent[2, 2], (0.0, 0.0, 1.0))
|
||||
|
||||
|
||||
def test_plane_source_topology_is_one_quad_at_every_detail_level():
|
||||
low = mesh._plane_domain((-2.0, 2.0, -1.0, 1.0), 1)
|
||||
high = mesh._plane_domain((-2.0, 2.0, -1.0, 1.0), 512)
|
||||
|
||||
assert low[0].shape == high[0].shape == (4, 3)
|
||||
assert low[1].shape == high[1].shape == (1, 4)
|
||||
|
||||
|
||||
def test_cutout_front_back_and_side_faces_point_outward():
|
||||
vertices = np.asarray(
|
||||
((0.0, 0.0, 0.0), (0.0, 1.0, 0.0), (1.0, 0.0, 0.0)),
|
||||
dtype=np.float64,
|
||||
)
|
||||
faces = np.asarray(((0, 1, 2),), dtype=np.int32)
|
||||
uv = vertices[:, :2].astype(np.float32)
|
||||
boundary = np.ones(3, dtype=bool)
|
||||
layered = mesh._layered_geometry(
|
||||
(vertices, faces, uv, boundary, vertices[:, :2]),
|
||||
mesh.CUTOUT,
|
||||
)
|
||||
positioned = layered[0] + layered[3]
|
||||
output_faces = layered[1]
|
||||
|
||||
def face_normal(face):
|
||||
points = positioned[np.asarray(face[:3], dtype=np.int32)]
|
||||
return np.cross(points[1] - points[0], points[2] - points[0])
|
||||
|
||||
assert face_normal(output_faces[0])[2] > 0.0
|
||||
assert face_normal(output_faces[1])[2] < 0.0
|
||||
center = vertices[:, :2].mean(axis=0)
|
||||
for face in output_faces[2:]:
|
||||
normal = face_normal(face)
|
||||
side_center = positioned[np.asarray(face, dtype=np.int32), :2].mean(axis=0)
|
||||
assert np.dot(normal[:2], side_center - center) > 0.0
|
||||
|
||||
|
||||
def test_depth_surface_moves_both_sides_with_camera_projection_and_extrudes_back():
|
||||
domain = mesh._plane_domain((-1.0, 1.0, -1.0, 1.0), 4)
|
||||
field = np.zeros((2, 2, 3), dtype=np.float32)
|
||||
field[..., 2] = 1.0
|
||||
valid = np.ones((2, 2), dtype=bool)
|
||||
camera_depth = np.ones((2, 2), dtype=np.float32)
|
||||
|
||||
depth_surface = mesh._layered_geometry(
|
||||
domain, mesh.DEPTH_SURFACE, (field, valid, camera_depth)
|
||||
)
|
||||
base_count = len(domain[0])
|
||||
|
||||
assert np.allclose(depth_surface[4][:base_count, 2], 1.0)
|
||||
assert np.allclose(depth_surface[4][base_count:, 2], 1.0)
|
||||
assert np.allclose(depth_surface[3][:base_count], 0.0)
|
||||
assert np.allclose(depth_surface[3][base_count:, 2], -1.0)
|
||||
assert all(depth_surface[5][: len(domain[1])])
|
||||
assert not any(depth_surface[5][len(domain[1]) :])
|
||||
|
||||
|
||||
def test_depth_surface_records_depth_discontinuities_for_geometry_nodes():
|
||||
faces = np.asarray(((0, 1, 2), (1, 2, 3)), dtype=np.int32)
|
||||
depth = np.asarray((1.0, 1.01, 1.02, 2.0), dtype=np.float32)
|
||||
|
||||
discontinuity = mesh._depth_discontinuity_values(faces, depth)
|
||||
|
||||
assert discontinuity[0] < mesh.DEPTH_EDGE_THRESHOLD
|
||||
assert discontinuity[1] > mesh.DEPTH_EDGE_THRESHOLD
|
||||
|
||||
|
||||
def test_depth_surface_discontinuity_map_uses_local_three_by_three_range():
|
||||
depth = np.ones((5, 5), dtype=np.float32)
|
||||
depth[:, 3:] = 2.0
|
||||
valid = np.ones_like(depth, dtype=bool)
|
||||
|
||||
discontinuity = mesh._depth_discontinuity_map(depth, valid)
|
||||
|
||||
assert discontinuity[2, 0] < mesh.DEPTH_EDGE_THRESHOLD
|
||||
assert discontinuity[2, 2] > mesh.DEPTH_EDGE_THRESHOLD
|
||||
|
||||
|
||||
def test_cutout_regions_group_holes_with_their_connected_component():
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "cutout.png"
|
||||
pixels = np.zeros((120, 200, 4), dtype=np.uint8)
|
||||
pixels[20:100, 50:160] = (20, 40, 60, 255)
|
||||
pixels[45:75, 80:120] = 0
|
||||
pixels[5:15, 5:15] = (20, 40, 60, 255)
|
||||
Image.fromarray(pixels, mode="RGBA").save(path)
|
||||
|
||||
regions, size = image_alpha_regions(path, max_resolution=200)
|
||||
|
||||
assert size == (200, 120)
|
||||
assert sorted(len(region) for region in regions) == [1, 2]
|
||||
contours = [contour for region in regions for contour in region]
|
||||
bounds = [
|
||||
(*np.min(contour, axis=0), *np.max(contour, axis=0))
|
||||
for contour in contours
|
||||
]
|
||||
assert (50.0, 20.0, 160.0, 100.0) in bounds
|
||||
assert (80.0, 45.0, 120.0, 75.0) in bounds
|
||||
assert (5.0, 5.0, 15.0, 15.0) in bounds
|
||||
|
||||
|
||||
def test_alpha_regions_accepts_blender_float_alpha_with_a_hole():
|
||||
alpha = np.zeros((120, 200), dtype=np.float32)
|
||||
alpha[20:100, 50:160] = 1.0
|
||||
alpha[45:75, 80:120] = 0.0
|
||||
|
||||
regions, size = alpha_regions(alpha, max_resolution=200)
|
||||
|
||||
assert size == (200, 120)
|
||||
assert len(regions) == 1
|
||||
assert len(regions[0]) == 2
|
||||
|
||||
|
||||
def test_blender_cutout_alpha_pipeline_does_not_import_pillow():
|
||||
original_import = builtins.__import__
|
||||
|
||||
def without_pillow(name, *args, **kwargs):
|
||||
if name == "PIL" or name.startswith("PIL."):
|
||||
raise ModuleNotFoundError("Pillow is unavailable in Blender")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
selection = np.zeros((32, 48), dtype=bool)
|
||||
selection[4:28, 6:42] = True
|
||||
with patch("builtins.__import__", side_effect=without_pillow):
|
||||
antialiased = mesh._antialias_selection(selection)
|
||||
regions, size = alpha_regions(antialiased, max_resolution=24)
|
||||
|
||||
assert size == (48, 32)
|
||||
assert regions
|
||||
assert np.any((antialiased > 0.0) & (antialiased < 1.0))
|
||||
|
||||
def test_cutout_geometry_ignores_source_alpha_without_refine():
|
||||
alpha = np.zeros((80, 120), dtype=np.float32)
|
||||
alpha[10:70, 10:110] = 1.0
|
||||
alpha[30:50, 45:75] = 0.0
|
||||
polygon = ((10, 10), (110, 10), (110, 70), (10, 70))
|
||||
|
||||
regions, size = mesh._geometry_cutout_regions(alpha, polygon, 64)
|
||||
|
||||
assert size == (120, 80)
|
||||
assert len(regions) == 1
|
||||
assert len(regions[0]) == 1
|
||||
|
||||
|
||||
def test_cutout_geometry_uses_final_alpha_with_refine():
|
||||
alpha = np.zeros((80, 120), dtype=np.float32)
|
||||
alpha[10:70, 10:110] = 1.0
|
||||
alpha[30:50, 45:75] = 0.0
|
||||
polygon = ((10, 10), (110, 10), (110, 70), (10, 70))
|
||||
|
||||
regions, size = mesh._geometry_cutout_regions(
|
||||
alpha, polygon, 64, refine_selection=True
|
||||
)
|
||||
|
||||
assert size == (120, 80)
|
||||
assert len(regions) == 1
|
||||
assert len(regions[0]) == 2
|
||||
|
||||
def test_cutout_geometry_can_extend_beyond_the_source_image():
|
||||
alpha = np.zeros((60, 100), dtype=np.float32)
|
||||
alpha[10:50] = 1.0
|
||||
polygon = ((-20, 10), (120, 10), (120, 50), (-20, 50))
|
||||
|
||||
regions, size = mesh._geometry_cutout_regions(alpha, polygon, 64)
|
||||
|
||||
points = np.concatenate(
|
||||
[np.asarray(contour) for region in regions for contour in region]
|
||||
)
|
||||
assert size == (100, 60)
|
||||
assert points[:, 0].min() <= -19.0
|
||||
assert points[:, 0].max() >= 119.0
|
||||
|
||||
def test_refined_cutout_geometry_discards_out_of_frame_selection():
|
||||
alpha = np.zeros((60, 100), dtype=np.float32)
|
||||
alpha[10:50] = 1.0
|
||||
polygon = ((-20, 10), (120, 10), (120, 50), (-20, 50))
|
||||
|
||||
regions, size = mesh._geometry_cutout_regions(
|
||||
alpha, polygon, 64, refine_selection=True
|
||||
)
|
||||
|
||||
points = np.concatenate(
|
||||
[np.asarray(contour) for region in regions for contour in region]
|
||||
)
|
||||
assert size == (100, 60)
|
||||
assert points[:, 0].min() >= 0.0
|
||||
assert points[:, 0].max() <= 100.0
|
||||
|
||||
def test_blender_cutout_contour_resolution_tracks_mesh_detail():
|
||||
expected = ([(((0, 0), (1, 0), (0, 1)),)], (1, 1))
|
||||
alpha = np.ones((2, 2), dtype=np.float32)
|
||||
with patch.object(mesh, "alpha_regions", return_value=expected) as trace:
|
||||
assert mesh._cutout_regions(alpha, 32) == expected
|
||||
assert mesh._cutout_regions(alpha, 256) == expected
|
||||
|
||||
assert trace.call_args_list[0].kwargs["max_resolution"] == 512
|
||||
assert trace.call_args_list[1].kwargs["max_resolution"] == 1024
|
||||
|
||||
def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_region():
|
||||
regions = (
|
||||
(((2046, 2046), (2050, 2046), (2050, 2050), (2046, 2050)),),
|
||||
)
|
||||
|
||||
domain = mesh._cutout_domain(
|
||||
regions,
|
||||
(4096, 4096),
|
||||
(-1.0, 1.0, -1.0, 1.0),
|
||||
4,
|
||||
((1.0, 0.0), (0.0, 1.0)),
|
||||
)
|
||||
|
||||
assert len(domain[0]) >= 3
|
||||
assert len(domain[1]) >= 1
|
||||
|
||||
|
||||
def test_cutout_domain_builds_a_full_image_at_minimum_detail():
|
||||
regions = ((((0, 0), (4096, 0), (4096, 4096), (0, 4096)),),)
|
||||
|
||||
domain = mesh._cutout_domain(
|
||||
regions,
|
||||
(4096, 4096),
|
||||
(-1.0, 1.0, -1.0, 1.0),
|
||||
4,
|
||||
((1.0, 0.0), (0.0, 1.0)),
|
||||
)
|
||||
|
||||
assert len(domain[0]) >= 4
|
||||
assert len(domain[1]) >= 2
|
||||
|
||||
def test_cutout_domain_keeps_overlapping_regions_disconnected():
|
||||
regions = (
|
||||
(
|
||||
((20, 0), (100, 0), (100, 80), (20, 80)),
|
||||
((50, 20), (50, 60), (70, 60), (70, 20)),
|
||||
),
|
||||
(
|
||||
((0, 20), (40, 20), (40, 50), (0, 50)),
|
||||
),
|
||||
)
|
||||
domain = mesh._cutout_domain(
|
||||
regions,
|
||||
(120, 80),
|
||||
(0.0, 1.0, 0.0, 1.0),
|
||||
40,
|
||||
((1.0, 0.0), (0.0, 1.0)),
|
||||
)
|
||||
|
||||
centers = domain[2][domain[1]].mean(axis=1)
|
||||
in_hole = (
|
||||
(centers[:, 0] > 0.45)
|
||||
& (centers[:, 0] < 0.55)
|
||||
& (centers[:, 1] > 0.35)
|
||||
& (centers[:, 1] < 0.65)
|
||||
)
|
||||
in_left_region = (
|
||||
(centers[:, 0] > 0.02)
|
||||
& (centers[:, 0] < 0.12)
|
||||
& (centers[:, 1] > 0.4)
|
||||
& (centers[:, 1] < 0.75)
|
||||
)
|
||||
|
||||
assert not in_hole.any()
|
||||
assert in_left_region.any()
|
||||
assert mesh_component_count(domain[1]) == 2
|
||||
562
tests/test_mesh_blender.py
Normal file
562
tests/test_mesh_blender.py
Normal file
@@ -0,0 +1,562 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Matrix
|
||||
|
||||
SOURCE_ROOT = Path(__file__).parents[1] / "src"
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
from anyimage.operators import material, mesh
|
||||
from anyimage.operators.normal_map import bake_tangent_normal_image
|
||||
|
||||
|
||||
def _inputs(group):
|
||||
return [
|
||||
item.name
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"
|
||||
]
|
||||
|
||||
|
||||
def _group_node(group, node_tree):
|
||||
return next(
|
||||
node
|
||||
for node in node_tree.nodes
|
||||
if node.bl_idname == "ShaderNodeGroup" and node.node_tree == group
|
||||
)
|
||||
|
||||
|
||||
def test_tangent_bake_changes_pixels_using_evaluated_mesh_tbn():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source = bpy.data.objects.new("Source", None)
|
||||
bpy.context.collection.objects.link(source)
|
||||
target_mesh = bpy.data.meshes.new("Target")
|
||||
target_mesh.from_pydata(
|
||||
((0.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
|
||||
(),
|
||||
((0, 1, 2),),
|
||||
)
|
||||
target_mesh.polygons[0].use_smooth = True
|
||||
mesh._assign_uv(
|
||||
target_mesh,
|
||||
np.asarray(((0.0, 0.0), (1.0, 0.0), (0.0, 1.0))),
|
||||
)
|
||||
target = bpy.data.objects.new("Target", target_mesh)
|
||||
bpy.context.collection.objects.link(target)
|
||||
target.matrix_world = Matrix.Rotation(np.radians(-90.0), 4, "Y")
|
||||
image = bpy.data.images.new("Object Normal", width=3, height=3, alpha=True)
|
||||
pixels = np.ones((3, 3, 4), dtype=np.float32)
|
||||
pixels[..., :3] = (1.0, 0.5, 0.5)
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
|
||||
bake_tangent_normal_image(bpy.context, source, target, image)
|
||||
|
||||
baked = np.empty(3 * 3 * 4, dtype=np.float32)
|
||||
image.pixels.foreach_get(baked)
|
||||
baked = baked.reshape((3, 3, 4))
|
||||
assert not np.allclose(baked[0, 0, :3], pixels[0, 0, :3])
|
||||
|
||||
|
||||
def test_shape_node_groups_expose_only_shape_parameters():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
plane = mesh._mesh_node_group(mesh.PLANE)
|
||||
depth_plane = mesh._mesh_node_group(mesh.DEPTH_PLANE)
|
||||
cutout = mesh._mesh_node_group(mesh.CUTOUT)
|
||||
depth_surface = mesh._mesh_node_group(mesh.DEPTH_SURFACE)
|
||||
|
||||
assert plane.name == "O Mesh Plane"
|
||||
assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness", "Material"]
|
||||
assert _inputs(cutout) == ["Geometry", "Thickness"]
|
||||
assert _inputs(depth_plane) == [
|
||||
"Geometry",
|
||||
"Subdivide",
|
||||
"Thickness",
|
||||
"Depth Amount",
|
||||
"Depth Image",
|
||||
"Material",
|
||||
]
|
||||
assert _inputs(depth_surface) == [
|
||||
"Geometry",
|
||||
"Thickness",
|
||||
"Depth Scale",
|
||||
"Iterations",
|
||||
"Smooth",
|
||||
"Depth Continuity",
|
||||
]
|
||||
for group in (depth_plane, cutout, depth_surface):
|
||||
thickness = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.name == "Thickness"
|
||||
)
|
||||
assert thickness.subtype == "DISTANCE"
|
||||
for group, name in (
|
||||
(depth_plane, "Depth Amount"),
|
||||
(depth_surface, "Depth Scale"),
|
||||
):
|
||||
depth_amount = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.name == name
|
||||
)
|
||||
assert depth_amount.subtype == "NONE"
|
||||
assert depth_amount.min_value == 0.0
|
||||
assert depth_amount.max_value > 1e20
|
||||
assert any(
|
||||
node.bl_idname == "ShaderNodeMath" and node.operation == "MAXIMUM"
|
||||
for node in depth_plane.nodes
|
||||
)
|
||||
assert any(node.bl_idname == "GeometryNodeMeshGrid" for node in plane.nodes)
|
||||
assert any(
|
||||
node.bl_idname == "GeometryNodeGroup" and node.node_tree == plane
|
||||
for node in depth_plane.nodes
|
||||
)
|
||||
assert any(
|
||||
node.bl_idname == "GeometryNodeDeleteGeometry" for node in depth_plane.nodes
|
||||
)
|
||||
smooth = next(
|
||||
item
|
||||
for item in depth_surface.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.name == "Smooth"
|
||||
)
|
||||
assert smooth.default_value == 1.0
|
||||
iterations = next(
|
||||
item
|
||||
for item in depth_surface.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.name == "Iterations"
|
||||
)
|
||||
assert iterations.default_value == 1
|
||||
assert iterations.min_value == 0
|
||||
continuity = next(
|
||||
item
|
||||
for item in depth_surface.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.name == "Depth Continuity"
|
||||
)
|
||||
assert continuity.default_value == 0.0
|
||||
assert not any(node.bl_idname == "GeometryNodeGroup" for node in depth_surface.nodes)
|
||||
assert any(
|
||||
node.bl_idname == "GeometryNodeRepeatInput" for node in depth_surface.nodes
|
||||
)
|
||||
assert any(
|
||||
node.bl_idname == "GeometryNodeRepeatOutput" for node in depth_surface.nodes
|
||||
)
|
||||
assert any(
|
||||
node.bl_idname == "GeometryNodeBlurAttribute" for node in depth_surface.nodes
|
||||
)
|
||||
assert any(
|
||||
node.bl_idname == "GeometryNodeInputMeshEdgeNeighbors"
|
||||
for node in depth_surface.nodes
|
||||
)
|
||||
assert sum(
|
||||
node.bl_idname == "GeometryNodeInputMeshEdgeNeighbors"
|
||||
for node in depth_surface.nodes
|
||||
) == 2
|
||||
assert sum(
|
||||
node.bl_idname == "GeometryNodeInputMeshVertexNeighbors"
|
||||
for node in depth_surface.nodes
|
||||
) == 2
|
||||
boundary_compares = [
|
||||
node
|
||||
for node in depth_surface.nodes
|
||||
if node.bl_idname == "FunctionNodeCompare"
|
||||
and node.data_type == "INT"
|
||||
and node.operation == "EQUAL"
|
||||
]
|
||||
assert len(boundary_compares) == 4
|
||||
for compare in boundary_compares:
|
||||
compare_a = next(
|
||||
socket for socket in compare.inputs if socket.identifier == "A_INT"
|
||||
)
|
||||
compare_b = next(
|
||||
socket for socket in compare.inputs if socket.identifier == "B_INT"
|
||||
)
|
||||
assert compare_a.is_linked
|
||||
assert compare_b.default_value == 1
|
||||
fixed_pin_switches = [
|
||||
node
|
||||
for node in depth_surface.nodes
|
||||
if node.bl_idname == "GeometryNodeSwitch"
|
||||
and not node.inputs["Switch"].is_linked
|
||||
]
|
||||
assert len(fixed_pin_switches) == 2
|
||||
assert all(node.inputs["Switch"].default_value for node in fixed_pin_switches)
|
||||
assert any(
|
||||
node.bl_idname == "ShaderNodeMix"
|
||||
and not node.inputs[0].is_linked
|
||||
and node.inputs[0].default_value == 1.0
|
||||
for node in depth_surface.nodes
|
||||
)
|
||||
assert any(
|
||||
node.bl_idname == "GeometryNodeInputNamedAttribute"
|
||||
and node.inputs["Name"].default_value == mesh.DEPTH_DISCONTINUITY_ATTRIBUTE
|
||||
for node in depth_surface.nodes
|
||||
)
|
||||
for group in (depth_plane, cutout, depth_surface):
|
||||
input_sockets = [
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"
|
||||
]
|
||||
assert all(item.structure_type == "SINGLE" for item in input_sockets)
|
||||
for group_input in (
|
||||
node for node in group.nodes if node.bl_idname == "NodeGroupInput"
|
||||
):
|
||||
assert all(
|
||||
output.is_linked or output.hide
|
||||
for output in group_input.outputs
|
||||
if output.name
|
||||
)
|
||||
geometry_links = [
|
||||
link
|
||||
for link in group.links
|
||||
if link.from_socket.type == "GEOMETRY"
|
||||
and link.to_socket.type == "GEOMETRY"
|
||||
]
|
||||
assert all(
|
||||
link.from_node.location.x <= link.to_node.location.x
|
||||
for link in geometry_links
|
||||
)
|
||||
assert all(not node.label for node in group.nodes)
|
||||
node_locations = {tuple(node.location) for node in depth_surface.nodes}
|
||||
assert len(node_locations) == len(depth_surface.nodes)
|
||||
depth_plane_locations = {tuple(node.location) for node in depth_plane.nodes}
|
||||
assert len(depth_plane_locations) == len(depth_plane.nodes)
|
||||
assert any(
|
||||
node.bl_idname == "GeometryNodeSetShadeSmooth"
|
||||
and node.inputs["Shade Smooth"].default_value
|
||||
for node in depth_surface.nodes
|
||||
)
|
||||
|
||||
|
||||
def test_image_layer_switches_between_tangent_and_object_space_normal():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
assert material.SURFACE_MATERIAL_NODE_GROUP_NAME == "O Image Layer"
|
||||
assert (
|
||||
material.MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME
|
||||
== material.BALLOON_MATERIAL_NODE_GROUP_NAME
|
||||
== material.IMAGE_MATERIAL_NODE_GROUP_NAME
|
||||
)
|
||||
group = material.material_node_group(
|
||||
surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME
|
||||
)
|
||||
normal = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Normal"
|
||||
)
|
||||
assert tuple(normal.default_value) == (0.5, 0.5, 1.0, 1.0)
|
||||
bump_scale = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Bump Scale"
|
||||
)
|
||||
assert bump_scale.default_value == 0.0
|
||||
normal_scale = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Normal Scale"
|
||||
)
|
||||
assert normal_scale.default_value == 1.0
|
||||
object_space = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Object Space"
|
||||
)
|
||||
assert object_space.default_value is False
|
||||
normal_maps = {
|
||||
node.space
|
||||
for node in group.nodes
|
||||
if node.bl_idname == "ShaderNodeNormalMap"
|
||||
}
|
||||
assert normal_maps == {"TANGENT", "OBJECT"}
|
||||
assert all(
|
||||
not node.label
|
||||
for node in group.nodes
|
||||
if node.bl_idname in {"ShaderNodeNormalMap", "ShaderNodeMix"}
|
||||
)
|
||||
|
||||
color = bpy.data.images.new("Color", width=2, height=2)
|
||||
normal_image = bpy.data.images.new("Normal", width=2, height=2)
|
||||
object_material = material.create_material(
|
||||
"Object Normal",
|
||||
color,
|
||||
1.0,
|
||||
surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME,
|
||||
normal_image=normal_image,
|
||||
normal_space="OBJECT",
|
||||
)
|
||||
layer = _group_node(group, object_material.node_tree)
|
||||
assert layer.inputs["Object Space"].default_value is True
|
||||
|
||||
tangent_material = material.create_material(
|
||||
"Tangent Normal",
|
||||
color,
|
||||
1.0,
|
||||
surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME,
|
||||
normal_image=normal_image,
|
||||
)
|
||||
layer = _group_node(group, tangent_material.node_tree)
|
||||
assert layer.inputs["Object Space"].default_value is False
|
||||
reused = material.material_node_group(
|
||||
surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME
|
||||
)
|
||||
assert {
|
||||
node.space
|
||||
for node in reused.nodes
|
||||
if node.bl_idname == "ShaderNodeNormalMap"
|
||||
} == {"TANGENT", "OBJECT"}
|
||||
|
||||
|
||||
def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Source", width=4096, height=2048)
|
||||
source = bpy.data.objects.new("Source", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
material = bpy.data.materials.new("Source.material")
|
||||
|
||||
result = mesh._build_mesh_object(
|
||||
bpy.context,
|
||||
source,
|
||||
mesh.PLANE,
|
||||
2,
|
||||
material,
|
||||
keep_original=True,
|
||||
)
|
||||
|
||||
assert result["anyimage_mesh_shape"] == mesh.PLANE
|
||||
assert len(result.data.vertices) == 4
|
||||
assert len(result.data.polygons) == 1
|
||||
assert all(polygon.use_smooth for polygon in result.data.polygons)
|
||||
assert mesh.PROFILE_ATTRIBUTE not in result.data.attributes
|
||||
assert mesh.DEPTH_ATTRIBUTE not in result.data.attributes
|
||||
assert mesh.FRONT_FACE_ATTRIBUTE not in result.data.attributes
|
||||
assert result.modifiers[0].node_group.name == "O Mesh Plane"
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
evaluated_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
assert len(evaluated_mesh.polygons) > 0
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
def test_depth_surface_protected_smooth_evaluates():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Surface", width=64, height=64)
|
||||
source = bpy.data.objects.new("Surface", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
surface_material = bpy.data.materials.new("Surface.material")
|
||||
|
||||
size = 8
|
||||
depth = np.ones((size, size), dtype=np.float32)
|
||||
x, y = np.meshgrid(np.arange(size), np.arange(size))
|
||||
intrinsics = np.asarray(
|
||||
((8.0, 0.0, 3.5), (0.0, 8.0, 3.5), (0.0, 0.0, 1.0)),
|
||||
dtype=np.float32,
|
||||
)
|
||||
points = np.empty((size, size, 3), dtype=np.float32)
|
||||
points[..., 0] = (x - intrinsics[0, 2]) / intrinsics[0, 0] * depth
|
||||
points[..., 1] = (y - intrinsics[1, 2]) / intrinsics[1, 1] * depth
|
||||
points[..., 2] = depth
|
||||
field = (
|
||||
points,
|
||||
intrinsics,
|
||||
np.ones((size, size), dtype=bool),
|
||||
(size, size),
|
||||
1.0,
|
||||
)
|
||||
|
||||
result = mesh._build_mesh_object(
|
||||
bpy.context,
|
||||
source,
|
||||
mesh.DEPTH_SURFACE,
|
||||
8,
|
||||
surface_material,
|
||||
field=field,
|
||||
regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),),
|
||||
image_size=(64, 64),
|
||||
keep_original=True,
|
||||
)
|
||||
|
||||
assert mesh.BOUNDARY_ATTRIBUTE in result.data.attributes
|
||||
assert mesh.DEPTH_PROTECTION_ATTRIBUTE in result.data.attributes
|
||||
modifier = result.modifiers[0]
|
||||
mesh.set_modifier_input(
|
||||
modifier,
|
||||
mesh.modifier_input_identifier(modifier.node_group, "Smooth"),
|
||||
0.0,
|
||||
)
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
baseline_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
baseline_z = np.asarray([vertex.co.z for vertex in baseline_mesh.vertices])
|
||||
baseline_min = baseline_z.min()
|
||||
baseline_max = baseline_z.max()
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
mesh.set_modifier_input(
|
||||
modifier,
|
||||
mesh.modifier_input_identifier(modifier.node_group, "Thickness"),
|
||||
1.0,
|
||||
)
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
evaluated_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
assert len(evaluated_mesh.polygons) > 0
|
||||
z = np.asarray([vertex.co.z for vertex in evaluated_mesh.vertices])
|
||||
assert np.isclose(z.max(), baseline_max, atol=1e-5)
|
||||
assert np.isclose(z.min(), baseline_min - 1.0, atol=1e-5)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
mesh.set_modifier_input(
|
||||
modifier,
|
||||
mesh.modifier_input_identifier(modifier.node_group, "Iterations"),
|
||||
4,
|
||||
)
|
||||
mesh.set_modifier_input(
|
||||
modifier,
|
||||
mesh.modifier_input_identifier(modifier.node_group, "Smooth"),
|
||||
1.0,
|
||||
)
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
internal_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
internal_positions = np.asarray(
|
||||
[tuple(vertex.co) for vertex in internal_mesh.vertices]
|
||||
)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
mesh.set_modifier_input(
|
||||
modifier,
|
||||
mesh.modifier_input_identifier(modifier.node_group, "Iterations"),
|
||||
0,
|
||||
)
|
||||
external_group = bpy.data.node_groups.new("External O Smooth", "GeometryNodeTree")
|
||||
external_group.is_modifier = True
|
||||
mesh._interface_socket(
|
||||
external_group, "Geometry", "OUTPUT", "NodeSocketGeometry"
|
||||
)
|
||||
mesh._interface_socket(
|
||||
external_group, "Geometry", "INPUT", "NodeSocketGeometry"
|
||||
)
|
||||
external_iterations = mesh._interface_socket(
|
||||
external_group, "Iterations", "INPUT", "NodeSocketInt"
|
||||
)
|
||||
external_iterations.default_value = 4
|
||||
external_iterations.min_value = 0
|
||||
external_smooth = mesh._interface_socket(
|
||||
external_group, "Smooth", "INPUT", "NodeSocketFloat"
|
||||
)
|
||||
external_smooth.default_value = 1.0
|
||||
external_input = external_group.nodes.new("NodeGroupInput")
|
||||
external_output = external_group.nodes.new("NodeGroupOutput")
|
||||
external_geometry = mesh._expand_smooth(
|
||||
external_group, external_input.outputs["Geometry"]
|
||||
)
|
||||
external_group.links.new(
|
||||
external_geometry, external_output.inputs["Geometry"]
|
||||
)
|
||||
external_modifier = result.modifiers.new("External O Smooth", "NODES")
|
||||
external_modifier.node_group = external_group
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
external_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
external_positions = np.asarray(
|
||||
[tuple(vertex.co) for vertex in external_mesh.vertices]
|
||||
)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
assert internal_positions.shape == external_positions.shape
|
||||
assert np.allclose(internal_positions, external_positions, atol=1e-6)
|
||||
|
||||
|
||||
def test_converted_color_images_are_new_and_keep_color_as_terminal_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)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
bpy.context.collection.objects.link(source)
|
||||
|
||||
first = mesh._copy_color_image(source)
|
||||
second = mesh._copy_color_image(source)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Local Cutout", width=64, height=48, alpha=True)
|
||||
pixels = np.ones((48, 64, 4), dtype=np.float32)
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
source = bpy.data.objects.new("Local Cutout", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
bpy.context.collection.objects.link(source)
|
||||
source.select_set(True)
|
||||
bpy.context.view_layer.objects.active = source
|
||||
selection = "[[-8,8],[72,8],[72,40],[-8,40]]"
|
||||
source_bounds = mesh.plane_bounds(source)
|
||||
|
||||
with patch.object(mesh, "push_undo") as undo:
|
||||
mesh._build_local_cutout(
|
||||
SimpleNamespace(mesh_detail=16),
|
||||
bpy.context,
|
||||
source,
|
||||
mesh.CUTOUT,
|
||||
selection,
|
||||
)
|
||||
|
||||
result = bpy.context.view_layer.objects.active
|
||||
assert result is not source
|
||||
assert result.type == "MESH"
|
||||
assert result["anyimage_mesh_shape"] == mesh.CUTOUT
|
||||
assert len(result.data.vertices) > 4
|
||||
assert len(result.data.polygons) > 1
|
||||
assert all(polygon.use_smooth for polygon in result.data.polygons)
|
||||
vertex_x = [vertex.co.x for vertex in result.data.vertices]
|
||||
assert min(vertex_x) < source_bounds[0]
|
||||
assert max(vertex_x) > source_bounds[1]
|
||||
assert bpy.data.objects.get(source.name) is source
|
||||
assert result.data.materials[0].node_tree is not None
|
||||
color_texture = next(
|
||||
node
|
||||
for node in result.data.materials[0].node_tree.nodes
|
||||
if node.bl_idname == "ShaderNodeTexImage"
|
||||
)
|
||||
assert color_texture.image is not image
|
||||
copied = np.empty(48 * 64 * 4, dtype=np.float32)
|
||||
color_texture.image.pixels.foreach_get(copied)
|
||||
assert np.allclose(copied.reshape((48, 64, 4)), pixels)
|
||||
undo.assert_called_once_with("Convert to Cutout")
|
||||
@@ -8,7 +8,6 @@ from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@@ -42,6 +41,8 @@ class ServerTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.server_module = load_server_module()
|
||||
importlib.import_module("server.jobs.balloon")
|
||||
importlib.import_module("server.jobs.mesh")
|
||||
from blendjob import JobServer
|
||||
from server.model_manager import ModelManager, ModelSessionCache
|
||||
|
||||
@@ -50,6 +51,7 @@ class ServerTest(unittest.TestCase):
|
||||
cls.ModelManager, cls.ModelSessionCache = ModelManager, ModelSessionCache
|
||||
cls.server = cls.server_module.server
|
||||
cls.app_module = sys.modules["server.app"]
|
||||
|
||||
def setUp(self):
|
||||
self.jobs_temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.jobs_temp.cleanup)
|
||||
@@ -89,8 +91,7 @@ class ServerTest(unittest.TestCase):
|
||||
{
|
||||
"loaded": {
|
||||
"ben2": "",
|
||||
"da3": "",
|
||||
"moge2": "",
|
||||
"geometry": "",
|
||||
"upscale": "",
|
||||
}
|
||||
},
|
||||
@@ -99,9 +100,7 @@ class ServerTest(unittest.TestCase):
|
||||
self.assertNotIn("model_catalog", sys.modules)
|
||||
|
||||
def create_cache(self):
|
||||
cache = self.ModelSessionCache.__new__(
|
||||
self.ModelSessionCache
|
||||
)
|
||||
cache = self.ModelSessionCache.__new__(self.ModelSessionCache)
|
||||
cache.ben2_model = None
|
||||
cache.ben2_key = None
|
||||
cache.da3_model = None
|
||||
@@ -114,9 +113,7 @@ class ServerTest(unittest.TestCase):
|
||||
|
||||
def create_server(self):
|
||||
source = self.server_module.server
|
||||
server = self.JobServer(
|
||||
"AnyImage Job Server", storage_root=self.storage_root
|
||||
)
|
||||
server = self.JobServer("AnyImage Job Server", storage_root=self.storage_root)
|
||||
server.handlers.update(source.handlers)
|
||||
server.resource_factories.update(source.resource_factories)
|
||||
server.bind(self.storage_root)
|
||||
@@ -168,14 +165,19 @@ class ServerTest(unittest.TestCase):
|
||||
|
||||
self.assertIs(first, second)
|
||||
self.assertEqual(loads, [("model", "cuda")])
|
||||
self.assertEqual(cache.snapshot()["da3"], "model")
|
||||
self.assertEqual(cache.snapshot()["geometry"], "model")
|
||||
|
||||
def test_model_cache_keeps_only_the_most_recent_family(self):
|
||||
def test_model_cache_keeps_three_categories(self):
|
||||
cache = self.create_cache()
|
||||
cache.ben2_model = object()
|
||||
ben2 = object()
|
||||
old_moge = object()
|
||||
upscale = object()
|
||||
cache.ben2_model = ben2
|
||||
cache.ben2_key = ("ben2", "cuda")
|
||||
cache.moge_model = object()
|
||||
cache.moge_key = ("moge", "cuda")
|
||||
cache.moge_model = old_moge
|
||||
cache.moge_key = ("old-moge", "cuda")
|
||||
cache.upscale_model = upscale
|
||||
cache.upscale_key = ("upscale", "cuda")
|
||||
device = SimpleNamespace(type="cuda")
|
||||
da3 = object()
|
||||
|
||||
@@ -187,18 +189,21 @@ class ServerTest(unittest.TestCase):
|
||||
with patch.object(cache, "_load_da3", side_effect=load_da3):
|
||||
self.assertIs(cache.get_da3("da3", device), da3)
|
||||
|
||||
self.assertIs(cache.ben2_model, ben2)
|
||||
self.assertIsNone(cache.moge_model)
|
||||
self.assertIsNone(cache.ben2_model)
|
||||
|
||||
moge = object()
|
||||
self.assertIs(cache.upscale_model, upscale)
|
||||
self.assertEqual(cache.snapshot()["geometry"], "da3")
|
||||
|
||||
new_moge = object()
|
||||
fake_moge = ModuleType("server.models.onnx_moge2")
|
||||
fake_moge.create_session = lambda _model_dir, _device: moge
|
||||
fake_moge.create_session = lambda _model_dir, _device: new_moge
|
||||
with patch.dict(sys.modules, {"server.models.onnx_moge2": fake_moge}):
|
||||
self.assertIs(cache.get_moge2("moge", "cuda"), moge)
|
||||
self.assertIs(cache.get_moge2("new-moge", "cuda"), new_moge)
|
||||
|
||||
self.assertIs(cache.ben2_model, ben2)
|
||||
self.assertIsNone(cache.da3_model)
|
||||
self.assertIsNone(cache.ben2_model)
|
||||
self.assertIs(cache.upscale_model, upscale)
|
||||
self.assertEqual(cache.snapshot()["geometry"], "new-moge")
|
||||
|
||||
def test_upscale_model_is_reused_for_matching_runtime_key(self):
|
||||
loads = []
|
||||
@@ -254,13 +259,11 @@ class ServerTest(unittest.TestCase):
|
||||
{
|
||||
"download-model",
|
||||
"download-required-models",
|
||||
"analyze-balloon",
|
||||
"refine-image-mask",
|
||||
"remove-background",
|
||||
"upscale-image",
|
||||
"extract-frames",
|
||||
"generate-depth",
|
||||
"generate-surface",
|
||||
"generate-mesh",
|
||||
"debug-da3",
|
||||
"debug-moge2",
|
||||
"debug-ben2",
|
||||
@@ -438,6 +441,54 @@ class ServerTest(unittest.TestCase):
|
||||
self.assertEqual(tuple(result[0, 0]), (20, 40, 60, 64))
|
||||
self.assertEqual(tuple(result[3, 3]), (0, 0, 0, 0))
|
||||
|
||||
def test_polygon_selection_mask_uses_even_odd_fill_for_self_intersection(self):
|
||||
image_mask = sys.modules["server.jobs.image_mask"]
|
||||
polygon = (
|
||||
(1, 1),
|
||||
(9, 1),
|
||||
(9, 9),
|
||||
(1, 9),
|
||||
(1, 1),
|
||||
(3, 3),
|
||||
(3, 7),
|
||||
(7, 7),
|
||||
(7, 3),
|
||||
(3, 3),
|
||||
(1, 1),
|
||||
)
|
||||
|
||||
mask = image_mask.polygon_selection_mask((10, 10), polygon)
|
||||
|
||||
self.assertTrue(mask[2, 2])
|
||||
self.assertFalse(mask[4, 4])
|
||||
self.assertEqual(int(mask.sum()), 48)
|
||||
|
||||
def test_polygon_selection_mask_nonzero_keeps_same_direction_overlap(self):
|
||||
image_mask = sys.modules["server.jobs.image_mask"]
|
||||
polygon = (
|
||||
(1, 1),
|
||||
(7, 1),
|
||||
(7, 7),
|
||||
(1, 7),
|
||||
(1, 1),
|
||||
(4, 1),
|
||||
(10, 1),
|
||||
(10, 7),
|
||||
(4, 7),
|
||||
(4, 1),
|
||||
(1, 1),
|
||||
)
|
||||
|
||||
even_odd = image_mask.polygon_selection_mask((12, 9), polygon)
|
||||
nonzero = image_mask.polygon_selection_mask(
|
||||
(12, 9), polygon, "NONZERO"
|
||||
)
|
||||
|
||||
self.assertTrue(even_odd[3, 2])
|
||||
self.assertFalse(even_odd[3, 5])
|
||||
self.assertTrue(nonzero[3, 2])
|
||||
self.assertTrue(nonzero[3, 5])
|
||||
self.assertTrue(nonzero[3, 8])
|
||||
def test_image_mask_canvas_clears_rgba_outside_the_selection(self):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
@@ -475,6 +526,288 @@ class ServerTest(unittest.TestCase):
|
||||
self.assertEqual(result.shape, (2, 2, 4))
|
||||
self.assertTrue(np.all(result[:, :, 3] == 255))
|
||||
|
||||
def test_mesh_refine_selection_keeps_source_size_and_selection_alpha(self):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
mesh_job = sys.modules["server.jobs.mesh"]
|
||||
source_path = self.storage_root / "source.png"
|
||||
pixels = np.full((6, 8, 4), (20, 40, 60, 255), dtype=np.uint8)
|
||||
Image.fromarray(pixels, mode="RGBA").save(source_path)
|
||||
job_directory = self.storage_root / "job"
|
||||
job_directory.mkdir()
|
||||
context = SimpleNamespace(
|
||||
directory=job_directory,
|
||||
resource=lambda _name: SimpleNamespace(
|
||||
directory=lambda _model: Path("model")
|
||||
),
|
||||
check_cancelled=lambda: None,
|
||||
)
|
||||
|
||||
def passthrough(path, *_args):
|
||||
return Image.open(path).convert("RGBA"), 0.0, 0.0
|
||||
|
||||
output_path = job_directory / "image.0001.png"
|
||||
with patch.object(mesh_job, "remove_image_background", side_effect=passthrough):
|
||||
mesh_job._write_refined_color(
|
||||
context,
|
||||
{"refine_model": "BEN2_BASE", "device": "cpu"},
|
||||
source_path,
|
||||
((1, 1), (6, 1), (1, 5)),
|
||||
output_path,
|
||||
)
|
||||
result = np.asarray(Image.open(output_path).convert("RGBA"))
|
||||
|
||||
self.assertEqual(result.shape, (6, 8, 4))
|
||||
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):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
mesh_job = sys.modules["server.jobs.mesh"]
|
||||
source_path = self.storage_root / "mesh-source.png"
|
||||
pixels = np.full((6, 8, 4), (20, 40, 60, 255), dtype=np.uint8)
|
||||
Image.fromarray(pixels, mode="RGBA").save(source_path)
|
||||
job_directory = self.storage_root / "mesh-job"
|
||||
context = SimpleNamespace(
|
||||
directory=job_directory,
|
||||
progress=lambda *_args: None,
|
||||
check_cancelled=lambda: None,
|
||||
resource=lambda _name: object(),
|
||||
)
|
||||
normal = np.zeros((1, 6, 8, 3), dtype=np.float32)
|
||||
normal[..., 0] = 1.0
|
||||
prediction = SimpleNamespace(normal=normal)
|
||||
received = []
|
||||
received_images = []
|
||||
|
||||
def infer(_manager, parameters, images, **_kwargs):
|
||||
received.append(parameters)
|
||||
received_images.extend(images)
|
||||
return prediction
|
||||
|
||||
with patch.object(mesh_job.moge2, "infer", side_effect=infer):
|
||||
result = mesh_job.run(
|
||||
context,
|
||||
{
|
||||
"input": str(source_path),
|
||||
"shape": "BALLOON",
|
||||
"polygon": ((2, 1), (7, 1), (7, 5), (2, 5)),
|
||||
"mesh_detail": 64,
|
||||
"invert": False,
|
||||
"device": "cpu",
|
||||
"normal_model": "MOGE2_VITS_NORMAL",
|
||||
"normal_resolution_level": 5,
|
||||
"normal_analysis": True,
|
||||
},
|
||||
)
|
||||
|
||||
color_path = job_directory / "image.0001.png"
|
||||
color_pixels = np.asarray(Image.open(color_path).convert("RGBA"))
|
||||
normal_pixels = np.asarray(
|
||||
Image.open(job_directory / result["normal"]).convert("RGB")
|
||||
)
|
||||
self.assertEqual(result, {"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))
|
||||
|
||||
def test_mesh_job_skips_moge_normal_when_option_is_disabled(self):
|
||||
from PIL import Image
|
||||
|
||||
mesh_job = sys.modules["server.jobs.mesh"]
|
||||
source_path = self.storage_root / "mesh-no-normal-source.png"
|
||||
Image.new("RGBA", (8, 6), (20, 40, 60, 255)).save(source_path)
|
||||
job_directory = self.storage_root / "mesh-no-normal-job"
|
||||
context = SimpleNamespace(
|
||||
directory=job_directory,
|
||||
progress=lambda *_args: None,
|
||||
check_cancelled=lambda: None,
|
||||
resource=lambda _name: object(),
|
||||
)
|
||||
|
||||
with patch.object(mesh_job, "_write_camera_normal") as write_normal:
|
||||
result = mesh_job.run(
|
||||
context,
|
||||
{
|
||||
"input": str(source_path),
|
||||
"shape": "BALLOON",
|
||||
"polygon": ((0, 0), (8, 0), (8, 6), (0, 6)),
|
||||
"mesh_detail": 64,
|
||||
"normal_analysis": False,
|
||||
"invert": False,
|
||||
"device": "cpu",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(result, {})
|
||||
write_normal.assert_not_called()
|
||||
self.assertFalse((job_directory / "normal.png").exists())
|
||||
|
||||
def test_mesh_depth_balloon_analyzes_full_source_then_applies_selection(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)
|
||||
job_directory = self.storage_root / "mesh-depth-balloon-job"
|
||||
job_directory.mkdir()
|
||||
context = SimpleNamespace(
|
||||
directory=job_directory,
|
||||
check_cancelled=lambda: None,
|
||||
resource=lambda _name: object(),
|
||||
)
|
||||
received = []
|
||||
|
||||
def infer(_manager, _parameters, images, **_kwargs):
|
||||
received.extend(images)
|
||||
depth = np.linspace(1.0, 2.0, 36, dtype=np.float32).reshape(6, 6)
|
||||
normal = np.zeros((1, 6, 6, 3), dtype=np.float32)
|
||||
normal[..., 0] = 1.0
|
||||
return SimpleNamespace(
|
||||
depth=depth[None],
|
||||
mask=np.ones((1, 6, 6), dtype=bool),
|
||||
normal=normal,
|
||||
)
|
||||
|
||||
with patch.object(mesh_job.moge2, "infer", side_effect=infer):
|
||||
depth_path, normal_path = mesh_job._write_balloon_depth(
|
||||
context,
|
||||
{"device": "cpu"},
|
||||
source_path,
|
||||
((2, 2), (10, 2), (2, 10)),
|
||||
selection_size=(12, 12),
|
||||
include_normal=True,
|
||||
)
|
||||
|
||||
self.assertEqual(received, [source_path])
|
||||
with np.load(depth_path, allow_pickle=False) as data:
|
||||
self.assertEqual(tuple(data["origin"]), (0, 0))
|
||||
self.assertEqual(tuple(data["crop_size"]), (6, 6))
|
||||
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[2, 2]), (255, 127, 127))
|
||||
|
||||
def test_mesh_job_uses_upscale_source_only_for_analysis(self):
|
||||
from PIL import Image
|
||||
|
||||
mesh_job = sys.modules["server.jobs.mesh"]
|
||||
display_path = self.storage_root / "upscaled.png"
|
||||
analysis_path = self.storage_root / "original.png"
|
||||
Image.new("RGBA", (12, 8), (200, 20, 40, 255)).save(display_path)
|
||||
Image.new("RGBA", (3, 2), (10, 80, 160, 255)).save(analysis_path)
|
||||
job_directory = self.storage_root / "mesh-upscale-source-job"
|
||||
context = SimpleNamespace(
|
||||
directory=job_directory,
|
||||
progress=lambda *_args: None,
|
||||
check_cancelled=lambda: None,
|
||||
)
|
||||
field_path = job_directory / "field.npz"
|
||||
|
||||
with patch.object(
|
||||
mesh_job,
|
||||
"_write_surface_depth",
|
||||
return_value=(field_path, None),
|
||||
) as write_surface:
|
||||
result = mesh_job.run(
|
||||
context,
|
||||
{
|
||||
"input": str(display_path),
|
||||
"analysis_input": str(analysis_path),
|
||||
"shape": "DEPTH_SURFACE",
|
||||
"normal_analysis": False,
|
||||
"video_frames": 1,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"field": "field.npz"})
|
||||
self.assertEqual(write_surface.call_args.args[2], analysis_path)
|
||||
with Image.open(job_directory / "image.0001.png") as color:
|
||||
self.assertEqual(color.size, (12, 8))
|
||||
|
||||
def test_mesh_masked_color_keeps_same_direction_overlap(self):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
mesh_job = sys.modules["server.jobs.mesh"]
|
||||
source_path = self.storage_root / "opaque-overlap.png"
|
||||
output_path = self.storage_root / "cutout-overlap.png"
|
||||
Image.new("RGBA", (12, 9), (20, 40, 60, 255)).save(source_path)
|
||||
polygon = (
|
||||
(1, 1),
|
||||
(7, 1),
|
||||
(7, 7),
|
||||
(1, 7),
|
||||
(1, 1),
|
||||
(4, 1),
|
||||
(10, 1),
|
||||
(10, 7),
|
||||
(4, 7),
|
||||
(4, 1),
|
||||
(1, 1),
|
||||
)
|
||||
|
||||
mesh_job._write_masked_color(source_path, polygon, output_path)
|
||||
alpha = np.asarray(Image.open(output_path).convert("RGBA"))[:, :, 3]
|
||||
|
||||
self.assertGreater(int(alpha[3, 2]), 200)
|
||||
self.assertGreater(int(alpha[3, 5]), 200)
|
||||
self.assertGreater(int(alpha[3, 8]), 200)
|
||||
def test_mesh_masked_color_preserves_nonzero_winding_hole(self):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
mesh_job = sys.modules["server.jobs.mesh"]
|
||||
source_path = self.storage_root / "opaque-hole.png"
|
||||
output_path = self.storage_root / "cutout-hole.png"
|
||||
Image.new("RGBA", (10, 10), (20, 40, 60, 255)).save(source_path)
|
||||
polygon = (
|
||||
(1, 1),
|
||||
(9, 1),
|
||||
(9, 9),
|
||||
(1, 9),
|
||||
(1, 1),
|
||||
(3, 3),
|
||||
(3, 7),
|
||||
(7, 7),
|
||||
(7, 3),
|
||||
(3, 3),
|
||||
(1, 1),
|
||||
)
|
||||
|
||||
mesh_job._write_masked_color(source_path, polygon, output_path)
|
||||
alpha = np.asarray(Image.open(output_path).convert("RGBA"))[:, :, 3]
|
||||
|
||||
self.assertGreater(int(alpha[2, 2]), 200)
|
||||
self.assertLess(int(alpha[4, 4]), 32)
|
||||
|
||||
def test_mesh_masked_color_antialiases_the_cutout_edge(self):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
mesh_job = sys.modules["server.jobs.mesh"]
|
||||
source_path = self.storage_root / "opaque.png"
|
||||
output_path = self.storage_root / "cutout.png"
|
||||
Image.new("RGBA", (16, 16), (20, 40, 60, 255)).save(source_path)
|
||||
|
||||
mesh_job._write_masked_color(
|
||||
source_path,
|
||||
((1, 1), (14, 1), (1, 14)),
|
||||
output_path,
|
||||
)
|
||||
alpha = np.asarray(Image.open(output_path).convert("RGBA"))[:, :, 3]
|
||||
|
||||
def test_balloon_job_sends_the_masked_crop_to_ben2(self):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
@@ -587,6 +920,7 @@ class ServerTest(unittest.TestCase):
|
||||
np.testing.assert_allclose(farther[3], original[3], atol=2e-4)
|
||||
np.testing.assert_allclose(rescaled[0], original[0], atol=2e-4)
|
||||
np.testing.assert_allclose(rescaled[3], original[3], atol=2e-4)
|
||||
|
||||
def test_balloon_depth_job_sends_masked_crop_only_to_moge(self):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
@@ -99,13 +99,14 @@ def main():
|
||||
material_group = material_node_group(
|
||||
surface_group_name=BALLOON_MATERIAL_NODE_GROUP_NAME,
|
||||
)
|
||||
normal_map = next(
|
||||
node
|
||||
normal_maps = {
|
||||
node.space
|
||||
for node in material_group.nodes
|
||||
if node.bl_idname == "ShaderNodeNormalMap"
|
||||
)
|
||||
assert normal_map.space == "TANGENT"
|
||||
assert abs(input_socket(material_group, "Normal Strength").default_value - 0.5) < 1e-6
|
||||
}
|
||||
assert normal_maps == {"TANGENT", "OBJECT"}
|
||||
assert input_socket(material_group, "Object Space").default_value is False
|
||||
assert input_socket(material_group, "Normal Scale").default_value == 1.0
|
||||
|
||||
source_image = bpy.data.images.new("Reuse Source", 2, 1)
|
||||
shared_normal = bpy.data.images.new("Shared Normal", 2, 1)
|
||||
@@ -115,8 +116,6 @@ def main():
|
||||
material = bpy.data.materials.new("Unrelated Material Name")
|
||||
mark_balloon_material(material, source_image, shared_normal)
|
||||
assert reusable_balloon_material(
|
||||
"unused",
|
||||
"unused",
|
||||
source_image,
|
||||
normal_required=True,
|
||||
) is material
|
||||
|
||||
@@ -285,14 +285,37 @@ def main():
|
||||
and node.node_tree == surface_group
|
||||
]
|
||||
assert len(surface_group_nodes) == 1
|
||||
assert surface_group_nodes[0].name == "O DA3 Surface Layer"
|
||||
assert surface_group_nodes[0].name == "O Image Layer"
|
||||
surface_inputs = [
|
||||
item.name
|
||||
for item in surface_group.interface.items_tree
|
||||
if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
]
|
||||
assert surface_inputs == ["Color", "Alpha", "Bump Scale"]
|
||||
assert surface_inputs == [
|
||||
"Color",
|
||||
"Alpha",
|
||||
"Normal",
|
||||
"Normal Scale",
|
||||
"Object Space",
|
||||
"Bump Scale",
|
||||
]
|
||||
normal_input = next(
|
||||
item
|
||||
for item in surface_group.interface.items_tree
|
||||
if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Normal"
|
||||
)
|
||||
assert tuple(normal_input.default_value) == (0.5, 0.5, 1.0, 1.0)
|
||||
normal_scale = next(
|
||||
item
|
||||
for item in surface_group.interface.items_tree
|
||||
if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Normal Scale"
|
||||
)
|
||||
assert normal_scale.default_value == 1.0
|
||||
surface_outputs = [
|
||||
item.name
|
||||
for item in surface_group.interface.items_tree
|
||||
|
||||
@@ -5,7 +5,6 @@ from pathlib import Path
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||
sys.path.insert(
|
||||
@@ -142,14 +141,14 @@ def main():
|
||||
depth_edge_socket = next(
|
||||
item
|
||||
for item in surface.modifiers[0].node_group.interface.items_tree
|
||||
if getattr(item, "in_out", None) == "INPUT"
|
||||
and item.name == "Depth Continuity"
|
||||
if getattr(item, "in_out", None) == "INPUT" and item.name == "Depth Continuity"
|
||||
)
|
||||
assert depth_edge_socket.default_value == 50.0
|
||||
assert depth_edge_socket.subtype == "PERCENTAGE"
|
||||
assert depth_edge_socket.structure_type == "SINGLE"
|
||||
culling_panel = next(
|
||||
item for item in surface.modifiers[0].node_group.interface.items_tree
|
||||
item
|
||||
for item in surface.modifiers[0].node_group.interface.items_tree
|
||||
if item.item_type == "PANEL" and item.name == "Culling"
|
||||
)
|
||||
assert [item.name for item in culling_panel.interface_items] == [
|
||||
@@ -157,7 +156,8 @@ def main():
|
||||
"Mask",
|
||||
]
|
||||
input_sockets = [
|
||||
item for item in surface.modifiers[0].node_group.interface.items_tree
|
||||
item
|
||||
for item in surface.modifiers[0].node_group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"
|
||||
]
|
||||
assert all(item.structure_type == "SINGLE" for item in input_sockets)
|
||||
@@ -179,22 +179,34 @@ def main():
|
||||
assert MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME in bpy.data.node_groups
|
||||
material_group = bpy.data.node_groups[MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME]
|
||||
assert any(node.bl_idname == "ShaderNodeBump" for node in material_group.nodes)
|
||||
normal_map = next(
|
||||
node for node in material_group.nodes
|
||||
if node.bl_idname == "ShaderNodeNormalMap"
|
||||
)
|
||||
assert normal_map.space == "OBJECT"
|
||||
normal_maps = [
|
||||
node for node in material_group.nodes if node.bl_idname == "ShaderNodeNormalMap"
|
||||
]
|
||||
assert {node.space for node in normal_maps} == {"TANGENT", "OBJECT"}
|
||||
normal_strength = next(
|
||||
item for item in material_group.interface.items_tree
|
||||
if getattr(item, "in_out", None) == "INPUT"
|
||||
and item.name == "Normal Strength"
|
||||
item
|
||||
for item in material_group.interface.items_tree
|
||||
if getattr(item, "in_out", None) == "INPUT" and item.name == "Normal Scale"
|
||||
)
|
||||
assert normal_strength.default_value == 1.0
|
||||
assert normal_strength.min_value == 0.0
|
||||
assert normal_map.inputs["Strength"].is_linked
|
||||
normal_input = next(
|
||||
item
|
||||
for item in material_group.interface.items_tree
|
||||
if getattr(item, "in_out", None) == "INPUT" and item.name == "Normal"
|
||||
)
|
||||
assert tuple(normal_input.default_value) == (0.5, 0.5, 1.0, 1.0)
|
||||
assert all(node.inputs["Strength"].is_linked for node in normal_maps)
|
||||
layer = next(
|
||||
node
|
||||
for node in surface.data.materials[0].node_tree.nodes
|
||||
if node.bl_idname == "ShaderNodeGroup" and node.node_tree == material_group
|
||||
)
|
||||
assert layer.inputs["Object Space"].default_value is True
|
||||
assert not surface.data.has_custom_normals
|
||||
color_texture = next(
|
||||
node for node in surface.data.materials[0].node_tree.nodes
|
||||
node
|
||||
for node in surface.data.materials[0].node_tree.nodes
|
||||
if node.bl_idname == "ShaderNodeTexImage" and node.image == foreground
|
||||
)
|
||||
assert color_texture.image.name == "MoGe Surface Test.color"
|
||||
@@ -202,7 +214,8 @@ def main():
|
||||
bpy.context.scene
|
||||
)
|
||||
normal_texture = next(
|
||||
node for node in surface.data.materials[0].node_tree.nodes
|
||||
node
|
||||
for node in surface.data.materials[0].node_tree.nodes
|
||||
if node.bl_idname == "ShaderNodeTexImage"
|
||||
and node.image.colorspace_settings.name == "Non-Color"
|
||||
)
|
||||
@@ -243,12 +256,17 @@ def validate_real_result(results_path, image_path):
|
||||
assert not surface.data.has_custom_normals
|
||||
material_group = bpy.data.node_groups[MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME]
|
||||
assert any(node.bl_idname == "ShaderNodeBump" for node in material_group.nodes)
|
||||
normal_map = next(
|
||||
node for node in material_group.nodes
|
||||
if node.bl_idname == "ShaderNodeNormalMap"
|
||||
normal_maps = [
|
||||
node for node in material_group.nodes if node.bl_idname == "ShaderNodeNormalMap"
|
||||
]
|
||||
assert {node.space for node in normal_maps} == {"TANGENT", "OBJECT"}
|
||||
assert all(node.inputs["Strength"].is_linked for node in normal_maps)
|
||||
layer = next(
|
||||
node
|
||||
for node in surface.data.materials[0].node_tree.nodes
|
||||
if node.bl_idname == "ShaderNodeGroup" and node.node_tree == material_group
|
||||
)
|
||||
assert normal_map.space == "OBJECT"
|
||||
assert normal_map.inputs["Strength"].is_linked
|
||||
assert layer.inputs["Object Space"].default_value is True
|
||||
attribute_names = set(surface.data.attributes.keys())
|
||||
assert {"o_depth", "o_mask"}.issubset(attribute_names)
|
||||
assert "o_depth_discontinuity" in attribute_names
|
||||
|
||||
Reference in New Issue
Block a user