diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..44abbd0 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# 复制为 .env,并填写本机 Blender 可执行文件路径。 +# Windows 路径可使用正斜杠。 +BLENDER_BIN="C:/Program Files/Blender Foundation/Blender 4.5/blender.exe" diff --git a/AGENTS.md b/AGENTS.md index 40c10f2..73dadbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ - 说明性内容以中文为主,专有名词和约定俗成的表达可使用英文 - 修改代码、依赖或执行方式后, - 不保留旧路径、转发函数、兼容层或胶水代码 - - 更新并运行相关测试,但不主动同步说明文档、构建文档或打包扩展 + - 仅更新并运行相关测试,但不主动同步说明文档、构建文档或打包扩展 - 优先选择依赖少、直接、易验证的实现 - 复用来自 `common` 的函数,避免重复造轮子 - 确保文件名/文件夹名和内容边界一致 @@ -75,7 +75,7 @@ uv run sync-models - `README.md` 面向用户,不在其中堆叠内部细节。实现原理放入 `docs/internals` 对应主题 - 文档只正面描述当前主题的职责与实现,不记录无关模块、未采用路径、历史差异、边界辩解、“不做什么”的排除性信息。 -- 实现原理用流程图替代文字,Mermaid 默认使用从上到下 +- 根据内容选择最清晰的表达方式,优先简洁文字;复杂流程或关系在有助于理解时使用图表。Mermaid 流程图默认从上到下。 ## Blender 开发规范 diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index 82e57e1..b0b86a0 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -3,7 +3,7 @@ | 模块 | 职责 | | --- | --- | | `__init__.py` | 注册类型、工具、菜单、快捷键与 Runtime | -| `menu.py`、`panel.py` | Image Empty 上下文菜单与 Job Server 面板 | +| `menu.py`、`panel.py` | Image Empty 与 Shader Image Texture 上下文菜单与 Job Server 面板 | | `preferences.py` | 扩展设置及读取入口 | | `properties.py` | Scene 手势状态、视频帧数、模型状态与枚举 | | `common/` | Blender 侧公共能力 | @@ -12,15 +12,6 @@ ## 注册与配置 -```mermaid -flowchart TD - A[register] --> B[按 CLASSES 顺序注册类型] - B --> C[注册 Frame、Mask、Rectify、Cutout 工具] - C --> D[挂载 Scene.anyimage_settings] - D --> E[挂载 View3D 与 Outliner 菜单及剪贴板快捷键] - E --> F[注册 JobRuntime] -``` - 注销先关闭 Runtime 和交互入口,再移除 Scene 属性,按 `CLASSES` 逆序注销类型。设备、存储目录、模型、材质显示适配和功能上限统一通过 `preferences.py` 读取。 **Maximum AI Input Size** 限制模型分析图和 Upscale 可提交源图;**Maximum Frame Resolution** 限制 Frame 输出;**Maximum Cutout Mesh Resolution** 限制轮廓网格采样。三者各自作用于对应数据阶段。 @@ -40,4 +31,4 @@ flowchart TD Plane、Frame、Mask、Rectify 和无需 AI 的 Cutout 在 Blender 内执行。Server 的 `jobs/` 编排任务,`models/` 负责模型适配,`geometry/` 处理深度与法线产物。Server 模块通过内部相对导入共享能力,Blender 侧通过 `common/` 复用。 -MoGe-2 输出统一为 `GeometryFrame`:包含 Depth、Validity、Intrinsics,以及按需提供的 Normal 和 Points。产物写入器消费同一个 Frame,保持深度、法线和相机数据一致。参见 [Runtime](runtime.md) 与 [产物协议](outputs.md)。 +MoGe-2 与 MoGe-3 输出统一为 `GeometryFrame`:包含 Depth、Validity、Intrinsics,以及按需提供的 Normal 和 Points。产物写入器消费同一个 Frame,保持深度、法线和相机数据一致。参见 [Runtime](runtime.md) 与 [产物协议](outputs.md)。 diff --git a/docs/internals/common.md b/docs/internals/common.md index 578d43f..b7c4a85 100644 --- a/docs/internals/common.md +++ b/docs/internals/common.md @@ -6,6 +6,9 @@ | --- | --- | | `ai.py` | 环境和模型就绪检查、输入路径校验、`production_device()`、`moge2_parameters()` 与安装 UI | | `image.py` | `image_rgba()`、动画帧设置、Job 输入准备、像素采样、结果 Image 和 `replace_empty_image()` | +| `color_image.py` | 材质颜色与 AI 分析输入的准备和清理 | +| `hdr_image.py` | HDR 去背景分析输入及浮点颜色保留 | +| `image_target.py` | Image Empty 与 Shader Image Texture 编辑目标 | | `selection.py` | `SelectionPath`、`SelectionMask`、non-zero winding 栅格化与 Alpha 修整 | | `viewport.py` | 原生点选、屏幕投影、`ImageGesture`、Lasso / Brush / Polyline 与 Overlay | | `depth.py` | 深度图片和元数据加载、相机坐标与显示范围的尺度换算 | @@ -15,22 +18,9 @@ ## 图像生命周期 -```mermaid -flowchart TD - A[Image 与 Image Empty] --> B[image_rgba 读取业务像素] - B --> C{处理位置} - C -- 本地 --> D[像素算法] - C -- Server --> E[prepare_image_input 或 prepare_image_bounds_input] - E --> F[Job 输出文件] - F --> G[加载并 Pack] - D --> G - G --> H[replace_empty_image] - H --> I[保留身份、共享引用、帧设置和画框] -``` - 业务像素采用 Top-down RGBA,`image_pixels()` 使用 Blender 像素顺序。预乘采样用于投影插值;文件或 Packed 图像的业务读取保留隐藏 RGB。直接编辑结果通过统一入口处理共享 Image、失败恢复和源数据清理。 -`prepare_image_input()` 返回路径及临时标记;`cleanup_image_input()` 与创建方配对。Cutout 的 `prepare_image_bounds_input()` 复用已读取的源 RGBA,输出指定几何 bounds 的矩形图片。 +`prepare_image_input()` 返回路径及临时标记;`cleanup_image_input()` 与创建方配对。`color_image.py` 的 `prepare_material_color_input()` 准备材质颜色,`material_analysis_input()` 提供分析输入;Cutout 按几何 bounds 裁切。 ## 手势与 Selection diff --git a/docs/internals/development.md b/docs/internals/development.md index c8b5711..e102869 100644 --- a/docs/internals/development.md +++ b/docs/internals/development.md @@ -15,11 +15,11 @@ uv run pytest | Frame、Mask、Rectify、Plane、Cutout | 对应 `test_*_tool.py`、`test_*_operators.py` 与对象、投影、网格测试 | | 图像身份、Alpha、材质与 Undo | 图像编辑集成、共享替换、材质适配与 Undo 测试 | | Server | `test_job_server.py`、`test_server_jobs.py`、`test_media_input.py`、`test_geometry_artifacts.py` | -| 模型 | `test_model_manager.py`、目录、下载、推理与各 ONNX 模型测试 | -| 节点资产 | 布局检查、Depth Plane 模式、深度分离与 Cutout 体积求值 | -| 工具与包协议 | 扩展 Manifest、打包、模型同步、节点启动器和文档命令测试 | +| 模型 | `test_model_manager.py`、下载、推理与各 ONNX 模型测试 | +| 节点资产 | 布局前后功能等价、深度分离与 Cutout 体积求值 | +| 工具与包协议 | 扩展 Manifest、打包、模型同步与节点启动器 | -Blender Stub 共用 `blender_test_support.py`;Server 和实际 Blender 几何 fixture 按使用范围存放在对应 support 模块。测试命名描述功能、触发条件与可观察结果。 +Blender Stub 共用 `tests/support/blender.py`;Server 和实际 Blender 几何 fixture 按使用范围存放在对应 support 模块。测试命名描述功能、触发条件与可观察结果。 ## 发布命令 @@ -27,14 +27,14 @@ Blender Stub 共用 `blender_test_support.py`;Server 和实际 Blender 几何 | --- | --- | | `uv run pack` | 生成 Windows、macOS arm64、Linux 扩展包 | | `uv run pack --prepare` | 为本机同步扩展 wheel 与 Manifest | -| `uv run build-nodes` | 构建、验证节点资产并运行测试 | -| `uv run sync-models` | 准备、校验并上传模型声明的文件 | +| `uv run node-group build` | 构建、验证节点资产并运行测试 | +| `uv run model sync` | 准备、校验并上传模型声明的文件 | | `uv run docs -dev` | 本地预览文档 | | `uv run docs` | 通过 Mike 发布项目版本并更新 latest | `tools/pack.py` 从 `pyproject.toml` 读取依赖,按平台与 Blender Python 版本解析 wheel,使用 Blender 提供的 NumPy。产物命名为 `AnyImage.v..zip`,写入 `dist/`。本地 BlendJob wheel 内容变化后运行 `uv lock --refresh`,并核对锁文件、Manifest 与打包测试。 -`tools/models/cli.py` 根据模型目录选择上游 ONNX 或本地导出流程;`tools/models/exports.py` 处理对应 PyTorch 权重导出。文件校验依据声明大小和 SHA-256,上传使用逐文件复制。`--prepare-only` 用于本地准备。 +`tools/models/cli.py` 根据模型目录选择上游 ONNX 或本地导出流程;`tools/models/exports.py` 处理对应 PyTorch 权重导出。文件校验依据声明大小和 SHA-256,上传使用逐文件复制。`uv run model prepare` 用于本地准备。 ## CI 与文档导航 diff --git a/docs/internals/index.md b/docs/internals/index.md index a490876..98e52df 100644 --- a/docs/internals/index.md +++ b/docs/internals/index.md @@ -2,16 +2,6 @@ AnyImage 在 Blender 主线程处理交互、像素编辑和数据创建,通过 BlendJob 把模型推理交给独立 Job Server。 -```mermaid -flowchart TD - A[Image Empty 或剪贴板图片] --> B[Operator 与手势工具] - B --> C{需要 AI?} - C -- 是 --> D[准备文件与普通字典参数] - D --> E[JobRuntime → Job Server] - E --> F[图片与相机元数据] - F --> G[Blender 主线程导入] - C -- 否 --> G - G --> H[Image 编辑或 Mesh 与材质创建] ``` | 主题 | 对应职责 | diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md index dc5e28f..772b46a 100644 --- a/docs/internals/node-assets.md +++ b/docs/internals/node-assets.md @@ -1,11 +1,26 @@ # 节点资产 +## 资产清单 + +节点组文件在 `tools/nodes/groups` + +| 节点组 | 构建模块 | +| ------------------------ | ------------------------- | +| `O Image Plane` | `image_plane.py` | +| `O Image Depth Plane` | `image_depth_plane.py` | +| `O Image Relief Plane` | `image_relief_plane.py` | +| `O Image Layer` | `image_layer.py` | +| `O Image Depth Layer` | `image_depth_layer.py` | +| `O Shadeless` | `shadeless.py` | +| `O Image Cutout` | `image_cutout.py` | +| `O Image Depth Cutout` | `image_depth_cutout.py` | +| `O Image Depth Balloon` | `image_depth_balloon.py` | +| `O Image Depth Panorama` | `image_depth_panorama.py` | + ## 构建节点资产 生产节点组由 `uv run node-group build` 构建到 `src/anyimage/assets/O_AnyImage.blend` -节点组文件在 `tools/nodes/groups` - 节点组规则: - 字段默认直接连接,多个消费者可复用同一输出;按消费位置的几何、域、类型和输入属性版本验证求值等价性 @@ -20,52 +35,9 @@ - socket `subtype` 按业务语义设置:长度用 `DISTANCE`,`0–1` 比例用 `FACTOR`,`0–100` 用 `PERCENTAGE`;无对应范围语义时保留默认 - 接口或布局变更须验证 socket 顺序与结构、未使用输出隐藏及几何求值;纯布局变更比较运算、默认值和实际连接,确认功能不变 -```mermaid -flowchart TD - A[字段生产与消费] --> B{需要按名称访问} - B -->|是| C[命名属性] - B -->|否| D{目标上下文重新求值是否等价} - D -->|是| E[字段直连并显式转换域] - D -->|否| F[调整消费顺序或在源几何采样] - F --> G{仍需保存源数据} - G -->|否| E - G -->|是| H[仅存储必要命名数据] - H --> I[最后消费者后精确清理] -``` - -## 字段与临时属性 - -平滑循环按固定源位置查询原边界索引,并在当前边界网格上消费位置;拓扑不变的边界与影响权重直接求值。Depth Plane 由 UV 矩形判断原边界,Panorama 的闭合源球面以 False 表示原边界。 - -厚度构建使用源顶点索引排列构造网格,完成挤出与合并后按索引从源表面采样位置、厚度和法线。Depth Cutout 的中心、圆化、结构及膨胀字段在投影源表面求值。 - -| 属性 | 域 / 类型 | 写入与最后消费 | -| --- | --- | --- | -| `.o_anyimage_depth_cut` | CORNER / BOOLEAN | 三个深度组在 Split Edges 前保存切分标记,供投影调整射线,投影后精确清理 | -| `.o_anyimage_original_boundary` | POINT / BOOLEAN | Depth Cutout 在切分前保存任意输入轮廓,供切口平滑保护原边界,平滑后精确清理 | -| `UVMap` | CORNER / FLOAT2 | Image Plane 与 Panorama 的输出 UV,供材质读取 | -| `o_normal_scale` | POINT 或 FACE / FLOAT | Cutout 输出法线掩码,Depth Cutout 合并背面与侧壁后统一写入,供形状和材质消费 | - - -## 资产清单 - -| 节点组 | 构建模块 | -| --- | --- | -| `O Image Plane` | `image_plane.py` | -| `O Image Depth Plane` | `image_depth_plane.py` | -| `O Image Relief Plane` | `image_relief_plane.py` | -| `O Image Layer` | `image_layer.py` | -| `O Image Depth Layer` | `image_depth_layer.py` | -| `O Shadeless` | `shadeless.py` | -| `O Image Cutout` | `image_cutout.py` | -| `O Image Depth Cutout` | `image_depth_cutout.py` | -| `O Image Depth Balloon` | `image_depth_balloon.py` | -| `O Image Depth Panorama` | `image_depth_panorama.py` | - - ## 构建资产脚本 -`tools/nodes/build.py` 按依赖顺序构建资产,调用 `arrangement/` 统一排列几何节点组后保存。`common/` 提供共用构建函数,`preview/` 导出交互预览。 +`tools/nodes/build.py` 按依赖顺序构建资产,调用 `arrangement/` 统一排列几何节点组后保存。`common/` 提供共用构建函数,其中 `material_slots.py` 继承几何材质槽,`shader.py` 构建材质着色节点,`preview/` 导出交互预览。 保存后,`check.py` 在独立 Blender 进程中重新加载资产并做基本几何求值。接口、默认值和详细几何行为由 `tests/` 检查。 diff --git a/docs/internals/operators/ai-setup.md b/docs/internals/operators/ai-setup.md index 1e736cb..4e154d9 100644 --- a/docs/internals/operators/ai-setup.md +++ b/docs/internals/operators/ai-setup.md @@ -6,7 +6,7 @@ AI 环境管理从当前安装状态出发,补齐独立运行环境和必需 ```mermaid flowchart TD - Entry["Set Up AI Environment"] --> Status["读取环境与必需模型状态"] + Entry["Set Up AI Server"] --> Status["读取环境与必需模型状态"] Status --> Ready{"是否全部就绪?"} Ready -- "是" --> Done["结束"] Ready -- "否" --> Environment{"运行环境是否就绪?"} @@ -29,7 +29,7 @@ flowchart TD 环境尚未就绪时,`SetupAIEnvironment` 调用 Runtime 注册的安装 Operator。`use_mirror` 决定 uv、Python 和 Python 包的下载源,模型使用模型目录中声明的来源。 -安装完成后继续进入模型下载,不在 Blender Python 中加载 ONNX Runtime 或模型依赖。 +安装完成后通过 `post_install()` 下载默认模型,模型依赖加载于独立 Server 环境。 ### 下载模型 @@ -39,4 +39,4 @@ Job 在 `server/app.py` 注册;`server/model_catalog.py` 保存模型名称、 ### 释放模型缓存 -`ai_setup.py` 中的 `ClearModels` 调用 `runtime.clear_resource("model_manager")`,释放 Server 中已加载的模型 Session。它不会删除磁盘上的模型文件,下次推理仍可从现有文件重新加载。 +`ai_setup.py` 中的 `ClearModels` 调用 `runtime.clear_resource("model_manager")`,释放 Server 中已加载的模型 Session。下次推理从模型目录重新加载 Session。 diff --git a/docs/internals/operators/convert-to-panorama.md b/docs/internals/operators/convert-to-panorama.md new file mode 100644 index 0000000..4a3e3fc --- /dev/null +++ b/docs/internals/operators/convert-to-panorama.md @@ -0,0 +1,14 @@ +# Panorama 转换 + +`operators/convert_to_panorama/` 将静态 2:1 等距柱状 Image Empty 转换为径向深度表面。`operators.py` 提交任务,`object.py` 导入对象,`__init__.py` 汇总导出与注册清单。 + +```mermaid +flowchart TD + A[准备颜色与分析图] --> B[generate-panorama-geometry] + B --> C[采样 12 个透视视图并推理] + C --> D[融合径向距离与有效性] + D --> E[写出 depth.exr 与 depth.json] + E --> F[创建材质与 O Image Depth Panorama] +``` + +任务使用 Preferences 选择的几何模型。源颜色由 Blender 侧保存,Server 的 `geometry/panorama.py` 负责视图采样与距离融合,`jobs/panorama.py` 编排推理和产物写入。 diff --git a/docs/internals/operators/convert-to-plane.md b/docs/internals/operators/convert-to-plane.md index a5858e9..97724f1 100644 --- a/docs/internals/operators/convert-to-plane.md +++ b/docs/internals/operators/convert-to-plane.md @@ -1,23 +1,21 @@ # Plane 转换 -`operators/convert_to_plane/operators.py` 提供 `ConvertToPlane`、`ConvertToDepthPlane` 与 `GenerateDepthPlane`。对象和材质导入位于 `object.py`,基础矩形网格位于 `image_plane.py`。 +`operators/convert_to_plane/operators.py` 提供 `ConvertToPlane`、`ConvertToDepthPlane`、`ConvertToReliefPlane` 与 `GenerateDepthPlane`。`object.py` 导入对象和材质,`image_plane.py` 构建基础矩形网格,`__init__.py` 汇总导出与注册清单。 ```mermaid flowchart TD - A[Image Empty] --> B{转换类型} - B -- Plane --> C[复制 Color 与动画设置] - C --> D[中心原点四边形与 O Image Plane] - B -- Depth Plane --> E[检查静态图与 AI 就绪状态] - E --> F[generate-depth-plane-geometry] - F --> G[Color、depth.exr、depth.json、Tangent Normal] - G --> H[加载并 Pack 数据] - H --> I[O Image Depth Plane 与材质] - D --> J[替换源 Empty 并保留世界位置] - I --> J + A[Image Empty] --> B[准备材质颜色] + B --> C{转换类型} + C -- Plane --> D[四边形与 O Image Plane] + C -- Depth 或 Relief --> E[静态图提交几何 Job] + E --> F[加载深度、元数据与法线] + F --> G[对应节点修改器与材质] + D --> H[创建对象并替换源 Empty] + G --> H ``` -普通 Plane 支持图片、视频和序列,材质保留 Image User 帧设置。基础四边形以局部中心为原点,对象矩阵补偿原 Image Empty 显示范围;节点组生成细分与厚度,材质位于 Mesh 第 0 个槽。 +普通 Plane 支持图片、视频和序列,材质保留 Image User 帧设置。基础网格以局部中心为原点,对象矩阵补偿源 Empty 的显示范围。 -Depth Plane 从 Preferences 取得 MoGe-2 模型、分辨率级别与 AI 输入上限,一次推理生成相机 XYZ 深度和切线法线。`create_depth_plane_from_result()` 创建颜色材质与深度修改器,并在失败时清理未使用的数据块。 +Depth 与 Relief 从 Preferences 读取几何模型、分辨率级别和 AI 输入上限。`create_depth_plane_from_result()` 导入产物并创建修改器。`O Image Depth Plane` 使用相机 XYZ 与对象空间法线构建深度表面;`O Image Relief Plane` 使用深度 Z 与切线空间法线构建浮雕。 -`O Image Depth Plane` 提供默认 Relief 和 Camera:Relief 读取深度 Z 形成固定底面的地形;Camera 使用相机 XYZ 投影并沿平滑法线形成壳。两个模式各自保存 Thickness。完整接口见[节点资产](../node-assets.md),文件协议见[Job 产物](../outputs.md)。 +节点构建见[节点资产](../node-assets.md),文件协议见[Job 产物](../outputs.md)。 diff --git a/docs/internals/operators/cutout-tool.md b/docs/internals/operators/cutout-tool.md index 110dfd0..68de1b3 100644 --- a/docs/internals/operators/cutout-tool.md +++ b/docs/internals/operators/cutout-tool.md @@ -4,7 +4,7 @@ Cutout 在静态 Image Empty 上圈定 Selection,按轮廓创建 Flat、Solid ```mermaid flowchart TD - A[Lasso 拖动并提交 SelectionPath] --> B[选择 Shape] + A[Lasso 或 Polyline 提交 SelectionPath] --> B[选择 Shape] B --> C[栅格化 SelectionMask 并读取源 RGBA] C --> D{需要 Depth 或 Normal?} D -- 是 --> E[准备 bounds 矩形 RGBA] @@ -28,11 +28,13 @@ flowchart TD `geometry.py` 从内容 Alpha 提取连通轮廓和孔洞,按 Mesh Detail 采样并生成 `BaseShape`。Low、Medium、High、Ultra 分别以 32、16、8、4 个源像素作为基础间距,并受 Maximum Cutout Mesh Resolution 限制。 -`fine_mesh.py` 负责 Fine Outline 的细节保留与约束网格,`polygon.py` 提供轮廓运算。Balloon Profile 按 Preferences 选择 Poisson 或 Teddy,生成所有预设共用的 POINT 属性 `o_balloon`。 +`fine_mesh.py` 负责 Fine Outline 的细节保留与约束网格,`polygon.py` 提供轮廓运算。`balloon.py` 负责 Balloon Profile,按 Preferences 选择 Poisson 或 Teddy,生成所有预设共用的 POINT 属性 `o_balloon`。 + +`depth_calibration.py` 负责深度参考平面、尺度与方向校准;`geometry.py` 保留轮廓和网格生成。 ## AI 与对象创建 -Server 接收 Selection bounds 内保留源 Alpha 的 RGBA,一次 MoGe-2 推理按 `generate_depth` 与 `normal_mode` 返回产物。深度边缘延展使用所选 Alpha Threshold,Normal 使用原预测;AI 输入上限只影响分析分辨率。 +Server 接收 Selection bounds 内保留源 Alpha 的 RGBA,一次 MoGe 推理按 `generate_depth` 与 `normal_mode` 返回产物。深度参考区域使用所选 Alpha Threshold,Normal 使用原预测;AI 输入上限只影响分析分辨率。 `object.py` 的 `create_shape_object()` 消费 BaseShape、Color、Depth 和 Normal,加载对应节点组。基础网格位于局部 XY,向图片内部为 -Z;选择 +X 时节点与对象矩阵配对变换,保持世界几何一致。Object Normal 同步按方向转换。 diff --git a/docs/internals/operators/frame.md b/docs/internals/operators/frame.md index 89908d7..9a03b24 100644 --- a/docs/internals/operators/frame.md +++ b/docs/internals/operators/frame.md @@ -1,6 +1,6 @@ # Frame 多图合成 -`operators/frame_tool.py` 定义 `FrameImages` 和 `FrameTool`。Frame 将选中的静态 Image Empty 按当前视图合成为一张图片。 +`operators/frame_tool/operators.py` 定义 `FrameImages` 和 `FrameTool`。`projection.py` 负责视图投影、交叠和结果平面定位,`compositing.py` 负责像素深度排序与边界覆盖采样,`__init__.py` 导出类型及注册清单。Frame 将选中的静态 Image Empty 按当前视图合成为一张图片。 ```mermaid flowchart TD diff --git a/docs/internals/operators/index.md b/docs/internals/operators/index.md index b56f8ac..b80a7df 100644 --- a/docs/internals/operators/index.md +++ b/docs/internals/operators/index.md @@ -5,11 +5,12 @@ | `operators/ai_setup.py` | [AI 环境管理](ai-setup.md) | | `operators/clipboard_image/` | [剪贴板图片](clipboard-image.md) | | `operators/convert_to_plane/` | [Plane 与 Depth Plane](convert-to-plane.md) | +| `operators/convert_to_panorama/` | [Panorama](convert-to-panorama.md) | | `operators/cutout_tool/` | [Cutout](cutout-tool.md) | -| `operators/frame_tool.py` | [Frame 多图合成](frame.md) | +| `operators/frame_tool/operators.py` | [Frame 多图合成](frame.md) | | `operators/mask_tool.py` | [Mask Alpha 编辑](mask.md) | -| `operators/rectify_tool.py` | [Rectify 透视校正](rectify.md) | +| `operators/rectify_tool/operators.py` | [Rectify 透视校正](rectify.md) | | `operators/remove_background.py` | [Remove Background](remove-background.md) | | `operators/upscale.py` | [Upscale](upscale.md) | -单文件功能按业务命名;复杂功能包在 `operators.py` 定义 Operator、对应 WorkSpaceTool 和入口协调逻辑,其余模块承载具体实现。包的 `__init__.py` 汇总导出,类型在 `operators/__init__.py` 和扩展 `__init__.py` 汇总注册。AI Operator 使用普通字典提交任务,并在主线程响应中读取文件和修改 Blender 数据。 +单文件功能按业务命名;复杂功能包在 `operators.py` 定义 Operator、对应 WorkSpaceTool 和入口协调逻辑,其余模块承载具体实现。包的 `__init__.py` 汇总导出与 `CLASSES` 注册清单,类型在 `operators/__init__.py` 和扩展 `__init__.py` 汇总注册。AI Operator 使用普通字典提交任务,并在主线程响应中读取文件和修改 Blender 数据。 diff --git a/docs/internals/operators/rectify.md b/docs/internals/operators/rectify.md index d4d7c4e..4dbc5ba 100644 --- a/docs/internals/operators/rectify.md +++ b/docs/internals/operators/rectify.md @@ -1,6 +1,6 @@ # Rectify 透视校正 -`operators/rectify_tool.py` 定义 `RectifyImagePerspective` 与 `RectifyTool`,将图片内的四角区域校正为矩形。 +`operators/rectify_tool/operators.py` 定义 `RectifyImagePerspective` 与 `RectifyTool`,将图片内的四角区域校正为矩形。`geometry.py` 负责四角验证与像素校正,`preview.py` 负责预览图、Overlay 和宽高比控制,`__init__.py` 导出类型及注册清单。 ```mermaid flowchart TD diff --git a/docs/internals/operators/remove-background.md b/docs/internals/operators/remove-background.md index 186f688..4b678ad 100644 --- a/docs/internals/operators/remove-background.md +++ b/docs/internals/operators/remove-background.md @@ -1,37 +1,19 @@ # Remove Background -Remove Background 从当前 Image Empty 准备单图或多帧输入,运行 BEN2 生成前景 Alpha,再用结果 Image 替换原图。 - -## 执行过程 +`operators/remove_background.py` 从 Image Empty 或当前 Shader Image Texture 准备输入。`RemoveImageBackground` 协调设置与输入,`RunBackgroundRemoval` 提交 Job 并应用结果。 ```mermaid flowchart TD - Empty["Image Empty"] --> Prepare["准备图片、视频或序列输入"] - Prepare --> Job["remove-background Job"] - Job --> Frames["逐帧运行 BEN2"] - Frames --> Result["写出 PNG 结果序列"] - Result --> Import["创建结果 Image"] - Import --> Replace["替换 Empty.data"] + A[ImageEditTarget 取得编辑目标] --> B[准备图像输入] + B --> C[remove-background Job] + C --> D[所选背景模型生成前景 Alpha] + D --> E{输入类型} + E -- 普通媒体 --> F[PNG 结果序列] + E -- HDR --> G[alpha.npy] + F --> H[应用结果与 Undo] + G --> H ``` -Packed 图片和内存中修改过的单图会先导出临时文件;文件图片、视频和序列沿用原路径。 +`server/jobs/remove_background.py` 展开媒体并调用 `server/models/background.py`,支持 BiRefNet 和 BEN2。模型前景 Alpha 与源 Alpha 合成。 -## 节点说明 - -### 准备媒体输入 - -入口是 `operators/remove_background.py` 中的 `RemoveImageBackground`。它检查当前对象、AI Environment 和帧数,再调用 `common/image.py` 的 `prepare_image_input()`。多帧输入会先写入 Scene 中的抽帧设置。 - -准备完成后,内部 Operator `RunBackgroundRemoval` 保存输入路径、源对象名和临时文件清理标记。 - -### 运行 BEN2 - -`RunBackgroundRemoval` 提交 `remove-background` Job。`server/jobs/remove_background.py` 使用公共媒体输入层展开单图、视频或序列,并逐帧调用 `server/models/ben2.py`。 - -BEN2 推理前先用源 Alpha 屏蔽透明区域,模型输出的前景 Alpha 再与源 Alpha 合成,RGB 保持源图内容。多帧结果按连续文件名写入 Job 目录。 - -### 导入并替换结果 - -Job 返回后,`remove_background.py` 的 `apply_background_response()` 调用 `common/image.py` 创建单图或序列 Image,并恢复源 Image User 的帧起点、偏移和循环设置。 - -`replace_empty_image()` 随后替换 `Empty.data`,提交一个 Remove Background Undo 步骤,并清理本次创建的临时输入。 +普通结果通过 `ImageEditTarget` 保留共享图像引用与帧设置。HDR 使用 `common/hdr_image.py` 的 `HdrBackgroundInput` 准备分析输入,并将返回 Alpha 应用于原浮点 RGB。Operator 负责临时输入清理。 diff --git a/docs/internals/operators/upscale.md b/docs/internals/operators/upscale.md index 483afd2..db63593 100644 --- a/docs/internals/operators/upscale.md +++ b/docs/internals/operators/upscale.md @@ -1,21 +1,15 @@ # Upscale -`operators/upscale.py` 从当前 Image Empty 生成 2× 结果并替换原图,支持单图、视频与序列帧。 +`operators/upscale.py` 从 Image Empty 或当前 Shader Image Texture 生成 2× 结果。普通媒体支持单图、视频和序列帧。 ```mermaid flowchart TD - A[检查源图长边小于 AI 输入上限] --> B[冻结 Preferences 模型与尺寸设置] - B --> C[准备媒体输入] - C --> D[upscale-image 逐帧处理] - D --> E{所选模型} - E -- Real-ESRGAN 或 SAFMN --> F[256×256 tile 与边缘上下文执行 4× RGB 推理] - E -- PiSA-SR --> G[分阶段生成推理,细节强度 0.7] - F --> H[统一缩放到源图 2×] - G --> H - H --> I[原 Alpha 独立 Lanczos 缩放并回填] - I --> J[写出 upscale 序列并替换 Image] + A[检查源图尺寸并冻结模型设置] --> B[准备媒体输入] + B --> C[upscale-image 逐帧处理] + C --> D[分块执行 4× RGB 推理] + D --> E[统一缩放为源图 2×] + E --> F[原 Alpha 独立 Lanczos 缩放] + F --> G[ImageEditTarget 应用结果并提交 Undo] ``` -`server/models/upscale.py` 统一处理输入上限、模型分派和最终 RGB/Alpha 尺寸。`onnx_upscale.py` 处理常规 4× 模型,`onnx_pisa_sr.py` 处理 PiSA-SR。多帧与多 tile 只在最后一次运行请求内存收缩。 - -Provider 资源错误会释放当前 Upscale Session,并返回降低输入上限或切换 CPU 的提示。响应通过 `common/image.py` 加载、Pack、保留图像共享与帧设置,并提交 Undo。 +`server/models/upscale.py` 处理输入上限和最终尺寸,`onnx_upscale.py` 执行 Real-ESRGAN 与 HAT 模型。ModelManager 管理 Session;Provider 资源错误会释放当前 Session 并返回处理建议。 diff --git a/docs/internals/outputs.md b/docs/internals/outputs.md index 3ce6308..7516485 100644 --- a/docs/internals/outputs.md +++ b/docs/internals/outputs.md @@ -1,43 +1,40 @@ # Job 产物 -Server 把结果写入 Job 目录,返回以相对文件名为值的字典。Blender 主线程通过 JobResult 读取文件,再创建或更新 Image、Mesh、Material 和 Object。 +Server 将结果写入 Job 目录,返回以相对文件名为值的字典。Blender 主线程通过 JobResult 读取文件,创建或更新 Image、Mesh、Material 和 Object。 | Job | 文件 | 消费方 | | --- | --- | --- | -| `remove-background` | `image.0001.png` 起 | Image Empty 替换 | -| `upscale-image` | `upscale.0001.png` 起 | 2× Image Empty 替换 | -| `generate-depth-plane-geometry` | `image.0001.*`、`depth.exr`、`depth.json`、`tangent-normal.png` | Plane 对象与材质 | -| `generate-cutout-artifacts` | 按请求提供深度、元数据和法线 | Cutout 对象与材质 | +| `remove-background` | PNG 序列或 `alpha.npy` | ImageEditTarget 或 HDR Alpha 编辑 | +| `upscale-image` | `upscale.0001.png` 起 | 图像替换 | +| `generate-depth-plane-geometry` | `depth.exr`、`depth.json` 和法线 | Depth / Relief Plane | +| `generate-cutout-artifacts` | 按请求提供深度、元数据和法线 | Cutout | +| `generate-panorama-geometry` | `depth.exr`、`depth.json` | Panorama | ## 深度与法线协议 -| 返回字段 | 文件 | 内容 | -| --- | --- | --- | -| `depth` | `depth.exr` | float32 RGB 保存相机空间 XYZ,Alpha 保存 Validity | -| `depth_metadata` | `depth.json` | `image_size`、3×3 `intrinsics`、`reference_depth` | -| `tangent_normal` | `tangent-normal.png` | 切线空间法线 | -| `object_normal` | `object-normal.png` | Cutout 原生 XY / Z 坐标的对象空间法线 | +| 返回字段 | 内容 | +| --- | --- | +| `depth` | float32 EXR,透视产物 RGB 保存相机空间 XYZ,Alpha 保存源 Alpha × 模型有效性 | +| `depth_metadata` | JSON,透视产物包含 `image_size`、3×3 `intrinsics`、`reference_depth` | +| `tangent_normal` | `tangent-normal.png`,切线空间法线 | +| `object_normal` | `object-normal.png`,对象空间法线 | ```mermaid flowchart TD - A[矩形源 RGBA] --> B[限尺寸并合成黑底模型输入] - B --> C[MoGe-2 GeometryFrame] - C --> D[原预测生成 Normal] - C --> E{Cutout 深度?} - E -- 是 --> F[按源 Alpha 延展轮廓深度] - E -- 否 --> G[完整相机 Points] - F --> G - G --> H[depth.exr 与 depth.json] - D --> I[Blender 加载并 Pack] - H --> I + A[源 RGB 与 Alpha] --> B[限尺寸 RGB 推理] + B --> C[GeometryFrame] + C --> D[写出深度与法线] + A --> E[Alpha 有效性与参考区域] + E --> D + D --> F[Blender 加载并 Pack] ``` -`depth_texture.py` 保存完整有限 Points;模型 Mask 转为 `0/1` Validity。参考深度取有效、有限、正深度且满足源 Alpha 条件的样本中位数:整图使用 Alpha ≥ 0.1,Cutout 使用所选 Alpha Threshold 并要求 Alpha > 0。非法字段和空参考区域抛出错误。 +参考深度取满足模型有效性、有限正深度与 Alpha 阈值的样本中位数。`common/depth.py` 加载 EXR,设置 Non-Color 和 CHANNEL_PACKED。Depth Plane 使用对象空间法线,Relief Plane 使用切线空间法线。 -`common/depth.py` 加载深度并配置 Non-Color、CHANNEL_PACKED 与 Pack。Relief 和 Depth Balloon 读取 B/Z,Camera 与 Depth Solid 读取 XYZ,共用同一 EXR。 +Panorama 的 EXR RGB 保存径向距离,Alpha 保存源 Alpha 与融合有效性;元数据包含 `projection: equirectangular` 和 `image_size`。 -## Selection 与颜色 +## 颜色与图像编辑 -Cutout 提交几何 bounds 内的原始 RGBA。Blender 在创建网格时组合源 Alpha 和 `SelectionMask`;颜色图保留矩形源 RGBA,法线可见区域取源 Alpha。AI 分析尺寸与颜色分辨率分别处理。 +`common/color_image.py` 为 Plane、Panorama 和 Cutout 准备独立的材质颜色与 AI 分析输入。Cutout 使用 Selection bounds 内的源图;网格创建时组合源 Alpha 与 SelectionMask。颜色分辨率与分析尺寸分别处理。 -Remove Background、Upscale、Frame、Mask、Rectify 通过 `common/image.py` 的编辑结果入口保留源 Image 名称和共享引用语义。Plane 与 Cutout 的材质贴图使用 Color、Depth、Normal 命名函数。 +`ImageEditTarget` 统一 Image Empty 和 Shader Image Texture 的编辑目标及共享引用。HDR 去背景通过 `HdrBackgroundInput` 保留 Blender 浮点 RGB,并应用 Server 返回的连续 Alpha。 diff --git a/docs/internals/runtime.md b/docs/internals/runtime.md index f18dc0e..32458bd 100644 --- a/docs/internals/runtime.md +++ b/docs/internals/runtime.md @@ -1,53 +1,36 @@ # Job Runtime 与 Server -`runtime.py` 配置 BlendJob `JobRuntime` 和独立 Python 3.12 Environment。`server/app.py` 注册 Job,`model_manager.py` 作为 `model_manager` Resource 管理下载文件与缓存 Session。 +`runtime.py` 配置 BlendJob `JobRuntime` 和独立 Python 3.12 Environment。`server/app.py` 注册 Job,`model_manager.py` 管理模型文件与缓存 Session。 ```mermaid flowchart TD - A[Operator request] --> B[JobRuntime] - B --> C[Server 单 Worker 队列] - C --> D[Job 调用媒体与模型模块] - D --> E[写入 jobs/job-id 目录] - E --> F[主线程 response] + A[Operator request] --> B[JobRuntime 队列] + B --> C[Server Job 调用媒体与模型模块] + C --> D[写入 Job 目录] + D --> E[主线程 response 导入结果] ``` -Job 在阶段和帧边界检查取消,进度通过 `context.progress()` 返回。视频临时帧由 `media/input.py` 的上下文管理器清理,Blender 导出的临时输入由对应 Operator 清理。 - -## Job 与数据模块 - | Job | 实现与用途 | | --- | --- | -| `download-model`、`download-required-models` | `app.py` 调用 ModelManager 下载单个或缺失模型 | -| `remove-background` | `jobs/remove_background.py`,生成 BEN2 前景 | -| `upscale-image` | `jobs/upscale.py`,生成 2× 图像或序列 | -| `generate-depth-plane-geometry` | `jobs/depth_plane.py`,复制整图颜色并生成深度、元数据与切线法线 | -| `generate-cutout-artifacts` | `jobs/cutout.py`,按请求生成 Cutout 深度和法线 | +| `download-model`、`download-required-models` | `app.py` 下载单个或缺失模型 | +| `remove-background` | `jobs/remove_background.py` 生成前景 Alpha | +| `upscale-image` | `jobs/upscale.py` 生成 2× 图像或序列 | +| `generate-depth-plane-geometry` | `jobs/depth_plane.py` 生成深度、元数据和法线 | +| `generate-cutout-artifacts` | `jobs/cutout.py` 按请求生成深度和法线 | +| `generate-panorama-geometry` | `jobs/panorama.py` 生成全景径向深度 | -`media/` 处理单图、序列、视频与输入尺寸。Depth Plane 和 Cutout 按单张静态图片工作;Remove Background 与 Upscale 逐帧处理动画输入。 +`media/` 展开单图、序列与视频,并限制分析尺寸。Job 在阶段与帧边界检查取消,通过 `context.progress()` 返回进度。媒体上下文清理抽取的临时帧,Operator 清理 Blender 导出的临时输入。 -`geometry/prediction_artifacts.py` 将限尺寸后的 RGBA 合成到黑底,运行一次 MoGe-2,再调用深度、法线和元数据写入器。Cutout 的深度分支通过 `edge_depth.py` 延展轮廓附近的深度;法线使用原预测。文件字段见 [Job 产物](outputs.md)。 +`geometry/prediction_artifacts.py` 缩放源 RGB 后运行一次 MoGe,将同一预测交给深度、法线和元数据写入器。源 Alpha 用于产物有效性和参考深度采样。文件协议见 [Job 产物](outputs.md)。 ## 模型与缓存 | 用途 | 模型 | Session 分类 | | --- | --- | --- | -| Depth 与 Normal | MoGe-2 ViT-S / ViT-B / ViT-L Normal | geometry | -| Remove Background | BEN2 | ben2 | -| Upscale | Real-ESRGAN WDN、SAFMN、Real-ESRGAN x4plus、PiSA-SR | upscale | +| Depth 与 Normal | MoGe-2 ViT-S / ViT-B Normal、MoGe-3 ViT-L | geometry | +| Remove Background | BiRefNet Lite、BEN2、BiRefNet HR Matting | background | +| Upscale | Real-ESRGAN WDN、Real-ESRGAN x4plus、HAT GAN x4 Sharper | upscale | -`model_catalog.py` 声明文件来源、许可、大小和 SHA-256。`model_download.py` 下载文件并校验;ModelManager 按模型路径和设备缓存各类最近使用的 Session,切换模型或设备时释放对应类别。 +`model_catalog.py` 声明文件来源、许可、大小和 SHA-256;`model_download.py` 下载并校验。ModelManager 按模型路径和设备缓存 Session,切换模型或设备时释放对应类别。Clear Models 或关闭 Server 时释放已加载资源。 -```mermaid -flowchart TD - A[取得缓存 Session] --> B[运行单图、多帧或 tile] - B --> C[最后一次推理] - C --> D[CUDA 收缩空闲 arena] - D --> E[保留 Session 供后续复用] - E --> F[Clear Models 或关闭 Server 时释放] -``` - -Windows 默认 DirectML,macOS arm64 默认 CPU 并支持选择 CoreML。MoGe-2 为 CoreML 配置静态 shape 分区,其余计算由 CPU 执行。PiSA-SR 在 CoreML 上返回明确的支持限制;其分阶段 Session 在切换阶段、取消和清理时关闭。 - -## Environment 与存储 - -首次安装通过 `post_install()` 下载默认 MoGe-2、BEN2 与 Real-ESRGAN WDN。Storage Root 默认 `~/.anyimage`,其中保存 `.venv/`、`models/`、`jobs/` 及环境状态和日志。设置入口与安装流程见 [AI 环境管理](operators/ai-setup.md)。 +首次安装通过 `post_install()` 下载默认 MoGe-2、BiRefNet Lite 与 Real-ESRGAN WDN。Storage Root 默认 `~/.anyimage`,保存环境、模型、Job 产物和日志。设置入口见 [AI 环境管理](operators/ai-setup.md)。 diff --git a/mkdocs.yml b/mkdocs.yml index 86f86a9..d2a41be 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,6 +23,7 @@ nav: - AI 环境管理: internals/operators/ai-setup.md - Clipboard Image: internals/operators/clipboard-image.md - Convert to Plane: internals/operators/convert-to-plane.md + - Convert to Panorama: internals/operators/convert-to-panorama.md - Cutout Tool: internals/operators/cutout-tool.md - Frame: internals/operators/frame.md - Mask: internals/operators/mask.md diff --git a/openspec/changes/add-cutout-polyline/.openspec.yaml b/openspec/changes/add-cutout-polyline/.openspec.yaml new file mode 100644 index 0000000..2b596d1 --- /dev/null +++ b/openspec/changes/add-cutout-polyline/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-12 diff --git a/openspec/changes/add-cutout-polyline/design.md b/openspec/changes/add-cutout-polyline/design.md new file mode 100644 index 0000000..cac82f5 --- /dev/null +++ b/openspec/changes/add-cutout-polyline/design.md @@ -0,0 +1,49 @@ +## Context + +Cutout 的 `SelectCutoutSelection` 已继承 `common.viewport.ImageGesture`,目前固定为 Lasso 并通过拖动键位启动。Mask 的 Polyline 已由该共用类实现。Cutout 完成选区后打开 Shape 饼菜单,Depth Solid 经异步生成后使用 `O Image Depth Cutout` 创建对象。 + +节点构建内部的 Uniform Thickness 输入在保存前显示名改为 Thickness;节点组同时存在 Balloon 使用的 Thickness 输入,初始化必须区分 socket 标识。 + +## Goals + +支持 Cutout 逐点剪出及 Uniform 初始化;统一 Mask 的 Extend 命名和 Set 默认值。 + +## Decisions + +### 共用手势与独立设置 + +在 `properties.py` 定义 Cutout 的 Lasso / Polyline 属性,由 Scene 保存,默认 Lasso。Cutout 在启动时读取手势,沿用 `ImageGesture` 的逐点输入、实时预览、闭合提示和取消清理。使用共用按下启动键位支持首点输入,并验证 Lasso 拖动完成以及空白处源对象保持行为。共用逻辑留在 `common`,Cutout 负责自己的状态文本和选区提交。 + +### 创建时应用 Uniform + +```mermaid +flowchart TD + A[读取 Cutout Gesture] --> B[ImageGesture 收集并确认选区] + B --> C[Shape 饼菜单携带本次 Gesture 快照] + C --> D[CutoutSelectionToShape 保存 Gesture] + D --> E[生成所需图像并在主线程创建对象] + E --> F{Polyline 且 Depth Solid} + F -->|是| G[设置新修改器 Mode 为 Uniform] + F -->|否| H[按 Shape 初始化修改器] +``` + +手势作为普通字符串从选择 Operator 显式传到饼菜单、转换 Operator 和对象创建链路,异步响应使用该快照,避免用户中途切换 Scene 设置影响结果。服务器生成协议继续处理深度和法线产物;修改器初始化由主线程完成。Uniform 自动设置针对本次新建的 Depth Solid 对象,之后用户可手动修改 Mode。 + +### 节点厚度默认值 + +将构建内部 Uniform Thickness 默认值设为 0.5,并验证最终资产对应 socket 的默认值和新修改器实际值。检查现有通用 Thickness 赋值只初始化其目标输入,避免同名 socket 覆盖 Uniform 默认值。按现有资产修订机制更新 Depth Solid 版本并运行 `uv run node-group build`。 + +### Mask 模式 + +统一为 `SET` / Set、`EXTEND` / Extend、`SUBTRACT` / Subtract,保留枚举数值 0、1、2。Scene 和 Operator 均通过共用属性默认 Set。Extend 保持 `max(alpha, mask)`,Set 保持 `alpha * mask`,Subtract 保持 `alpha * (1-mask)`;状态文本和测试同步命名。 + +## Risks / Trade-offs + +- 按下启动可能改变 Cutout 的源对象选择时机 → 覆盖 Lasso、Polyline、空白点击和取消行为。 +- 异步生成期间手势被切换 → 在提交时保存本次手势,并覆盖设置改变后的响应。 +- 同名 Thickness socket 容易导致写入错误 → 验证具体 socket identifier、资产默认值及实际修改器值。 +- 旧脚本传入 `ADD` 将失效 → 调用方统一使用 `EXTEND`,保持原枚举数值且不增加别名。 + +## Migration Plan + +实现代码与测试后重建节点资产,执行相关测试及全量测试。回退时将本次代码、测试和资产作为同一变更回退。 diff --git a/openspec/changes/add-cutout-polyline/proposal.md b/openspec/changes/add-cutout-polyline/proposal.md new file mode 100644 index 0000000..db83fa9 --- /dev/null +++ b/openspec/changes/add-cutout-polyline/proposal.md @@ -0,0 +1,27 @@ +## Why + +Cutout 目前只能自由绘制 Lasso,沿直线轮廓剪出时需要逐点定义边界。复用 Mask 的 Polyline 交互,并为这种剪出方式设置 Uniform 厚度,可减少手动调整;Mask 使用 Extend 和默认 Set,使名称与常用操作一致。 + +## What Changes + +- Cutout 增加 Gesture 选项 Lasso / Polyline,默认 Lasso,复用共用逐点交互和选区提交链路。 +- Polyline 剪出创建使用 `O Image Depth Cutout` 的 Depth Solid 对象时,自动设置修改器 Mode 为 Uniform。 +- `O Image Depth Cutout` 的 Uniform Thickness 接口默认值改为 0.5(资产中显示为 Thickness)。 +- **BREAKING** Mask 的 `ADD` / Add 统一重命名为 `EXTEND` / Extend,保留扩展 Alpha 的运算语义;默认模式从 Subtract 改为 Set。 + +## Capabilities + +### New Capabilities + +- `cutout-polyline`: Cutout 手势选择、共用 Polyline 交互,以及创建结果的 Uniform 初始化。 +- `mask-mode-options`: Mask 的 Set / Extend / Subtract 名称、运算和默认值。 + +### Modified Capabilities + +无。当前 `openspec/specs` 尚无已归档的对应规范。 + +## Impact + +- `properties.py`、`common/viewport.py`、`operators/cutout_tool`、`operators/mask_tool.py` 和相关注册、交互、对象创建测试。 +- `tools/nodes/groups/image_depth_cutout.py`、节点资产 `O_AnyImage.blend` 及节点测试;实现阶段需运行节点构建与验证。 +- 无新增依赖。已有 change 中的 Mask、Polyline 和 Cutout 提案作为实现背景,本 change 定义本次行为。 diff --git a/openspec/changes/add-cutout-polyline/specs/cutout-polyline/spec.md b/openspec/changes/add-cutout-polyline/specs/cutout-polyline/spec.md new file mode 100644 index 0000000..8f31ecf --- /dev/null +++ b/openspec/changes/add-cutout-polyline/specs/cutout-polyline/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Cutout offers persistent gesture selection + +Cutout SHALL 提供 Lasso 和 Polyline 手势,默认 Lasso,并保存到 Scene 的独立 Cutout 设置。 + +#### Scenario: User selects Polyline +- **WHEN** 用户将 Cutout Gesture 切换为 Polyline 并开始剪出 +- **THEN** 工具以首次左键按下的位置为起点,随后通过点击添加顶点 + +#### Scenario: User returns to Cutout +- **WHEN** 用户选择 Polyline 后切换到其他工具再返回 +- **THEN** Cutout 继续使用 Polyline,Mask Gesture 保持自己的设置 + +#### Scenario: Default Lasso interaction +- **WHEN** 新 Scene 中用户拖动 Cutout 并释放左键 +- **THEN** 工具以 Lasso 收集轮廓并在有效选区完成后打开 Shape 饼菜单 + +### Requirement: Cutout reuses shared Polyline interaction + +Cutout MUST 复用 Mask 所用的共用 Polyline 手势,提供轮廓预览、起点闭合提示、左键添加顶点、Backspace 删除末点、点击起点或双击或 Enter 完成,以及 RMB、Esc 和切换工具取消。 + +#### Scenario: User completes a polygon +- **WHEN** 至少三个顶点后,用户点击起点闭合范围内、双击或按 Enter / Numpad Enter +- **THEN** 工具提交已确认的闭合 SelectionPath 并打开 Shape 饼菜单,闭合点击和双击第二击不添加重复顶点 + +#### Scenario: Too few points +- **WHEN** 少于三个顶点时用户尝试完成 +- **THEN** 工具继续收集顶点,不打开 Shape 菜单 + +#### Scenario: User edits or cancels a polygon +- **WHEN** 用户按 Backspace,或通过 RMB、Esc、切换工具取消 +- **THEN** Backspace 删除末点并至少保留起点;取消清理预览且不创建结果对象 + +#### Scenario: Empty click preserves the source +- **WHEN** 用户在空白处启动 Cutout 手势 +- **THEN** 工具保持现有源对象选择规则,不因空白点击清空源对象 + +### Requirement: Polyline initializes Depth Solid with Uniform mode + +通过 Polyline 创建 Depth Solid 时,系统 MUST 将新对象的 `O Image Depth Cutout` 修改器 Mode 初始化为 Uniform。手势 MUST 使用本次提交快照。 + +#### Scenario: Polyline depth result completes asynchronously +- **WHEN** 用户提交 Polyline 并选择 Depth Solid,在生成结束前将 Cutout Gesture 改为 Lasso +- **THEN** 本次结果的 Mode 仍为 Uniform,用户可在创建后手动调整 + +#### Scenario: Lasso or another shape is used +- **WHEN** 用户通过 Lasso 创建 Depth Solid,或通过 Polyline 创建 Flat、Solid、Depth Balloon +- **THEN** 对象沿用该 Shape 的现有 Mode 初始化规则 + +### Requirement: Uniform thickness defaults to half a unit + +`O Image Depth Cutout` 的 Uniform Thickness socket MUST 默认 0.5,保存资产中对应显示为 Thickness 的 socket 和新建修改器 MUST 使用该值。 + +#### Scenario: Node asset is built and loaded +- **WHEN** 完成节点资产构建并从资产加载节点组创建 Polyline Depth Solid +- **THEN** Mode 为 Uniform,Uniform 厚度值为 0.5,通用 Thickness 初始化不会覆盖该值 diff --git a/openspec/changes/add-cutout-polyline/specs/mask-mode-options/spec.md b/openspec/changes/add-cutout-polyline/specs/mask-mode-options/spec.md new file mode 100644 index 0000000..df2e130 --- /dev/null +++ b/openspec/changes/add-cutout-polyline/specs/mask-mode-options/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Mask exposes Set Extend and Subtract + +Mask MUST 按顺序提供 `SET` / Set、`EXTEND` / Extend、`SUBTRACT` / Subtract,枚举数值分别为 0、1、2;界面和状态文本 MUST 使用 Extend。 + +#### Scenario: User extends Alpha +- **WHEN** 用户以 Extend 提交值为 mask 的选区到现有 alpha +- **THEN** 结果 Alpha 为 `max(alpha, mask)`,RGB 保持不变 + +#### Scenario: User uses Set or Subtract +- **WHEN** 用户使用 Set 或 Subtract +- **THEN** 结果 Alpha 分别为 `alpha * mask` 或 `alpha * (1-mask)`,RGB 保持不变 + +### Requirement: Mask defaults to Set + +Scene 的 Mask Mode 和 Mask Operator 的模式属性 MUST 默认 Set,并通过同一属性定义保持一致。 + +#### Scenario: New scene and operator defaults +- **WHEN** 创建新 Scene 或未指定模式的 Mask Operator +- **THEN** 模式默认值为 `SET` + +#### Scenario: User chooses another mode +- **WHEN** 用户主动设置 Extend 或 Subtract 后启动 Mask +- **THEN** 本次操作使用用户保存的模式 diff --git a/openspec/changes/add-cutout-polyline/tasks.md b/openspec/changes/add-cutout-polyline/tasks.md new file mode 100644 index 0000000..ad49f44 --- /dev/null +++ b/openspec/changes/add-cutout-polyline/tasks.md @@ -0,0 +1,21 @@ +## 1. Cutout 手势 + +- [x] 1.1 在 properties.py 添加默认 Lasso 的 Cutout Gesture 属性并接入 Scene、Operator 和工具设置,检查注册与逆序注销测试。 +- [x] 1.2 将 Cutout 接入共用 Polyline 启动、预览与完成逻辑,更新状态文本;覆盖逐点、闭合、双击、Enter、Backspace、取消、Lasso 回归和空白处源对象保持行为。 + +## 2. Uniform 初始化与资产 + +- [x] 2.1 将本次 Gesture 快照从选区提交经 Shape 菜单传入转换 Operator 和对象创建链路;覆盖异步期间切换设置的行为。 +- [x] 2.2 为 Polyline 创建的 Depth Solid 设置 Mode 为 Uniform,验证 Lasso 与其他 Shape 的初始化行为。 +- [x] 2.3 将节点内部 Uniform Thickness 默认值改为 0.5,检查同名 Thickness socket 的赋值目标并更新资产修订号;补充资产默认值与实际修改器值测试。 +- [x] 2.4 运行 `uv run node-group build`,更新并验证 O_AnyImage.blend,确认源码与保存资产的 Capture Attribute 数量为零。 + +## 3. Mask 模式 + +- [x] 3.1 将 Mask 的 ADD / Add 统一改为 EXTEND / Extend,保持枚举数值与 Alpha 运算语义,将共用模式属性默认值改为 SET。 +- [x] 3.2 更新模式、状态文本、设置持久化和注册测试,验证三种模式的 Alpha 结果及 RGB 保持行为。 + +## 4. 验证 + +- [x] 4.1 运行相关测试及 `uv run pytest`,确认正常退出。 +- [x] 4.2 检查运行时代码和测试中的旧 Mask 模式引用及最终差异,确认源码、测试和节点资产一致。 diff --git a/pyproject.toml b/pyproject.toml index 7f443ba..5e21ebc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "anyimage" -version = "0.13.2" +version = "0.13.3" description = "Fast image-guided structure building tools for Blender" readme = "README.md" requires-python = ">=3.11,<3.12" @@ -9,7 +9,7 @@ dependencies = ["blendjob==0.1.17", "numpy>=1.26,<2.0", "scipy==1.15.3"] [dependency-groups] dev = [ - "av==17.0.0", + "bpy==4.5.3", "huggingface-hub==1.4.1", "mike>=2.1.3,<3", diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index f3bdabf..224309f 100644 --- a/src/anyimage/assets/O_AnyImage.blend +++ b/src/anyimage/assets/O_AnyImage.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dcd246d17d02dac65bb3d72c5c0c40f48fce9f259f0ff737ca81050343990d04 -size 9813608 +oid sha256:6aaeb2094a859dc07e2d08fabb6357bb0c60f00c3becf2e7a756028fe1c1bede +size 9838587 diff --git a/src/anyimage/blender_manifest.toml b/src/anyimage/blender_manifest.toml index 5ddc72b..3906755 100644 --- a/src/anyimage/blender_manifest.toml +++ b/src/anyimage/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "anyimage" -version = "0.13.2" +version = "0.13.3" name = "AnyImage" tagline = "Build spatial structures from images, fast" maintainer = "OmooLab" diff --git a/src/anyimage/common/ai.py b/src/anyimage/common/ai.py index 5b98559..91b3efe 100644 --- a/src/anyimage/common/ai.py +++ b/src/anyimage/common/ai.py @@ -69,12 +69,10 @@ def require_model(model_key): return model -def moge2_parameters(settings, path, model, resolution_level, start_frame): +def moge2_parameters(path, model, resolution_level): return { "input": str(path), "model": model, "resolution_level": int(resolution_level), - "video_frames": settings.video_frames, - "start_frame": start_frame, "device": production_device().lower(), } diff --git a/src/anyimage/common/color_image.py b/src/anyimage/common/color_image.py index ca807b8..fed7d2c 100644 --- a/src/anyimage/common/color_image.py +++ b/src/anyimage/common/color_image.py @@ -7,12 +7,12 @@ from pathlib import Path import bpy -from .image import cleanup_image_input, image_base_name, image_is_packed, image_rgba, is_animated_image, video_total_frames +from .image import cleanup_image_input, image_base_name, image_is_packed, image_rgba, is_animated_image def require_static_color_image(image): """Reject animated inputs before preparing material resources.""" - if is_animated_image(image) or video_total_frames(image) != 1: + if is_animated_image(image): raise ValueError("Material conversion supports static images only") diff --git a/src/anyimage/common/image.py b/src/anyimage/common/image.py index 6b93146..8f0632d 100644 --- a/src/anyimage/common/image.py +++ b/src/anyimage/common/image.py @@ -1,14 +1,12 @@ """Shared Blender Image and Image Empty lifecycle helpers.""" import math -import re import shutil import tempfile from pathlib import Path import bpy -from ..server.media.input import validate_input_path VIEW_TRANSFORM_COLOR_SPACES = ( ("ACES 1.3", "ACES 1.3 sRGB"), @@ -36,19 +34,6 @@ MAX_PROJECTIVE_OUTPUT_PIXELS = 16_777_216 PROJECTIVE_CHUNK_ROWS = 256 -VIDEO_NAME_SUFFIXES = ( - ".mp4", - ".mov", - ".avi", - ".mkv", - ".webm", - ".m4v", - ".mts", - ".mpg", - ".mpeg", -) - - def is_image_empty(candidate): return bool( candidate @@ -290,21 +275,10 @@ def warp_projective_pixels(source_pixels, source_size, quad, output_size): return np.flipud(straight_rgba(output)).ravel() - def is_animated_image(image): return getattr(image, "source", None) in {"MOVIE", "SEQUENCE"} -def video_total_frames(image): - """Return the total animated frame count, or 1 for a still image.""" - source = getattr(image, "source", None) - if source == "MOVIE": - return int(getattr(image, "frame_duration", 1) or 1) - if source == "SEQUENCE": - return len(collect_sequence_frames(image)) or 1 - return 1 - - def image_user_settings(source_object): """Return an image owner's playback settings as a four-item tuple.""" image_user = getattr(source_object, "image_user", None) @@ -318,28 +292,11 @@ def image_user_settings(source_object): ) -def extract_frame_range(image, image_user): - """Return the clamped (start_frame, frame_count) range extracted from the source.""" - total_frames = video_total_frames(image) - if total_frames <= 1: - return 1, total_frames - frame_offset = int(getattr(image_user, "frame_offset", 0) or 0) - image_frames = int(getattr(image_user, "frame_duration", 0) or 0) - frame_count = ( - min(image_frames, total_frames) - if image_frames >= 1 - else total_frames - ) - start_frame = min(1 + frame_offset, total_frames - frame_count + 1) - start_frame = max(start_frame, 1) - return start_frame, frame_count - - def image_path(image): filepath_from_user = getattr(image, "filepath_from_user", None) filepath = ( filepath_from_user() - if callable(filepath_from_user) and getattr(image, "source", None) != "SEQUENCE" + if callable(filepath_from_user) else getattr(image, "filepath", "") ) if not filepath: @@ -371,7 +328,7 @@ def cleanup_image_input(path, temporary): def image_base_name(image): name = image.name lower_name = name.lower() - for suffix in IMAGE_NAME_SUFFIXES + VIDEO_NAME_SUFFIXES: + for suffix in IMAGE_NAME_SUFFIXES: suffix_index = lower_name.rfind(suffix) if suffix_index <= 0: continue @@ -409,137 +366,35 @@ def save_packed_image(image, _scene): return output_path -def collect_sequence_frames(image): - """Return the numbered frame files of a SEQUENCE image, or [] when absent.""" - source_path = image_path(image) - if not is_animated_image(image) or not source_path.is_file(): - return [] - match = re.match( - r"^(?P.*?)(?P\d+)(?P\.[^.]+)$", - source_path.name, - ) - if match is None: - return [] - prefix = match.group("prefix") - suffix = match.group("suffix").lower() - frames = [] - for path in source_path.parent.iterdir(): - frame_match = re.match( - r"^(?P.*?)(?P\d+)(?P\.[^.]+)$", - path.name, - ) - if ( - frame_match is not None - and frame_match.group("prefix") == prefix - and frame_match.group("suffix").lower() == suffix - ): - frames.append((int(frame_match.group("digits")), path)) - return [path for _digits, path in sorted(frames)] - - def prepare_image_input(image, scene): - source_path = image_path(image) + """Prepare one still image for a Server job.""" + if is_animated_image(image): + raise RuntimeError("Only still images are supported") if image_is_packed(image): return save_packed_image(image, scene), True - if getattr(image, "source", None) == "MOVIE": - if not source_path.is_file(): - raise RuntimeError( - f"Source video file does not exist: {source_path}" - ) - return source_path, False - if getattr(image, "source", None) == "SEQUENCE": - validate_input_path(source_path, images_only=True) - sequence_frames = collect_sequence_frames(image) - if sequence_frames: - temporary_directory = Path(tempfile.mkdtemp(prefix="anyimage-input-")) - try: - for index, frame_path in enumerate(sequence_frames): - suffix = frame_path.suffix.lower() or ".jpg" - shutil.copy2( - frame_path, - temporary_directory / f"frame.{index + 1:04d}{suffix}", - ) - except Exception: - shutil.rmtree(temporary_directory, ignore_errors=True) - raise - return temporary_directory, True + source_path = image_path(image) if not source_path.is_file(): - raise RuntimeError( - f"Source image file does not exist: {source_path}" - ) + raise RuntimeError(f"Source image file does not exist: {source_path}") return source_path, False -def configure_image_user( - image_user, - frame_count=None, - frame_start=1, - frame_offset=0, - use_cyclic=None, -): - """Map the image to scene frames and enable auto refresh.""" - if image_user is None: - return - if hasattr(image_user, "frame_start"): - image_user.frame_start = frame_start - if hasattr(image_user, "frame_offset"): - image_user.frame_offset = frame_offset - if hasattr(image_user, "use_auto_refresh"): - image_user.use_auto_refresh = True - if use_cyclic is not None and hasattr(image_user, "use_cyclic"): - image_user.use_cyclic = use_cyclic - if frame_count is not None and hasattr(image_user, "frame_duration"): - image_user.frame_duration = frame_count - - -def pack_sequence_if_possible(image, expected_frames): - """Pack a frame sequence when Blender supports it; otherwise keep external files.""" - if expected_frames <= 1 or not image.has_data: - return False +def load_image_edit_result(source_image, output_path): + """Load and pack the image file returned by a Job.""" + output_path = Path(output_path) + if not output_path.is_file(): + raise RuntimeError(f"Color result image does not exist: {output_path}") + result_image = None try: - image.pack() - except (OSError, RuntimeError): - return False - if len(getattr(image, "packed_files", ())) >= expected_frames: - return True - try: - image.unpack(method="REMOVE") - except (OSError, RuntimeError): - pass - return False - - -def sorted_frame_paths(directory, prefix=None): - """Return image files in a directory sorted by their trailing frame number.""" - def frame_key(path): - match = re.search(r"(\d+)(?=\.[^.]+$)", path.name) - return (int(match.group(1)) if match is not None else 0, path.name) - - return sorted( - ( - path - for path in Path(directory).iterdir() - if path.is_file() - and path.suffix.lower() in IMAGE_NAME_SUFFIXES - and (prefix is None or path.stem.startswith(prefix)) - ), - key=frame_key, - ) - - -def force_sequence_source(image): - """Ensure a loaded image is a SEQUENCE datablock for its numbered frames.""" - if getattr(image, "source", None) == "SEQUENCE": - return image - try: - image.source = "SEQUENCE" - except (OSError, RuntimeError, TypeError) as error: - raise RuntimeError( - "Blender cannot load the frame sequence" - ) from error - if getattr(image, "source", None) != "SEQUENCE": - raise RuntimeError("Blender cannot load the frame sequence") - return image + result_image = bpy.data.images.load(str(output_path), check_existing=False) + result_image.colorspace_settings.name = source_image.colorspace_settings.name + result_image.alpha_mode = source_image.alpha_mode + result_image.pack() + result_image.name = source_image.name + except (OSError, RuntimeError, TypeError, ValueError) as error: + if result_image is not None: + bpy.data.images.remove(result_image, do_unlink=True) + raise RuntimeError("Unable to load the color result") from error + return result_image def load_normal_result_image(path, source_object): @@ -556,22 +411,6 @@ def color_alpha_mode(color_space): return "STRAIGHT" if color_space in dict(VIEW_TRANSFORM_COLOR_SPACES).values() else "PREMUL" -def configure_color_alpha(image): - """Apply the presentation mode to an owned Color image.""" - alpha_mode = color_alpha_mode(image.colorspace_settings.name) - if image.alpha_mode != alpha_mode: - pixels = ( - image_pixels(image) - if getattr(image, "source", None) == "GENERATED" - or getattr(image, "is_dirty", False) - else None - ) - image.alpha_mode = alpha_mode - if pixels is not None: - image.pixels.foreach_set(pixels) - image.update() - - def create_image_edit_result(source_image, pixels, output_size): """Create a packed Image datablock from edited RGBA pixels.""" output_width, output_height = (int(value) for value in output_size) @@ -680,11 +519,6 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None, image_user = getattr(source_object, "image_user", None) original_timing = image_user_settings(source_object) if image_user is not None else None original_auto_refresh = getattr(image_user, "use_auto_refresh", False) - frame_count = ( - video_total_frames(result_image) - if is_animated_image(result_image) - else None - ) alignment = ( empty_display_alignment( source_object, @@ -695,13 +529,6 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None, if placement_bounds is not None else None ) - if frame_count is not None: - ( - original_frame_start, - _original_frame_offset, - _original_frame_duration, - original_use_cyclic, - ) = image_user_settings(source_object) source_image.use_fake_user = True original_alignment = ( @@ -720,13 +547,6 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None, final_image = source_image source_object.data = final_image source_object.use_empty_image_alpha = True - if frame_count is not None: - configure_image_user( - image_user, - frame_count, - frame_start=original_frame_start, - use_cyclic=original_use_cyclic, - ) if alignment is not None: source_object.empty_display_size = alignment[0] source_object.empty_image_offset = alignment[1] @@ -751,64 +571,6 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None, bpy.data.images.remove(result_image) -def _load_packed_result_image( - source_image, - output_directory, - *, - frame_prefix, - result_name, - alpha_mode, -): - output_directory = Path(output_directory) - color_paths = sorted_frame_paths(output_directory, frame_prefix) - if not color_paths: - raise RuntimeError( - f"Color result has no images: {output_directory}" - ) - source_color_space = source_image.colorspace_settings.name - animated = is_animated_image(source_image) - try: - result_image = bpy.data.images.load( - str(color_paths[0]), - check_existing=False, - ) - result_image.colorspace_settings.name = source_color_space - result_image.alpha_mode = alpha_mode - if animated: - force_sequence_source(result_image) - except (OSError, RuntimeError, TypeError, ValueError) as error: - if "result_image" in locals(): - bpy.data.images.remove(result_image, do_unlink=True) - raise RuntimeError("Unable to load the color result") from error - - try: - if animated: - pack_sequence_if_possible(result_image, len(color_paths)) - else: - result_image.pack() - result_image.name = result_name - except (OSError, RuntimeError, TypeError, ValueError) as error: - bpy.data.images.remove(result_image, do_unlink=True) - raise RuntimeError("Unable to prepare the color result") from error - return result_image - - -def load_image_edit_result( - source_image, - output_directory, - *, - frame_prefix="image.", -): - """Load one packed Job result that directly edits the source Image.""" - return _load_packed_result_image( - source_image, - output_directory, - frame_prefix=frame_prefix, - result_name=source_image.name, - alpha_mode=source_image.alpha_mode, - ) - - def image_empty_bounds(source_object): image_width, image_height = source_object.data.size if image_width <= 0 or image_height <= 0: diff --git a/src/anyimage/common/image_target.py b/src/anyimage/common/image_target.py index 64b2034..ae4727c 100644 --- a/src/anyimage/common/image_target.py +++ b/src/anyimage/common/image_target.py @@ -6,8 +6,6 @@ from uuid import uuid4 import bpy from .image import ( - configure_image_user, - extract_frame_range, image_content_state, image_user_settings, is_animated_image, @@ -16,7 +14,6 @@ from .image import ( prepare_image_input, replace_empty_image, restore_image_content, - video_total_frames, ) @@ -84,7 +81,6 @@ def replace_texture_image(node, result_image): original_timing = image_user_settings(node) original_auto_refresh = node.image_user.use_auto_refresh original_content = None - frame_count = video_total_frames(result_image) if is_animated_image(result_image) else None try: if has_other_image_user(node): final_image = result_image @@ -94,13 +90,6 @@ def replace_texture_image(node, result_image): restore_image_content(source_image, result_content) final_image = source_image node.image = final_image - if frame_count is not None: - configure_image_user( - node.image_user, - frame_count, - frame_start=original_timing[0], - use_cyclic=original_timing[3], - ) except Exception: if original_content is not None: restore_image_content(source_image, original_content) @@ -122,8 +111,6 @@ class ImageEditTarget: owner: object image: object tree: object = None - start_frame: int = 1 - frame_count: int = 1 node_identity: str = "" @classmethod @@ -132,7 +119,8 @@ class ImageEditTarget: if owner is None: raise RuntimeError("Select an Image Empty or a material Image Texture") image = owner_image(owner) - start_frame, frame_count = extract_frame_range(image, owner.image_user) + if is_animated_image(image): + raise RuntimeError("Only still images are supported") tree = None if is_image_empty(owner) else owner.id_data identity = "" if tree is not None: @@ -141,7 +129,7 @@ class ImageEditTarget: identity = uuid4().hex owner["anyimage_identity"] = identity return cls( - owner, image, tree, start_frame, frame_count, identity, + owner, image, tree, identity, ) def validate(self): @@ -165,13 +153,11 @@ class ImageEditTarget: def prepare(self, context): self.validate() - if video_total_frames(self.image) > 1: - context.scene.anyimage_settings.video_frames = self.frame_count return prepare_image_input(self.image, context.scene) - def apply(self, directory, *, frame_prefix="image."): + def apply(self, output_path): self.validate() - result_image = load_image_edit_result(self.image, directory, frame_prefix=frame_prefix) + result_image = load_image_edit_result(self.image, output_path) return self.commit(result_image) def commit(self, result_image, *, isolate_shared=False): diff --git a/src/anyimage/common/viewport.py b/src/anyimage/common/viewport.py index 4d66d4c..3d1006d 100644 --- a/src/anyimage/common/viewport.py +++ b/src/anyimage/common/viewport.py @@ -10,7 +10,7 @@ from .image import ( image_empty_bounds, is_image_empty, require_image_empty, - video_total_frames, + is_animated_image, ) from .selection import ImageEditWarning, SelectionPath @@ -1019,7 +1019,7 @@ class ImageGesture: return {"CANCELLED"} if source_object is None: return {"CANCELLED"} - if video_total_frames(source_object.data) > 1: + if is_animated_image(source_object.data): self.report({"WARNING"}, "Image gestures only support a still image") return {"CANCELLED"} self.source_object_name = source_object.name diff --git a/src/anyimage/operators/__init__.py b/src/anyimage/operators/__init__.py index fe90d6f..5915f51 100644 --- a/src/anyimage/operators/__init__.py +++ b/src/anyimage/operators/__init__.py @@ -1,17 +1,9 @@ from .remove_background import RemoveImageBackground, RunBackgroundRemoval -from .cutout_tool import ( - CutoutSelectionToShape, - SelectCutoutSelection, -) -from .frame_tool import FrameImages +from .cutout_tool import CLASSES as CUTOUT_CLASSES +from .frame_tool import CLASSES as FRAME_CLASSES from .mask_tool import EditImageAlpha -from .rectify_tool import RectifyImagePerspective -from .convert_to_plane import ( - ConvertToDepthPlane, - ConvertToReliefPlane, - ConvertToPlane, - GenerateDepthPlane, -) +from .rectify_tool import CLASSES as RECTIFY_CLASSES +from .convert_to_plane import CLASSES as PLANE_CLASSES from .ai_setup import ( ClearModels, DownloadModel, @@ -25,21 +17,17 @@ from .convert_to_panorama import CLASSES as PANORAMA_CLASSES CLASSES = ( *PANORAMA_CLASSES, + *PLANE_CLASSES, + *CUTOUT_CLASSES, SetupAIEnvironment, OpenAIEnvironmentSettings, DownloadModel, DownloadRequiredModels, - GenerateDepthPlane, - CutoutSelectionToShape, - SelectCutoutSelection, RunBackgroundRemoval, ClearModels, - FrameImages, + *FRAME_CLASSES, EditImageAlpha, - RectifyImagePerspective, + *RECTIFY_CLASSES, RemoveImageBackground, UpscaleImage, - ConvertToPlane, - ConvertToDepthPlane, - ConvertToReliefPlane, ) diff --git a/src/anyimage/operators/ai_setup.py b/src/anyimage/operators/ai_setup.py index f9c91cb..2fe23b5 100644 --- a/src/anyimage/operators/ai_setup.py +++ b/src/anyimage/operators/ai_setup.py @@ -21,11 +21,8 @@ class SetupAIEnvironment(bpy.types.Operator): bl_description = "Install the environment and required AI models" use_mirror: bpy.props.BoolProperty( - name="Use Mirror", - description=( - "Use the China mirrors for uv, Python and Python packages; " - "model download sources are unchanged" - ), + name="Use China Mirror", + description="Download Python and packages from China mirrors", default=False, ) @@ -34,36 +31,24 @@ class SetupAIEnvironment(bpy.types.Operator): return not runtime.server_busy() def invoke(self, context, _event): - return context.window_manager.invoke_props_dialog(self, width=440) + return context.window_manager.invoke_props_dialog( + self, width=440, title="Set Up AI", confirm_text="Install" + ) def draw(self, _context): layout = self.layout status = ai_status() if not status["environment_ready"]: required_size = sum( - model_download_size(model_key) - for model_key in DEFAULT_MODEL_KEYS + model_download_size(model_key) for model_key in DEFAULT_MODEL_KEYS ) layout.label( - text="AnyImage AI features run in an isolated Python environment." - ) - layout.label( - text="This installs Python, ONNX Runtime and supporting packages." + text="Set up AI features for depth, background removal and upscaling." ) layout.label( text=( - "It also downloads MoGe-2 ViT-S Normal, BiRefNet Lite and " - "Real-ESRGAN General WDN x4v3 " - f"({format_download_size(required_size)})." - ) - ) - layout.label( - text="Allow about 600 MB of downloads and 1 GB of disk space." - ) - layout.label( - text=( - "Installation usually takes 5–20 minutes, " - "depending on network speed." + "Downloads the runtime and required models " + f"({format_download_size(required_size)} for models)." ) ) layout.separator(type="LINE") @@ -71,7 +56,7 @@ class SetupAIEnvironment(bpy.types.Operator): return if status["ready"]: - layout.label(text="The AI Environment is ready.", icon="CHECKMARK") + layout.label(text="AI Server is ready.", icon="CHECKMARK") return labels = [] @@ -83,12 +68,13 @@ class SetupAIEnvironment(bpy.types.Operator): for model_key in status["missing_models"] ) layout.label( - text="The AI Environment is already installed.", + text="AI environment installed.", icon="CHECKMARK", ) - layout.label(text=f"Required models to download: {', '.join(labels)}") - layout.label(text=f"Download size: {format_download_size(missing_size)}") - layout.label(text="The download usually takes 2–10 minutes.") + layout.label(text=f"Download: {', '.join(labels)}") + layout.label( + text=f"{format_download_size(missing_size)} · Usually 2–10 minutes." + ) def execute(self, _context): status = ai_status() @@ -159,7 +145,8 @@ class DownloadRequiredModels(JobOperatorBase, bpy.types.Operator): class ClearModels(bpy.types.Operator): bl_idname = "anyimage.clear_models" - bl_label = "Clear Models" + bl_label = "Unload Models" + bl_description = "Unload AI models to free memory. Downloaded model files are kept." def execute(self, _context): try: diff --git a/src/anyimage/operators/clipboard_image/__init__.py b/src/anyimage/operators/clipboard_image/__init__.py index 96c5e43..a2d62a6 100644 --- a/src/anyimage/operators/clipboard_image/__init__.py +++ b/src/anyimage/operators/clipboard_image/__init__.py @@ -1,11 +1,12 @@ from .operators import ( - CLASSES, PasteClipboardImage, TrackNativeCopy, register_keymaps, unregister_keymaps, ) +CLASSES = (TrackNativeCopy, PasteClipboardImage) + __all__ = ( "CLASSES", "PasteClipboardImage", diff --git a/src/anyimage/operators/clipboard_image/operators.py b/src/anyimage/operators/clipboard_image/operators.py index f62e8e7..fc76450 100644 --- a/src/anyimage/operators/clipboard_image/operators.py +++ b/src/anyimage/operators/clipboard_image/operators.py @@ -148,9 +148,6 @@ class PasteClipboardImage(bpy.types.Operator): return {"FINISHED"} -CLASSES = (TrackNativeCopy, PasteClipboardImage) - - def unregister_keymaps(): global _native_copy_token for keymap, keymap_item in _keymap_items: diff --git a/src/anyimage/operators/convert_to_panorama/operators.py b/src/anyimage/operators/convert_to_panorama/operators.py index 997bedf..c3be452 100644 --- a/src/anyimage/operators/convert_to_panorama/operators.py +++ b/src/anyimage/operators/convert_to_panorama/operators.py @@ -3,7 +3,7 @@ import bpy from ...common.ai import invoke_ai_setup_if_needed, production_device, report_ai_error, require_environment, require_input_path, require_model -from ...common.image import is_image_empty, video_total_frames +from ...common.image import is_image_empty, is_animated_image from ...common.color_image import prepare_material_color_input, material_analysis_input, cleanup_material_color_input from ...preferences import addon_preferences, configured_max_ai_input_size from ...preferences import configured_geometry_model @@ -26,7 +26,7 @@ class GeneratePanorama(JobOperatorBase, bpy.types.Operator): source_object_name: bpy.props.StringProperty(options=_HIDDEN) source_identity: bpy.props.StringProperty(options=_HIDDEN) image_identity: bpy.props.StringProperty(options=_HIDDEN) - mesh_detail: bpy.props.IntProperty(default=7, min=0, max=8, options=_HIDDEN) + mesh_detail: bpy.props.IntProperty(default=8, min=0, max=8, options=_HIDDEN) model: bpy.props.StringProperty(default=DEFAULT_GEOMETRY_MODEL_KEY, options=_HIDDEN) resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=_HIDDEN) max_input_size: bpy.props.IntProperty(default=DEFAULT_MAX_AI_INPUT_SIZE, @@ -39,7 +39,7 @@ class GeneratePanorama(JobOperatorBase, bpy.types.Operator): "input": str(require_input_path(self.input_path)), "device": production_device().lower(), "model": self.model, "resolution_level": int(self.resolution_level), - "max_input_size": int(self.max_input_size), "video_frames": 1, "start_frame": 1, + "max_input_size": int(self.max_input_size), } def response(self, context, result): @@ -53,10 +53,10 @@ class GeneratePanorama(JobOperatorBase, bpy.types.Operator): class ConvertToPanorama(bpy.types.Operator): bl_idname = "anyimage.convert_to_panorama" bl_label = "Convert to Panorama" - bl_description = "Convert a static 2:1 equirectangular Image Empty into a radial depth surface" + bl_description = "Turn a still 2:1 panoramic image into a surrounding 3D surface using AI-estimated depth." bl_options = {"REGISTER", "UNDO"} - mesh_detail: bpy.props.IntProperty(name="Subdivide", default=7, min=0, max=8) + mesh_detail: bpy.props.IntProperty(name="Subdivide", default=8, min=0, max=8) @classmethod def poll(cls, context): @@ -68,7 +68,7 @@ class ConvertToPanorama(bpy.types.Operator): source = context.object image = source.data width, height = image.size - if image.source in {"MOVIE", "SEQUENCE"} or video_total_frames(image) != 1 or height <= 0 or width != height * 2: + if is_animated_image(image) or height <= 0 or width != height * 2: self.report({"ERROR"}, "Panorama conversion requires a static 2:1 equirectangular image") return {"CANCELLED"} setup = invoke_ai_setup_if_needed(self) diff --git a/src/anyimage/operators/convert_to_plane/__init__.py b/src/anyimage/operators/convert_to_plane/__init__.py index 332a97c..a599ab4 100644 --- a/src/anyimage/operators/convert_to_plane/__init__.py +++ b/src/anyimage/operators/convert_to_plane/__init__.py @@ -7,7 +7,10 @@ from .operators import ( GenerateDepthPlane, ) +CLASSES = (GenerateDepthPlane, ConvertToPlane, ConvertToDepthPlane, ConvertToReliefPlane) + __all__ = ( + "CLASSES", "ConvertToDepthPlane", "ConvertToReliefPlane", "ConvertToPlane", diff --git a/src/anyimage/operators/convert_to_plane/operators.py b/src/anyimage/operators/convert_to_plane/operators.py index 35ddf47..c966c37 100644 --- a/src/anyimage/operators/convert_to_plane/operators.py +++ b/src/anyimage/operators/convert_to_plane/operators.py @@ -75,18 +75,14 @@ class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator): parameters = { "input": str(input_path), "device": production_device().lower(), - "video_frames": 1, - "start_frame": 1, "max_input_size": int(self.max_input_size), "plane_type": self.plane_type, } parameters.update( moge2_parameters( - context.scene.anyimage_settings, input_path, self.model, self.resolution_level, - 1, ) ) return parameters @@ -175,7 +171,10 @@ class ConvertToPlane(bpy.types.Operator): class ConvertToDepthPlane(bpy.types.Operator): bl_idname = "anyimage.convert_to_depth_plane" bl_label = "Convert to Depth Plane" - bl_description = "Convert the complete Image Empty into a camera-projected depth plane" + bl_description = ( + "Use AI-estimated depth to turn the image into a 3D surface " + "that follows the source image's perspective." + ) bl_options = {"REGISTER", "UNDO"} mesh_detail: bpy.props.IntProperty(name="Subdivide", default=6, min=0, max=10) @@ -193,7 +192,7 @@ class ConvertToDepthPlane(bpy.types.Operator): class ConvertToReliefPlane(bpy.types.Operator): bl_idname = "anyimage.convert_to_relief_plane" bl_label = "Convert to Relief Plane" - bl_description = "Convert the complete Image Empty into a fixed-base relief plane" + bl_description = "Use AI-estimated depth to create a raised relief on a flat base." bl_options = {"REGISTER", "UNDO"} mesh_detail: bpy.props.IntProperty(name="Subdivide", default=6, min=0, max=10) diff --git a/src/anyimage/operators/cutout_tool/__init__.py b/src/anyimage/operators/cutout_tool/__init__.py index 1e7be1a..c07c960 100644 --- a/src/anyimage/operators/cutout_tool/__init__.py +++ b/src/anyimage/operators/cutout_tool/__init__.py @@ -1,6 +1,9 @@ from .operators import CutoutSelectionToShape, CutoutTool, SelectCutoutSelection +CLASSES = (CutoutSelectionToShape, SelectCutoutSelection) + __all__ = ( + "CLASSES", "CutoutSelectionToShape", "CutoutTool", "SelectCutoutSelection", diff --git a/src/anyimage/operators/cutout_tool/balloon.py b/src/anyimage/operators/cutout_tool/balloon.py new file mode 100644 index 0000000..969e884 --- /dev/null +++ b/src/anyimage/operators/cutout_tool/balloon.py @@ -0,0 +1,396 @@ +"""Compute Poisson and Teddy height profiles for Cutout meshes.""" + +import math + + +DEFAULT_INFLATION = 0.45 + + +def distances_to_contour(points, contour): + import numpy as np + + points = np.asarray(points, dtype=np.float64) + contour = np.asarray(contour, dtype=np.float64) + squared = np.full(len(points), np.inf, dtype=np.float64) + 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 + factor = np.clip( + (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), + ) + return np.sqrt(squared) + + +def chordal_axis(contour, epsilon=1e-9): + import numpy as np + from mathutils import Vector + from mathutils.geometry import delaunay_2d_cdt + + contour = np.asarray(contour, dtype=np.float64) + 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 contour], + boundary_edges, + [list(range(boundary_count))], + 1, + epsilon, + ) + points = np.asarray( + [tuple(point) for point in result[0]], + dtype=np.float64, + ) + triangles = [tuple(face) for face in result[2] if len(face) == 3] + boundary = {tuple(sorted(edge)) for edge in boundary_edges} + node_indices = {} + nodes = [] + segments = set() + + def edge_node(edge): + key = ("edge", *tuple(sorted(edge))) + if key not in node_indices: + node_indices[key] = len(nodes) + nodes.append((points[edge[0]] + points[edge[1]]) * 0.5) + return node_indices[key] + + for face_index, face in enumerate(triangles): + edges = [ + (face[0], face[1]), + (face[1], face[2]), + (face[2], face[0]), + ] + internal = [edge for edge in edges if tuple(sorted(edge)) not in boundary] + internal_nodes = [edge_node(edge) for edge in internal] + if len(internal_nodes) == 2: + segments.add(tuple(sorted(internal_nodes))) + elif len(internal_nodes) == 3: + key = ("face", face_index) + center_index = len(nodes) + node_indices[key] = center_index + nodes.append(points[list(face)].mean(axis=0)) + for midpoint_index in internal_nodes: + segments.add(tuple(sorted((center_index, midpoint_index)))) + + if not nodes: + center = contour.mean(axis=0) + return ( + np.asarray((center,), dtype=np.float64), + [], + np.asarray( + (distances_to_contour((center,), contour)[0],), + dtype=np.float64, + ), + ) + + nodes = np.asarray(nodes, dtype=np.float64) + radii = distances_to_contour(nodes, contour) + active = set(segments) + while len(active) > 1: + degree = [0] * len(nodes) + for start, end in active: + degree[start] += 1 + degree[end] += 1 + removable = [] + for edge in active: + start, end = edge + leaf = start if degree[start] == 1 else end if degree[end] == 1 else None + if leaf is None: + continue + length = float(np.linalg.norm(nodes[start] - nodes[end])) + if length < max(radii[leaf] * 0.5, epsilon): + removable.append(edge) + if not removable: + break + active.difference_update(removable) + + if active: + used = sorted({index for edge in active for index in edge}) + remap = {old: new for new, old in enumerate(used)} + nodes = nodes[used] + radii = radii[used] + active = {tuple(sorted((remap[start], remap[end]))) for start, end in active} + else: + widest = int(np.argmax(radii)) + nodes = nodes[[widest]] + radii = radii[[widest]] + + return nodes, sorted(active), radii + + +def smooth_interior_heights( + heights, + planar_faces, + boundary_mask, + iterations=2, + factor=0.3, +): + import numpy as np + + values = np.asarray(heights, dtype=np.float64).copy() + boundary_mask = np.asarray(boundary_mask, dtype=bool) + neighbors = [set() for _index in range(len(values))] + for face in planar_faces: + for start, end in zip(face, face[1:] + face[:1]): + neighbors[start].add(end) + neighbors[end].add(start) + for _iteration in range(iterations): + updated = values.copy() + for index in np.flatnonzero(~boundary_mask): + adjacent = [ + neighbor for neighbor in neighbors[index] if not boundary_mask[neighbor] + ] + if adjacent: + average = float(values[adjacent].mean()) + updated[index] = values[index] * (1.0 - factor) + average * factor + updated[boundary_mask] = 0.0 + values = updated + return values + + +def teddy_balloon_heights( + planar_points, + planar_faces, + contour, + boundary_mask, + inflation, +): + import numpy as np + + points = np.asarray(planar_points, dtype=np.float64) + boundary_mask = np.asarray(boundary_mask, dtype=bool) + nodes, segments, radii = chordal_axis(contour) + squared_heights = np.zeros(len(points), dtype=np.float64) + + for start, end in segments: + axis_start = nodes[start] + axis_end = nodes[end] + axis = axis_end - axis_start + length_squared = float(axis.dot(axis)) + if length_squared <= 1e-16: + continue + factors = np.clip( + (points - axis_start) @ axis / length_squared, + 0.0, + 1.0, + ) + centers = axis_start + factors[:, None] * axis + local_radii = radii[start] + factors * (radii[end] - radii[start]) + distance_squared = np.sum((points - centers) ** 2, axis=1) + squared_heights = np.maximum( + squared_heights, + local_radii * local_radii - distance_squared, + ) + + degree = np.zeros(len(nodes), dtype=np.int64) + for start, end in segments: + degree[start] += 1 + degree[end] += 1 + for index in np.flatnonzero(degree != 2): + distance_squared = np.sum((points - nodes[index]) ** 2, axis=1) + squared_heights = np.maximum( + squared_heights, + radii[index] * radii[index] - distance_squared, + ) + + missing = (~boundary_mask) & (squared_heights <= 0.0) + if np.any(missing): + fallback = distances_to_contour(points[missing], contour) + squared_heights[missing] = fallback * fallback + heights = inflation * np.sqrt(np.maximum(squared_heights, 0.0)) + heights[boundary_mask] = 0.0 + return smooth_interior_heights( + heights, + planar_faces, + boundary_mask, + ) + + +def cotangent_poisson_field( + planar_points, + planar_faces, + boundary_mask, + tolerance=1e-8, +): + import numpy as np + + points = np.asarray(planar_points, dtype=np.float64) + faces = np.asarray(planar_faces, dtype=np.int64) + if faces.size == 0: + faces = np.empty((0, 3), dtype=np.int64) + if faces.ndim != 2 or faces.shape[1] != 3: + raise ValueError("Balloon profile faces must be triangles") + boundary_mask = np.asarray(boundary_mask, dtype=bool) + triangles = points[faces] + a, b, c = (triangles[:, index] for index in range(3)) + cross = np.abs( + (b[:, 0] - a[:, 0]) * (c[:, 1] - a[:, 1]) + - (b[:, 1] - a[:, 1]) * (c[:, 0] - a[:, 0]) + ) + usable = cross > 1e-14 + faces = faces[usable] + a, b, c = a[usable], b[usable], c[usable] + cross = cross[usable] + loads = np.bincount( + faces.ravel(), + weights=np.repeat(cross / 6.0, 3), + minlength=len(points), + ) + edges = np.concatenate( + ( + faces[:, (1, 2)], + faces[:, (0, 2)], + faces[:, (0, 1)], + ), + axis=0, + ) + edge_weights = np.concatenate( + ( + np.sum((b - a) * (c - a), axis=1), + np.sum((a - b) * (c - b), axis=1), + np.sum((a - c) * (b - c), axis=1), + ) + ) / np.tile(cross * 2.0, 3) + edges.sort(axis=1) + edges, inverse = np.unique(edges, axis=0, return_inverse=True) + edge_weights = np.bincount( + inverse, + weights=edge_weights, + minlength=len(edges), + ) + + interior = np.flatnonzero(~boundary_mask) + field = np.zeros(len(points), dtype=np.float64) + if not len(interior): + return field + compact = np.full(len(points), -1, dtype=np.int64) + compact[interior] = np.arange(len(interior), dtype=np.int64) + # Constrained triangulation can create negative cotangent weights. + # Clamping them keeps the Dirichlet system positive definite. + edge_weights = np.maximum(edge_weights, 0.0) + usable = edge_weights > 1e-14 + compact_edges = compact[edges[usable]] + edge_weights = edge_weights[usable] + start_compact = compact_edges[:, 0] + end_compact = compact_edges[:, 1] + start_usable = start_compact >= 0 + end_usable = end_compact >= 0 + diagonal = np.zeros(len(interior), dtype=np.float64) + diagonal += np.bincount( + start_compact[start_usable], + weights=edge_weights[start_usable], + minlength=len(interior), + ) + diagonal += np.bincount( + end_compact[end_usable], + weights=edge_weights[end_usable], + minlength=len(interior), + ) + if np.any(diagonal <= 1e-14): + raise ValueError("Unable to build a stable Balloon profile") + + interior_edges = start_usable & end_usable + edge_starts = start_compact[interior_edges] + edge_ends = end_compact[interior_edges] + weights = edge_weights[interior_edges] + directed_starts = np.concatenate((edge_starts, edge_ends)) + directed_ends = np.concatenate((edge_ends, edge_starts)) + directed_weights = np.concatenate((weights, weights)) + + def multiply(values): + result = diagonal * values + if len(directed_weights): + result -= np.bincount( + directed_starts, + weights=directed_weights * values[directed_ends], + minlength=len(values), + ) + return result + + right_hand_side = loads[interior] + solution = np.zeros(len(interior), dtype=np.float64) + residual = right_hand_side.copy() + preconditioned = residual / diagonal + direction = preconditioned.copy() + residual_product = float(residual.dot(preconditioned)) + target = max(float(np.linalg.norm(right_hand_side)) * tolerance, 1e-12) + max_iterations = max(64, min(len(interior) * 4, 4000)) + for _iteration in range(max_iterations): + if float(np.linalg.norm(residual)) <= target: + break + multiplied = multiply(direction) + denominator = float(direction.dot(multiplied)) + if denominator <= 0.0 or not math.isfinite(denominator): + raise ValueError("Unable to solve the Balloon profile") + step = residual_product / denominator + solution += step * direction + residual -= step * multiplied + preconditioned = residual / diagonal + next_product = float(residual.dot(preconditioned)) + if not math.isfinite(next_product): + raise ValueError("Unable to solve the Balloon profile") + if abs(residual_product) <= 1e-30: + break + direction = preconditioned + next_product / residual_product * direction + residual_product = next_product + + field[interior] = np.maximum(solution, 0.0) + return field + + +def poisson_balloon_profile( + planar_points, + planar_faces, + boundary_mask, + inflation=DEFAULT_INFLATION, +): + import numpy as np + + field = cotangent_poisson_field( + planar_points, + planar_faces, + boundary_mask, + ) + return 2.0 * float(inflation) * np.sqrt(field) + + +def balloon_profile( + algorithm, + planar_points, + planar_faces, + boundary_mask, + components, + inflation=DEFAULT_INFLATION, +): + import numpy as np + + algorithm = str(algorithm).upper() + if algorithm == "POISSON": + return poisson_balloon_profile( + planar_points, + planar_faces, + boundary_mask, + inflation, + ) + if algorithm != "TEDDY": + raise ValueError(f"Unknown Balloon profile algorithm: {algorithm}") + result = np.zeros(len(planar_points), dtype=np.float64) + for start, end, component_faces, outer_contour in components: + result[start:end] = teddy_balloon_heights( + np.asarray(planar_points)[start:end], + component_faces, + outer_contour, + np.asarray(boundary_mask)[start:end], + inflation, + ) + return result diff --git a/src/anyimage/operators/cutout_tool/depth_calibration.py b/src/anyimage/operators/cutout_tool/depth_calibration.py new file mode 100644 index 0000000..e7f83ff --- /dev/null +++ b/src/anyimage/operators/cutout_tool/depth_calibration.py @@ -0,0 +1,169 @@ +"""Calibrate metric reference depth and the Depth Balloon base plane.""" + +PLANE_VARIANCE_FLOOR_RATIO = 0.025 +DEPTH_REFERENCE_PERCENTILE = 95.0 +ADAPTIVE_DEPTH_TRANSPARENCY_RATIO = 0.05 +DEFAULT_DEPTH_BASELINE = 8.0 +DEFAULT_DEPTH_DIRECTION = (0.0, 0.0, 1.0) + + +def use_adaptive_depth_calibration(selection_transparent_fraction): + """Return whether the Selection input has enough transparent context.""" + return float(selection_transparent_fraction) >= ADAPTIVE_DEPTH_TRANSPARENCY_RATIO + + +def metric_depth_reference(camera_depth, valid, uniform_scale): + """Return the shared upper-percentile scene depth for both Depth shapes.""" + import numpy as np + + camera_depth = np.asarray(camera_depth, dtype=np.float32) + valid = np.asarray(valid, dtype=bool) + if valid.shape != camera_depth.shape: + raise ValueError("Depth values and validity mask dimensions do not match") + usable = valid & np.isfinite(camera_depth) & (camera_depth > 0.0) + if not usable.any(): + raise ValueError("The depth field contains no valid metric depth") + if not uniform_scale > 0.0: + raise ValueError("Depth Uniform Scale must be positive") + reference = np.percentile( + camera_depth[usable], + DEPTH_REFERENCE_PERCENTILE, + ) + return float(reference) * float(uniform_scale) + + +def _sample_valid_bilinear(values, valid, x, y): + import numpy as np + + height, width = values.shape + x = np.clip(x, 0.0, max(width - 1, 0)) + y = np.clip(y, 0.0, max(height - 1, 0)) + x0 = np.floor(x).astype(np.int64) + y0 = np.floor(y).astype(np.int64) + x1 = np.minimum(x0 + 1, width - 1) + y1 = np.minimum(y0 + 1, height - 1) + fx = x - x0 + fy = y - y0 + samples = np.stack( + ( + values[y0, x0], + values[y0, x1], + values[y1, x0], + values[y1, x1], + ), + axis=1, + ) + weights = np.stack( + ( + (1.0 - fx) * (1.0 - fy), + fx * (1.0 - fy), + (1.0 - fx) * fy, + fx * fy, + ), + axis=1, + ) + sample_valid = np.stack( + ( + valid[y0, x0], + valid[y0, x1], + valid[y1, x0], + valid[y1, x1], + ), + axis=1, + ) + weights *= sample_valid + totals = weights.sum(axis=1) + result = np.zeros(len(x), dtype=np.float32) + usable = totals > 1e-8 + result[usable] = (samples[usable] * weights[usable]).sum(axis=1) / totals[usable] + return result, usable + + +def _weighted_plane(coordinates, values, weights): + import numpy as np + + total = float(np.sum(weights)) + if total <= 1e-12: + return np.zeros(3, dtype=np.float32) + center = np.sum(coordinates * weights[:, None], axis=0) / total + value_center = float(np.sum(values * weights) / total) + centered = coordinates - center + covariance = (centered * weights[:, None]).T @ centered / total + cross = centered.T @ (weights * (values - value_center)) / total + eigenvalues, eigenvectors = np.linalg.eigh(covariance) + largest = max(float(eigenvalues[-1]), 0.0) + if largest <= 1e-12: + slopes = np.zeros(2, dtype=np.float64) + else: + supported = np.maximum(eigenvalues, largest * PLANE_VARIANCE_FLOOR_RATIO) + slopes = eigenvectors @ ((eigenvectors.T @ cross) / supported) + intercept = value_center - float(center @ slopes) + return np.asarray((*slopes, intercept), dtype=np.float32) + + +def _stable_plane(coordinates, values): + import numpy as np + + coordinates = np.asarray(coordinates, dtype=np.float64) + values = np.asarray(values, dtype=np.float64) + if len(coordinates) < 2: + return np.asarray((0.0, 0.0, np.median(values)), dtype=np.float32) + weights = np.ones(len(values), dtype=np.float64) + plane = np.zeros(3, dtype=np.float32) + for _iteration in range(4): + plane = _weighted_plane(coordinates, values, weights) + residual = values - coordinates @ plane[:2] - plane[2] + centered = residual - np.median(residual) + scale = max(float(np.median(np.abs(centered))) * 1.4826, 1e-6) + weights = np.minimum( + 1.0, + (2.5 * scale) / np.maximum(np.abs(centered), 1e-12), + ) + return plane + + +def depth_balloon_projection_parameters( + vertices, + vertex_uv, + camera_depth, + valid, + uniform_scale, +): + """Fit a metric base plane and return its center depth and direction.""" + import numpy as np + + vertices = np.asarray(vertices, dtype=np.float32) + uv = np.asarray(vertex_uv, dtype=np.float32) + if vertices.ndim != 2 or vertices.shape[1] != 3: + raise ValueError("Depth Balloon vertices must have three components") + if uv.shape != (len(vertices), 2): + raise ValueError("Depth Balloon UV coordinates do not match its vertices") + camera_depth = np.asarray(camera_depth, dtype=np.float32) + valid = np.asarray(valid, dtype=bool) + if valid.shape != camera_depth.shape: + raise ValueError("Depth Balloon depth and mask dimensions do not match") + if not uniform_scale > 0.0: + raise ValueError("Depth Balloon Uniform Scale must be positive") + height, width = camera_depth.shape + sample_x = uv[:, 0] * max(width - 1, 0) + sample_y = (1.0 - uv[:, 1]) * max(height - 1, 0) + calibration_valid = valid & np.isfinite(camera_depth) & (camera_depth > 0.0) + sampled, usable = _sample_valid_bilinear( + camera_depth, + calibration_valid, + sample_x, + sample_y, + ) + if not usable.any(): + raise ValueError("MoGe-2 found no valid Depth Balloon samples") + local_coordinates = vertices[:, :2] + metric_depth = sampled[usable] * float(uniform_scale) + plane = _stable_plane(local_coordinates[usable], metric_depth) + reference = metric_depth_reference( + camera_depth, + calibration_valid, + uniform_scale, + ) + direction = np.asarray((plane[0], plane[1], 1.0), dtype=np.float64) + direction /= np.linalg.norm(direction) + return reference, tuple(float(value) for value in direction) diff --git a/src/anyimage/operators/cutout_tool/geometry.py b/src/anyimage/operators/cutout_tool/geometry.py index 20fb54b..b641e81 100644 --- a/src/anyimage/operators/cutout_tool/geometry.py +++ b/src/anyimage/operators/cutout_tool/geometry.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from ...preferences import addon_preferences from ...common.image import image_empty_bounds +from . import balloon @dataclass(frozen=True) @@ -661,568 +662,6 @@ def triangulate_selection_contours( return (*result, components) if return_components else result -DEFAULT_INFLATION = 0.45 - - -def distances_to_contour(points, contour): - import numpy as np - - points = np.asarray(points, dtype=np.float64) - contour = np.asarray(contour, dtype=np.float64) - squared = np.full(len(points), np.inf, dtype=np.float64) - 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 - factor = np.clip( - (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), - ) - return np.sqrt(squared) - - -def chordal_axis(contour, epsilon=1e-9): - import numpy as np - from mathutils import Vector - from mathutils.geometry import delaunay_2d_cdt - - contour = np.asarray(contour, dtype=np.float64) - 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 contour], - boundary_edges, - [list(range(boundary_count))], - 1, - epsilon, - ) - points = np.asarray( - [tuple(point) for point in result[0]], - dtype=np.float64, - ) - triangles = [tuple(face) for face in result[2] if len(face) == 3] - boundary = {tuple(sorted(edge)) for edge in boundary_edges} - node_indices = {} - nodes = [] - segments = set() - - def edge_node(edge): - key = ("edge", *tuple(sorted(edge))) - if key not in node_indices: - node_indices[key] = len(nodes) - nodes.append((points[edge[0]] + points[edge[1]]) * 0.5) - return node_indices[key] - - for face_index, face in enumerate(triangles): - edges = [ - (face[0], face[1]), - (face[1], face[2]), - (face[2], face[0]), - ] - internal = [edge for edge in edges if tuple(sorted(edge)) not in boundary] - internal_nodes = [edge_node(edge) for edge in internal] - if len(internal_nodes) == 2: - segments.add(tuple(sorted(internal_nodes))) - elif len(internal_nodes) == 3: - key = ("face", face_index) - center_index = len(nodes) - node_indices[key] = center_index - nodes.append(points[list(face)].mean(axis=0)) - for midpoint_index in internal_nodes: - segments.add(tuple(sorted((center_index, midpoint_index)))) - - if not nodes: - center = contour.mean(axis=0) - return ( - np.asarray((center,), dtype=np.float64), - [], - np.asarray( - (distances_to_contour((center,), contour)[0],), - dtype=np.float64, - ), - ) - - nodes = np.asarray(nodes, dtype=np.float64) - radii = distances_to_contour(nodes, contour) - active = set(segments) - while len(active) > 1: - degree = [0] * len(nodes) - for start, end in active: - degree[start] += 1 - degree[end] += 1 - removable = [] - for edge in active: - start, end = edge - leaf = start if degree[start] == 1 else end if degree[end] == 1 else None - if leaf is None: - continue - length = float(np.linalg.norm(nodes[start] - nodes[end])) - if length < max(radii[leaf] * 0.5, epsilon): - removable.append(edge) - if not removable: - break - active.difference_update(removable) - - if active: - used = sorted({index for edge in active for index in edge}) - remap = {old: new for new, old in enumerate(used)} - nodes = nodes[used] - radii = radii[used] - active = {tuple(sorted((remap[start], remap[end]))) for start, end in active} - else: - widest = int(np.argmax(radii)) - nodes = nodes[[widest]] - radii = radii[[widest]] - - return nodes, sorted(active), radii - - -def smooth_interior_heights( - heights, - planar_faces, - boundary_mask, - iterations=2, - factor=0.3, -): - import numpy as np - - values = np.asarray(heights, dtype=np.float64).copy() - boundary_mask = np.asarray(boundary_mask, dtype=bool) - neighbors = [set() for _index in range(len(values))] - for face in planar_faces: - for start, end in zip(face, face[1:] + face[:1]): - neighbors[start].add(end) - neighbors[end].add(start) - for _iteration in range(iterations): - updated = values.copy() - for index in np.flatnonzero(~boundary_mask): - adjacent = [ - neighbor for neighbor in neighbors[index] if not boundary_mask[neighbor] - ] - if adjacent: - average = float(values[adjacent].mean()) - updated[index] = values[index] * (1.0 - factor) + average * factor - updated[boundary_mask] = 0.0 - values = updated - return values - - -def teddy_balloon_heights( - planar_points, - planar_faces, - contour, - boundary_mask, - inflation, -): - import numpy as np - - points = np.asarray(planar_points, dtype=np.float64) - boundary_mask = np.asarray(boundary_mask, dtype=bool) - nodes, segments, radii = chordal_axis(contour) - squared_heights = np.zeros(len(points), dtype=np.float64) - - for start, end in segments: - axis_start = nodes[start] - axis_end = nodes[end] - axis = axis_end - axis_start - length_squared = float(axis.dot(axis)) - if length_squared <= 1e-16: - continue - factors = np.clip( - (points - axis_start) @ axis / length_squared, - 0.0, - 1.0, - ) - centers = axis_start + factors[:, None] * axis - local_radii = radii[start] + factors * (radii[end] - radii[start]) - distance_squared = np.sum((points - centers) ** 2, axis=1) - squared_heights = np.maximum( - squared_heights, - local_radii * local_radii - distance_squared, - ) - - degree = np.zeros(len(nodes), dtype=np.int64) - for start, end in segments: - degree[start] += 1 - degree[end] += 1 - for index in np.flatnonzero(degree != 2): - distance_squared = np.sum((points - nodes[index]) ** 2, axis=1) - squared_heights = np.maximum( - squared_heights, - radii[index] * radii[index] - distance_squared, - ) - - missing = (~boundary_mask) & (squared_heights <= 0.0) - if np.any(missing): - fallback = distances_to_contour(points[missing], contour) - squared_heights[missing] = fallback * fallback - heights = inflation * np.sqrt(np.maximum(squared_heights, 0.0)) - heights[boundary_mask] = 0.0 - return smooth_interior_heights( - heights, - planar_faces, - boundary_mask, - ) - - -def cotangent_poisson_field( - planar_points, - planar_faces, - boundary_mask, - tolerance=1e-8, -): - import numpy as np - - points = np.asarray(planar_points, dtype=np.float64) - faces = np.asarray(planar_faces, dtype=np.int64) - if faces.size == 0: - faces = np.empty((0, 3), dtype=np.int64) - if faces.ndim != 2 or faces.shape[1] != 3: - raise ValueError("Balloon profile faces must be triangles") - boundary_mask = np.asarray(boundary_mask, dtype=bool) - triangles = points[faces] - a, b, c = (triangles[:, index] for index in range(3)) - cross = np.abs( - (b[:, 0] - a[:, 0]) * (c[:, 1] - a[:, 1]) - - (b[:, 1] - a[:, 1]) * (c[:, 0] - a[:, 0]) - ) - usable = cross > 1e-14 - faces = faces[usable] - a, b, c = a[usable], b[usable], c[usable] - cross = cross[usable] - loads = np.bincount( - faces.ravel(), - weights=np.repeat(cross / 6.0, 3), - minlength=len(points), - ) - edges = np.concatenate( - ( - faces[:, (1, 2)], - faces[:, (0, 2)], - faces[:, (0, 1)], - ), - axis=0, - ) - edge_weights = np.concatenate( - ( - np.sum((b - a) * (c - a), axis=1), - np.sum((a - b) * (c - b), axis=1), - np.sum((a - c) * (b - c), axis=1), - ) - ) / np.tile(cross * 2.0, 3) - edges.sort(axis=1) - edges, inverse = np.unique(edges, axis=0, return_inverse=True) - edge_weights = np.bincount( - inverse, - weights=edge_weights, - minlength=len(edges), - ) - - interior = np.flatnonzero(~boundary_mask) - field = np.zeros(len(points), dtype=np.float64) - if not len(interior): - return field - compact = np.full(len(points), -1, dtype=np.int64) - compact[interior] = np.arange(len(interior), dtype=np.int64) - # Constrained triangulation can create negative cotangent weights. - # Clamping them keeps the Dirichlet system positive definite. - edge_weights = np.maximum(edge_weights, 0.0) - usable = edge_weights > 1e-14 - compact_edges = compact[edges[usable]] - edge_weights = edge_weights[usable] - start_compact = compact_edges[:, 0] - end_compact = compact_edges[:, 1] - start_usable = start_compact >= 0 - end_usable = end_compact >= 0 - diagonal = np.zeros(len(interior), dtype=np.float64) - diagonal += np.bincount( - start_compact[start_usable], - weights=edge_weights[start_usable], - minlength=len(interior), - ) - diagonal += np.bincount( - end_compact[end_usable], - weights=edge_weights[end_usable], - minlength=len(interior), - ) - if np.any(diagonal <= 1e-14): - raise ValueError("Unable to build a stable Balloon profile") - - interior_edges = start_usable & end_usable - edge_starts = start_compact[interior_edges] - edge_ends = end_compact[interior_edges] - weights = edge_weights[interior_edges] - directed_starts = np.concatenate((edge_starts, edge_ends)) - directed_ends = np.concatenate((edge_ends, edge_starts)) - directed_weights = np.concatenate((weights, weights)) - - def multiply(values): - result = diagonal * values - if len(directed_weights): - result -= np.bincount( - directed_starts, - weights=directed_weights * values[directed_ends], - minlength=len(values), - ) - return result - - right_hand_side = loads[interior] - solution = np.zeros(len(interior), dtype=np.float64) - residual = right_hand_side.copy() - preconditioned = residual / diagonal - direction = preconditioned.copy() - residual_product = float(residual.dot(preconditioned)) - target = max(float(np.linalg.norm(right_hand_side)) * tolerance, 1e-12) - max_iterations = max(64, min(len(interior) * 4, 4000)) - for _iteration in range(max_iterations): - if float(np.linalg.norm(residual)) <= target: - break - multiplied = multiply(direction) - denominator = float(direction.dot(multiplied)) - if denominator <= 0.0 or not math.isfinite(denominator): - raise ValueError("Unable to solve the Balloon profile") - step = residual_product / denominator - solution += step * direction - residual -= step * multiplied - preconditioned = residual / diagonal - next_product = float(residual.dot(preconditioned)) - if not math.isfinite(next_product): - raise ValueError("Unable to solve the Balloon profile") - if abs(residual_product) <= 1e-30: - break - direction = preconditioned + next_product / residual_product * direction - residual_product = next_product - - field[interior] = np.maximum(solution, 0.0) - return field - - -def poisson_balloon_profile( - planar_points, - planar_faces, - boundary_mask, - inflation=DEFAULT_INFLATION, -): - import numpy as np - - field = cotangent_poisson_field( - planar_points, - planar_faces, - boundary_mask, - ) - return 2.0 * float(inflation) * np.sqrt(field) - - -def balloon_profile( - algorithm, - planar_points, - planar_faces, - boundary_mask, - components, - inflation=DEFAULT_INFLATION, -): - import numpy as np - - algorithm = str(algorithm).upper() - if algorithm == "POISSON": - return poisson_balloon_profile( - planar_points, - planar_faces, - boundary_mask, - inflation, - ) - if algorithm != "TEDDY": - raise ValueError(f"Unknown Balloon profile algorithm: {algorithm}") - result = np.zeros(len(planar_points), dtype=np.float64) - for start, end, component_faces, outer_contour in components: - result[start:end] = teddy_balloon_heights( - np.asarray(planar_points)[start:end], - component_faces, - outer_contour, - np.asarray(boundary_mask)[start:end], - inflation, - ) - return result - - -PLANE_VARIANCE_FLOOR_RATIO = 0.025 -DEPTH_REFERENCE_PERCENTILE = 95.0 -ADAPTIVE_DEPTH_TRANSPARENCY_RATIO = 0.05 -DEFAULT_DEPTH_BASELINE = 8.0 -DEFAULT_DEPTH_DIRECTION = (0.0, 0.0, 1.0) - - -def use_adaptive_depth_calibration(selection_transparent_fraction): - """Return whether the Selection input has enough transparent context.""" - return float(selection_transparent_fraction) >= ADAPTIVE_DEPTH_TRANSPARENCY_RATIO - - -def metric_depth_reference(camera_depth, valid, uniform_scale): - """Return the shared upper-percentile scene depth for both Depth shapes.""" - import numpy as np - - camera_depth = np.asarray(camera_depth, dtype=np.float32) - valid = np.asarray(valid, dtype=bool) - if valid.shape != camera_depth.shape: - raise ValueError("Depth values and validity mask dimensions do not match") - usable = valid & np.isfinite(camera_depth) & (camera_depth > 0.0) - if not usable.any(): - raise ValueError("The depth field contains no valid metric depth") - if not uniform_scale > 0.0: - raise ValueError("Depth Uniform Scale must be positive") - reference = np.percentile( - camera_depth[usable], - DEPTH_REFERENCE_PERCENTILE, - ) - return float(reference) * float(uniform_scale) - - -def _sample_valid_bilinear(values, valid, x, y): - import numpy as np - - height, width = values.shape - x = np.clip(x, 0.0, max(width - 1, 0)) - y = np.clip(y, 0.0, max(height - 1, 0)) - x0 = np.floor(x).astype(np.int64) - y0 = np.floor(y).astype(np.int64) - x1 = np.minimum(x0 + 1, width - 1) - y1 = np.minimum(y0 + 1, height - 1) - fx = x - x0 - fy = y - y0 - samples = np.stack( - ( - values[y0, x0], - values[y0, x1], - values[y1, x0], - values[y1, x1], - ), - axis=1, - ) - weights = np.stack( - ( - (1.0 - fx) * (1.0 - fy), - fx * (1.0 - fy), - (1.0 - fx) * fy, - fx * fy, - ), - axis=1, - ) - sample_valid = np.stack( - ( - valid[y0, x0], - valid[y0, x1], - valid[y1, x0], - valid[y1, x1], - ), - axis=1, - ) - weights *= sample_valid - totals = weights.sum(axis=1) - result = np.zeros(len(x), dtype=np.float32) - usable = totals > 1e-8 - result[usable] = (samples[usable] * weights[usable]).sum(axis=1) / totals[usable] - return result, usable - - -def _weighted_plane(coordinates, values, weights): - import numpy as np - - total = float(np.sum(weights)) - if total <= 1e-12: - return np.zeros(3, dtype=np.float32) - center = np.sum(coordinates * weights[:, None], axis=0) / total - value_center = float(np.sum(values * weights) / total) - centered = coordinates - center - covariance = (centered * weights[:, None]).T @ centered / total - cross = centered.T @ (weights * (values - value_center)) / total - eigenvalues, eigenvectors = np.linalg.eigh(covariance) - largest = max(float(eigenvalues[-1]), 0.0) - if largest <= 1e-12: - slopes = np.zeros(2, dtype=np.float64) - else: - supported = np.maximum(eigenvalues, largest * PLANE_VARIANCE_FLOOR_RATIO) - slopes = eigenvectors @ ((eigenvectors.T @ cross) / supported) - intercept = value_center - float(center @ slopes) - return np.asarray((*slopes, intercept), dtype=np.float32) - - -def _stable_plane(coordinates, values): - import numpy as np - - coordinates = np.asarray(coordinates, dtype=np.float64) - values = np.asarray(values, dtype=np.float64) - if len(coordinates) < 2: - return np.asarray((0.0, 0.0, np.median(values)), dtype=np.float32) - weights = np.ones(len(values), dtype=np.float64) - plane = np.zeros(3, dtype=np.float32) - for _iteration in range(4): - plane = _weighted_plane(coordinates, values, weights) - residual = values - coordinates @ plane[:2] - plane[2] - centered = residual - np.median(residual) - scale = max(float(np.median(np.abs(centered))) * 1.4826, 1e-6) - weights = np.minimum( - 1.0, - (2.5 * scale) / np.maximum(np.abs(centered), 1e-12), - ) - return plane - - -def depth_balloon_projection_parameters( - vertices, - vertex_uv, - camera_depth, - valid, - uniform_scale, -): - """Fit a metric base plane and return its center depth and direction.""" - import numpy as np - - vertices = np.asarray(vertices, dtype=np.float32) - uv = np.asarray(vertex_uv, dtype=np.float32) - if vertices.ndim != 2 or vertices.shape[1] != 3: - raise ValueError("Depth Balloon vertices must have three components") - if uv.shape != (len(vertices), 2): - raise ValueError("Depth Balloon UV coordinates do not match its vertices") - camera_depth = np.asarray(camera_depth, dtype=np.float32) - valid = np.asarray(valid, dtype=bool) - if valid.shape != camera_depth.shape: - raise ValueError("Depth Balloon depth and mask dimensions do not match") - if not uniform_scale > 0.0: - raise ValueError("Depth Balloon Uniform Scale must be positive") - height, width = camera_depth.shape - sample_x = uv[:, 0] * max(width - 1, 0) - sample_y = (1.0 - uv[:, 1]) * max(height - 1, 0) - calibration_valid = valid & np.isfinite(camera_depth) & (camera_depth > 0.0) - sampled, usable = _sample_valid_bilinear( - camera_depth, - calibration_valid, - sample_x, - sample_y, - ) - if not usable.any(): - raise ValueError("MoGe-2 found no valid Depth Balloon samples") - local_coordinates = vertices[:, :2] - metric_depth = sampled[usable] * float(uniform_scale) - plane = _stable_plane(local_coordinates[usable], metric_depth) - reference = metric_depth_reference( - camera_depth, - calibration_valid, - uniform_scale, - ) - direction = np.asarray((plane[0], plane[1], 1.0), dtype=np.float64) - direction /= np.linalg.norm(direction) - return reference, tuple(float(value) for value in direction) - - CUTOUT_ALPHA_THRESHOLD = 0.9 CUTOUT_CLEANUP_UI_SCALE = 100.0 @@ -1313,7 +752,7 @@ def cutout_domain( planar = np.concatenate(refined_points) planar_faces, components = refined_faces, refined_components boundary = boundary_vertices_from_faces(len(planar), planar_faces) - profile = balloon_profile( + profile = balloon.balloon_profile( balloon_algorithm, planar, planar_faces, boundary, components ) faces = np.asarray(planar_faces, dtype=np.int32) diff --git a/src/anyimage/operators/cutout_tool/interaction.py b/src/anyimage/operators/cutout_tool/interaction.py index 7c97b87..13aed04 100644 --- a/src/anyimage/operators/cutout_tool/interaction.py +++ b/src/anyimage/operators/cutout_tool/interaction.py @@ -31,9 +31,9 @@ def _pie_button( selection_path_json, generate_normal, point_spacing, - inward_axis, alpha_threshold, fine_outline, + gesture, ): operator = pie.operator( "anyimage.cutout_selection_to_shape", @@ -45,9 +45,9 @@ def _pie_button( operator.shape = shape operator.generate_normal = generate_normal operator.point_spacing = point_spacing - operator.inward_axis = inward_axis operator.alpha_threshold = alpha_threshold operator.fine_outline = fine_outline + operator.gesture = gesture def open_shape_pie( @@ -55,6 +55,8 @@ def open_shape_pie( event, source_object, selection_path, + *, + gesture="LASSO", ): source_name = source_object.name selection_path_json = selection_path.to_json() @@ -64,7 +66,6 @@ def open_shape_pie( generate_normal = ready and bool( getattr(settings, "cutout_generate_normal", False) ) - inward_axis = str(getattr(settings, "cutout_inward_axis", "NEGATIVE_Z")) alpha_threshold = settings.cutout_alpha_threshold fine_outline = settings.cutout_fine_outline @@ -81,9 +82,9 @@ def open_shape_pie( selection_path_json, generate_normal, point_spacing, - inward_axis, alpha_threshold, fine_outline, + gesture, ) context.window_manager.popup_menu_pie( diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py index e42e48b..8fd4aa4 100644 --- a/src/anyimage/operators/cutout_tool/object.py +++ b/src/anyimage/operators/cutout_tool/object.py @@ -15,23 +15,24 @@ from ...common.object import ( set_modifier_input, ) from ...common.node import load_node_group -from .geometry import ( - CUTOUT_ALPHA_THRESHOLD, +from .depth_calibration import ( DEFAULT_DEPTH_BASELINE, DEFAULT_DEPTH_DIRECTION, + depth_balloon_projection_parameters, + metric_depth_reference, + use_adaptive_depth_calibration, +) +from .geometry import ( + CUTOUT_ALPHA_THRESHOLD, crop_plane_bounds, cutout_cleanup_value, cutout_sampling_spacing, - depth_balloon_projection_parameters, - metric_depth_reference, selection_calibration, - use_adaptive_depth_calibration, world_plane_metric, ) from .shape import ( BALLOON_ATTRIBUTE_NAME, NORMAL_SCALE_ATTRIBUTE_NAME, - CUTOUT_X_TO_Z, CUTOUT_NODE_GROUP_NAMES, DEPTH_CUTOUT_SHAPES, ) @@ -43,38 +44,11 @@ class NormalMode(StrEnum): OBJECT = "OBJECT" -class CutoutInwardAxis(StrEnum): - NEGATIVE_Z = "NEGATIVE_Z" - POSITIVE_X = "POSITIVE_X" - - -def _cutout_world_matrix(source_matrix, crop_center, inward_axis): +def _cutout_world_matrix(source_matrix, crop_center): from mathutils import Matrix center_x, center_y = (float(value) for value in crop_center) - matrix = source_matrix @ Matrix.Translation((center_x, center_y, 0.0)) - if CutoutInwardAxis(inward_axis) == CutoutInwardAxis.POSITIVE_X: - matrix @= Matrix(CUTOUT_X_TO_Z) - return matrix - - -def orient_object_normal_image(image, inward_axis): - """Convert native Z-axis Cutout normals to the selected object axes.""" - if CutoutInwardAxis(inward_axis) == CutoutInwardAxis.NEGATIVE_Z: - return image - import numpy as np - - pixels = np.empty(int(image.size[0]) * int(image.size[1]) * 4, dtype=np.float32) - image.pixels.foreach_get(pixels) - rgba = pixels.reshape((-1, 4)) - normal = rgba[:, :3] * 2.0 - 1.0 - rgba[:, :3] = np.column_stack( - (-normal[:, 2], -normal[:, 0], normal[:, 1]) - ) * 0.5 + 0.5 - image.pixels.foreach_set(pixels) - image.update() - image.pack() - return image + return source_matrix @ Matrix.Translation((center_x, center_y, 0.0)) def _assign_uv(mesh, uv): @@ -99,7 +73,7 @@ def _assign_float_point_attribute(mesh, name, values): def load_cutout_node_group(shape): - return load_node_group(CUTOUT_NODE_GROUP_NAMES[shape], asset_revision=23 if shape == "DEPTH_SOLID" else 12 if shape == "DEPTH_BALLOON" else 11) + return load_node_group(CUTOUT_NODE_GROUP_NAMES[shape], asset_revision=26 if shape == "DEPTH_SOLID" else 14 if shape == "DEPTH_BALLOON" else 13) def _connect_cutout_normal_scale(material): @@ -134,13 +108,12 @@ def create_shape_object( depth_metadata=None, normal_image=None, normal_mode=NormalMode.NONE, - inward_axis=CutoutInwardAxis.NEGATIVE_Z, color_image, + gesture="LASSO", ): import numpy as np normal_mode = NormalMode(normal_mode) - inward_axis = CutoutInwardAxis(inward_axis) bounds = crop_plane_bounds( source_object, content_bounds, @@ -229,7 +202,6 @@ def create_shape_object( mesh_object.matrix_world = _cutout_world_matrix( source_object.matrix_world, crop_center, - inward_axis, ) mesh_object["anyimage_mesh_shape"] = shape node_group = load_cutout_node_group(shape) @@ -237,10 +209,6 @@ def create_shape_object( modifier.node_group = node_group modifier.show_group_selector = False for name, value in ( - ( - "Inward Axis", - 0 if inward_axis == CutoutInwardAxis.NEGATIVE_Z else 1, - ), ("Cleanup Threshold", cleanup_value), ( "Thickness", @@ -249,9 +217,24 @@ def create_shape_object( ): set_modifier_input( modifier, - modifier_input_identifier(node_group, name), + modifier_input_identifier( + node_group, name, + subtype="NONE" if name == "Thickness" and shape == "DEPTH_SOLID" else None, + ), value, ) + if shape in {"FLAT", "SOLID", "DEPTH_SOLID"} and gesture == "POLYLINE": + set_modifier_input( + modifier, + modifier_input_identifier(node_group, "Mode"), + 1, + ) + if shape == "SOLID": + set_modifier_input( + modifier, + modifier_input_identifier(node_group, "Thickness", subtype="DISTANCE"), + 0.5, + ) if shape in DEPTH_CUTOUT_SHAPES: for name, value in ( ("Depth Scale", 1.0), diff --git a/src/anyimage/operators/cutout_tool/operators.py b/src/anyimage/operators/cutout_tool/operators.py index 1a274b8..0ce6902 100644 --- a/src/anyimage/operators/cutout_tool/operators.py +++ b/src/anyimage/operators/cutout_tool/operators.py @@ -13,6 +13,7 @@ from ...preferences import ( from ...properties import ( cutout_alpha_threshold_property, cutout_fine_outline_property, + cutout_gesture_property, ) from ...runtime import JobOperatorBase from ...server.model_catalog import DEFAULT_GEOMETRY_MODEL_KEY @@ -35,7 +36,7 @@ from ...common.image import ( image_rgba, load_normal_result_image, require_image_empty, - video_total_frames, + is_animated_image, ) from ...common.depth import load_depth_metadata, load_depth_result_image from ...common.color_image import ( @@ -47,7 +48,7 @@ from ...common.color_image import ( ) from ...common.viewport import ( ImageGesture, - image_edit_drag_keymap, + image_edit_point_keymap, report_image_edit_exception, ) from ...common.selection import ( @@ -62,10 +63,8 @@ from .geometry import ( ) from .interaction import open_shape_pie from .object import ( - CutoutInwardAxis, NormalMode, create_shape_object, - orient_object_normal_image, ) from .shape import CUTOUT_SHAPES, DEPTH_CUTOUT_SHAPES @@ -100,8 +99,8 @@ def create_cutout_shape( depth_metadata=None, normal_image=None, normal_mode=NormalMode.NONE, - inward_axis=CutoutInwardAxis.NEGATIVE_Z, color_image, + gesture="LASSO", ): base_shape = build_base_shape( context, @@ -125,8 +124,8 @@ def create_cutout_shape( depth_metadata=depth_metadata, normal_image=normal_image, normal_mode=normal_mode, - inward_axis=inward_axis, color_image=color_image, + gesture=gesture, ) undo_message = f"Convert to {shape.replace('_', ' ').title()}" if "FINISHED" not in bpy.ops.ed.undo_push(message=undo_message): @@ -191,6 +190,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): selection_path_json: bpy.props.StringProperty(options=_HIDDEN) selection_bounds_json: bpy.props.StringProperty(options=_HIDDEN) shape: bpy.props.StringProperty(default="SOLID", options=_HIDDEN) + gesture: bpy.props.StringProperty(default="LASSO", options=_HIDDEN) point_spacing: bpy.props.FloatProperty( default=32.0, min=0.01, @@ -215,23 +215,13 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): generate_normal: bpy.props.BoolProperty(default=False, options=_HIDDEN) alpha_threshold: cutout_alpha_threshold_property() fine_outline: cutout_fine_outline_property() - inward_axis: bpy.props.StringProperty( - default=CutoutInwardAxis.NEGATIVE_Z.value, - options=_HIDDEN, - ) - def _needs_generation(self): return self.shape in DEPTH_CUTOUT_SHAPES or self.generate_normal def execute(self, context): try: - self.inward_axis = getattr( - self, - "inward_axis", - CutoutInwardAxis.NEGATIVE_Z.value, - ) source_object = require_image_empty(self.source_object_name) - if video_total_frames(source_object.data) != 1: + if is_animated_image(source_object.data): raise ValueError("Cutout currently supports still images") if self.shape not in CUTOUT_SHAPES: raise ValueError(f"Unsupported Cutout shape: {self.shape}") @@ -263,8 +253,9 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): create_cutout_shape( context, source_object, self.shape, self.point_spacing, content_values, selection_mask.bounds, - inward_axis=self.inward_axis, alpha_threshold=self.alpha_threshold, + alpha_threshold=self.alpha_threshold, fine_outline=self.fine_outline, color_image=color_image, + gesture=self.gesture, ) return {"FINISHED"} setup_result = invoke_ai_setup_if_needed(self) @@ -317,21 +308,14 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): ) parameters.update( moge2_parameters( - context.scene.anyimage_settings, source_path, self.model, self.resolution_level, - 1, ) ) return parameters def response(self, context, result): - inward_axis = getattr( - self, - "inward_axis", - CutoutInwardAxis.NEGATIVE_Z.value, - ) source_object = require_image_empty(self.source_object_name) source_bounds = tuple(json.loads(self.selection_bounds_json)) color_image, source_alpha, color_bounds = _generated_color_result( @@ -358,8 +342,6 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): result.file(normal_key), source_object, ) - if normal_mode == NormalMode.OBJECT: - orient_object_normal_image(normal_image, inward_axis) depth_metadata = None if shape_uses_depth(self.shape): depth_metadata = load_depth_metadata(result.file("depth_metadata")) @@ -379,10 +361,10 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): depth_metadata=depth_metadata, normal_image=normal_image, normal_mode=normal_mode, - inward_axis=inward_axis, alpha_threshold=self.alpha_threshold, fine_outline=self.fine_outline, color_image=color_image, + gesture=self.gesture, ) except Exception: if normal_image is not None and normal_image.users == 0: @@ -401,22 +383,32 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): class SelectCutoutSelection(ImageGesture, bpy.types.Operator): bl_idname = "anyimage.select_cutout_selection" bl_label = "Start Cutout Selection" - bl_description = "Lasso an Image Empty selection, then choose its mesh shape" + bl_description = "Select an Image Empty area with a lasso or polyline, then choose its mesh shape" bl_options = {"INTERNAL"} active_tool_id = "anyimage.cutout_lasso" - gesture = "LASSO" + resolve_on_press = True + gesture: cutout_gesture_property() source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) _pie_event = None + _polyline_confirm_type = None + + def _read_gesture_settings(self, context): + self.gesture = context.scene.anyimage_settings.cutout_gesture def _set_status(self, context): + action = ( + "click points; click start, double-click, or Enter for shapes; Backspace removes" + if self.gesture == "POLYLINE" else "drag and release for shapes" + ) context.workspace.status_text_set( - "Cutout Lasso: drag and release for shapes; RMB or Esc cancels" + f"Cutout {self.gesture.title()}: {action}; RMB or Esc cancels" ) def invoke(self, context, event): + self._polyline_confirm_type = None self._pie_event = event return super().invoke(context, event) @@ -424,6 +416,19 @@ class SelectCutoutSelection(ImageGesture, bpy.types.Operator): self._pie_event = event return ImageGesture.modal(self, context, event) + def _confirm_polyline(self, context): + if len(self._path) >= 3: + self._polyline_confirm_type = self._pie_event.type + return {"RUNNING_MODAL"} + + def _modal_polyline(self, context, event): + if self._polyline_confirm_type is not None: + if event.type == self._polyline_confirm_type and event.value == "RELEASE": + self._polyline_confirm_type = None + return ImageGesture._confirm_polyline(self, context) + return {"RUNNING_MODAL"} + return ImageGesture._modal_polyline(self, context, event) + def _submit_selection_path(self, context, selection_path): try: source_object = require_image_empty(self.source_object_name) @@ -435,6 +440,7 @@ class SelectCutoutSelection(ImageGesture, bpy.types.Operator): self._pie_event, source_object, selection_path, + gesture=self.gesture, ) return {"FINISHED"} @@ -444,19 +450,18 @@ class CutoutTool(bpy.types.WorkSpaceTool): bl_context_mode = "OBJECT" bl_idname = "anyimage.cutout_lasso" bl_label = "Cutout" - bl_description = "Lasso an Image Empty and choose a mesh shape" + bl_description = "Select an Image Empty area with a lasso or polyline and choose a mesh shape" bl_icon = "ops.mesh.primitive_sphere_add_gizmo" bl_operator = SelectCutoutSelection.bl_idname - bl_keymap = image_edit_drag_keymap( + bl_keymap = image_edit_point_keymap( SelectCutoutSelection.bl_idname, - deselect_on_empty=False, ) def draw_settings(context, layout, _tool): settings = context.scene.anyimage_settings - status = draw_ai_setup(layout) - draw_ai_property(layout, settings, "cutout_generate_normal", status) + layout.prop(settings, "cutout_gesture") layout.prop(settings, "cutout_mesh_detail") layout.prop(settings, "cutout_fine_outline") layout.prop(settings, "cutout_alpha_threshold") - layout.prop(settings, "cutout_inward_axis") + status = draw_ai_setup(layout) + draw_ai_property(layout, settings, "cutout_generate_normal", status) diff --git a/src/anyimage/operators/cutout_tool/shape.py b/src/anyimage/operators/cutout_tool/shape.py index 09a8fdd..77c8543 100644 --- a/src/anyimage/operators/cutout_tool/shape.py +++ b/src/anyimage/operators/cutout_tool/shape.py @@ -2,12 +2,6 @@ BALLOON_ATTRIBUTE_NAME = "o_balloon" NORMAL_SCALE_ATTRIBUTE_NAME = "o_normal_scale" -CUTOUT_X_TO_Z = ( - (0.0, -1.0, 0.0, 0.0), - (0.0, 0.0, 1.0, 0.0), - (-1.0, 0.0, 0.0, 0.0), - (0.0, 0.0, 0.0, 1.0), -) CUTOUT_Z_TO_X = ( (0.0, 0.0, -1.0, 0.0), (-1.0, 0.0, 0.0, 0.0), diff --git a/src/anyimage/operators/frame_tool.py b/src/anyimage/operators/frame_tool.py deleted file mode 100644 index 69f1a2d..0000000 --- a/src/anyimage/operators/frame_tool.py +++ /dev/null @@ -1,648 +0,0 @@ -import math - -import bpy - -from ..common.image import ( - MAX_PROJECTIVE_OUTPUT_PIXELS, - PROJECTIVE_CHUNK_ROWS, - create_image_edit_result, - homography_from_points, - image_empty_bounds, - image_pixels, - is_image_empty, - premultiplied_rgba, - replace_empty_image, - sample_rgba, - transform_homography, - video_total_frames, -) -from ..preferences import configured_max_frame_resolution -from ..common.viewport import ( - active_image_edit_source, - active_view3d_tool_id, - draw_dashed_line, - draw_polygon_fill, - drawing_in_region, - image_edit_drag_keymap, - image_edit_poll, - rectangle_screen_path, - report_image_edit_exception, -) -from ..common.selection import ImageEditWarning, clip_polygon_halfplanes - -FRAME_TOOL_ID = "anyimage.frame" -PROJECTIVE_RELATIVE_EPSILON = 1e-12 - - -def _matrix_array(matrix): - import numpy as np - - return np.asarray( - tuple(tuple(float(value) for value in row) for row in matrix), - dtype=np.float64, - ) - - -def frame_source_projection( - projection_matrix, - view_matrix, - matrix_world, - bounds, - region_size, - *, - perspective, -): - """Return normalized image-plane transforms for one frozen view.""" - import numpy as np - - x_min, x_max, y_min, y_max = (float(value) for value in bounds) - region_width, region_height = (float(value) for value in region_size) - if min(x_max - x_min, y_max - y_min, region_width, region_height) <= 0.0: - raise ValueError("The Image Empty projection is invalid") - source_plane = np.asarray( - ( - (x_max - x_min, 0.0, x_min), - (0.0, y_min - y_max, y_max), - (0.0, 0.0, 0.0), - (0.0, 0.0, 1.0), - ), - dtype=np.float64, - ) - world = _matrix_array(matrix_world) - source_to_world = world @ source_plane - source_to_view = _matrix_array(view_matrix) @ source_to_world - source_to_clip = _matrix_array(projection_matrix) @ source_to_world - source_to_screen = np.stack( - ( - 0.5 * (source_to_clip[0] + source_to_clip[3]), - 0.5 * (source_to_clip[1] + source_to_clip[3]), - source_to_clip[3], - ) - ) - scale = float(np.max(np.abs(source_to_screen))) - if not math.isfinite(scale) or scale <= 0.0: - raise ValueError("The Image Empty projection is invalid") - source_to_screen /= scale - if not np.isfinite(source_to_screen).all(): - raise ValueError("The Image Empty projection is invalid") - condition = float(np.linalg.cond(source_to_screen)) - if not math.isfinite(condition) or condition > 1.0 / np.finfo(np.float64).eps: - raise ValueError("The Image Empty is edge-on to the current view") - return { - "bounds": (x_min, x_max, y_min, y_max), - "origin_depth": -float((_matrix_array(view_matrix) @ world)[2, 3]), - "perspective": bool(perspective), - "region_size": (region_width, region_height), - "screen_to_source": np.linalg.inv(source_to_screen), - "source_to_screen": source_to_screen, - "source_to_view": source_to_view, - "source_to_world": source_to_world, - } - - -def _projective_points(points, matrix): - """Map points and return finite results plus per-point validity.""" - import numpy as np - - points = np.asarray(points, dtype=np.float64) - flat = points.reshape((-1, 2)) - homogeneous = np.column_stack((flat, np.ones(len(flat), dtype=np.float64))) - mapped = homogeneous @ np.asarray(matrix, dtype=np.float64).T - magnitude = np.max(np.abs(mapped), axis=1) - valid = ( - np.isfinite(mapped).all(axis=1) - & (np.abs(mapped[:, 2]) > PROJECTIVE_RELATIVE_EPSILON * magnitude) - ) - result = np.full((len(flat), 2), np.nan, dtype=np.float64) - result[valid] = mapped[valid, :2] / mapped[valid, 2, None] - return result.reshape(points.shape), valid.reshape(points.shape[:-1]) - - -def frame_source_coordinates(source, screen_points): - """Return source pixels, view depth, validity, and normalized coordinates.""" - import numpy as np - - screen_points = np.asarray(screen_points, dtype=np.float64) - region_width, region_height = source["region_size"] - normalized_screen = screen_points / (region_width, region_height) - normalized_source, valid = _projective_points( - normalized_screen, - source["screen_to_source"], - ) - flat_source = normalized_source.reshape((-1, 2)) - homogeneous = np.column_stack( - (flat_source, np.ones(len(flat_source), dtype=np.float64)) - ) - depth = -(homogeneous @ source["source_to_view"][2]) - depth = depth.reshape(valid.shape) - valid &= np.isfinite(depth) - if source["perspective"]: - valid &= depth > 0.0 - valid &= ( - (normalized_source[..., 0] >= 0.0) - & (normalized_source[..., 0] <= 1.0) - & (normalized_source[..., 1] >= 0.0) - & (normalized_source[..., 1] <= 1.0) - ) - image_width, image_height = source["image_size"] - source_points = normalized_source * (image_width, image_height) - source_points[~valid] = -1.0 - return source_points, depth, valid, normalized_source - - -def frame_source_overlap(source, frame_quad): - """Clip the source plane before dividing its homogeneous projection.""" - import numpy as np - - frame = np.asarray(frame_quad, dtype=np.float64) / source["region_size"] - left, bottom = frame.min(axis=0) - right, top = frame.max(axis=0) - x, y, w = source["source_to_screen"] - boundaries = [x - left * w, right * w - x, y - bottom * w, top * w - y] - if source["perspective"]: - boundaries.insert(0, -source["source_to_view"][2]) - polygon = clip_polygon_halfplanes( - ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)), boundaries - ) - if len(polygon) < 3: - return () - screen, valid = _projective_points(polygon, source["source_to_screen"]) - if not np.all(valid): - return () - screen *= source["region_size"] - # Center the coordinates to avoid cancellation in a distant small polygon. - centered = screen - screen[0] - area = abs(np.sum(centered[:, 0] * np.roll(centered[:, 1], -1) - - centered[:, 1] * np.roll(centered[:, 0], -1))) * 0.5 - return tuple(map(tuple, screen)) if area > 1e-8 else () - - -def polygon_centroid(polygon): - """Return the area centroid of one non-degenerate screen polygon.""" - cross_sum = 0.0 - x_sum = 0.0 - y_sum = 0.0 - for first, second in zip(polygon, (*polygon[1:], polygon[0])): - cross = first[0] * second[1] - second[0] * first[1] - cross_sum += cross - x_sum += (first[0] + second[0]) * cross - y_sum += (first[1] + second[1]) * cross - if abs(cross_sum) <= 1e-12: - raise ValueError("Frame does not overlap the active Image Empty") - return x_sum / (3.0 * cross_sum), y_sum / (3.0 * cross_sum) - - -def _pixel_quad(image_size): - width, height = (float(value) for value in image_size) - if min(width, height) <= 0.0: - raise ValueError("The source image has no readable dimensions") - return ((0.0, 0.0), (width, 0.0), (width, height), (0.0, height)) - - -def frame_output_size(frame_quad, region_size, max_resolution): - """Choose output resolution from Frame pixels within the current Region.""" - frame_width = abs(float(frame_quad[1][0] - frame_quad[0][0])) - frame_height = abs(float(frame_quad[0][1] - frame_quad[3][1])) - region_width, region_height = (float(value) for value in region_size) - maximum = float(max_resolution) - if min(frame_width, frame_height, region_width, region_height, maximum) <= 0.0: - raise ValueError("The Frame output dimensions must be positive") - scale = min( - 1.0, - region_width / frame_width, - region_height / frame_height, - maximum / max(frame_width, frame_height), - math.sqrt( - MAX_PROJECTIVE_OUTPUT_PIXELS / max(frame_width * frame_height, 1.0) - ), - ) - return ( - max(2, int(round(frame_width * scale))), - max(2, int(round(frame_height * scale))), - ) - - -def _frame_source(source_object, region, view3d, view_matrix, *, active): - import numpy as np - - image_size = tuple(source_object.data.size) - pixels = image_pixels(source_object.data) - width, height = image_size - bounds = image_empty_bounds(source_object) - source = frame_source_projection( - view3d.perspective_matrix, - view_matrix, - source_object.matrix_world, - bounds, - (region.width, region.height), - perspective=view3d.is_perspective, - ) - source.update( - { - "active": bool(active), - "image_size": image_size, - "name": source_object.name, - "object": source_object, - "pixels": premultiplied_rgba(pixels, image_size), - "rgba": np.flipud(pixels.reshape((height, width, 4))), - } - ) - return source - - -def _composite_frame_samples(sources, screen_points): - """Resolve independently sorted samples with an unweighted bottom RGB base.""" - import numpy as np - - samples, depths, coordinates = [], [], [] - for source in sources: - points, depth, valid, _ = frame_source_coordinates(source, screen_points) - sample = sample_rgba(source["pixels"], points) - sample[~valid] = 0 - depths.append(np.where(valid, depth, -math.inf)) - samples.append(sample) - coordinates.append(points) - samples = np.stack(samples) - depths = np.stack(depths) - order = np.argsort(-depths, axis=0, kind="stable") - bottom = order[0] - covered = np.isfinite(np.take_along_axis(depths, bottom[None], axis=0)[0]) - result = np.zeros_like(samples[0]) - for index, source in enumerate(sources): - selected = covered & (bottom == index) - if np.any(selected): - result[..., :3][selected] = sample_rgba( - source["rgba"], coordinates[index][selected] - )[..., :3] - for index in range(len(sources)): - layer = np.take_along_axis(samples, order[index][None, ..., None], axis=0)[0] - alpha = layer[..., 3:4] - result[..., 3:4] = alpha + result[..., 3:4] * (1 - alpha) - if index: - result[..., :3] = layer[..., :3] + result[..., :3] * (1 - alpha) - return result, covered - - -def _frame_boundary_pixels(sources, points, output_to_screen): - """Conservatively detect projected contour and depth-order crossings.""" - import numpy as np - - boundary = np.zeros(points.shape[:-1], dtype=bool) - for source in sources: - width, height = source["region_size"] - mapping = source["screen_to_source"] @ np.diag((1 / width, 1 / height, 1)) @ output_to_screen - u, v, w = mapping - lines = [u, v, u - w, v - w, w] - positive = np.ones_like(boundary) - negative = np.ones_like(boundary) - for line in (u, v, w - u, w - v, w): - radius = 0.5 * (abs(line[0]) + abs(line[1])) - value = points[..., 0] * line[0] + points[..., 1] * line[1] + line[2] - positive &= value + radius >= 0 - negative &= value - radius <= 0 - source_boundary = np.zeros_like(boundary) - if source["perspective"]: - lines.append(source["source_to_view"][2] @ mapping) - for line in lines: - radius = 0.5 * (abs(line[0]) + abs(line[1])) - value = points[..., 0] * line[0] + points[..., 1] * line[1] + line[2] - source_boundary |= np.abs(value) < radius - 1e-10 * max(radius, 1e-20) - boundary |= source_boundary & (positive | negative) - if len(sources) > 1: - first_order = None - for offset in ((-0.499999, -0.499999), (0.499999, -0.499999), - (-0.499999, 0.499999), (0.499999, 0.499999)): - screen = transform_homography(points + offset, output_to_screen) - depths = [] - for source in sources: - _, depth, valid, _ = frame_source_coordinates(source, screen) - depths.append(np.where(valid, depth, -math.inf)) - order = np.argsort(-np.stack(depths), axis=0, kind="stable") - if first_order is None: - first_order = order - else: - boundary |= np.any(order != first_order, axis=0) - return boundary - - -def composite_frame_pixels(sources, frame_quad, output_size): - """Composite projected Image Empty sources into one Blender-order RGBA image.""" - import numpy as np - - output_width, output_height = (int(value) for value in output_size) - if min(output_width, output_height) <= 0: - raise ValueError("The Frame output dimensions must be positive") - sources = tuple( - sorted(sources, key=lambda source: (source["active"], source["name"])) - ) - if not sources or not sources[-1]["active"]: - raise ValueError("Frame requires one active Image Empty") - output_quad = _pixel_quad(output_size) - output_to_screen = homography_from_points(output_quad, frame_quad) - output = np.zeros((output_height, output_width, 4), dtype=np.float32) - covered = np.zeros((output_height, output_width), dtype=bool) - target_x = np.arange(output_width, dtype=np.float64) + 0.5 - for row_start in range(0, output_height, PROJECTIVE_CHUNK_ROWS): - row_end = min(row_start + PROJECTIVE_CHUNK_ROWS, output_height) - target_y = np.arange(row_start, row_end, dtype=np.float64) + 0.5 - x, y = np.meshgrid(target_x, target_y) - points = np.stack((x, y), axis=-1) - composite, coverage = _composite_frame_samples( - sources, transform_homography(points, output_to_screen) - ) - boundary = _frame_boundary_pixels(sources, points, output_to_screen) - if np.any(boundary): - edge_points = points[boundary] - accumulated = np.zeros((len(edge_points), 4), dtype=np.float32) - counts = np.zeros(len(edge_points), dtype=np.int32) - for dy in (-0.375, -0.125, 0.125, 0.375): - for dx in (-0.375, -0.125, 0.125, 0.375): - sample, hit = _composite_frame_samples( - sources, - transform_homography(edge_points + (dx, dy), output_to_screen), - ) - accumulated += sample - counts += hit - accumulated[..., 3] /= 16 - accumulated[..., :3] /= np.maximum(counts, 1)[..., None] - composite[boundary] = accumulated - coverage[boundary] = counts > 0 - covered[row_start:row_end] = coverage - output[row_start:row_end] = composite - output[..., 3][output[..., 3] <= 1e-8] = 0.0 - output[~covered] = 0.0 - return np.flipud(output).ravel() - - -def view_facing_frame_geometry(corners, output_size): - """Return a world matrix array and display size for frame-plane corners.""" - import numpy as np - - corners = np.asarray(corners, dtype=np.float64) - if corners.shape != (4, 3) or not np.isfinite(corners).all(): - raise ValueError("The Frame view plane is invalid") - top_left, top_right, _bottom_right, bottom_left = corners - right = top_right - top_left - up = top_left - bottom_left - width = float(np.linalg.norm(right)) - height = float(np.linalg.norm(up)) - if min(width, height) <= 1e-10: - raise ValueError("Draw a larger Frame") - right /= width - up /= height - forward = np.cross(right, up) - forward_length = float(np.linalg.norm(forward)) - if forward_length <= 1e-10: - raise ValueError("The Frame view plane is invalid") - forward /= forward_length - matrix = np.identity(4, dtype=np.float64) - matrix[:3, 0] = right - matrix[:3, 1] = up - matrix[:3, 2] = forward - matrix[:3, 3] = corners.mean(axis=0) - display_size = width if output_size[0] >= output_size[1] else height - return matrix, display_size - - -def frame_result_transform(frame_quad, source_origin, region, view3d, output_size): - """Return the view-facing object matrix and Image Empty display size.""" - from mathutils import Matrix - from bpy_extras import view3d_utils - - corners = tuple( - tuple( - float(value) - for value in view3d_utils.region_2d_to_location_3d( - region, - view3d, - point, - source_origin, - ) - ) - for point in frame_quad - ) - matrix, display_size = view_facing_frame_geometry(corners, output_size) - return Matrix(tuple(tuple(row) for row in matrix)), display_size - - -def frame_depth_location(active_source, overlap_polygon): - """Return the world point whose view depth places the Frame result.""" - import numpy as np - - source_object = active_source["object"] - if not active_source["perspective"] or active_source["origin_depth"] > 0.0: - return source_object.matrix_world.translation.copy() - centroid = np.asarray((polygon_centroid(overlap_polygon),), dtype=np.float64) - _points, depth, valid, normalized_source = frame_source_coordinates( - active_source, - centroid, - ) - if not bool(valid[0]) or depth[0] <= 0.0: - raise ValueError("The Frame result depth is invalid") - source_point = np.asarray( - (normalized_source[0, 0], normalized_source[0, 1], 1.0), - dtype=np.float64, - ) - world = active_source["source_to_world"] @ source_point - if not np.isfinite(world).all() or abs(world[3]) <= 1e-12: - raise ValueError("The Frame result depth is invalid") - return tuple(float(value) for value in world[:3] / world[3]) - - -def selected_frame_sources(context): - """Return selected still Image Empties with the active object last.""" - active = active_image_edit_source(context) - sources = [ - source for source in context.selected_objects if is_image_empty(source) - ] - for source in sources: - if video_total_frames(source.data) > 1: - raise ValueError("Frame only supports still images") - return tuple( - sorted(sources, key=lambda source: (source is active, source.name)) - ) - - -def apply_frame(context, source_objects, frame_quad): - """Bake selected projections into the active Image Empty.""" - active = context.active_object - if active not in source_objects: - raise ValueError("The active Image Empty is not a Frame source") - view3d = context.region_data - sources = tuple( - _frame_source( - source, - context.region, - view3d, - view3d.view_matrix, - active=source is active, - ) - for source in source_objects - ) - active_source = next(source for source in sources if source["active"]) - active_overlap = frame_source_overlap(active_source, frame_quad) - if not active_overlap: - raise ImageEditWarning("Frame does not overlap the active Image Empty") - output_size = frame_output_size( - frame_quad, - (context.region.width, context.region.height), - configured_max_frame_resolution(context), - ) - pixels = composite_frame_pixels(sources, frame_quad, output_size) - depth_location = frame_depth_location(active_source, active_overlap) - matrix, display_size = frame_result_transform( - frame_quad, - depth_location, - context.region, - view3d, - output_size, - ) - result_image = create_image_edit_result(active.data, pixels, output_size) - old_matrix = active.matrix_world.copy() - old_display_size = active.empty_display_size - old_offset = tuple(active.empty_image_offset) - source_images = [] - for source in source_objects: - if all(image is not source.data for image in source_images): - source_images.append(source.data) - try: - active.matrix_world = matrix - active.empty_display_size = display_size - active.empty_image_offset = (-0.5, -0.5) - replace_empty_image( - active, result_image, - removed_objects=tuple(source for source in source_objects if source != active), - ) - except Exception: - active.matrix_world = old_matrix - active.empty_display_size = old_display_size - active.empty_image_offset = old_offset - raise - for source in source_objects: - if source is not active: - bpy.data.objects.remove(source, do_unlink=True) - for image in source_images: - try: - if image.users == 0 and not image.use_fake_user: - bpy.data.images.remove(image, do_unlink=True) - except (ReferenceError, RuntimeError): - pass - return active - - -class FrameImages(bpy.types.Operator): - bl_idname = "anyimage.frame_images" - bl_label = "Frame Images" - bl_description = "Bake selected Image Empties into a view-facing frame" - bl_options = {"UNDO"} - - _active_name = "" - _source_names = () - _start = None - _cursor = None - _handle = None - _area_pointer = 0 - _region_pointer = 0 - - @classmethod - def poll(cls, context): - return image_edit_poll(context) - - def _draw_overlay(self): - if not drawing_in_region(self._area_pointer, self._region_pointer): - return - frame = rectangle_screen_path(self._start, self._cursor) - if not frame: - return - draw_polygon_fill(frame) - draw_dashed_line((*frame, frame[0])) - - def _finish(self, context): - if self._handle is not None: - bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW") - self._handle = None - context.workspace.status_text_set(None) - if context.area is not None: - context.area.tag_redraw() - - def cancel(self, context): - self._finish(context) - - def invoke(self, context, event): - try: - sources = selected_frame_sources(context) - except (TypeError, ValueError) as error: - report_image_edit_exception(self, error) - return {"CANCELLED"} - self._active_name = context.active_object.name - self._source_names = tuple(source.name for source in sources) - point = (float(event.mouse_region_x), float(event.mouse_region_y)) - self._start = point - self._cursor = point - self._area_pointer = context.area.as_pointer() - self._region_pointer = context.region.as_pointer() - self._handle = bpy.types.SpaceView3D.draw_handler_add( - self._draw_overlay, (), "WINDOW", "POST_PIXEL" - ) - context.workspace.status_text_set( - "Frame: drag a rectangle to merge selected Image Empties; " - "RMB or Esc cancels" - ) - context.window_manager.modal_handler_add(self) - context.area.tag_redraw() - return {"RUNNING_MODAL"} - - def modal(self, context, event): - active_tool = active_view3d_tool_id(context) - if active_tool is not None and active_tool != FRAME_TOOL_ID: - self._finish(context) - return {"CANCELLED"} - if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS": - self._finish(context) - return {"CANCELLED"} - if event.type == "MOUSEMOVE": - self._cursor = ( - float(event.mouse_region_x), - float(event.mouse_region_y), - ) - context.area.tag_redraw() - return {"RUNNING_MODAL"} - if event.type == "LEFTMOUSE" and event.value == "RELEASE": - self._cursor = ( - float(event.mouse_region_x), - float(event.mouse_region_y), - ) - frame = rectangle_screen_path(self._start, self._cursor) - if not frame: - self._finish(context) - return {"CANCELLED"} - try: - active = bpy.data.objects.get(self._active_name) - sources = tuple( - bpy.data.objects.get(name) for name in self._source_names - ) - if ( - not is_image_empty(active) - or any(not is_image_empty(source) for source in sources) - or context.active_object is not active - ): - raise ValueError("The selected Image Empties changed during Frame") - apply_frame(context, sources, frame) - except (RuntimeError, TypeError, ValueError) as error: - report_image_edit_exception(self, error) - self._finish(context) - return {"CANCELLED"} - self._finish(context) - return {"FINISHED"} - return {"PASS_THROUGH"} - - -class FrameTool(bpy.types.WorkSpaceTool): - bl_space_type = "VIEW_3D" - bl_context_mode = "OBJECT" - bl_idname = FRAME_TOOL_ID - bl_label = "Frame" - bl_description = "Bake selected Image Empties into a view-facing frame" - bl_icon = "ops.sculpt.border_mask" - bl_operator = FrameImages.bl_idname - bl_keymap = image_edit_drag_keymap(FrameImages.bl_idname) diff --git a/src/anyimage/operators/frame_tool/__init__.py b/src/anyimage/operators/frame_tool/__init__.py new file mode 100644 index 0000000..6435799 --- /dev/null +++ b/src/anyimage/operators/frame_tool/__init__.py @@ -0,0 +1,7 @@ +"""Frame conversion operators and workspace tool.""" + +from .operators import FrameImages, FrameTool + +CLASSES = (FrameImages,) + +__all__ = ("CLASSES", "FrameImages", "FrameTool") diff --git a/src/anyimage/operators/frame_tool/compositing.py b/src/anyimage/operators/frame_tool/compositing.py new file mode 100644 index 0000000..9f7e359 --- /dev/null +++ b/src/anyimage/operators/frame_tool/compositing.py @@ -0,0 +1,141 @@ +"""Composite projected image samples with independent RGB and Alpha coverage.""" + +import math +from ...common.image import ( + PROJECTIVE_CHUNK_ROWS, + homography_from_points, + sample_rgba, + transform_homography, +) +from .projection import frame_source_coordinates + + +def _pixel_quad(image_size): + width, height = (float(value) for value in image_size) + if min(width, height) <= 0.0: + raise ValueError("The source image has no readable dimensions") + return ((0.0, 0.0), (width, 0.0), (width, height), (0.0, height)) + + +def _composite_frame_samples(sources, screen_points): + """Resolve independently sorted samples with an unweighted bottom RGB base.""" + import numpy as np + + samples, depths, coordinates = [], [], [] + for source in sources: + points, depth, valid, _ = frame_source_coordinates(source, screen_points) + sample = sample_rgba(source["pixels"], points) + sample[~valid] = 0 + depths.append(np.where(valid, depth, -math.inf)) + samples.append(sample) + coordinates.append(points) + samples = np.stack(samples) + depths = np.stack(depths) + order = np.argsort(-depths, axis=0, kind="stable") + bottom = order[0] + covered = np.isfinite(np.take_along_axis(depths, bottom[None], axis=0)[0]) + result = np.zeros_like(samples[0]) + for index, source in enumerate(sources): + selected = covered & (bottom == index) + if np.any(selected): + result[..., :3][selected] = sample_rgba( + source["rgba"], coordinates[index][selected] + )[..., :3] + for index in range(len(sources)): + layer = np.take_along_axis(samples, order[index][None, ..., None], axis=0)[0] + alpha = layer[..., 3:4] + result[..., 3:4] = alpha + result[..., 3:4] * (1 - alpha) + if index: + result[..., :3] = layer[..., :3] + result[..., :3] * (1 - alpha) + return result, covered + + +def _frame_boundary_pixels(sources, points, output_to_screen): + """Conservatively detect projected contour and depth-order crossings.""" + import numpy as np + + boundary = np.zeros(points.shape[:-1], dtype=bool) + for source in sources: + width, height = source["region_size"] + mapping = source["screen_to_source"] @ np.diag((1 / width, 1 / height, 1)) @ output_to_screen + u, v, w = mapping + lines = [u, v, u - w, v - w, w] + positive = np.ones_like(boundary) + negative = np.ones_like(boundary) + for line in (u, v, w - u, w - v, w): + radius = 0.5 * (abs(line[0]) + abs(line[1])) + value = points[..., 0] * line[0] + points[..., 1] * line[1] + line[2] + positive &= value + radius >= 0 + negative &= value - radius <= 0 + source_boundary = np.zeros_like(boundary) + if source["perspective"]: + lines.append(source["source_to_view"][2] @ mapping) + for line in lines: + radius = 0.5 * (abs(line[0]) + abs(line[1])) + value = points[..., 0] * line[0] + points[..., 1] * line[1] + line[2] + source_boundary |= np.abs(value) < radius - 1e-10 * max(radius, 1e-20) + boundary |= source_boundary & (positive | negative) + if len(sources) > 1: + first_order = None + for offset in ((-0.499999, -0.499999), (0.499999, -0.499999), + (-0.499999, 0.499999), (0.499999, 0.499999)): + screen = transform_homography(points + offset, output_to_screen) + depths = [] + for source in sources: + _, depth, valid, _ = frame_source_coordinates(source, screen) + depths.append(np.where(valid, depth, -math.inf)) + order = np.argsort(-np.stack(depths), axis=0, kind="stable") + if first_order is None: + first_order = order + else: + boundary |= np.any(order != first_order, axis=0) + return boundary + + +def composite_frame_pixels(sources, frame_quad, output_size): + """Composite projected Image Empty sources into one Blender-order RGBA image.""" + import numpy as np + + output_width, output_height = (int(value) for value in output_size) + if min(output_width, output_height) <= 0: + raise ValueError("The Frame output dimensions must be positive") + sources = tuple( + sorted(sources, key=lambda source: (source["active"], source["name"])) + ) + if not sources or not sources[-1]["active"]: + raise ValueError("Frame requires one active Image Empty") + output_quad = _pixel_quad(output_size) + output_to_screen = homography_from_points(output_quad, frame_quad) + output = np.zeros((output_height, output_width, 4), dtype=np.float32) + covered = np.zeros((output_height, output_width), dtype=bool) + target_x = np.arange(output_width, dtype=np.float64) + 0.5 + for row_start in range(0, output_height, PROJECTIVE_CHUNK_ROWS): + row_end = min(row_start + PROJECTIVE_CHUNK_ROWS, output_height) + target_y = np.arange(row_start, row_end, dtype=np.float64) + 0.5 + x, y = np.meshgrid(target_x, target_y) + points = np.stack((x, y), axis=-1) + composite, coverage = _composite_frame_samples( + sources, transform_homography(points, output_to_screen) + ) + boundary = _frame_boundary_pixels(sources, points, output_to_screen) + if np.any(boundary): + edge_points = points[boundary] + accumulated = np.zeros((len(edge_points), 4), dtype=np.float32) + counts = np.zeros(len(edge_points), dtype=np.int32) + for dy in (-0.375, -0.125, 0.125, 0.375): + for dx in (-0.375, -0.125, 0.125, 0.375): + sample, hit = _composite_frame_samples( + sources, + transform_homography(edge_points + (dx, dy), output_to_screen), + ) + accumulated += sample + counts += hit + accumulated[..., 3] /= 16 + accumulated[..., :3] /= np.maximum(counts, 1)[..., None] + composite[boundary] = accumulated + coverage[boundary] = counts > 0 + covered[row_start:row_end] = coverage + output[row_start:row_end] = composite + output[..., 3][output[..., 3] <= 1e-8] = 0.0 + output[~covered] = 0.0 + return np.flipud(output).ravel() diff --git a/src/anyimage/operators/frame_tool/operators.py b/src/anyimage/operators/frame_tool/operators.py new file mode 100644 index 0000000..f9f05e0 --- /dev/null +++ b/src/anyimage/operators/frame_tool/operators.py @@ -0,0 +1,264 @@ +"""Frame interaction, source preparation, and Blender image replacement.""" + +import bpy + +from ...common.image import ( + create_image_edit_result, + image_empty_bounds, + image_pixels, + is_image_empty, + premultiplied_rgba, + replace_empty_image, + is_animated_image, +) +from ...preferences import configured_max_frame_resolution +from ...common.viewport import ( + active_image_edit_source, + active_view3d_tool_id, + draw_dashed_line, + draw_polygon_fill, + drawing_in_region, + image_edit_drag_keymap, + image_edit_poll, + rectangle_screen_path, + report_image_edit_exception, +) +from ...common.selection import ImageEditWarning +from .projection import ( + frame_depth_location, + frame_output_size, + frame_result_transform, + frame_source_overlap, + frame_source_projection, +) +from .compositing import composite_frame_pixels + + +FRAME_TOOL_ID = "anyimage.frame" + + +def _frame_source(source_object, region, view3d, view_matrix, *, active): + import numpy as np + + image_size = tuple(source_object.data.size) + pixels = image_pixels(source_object.data) + width, height = image_size + bounds = image_empty_bounds(source_object) + source = frame_source_projection( + view3d.perspective_matrix, + view_matrix, + source_object.matrix_world, + bounds, + (region.width, region.height), + perspective=view3d.is_perspective, + ) + source.update( + { + "active": bool(active), + "image_size": image_size, + "name": source_object.name, + "object": source_object, + "pixels": premultiplied_rgba(pixels, image_size), + "rgba": np.flipud(pixels.reshape((height, width, 4))), + } + ) + return source + + +def selected_frame_sources(context): + """Return selected still Image Empties with the active object last.""" + active = active_image_edit_source(context) + sources = [ + source for source in context.selected_objects if is_image_empty(source) + ] + for source in sources: + if is_animated_image(source.data): + raise ValueError("Frame only supports still images") + return tuple( + sorted(sources, key=lambda source: (source is active, source.name)) + ) + + +def apply_frame(context, source_objects, frame_quad): + """Bake selected projections into the active Image Empty.""" + active = context.active_object + if active not in source_objects: + raise ValueError("The active Image Empty is not a Frame source") + view3d = context.region_data + sources = tuple( + _frame_source( + source, + context.region, + view3d, + view3d.view_matrix, + active=source is active, + ) + for source in source_objects + ) + active_source = next(source for source in sources if source["active"]) + active_overlap = frame_source_overlap(active_source, frame_quad) + if not active_overlap: + raise ImageEditWarning("Frame does not overlap the active Image Empty") + output_size = frame_output_size( + frame_quad, + (context.region.width, context.region.height), + configured_max_frame_resolution(context), + ) + pixels = composite_frame_pixels(sources, frame_quad, output_size) + depth_location = frame_depth_location(active_source, active_overlap) + matrix, display_size = frame_result_transform( + frame_quad, + depth_location, + context.region, + view3d, + output_size, + ) + result_image = create_image_edit_result(active.data, pixels, output_size) + old_matrix = active.matrix_world.copy() + old_display_size = active.empty_display_size + old_offset = tuple(active.empty_image_offset) + source_images = [] + for source in source_objects: + if all(image is not source.data for image in source_images): + source_images.append(source.data) + try: + active.matrix_world = matrix + active.empty_display_size = display_size + active.empty_image_offset = (-0.5, -0.5) + replace_empty_image( + active, result_image, + removed_objects=tuple(source for source in source_objects if source != active), + ) + except Exception: + active.matrix_world = old_matrix + active.empty_display_size = old_display_size + active.empty_image_offset = old_offset + raise + for source in source_objects: + if source is not active: + bpy.data.objects.remove(source, do_unlink=True) + for image in source_images: + try: + if image.users == 0 and not image.use_fake_user: + bpy.data.images.remove(image, do_unlink=True) + except (ReferenceError, RuntimeError): + pass + return active + + +class FrameImages(bpy.types.Operator): + bl_idname = "anyimage.frame_images" + bl_label = "Frame Images" + bl_description = "Draw a rectangle to combine selected images into one image from the current view." + bl_options = {"UNDO"} + + _active_name = "" + _source_names = () + _start = None + _cursor = None + _handle = None + _area_pointer = 0 + _region_pointer = 0 + + @classmethod + def poll(cls, context): + return image_edit_poll(context) + + def _draw_overlay(self): + if not drawing_in_region(self._area_pointer, self._region_pointer): + return + frame = rectangle_screen_path(self._start, self._cursor) + if not frame: + return + draw_polygon_fill(frame) + draw_dashed_line((*frame, frame[0])) + + def _finish(self, context): + if self._handle is not None: + bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW") + self._handle = None + context.workspace.status_text_set(None) + if context.area is not None: + context.area.tag_redraw() + + def cancel(self, context): + self._finish(context) + + def invoke(self, context, event): + try: + sources = selected_frame_sources(context) + except (TypeError, ValueError) as error: + report_image_edit_exception(self, error) + return {"CANCELLED"} + self._active_name = context.active_object.name + self._source_names = tuple(source.name for source in sources) + point = (float(event.mouse_region_x), float(event.mouse_region_y)) + self._start = point + self._cursor = point + self._area_pointer = context.area.as_pointer() + self._region_pointer = context.region.as_pointer() + self._handle = bpy.types.SpaceView3D.draw_handler_add( + self._draw_overlay, (), "WINDOW", "POST_PIXEL" + ) + context.workspace.status_text_set( + "Frame: drag a rectangle to merge selected Image Empties; " + "RMB or Esc cancels" + ) + context.window_manager.modal_handler_add(self) + context.area.tag_redraw() + return {"RUNNING_MODAL"} + + def modal(self, context, event): + active_tool = active_view3d_tool_id(context) + if active_tool is not None and active_tool != FRAME_TOOL_ID: + self._finish(context) + return {"CANCELLED"} + if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS": + self._finish(context) + return {"CANCELLED"} + if event.type == "MOUSEMOVE": + self._cursor = ( + float(event.mouse_region_x), + float(event.mouse_region_y), + ) + context.area.tag_redraw() + return {"RUNNING_MODAL"} + if event.type == "LEFTMOUSE" and event.value == "RELEASE": + self._cursor = ( + float(event.mouse_region_x), + float(event.mouse_region_y), + ) + frame = rectangle_screen_path(self._start, self._cursor) + if not frame: + self._finish(context) + return {"CANCELLED"} + try: + active = bpy.data.objects.get(self._active_name) + sources = tuple( + bpy.data.objects.get(name) for name in self._source_names + ) + if ( + not is_image_empty(active) + or any(not is_image_empty(source) for source in sources) + or context.active_object is not active + ): + raise ValueError("The selected Image Empties changed during Frame") + apply_frame(context, sources, frame) + except (RuntimeError, TypeError, ValueError) as error: + report_image_edit_exception(self, error) + self._finish(context) + return {"CANCELLED"} + self._finish(context) + return {"FINISHED"} + return {"PASS_THROUGH"} + + +class FrameTool(bpy.types.WorkSpaceTool): + bl_space_type = "VIEW_3D" + bl_context_mode = "OBJECT" + bl_idname = FRAME_TOOL_ID + bl_label = "Frame" + bl_description = "Draw a rectangle to combine selected images into one image from the current view." + bl_icon = "ops.sculpt.border_mask" + bl_operator = FrameImages.bl_idname + bl_keymap = image_edit_drag_keymap(FrameImages.bl_idname) diff --git a/src/anyimage/operators/frame_tool/projection.py b/src/anyimage/operators/frame_tool/projection.py new file mode 100644 index 0000000..765cab1 --- /dev/null +++ b/src/anyimage/operators/frame_tool/projection.py @@ -0,0 +1,264 @@ +"""Project image planes and place the view-facing Frame result.""" + +import math +from ...common.image import MAX_PROJECTIVE_OUTPUT_PIXELS +from ...common.selection import clip_polygon_halfplanes + + +PROJECTIVE_RELATIVE_EPSILON = 1e-12 + + +def _matrix_array(matrix): + import numpy as np + + return np.asarray( + tuple(tuple(float(value) for value in row) for row in matrix), + dtype=np.float64, + ) + + +def frame_source_projection( + projection_matrix, + view_matrix, + matrix_world, + bounds, + region_size, + *, + perspective, +): + """Return normalized image-plane transforms for one frozen view.""" + import numpy as np + + x_min, x_max, y_min, y_max = (float(value) for value in bounds) + region_width, region_height = (float(value) for value in region_size) + if min(x_max - x_min, y_max - y_min, region_width, region_height) <= 0.0: + raise ValueError("The Image Empty projection is invalid") + source_plane = np.asarray( + ( + (x_max - x_min, 0.0, x_min), + (0.0, y_min - y_max, y_max), + (0.0, 0.0, 0.0), + (0.0, 0.0, 1.0), + ), + dtype=np.float64, + ) + world = _matrix_array(matrix_world) + source_to_world = world @ source_plane + source_to_view = _matrix_array(view_matrix) @ source_to_world + source_to_clip = _matrix_array(projection_matrix) @ source_to_world + source_to_screen = np.stack( + ( + 0.5 * (source_to_clip[0] + source_to_clip[3]), + 0.5 * (source_to_clip[1] + source_to_clip[3]), + source_to_clip[3], + ) + ) + scale = float(np.max(np.abs(source_to_screen))) + if not math.isfinite(scale) or scale <= 0.0: + raise ValueError("The Image Empty projection is invalid") + source_to_screen /= scale + if not np.isfinite(source_to_screen).all(): + raise ValueError("The Image Empty projection is invalid") + condition = float(np.linalg.cond(source_to_screen)) + if not math.isfinite(condition) or condition > 1.0 / np.finfo(np.float64).eps: + raise ValueError("The Image Empty is edge-on to the current view") + return { + "bounds": (x_min, x_max, y_min, y_max), + "origin_depth": -float((_matrix_array(view_matrix) @ world)[2, 3]), + "perspective": bool(perspective), + "region_size": (region_width, region_height), + "screen_to_source": np.linalg.inv(source_to_screen), + "source_to_screen": source_to_screen, + "source_to_view": source_to_view, + "source_to_world": source_to_world, + } + + +def _projective_points(points, matrix): + """Map points and return finite results plus per-point validity.""" + import numpy as np + + points = np.asarray(points, dtype=np.float64) + flat = points.reshape((-1, 2)) + homogeneous = np.column_stack((flat, np.ones(len(flat), dtype=np.float64))) + mapped = homogeneous @ np.asarray(matrix, dtype=np.float64).T + magnitude = np.max(np.abs(mapped), axis=1) + valid = ( + np.isfinite(mapped).all(axis=1) + & (np.abs(mapped[:, 2]) > PROJECTIVE_RELATIVE_EPSILON * magnitude) + ) + result = np.full((len(flat), 2), np.nan, dtype=np.float64) + result[valid] = mapped[valid, :2] / mapped[valid, 2, None] + return result.reshape(points.shape), valid.reshape(points.shape[:-1]) + + +def frame_source_coordinates(source, screen_points): + """Return source pixels, view depth, validity, and normalized coordinates.""" + import numpy as np + + screen_points = np.asarray(screen_points, dtype=np.float64) + region_width, region_height = source["region_size"] + normalized_screen = screen_points / (region_width, region_height) + normalized_source, valid = _projective_points( + normalized_screen, + source["screen_to_source"], + ) + flat_source = normalized_source.reshape((-1, 2)) + homogeneous = np.column_stack( + (flat_source, np.ones(len(flat_source), dtype=np.float64)) + ) + depth = -(homogeneous @ source["source_to_view"][2]) + depth = depth.reshape(valid.shape) + valid &= np.isfinite(depth) + if source["perspective"]: + valid &= depth > 0.0 + valid &= ( + (normalized_source[..., 0] >= 0.0) + & (normalized_source[..., 0] <= 1.0) + & (normalized_source[..., 1] >= 0.0) + & (normalized_source[..., 1] <= 1.0) + ) + image_width, image_height = source["image_size"] + source_points = normalized_source * (image_width, image_height) + source_points[~valid] = -1.0 + return source_points, depth, valid, normalized_source + + +def frame_source_overlap(source, frame_quad): + """Clip the source plane before dividing its homogeneous projection.""" + import numpy as np + + frame = np.asarray(frame_quad, dtype=np.float64) / source["region_size"] + left, bottom = frame.min(axis=0) + right, top = frame.max(axis=0) + x, y, w = source["source_to_screen"] + boundaries = [x - left * w, right * w - x, y - bottom * w, top * w - y] + if source["perspective"]: + boundaries.insert(0, -source["source_to_view"][2]) + polygon = clip_polygon_halfplanes( + ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)), boundaries + ) + if len(polygon) < 3: + return () + screen, valid = _projective_points(polygon, source["source_to_screen"]) + if not np.all(valid): + return () + screen *= source["region_size"] + # Center the coordinates to avoid cancellation in a distant small polygon. + centered = screen - screen[0] + area = abs(np.sum(centered[:, 0] * np.roll(centered[:, 1], -1) + - centered[:, 1] * np.roll(centered[:, 0], -1))) * 0.5 + return tuple(map(tuple, screen)) if area > 1e-8 else () + + +def polygon_centroid(polygon): + """Return the area centroid of one non-degenerate screen polygon.""" + cross_sum = 0.0 + x_sum = 0.0 + y_sum = 0.0 + for first, second in zip(polygon, (*polygon[1:], polygon[0])): + cross = first[0] * second[1] - second[0] * first[1] + cross_sum += cross + x_sum += (first[0] + second[0]) * cross + y_sum += (first[1] + second[1]) * cross + if abs(cross_sum) <= 1e-12: + raise ValueError("Frame does not overlap the active Image Empty") + return x_sum / (3.0 * cross_sum), y_sum / (3.0 * cross_sum) + + +def frame_output_size(frame_quad, region_size, max_resolution): + """Choose output resolution from Frame pixels within the current Region.""" + frame_width = abs(float(frame_quad[1][0] - frame_quad[0][0])) + frame_height = abs(float(frame_quad[0][1] - frame_quad[3][1])) + region_width, region_height = (float(value) for value in region_size) + maximum = float(max_resolution) + if min(frame_width, frame_height, region_width, region_height, maximum) <= 0.0: + raise ValueError("The Frame output dimensions must be positive") + scale = min( + 1.0, + region_width / frame_width, + region_height / frame_height, + maximum / max(frame_width, frame_height), + math.sqrt( + MAX_PROJECTIVE_OUTPUT_PIXELS / max(frame_width * frame_height, 1.0) + ), + ) + return ( + max(2, int(round(frame_width * scale))), + max(2, int(round(frame_height * scale))), + ) + + +def view_facing_frame_geometry(corners, output_size): + """Return a world matrix array and display size for frame-plane corners.""" + import numpy as np + + corners = np.asarray(corners, dtype=np.float64) + if corners.shape != (4, 3) or not np.isfinite(corners).all(): + raise ValueError("The Frame view plane is invalid") + top_left, top_right, _bottom_right, bottom_left = corners + right = top_right - top_left + up = top_left - bottom_left + width = float(np.linalg.norm(right)) + height = float(np.linalg.norm(up)) + if min(width, height) <= 1e-10: + raise ValueError("Draw a larger Frame") + right /= width + up /= height + forward = np.cross(right, up) + forward_length = float(np.linalg.norm(forward)) + if forward_length <= 1e-10: + raise ValueError("The Frame view plane is invalid") + forward /= forward_length + matrix = np.identity(4, dtype=np.float64) + matrix[:3, 0] = right + matrix[:3, 1] = up + matrix[:3, 2] = forward + matrix[:3, 3] = corners.mean(axis=0) + display_size = width if output_size[0] >= output_size[1] else height + return matrix, display_size + + +def frame_result_transform(frame_quad, source_origin, region, view3d, output_size): + """Return the view-facing object matrix and Image Empty display size.""" + from mathutils import Matrix + from bpy_extras import view3d_utils + + corners = tuple( + tuple( + float(value) + for value in view3d_utils.region_2d_to_location_3d( + region, + view3d, + point, + source_origin, + ) + ) + for point in frame_quad + ) + matrix, display_size = view_facing_frame_geometry(corners, output_size) + return Matrix(tuple(tuple(row) for row in matrix)), display_size + + +def frame_depth_location(active_source, overlap_polygon): + """Return the world point whose view depth places the Frame result.""" + import numpy as np + + source_object = active_source["object"] + if not active_source["perspective"] or active_source["origin_depth"] > 0.0: + return source_object.matrix_world.translation.copy() + centroid = np.asarray((polygon_centroid(overlap_polygon),), dtype=np.float64) + _points, depth, valid, normalized_source = frame_source_coordinates( + active_source, + centroid, + ) + if not bool(valid[0]) or depth[0] <= 0.0: + raise ValueError("The Frame result depth is invalid") + source_point = np.asarray( + (normalized_source[0, 0], normalized_source[0, 1], 1.0), + dtype=np.float64, + ) + world = active_source["source_to_world"] @ source_point + if not np.isfinite(world).all() or abs(world[3]) <= 1e-12: + raise ValueError("The Frame result depth is invalid") + return tuple(float(value) for value in world[:3] / world[3]) diff --git a/src/anyimage/operators/mask_tool.py b/src/anyimage/operators/mask_tool.py index 47c4850..3baa00c 100644 --- a/src/anyimage/operators/mask_tool.py +++ b/src/anyimage/operators/mask_tool.py @@ -39,7 +39,7 @@ def compose_alpha(source_alpha, mask_values, mode): mode = str(mode) if mode == "SET": result = alpha * mask - elif mode == "ADD": + elif mode == "EXTEND": result = np.maximum(alpha, mask) elif mode == "SUBTRACT": result = alpha * (1.0 - mask) @@ -72,7 +72,7 @@ def rasterize_brush_path(image_size, path, radius, project_path): class EditImageAlpha(ImageGesture, bpy.types.Operator): bl_idname = "anyimage.edit_image_alpha" bl_label = "Edit Image Alpha" - bl_description = "Edit Image Empty Alpha with a lasso, brush, or polyline" + bl_description = "Hide or reveal image areas with a lasso, brush, or polyline." bl_options = {"UNDO"} active_tool_id = MASK_TOOL_ID resolve_on_press = True @@ -157,7 +157,7 @@ class MaskTool(bpy.types.WorkSpaceTool): bl_context_mode = "OBJECT" bl_idname = MASK_TOOL_ID bl_label = "Mask" - bl_description = "Edit Image Empty Alpha with a lasso, brush, or polyline" + bl_description = "Hide or reveal image areas with a lasso, brush, or polyline." bl_icon = "ops.sculpt.lasso_mask" bl_operator = EditImageAlpha.bl_idname bl_cursor = "PAINT_CROSS" diff --git a/src/anyimage/operators/rectify_tool/__init__.py b/src/anyimage/operators/rectify_tool/__init__.py new file mode 100644 index 0000000..11d0cce --- /dev/null +++ b/src/anyimage/operators/rectify_tool/__init__.py @@ -0,0 +1,7 @@ +"""Perspective correction operators and workspace tool.""" + +from .operators import RectifyImagePerspective, RectifyTool + +CLASSES = (RectifyImagePerspective,) + +__all__ = ("CLASSES", "RectifyImagePerspective", "RectifyTool") diff --git a/src/anyimage/operators/rectify_tool/geometry.py b/src/anyimage/operators/rectify_tool/geometry.py new file mode 100644 index 0000000..5299f9d --- /dev/null +++ b/src/anyimage/operators/rectify_tool/geometry.py @@ -0,0 +1,144 @@ +"""Validate perspective quads and resample the selected image region.""" + +import math + +from ...common.selection import ( + clip_polygon_halfplanes, + trim_pixels_to_alpha, +) +from ...common.image import ( + projective_output_size, + warp_projective_pixels, +) + + +MIN_INTERACTIVE_ASPECT = 0.05 +MAX_INTERACTIVE_ASPECT = 20.0 + + +def validate_perspective_quad(points, min_area=1e-6): + """Return four convex corners without changing their correspondence.""" + import numpy as np + + quad = np.asarray(points, dtype=np.float64) + if quad.shape != (4, 2) or not np.isfinite(quad).all(): + raise ValueError("Rectify requires four valid image corners") + edges = np.roll(quad, -1, axis=0) - quad + if np.any(np.linalg.norm(edges, axis=1) < 2.0): + raise ValueError("Rectify corners are too close together") + crosses = np.cross(edges, np.roll(edges, -1, axis=0)) + if np.any(np.abs(crosses) <= 1e-8) or not ( + np.all(crosses > 0.0) or np.all(crosses < 0.0) + ): + raise ValueError("Rectify corners must define one convex quadrilateral") + area = 0.5 * abs( + float( + np.dot(quad[:, 0], np.roll(quad[:, 1], -1)) + - np.dot(quad[:, 1], np.roll(quad[:, 0], -1)) + ) + ) + if area < min_area: + raise ValueError("Draw a larger Rectify quad") + return quad + + +def canonical_perspective_quad(points, min_area=1e-6): + """Order four unordered points as source-oriented top-left through bottom-left.""" + import numpy as np + + points = np.asarray(points, dtype=np.float64) + if points.shape != (4, 2) or not np.isfinite(points).all(): + raise ValueError("Rectify requires four valid image corners") + distances = np.linalg.norm(points[:, None] - points[None, :], axis=2) + distances += np.eye(4) * 1e9 + if float(distances.min()) < 2.0: + raise ValueError("Rectify corners are too close together") + center = points.mean(axis=0) + ring = points[np.argsort(np.arctan2(points[:, 1] - center[1], points[:, 0] - center[0]))] + ring = validate_perspective_quad(ring, min_area) + candidates = [] + for winding in (ring, ring[::-1]): + for start in range(4): + candidate = np.roll(winding, -start, axis=0) + top = candidate[1] - candidate[0] + left = candidate[3] - candidate[0] + score = top[0] / np.linalg.norm(top) + left[1] / np.linalg.norm(left) + candidates.append((float(score), -candidate[0, 1], -candidate[0, 0], candidate)) + return max(candidates, key=lambda item: item[:3])[3] + + +def perspective_quad_overlaps_image(points, image_size): + import numpy as np + + width, height = image_size + if min(width, height) <= 0: + raise ValueError("The source image has no readable dimensions") + clipped = np.asarray(clip_polygon_halfplanes( + points, ((1, 0, 0), (-1, 0, width), (0, 1, 0), (0, -1, height)) + )) + if clipped.ndim != 2 or len(clipped) < 3: + return False + area = 0.5 * abs( + float( + np.dot(clipped[:, 0], np.roll(clipped[:, 1], -1)) + - np.dot(clipped[:, 1], np.roll(clipped[:, 0], -1)) + ) + ) + return area > 1e-6 + + +def perspective_quad_aspect(quad): + """Estimate an initial output aspect from the visible quad edge lengths.""" + import numpy as np + + quad = validate_perspective_quad(quad) + width = 0.5 * ( + np.linalg.norm(quad[1] - quad[0]) + np.linalg.norm(quad[2] - quad[3]) + ) + height = 0.5 * ( + np.linalg.norm(quad[2] - quad[1]) + np.linalg.norm(quad[3] - quad[0]) + ) + return min( + max(float(width / max(height, 1e-8)), MIN_INTERACTIVE_ASPECT), + MAX_INTERACTIVE_ASPECT, + ) + + +def perspective_placement_bounds(quad, output_size, trim_bounds): + """Map a rectified Alpha trim back to an approximate source-image rectangle.""" + quad = validate_perspective_quad(quad) + source_left = math.floor(float(quad[:, 0].min())) + source_top = math.floor(float(quad[:, 1].min())) + source_right = math.ceil(float(quad[:, 0].max())) + source_bottom = math.ceil(float(quad[:, 1].max())) + output_width, output_height = (float(value) for value in output_size) + trim_left, trim_top, trim_right, trim_bottom = trim_bounds + source_width = source_right - source_left + source_height = source_bottom - source_top + return ( + source_left + trim_left * source_width / output_width, + source_top + trim_top * source_height / output_height, + source_left + trim_right * source_width / output_width, + source_top + trim_bottom * source_height / output_height, + ) + + +def extract_perspective_pixels( + source_pixels, + source_size, + quad, + aspect_ratio, +): + output_size = projective_output_size(quad, aspect_ratio) + pixels = warp_projective_pixels( + source_pixels, source_size, quad, output_size + ) + pixels, trim_bounds = trim_pixels_to_alpha(pixels, (0, 0, *output_size)) + trimmed_size = ( + trim_bounds[2] - trim_bounds[0], + trim_bounds[3] - trim_bounds[1], + ) + placement_bounds = perspective_placement_bounds( + quad, output_size, trim_bounds + ) + return pixels, trimmed_size, placement_bounds diff --git a/src/anyimage/operators/rectify_tool.py b/src/anyimage/operators/rectify_tool/operators.py similarity index 58% rename from src/anyimage/operators/rectify_tool.py rename to src/anyimage/operators/rectify_tool/operators.py index 6dca61f..f7dbb95 100644 --- a/src/anyimage/operators/rectify_tool.py +++ b/src/anyimage/operators/rectify_tool/operators.py @@ -1,9 +1,11 @@ +"""Rectify interaction and Blender image replacement.""" + import json import math import bpy -from ..common.viewport import ( +from ...common.viewport import ( active_view3d_tool_id, deserialize_matrix, draw_dashed_line, @@ -16,311 +18,45 @@ from ..common.viewport import ( screen_path_to_image_pixels, serialize_matrix, ) -from ..common.selection import clip_polygon_halfplanes, trim_pixels_to_alpha -from ..common.image import ( +from ...common.image import ( create_image_edit_result, - image_base_name, image_pixels, image_empty_bounds, - projective_output_size, replace_empty_image, require_image_empty, - video_total_frames, + is_animated_image, warp_projective_pixels, ) -MIN_QUAD_AREA = 16.0 -RECTIFY_TOOL_ID = "anyimage.rectify" -PREVIEW_TEXTURE_SIZE = 512 -ASPECT_PIXELS_PER_DOUBLE = 240.0 -MIN_INTERACTIVE_ASPECT = 0.05 -MAX_INTERACTIVE_ASPECT = 20.0 -ASPECT_PRESETS = ( - ("9 : 16", 9.0 / 16.0), - ("3 : 4", 3.0 / 4.0), - ("1 : 1", 1.0), - ("4 : 3", 4.0 / 3.0), - ("16 : 9", 16.0 / 9.0), +from .geometry import ( + MAX_INTERACTIVE_ASPECT, + MIN_INTERACTIVE_ASPECT, + canonical_perspective_quad, + extract_perspective_pixels, + perspective_quad_aspect, + perspective_quad_overlaps_image, + validate_perspective_quad, +) +from .preview import ( + PREVIEW_TEXTURE_SIZE, + aspect_ratio_from_mouse, + create_perspective_preview_image, + draw_aspect_hud, + draw_preview_frame, + interactive_preview_polygon, + preview_draw_bounds, + preview_texture_draw_options, + snapped_aspect_ratio, ) -def validate_perspective_quad(points, min_area=1e-6): - """Return four convex corners without changing their correspondence.""" - import numpy as np - - quad = np.asarray(points, dtype=np.float64) - if quad.shape != (4, 2) or not np.isfinite(quad).all(): - raise ValueError("Rectify requires four valid image corners") - edges = np.roll(quad, -1, axis=0) - quad - if np.any(np.linalg.norm(edges, axis=1) < 2.0): - raise ValueError("Rectify corners are too close together") - crosses = np.cross(edges, np.roll(edges, -1, axis=0)) - if np.any(np.abs(crosses) <= 1e-8) or not ( - np.all(crosses > 0.0) or np.all(crosses < 0.0) - ): - raise ValueError("Rectify corners must define one convex quadrilateral") - area = 0.5 * abs( - float( - np.dot(quad[:, 0], np.roll(quad[:, 1], -1)) - - np.dot(quad[:, 1], np.roll(quad[:, 0], -1)) - ) - ) - if area < min_area: - raise ValueError("Draw a larger Rectify quad") - return quad - - -def canonical_perspective_quad(points, min_area=1e-6): - """Order four unordered points as source-oriented top-left through bottom-left.""" - import numpy as np - - points = np.asarray(points, dtype=np.float64) - if points.shape != (4, 2) or not np.isfinite(points).all(): - raise ValueError("Rectify requires four valid image corners") - distances = np.linalg.norm(points[:, None] - points[None, :], axis=2) - distances += np.eye(4) * 1e9 - if float(distances.min()) < 2.0: - raise ValueError("Rectify corners are too close together") - center = points.mean(axis=0) - ring = points[np.argsort(np.arctan2(points[:, 1] - center[1], points[:, 0] - center[0]))] - ring = validate_perspective_quad(ring, min_area) - candidates = [] - for winding in (ring, ring[::-1]): - for start in range(4): - candidate = np.roll(winding, -start, axis=0) - top = candidate[1] - candidate[0] - left = candidate[3] - candidate[0] - score = top[0] / np.linalg.norm(top) + left[1] / np.linalg.norm(left) - candidates.append((float(score), -candidate[0, 1], -candidate[0, 0], candidate)) - return max(candidates, key=lambda item: item[:3])[3] - - -def preview_polygon(points): - """Return a non-crossing preview hull for the current unordered clicks.""" - import numpy as np - - points = np.asarray(points, dtype=np.float64) - if len(points) < 3: - return points - center = points.mean(axis=0) - return points[np.argsort(np.arctan2(points[:, 1] - center[1], points[:, 0] - center[0]))] - - -def interactive_preview_polygon(points, cursor): - """Return the clicked points plus one distinct moving cursor point.""" - vertices = list(points) - if ( - cursor is not None - and vertices - and len(vertices) < 4 - and math.dist(vertices[-1], cursor) >= 2.0 - ): - vertices.append(cursor) - return preview_polygon(vertices) - - -def perspective_quad_overlaps_image(points, image_size): - import numpy as np - - width, height = image_size - if min(width, height) <= 0: - raise ValueError("The source image has no readable dimensions") - clipped = np.asarray(clip_polygon_halfplanes( - points, ((1, 0, 0), (-1, 0, width), (0, 1, 0), (0, -1, height)) - )) - if clipped.ndim != 2 or len(clipped) < 3: - return False - area = 0.5 * abs( - float( - np.dot(clipped[:, 0], np.roll(clipped[:, 1], -1)) - - np.dot(clipped[:, 1], np.roll(clipped[:, 0], -1)) - ) - ) - return area > 1e-6 - - -def aspect_ratio_from_mouse(initial_ratio, horizontal_delta): - ratio = initial_ratio * math.pow( - 2.0, float(horizontal_delta) / ASPECT_PIXELS_PER_DOUBLE - ) - return min(max(ratio, MIN_INTERACTIVE_ASPECT), MAX_INTERACTIVE_ASPECT) - - -def snapped_aspect_ratio(aspect_ratio): - label, ratio = min( - ASPECT_PRESETS, - key=lambda preset: abs(math.log(aspect_ratio / preset[1])), - ) - return ratio, label - - -def aspect_ratio_label(aspect_ratio, preset_label=None): - if preset_label is not None: - return preset_label - if aspect_ratio >= 1.0: - return f"{aspect_ratio:.2f} : 1" - return f"1 : {1.0 / aspect_ratio:.2f}" - - -def preview_draw_bounds(region_size, aspect_ratio): - region_width, region_height = (float(value) for value in region_size) - available_width = min(region_width * 0.65, 640.0) - available_height = min(region_height * 0.65, 640.0) - if aspect_ratio >= available_width / available_height: - width = available_width - height = width / aspect_ratio - else: - height = available_height - width = height * aspect_ratio - return ( - (region_width - width) * 0.5, - (region_height - height) * 0.5, - width, - height, - ) - - -def draw_preview_frame(bounds): - import gpu - from gpu_extras.batch import batch_for_shader - - left, bottom, width, height = bounds - vertices = ( - (left, bottom), - (left + width, bottom), - (left + width, bottom + height), - (left, bottom + height), - (left, bottom), - ) - shader = gpu.shader.from_builtin("UNIFORM_COLOR") - batch = batch_for_shader(shader, "LINE_STRIP", {"pos": vertices}) - gpu.state.blend_set("ALPHA") - gpu.state.line_width_set(2.0) - shader.bind() - shader.uniform_float("color", (0.12, 0.52, 1.0, 1.0)) - batch.draw(shader) - gpu.state.line_width_set(1.0) - gpu.state.blend_set("NONE") - - -def draw_centered_text(text, center_x, baseline_y, size, color): - import blf - - font_id = 0 - blf.size(font_id, size) - width, _height = blf.dimensions(font_id, text) - x = center_x - width * 0.5 - blf.position(font_id, x + 1.0, baseline_y - 1.0, 0.0) - blf.color(font_id, 0.0, 0.0, 0.0, 0.9) - blf.draw(font_id, text) - blf.position(font_id, x, baseline_y, 0.0) - blf.color(font_id, *color) - blf.draw(font_id, text) - - -def draw_aspect_hud(bounds, aspect_ratio, preset_label=None): - left, bottom, width, height = bounds - center_x = left + width * 0.5 - draw_centered_text( - aspect_ratio_label(aspect_ratio, preset_label), - center_x, - bottom + height * 0.5 - 10.0, - 22, - (0.25, 0.65, 1.0, 1.0), - ) - draw_centered_text( - "Move horizontally • Ctrl snap • LMB confirm • Backspace edit", - center_x, - bottom - 26.0, - 13, - (0.85, 0.9, 1.0, 1.0), - ) - - -def create_perspective_preview_image(source_image, pixels): - image = bpy.data.images.new( - f"{image_base_name(source_image)}.rectify-preview", - width=PREVIEW_TEXTURE_SIZE, - height=PREVIEW_TEXTURE_SIZE, - alpha=True, - ) - try: - image.colorspace_settings.name = "sRGB" - image.alpha_mode = "STRAIGHT" - image.pixels.foreach_set(pixels) - image.update() - except Exception: - bpy.data.images.remove(image, do_unlink=True) - raise - return image - - -def preview_texture_draw_options(blender_version): - if tuple(blender_version) >= (5, 0, 0): - return {"is_scene_linear_with_rec709_srgb_target": True} - return {} - - -def perspective_quad_aspect(quad): - """Estimate an initial output aspect from the visible quad edge lengths.""" - import numpy as np - - quad = validate_perspective_quad(quad) - width = 0.5 * ( - np.linalg.norm(quad[1] - quad[0]) + np.linalg.norm(quad[2] - quad[3]) - ) - height = 0.5 * ( - np.linalg.norm(quad[2] - quad[1]) + np.linalg.norm(quad[3] - quad[0]) - ) - return min( - max(float(width / max(height, 1e-8)), MIN_INTERACTIVE_ASPECT), - MAX_INTERACTIVE_ASPECT, - ) - - -def perspective_placement_bounds(quad, output_size, trim_bounds): - """Map a rectified Alpha trim back to an approximate source-image rectangle.""" - quad = validate_perspective_quad(quad) - source_left = math.floor(float(quad[:, 0].min())) - source_top = math.floor(float(quad[:, 1].min())) - source_right = math.ceil(float(quad[:, 0].max())) - source_bottom = math.ceil(float(quad[:, 1].max())) - output_width, output_height = (float(value) for value in output_size) - trim_left, trim_top, trim_right, trim_bottom = trim_bounds - source_width = source_right - source_left - source_height = source_bottom - source_top - return ( - source_left + trim_left * source_width / output_width, - source_top + trim_top * source_height / output_height, - source_left + trim_right * source_width / output_width, - source_top + trim_bottom * source_height / output_height, - ) - - -def extract_perspective_pixels( - source_pixels, - source_size, - quad, - aspect_ratio, -): - output_size = projective_output_size(quad, aspect_ratio) - pixels = warp_projective_pixels( - source_pixels, source_size, quad, output_size - ) - pixels, trim_bounds = trim_pixels_to_alpha(pixels, (0, 0, *output_size)) - trimmed_size = ( - trim_bounds[2] - trim_bounds[0], - trim_bounds[3] - trim_bounds[1], - ) - placement_bounds = perspective_placement_bounds( - quad, output_size, trim_bounds - ) - return pixels, trimmed_size, placement_bounds +MIN_QUAD_AREA = 16.0 +RECTIFY_TOOL_ID = "anyimage.rectify" class RectifyImagePerspective(bpy.types.Operator): bl_idname = "anyimage.rectify_image_perspective" bl_label = "Rectify Image Perspective" - bl_description = "Rectify four image corners into a rectangular image" + bl_description = "Select four corners of an image area to correct its perspective into a rectangle." bl_options = {"REGISTER", "UNDO"} source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) @@ -456,7 +192,7 @@ class RectifyImagePerspective(bpy.types.Operator): 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: + if is_animated_image(source_object.data): self.report( {"WARNING"}, "Rectify only supports a still image", @@ -621,7 +357,7 @@ class RectifyImagePerspective(bpy.types.Operator): self.report({"ERROR"}, str(error)) return {"CANCELLED"} try: - if video_total_frames(source_object.data) > 1: + if is_animated_image(source_object.data): raise RuntimeError("Rectify only supports a still image") quad = validate_perspective_quad(json.loads(self.quad_json)) if not perspective_quad_overlaps_image( @@ -632,8 +368,6 @@ class RectifyImagePerspective(bpy.types.Operator): "Rectify quad does not overlap the source image", ) return {"CANCELLED"} - import numpy as np - source_pixels = self._source_pixels if source_pixels is None or len(source_pixels) != len( source_object.data.pixels @@ -672,7 +406,7 @@ class RectifyTool(bpy.types.WorkSpaceTool): bl_context_mode = "OBJECT" bl_idname = RECTIFY_TOOL_ID bl_label = "Rectify" - bl_description = "Rectify four image corners into a rectangular image" + bl_description = "Select four corners of an image area to correct its perspective into a rectangle." bl_icon = "ops.sculpt.line_project" bl_operator = RectifyImagePerspective.bl_idname bl_keymap = image_edit_point_keymap(RectifyImagePerspective.bl_idname) diff --git a/src/anyimage/operators/rectify_tool/preview.py b/src/anyimage/operators/rectify_tool/preview.py new file mode 100644 index 0000000..22f813d --- /dev/null +++ b/src/anyimage/operators/rectify_tool/preview.py @@ -0,0 +1,168 @@ +"""Rectify preview images, overlays, and interactive aspect controls.""" + +import math + +import bpy + +from ...common.image import image_base_name +from .geometry import ( + MAX_INTERACTIVE_ASPECT, + MIN_INTERACTIVE_ASPECT, +) + + +PREVIEW_TEXTURE_SIZE = 512 +ASPECT_PIXELS_PER_DOUBLE = 240.0 +ASPECT_PRESETS = ( + ("9 : 16", 9.0 / 16.0), + ("3 : 4", 3.0 / 4.0), + ("1 : 1", 1.0), + ("4 : 3", 4.0 / 3.0), + ("16 : 9", 16.0 / 9.0), +) + + +def preview_polygon(points): + """Return a non-crossing preview hull for the current unordered clicks.""" + import numpy as np + + points = np.asarray(points, dtype=np.float64) + if len(points) < 3: + return points + center = points.mean(axis=0) + return points[np.argsort(np.arctan2(points[:, 1] - center[1], points[:, 0] - center[0]))] + + +def interactive_preview_polygon(points, cursor): + """Return the clicked points plus one distinct moving cursor point.""" + vertices = list(points) + if ( + cursor is not None + and vertices + and len(vertices) < 4 + and math.dist(vertices[-1], cursor) >= 2.0 + ): + vertices.append(cursor) + return preview_polygon(vertices) + + +def aspect_ratio_from_mouse(initial_ratio, horizontal_delta): + ratio = initial_ratio * math.pow( + 2.0, float(horizontal_delta) / ASPECT_PIXELS_PER_DOUBLE + ) + return min(max(ratio, MIN_INTERACTIVE_ASPECT), MAX_INTERACTIVE_ASPECT) + + +def snapped_aspect_ratio(aspect_ratio): + label, ratio = min( + ASPECT_PRESETS, + key=lambda preset: abs(math.log(aspect_ratio / preset[1])), + ) + return ratio, label + + +def aspect_ratio_label(aspect_ratio, preset_label=None): + if preset_label is not None: + return preset_label + if aspect_ratio >= 1.0: + return f"{aspect_ratio:.2f} : 1" + return f"1 : {1.0 / aspect_ratio:.2f}" + + +def preview_draw_bounds(region_size, aspect_ratio): + region_width, region_height = (float(value) for value in region_size) + available_width = min(region_width * 0.65, 640.0) + available_height = min(region_height * 0.65, 640.0) + if aspect_ratio >= available_width / available_height: + width = available_width + height = width / aspect_ratio + else: + height = available_height + width = height * aspect_ratio + return ( + (region_width - width) * 0.5, + (region_height - height) * 0.5, + width, + height, + ) + + +def draw_preview_frame(bounds): + import gpu + from gpu_extras.batch import batch_for_shader + + left, bottom, width, height = bounds + vertices = ( + (left, bottom), + (left + width, bottom), + (left + width, bottom + height), + (left, bottom + height), + (left, bottom), + ) + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + batch = batch_for_shader(shader, "LINE_STRIP", {"pos": vertices}) + gpu.state.blend_set("ALPHA") + gpu.state.line_width_set(2.0) + shader.bind() + shader.uniform_float("color", (0.12, 0.52, 1.0, 1.0)) + batch.draw(shader) + gpu.state.line_width_set(1.0) + gpu.state.blend_set("NONE") + + +def draw_centered_text(text, center_x, baseline_y, size, color): + import blf + + font_id = 0 + blf.size(font_id, size) + width, _height = blf.dimensions(font_id, text) + x = center_x - width * 0.5 + blf.position(font_id, x + 1.0, baseline_y - 1.0, 0.0) + blf.color(font_id, 0.0, 0.0, 0.0, 0.9) + blf.draw(font_id, text) + blf.position(font_id, x, baseline_y, 0.0) + blf.color(font_id, *color) + blf.draw(font_id, text) + + +def draw_aspect_hud(bounds, aspect_ratio, preset_label=None): + left, bottom, width, height = bounds + center_x = left + width * 0.5 + draw_centered_text( + aspect_ratio_label(aspect_ratio, preset_label), + center_x, + bottom + height * 0.5 - 10.0, + 22, + (0.25, 0.65, 1.0, 1.0), + ) + draw_centered_text( + "Move horizontally • Ctrl snap • LMB confirm • Backspace edit", + center_x, + bottom - 26.0, + 13, + (0.85, 0.9, 1.0, 1.0), + ) + + +def create_perspective_preview_image(source_image, pixels): + image = bpy.data.images.new( + f"{image_base_name(source_image)}.rectify-preview", + width=PREVIEW_TEXTURE_SIZE, + height=PREVIEW_TEXTURE_SIZE, + alpha=True, + ) + try: + image.colorspace_settings.name = "sRGB" + image.alpha_mode = "STRAIGHT" + image.pixels.foreach_set(pixels) + image.update() + except Exception: + bpy.data.images.remove(image, do_unlink=True) + raise + return image + + +def preview_texture_draw_options(blender_version): + if tuple(blender_version) >= (5, 0, 0): + return {"is_scene_linear_with_rec709_srgb_target": True} + return {} diff --git a/src/anyimage/operators/remove_background.py b/src/anyimage/operators/remove_background.py index 34bdf75..6655c0d 100644 --- a/src/anyimage/operators/remove_background.py +++ b/src/anyimage/operators/remove_background.py @@ -86,23 +86,20 @@ class RunBackgroundRemoval(JobOperatorBase, bpy.types.Operator): require_environment() model = configured_background_model(context) require_model(model) - settings = context.scene.anyimage_settings parameters = { "input": str(require_input_path(self.input_path)), "model": model, "device": production_device().lower(), - "video_frames": settings.video_frames, - "start_frame": self._image_target.start_frame, } if getattr(self, "_hdr_input", None) is not None: - parameters.update(output_kind="alpha", video_frames=1, start_frame=1) + parameters["output_kind"] = "alpha" return parameters def response(self, _context, result): if getattr(self, "_hdr_input", None) is not None: self._hdr_input.apply(self._image_target, result.file("alpha"), prepare_undo=True) else: - self._image_target.apply(result.directory) + self._image_target.apply(result.file("foreground")) if "FINISHED" not in bpy.ops.ed.undo_push(message="Remove Background"): raise RuntimeError("Unable to create the Remove Background Undo step") return "Image background is removed" diff --git a/src/anyimage/operators/upscale.py b/src/anyimage/operators/upscale.py index 2456293..a6e8d2d 100644 --- a/src/anyimage/operators/upscale.py +++ b/src/anyimage/operators/upscale.py @@ -115,18 +115,15 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator): def request(self, context): require_environment() require_model(self.model) - settings = context.scene.anyimage_settings return { "input": str(require_input_path(self.input_path)), "model": self.model, "device": production_device().lower(), - "video_frames": settings.video_frames, - "start_frame": self._image_target.start_frame, "max_input_size": int(self.max_input_size), } def response(self, _context, result): - self._image_target.apply(result.directory, frame_prefix="upscale.") + self._image_target.apply(result.file("upscale")) model_label = upscale_model_label(self.model) return f"Image is upscaled with {model_label}" diff --git a/src/anyimage/panel.py b/src/anyimage/panel.py index 2885cd3..c2b8339 100644 --- a/src/anyimage/panel.py +++ b/src/anyimage/panel.py @@ -87,15 +87,15 @@ class ServerPanel(bpy.types.Panel): cached_row = layout.row(align=True) if len(cached_names) > 1: cached_list = cached_row.column(align=True) - cached_list.label(text="Cached:") + cached_list.label(text="Loaded Models:") for name in cached_names: cached_list.label(text=name) else: cached_row.label( text=( - f"Cached: {cached_names[0]}" + f"Loaded Models: {cached_names[0]}" if cached_names - else "Cached: None" + else "No Models Loaded" ) ) clear = cached_row.row(align=True) diff --git a/src/anyimage/preferences.py b/src/anyimage/preferences.py index 98ec8f4..518b152 100644 --- a/src/anyimage/preferences.py +++ b/src/anyimage/preferences.py @@ -39,9 +39,9 @@ BALLOON_PROFILE_ALGORITHM_ITEMS = ( ( "POISSON", "Poisson", - "Smooth cotangent-Laplacian Balloon profile", + "Create a smooth, rounded bulge within the Cutout outline.", ), - ("TEDDY", "Teddy", "Chordal-axis Balloon profile"), + ("TEDDY", "Teddy", "Create a bulge shaped by the Cutout's central branches."), ) @@ -136,27 +136,25 @@ class AnyImagePreferences(bpy.types.AddonPreferences): bl_idname = ADDON_PACKAGE adapt_material_to_view_transform: bpy.props.BoolProperty( - name="Adapt to Scene View Transform", + name="Preserve Image Colors", description=( - "Use the scene view transform's inverse color space with Straight alpha " - "for new material color textures; otherwise use sRGB with Premultiplied alpha" + "Compensate for the scene's view transform to keep image colors " + "close to the original when creating materials." ), default=True, ) storage_root: bpy.props.StringProperty( - name="Storage Root", + name="Storage Folder", description=( - "Shared user-writable storage for the environment, models and jobs; " - "all Blender versions can use the same directory" + "Choose where AnyImage stores its AI environment, downloaded models, and task files." ), subtype="DIR_PATH", default=str(DEFAULT_STORAGE_ROOT), ) device: bpy.props.EnumProperty( - name="Device", + name="AI Processing Device", description=( - "Inference device used by production tasks; only devices supported " - "on this machine are listed" + "Choose the CPU or GPU used to run AI models." ), items=device_items, default=0, @@ -170,8 +168,8 @@ class AnyImagePreferences(bpy.types.AddonPreferences): max_ai_input_size: bpy.props.IntProperty( name="Maximum AI Input Size", description=( - "Maximum source long edge eligible for Upscale and maximum long edge " - "used by Depth, Normal and Selection refinement" + "Limit the longest image edge used for depth, normal, and selection refinement. " + "Upscale requires a source image smaller than this limit." ), default=DEFAULT_MAX_AI_INPUT_SIZE, min=MIN_MAX_AI_INPUT_SIZE, @@ -181,7 +179,10 @@ class AnyImagePreferences(bpy.types.AddonPreferences): ) max_cutout_mesh_resolution: bpy.props.IntProperty( name="Maximum Cutout Mesh Resolution", - description="Maximum sampling resolution along the longest Cutout edge", + description=( + "Limit the longest image edge used to sample the Cutout mesh. " + "Higher values allow finer outlines and denser meshes." + ), default=DEFAULT_MAX_CUTOUT_MESH_RESOLUTION, min=MIN_CUTOUT_MESH_RESOLUTION, max=MAX_CUTOUT_MESH_RESOLUTION, @@ -190,7 +191,7 @@ class AnyImagePreferences(bpy.types.AddonPreferences): ) max_frame_resolution: bpy.props.IntProperty( name="Maximum Frame Resolution", - description="Maximum output resolution along the longest Frame edge", + description="Set the maximum length, in pixels, of the combined image's longest edge.", default=DEFAULT_MAX_FRAME_RESOLUTION, min=MIN_FRAME_RESOLUTION, max=MAX_FRAME_RESOLUTION, @@ -199,7 +200,7 @@ class AnyImagePreferences(bpy.types.AddonPreferences): ) balloon_profile_algorithm: bpy.props.EnumProperty( name="Balloon Profile", - description="Algorithm used to generate o_balloon for every Cutout shape", + description="Control how the Cutout surface bulges outward from its outline.", items=BALLOON_PROFILE_ALGORITHM_ITEMS, default="POISSON", ) @@ -210,14 +211,17 @@ class AnyImagePreferences(bpy.types.AddonPreferences): default=DEFAULT_BACKGROUND_MODEL_KEY, ) geometry_model: bpy.props.EnumProperty( - name="Generate Depth Map Model", - description="Model used to generate depth maps", + name="Depth & Normal Model", + description="Choose the AI model used to generate depth and surface normals.", items=geometry_model_items, default=0, ) geometry_resolution_level: bpy.props.IntProperty( name="MoGe Resolution Level", - description="MoGe-2 inference resolution used for geometry and normal generation", + description=( + "Set the processing resolution for depth and normal generation. " + "Higher levels can capture finer details but take more time and memory." + ), default=5, min=0, max=9, diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py index 492be42..892d890 100644 --- a/src/anyimage/properties.py +++ b/src/anyimage/properties.py @@ -140,6 +140,18 @@ def upscale_model_label(model_key): return model.label if model is not None else model_key +def cutout_gesture_property(): + return bpy.props.EnumProperty( + name="Gesture", + description="Shape used to select the Cutout area", + items=( + ("LASSO", "Lasso", "Draw a closed freehand area"), + ("POLYLINE", "Polyline", "Click points to define a closed area"), + ), + default="LASSO", + ) + + def mask_gesture_property(): return bpy.props.EnumProperty( name="Gesture", @@ -156,19 +168,31 @@ def mask_gesture_property(): def mask_mode_property(): return bpy.props.EnumProperty( name="Mode", - description="How the gesture changes image Alpha", + description="Choose how the selected or painted area changes image visibility.", items=( - ("SET", "Set", "Keep current Alpha inside the gesture", "SELECT_SET", 0), - ("ADD", "Add", "Extend Alpha with the gesture", "SELECT_EXTEND", 1), + ( + "SET", + "Set", + "Keep visible content inside the selected or painted area and hide everything outside.", + "SELECT_SET", + 0, + ), + ( + "EXTEND", + "Extend", + "Reveal the selected or painted area while keeping other areas unchanged.", + "SELECT_EXTEND", + 1, + ), ( "SUBTRACT", "Subtract", - "Remove Alpha inside the gesture", + "Hide the selected or painted area while keeping other areas unchanged.", "SELECT_SUBTRACT", 2, ), ), - default="SUBTRACT", + default="SET", ) @@ -183,24 +207,12 @@ def mask_radius_property(): ) -def cutout_inward_axis_property(): - return bpy.props.EnumProperty( - name="Inward Axis", - description="Local axis pointing inward from the image sheet", - items=( - ("NEGATIVE_Z", "-Z", "Match the source Image Empty local axes"), - ("POSITIVE_X", "+X", "Use the original Cutout local axes"), - ), - default="NEGATIVE_Z", - ) - - def cutout_generate_normal_property(): return bpy.props.BoolProperty( name="Normal Map", description=( - "Generate a normal map for Flat and Solid; " - "Depth Solid and Depth Balloon always generate normal maps" + "Generate a normal map to add surface detail under lighting for Flat and Solid shapes. " + "Depth Solid and Depth Balloon always generate normal maps." ), default=False, ) @@ -209,7 +221,10 @@ def cutout_generate_normal_property(): def cutout_alpha_threshold_property(): return bpy.props.FloatProperty( name="Alpha Threshold", - description="Minimum Alpha kept in the Cutout outline", + description=( + "Exclude areas below this opacity from the Cutout outline. " + "Higher values exclude more semi-transparent edges." + ), default=0.9, min=0.0, max=1.0, @@ -230,7 +245,7 @@ def cutout_fine_outline_property(): def cutout_mesh_detail_property(): return bpy.props.EnumProperty( name="Mesh Detail", - description="Mesh sampling density", + description="Control mesh density. Higher levels use more vertices to represent finer shapes.", items=( ("LOW", "Low", "32 source image pixels between neighboring mesh points"), ("MEDIUM", "Medium", "16 source image pixels between neighboring mesh points"), @@ -242,18 +257,11 @@ def cutout_mesh_detail_property(): class AnyImageSettings(bpy.types.PropertyGroup): + cutout_gesture: cutout_gesture_property() cutout_alpha_threshold: cutout_alpha_threshold_property() cutout_mesh_detail: cutout_mesh_detail_property() cutout_fine_outline: cutout_fine_outline_property() cutout_generate_normal: cutout_generate_normal_property() - cutout_inward_axis: cutout_inward_axis_property() mask_gesture: mask_gesture_property() mask_mode: mask_mode_property() mask_radius: mask_radius_property() - video_frames: bpy.props.IntProperty( - name="Extract Frames", - description="Maximum number of consecutive frames extracted from the start of a video or frame sequence", - default=16, - min=1, - soft_max=64, - ) diff --git a/src/anyimage/runtime.py b/src/anyimage/runtime.py index d4488ee..66959b8 100644 --- a/src/anyimage/runtime.py +++ b/src/anyimage/runtime.py @@ -12,7 +12,7 @@ ENVIRONMENT = { "numpy==2.4.2", "scipy==1.15.3", "pillow==12.1.1", - "av==17.0.0", + "trimesh==4.11.2", "huggingface-hub==1.4.1", ], diff --git a/src/anyimage/server/geometry/prediction_artifacts.py b/src/anyimage/server/geometry/prediction_artifacts.py index 346c643..6e1ff78 100644 --- a/src/anyimage/server/geometry/prediction_artifacts.py +++ b/src/anyimage/server/geometry/prediction_artifacts.py @@ -64,16 +64,13 @@ def generate_moge_artifacts( parameters.get("max_input_size", DEFAULT_MAX_AI_INPUT_SIZE), ) try: - frames = moge.infer( + frame = moge.infer( context.resource("model_manager"), parameters, - [model_path], + model_path, include_points=generate_depth, cancel_check=context.check_cancelled, ) - if len(frames) != 1: - raise ValueError("Artifact generation requires one geometry frame") - frame = frames[0] result = {} if generate_depth: context.progress(0.45, "Generating depth texture") diff --git a/src/anyimage/server/jobs/cutout.py b/src/anyimage/server/jobs/cutout.py index f52ccb0..607370a 100644 --- a/src/anyimage/server/jobs/cutout.py +++ b/src/anyimage/server/jobs/cutout.py @@ -1,6 +1,6 @@ """Generate optional AI files from one rectangular Cutout image input.""" -from pathlib import Path +from ..media.input import validate_input_path from ..geometry.prediction_artifacts import generate_moge_artifacts @@ -8,7 +8,7 @@ from ..geometry.prediction_artifacts import generate_moge_artifacts def run(context, parameters): context.progress(0.05, "Preparing Cutout Selection") context.directory.mkdir(parents=True, exist_ok=True) - input_path = Path(parameters["input"]) + input_path = validate_input_path(parameters["input"]) result = {} generate_depth = bool(parameters.get("generate_depth", False)) normal_mode = str(parameters.get("normal_mode", "NONE")) diff --git a/src/anyimage/server/jobs/depth_plane.py b/src/anyimage/server/jobs/depth_plane.py index 8078605..6fbf357 100644 --- a/src/anyimage/server/jobs/depth_plane.py +++ b/src/anyimage/server/jobs/depth_plane.py @@ -1,7 +1,7 @@ """Generate model-space depth and normals for one complete image.""" from ..geometry.prediction_artifacts import generate_moge_artifacts -from ..media import input_frames +from ..media.input import validate_input_path def run(context, parameters): @@ -10,16 +10,14 @@ def run(context, parameters): raise ValueError(f"Unsupported plane type: {plane_type}") context.progress(0.05, "Preparing depth conversion") context.directory.mkdir(parents=True, exist_ok=True) - with input_frames(context, parameters) as images: - if len(images) != 1: - raise ValueError("Depth conversion currently supports one image") - artifacts = generate_moge_artifacts( - context, - parameters, - images[0], - generate_depth=True, - normal_mode="OBJECT" if plane_type == "DEPTH" else "TANGENT", - ) + input_path = validate_input_path(parameters["input"]) + artifacts = generate_moge_artifacts( + context, + parameters, + input_path, + generate_depth=True, + normal_mode="OBJECT" if plane_type == "DEPTH" else "TANGENT", + ) context.check_cancelled() context.progress(1.0, "Depth conversion data is ready") return {key: path.name for key, path in artifacts.items()} diff --git a/src/anyimage/server/jobs/panorama.py b/src/anyimage/server/jobs/panorama.py index 7b9cf59..97b62a5 100644 --- a/src/anyimage/server/jobs/panorama.py +++ b/src/anyimage/server/jobs/panorama.py @@ -1,12 +1,10 @@ """Generate a radial depth texture from the current RGBA panorama.""" import json -from pathlib import Path from ..geometry.depth_texture import write_float_exr from ..geometry.panorama import camera_bases, merge_distances, sample_panorama, view_rays -from ..media import VIDEO_SUFFIXES, input_frames -from ..media.input import open_image +from ..media.input import open_image, validate_input_path from ..media.resolution import DEFAULT_MAX_AI_INPUT_SIZE, normalized_max_input_size from ..models.moge import infer_image as infer @@ -15,63 +13,58 @@ def run(context, parameters): import numpy as np context.check_cancelled() - input_path = Path(parameters["input"]) - if input_path.is_dir() or input_path.suffix.lower() in VIDEO_SUFFIXES: - raise ValueError("Panorama conversion requires one static 2:1 image") + input_path = validate_input_path(parameters["input"]) maximum = normalized_max_input_size(parameters.get("max_input_size", DEFAULT_MAX_AI_INPUT_SIZE)) context.directory.mkdir(parents=True, exist_ok=True) outputs = [context.directory / name for name in ("depth.exr", "depth.json")] try: - with input_frames(context, parameters) as images: - if len(images) != 1: + with open_image(input_path) as opened: + if opened.width != opened.height * 2: raise ValueError("Panorama conversion requires one static 2:1 image") - with open_image(images[0]) as opened: - if getattr(opened, "n_frames", 1) != 1 or opened.width != opened.height * 2: - raise ValueError("Panorama conversion requires one static 2:1 image") - source = opened.convert("RGBA") - width, height = source.size - size = min(maximum, max(512, width // 4)) - image = np.asarray(source, dtype=np.float32) / 255.0 - manager = context.resource("model_manager") - bases = camera_bases() - rays = view_rays(size) - distances, masks = [], [] - session = None - for index, basis in enumerate(bases): - context.check_cancelled() - context.progress(0.05 + 0.65 * index / len(bases), f"Generating panorama view {index + 1}/12") - directions = rays @ basis - alpha = sample_panorama(image[..., 3], directions, order=0) - visible = alpha > 0 - if not visible.any(): - distances.append(np.ones((size, size), dtype=np.float32)) - masks.append(visible) - continue - if session is None: - session = manager.get_moge(manager.directory(parameters["model"]), parameters["device"]) - color = sample_panorama(image[..., :3], directions) * alpha[..., None] - prediction = infer(session, color, parameters["resolution_level"], - fov_x=90.0, source_valid=visible) - distances.append(np.linalg.norm(prediction["points"], axis=-1)) - masks.append((prediction["mask"] > 0.5) & visible) - del prediction - context.progress(0.75, "Merging panoramic distance") - distance, valid = merge_distances(distances, masks, bases, width, height, - cancel_check=context.check_cancelled) - alpha = np.asarray(source.getchannel("A"), - dtype=np.float32) / 255.0 - alpha *= valid - valid_depth = (alpha >= 0.9) & np.isfinite(distance) & (distance > 0) - if not valid_depth.any(): - raise ValueError("The panorama contains no valid depth pixels") - if not np.isfinite(distance).all() or (distance <= 0).any(): - raise ValueError("Panorama fusion produced invalid distances") - rgba = np.empty((height, width, 4), dtype=np.float32) - rgba[..., :3], rgba[..., 3] = distance[..., None], alpha - write_float_exr(rgba, outputs[0]) - outputs[1].write_text(json.dumps({ - "projection": "equirectangular", "image_size": [width, height], - }, separators=(",", ":")), encoding="utf-8") + source = opened.convert("RGBA") + width, height = source.size + size = min(maximum, max(512, width // 4)) + image = np.asarray(source, dtype=np.float32) / 255.0 + manager = context.resource("model_manager") + bases = camera_bases() + rays = view_rays(size) + distances, masks = [], [] + session = None + for index, basis in enumerate(bases): + context.check_cancelled() + context.progress(0.05 + 0.65 * index / len(bases), f"Generating panorama view {index + 1}/12") + directions = rays @ basis + alpha = sample_panorama(image[..., 3], directions, order=0) + visible = alpha > 0 + if not visible.any(): + distances.append(np.ones((size, size), dtype=np.float32)) + masks.append(visible) + continue + if session is None: + session = manager.get_moge(manager.directory(parameters["model"]), parameters["device"]) + color = sample_panorama(image[..., :3], directions) * alpha[..., None] + prediction = infer(session, color, parameters["resolution_level"], + fov_x=90.0, source_valid=visible) + distances.append(np.linalg.norm(prediction["points"], axis=-1)) + masks.append((prediction["mask"] > 0.5) & visible) + del prediction + context.progress(0.75, "Merging panoramic distance") + distance, valid = merge_distances(distances, masks, bases, width, height, + cancel_check=context.check_cancelled) + alpha = np.asarray(source.getchannel("A"), + dtype=np.float32) / 255.0 + alpha *= valid + valid_depth = (alpha >= 0.9) & np.isfinite(distance) & (distance > 0) + if not valid_depth.any(): + raise ValueError("The panorama contains no valid depth pixels") + if not np.isfinite(distance).all() or (distance <= 0).any(): + raise ValueError("Panorama fusion produced invalid distances") + rgba = np.empty((height, width, 4), dtype=np.float32) + rgba[..., :3], rgba[..., 3] = distance[..., None], alpha + write_float_exr(rgba, outputs[0]) + outputs[1].write_text(json.dumps({ + "projection": "equirectangular", "image_size": [width, height], + }, separators=(",", ":")), encoding="utf-8") context.check_cancelled() context.progress(1.0, "Panorama conversion data is ready") return {"depth": outputs[0].name, "depth_metadata": outputs[1].name} diff --git a/src/anyimage/server/jobs/remove_background.py b/src/anyimage/server/jobs/remove_background.py index 9dbf48d..e0d352a 100644 --- a/src/anyimage/server/jobs/remove_background.py +++ b/src/anyimage/server/jobs/remove_background.py @@ -1,9 +1,8 @@ import time -from ..media import input_frames -from ..media.input import open_image +from ..media.input import open_image, validate_input_path from ..model_catalog import BACKGROUND_MODELS -from ..models.background import infer_alpha, infer_foreground, infer_foreground_frames, inference_device +from ..models.background import infer_alpha, infer_foreground, inference_device def run(context, parameters): @@ -24,62 +23,45 @@ def run(context, parameters): if output_kind not in {"foreground", "alpha"}: raise ValueError(f"Unknown background removal output: {output_kind}") - with input_frames(context, parameters) as images: - context.progress(0.12, f"Loading {parameters['model']} for {device.upper()}") - if output_kind == "alpha": - import numpy as np + input_path = validate_input_path(parameters["input"]) + context.progress(0.12, f"Loading {parameters['model']} for {device.upper()}") + if output_kind == "alpha": + import numpy as np - if len(images) != 1: - raise ValueError("Alpha output requires one still image") - with open_image(images[0]) as source: - alpha, load_ms, inference_ms = infer_alpha( - source, model_directory, device, model_cache=model_manager, - cancel_check=context.check_cancelled, - ) - context.check_cancelled() - np.save(output_directory / "alpha.npy", alpha, allow_pickle=False) - elif len(images) == 1: - context.progress(0.35, "Removing background") - foreground, load_ms, inference_ms = infer_foreground( - images[0], - model_directory, - device, - model_cache=model_manager, + with open_image(input_path) as source: + alpha, load_ms, inference_ms = infer_alpha( + source, model_directory, device, model_cache=model_manager, cancel_check=context.check_cancelled, - preserve_input_alpha=True, - ) - context.check_cancelled() - context.progress(0.92, "Writing transparent PNG") - output_path = output_directory / "image.0001.png" - foreground.convert("RGBA").save( - output_path, - format="PNG", - compress_level=1, - ) - if not output_path.is_file(): - raise RuntimeError("Background removal did not write a foreground image") - else: - load_ms, inference_ms = infer_foreground_frames( - images, - model_directory, - device, - output_directory, - context.progress, - 0.12, - 0.7, - model_cache=model_manager, - cancel_check=context.check_cancelled, - output_prefix="image", - preserve_input_alpha=True, ) + context.check_cancelled() + np.save(output_directory / "alpha.npy", alpha, allow_pickle=False) + else: + context.progress(0.35, "Removing background") + foreground, load_ms, inference_ms = infer_foreground( + input_path, + model_directory, + device, + model_cache=model_manager, + cancel_check=context.check_cancelled, + preserve_input_alpha=True, + ) + context.check_cancelled() + context.progress(0.92, "Writing transparent PNG") + output_path = output_directory / "foreground.png" + foreground.convert("RGBA").save( + output_path, + format="PNG", + compress_level=1, + ) + if not output_path.is_file(): + raise RuntimeError("Background removal did not write a foreground image") context.check_cancelled() total_ms = (time.perf_counter() - total_started) * 1000.0 - frame_text = "" if len(images) == 1 else f" from {len(images)} frame(s)" message = ( - f"Background removed{frame_text} in {inference_ms:.0f} ms " + f"Background removed in {inference_ms:.0f} ms " f"(model load {load_ms:.0f} ms, total {total_ms:.0f} ms)" ) context.log(message) context.progress(1.0, message) - return {"alpha": "alpha.npy"} if output_kind == "alpha" else {"image": "."} + return {"alpha": "alpha.npy"} if output_kind == "alpha" else {"foreground": "foreground.png"} diff --git a/src/anyimage/server/jobs/upscale.py b/src/anyimage/server/jobs/upscale.py index 1731909..ba6a50d 100644 --- a/src/anyimage/server/jobs/upscale.py +++ b/src/anyimage/server/jobs/upscale.py @@ -1,13 +1,8 @@ -from ..media import DEFAULT_MAX_AI_INPUT_SIZE, input_frames +from ..media import DEFAULT_MAX_AI_INPUT_SIZE +from ..media.input import validate_input_path from ..models.upscale import infer_one -def frame_progress_message(message, index, total): - if total > 1: - return f"{message} ({index + 1}/{total})" - return message - - def run(context, parameters): context.log( f"Upscale model: {parameters['model']}; " @@ -15,27 +10,17 @@ def run(context, parameters): f"{parameters.get('max_input_size', DEFAULT_MAX_AI_INPUT_SIZE)} px" ) context.progress(0.02, "Preparing upscale input") - context.directory.mkdir(parents=True, exist_ok=True) - with input_frames(context, parameters) as images: - total = len(images) - for index, image in enumerate(images): - context.check_cancelled() - context.progress( - 0.08 + index / total * 0.84, - frame_progress_message("Upscaling image", index, total), - ) - prediction = infer_one( - context.resource("model_manager"), - parameters, - image, - cancel_check=context.check_cancelled, - release_memory=index == total - 1, - ) - prediction.save( - context.directory / f"upscale.{index + 1:04d}.png", - format="PNG", - compress_level=1, - ) context.check_cancelled() + input_path = validate_input_path(parameters["input"]) + context.directory.mkdir(parents=True, exist_ok=True) + context.progress(0.08, "Upscaling image") + prediction = infer_one( + context.resource("model_manager"), + parameters, + input_path, + cancel_check=context.check_cancelled, + ) + context.check_cancelled() + prediction.save(context.directory / "upscale.png", format="PNG", compress_level=1) context.progress(1.0, "Image upscale complete") - return {"upscale": "."} + return {"upscale": "upscale.png"} diff --git a/src/anyimage/server/media/__init__.py b/src/anyimage/server/media/__init__.py index f52df17..1d858f2 100644 --- a/src/anyimage/server/media/__init__.py +++ b/src/anyimage/server/media/__init__.py @@ -1,14 +1,7 @@ from .input import ( IMAGE_SUFFIXES, - VIDEO_SUFFIXES, TaskCancelled, - analysis_frames, check_cancelled, - collect_images, - extract_video_frames, - first_frames, - input_frames, - prepare_input, ) @@ -23,15 +16,8 @@ from .resolution import ( __all__ = ( "IMAGE_SUFFIXES", - "VIDEO_SUFFIXES", "TaskCancelled", - "analysis_frames", "check_cancelled", - "collect_images", - "extract_video_frames", - "first_frames", - "input_frames", - "prepare_input", "DEFAULT_MAX_AI_INPUT_SIZE", "MAX_MAX_AI_INPUT_SIZE", "MIN_MAX_AI_INPUT_SIZE", diff --git a/src/anyimage/server/media/input.py b/src/anyimage/server/media/input.py index 043b733..66e804b 100644 --- a/src/anyimage/server/media/input.py +++ b/src/anyimage/server/media/input.py @@ -1,46 +1,21 @@ -import shutil -import tempfile from contextlib import contextmanager from pathlib import Path IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"} -VIDEO_SUFFIXES = { - ".mp4", - ".mov", - ".avi", - ".mkv", - ".webm", - ".m4v", - ".mts", - ".mpg", - ".mpeg", -} -def validate_input_path(value, *, images_only=False): - """Validate supported media without importing image or video libraries.""" +def validate_input_path(value): + """Validate one supported image file without importing image libraries.""" path = Path(value) - if path.is_dir() and not images_only: - if not collect_images(path): - supported = ", ".join(sorted(IMAGE_SUFFIXES)) - raise RuntimeError( - f"No supported image frames found in: {path}. Supported images: {supported}." - ) - return path if not path.is_file(): - kind = "video" if path.suffix.lower() in VIDEO_SUFFIXES else "image" - raise RuntimeError(f"Source {kind} file does not exist: {path}") + raise RuntimeError(f"Source image file does not exist: {path}") suffix = path.suffix.lower() - supported = IMAGE_SUFFIXES if images_only else IMAGE_SUFFIXES | VIDEO_SUFFIXES - if suffix not in supported: - message = ( + if suffix not in IMAGE_SUFFIXES: + raise RuntimeError( f"Unsupported input format: {suffix or '(no extension)'}. " f"Supported images: {', '.join(sorted(IMAGE_SUFFIXES))}." ) - if not images_only: - message += f" Supported videos: {', '.join(sorted(VIDEO_SUFFIXES))}." - raise RuntimeError(message) return path @@ -54,6 +29,8 @@ def open_image(path): try: opened = Image.open(path) opened.load() + if getattr(opened, "n_frames", 1) != 1: + raise RuntimeError("Only still images are supported") except (OSError, ValueError, SyntaxError) as error: raise RuntimeError(f"Unable to read image: {path}") from error yield opened @@ -69,134 +46,3 @@ class TaskCancelled(RuntimeError): def check_cancelled(cancel_check): if cancel_check is not None and cancel_check(): raise TaskCancelled("Task cancelled") - - -def collect_images(directory): - directory = Path(directory) - return sorted( - path - for path in directory.iterdir() - if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES - ) - - -def first_frames(images, maximum_frames, start_frame=1): - """Return up to maximum_frames image paths in order from start_frame.""" - if maximum_frames <= 0: - return images - start_index = max(start_frame - 1, 0) - return images[start_index:start_index + maximum_frames] - - -def extract_video_frames( - video_path, - output_directory, - maximum_frames, - av, - start_frame=1, - cancel_check=None, -): - output_directory = Path(output_directory) - output_directory.mkdir(parents=True, exist_ok=True) - try: - container = av.open(str(video_path)) - except Exception as error: - raise RuntimeError(f"Unable to read video: {video_path}") from error - image_paths = [] - skipped = 0 - try: - try: - stream = container.streams.video[0] - frames = iter(container.decode(stream)) - except Exception as error: - raise RuntimeError(f"Unable to read video: {video_path}") from error - while True: - try: - frame = next(frames) - except StopIteration: - break - except Exception as error: - raise RuntimeError(f"Unable to read video: {video_path}") from error - check_cancelled(cancel_check) - if skipped < start_frame - 1: - skipped += 1 - continue - image_path = output_directory / f"frame.{len(image_paths) + 1:04d}.jpg" - try: - decoded = frame.to_image() - except Exception as error: - raise RuntimeError(f"Unable to read video: {video_path}") from error - decoded.save(image_path, format="JPEG") - image_paths.append(image_path) - if len(image_paths) >= maximum_frames: - break - finally: - container.close() - - if not image_paths: - raise RuntimeError(f"Unable to read video: {video_path}") - return image_paths - - -def prepare_input( - input_path, - video_frames, - temporary_root, - start_frame=1, - cancel_check=None, -): - check_cancelled(cancel_check) - input_path = validate_input_path(input_path) - if input_path.is_dir(): - images = collect_images(input_path) - return first_frames(images, video_frames, start_frame), None - suffix = input_path.suffix.lower() - if suffix in IMAGE_SUFFIXES: - return [input_path], None - - import av - - temporary_directory = Path( - tempfile.mkdtemp(prefix="anyimage-video-", dir=temporary_root) - ) - try: - images = extract_video_frames( - input_path, - temporary_directory, - video_frames, - av, - start_frame=start_frame, - cancel_check=cancel_check, - ) - except Exception: - shutil.rmtree(temporary_directory, ignore_errors=True) - raise - return images, temporary_directory - - -@contextmanager -def input_frames(context, parameters): - """Yield prepared input frames and clean up extracted video frames.""" - images, temporary_directory = prepare_input( - parameters["input"], - parameters.get("video_frames", 16), - context.directory, - start_frame=parameters.get("start_frame", 1), - cancel_check=context.check_cancelled, - ) - try: - yield images - finally: - if temporary_directory is not None: - shutil.rmtree(temporary_directory, ignore_errors=True) - - -@contextmanager -def analysis_frames(context, parameters, fallback_images): - """Yield model-analysis frames, falling back to the color input.""" - analysis_input = parameters.get("analysis_input") - if not analysis_input or analysis_input == parameters["input"]: - yield fallback_images - return - with input_frames(context, dict(parameters, input=analysis_input)) as images: - yield images diff --git a/src/anyimage/server/model_catalog.py b/src/anyimage/server/model_catalog.py index 41d30f8..3cc81df 100644 --- a/src/anyimage/server/model_catalog.py +++ b/src/anyimage/server/model_catalog.py @@ -22,7 +22,7 @@ BIREFNET_LITE = ModelSpec( directory_name="birefnet-lite", repository="ZhengPeng7/BiRefNet_lite", license_name="MIT", - description="Compact 1024px background removal with continuous alpha", + description="Remove backgrounds at 1024 px processing resolution while preserving soft, semi-transparent edges.", download_patterns=("model.onnx", "LICENSE.txt"), ready_patterns=("model.onnx", "LICENSE.txt"), r2_directory="birefnet-lite", @@ -40,7 +40,7 @@ BEN2_MODEL = ModelSpec( repository="onnx-community/BEN2-ONNX", license_name="Apache 2.0", huggingface_repository="onnx-community/BEN2-ONNX", - description="General-purpose FP16 ONNX background removal model", + description="Remove backgrounds from general images while preserving foreground transparency.", download_patterns=("onnx/model_fp16.onnx",), ready_patterns=("onnx/model_fp16.onnx",), r2_directory="BEN2-ONNX", @@ -58,7 +58,7 @@ REALESRGAN_GENERAL_WDN_X4V3 = ModelSpec( directory_name="realesr-general-wdn-x4v3", repository="xinntao/Real-ESRGAN", license_name="BSD 3-Clause", - description="Fast and conservative 2x output from a 4x ONNX model", + description="Enlarge images to twice their width and height with fast, restrained detail enhancement.", download_patterns=("realesr-general-wdn-x4v3.onnx",), ready_patterns=("realesr-general-wdn-x4v3.onnx",), r2_directory="realesr-general-wdn-x4v3", @@ -77,7 +77,7 @@ REALESRGAN_X4PLUS = ModelSpec( repository="jonathanst29/tinier-upscale-models", license_name="BSD 3-Clause", huggingface_repository="jonathanst29/tinier-upscale-models", - description="Detailed 2x output from a general-purpose 4x ONNX model", + description="Enlarge images to twice their width and height with stronger detail enhancement.", download_patterns=("realesrgan-x4plus.onnx",), ready_patterns=("realesrgan-x4plus.onnx",), r2_directory="realesrgan-x4plus", @@ -97,7 +97,10 @@ HAT_GAN_X4_SHARPER = ModelSpec( directory_name="hat-gan-x4-sharper", repository="XPixelGroup/HAT", license_name="Apache 2.0", - description="Detailed texture enhancement with 2x output; higher processing cost", + description=( + "Enlarge images to twice their width and height with detailed texture enhancement. " + "Requires more processing time." + ), download_patterns=("model.onnx", "LICENSE.txt"), ready_patterns=("model.onnx", "LICENSE.txt"), r2_directory="hat-gan-x4-sharper", @@ -129,7 +132,7 @@ MOGE2_MODELS = { repository="Ruicheng/moge-2-vits-normal-onnx", license_name="MIT", huggingface_repository="Ruicheng/moge-2-vits-normal-onnx", - description="Fastest MoGe-2 model using ONNX Runtime", + description="Generate depth and surface normals with the fastest MoGe-2 option.", download_patterns=("model.onnx",), ready_patterns=("model.onnx",), r2_directory="moge-2-vits-normal-onnx", @@ -146,7 +149,7 @@ MOGE2_MODELS = { repository="Ruicheng/moge-2-vitb-normal-onnx", license_name="MIT", huggingface_repository="Ruicheng/moge-2-vitb-normal-onnx", - description="Balanced MoGe-2 model using ONNX Runtime", + description="Generate depth and surface normals with a balance of processing speed and detail.", download_patterns=("model.onnx",), ready_patterns=("model.onnx",), r2_directory="moge-2-vitb-normal-onnx", @@ -163,7 +166,7 @@ MOGE3_VITL = ModelSpec( directory_name="moge-3-vitl-onnx", repository="Ruicheng/moge-3-vitl", license_name="MIT", - description="MoGe-3 geometry with three refinement steps using ONNX Runtime", + description="Generate depth and surface normals with MoGe-3, refining the prediction in three steps.", download_patterns=("backbone.onnx", "refiner.onnx"), ready_patterns=("backbone.onnx", "refiner.onnx"), r2_directory="moge-3-vitl-onnx", @@ -180,7 +183,10 @@ BIREFNET_HR_MATTING = ModelSpec( directory_name="birefnet-hr-matting", repository="ZhengPeng7/BiRefNet_HR-matting", license_name="MIT", - description="2048px matting with continuous alpha; CPU only, high memory and processing cost", + description=( + "Remove backgrounds at 2048 px processing resolution while preserving soft, semi-transparent edges. " + "Runs on CPU and requires more time and memory." + ), download_patterns=("model.onnx", "LICENSE.txt"), ready_patterns=("model.onnx", "LICENSE.txt"), r2_directory="birefnet-hr-matting", diff --git a/src/anyimage/server/models/background.py b/src/anyimage/server/models/background.py index ee74aca..1b613a5 100644 --- a/src/anyimage/server/models/background.py +++ b/src/anyimage/server/models/background.py @@ -75,7 +75,7 @@ def infer_foreground( from PIL import Image, ImageChops check_cancelled(cancel_check) - input_path = validate_input_path(Path(input_path), images_only=True) + input_path = validate_input_path(Path(input_path)) with open_image(input_path) as opened_image: source = opened_image.convert("RGBA") alpha, load_ms, inference_ms = infer_alpha( @@ -91,47 +91,3 @@ def infer_foreground( foreground.paste(source.convert("RGB"), (0, 0), source.getchannel("A")) foreground.putalpha(mask) return foreground, load_ms, inference_ms - - -def infer_foreground_frames( - images, - model_dir, - device, - output_directory, - progress_callback, - start_progress, - progress_span, - model_cache=None, - cancel_check=None, - output_prefix="foreground", - preserve_input_alpha=False, -): - """Remove each frame's background and write numbered transparent PNG files.""" - total_load_ms = 0.0 - total_inference_ms = 0.0 - for index, image_path in enumerate(images): - check_cancelled(cancel_check) - progress_callback( - start_progress + progress_span * index / len(images), - f"Removing background ({index + 1}/{len(images)})", - ) - foreground, load_ms, inference_ms = infer_foreground( - image_path, - model_dir, - device, - model_cache=model_cache, - cancel_check=cancel_check, - preserve_input_alpha=preserve_input_alpha, - release_memory=index == len(images) - 1, - ) - output_path = output_directory / f"{output_prefix}.{index + 1:04d}.png" - foreground.convert("RGBA").save( - output_path, - format="PNG", - compress_level=1, - ) - if not output_path.is_file(): - raise RuntimeError(f"Background removal did not write: {output_path}") - total_load_ms += load_ms - total_inference_ms += inference_ms - return total_load_ms, total_inference_ms diff --git a/src/anyimage/server/models/moge.py b/src/anyimage/server/models/moge.py index 485c54e..0a11c01 100644 --- a/src/anyimage/server/models/moge.py +++ b/src/anyimage/server/models/moge.py @@ -38,30 +38,16 @@ def _geometry_frame(prediction, include_points): def infer( model_manager, parameters, - images, + image_path, *, include_points, - progress_callback=None, cancel_check=None, ): - frames = [] - for index, image_path in enumerate(images): - if cancel_check is not None: - cancel_check() - if progress_callback is not None: - progress_callback(index, len(images)) - frames.append( - _geometry_frame( - infer_one( - model_manager, - parameters, - image_path, - release_memory=index == len(images) - 1, - ), - include_points, - ) - ) - return frames + """Infer one image and return its geometry fields.""" + if cancel_check is not None: + cancel_check() + prediction = infer_one(model_manager, parameters, image_path) + return _geometry_frame(prediction, include_points) def infer_one( diff --git a/tests/anyimage/common/support.py b/tests/anyimage/common/support.py index 7e88b7a..080514c 100644 --- a/tests/anyimage/common/support.py +++ b/tests/anyimage/common/support.py @@ -9,14 +9,6 @@ from tests.support.blender import mock_bpy_imports def load_common_modules(): fake_bpy = ModuleType("bpy") - fake_bpy.props = SimpleNamespace( - BoolProperty=lambda **options: options, - EnumProperty=lambda **options: options, - FloatProperty=lambda **options: options, - IntProperty=lambda **options: options, - StringProperty=lambda **options: options, - ) - fake_bpy.types = SimpleNamespace(Menu=object, Operator=object) fake_bpy.path = SimpleNamespace(abspath=lambda path: path) fake_bpy.context = SimpleNamespace(scene=None) @@ -58,9 +50,7 @@ def load_common_modules(): name: ModuleType(name) for name in ( "anyimage", - "anyimage.operators", "anyimage.common", - "anyimage.operators.convert_to_plane", ) } for package in packages.values(): @@ -80,4 +70,4 @@ def load_common_modules(): specification.loader.exec_module(module) loaded.append(module) - return SimpleNamespace(image=loaded[0], node=loaded[1], material=loaded[2], object=loaded[3]) + return SimpleNamespace(image=loaded[0], material=loaded[2], object=loaded[3]) diff --git a/tests/anyimage/common/test_alpha_presentation.py b/tests/anyimage/common/test_alpha_presentation.py index a5cb395..32851bb 100644 --- a/tests/anyimage/common/test_alpha_presentation.py +++ b/tests/anyimage/common/test_alpha_presentation.py @@ -35,9 +35,9 @@ def test_owned_color_images_preserve_source_and_use_premul(floating): def test_job_loader_preserves_edit_alpha_policy(tmp_path): rgba = np.array([[[51, 102, 204, 64]]], dtype=np.uint8) - Image.fromarray(rgba).save(tmp_path / "image.0001.png") + Image.fromarray(rgba).save(tmp_path / "foreground.png") original = bpy.data.images.new("Job source", width=1, height=1, alpha=True) - result = images.load_image_edit_result(original, tmp_path) + result = images.load_image_edit_result(original, tmp_path / "foreground.png") try: assert result.alpha_mode == "STRAIGHT" assert result.packed_file is not None @@ -47,27 +47,6 @@ def test_job_loader_preserves_edit_alpha_policy(tmp_path): bpy.data.images.remove(original) -def test_failed_empty_replacement_restores_blending_and_timing(): - original = SimpleNamespace(name="Original", use_fake_user=False, users=1) - timing = SimpleNamespace(frame_start=3, frame_offset=2, frame_duration=7, use_cyclic=True) - source = SimpleNamespace(data=original, use_empty_image_alpha=False, image_user=timing) - result = SimpleNamespace(source="SEQUENCE", users=1) - - def fail_configuration(*args, **kwargs): - timing.frame_start = 99 - raise RuntimeError("Failed configuration") - - with patch.object(images, "has_other_image_empty", return_value=True), patch.object(images, "video_total_frames", return_value=3), patch.object( - images, "configure_image_user", side_effect=fail_configuration - ): - with pytest.raises(RuntimeError, match="Failed configuration"): - images.replace_empty_image(source, result) - assert source.data is original - assert not source.use_empty_image_alpha - assert not original.use_fake_user - assert images.image_user_settings(source) == (3, 2, 7, True) - - def test_business_pixels_ignore_premul_read_interpretation(): original = bpy.data.images.new("Premul read source", width=1, height=1, alpha=True) original.alpha_mode = "PREMUL" @@ -188,10 +167,11 @@ def test_isolated_decode_failure_releases_copy(): assert source.alpha_mode == "PREMUL" -def test_mask_pack_reopen_add_and_repeat_frame_preserve_hidden_color(tmp_path): +def test_mask_pack_reopen_extend_and_repeat_frame_preserve_hidden_color(tmp_path): from anyimage.common.selection import SelectionMask from anyimage.operators.mask_tool import apply_alpha_mask - from anyimage.operators import frame_tool as frame + from anyimage.operators.frame_tool.projection import frame_source_projection + from anyimage.operators.frame_tool.compositing import composite_frame_pixels source = bpy.data.images.new("Roundtrip source", width=2, height=2, alpha=True) rgba = np.tile(np.array([0.2, 0.4, 0.8, 1], dtype=np.float32), (2, 2, 1)) @@ -208,15 +188,15 @@ def test_mask_pack_reopen_add_and_repeat_frame_preserve_hidden_color(tmp_path): restored = images.image_rgba(result) np.testing.assert_allclose(restored[..., :3], rgba[..., :3], atol=1 / 255) np.testing.assert_array_equal(restored[..., 3], 0) - added = images.create_image_edit_result(result, apply_alpha_mask(restored, full, "ADD"), (2, 2)) + added = images.create_image_edit_result(result, apply_alpha_mask(restored, full, "EXTEND"), (2, 2)) bpy.data.images.remove(result) result = added for _ in range(2): - source = frame.frame_source_projection(np.eye(4), np.eye(4), np.eye(4), (-1, 1, -1, 1), (2, 2), perspective=False) + source = frame_source_projection(np.eye(4), np.eye(4), np.eye(4), (-1, 1, -1, 1), (2, 2), perspective=False) rgba_read = images.image_rgba(result) source.update(rgba=rgba_read, pixels=images.premultiplied_rgba(images.image_pixels(result), (2, 2)), image_size=(2, 2), active=True, name=result.name) - pixels = frame.composite_frame_pixels((source,), ((0, 2), (2, 2), (2, 0), (0, 0)), (2, 2)) + pixels = composite_frame_pixels((source,), ((0, 2), (2, 2), (2, 0), (0, 0)), (2, 2)) next_result = images.create_image_edit_result(result, pixels, (2, 2)) bpy.data.images.remove(result) result = next_result @@ -225,3 +205,19 @@ def test_mask_pack_reopen_add_and_repeat_frame_preserve_hidden_color(tmp_path): np.testing.assert_allclose(images.image_rgba(result), rgba, atol=1 / 255) finally: bpy.data.images.remove(result) + + +def test_job_loader_reads_only_the_returned_file(tmp_path): + Image.new("RGBA", (2, 2), (255, 0, 0, 255)).save(tmp_path / "foreground.png") + Image.new("RGBA", (4, 4), (0, 0, 255, 255)).save(tmp_path / "upscale.png") + original = bpy.data.images.new("Source", width=1, height=1, alpha=True) + result = images.load_image_edit_result(original, tmp_path / "upscale.png") + try: + assert tuple(result.size) == (4, 4) + assert result.source == "FILE" + assert result.packed_file is not None + with pytest.raises(RuntimeError, match="Color result image does not exist"): + images.load_image_edit_result(original, tmp_path / "missing.png") + finally: + bpy.data.images.remove(result) + bpy.data.images.remove(original) diff --git a/tests/anyimage/common/test_color_image.py b/tests/anyimage/common/test_color_image.py index 00ed26c..846e702 100644 --- a/tests/anyimage/common/test_color_image.py +++ b/tests/anyimage/common/test_color_image.py @@ -171,8 +171,10 @@ def test_hdr_analysis_preview_is_separate_from_full_precision_color(source): assert not preview.exists() -@pytest.mark.parametrize("alpha_mode", ["STRAIGHT", "PREMUL", "CHANNEL_PACKED", "NONE"]) -@pytest.mark.parametrize("source_kind", ["generated", "file", "dirty", "packed"]) +@pytest.mark.parametrize("alpha_mode,source_kind", [ + ("STRAIGHT", "generated"), ("PREMUL", "file"), + ("CHANNEL_PACKED", "dirty"), ("NONE", "packed"), +]) def test_float_color_conversion_preserves_rendered_color_and_opacity(source, tmp_path, alpha_mode, source_kind): from anyimage.common.hdr_image import save_hdr_result diff --git a/tests/anyimage/common/test_depth_metadata.py b/tests/anyimage/common/test_depth_metadata.py new file mode 100644 index 0000000..8d75623 --- /dev/null +++ b/tests/anyimage/common/test_depth_metadata.py @@ -0,0 +1,25 @@ +import json + +import numpy as np + +from anyimage.common.depth import load_depth_metadata + + +def test_depth_metadata_loader_uses_one_schema_for_plane_and_cutout(tmp_path): + path = tmp_path / "depth.json" + path.write_text( + json.dumps( + { + "image_size": [3, 2], + "intrinsics": np.eye(3).tolist(), + "reference_depth": 1.0, + } + ), + encoding="utf-8", + ) + + metadata = load_depth_metadata(path) + + assert metadata["image_size"] == (3, 2) + assert metadata["intrinsics"] == ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)) + assert metadata["reference_depth"] == 1.0 diff --git a/tests/anyimage/common/test_empty_image_edits.py b/tests/anyimage/common/test_empty_image_edits.py index 9f9fb07..fd7263a 100644 --- a/tests/anyimage/common/test_empty_image_edits.py +++ b/tests/anyimage/common/test_empty_image_edits.py @@ -33,9 +33,12 @@ def edit_result(source): ) -@pytest.mark.parametrize("alpha_mode", ["STRAIGHT", "PREMUL", "CHANNEL_PACKED", "NONE"]) -@pytest.mark.parametrize("shared", [False, True]) -@pytest.mark.parametrize("entry", ["pixels", "image.", "upscale."]) +@pytest.mark.parametrize("alpha_mode,shared,entry", [ + ("STRAIGHT", False, "pixels"), + ("PREMUL", True, "foreground.png"), + ("CHANNEL_PACKED", False, "upscale.png"), + ("NONE", True, "pixels"), +]) def test_image_edit_replacement_preserves_alpha_mode(source, tmp_path, alpha_mode, shared, entry): original = source.data original.alpha_mode = alpha_mode @@ -44,8 +47,8 @@ def test_image_edit_replacement_preserves_alpha_mode(source, tmp_path, alpha_mod if entry == "pixels": result = edit_result(source) else: - Image.new("RGBA", (4, 3), (100, 150, 200, 128)).save(tmp_path / f"{entry}0001.png") - result = images.load_image_edit_result(original, tmp_path, frame_prefix=entry) + Image.new("RGBA", (4, 3), (100, 150, 200, 128)).save(tmp_path / entry) + result = images.load_image_edit_result(original, tmp_path / entry) assert result.alpha_mode == alpha_mode images.replace_empty_image(source, result) assert source.data.alpha_mode == alpha_mode @@ -131,45 +134,6 @@ def test_frame_sharing_uses_surviving_objects(source, outside): assert tuple(original.size) == (2, 2) -@pytest.mark.parametrize("shared", [False, True]) -def test_failed_display_configuration_restores_content(source, tmp_path, shared): - original = source.data - if shared: - create_empty("Other", original) - # Preserve unsaved pixels as well as the previous packed representation. - original.pixels.foreach_set(np.tile([0.1, 0.9, 0.2, 0.5], 4).astype(np.float32)) - packed = bytes(original.packed_file.data) - pixels = images.image_pixels(original) - alpha = original.alpha_mode - source.image_user.frame_start = 5 - source.image_user.use_auto_refresh = False - for index in (1, 2): - Image.new("RGBA", (4, 3), (100, 150, 200, 255)).save(tmp_path / f"image.{index:04d}.png") - result = bpy.data.images.load(str(tmp_path / "image.0001.png")) - result.source = "SEQUENCE" - count = len(bpy.data.images) - 1 - - def fail_configuration(user, *args, **kwargs): - user.frame_start = 99 - user.use_auto_refresh = True - raise RuntimeError("Display failed") - - with patch.object(images, "configure_image_user", side_effect=fail_configuration): - with pytest.raises(RuntimeError, match="Display failed"): - images.replace_empty_image(source, result) - assert source.data == original - assert tuple(original.size) == (2, 2) - assert original.alpha_mode == alpha - assert original.is_dirty - assert bytes(original.packed_file.data) == packed - np.testing.assert_array_equal(images.image_pixels(original), pixels) - assert source.image_user.frame_start == 5 - assert not source.image_user.use_auto_refresh - assert not source.use_empty_image_alpha - assert not original.use_fake_user - assert len(bpy.data.images) == count - - def test_failed_content_write_restores_source(source): original = source.data packed = bytes(original.packed_file.data) @@ -193,47 +157,6 @@ def test_failed_content_write_restores_source(source): assert len(bpy.data.images) == 1 -def test_animated_result_preserves_id_and_reopens(source, tmp_path): - original = source.data - source.image_user.frame_start = 4 - source.image_user.use_cyclic = True - for index in (1, 2): - Image.new("RGBA", (4, 3), (index * 80, 100, 50, 255)).save(tmp_path / f"image.{index:04d}.png") - original.source = "SEQUENCE" - result = images.load_image_edit_result(source.data, tmp_path) - expected_path = result.filepath_raw - expected_packed = bytes(result.packed_file.data) if result.packed_file else None - final = images.replace_empty_image(source, result) - assert final == original - assert final.source == "SEQUENCE" - assert final.filepath_raw == expected_path - assert (bytes(final.packed_file.data) if final.packed_file else None) == expected_packed - assert len(images.collect_sequence_frames(final)) == 2 - assert source.image_user.frame_start == 4 - assert source.image_user.frame_duration == 2 - assert source.image_user.use_cyclic - assert source.image_user.use_auto_refresh - path = str(tmp_path / "animation.blend") - bpy.ops.wm.save_as_mainfile(filepath=path) - bpy.ops.wm.open_mainfile(filepath=path) - reopened = bpy.data.objects["Source"].data - assert reopened.name == "Source.png" - assert reopened.source == "SEQUENCE" - frames = images.collect_sequence_frames(reopened) - assert len(frames) == 2 - for index, frame in enumerate(frames, 1): - loaded = bpy.data.images.load(str(frame), check_existing=False) - try: - assert tuple(loaded.size) == (4, 3) - np.testing.assert_allclose( - images.image_pixels(loaded)[:4], - np.array([index * 80, 100, 50, 255]) / 255, - atol=1 / 255, - ) - finally: - bpy.data.images.remove(loaded) - - @pytest.mark.parametrize("floating", [False, True]) def test_failed_generated_commit_restores_unsaved_pixels(source, floating): original = bpy.data.images.new("Generated", width=3, height=2, alpha=True, float_buffer=floating) diff --git a/tests/anyimage/common/test_image_data.py b/tests/anyimage/common/test_image_data.py index f28d98f..8cbe6dc 100644 --- a/tests/anyimage/common/test_image_data.py +++ b/tests/anyimage/common/test_image_data.py @@ -1,6 +1,4 @@ -import tempfile import unittest -from pathlib import Path from types import SimpleNamespace from tests.anyimage.common.support import load_common_modules @@ -21,165 +19,6 @@ class ImageDataTest(unittest.TestCase): ) - def test_video_total_frames_reads_movie_duration(self): - image = SimpleNamespace(source="MOVIE", frame_duration=97) - - self.assertEqual(self.modules.image.video_total_frames(image), 97) - - - def test_video_total_frames_counts_sequence_files(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "foreground-0001.png").touch() - (root / "foreground-0002.png").touch() - image = SimpleNamespace( - source="SEQUENCE", - filepath=str(root / "foreground-0001.png"), - ) - - self.assertEqual(self.modules.image.video_total_frames(image), 2) - - - def test_video_total_frames_returns_one_for_still_image(self): - image = SimpleNamespace(source="FILE", frame_duration=1) - - self.assertEqual(self.modules.image.video_total_frames(image), 1) - - - def test_extract_frame_count_uses_empty_image_frames(self): - source_object = SimpleNamespace( - data=SimpleNamespace(source="MOVIE", frame_duration=97), - image_user=SimpleNamespace(frame_duration=24), - ) - - self.assertEqual(self.modules.image.extract_frame_range(source_object.data, getattr(source_object, "image_user", None))[1], 24) - - - def test_extract_frame_count_caps_at_video_total(self): - source_object = SimpleNamespace( - data=SimpleNamespace(source="MOVIE", frame_duration=97), - image_user=SimpleNamespace(frame_duration=200), - ) - - self.assertEqual(self.modules.image.extract_frame_range(source_object.data, getattr(source_object, "image_user", None))[1], 97) - - - def test_extract_frame_count_falls_back_to_video_total(self): - source_object = SimpleNamespace( - data=SimpleNamespace(source="MOVIE", frame_duration=97), - ) - - self.assertEqual(self.modules.image.extract_frame_range(source_object.data, getattr(source_object, "image_user", None))[1], 97) - - - def test_extract_frame_range_honors_offset_within_clip(self): - source_object = SimpleNamespace( - data=SimpleNamespace(source="MOVIE", frame_duration=97), - image_user=SimpleNamespace( - frame_start=4, - frame_offset=70, - frame_duration=24, - ), - ) - - self.assertEqual( - self.modules.image.extract_frame_range(source_object.data, source_object.image_user), - (71, 24), - ) - - - def test_extract_frame_range_clamps_large_offset_to_clip_tail(self): - source_object = SimpleNamespace( - data=SimpleNamespace(source="MOVIE", frame_duration=97), - image_user=SimpleNamespace( - frame_start=1, - frame_offset=90, - frame_duration=24, - ), - ) - - self.assertEqual( - self.modules.image.extract_frame_range(source_object.data, source_object.image_user), - (74, 24), - ) - - - def test_extract_frame_range_clamps_negative_offset_to_start(self): - source_object = SimpleNamespace( - data=SimpleNamespace(source="MOVIE", frame_duration=97), - image_user=SimpleNamespace( - frame_start=1, - frame_offset=-5, - frame_duration=24, - ), - ) - - self.assertEqual( - self.modules.image.extract_frame_range(source_object.data, source_object.image_user), - (1, 24), - ) - - - def test_extract_frame_range_returns_full_clip_for_still(self): - source_object = SimpleNamespace( - data=SimpleNamespace(source="FILE", frame_duration=1), - image_user=SimpleNamespace( - frame_start=1, - frame_offset=50, - frame_duration=24, - ), - ) - - self.assertEqual( - self.modules.image.extract_frame_range(source_object.data, source_object.image_user), - (1, 1), - ) - - - def test_extract_frame_range_honors_single_frame_setting(self): - source_object = SimpleNamespace( - data=SimpleNamespace(source="MOVIE", frame_duration=97), - image_user=SimpleNamespace( - frame_start=1, - frame_offset=70, - frame_duration=1, - ), - ) - - self.assertEqual( - self.modules.image.extract_frame_range(source_object.data, source_object.image_user), - (71, 1), - ) - - - def test_configure_image_user_preserves_cyclic_setting(self): - image_user = SimpleNamespace( - frame_start=0, - frame_offset=5, - use_auto_refresh=False, - use_cyclic=False, - frame_duration=1, - ) - - self.modules.image.configure_image_user( - image_user, - 97, - frame_start=4, - frame_offset=70, - ) - - self.assertFalse(image_user.use_cyclic) - self.assertTrue(image_user.use_auto_refresh) - self.assertEqual(image_user.frame_start, 4) - self.assertEqual(image_user.frame_offset, 70) - self.assertEqual(image_user.frame_duration, 97) - self.modules.image.configure_image_user( - image_user, - use_cyclic=True, - ) - self.assertTrue(image_user.use_cyclic) - - def test_image_user_settings_reads_empty_values(self): source_object = SimpleNamespace( image_user=SimpleNamespace( @@ -208,84 +47,6 @@ class ImageDataTest(unittest.TestCase): ) - def test_image_base_name_strips_video_extension(self): - image = SimpleNamespace(name="微信视频2026-07-23_192756_414.mp4") - - self.assertEqual( - self.modules.image.image_base_name(image), - "微信视频2026-07-23_192756_414", - ) - - - - - def test_force_sequence_source_keeps_loaded_sequence(self): - image = SimpleNamespace(source="SEQUENCE") - - result = self.modules.image.force_sequence_source(image) - - self.assertIs(result, image) - self.assertEqual(image.source, "SEQUENCE") - - - def test_force_sequence_source_switches_single_image(self): - image = SimpleNamespace(source="FILE") - - result = self.modules.image.force_sequence_source(image) - - self.assertIs(result, image) - self.assertEqual(image.source, "SEQUENCE") - - - def test_force_sequence_source_reports_unloadable_sequence(self): - class BlockedImage: - @property - def source(self): - return "FILE" - - @source.setter - def source(self, value): - raise RuntimeError("blocked") - - with self.assertRaisesRegex(RuntimeError, "cannot load"): - self.modules.image.force_sequence_source(BlockedImage()) - - - def test_collect_sequence_frames_returns_numbered_files_in_order(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "frame-0001.jpg").touch() - (root / "frame-0002.jpg").touch() - (root / "frame-0003.jpg").touch() - (root / "notes.txt").touch() - image = SimpleNamespace( - source="SEQUENCE", - filepath=str(root / "frame-0002.jpg"), - ) - - frames = self.modules.image.collect_sequence_frames(image) - - self.assertEqual( - [path.name for path in frames], - ["frame-0001.jpg", "frame-0002.jpg", "frame-0003.jpg"], - ) - - - def test_sorted_frame_paths_uses_numeric_frame_order(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "frame-2.jpg").touch() - (root / "frame-10.jpg").touch() - (root / "frame-1.jpg").touch() - - frames = self.modules.image.sorted_frame_paths(root) - - self.assertEqual( - [path.name for path in frames], - ["frame-1.jpg", "frame-2.jpg", "frame-10.jpg"], - ) - - def test_depth_data_uses_source_image_name(self): source_object = SimpleNamespace( name="obj_img", @@ -315,8 +76,6 @@ class ImageDataTest(unittest.TestCase): ) - - def test_image_empty_bounds_keep_image_aspect_and_empty_offset(self): source = SimpleNamespace( data=SimpleNamespace(size=(800, 400)), diff --git a/tests/anyimage/common/test_image_materials.py b/tests/anyimage/common/test_image_materials.py index a633d25..bec5d6c 100644 --- a/tests/anyimage/common/test_image_materials.py +++ b/tests/anyimage/common/test_image_materials.py @@ -5,7 +5,6 @@ import numpy as np from anyimage.common import image as image_data from anyimage.common import material as material_data -from anyimage.operators.convert_to_plane import object as plane_object from anyimage.operators.cutout_tool import object as cutout_object from anyimage.operators.cutout_tool import shape as cutout_shape from tests.support.image_objects import _create_cutout_shape, _group_node, _inputs diff --git a/tests/anyimage/common/test_image_target.py b/tests/anyimage/common/test_image_target.py index 2b689f6..1c27826 100644 --- a/tests/anyimage/common/test_image_target.py +++ b/tests/anyimage/common/test_image_target.py @@ -18,21 +18,19 @@ def test_target_uses_edit_tree_and_survives_renaming(texture, tmp_path): texture.id_data.nodes.active = other context.space_data.edit_tree = None write_result(tmp_path) - target.apply(tmp_path) + target.apply(tmp_path / "foreground.png") assert tuple(texture.image.size) == (8, 6) assert other.image is None -@pytest.mark.parametrize("color_space,alpha_mode", [ - ("sRGB", "PREMUL"), - ("AgX Base sRGB", "STRAIGHT"), - ("Filmic sRGB", "STRAIGHT"), - ("sRGB", "STRAIGHT"), - ("AgX Base sRGB", "PREMUL"), +@pytest.mark.parametrize("color_space,alpha_mode,filename,shared", [ + ("sRGB", "PREMUL", "foreground.png", True), + ("AgX Base sRGB", "STRAIGHT", "upscale.png", False), + ("Filmic sRGB", "STRAIGHT", "foreground.png", False), + ("sRGB", "STRAIGHT", "upscale.png", True), + ("AgX Base sRGB", "PREMUL", "foreground.png", True), ]) -@pytest.mark.parametrize("prefix", ["image.", "upscale."]) -@pytest.mark.parametrize("shared", [False, True]) -def test_image_actions_preserve_source_color_space_and_alpha(texture, tmp_path, color_space, alpha_mode, prefix, shared): +def test_image_actions_preserve_source_color_space_and_alpha(texture, tmp_path, color_space, alpha_mode, filename, shared): texture.image.colorspace_settings.name = color_space texture.image.alpha_mode = alpha_mode original = texture.image @@ -41,8 +39,8 @@ def test_image_actions_preserve_source_color_space_and_alpha(texture, tmp_path, other = texture.id_data.nodes.new("ShaderNodeTexImage") other.image = original target = targets.ImageEditTarget.capture(texture_context(texture)) - write_result(tmp_path, prefix) - target.apply(tmp_path, frame_prefix=prefix) + write_result(tmp_path, filename) + target.apply(tmp_path / filename) result = texture.image assert result.colorspace_settings.name == color_space assert result.alpha_mode == alpha_mode @@ -102,7 +100,7 @@ def test_target_rejects_changed_identity_before_loading_result(texture, tmp_path bpy.data.images.remove(texture.image) count = len(bpy.data.images) with pytest.raises(RuntimeError, match="no longer available"): - target.apply(tmp_path) + target.apply(tmp_path / "foreground.png") assert len(bpy.data.images) == count @@ -137,7 +135,7 @@ def test_texture_commit_preserves_structure_and_isolates_users(texture, tmp_path shared = reference in {"same_tree", "other_material", "empty", "brush"} links = [(link.from_socket, link.to_socket) for link in texture.id_data.links] write_result(tmp_path) - target.apply(tmp_path) + target.apply(tmp_path / "foreground.png") assert (texture.image == original) == (not shared) assert tuple(texture.image.size) == (8, 6) assert texture.image.colorspace_settings.name == "Non-Color" @@ -163,63 +161,46 @@ def test_texture_commit_preserves_structure_and_isolates_users(texture, tmp_path assert len(reopened.id_data.links) == len(links) -@pytest.mark.parametrize("shared", [False, True]) -def test_animated_texture_preserves_playback_and_reopens(texture, tmp_path, shared): - write_result(tmp_path, count=4) - texture.image.source = "SEQUENCE" - texture.image.filepath_raw = str(tmp_path / "image.0001.png") - texture.image_user.frame_start = 9 - texture.image_user.frame_offset = 1 - texture.image_user.frame_duration = 2 - texture.image_user.use_cyclic = True - context = texture_context(texture) - target = targets.ImageEditTarget.capture(context) - assert (target.start_frame, target.frame_count) == (2, 2) - if shared: - other = texture.id_data.nodes.new("ShaderNodeTexImage") - other.image = texture.image - with patch.object(targets, "prepare_image_input", return_value=(tmp_path, False)): - target.prepare(context) - assert context.scene.anyimage_settings.video_frames == 2 - target.apply(tmp_path) - assert images.image_user_settings(texture) == (9, 0, 4, True) - assert texture.image_user.use_auto_refresh - assert texture.image.source == "SEQUENCE" - path = str(tmp_path / "animated.blend") - bpy.ops.wm.save_as_mainfile(filepath=path) - bpy.ops.wm.open_mainfile(filepath=path) - reopened = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"] - assert images.image_user_settings(reopened) == (9, 0, 4, True) - assert len(images.collect_sequence_frames(reopened.image)) == 4 + + +@pytest.mark.parametrize("source", ["MOVIE", "SEQUENCE"]) +def test_target_rejects_animated_image(texture, source): + texture.image.source = source + with pytest.raises(RuntimeError, match="Only still images"): + targets.ImageEditTarget.capture(texture_context(texture)) @pytest.mark.parametrize("shared", [False, True]) -def test_texture_failed_commit_restores_dirty_pixels_and_playback(texture, tmp_path, shared): +def test_failed_texture_assignment_restores_source(texture, tmp_path, shared): original = texture.image - if shared: - other = texture.id_data.nodes.new("ShaderNodeTexImage") - other.image = original original.pixels.foreach_set(np.tile([0.1, 0.3, 0.7, 0.5], 12).astype(np.float32)) packed = bytes(original.packed_file.data) pixels = images.image_pixels(original) - timing = images.image_user_settings(texture) - write_result(tmp_path, count=2) - result = bpy.data.images.load(str(tmp_path / "image.0001.png")) - result.source = "SEQUENCE" + result = images.load_image_edit_result(original, write_result(tmp_path)) count = len(bpy.data.images) - 1 - def fail_configuration(user, *args, **kwargs): - user.frame_start = 80 - user.use_auto_refresh = True - raise RuntimeError("Playback failed") + class FailingTarget: + failed = False - with patch.object(targets, "configure_image_user", side_effect=fail_configuration): - with pytest.raises(RuntimeError, match="Playback failed"): - targets.replace_texture_image(texture, result) + def __getattr__(self, name): + return getattr(texture, name) + + @property + def image(self): + return texture.image + + @image.setter + def image(self, value): + texture.image = value + if not self.failed: + self.failed = True + raise RuntimeError("Assignment failed") + + with patch.object(targets, "has_other_image_user", return_value=shared): + with pytest.raises(RuntimeError, match="Assignment failed"): + targets.replace_texture_image(FailingTarget(), result) assert texture.image == original assert tuple(original.size) == (4, 3) assert bytes(original.packed_file.data) == packed np.testing.assert_array_equal(images.image_pixels(original), pixels) - assert images.image_user_settings(texture) == timing - assert not texture.image_user.use_auto_refresh assert len(bpy.data.images) == count diff --git a/tests/anyimage/common/test_input_preparation.py b/tests/anyimage/common/test_input_preparation.py index ff1180c..ce3d7b3 100644 --- a/tests/anyimage/common/test_input_preparation.py +++ b/tests/anyimage/common/test_input_preparation.py @@ -7,20 +7,6 @@ from tests.support.blender import BlenderTestCase class InputPreparationTest(BlenderTestCase): - def test_sequence_format_is_checked_before_copy(self): - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "frame.0001.exr" - path.touch() - image = SimpleNamespace(source="SEQUENCE") - with ( - patch.object(self.image_data, "image_path", return_value=path), - patch.object(self.image_data, "image_is_packed", return_value=False), - patch.object(self.image_data.tempfile, "mkdtemp") as create, - ): - with self.assertRaisesRegex(RuntimeError, "Unsupported input format: .exr"): - self.image_data.prepare_image_input(image, None) - create.assert_not_called() - self.assertTrue(path.exists()) def test_packed_export_failure_removes_temporary_directory(self): for failure in (OSError("disk full"), None): @@ -34,21 +20,11 @@ class InputPreparationTest(BlenderTestCase): self.assertEqual(image.file_format, "OPEN_EXR") self.assertFalse(output.exists()) - def test_sequence_copy_failure_preserves_originals(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - source = root / "frame.0001.png" - source.write_bytes(b"source") - output = root / "prepared" - output.mkdir() - with ( - patch.object(self.image_data, "image_path", return_value=source), - patch.object(self.image_data, "image_is_packed", return_value=False), - patch.object(self.image_data, "collect_sequence_frames", return_value=[source]), - patch.object(self.image_data.tempfile, "mkdtemp", return_value=str(output)), - patch.object(self.image_data.shutil, "copy2", side_effect=OSError("disk full")), - ): - with self.assertRaises(OSError): - self.image_data.prepare_image_input(SimpleNamespace(source="SEQUENCE"), None) - self.assertFalse(output.exists()) - self.assertEqual(source.read_bytes(), b"source") + + def test_animated_inputs_are_rejected_before_export(self): + for source in ("MOVIE", "SEQUENCE"): + with self.subTest(source=source), patch.object(self.image_data, "save_packed_image") as export: + image = SimpleNamespace(source=source, packed_file=object()) + with self.assertRaisesRegex(RuntimeError, "Only still images"): + self.image_data.prepare_image_input(image, None) + export.assert_not_called() diff --git a/tests/anyimage/common/test_material_alpha.py b/tests/anyimage/common/test_material_alpha.py index d8532cb..3e6cea5 100644 --- a/tests/anyimage/common/test_material_alpha.py +++ b/tests/anyimage/common/test_material_alpha.py @@ -19,7 +19,6 @@ def test_material_alpha_strength_follows_preference(image_layer, monkeypatch, en assert shader.node_tree.name == "O Shadeless" assert shader.inputs["Color"].links[0].from_node.type == "TEX_IMAGE" assert shader.inputs["Alpha"].links[0].from_socket.name == "Alpha" - assert not any(node.type in {"MIX_SHADER", "BSDF_TRANSPARENT"} for node in result.node_tree.nodes) assert len([node for node in result.node_tree.nodes if node.type == "GROUP"]) == 1 return layer = next(node for node in result.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer) diff --git a/tests/anyimage/common/test_material_settings.py b/tests/anyimage/common/test_material_settings.py index d300505..cf25bdd 100644 --- a/tests/anyimage/common/test_material_settings.py +++ b/tests/anyimage/common/test_material_settings.py @@ -29,36 +29,6 @@ class MaterialSettingsTest(unittest.TestCase): ) - def test_material_color_space_falls_back_when_unsupported(self): - class ColorSpaceSettings: - def __init__(self): - self._name = "sRGB" - - @property - def name(self): - return self._name - - @name.setter - def name(self, value): - if value != "sRGB": - raise TypeError("unsupported color space") - self._name = value - - image = SimpleNamespace( - colorspace_settings=ColorSpaceSettings(), - users=0, use_fake_user=False, alpha_mode="STRAIGHT", - ) - scene = SimpleNamespace( - view_settings=SimpleNamespace(view_transform="ACES 2.0") - ) - - result = self.modules.material.configure_material_color_image(image, scene) - - self.assertIs(result, image) - self.assertEqual(image.alpha_mode, "PREMUL") - self.assertEqual(image.colorspace_settings.name, "sRGB") - - def test_surface_displacement_is_set_to_displacement_only(self): class Material: surface_displacement_method = "BUMP" diff --git a/tests/anyimage/common/test_material_view_adaptation.py b/tests/anyimage/common/test_material_view_adaptation.py index 210b0e0..c26f8c4 100644 --- a/tests/anyimage/common/test_material_view_adaptation.py +++ b/tests/anyimage/common/test_material_view_adaptation.py @@ -35,18 +35,6 @@ def color_texture(result): return next(node.image for node in result.node_tree.nodes if node.type == "TEX_IMAGE") -def test_adaptation_preference_defaults_and_reads_current_value(): - with patch.object(preferences, "addon_preferences", return_value=None): - assert preferences.configured_material_view_adaptation() - with patch.object(preferences, "addon_preferences", return_value=SimpleNamespace()): - assert preferences.configured_material_view_adaptation() - settings = SimpleNamespace(adapt_material_to_view_transform=False) - with patch.object(preferences, "addon_preferences", return_value=settings): - assert not preferences.configured_material_view_adaptation() - settings.adapt_material_to_view_transform = True - assert preferences.configured_material_view_adaptation() - - @pytest.mark.parametrize("enabled", [False, True]) @pytest.mark.parametrize("view,space", [*material.VIEW_TRANSFORM_COLOR_SPACES, ("Standard", "sRGB"), ("Unknown", "sRGB")]) def test_color_space_and_alpha_follow_preference(color_data, enabled, view, space): @@ -85,11 +73,11 @@ def test_material_pixels_survive_pack_and_library_reload(color_data, tmp_path, e source.pixels.foreach_set((expected.astype(np.float32) / 255).ravel()) else: source = bpy.data.images.new("Material pixels", width=2, height=1, alpha=True, float_buffer=kind == "float") + source.alpha_mode = "PREMUL" source.pixels.foreach_set((expected.astype(np.float32) / 255).ravel()) if kind == "packed": source.pack() source.reload() - images.configure_color_alpha(source) before = images.image_pixels(source) owner = bpy.data.objects.new("Color source owner", None) owner.empty_display_type = "IMAGE" @@ -175,51 +163,13 @@ def test_material_configures_the_image_selected_by_its_caller(color_data, entry, def test_edit_results_preserve_source_alpha_with_adaptation_enabled(color_data, tmp_path): source = bpy.data.images.new("Editing source", width=1, height=1, alpha=True) rgba = np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32) - Image.fromarray(np.array([[[51, 102, 204, 64]]], dtype=np.uint8)).save(tmp_path / "image.0001.png") + Image.fromarray(np.array([[[51, 102, 204, 64]]], dtype=np.uint8)).save(tmp_path / "foreground.png") with patch.object(preferences, "addon_preferences", return_value=SimpleNamespace(adapt_material_to_view_transform=True)): edited = images.create_image_edit_result(source, rgba, (1, 1)) - loaded = images.load_image_edit_result(source, tmp_path) + loaded = images.load_image_edit_result(source, tmp_path / "foreground.png") for result in (edited, loaded): assert result.colorspace_settings.name == "sRGB" assert result.alpha_mode == source.alpha_mode np.testing.assert_allclose(images.image_pixels(result), rgba, atol=1 / 255) assert source.colorspace_settings.name == "sRGB" assert source.alpha_mode == "STRAIGHT" - - -@pytest.mark.parametrize("supported", [{"sRGB", "Non-Color"}, {"Non-Color"}]) -def test_unavailable_color_space_falls_back_or_preserves_original(supported): - class ColorSpace: - value = "Non-Color" - - @property - def name(self): - return self.value - - @name.setter - def name(self, value): - if value not in supported: - raise TypeError("Unsupported color space") - self.value = value - - image = SimpleNamespace(colorspace_settings=ColorSpace(), users=0, use_fake_user=False, alpha_mode="STRAIGHT") - with patch.object(material, "configured_material_view_adaptation", return_value=True): - if "sRGB" in supported: - result = material.configure_material_color_image(image, view_scene("AgX")) - assert result.colorspace_settings.name == "sRGB" - assert result.alpha_mode == "PREMUL" - else: - with pytest.raises(RuntimeError, match="material color space"): - material.configure_material_color_image(image, view_scene("AgX")) - assert image.colorspace_settings.name == "Non-Color" - assert image.alpha_mode == "STRAIGHT" - - -def test_missing_scene_uses_srgb(color_data): - image = bpy.data.images.new("Missing scene", width=1, height=1) - with patch.object(material, "bpy", SimpleNamespace(context=SimpleNamespace(scene=None))), patch.object( - material, "configured_material_view_adaptation", return_value=True - ): - result = material.configure_material_color_image(image) - assert result.colorspace_settings.name == "sRGB" - assert result.alpha_mode == "PREMUL" diff --git a/tests/anyimage/common/test_modifier_inputs.py b/tests/anyimage/common/test_modifier_inputs.py index 7b37c2d..94bf1fc 100644 --- a/tests/anyimage/common/test_modifier_inputs.py +++ b/tests/anyimage/common/test_modifier_inputs.py @@ -9,8 +9,8 @@ class ModifierInputsTest(unittest.TestCase): def test_runtime_modifier_enum_input_uses_string_identifier(self): enum_items = ( - SimpleNamespace(identifier="SURFACE"), - SimpleNamespace(identifier="BALLOON"), + SimpleNamespace(identifier="Balloon"), + SimpleNamespace(identifier="Shell"), ) value_property = SimpleNamespace(type="ENUM", enum_items=enum_items) input_slot = SimpleNamespace( @@ -19,13 +19,13 @@ class ModifierInputsTest(unittest.TestCase): ) modifier = SimpleNamespace( properties=SimpleNamespace( - inputs=SimpleNamespace(Shape=input_slot), + inputs=SimpleNamespace(Mode=input_slot), ) ) - self.modules.object.set_modifier_input(modifier, "Shape", 1) + self.modules.object.set_modifier_input(modifier, "Mode", 1) - self.assertEqual(input_slot.value, "BALLOON") + self.assertEqual(input_slot.value, "Shell") def test_modifier_inputs_support_rna_and_dictionary_values(self): @@ -44,9 +44,9 @@ class ModifierInputsTest(unittest.TestCase): self.assertEqual(input_slot.value, 2.0) - class LegacyModifier(dict): + class DictionaryModifier(dict): properties = None - legacy_modifier = LegacyModifier() - self.modules.object.set_modifier_input(legacy_modifier, "Shape", 1) - self.assertEqual(legacy_modifier["Shape"], 1) + dictionary_modifier = DictionaryModifier() + self.modules.object.set_modifier_input(dictionary_modifier, "Mode", 1) + self.assertEqual(dictionary_modifier["Mode"], 1) diff --git a/tests/anyimage/common/test_node_loading.py b/tests/anyimage/common/test_node_loading.py deleted file mode 100644 index 10c6dba..0000000 --- a/tests/anyimage/common/test_node_loading.py +++ /dev/null @@ -1,14 +0,0 @@ -from tests.support.paths import PROJECT_ROOT -import unittest -from tests.anyimage.common.support import load_common_modules - -class NodeLoadingTest(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.modules = load_common_modules() - - def test_node_asset_path_resolves_bundled_library(self): - self.assertEqual( - self.modules.node.node_asset_path(), - PROJECT_ROOT / "src" / "anyimage" / "assets" / "O_AnyImage.blend", - ) diff --git a/tests/anyimage/operators/test_ai_image_operators.py b/tests/anyimage/operators/test_ai_image_operators.py index c03062a..bd3ee08 100644 --- a/tests/anyimage/operators/test_ai_image_operators.py +++ b/tests/anyimage/operators/test_ai_image_operators.py @@ -21,11 +21,11 @@ class AiImageOperatorsTest(BlenderTestCase): self.assertEqual(operator_type.bl_options, {"UNDO"}) operator = operator_type() operator.input_path = "input.png" - operator._image_target = SimpleNamespace(start_frame=4) + operator._image_target = SimpleNamespace() operator.model = "HAT_GAN_X4_SHARPER" operator.max_input_size = 1024 context = SimpleNamespace( - scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=7)) + scene=SimpleNamespace(anyimage_settings=SimpleNamespace()) ) with ( patch.object(self.upscale, "require_environment"), @@ -44,8 +44,6 @@ class AiImageOperatorsTest(BlenderTestCase): "input": "input.png", "model": "HAT_GAN_X4_SHARPER", "device": "cuda", - "video_frames": 7, - "start_frame": 4, "max_input_size": 1024, }, ) @@ -89,7 +87,7 @@ class AiImageOperatorsTest(BlenderTestCase): def test_remove_background_loads_a_plain_image_edit_result(self): - result = SimpleNamespace(directory=Path("result")) + result = SimpleNamespace(file=lambda key: Path("result") / f"{key}.png") operator = self.remove_background.RunBackgroundRemoval() operator._image_target = Mock() ops = SimpleNamespace( @@ -100,12 +98,12 @@ class AiImageOperatorsTest(BlenderTestCase): ): operator.response(None, result) - operator._image_target.apply.assert_called_once_with(Path("result")) + operator._image_target.apply.assert_called_once_with(Path("result/foreground.png")) ops.ed.undo_push.assert_called_once_with(message="Remove Background") def test_upscale_loads_a_plain_image_edit_result(self): - result = SimpleNamespace(directory=Path("result")) + result = SimpleNamespace(file=lambda key: Path("result") / f"{key}.png") operator = self.upscale.UpscaleImage() operator._image_target = Mock() operator.model = "HAT_GAN_X4_SHARPER" @@ -119,7 +117,6 @@ class AiImageOperatorsTest(BlenderTestCase): message = operator.response(None, result) operator._image_target.apply.assert_called_once_with( - Path("result"), - frame_prefix="upscale.", + Path("result/upscale.png"), ) self.assertEqual(message, "Image is upscaled with HAT Sharper") diff --git a/tests/anyimage/operators/test_ai_input_failures.py b/tests/anyimage/operators/test_ai_input_failures.py index 86ba9c0..40cdf15 100644 --- a/tests/anyimage/operators/test_ai_input_failures.py +++ b/tests/anyimage/operators/test_ai_input_failures.py @@ -19,10 +19,10 @@ class AiInputFailuresTest(BlenderTestCase): ): operator = operator_type() operator.report = Mock() - target = Mock(image=SimpleNamespace(size=(16, 16)), start_frame=1) + target = Mock(image=SimpleNamespace(size=(16, 16))) target.prepare.return_value = (path, False) context = SimpleNamespace(scene=SimpleNamespace( - anyimage_settings=SimpleNamespace(video_frames=1))) + anyimage_settings=SimpleNamespace())) with ExitStack() as stack: stack.enter_context(patch.object(module.ImageEditTarget, "capture", return_value=target)) stack.enter_context(patch.object(module, "require_environment")) diff --git a/tests/anyimage/operators/test_ai_setup.py b/tests/anyimage/operators/test_ai_setup.py index 350fbb4..1861475 100644 --- a/tests/anyimage/operators/test_ai_setup.py +++ b/tests/anyimage/operators/test_ai_setup.py @@ -2,11 +2,43 @@ import importlib import tempfile from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import Mock, patch from tests.support.blender import BlenderTestCase class AiSetupTest(BlenderTestCase): + def test_setup_dialog_uses_install_title_and_button(self): + show_dialog = Mock(return_value={"RUNNING_MODAL"}) + context = SimpleNamespace( + window_manager=SimpleNamespace(invoke_props_dialog=show_dialog) + ) + operator = self.ai_setup.SetupAIEnvironment() + + self.assertEqual(operator.invoke(context, None), {"RUNNING_MODAL"}) + show_dialog.assert_called_once_with( + operator, width=440, title="Set Up AI", confirm_text="Install" + ) + + def test_setup_dialog_shows_install_summary_and_mirror_option(self): + labels = [] + properties = [] + operator = self.ai_setup.SetupAIEnvironment() + operator.layout = SimpleNamespace( + label=lambda **kwargs: labels.append(kwargs["text"]), + separator=lambda **_kwargs: None, + prop=lambda _owner, name: properties.append(name), + ) + with patch.object( + self.ai_setup, "ai_status", return_value={"environment_ready": False} + ): + operator.draw(None) + + self.assertEqual(labels, [ + "Set up AI features for depth, background removal and upscaling.", + "Downloads the runtime and required models (259 MB for models).", + ]) + self.assertEqual(properties, ["use_mirror"]) + def test_runtime_exposes_declared_environment_and_post_install(self): runtime = self.anyimage.runtime self.assertEqual(runtime.environment["python"], "3.12") diff --git a/tests/anyimage/operators/test_balloon_profiles.py b/tests/anyimage/operators/test_balloon_profiles.py index 2904e92..0d81c0a 100644 --- a/tests/anyimage/operators/test_balloon_profiles.py +++ b/tests/anyimage/operators/test_balloon_profiles.py @@ -2,8 +2,8 @@ from unittest.mock import patch import numpy as np -from anyimage.operators.cutout_tool import geometry as cutout_geometry -from anyimage.operators.cutout_tool.geometry import poisson_balloon_profile +from anyimage.operators.cutout_tool import balloon +from anyimage.operators.cutout_tool.balloon import poisson_balloon_profile def test_poisson_balloon_profile_is_zero_on_boundary_and_positive_inside(): @@ -62,11 +62,11 @@ def test_teddy_balloon_profile_uses_chordal_axis_radius(): nodes = np.asarray(((1, 1), (3, 1)), dtype=float) with patch.object( - cutout_geometry, + balloon, "chordal_axis", return_value=(nodes, [(0, 1)], np.asarray((1.0, 1.0))), ): - profile = cutout_geometry.teddy_balloon_heights( + profile = balloon.teddy_balloon_heights( points, faces, points[:4], diff --git a/tests/anyimage/operators/test_clipboard_cache.py b/tests/anyimage/operators/test_clipboard_cache.py index 859181f..a1d3ef0 100644 --- a/tests/anyimage/operators/test_clipboard_cache.py +++ b/tests/anyimage/operators/test_clipboard_cache.py @@ -23,7 +23,7 @@ def clipboard(): bpy.ops.wm.read_factory_settings(use_empty=True) -@pytest.mark.parametrize("edit", ["paint", "packed", "hidden_rgb", "size", "space", "alpha", "legacy", "unreadable"]) +@pytest.mark.parametrize("edit", ["paint", "packed", "hidden_rgb", "size", "space", "alpha", "unreadable"]) def test_changed_cache_is_skipped_without_modifying_candidate(clipboard, edit, monkeypatch): original, reused = actions.acquire_packed_image(clipboard, ".png") expected = images.image_pixels(original) @@ -41,8 +41,6 @@ def test_changed_cache_is_skipped_without_modifying_candidate(clipboard, edit, m original.colorspace_settings.name = "Non-Color" elif edit == "alpha": original.alpha_mode = "PREMUL" - elif edit == "legacy": - del original[actions.CLIPBOARD_CONTENT_PROPERTY] else: read = actions._clipboard_content_hash def fail_candidate(image): diff --git a/tests/anyimage/operators/test_clipboard_image.py b/tests/anyimage/operators/test_clipboard_image.py index e3b8e90..041ec35 100644 --- a/tests/anyimage/operators/test_clipboard_image.py +++ b/tests/anyimage/operators/test_clipboard_image.py @@ -84,7 +84,6 @@ class ClipboardImageTest(unittest.TestCase): ) for name in clipboard_image.PasteClipboardImage.__annotations__: self.assertFalse(name.startswith("anyimage_"), name) - self.assertFalse(name.startswith("o_"), name) def test_registers_and_removes_clipboard_shortcuts(self): class KeyMapItems: @@ -345,10 +344,6 @@ class ClipboardImageTest(unittest.TestCase): ) self.assertFalse(options["default"]) - annotations = self.addon.clipboard_image.PasteClipboardImage.__annotations__ - self.assertNotIn("subdivisions", annotations) - self.assertNotIn("thickness", annotations) - self.assertNotIn("unshaded", annotations) def test_native_copy_takes_priority_while_clipboard_is_unchanged(self): clipboard_image = self.addon.clipboard_image diff --git a/tests/anyimage/common/test_depth_calibration.py b/tests/anyimage/operators/test_cutout_calibration.py similarity index 80% rename from tests/anyimage/common/test_depth_calibration.py rename to tests/anyimage/operators/test_cutout_calibration.py index bfb7b28..11f72c5 100644 --- a/tests/anyimage/common/test_depth_calibration.py +++ b/tests/anyimage/operators/test_cutout_calibration.py @@ -1,34 +1,11 @@ -import json from types import SimpleNamespace + import numpy as np - from anyimage.common.selection import SelectionMask -from anyimage.common.depth import ( - depth_uniform_scale, - load_depth_metadata, -) +from anyimage.common.depth import depth_uniform_scale from anyimage.operators.cutout_tool import geometry as cutout_geometry - - -def test_depth_metadata_loader_uses_one_schema_for_plane_and_cutout(tmp_path): - path = tmp_path / "depth.json" - path.write_text( - json.dumps( - { - "image_size": [3, 2], - "intrinsics": np.eye(3).tolist(), - "reference_depth": 1.0, - } - ), - encoding="utf-8", - ) - - metadata = load_depth_metadata(path) - - assert metadata["image_size"] == (3, 2) - assert metadata["intrinsics"] == ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)) - assert metadata["reference_depth"] == 1.0 +from anyimage.operators.cutout_tool import depth_calibration def test_base_plane_fit_is_stable_for_a_slender_diagonal_domain(): @@ -42,7 +19,7 @@ def test_base_plane_fit_is_stable_for_a_slender_diagonal_domain(): ) values = 0.4 * along + np.sign(across) * 0.05 - plane = cutout_geometry._stable_plane(coordinates, values) + plane = depth_calibration._stable_plane(coordinates, values) selected_plane = coordinates @ plane[:2] + plane[2] corners = np.asarray(((-1.0, -1.0), (-1.0, 1.0), (1.0, -1.0), (1.0, 1.0))) corner_plane = corners @ plane[:2] + plane[2] @@ -63,7 +40,7 @@ def test_depth_balloon_plane_calibration_matches_cutout_axes(): uv.append((column * 0.5, 1.0 - row * 0.5)) camera_depth[row, column] = 2.0 + 0.2 * local_x + 0.1 * local_y - reference, direction = cutout_geometry.depth_balloon_projection_parameters( + reference, direction = depth_calibration.depth_balloon_projection_parameters( vertices, uv, camera_depth, @@ -93,7 +70,7 @@ def test_depth_balloon_reference_uses_the_shared_upper_depth_percentile(): vertices.append((float(x), float(size - 1 - y), 0.0)) uv.append((x / (size - 1), 1.0 - y / (size - 1))) - reference, direction = cutout_geometry.depth_balloon_projection_parameters( + reference, direction = depth_calibration.depth_balloon_projection_parameters( vertices, uv, camera_depth, @@ -126,7 +103,7 @@ def test_depth_surface_projection_uses_one_scale_for_all_camera_axes(): ) assert uniform_scale == 2.0 - reference_depth = cutout_geometry.metric_depth_reference( + reference_depth = depth_calibration.metric_depth_reference( points[..., 2], np.ones((51, 101), dtype=bool), uniform_scale, @@ -157,8 +134,8 @@ def test_cutout_threshold_controls_contour_and_calibration(): def test_cutout_depth_calibration_uses_five_percent_transparency_threshold(): - assert not cutout_geometry.use_adaptive_depth_calibration(0.0499) - assert cutout_geometry.use_adaptive_depth_calibration(0.05) + assert not depth_calibration.use_adaptive_depth_calibration(0.0499) + assert depth_calibration.use_adaptive_depth_calibration(0.05) def test_selection_calibration_uses_the_final_selection_mask(): diff --git a/tests/anyimage/operators/test_cutout_depth.py b/tests/anyimage/operators/test_cutout_depth.py index fb9a08d..71f5541 100644 --- a/tests/anyimage/operators/test_cutout_depth.py +++ b/tests/anyimage/operators/test_cutout_depth.py @@ -3,7 +3,8 @@ import numpy as np import pytest from anyimage.operators.cutout_tool.object import load_cutout_node_group from anyimage.operators.cutout_tool.operators import normal_mode_for_shape -from anyimage.operators.cutout_tool.geometry import refine_balloon_triangles, poisson_balloon_profile +from anyimage.operators.cutout_tool.geometry import refine_balloon_triangles +from anyimage.operators.cutout_tool.balloon import poisson_balloon_profile @pytest.mark.parametrize("enabled", [False, True]) @@ -20,18 +21,18 @@ def test_new_cutout_does_not_reuse_a_saved_wrapper(): saved.interface.new_socket(name="Shape", in_out="INPUT", socket_type="NodeSocketMenu") saved.use_fake_user = True current = load_cutout_node_group("SOLID") - assert current != saved and current.get("anyimage_asset_revision") == 11 + assert current != saved and current.get("anyimage_asset_revision") == 13 assert saved in bpy.data.node_groups.values() assert current == load_cutout_node_group("FLAT") assert any(item.name == "Shape" for item in saved.interface.items_tree) -def test_depth_cutout_loads_boundary_blur_revision_and_preserves_existing_group(): +def test_depth_cutout_loads_uniform_thickness_revision_and_preserves_existing_group(): bpy.ops.wm.read_factory_settings(use_empty=True) saved = bpy.data.node_groups.new("O Image Depth Cutout", "GeometryNodeTree") saved["anyimage_asset_revision"] = 11 current = load_cutout_node_group("DEPTH_SOLID") - assert current != saved and current["anyimage_asset_revision"] == 23 + assert current != saved and current["anyimage_asset_revision"] == 26 assert saved in bpy.data.node_groups.values() assert saved["anyimage_asset_revision"] == 11 assert load_cutout_node_group("DEPTH_SOLID") == current diff --git a/tests/anyimage/operators/test_cutout_fine_mesh.py b/tests/anyimage/operators/test_cutout_fine_mesh.py index 7f49536..170f85a 100644 --- a/tests/anyimage/operators/test_cutout_fine_mesh.py +++ b/tests/anyimage/operators/test_cutout_fine_mesh.py @@ -5,6 +5,7 @@ from scipy.sparse.csgraph import connected_components from scipy.spatial import cKDTree from anyimage.operators.cutout_tool import geometry as g +from anyimage.operators.cutout_tool.balloon import poisson_balloon_profile from anyimage.operators.cutout_tool import fine_mesh as fine @@ -42,7 +43,7 @@ def assert_supported(points, faces, boundary, components): ).tocsr() count, _labels = connected_components(graph[interior][:, interior], directed=False) assert count == len(components) - profile = g.poisson_balloon_profile(points, faces, boundary) + profile = poisson_balloon_profile(points, faces, boundary) assert np.isfinite(profile).all() assert np.all(profile[interior] > 0) diff --git a/tests/anyimage/operators/test_cutout_geometry.py b/tests/anyimage/operators/test_cutout_geometry.py index 9d268bd..62056cb 100644 --- a/tests/anyimage/operators/test_cutout_geometry.py +++ b/tests/anyimage/operators/test_cutout_geometry.py @@ -47,7 +47,7 @@ def test_cutout_node_groups_are_loaded_from_the_asset(): for shape in cutout_shape.CUTOUT_NODE_GROUP_NAMES: assert cutout_object.load_cutout_node_group(shape) is loaded load.assert_called_with(cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape], - asset_revision=23 if shape == "DEPTH_SOLID" else 12 if shape == "DEPTH_BALLOON" else 11) + asset_revision=26 if shape == "DEPTH_SOLID" else 14 if shape == "DEPTH_BALLOON" else 13) assert load.call_count == 4 diff --git a/tests/anyimage/operators/test_cutout_interaction.py b/tests/anyimage/operators/test_cutout_interaction.py index 30d12e5..eb598a8 100644 --- a/tests/anyimage/operators/test_cutout_interaction.py +++ b/tests/anyimage/operators/test_cutout_interaction.py @@ -1,5 +1,9 @@ import json from types import SimpleNamespace +from types import MethodType +from unittest.mock import Mock + +import pytest from anyimage.common.selection import SelectionPath @@ -21,8 +25,10 @@ class Pie: class WindowManager: def __init__(self): self.pie = Pie() + self.events = [] def popup_menu_pie(self, _event, draw, **_options): + self.events.append(_event) menu = SimpleNamespace(layout=SimpleNamespace(menu_pie=lambda: self.pie)) draw(menu, None) @@ -31,14 +37,12 @@ def cutout_context( window_manager, mesh_detail="LOW", generate_normal=False, - inward_axis="NEGATIVE_Z", alpha_threshold=0.9, fine_outline=True, ): settings = SimpleNamespace( cutout_mesh_detail=mesh_detail, cutout_generate_normal=generate_normal, - cutout_inward_axis=inward_axis, cutout_alpha_threshold=alpha_threshold, cutout_fine_outline=fine_outline, ) @@ -94,7 +98,6 @@ def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch): ) assert not any(call[2].generate_normal for call in window_manager.pie.calls) assert all(call[2].point_spacing == 32 for call in window_manager.pie.calls) - assert all(call[2].inward_axis == "NEGATIVE_Z" for call in window_manager.pie.calls) def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch): @@ -103,7 +106,6 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch): window_manager, mesh_detail="MEDIUM", generate_normal=True, - inward_axis="POSITIVE_X", alpha_threshold=0.1, fine_outline=False, ) @@ -149,7 +151,6 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch): for call in window_manager.pie.calls ) assert all(call[2].point_spacing == 16 for call in window_manager.pie.calls) - assert all(call[2].inward_axis == "POSITIVE_X" for call in window_manager.pie.calls) def test_cutout_normal_space_is_derived_from_shape(): @@ -175,20 +176,19 @@ def test_cutout_lasso_submission_passes_only_the_selected_path(monkeypatch): monkeypatch.setattr(cutout_main, "require_image_empty", lambda _name: source) monkeypatch.setattr(interaction, "ai_ready", lambda: False) path = SelectionPath(points=((0, 0), (10, 0), (0, 10))) - operator = SimpleNamespace(source_object_name=source.name, _pie_event=object()) + operator = SimpleNamespace(source_object_name=source.name, _pie_event=object(), gesture="LASSO") result = cutout_main.SelectCutoutSelection._submit_selection_path(operator, context, path) assert result == {"FINISHED"} assert SelectionPath.from_json(window_manager.pie.calls[0][2].selection_path_json) == path -def test_cutout_tool_only_binds_lasso_drag_and_native_selection(): +def test_cutout_tool_starts_on_press_and_keeps_shift_selection(): keymap = cutout_main.CutoutTool.bl_keymap - assert keymap[0][1]["value"] == "CLICK_DRAG" - assert [entry[0] for entry in keymap[1:]] == ["view3d.select", "view3d.select"] - assert all(entry[1]["value"] != "DOUBLE_CLICK" for entry in keymap) - assert "full_image" not in cutout_main.SelectCutoutSelection.__annotations__ - assert keymap[1][2] == {"properties": [("deselect_all", False)]} - assert keymap[2][2] == {"properties": [("toggle", True)]} + assert keymap[0][1]["value"] == "PRESS" + assert len(keymap) == 2 + assert keymap[1][0] == "view3d.select" + assert keymap[1][2] == {"properties": [("toggle", True)]} + assert cutout_main.SelectCutoutSelection.resolve_on_press def test_cutout_f_has_no_option_shortcut(monkeypatch): @@ -209,7 +209,7 @@ def test_cutout_f_has_no_option_shortcut(monkeypatch): def test_cutout_status_has_no_letter_shortcut(): status = [] - operator = SimpleNamespace() + operator = SimpleNamespace(gesture="LASSO") context = SimpleNamespace( workspace=SimpleNamespace(status_text_set=status.append) ) @@ -220,3 +220,95 @@ def test_cutout_status_has_no_letter_shortcut(): "Cutout Lasso: drag and release for shapes; RMB or Esc cancels" ] assert "F" not in status[0] + + +def test_cutout_menu_keeps_submitted_gesture_during_redraw(monkeypatch): + window_manager = WindowManager() + context = cutout_context(window_manager) + monkeypatch.setattr(interaction, "ai_ready", lambda: True) + + def delayed_popup(_event, draw, **_options): + context.scene.anyimage_settings.cutout_gesture = "LASSO" + draw(SimpleNamespace(layout=SimpleNamespace(menu_pie=lambda: window_manager.pie)), None) + + window_manager.popup_menu_pie = delayed_popup + interaction.open_shape_pie( + context, object(), cutout_source(), + SelectionPath(points=((0, 0), (10, 0), (0, 10))), gesture="POLYLINE", + ) + assert all(call[2].gesture == "POLYLINE" for call in window_manager.pie.calls) + + +@pytest.mark.parametrize("finish", ("START", "DOUBLE_CLICK", "RET", "NUMPAD_ENTER")) +def test_cutout_polyline_edits_and_submits_through_shared_gesture(monkeypatch, finish): + window_manager = WindowManager() + context = cutout_context(window_manager) + context.area = SimpleNamespace(tag_redraw=Mock()) + context.region = context.region_data = object() + context.scene.anyimage_settings.cutout_gesture = "POLYLINE" + source = cutout_source() + monkeypatch.setattr(viewport, "active_view3d_tool_id", lambda _context: None) + monkeypatch.setattr(viewport, "require_image_empty", lambda _name: source) + monkeypatch.setattr(cutout_main, "require_image_empty", lambda _name: source) + monkeypatch.setattr(viewport, "deserialize_matrix", lambda _data: None) + monkeypatch.setattr(viewport, "image_empty_bounds", lambda _source: None) + monkeypatch.setattr(viewport, "screen_path_to_image_pixels", lambda path, *_args: path) + monkeypatch.setattr(interaction, "ai_ready", lambda: False) + operator = SimpleNamespace( + source_object_name=source.name, source_matrix_data="matrix", + active_tool_id="anyimage.cutout_lasso", _path=[(0, 0)], + _polyline_confirm_type=None, + _finish=Mock(), + ) + for name in ("_modal_polyline", "_confirm_polyline", "_complete", "_submit_selection_path"): + setattr(operator, name, MethodType(getattr(cutout_main.SelectCutoutSelection, name), operator)) + cutout_main.SelectCutoutSelection._read_gesture_settings(operator, context) + + def event(kind, value="PRESS", point=(100, 100)): + return cutout_main.SelectCutoutSelection.modal(operator, context, SimpleNamespace( + type=kind, value=value, mouse_region_x=point[0], mouse_region_y=point[1], + )) + + assert event("RET") == {"RUNNING_MODAL"} + event("LEFTMOUSE", point=(100, 0)) + event("LEFTMOUSE", point=(100, 100)) + event("BACK_SPACE") + assert operator._path == [(0, 0), (100, 0)] + event("LEFTMOUSE", point=(100, 100)) + if finish == "START": + result = event("LEFTMOUSE", point=(0, 0)) + elif finish == "DOUBLE_CLICK": + result = event("LEFTMOUSE", "DOUBLE_CLICK") + else: + result = event(finish) + assert result == {"RUNNING_MODAL"} + assert window_manager.events == [] + operator._finish.assert_not_called() + points = tuple(operator._path) + assert event("MOUSEMOVE", point=(200, 200)) == {"RUNNING_MODAL"} + assert event("BACK_SPACE") == {"RUNNING_MODAL"} + assert tuple(operator._path) == points + release_type = "LEFTMOUSE" if finish in {"START", "DOUBLE_CLICK"} else finish + assert event("SPACE", "RELEASE") == {"RUNNING_MODAL"} + result = event(release_type, "RELEASE") + assert result == {"FINISHED"} + assert len(window_manager.events) == 1 + assert window_manager.events[0].value == "RELEASE" + operator._finish.assert_called_once_with(context) + submitted = window_manager.pie.calls[0][2] + assert submitted.gesture == "POLYLINE" + assert SelectionPath.from_json(submitted.selection_path_json).points == ((0, 0), (100, 0), (100, 100)) + + +@pytest.mark.parametrize("reason", ("ESC", "RIGHTMOUSE", "TOOL_SWITCH")) +def test_cutout_polyline_cancel_cleans_preview(monkeypatch, reason): + context = SimpleNamespace() + operator = SimpleNamespace( + gesture="POLYLINE", active_tool_id="anyimage.cutout_lasso", _finish=Mock(), + _polyline_confirm_type="LEFTMOUSE", + ) + operator._cancel_and_finish = MethodType(viewport.ImageGesture._cancel_and_finish, operator) + monkeypatch.setattr(viewport, "active_view3d_tool_id", lambda _context: "other" if reason == "TOOL_SWITCH" else None) + result = cutout_main.SelectCutoutSelection.modal(operator, context, SimpleNamespace(type=reason, value="PRESS")) + assert result == {"CANCELLED"} + operator._finish.assert_called_once_with(context) diff --git a/tests/anyimage/operators/test_cutout_objects.py b/tests/anyimage/operators/test_cutout_objects.py index f3c17e0..fc9be60 100644 --- a/tests/anyimage/operators/test_cutout_objects.py +++ b/tests/anyimage/operators/test_cutout_objects.py @@ -21,44 +21,12 @@ from anyimage.server.geometry.depth_texture import ( write_depth_texture, ) from anyimage.server.models.geometry import GeometryFrame -from tests.support.image_objects import _add_cutout_modifier, _create_cutout_shape, _depth_image, _depth_metadata, _depth_surface_step_object, _evaluated_positions, _evaluated_topology, _group_node, _inputs - - -def test_object_normal_image_follows_cutout_inward_axis(): - bpy.ops.wm.read_factory_settings(use_empty=True) - image = bpy.data.images.new( - "Object Normal", - width=1, - height=1, - alpha=True, - float_buffer=True, - ) - decoded = np.asarray((0.2, -0.4, 0.8), dtype=np.float32) - encoded = decoded * 0.5 + 0.5 - image.pixels.foreach_set((*encoded, 0.3)) - - cutout_object.orient_object_normal_image( - image, - cutout_object.CutoutInwardAxis.NEGATIVE_Z, - ) - - actual = np.empty(4, dtype=np.float32) - image.pixels.foreach_get(actual) - assert np.allclose(actual[:3], encoded, atol=1e-6) - assert np.isclose(actual[3], 0.3) - - cutout_object.orient_object_normal_image( - image, - cutout_object.CutoutInwardAxis.POSITIVE_X, - ) - image.pixels.foreach_get(actual) - expected = np.asarray((-0.8, -0.2, -0.4), dtype=np.float32) * 0.5 + 0.5 - assert np.allclose(actual[:3], expected, atol=1e-6) - assert np.isclose(actual[3], 0.3) +from tests.support.image_objects import _add_cutout_modifier, _create_cutout_shape, _depth_image, _depth_metadata, _depth_surface_step_object, _evaluated_positions, _evaluated_topology, _group_node @pytest.mark.parametrize("shape", ("FLAT", "SOLID")) -def test_cutout_axes_produce_the_same_world_geometry(shape): +@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE")) +def test_cutout_depth_axis_is_adjusted_only_in_modifier(shape, gesture): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Axis Source", width=32, height=16, alpha=True) source = bpy.data.objects.new("Axis Source", None) @@ -76,22 +44,38 @@ def test_cutout_axes_produce_the_same_world_geometry(shape): source, shape, 8, - inward_axis=cutout_object.CutoutInwardAxis.POSITIVE_X, + gesture=gesture, ) negative_z = _create_cutout_shape( source, shape, 8, - inward_axis=cutout_object.CutoutInwardAxis.NEGATIVE_Z, + gesture=gesture, ) for result in (positive_x, negative_z): + modifier = result.modifiers[0] + mode = object_data.modifier_input_identifier(modifier.node_group, "Mode") + assert modifier[mode] == (1 if gesture == "POLYLINE" else 0) + thickness = object_data.modifier_input_identifier( + modifier.node_group, "Thickness", subtype="DISTANCE", + ) + assert modifier[thickness] == (0.5 if shape == "SOLID" and gesture == "POLYLINE" else 0.0) local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices]) assert np.allclose(local[:, 2], 0.0, atol=1e-8) assert np.ptp(local[:, 0]) > 0.0 assert np.ptp(local[:, 1]) > 0.0 assert all(polygon.normal.z > 0.99 for polygon in result.data.polygons) + assert np.allclose(positive_x.matrix_world, source.matrix_world) + modifier = positive_x.modifiers[0] + axis = object_data.modifier_input_identifier(modifier.node_group, "Depth Axis") + assert modifier[axis] == 0 + object_data.set_modifier_input(modifier, axis, 1) + positive_x.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + positive_x.matrix_world @= Matrix(cutout_shape.CUTOUT_Z_TO_X).inverted() + def world_positions(obj): local = _evaluated_positions(obj) return np.asarray([tuple(obj.matrix_world @ Vector(point)) for point in local]) @@ -165,37 +149,6 @@ def test_depth_texture_keeps_points_outside_positive_validity(tmp_path): assert np.array_equal(packed[..., 3], frame.validity) -def test_cutout_presets_load_three_direct_modifier_assets(): - bpy.ops.wm.read_factory_settings(use_empty=True) - groups = {shape: cutout_object.load_cutout_node_group(shape) for shape in cutout_shape.CUTOUT_SHAPES} - assert groups["FLAT"] == groups["SOLID"] - assert len(set(groups.values())) == 3 - for shape, group in groups.items(): - assert group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape] - assert group.is_modifier - assert "Shape" not in _inputs(group) - assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes) - assert all(socket.is_linked or socket.hide for node in group.nodes for socket in node.outputs) - for shape in ("SOLID", "DEPTH_SOLID"): - group = groups[shape] - inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"} - assert next(s for s in group.interface.items_tree if s.name == "Thickness").default_value == 1 - thickness = [item for item in group.interface.items_tree if item.name == "Thickness"] - assert [item.subtype for item in thickness] == ["NONE", "DISTANCE"] - assert inputs["Thickness"].subtype == "DISTANCE" - assert inputs["Mode"].socket_type == "NodeSocketMenu" - assert inputs["Mode"].default_value == "Balloon" - assert inputs["Smooth"].default_value == 0 - assert inputs["Smooth Weight"].default_value == 1 - if shape == "DEPTH_SOLID": - assert inputs["Boundary Smooth"].default_value == 2 - assert inputs["Boundary Smooth"].parent.name == "Options" - else: - assert "Boundary Smooth" not in inputs - assert not any(node.bl_idname == "GeometryNodeRepeatInput" and node.inputs["Iterations"].default_value == 8 for node in group.nodes) - assert next(item for item in groups["DEPTH_BALLOON"].interface.items_tree if item.name == "Double Sided").default_value - - def test_balloon_build_starts_from_the_unified_single_sheet(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Balloon Sheet", width=64, height=64, alpha=True) @@ -271,7 +224,8 @@ def test_teddy_preference_writes_the_shared_balloon_attribute(): ) -def test_depth_surface_protected_smooth_evaluates(): +@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE")) +def test_depth_surface_protected_smooth_evaluates(gesture): 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) @@ -300,12 +254,21 @@ def test_depth_surface_protected_smooth_evaluates(): 8, depth_image=depth_image, depth_metadata=depth_metadata, + gesture=gesture, ) assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices) assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes modifier = result.modifiers[0] assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["DEPTH_SOLID"] + assert modifier[object_data.modifier_input_identifier(modifier.node_group, "Mode")] == (1 if gesture == "POLYLINE" else 0) + assert modifier[object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE")] == 0.5 + assert modifier[object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")] == 1.0 + object_data.set_modifier_input( + modifier, + object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"), + 0.0, + ) expected_cleanup = cutout_geometry.cutout_cleanup_value( cutout_geometry.cutout_sampling_spacing(source, 8), cutout_geometry.world_plane_metric(source.matrix_world)[0], @@ -431,7 +394,7 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge(): def test_depth_surface_removes_boundary_ears_without_loose_geometry(): bpy.ops.wm.read_factory_settings(use_empty=True) obj, modifier = _depth_surface_step_object() - split_id = object_data.modifier_input_identifier(modifier.node_group, "Split Threshold") + split_id = object_data.modifier_input_identifier(modifier.node_group, "Depth Split") # Both outer ears have three boundary vertices, including the raised corner. for value in (0., .5, 1.): object_data.set_modifier_input(modifier, split_id, value) @@ -473,8 +436,8 @@ def test_cutout_thickness_keeps_one_connected_shell(): assert len(result.vertices) == 18 assert len(result.polygons) == 24 z = np.asarray([vertex.co.z for vertex in result.vertices]) - assert np.isclose(z.min(), -0.5, atol=1e-6) - assert np.isclose(z.max(), 0.5, atol=1e-6) + assert np.isclose(z.min(), -1.0, atol=1e-6) + assert np.isclose(z.max(), 0.0, atol=1e-6) front_faces = [ polygon for polygon in result.polygons if polygon.normal.z > 0.9 ] @@ -623,7 +586,7 @@ def test_cutout_cleanup_uses_absolute_area_with_many_small_islands(): assert len(_evaluated_positions(obj)) == 9 -def test_depth_balloon_uses_metric_plane_inputs_without_controller(): +def test_depth_balloon_uses_metric_plane_inputs(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Depth Balloon", width=100, height=100, alpha=True) source = bpy.data.objects.new("Depth Balloon", None) @@ -667,9 +630,6 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller(): assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices) assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes - assert "o_balloon_depth" not in result.data.attributes - assert "o_depth_offset" not in result.data.attributes - assert not result.children assert {obj.name for obj in bpy.context.scene.objects} == { source.name, result.name, diff --git a/tests/anyimage/operators/test_cutout_operators.py b/tests/anyimage/operators/test_cutout_operators.py index bd5a870..7b95924 100644 --- a/tests/anyimage/operators/test_cutout_operators.py +++ b/tests/anyimage/operators/test_cutout_operators.py @@ -3,11 +3,50 @@ import importlib import json from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import Mock, patch from tests.support.blender import BlenderTestCase class CutoutOperatorsTest(BlenderTestCase): + def test_cutout_gestures_start_on_empty_pick_and_lasso_finishes_on_release(self): + viewport = importlib.import_module("anyimage.common.viewport") + source = SimpleNamespace(name="Source", matrix_world="matrix", data=object()) + context = SimpleNamespace( + area=SimpleNamespace(as_pointer=lambda: 1, tag_redraw=Mock()), + region=SimpleNamespace(as_pointer=lambda: 2), + workspace=SimpleNamespace(status_text_set=Mock()), + window_manager=SimpleNamespace(modal_handler_add=Mock()), + scene=SimpleNamespace(anyimage_settings=SimpleNamespace(mask_gesture="BRUSH")), + ) + event = SimpleNamespace(type="LEFTMOUSE", value="PRESS", mouse_region_x=12, mouse_region_y=18) + with ( + patch.object(viewport, "resolve_image_edit_click", return_value=(source, True)), + patch.object(viewport, "is_animated_image", return_value=False), + patch.object(viewport, "serialize_matrix", return_value="matrix"), + patch.object(viewport, "active_view3d_tool_id", return_value=None), + patch.object(self.fake_bpy.types, "SpaceView3D", SimpleNamespace(draw_handler_add=Mock()), create=True), + ): + for gesture in ("LASSO", "POLYLINE"): + with self.subTest(gesture=gesture): + context.scene.anyimage_settings.cutout_gesture = gesture + operator = self.cutout_main.SelectCutoutSelection() + operator.report = Mock() + self.assertEqual(operator.invoke(context, event), {"RUNNING_MODAL"}) + self.assertEqual(operator._path, [(12.0, 18.0)]) + self.assertEqual(operator.gesture, gesture) + self.assertEqual(context.scene.anyimage_settings.mask_gesture, "BRUSH") + operator._complete = Mock(return_value={"FINISHED"}) + release = SimpleNamespace(type="LEFTMOUSE", value="RELEASE", mouse_region_x=20, mouse_region_y=30) + result = operator.modal(context, release) + if gesture == "LASSO": + self.assertEqual(result, {"FINISHED"}) + operator._complete.assert_called_once_with(context) + else: + operator._complete.assert_not_called() + status = context.workspace.status_text_set.call_args.args[0] + self.assertIn("Polyline", status) + self.assertIn("Backspace", status) + def test_flat_and_solid_without_ai_build_locally(self): import numpy as np @@ -29,6 +68,7 @@ class CutoutOperatorsTest(BlenderTestCase): {"points": [[1, 1], [9, 1], [9, 9], [1, 9]]} ), shape=shape, + gesture="POLYLINE", point_spacing=32, alpha_threshold=0.25, fine_outline=False, @@ -43,7 +83,7 @@ class CutoutOperatorsTest(BlenderTestCase): return_value=source, ), patch.object( - self.cutout_main, "video_total_frames", return_value=1 + self.cutout_main, "is_animated_image", return_value=False ), patch.object( self.cutout_main, "image_rgba", return_value=source_rgba @@ -91,10 +131,10 @@ class CutoutOperatorsTest(BlenderTestCase): 32.0, selection_mask.values, selection_mask.bounds, - inward_axis="NEGATIVE_Z", alpha_threshold=0.25, fine_outline=False, color_image="Color", + gesture="POLYLINE", ) @@ -131,7 +171,7 @@ class CutoutOperatorsTest(BlenderTestCase): "require_image_empty", return_value=source, ), - patch.object(self.cutout_main, "video_total_frames", return_value=1), + patch.object(self.cutout_main, "is_animated_image", return_value=False), patch.object( self.cutout_main, "image_rgba", @@ -212,7 +252,7 @@ class CutoutOperatorsTest(BlenderTestCase): "require_image_empty", return_value=source, ), - patch.object(self.cutout_main, "video_total_frames", return_value=1), + patch.object(self.cutout_main, "is_animated_image", return_value=False), patch.object(self.cutout_main, "image_rgba"), patch.object( self.cutout_main, @@ -278,9 +318,6 @@ class CutoutOperatorsTest(BlenderTestCase): self.assertTrue(parameters["generate_depth"]) self.assertEqual(parameters["alpha_threshold"], 0.25) self.assertEqual(parameters["normal_mode"], "OBJECT") - self.assertNotIn("polygon", parameters) - self.assertNotIn("shape", parameters) - self.assertNotIn("normal_analysis", parameters) def test_cutout_geometry_settings_use_configured_moge(self): @@ -339,12 +376,12 @@ class CutoutOperatorsTest(BlenderTestCase): self.assertEqual( layout.events, [ - setup, - ("prop", "cutout_generate_normal", False), + ("prop", "cutout_gesture", True), ("prop", "cutout_mesh_detail", True), ("prop", "cutout_fine_outline", True), ("prop", "cutout_alpha_threshold", True), - ("prop", "cutout_inward_axis", True), + setup, + ("prop", "cutout_generate_normal", False), ], ) @@ -356,7 +393,7 @@ class CutoutOperatorsTest(BlenderTestCase): self.assertTrue(all(event[2] for event in layout.events)) - def test_cutout_ai_response_reuses_bounds_color_and_lasso(self): + def test_cutout_ai_response_reuses_bounds_color_and_gesture_snapshot(self): import numpy as np source = SimpleNamespace(data=SimpleNamespace()) @@ -375,6 +412,7 @@ class CutoutOperatorsTest(BlenderTestCase): operator.input_path = __file__ operator.color_path = __file__ operator.shape = "FLAT" + operator.gesture = "POLYLINE" operator.point_spacing = 32 operator.generate_normal = True operator._selection_mask = selection_mask @@ -397,7 +435,10 @@ class CutoutOperatorsTest(BlenderTestCase): ) as color_result, patch.object(self.cutout_main, "create_cutout_shape") as create_shape, ): - message = operator.response(SimpleNamespace(), result) + context = SimpleNamespace(scene=SimpleNamespace( + anyimage_settings=SimpleNamespace(cutout_gesture="LASSO") + )) + message = operator.response(context, result) color_result.assert_called_once_with( __file__, @@ -405,6 +446,7 @@ class CutoutOperatorsTest(BlenderTestCase): ) self.assertEqual(message, "Flat is ready") + self.assertEqual(create_shape.call_args.kwargs["gesture"], "POLYLINE") content_values = create_shape.call_args.args[4] np.testing.assert_array_equal(content_values, pure_values) self.assertEqual(create_shape.call_args.args[5], selection_mask.bounds) diff --git a/tests/anyimage/operators/test_frame_antialiasing.py b/tests/anyimage/operators/test_frame_antialiasing.py index 1599340..7f62411 100644 --- a/tests/anyimage/operators/test_frame_antialiasing.py +++ b/tests/anyimage/operators/test_frame_antialiasing.py @@ -4,7 +4,8 @@ import pytest from anyimage.common.image import premultiplied_rgba -from anyimage.operators import frame_tool as frame +from anyimage.operators.frame_tool import compositing as frame +from anyimage.operators.frame_tool.projection import frame_source_projection def source(rgba, *, bounds=(-1, 1, -1, 1), depth=1, slope=0, active=True): @@ -13,7 +14,7 @@ def source(rgba, *, bounds=(-1, 1, -1, 1), depth=1, slope=0, active=True): world = np.eye(4) world[2, 3] = -depth world[2, 0] = slope - result = frame.frame_source_projection( + result = frame_source_projection( np.eye(4), np.eye(4), world, bounds, (1, 1), perspective=False ) result.update( @@ -43,7 +44,7 @@ def test_uncovered_canvas_has_no_rgb_to_reveal_with_mask_add(): assert np.any(uncovered) np.testing.assert_array_equal(result[uncovered], 0) restored = np.flipud(apply_alpha_mask( - result, SelectionMask(np.ones((8, 8)), (0, 0, 8, 8)), "ADD" + result, SelectionMask(np.ones((8, 8)), (0, 0, 8, 8)), "EXTEND" ).reshape((8, 8, 4))) np.testing.assert_array_equal(restored[..., :3][uncovered], 0) np.testing.assert_array_equal(restored[..., 3], 1) @@ -86,7 +87,7 @@ def test_oblique_boundary_matches_reference_and_chunk_sizes(): world = np.eye(4) world[:2, :2] = ((np.cos(angle), -np.sin(angle)), (np.sin(angle), np.cos(angle))) world[2, 3] = -1 - top.update(frame.frame_source_projection( + top.update(frame_source_projection( np.eye(4), np.eye(4), world, (-0.7, 0.7, -0.7, 0.7), (1, 1), perspective=False )) bottom = source([[[0, 0, 1, 0]]], depth=2, active=False) @@ -104,7 +105,7 @@ def test_oblique_boundary_matches_reference_and_chunk_sizes(): @pytest.mark.parametrize("background", [0, 1]) -def test_packed_frame_mask_add_keeps_boundary_color(background): +def test_packed_frame_mask_extend_keeps_boundary_color(background): from io import BytesIO import bpy from PIL import Image @@ -122,7 +123,7 @@ def test_packed_frame_mask_add_keeps_boundary_color(background): expected = (np.array([1, 0, 0]) + background) / 2 np.testing.assert_allclose(packed[0, 0, :3], expected, atol=1 / 255) restored = apply_alpha_mask( - image_rgba(result), SelectionMask(np.ones((1, 1)), (0, 0, 1, 1)), "ADD" + image_rgba(result), SelectionMask(np.ones((1, 1)), (0, 0, 1, 1)), "EXTEND" ).reshape(1, 1, 4) np.testing.assert_allclose(restored[0, 0, :3], expected, atol=1 / 255) assert restored[0, 0, 3] == 1 diff --git a/tests/anyimage/operators/test_frame_compositing.py b/tests/anyimage/operators/test_frame_compositing.py new file mode 100644 index 0000000..dc6876f --- /dev/null +++ b/tests/anyimage/operators/test_frame_compositing.py @@ -0,0 +1,304 @@ +import unittest +from unittest.mock import patch +from anyimage.common import image as image_data, selection +from anyimage.operators import mask_tool +from anyimage.operators.frame_tool import compositing, projection as frame_projection + + +class FrameCompositingTest(unittest.TestCase): + def test_frame_composites_far_to_near_with_active_on_top(self): + import numpy as np + + frame = ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)) + + def source(name, color, depth, active=False): + pixels = np.empty((2, 2, 4), dtype=np.float32) + pixels[:] = color + return { + "active": active, + "image_size": (2, 2), + "name": name, + "perspective": True, + "pixels": pixels, + "rgba": image_data.straight_rgba(pixels), + "region_size": (2.0, 2.0), + "screen_to_source": np.asarray( + ((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0)) + ), + "source_to_view": np.asarray( + ( + (2.0, 0.0, -1.0), + (0.0, -2.0, 1.0), + (0.0, 0.0, -depth), + (0.0, 0.0, 1.0), + ) + ), + } + + far = source("Far", (1.0, 0.0, 0.0, 1.0), 2.0) + active = source("Active", (0.0, 0.0, 0.5, 0.5), 1.0, active=True) + pixels = compositing.composite_frame_pixels( + (active, far), frame, (2, 2) + ) + result = np.flipud(np.asarray(pixels).reshape((2, 2, 4))) + + np.testing.assert_allclose(result[:, :, 0], 0.5, atol=1e-6) + np.testing.assert_allclose(result[:, :, 2], 0.5, atol=1e-6) + np.testing.assert_allclose(result[:, :, 3], 1.0, atol=1e-6) + + + def test_frame_puts_active_above_an_equal_depth_source(self): + import numpy as np + + frame = ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)) + screen_to_source = np.asarray( + ((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0)) + ) + + def source(name, color, active): + pixels = np.empty((2, 2, 4), dtype=np.float32) + pixels[:] = color + return { + "active": active, + "image_size": (2, 2), + "name": name, + "perspective": True, + "pixels": pixels, + "rgba": pixels.copy(), + "region_size": (2.0, 2.0), + "screen_to_source": screen_to_source, + "source_to_view": np.asarray( + ( + (2.0, 0.0, -1.0), + (0.0, -2.0, 1.0), + (0.0, 0.0, -1.0), + (0.0, 0.0, 1.0), + ) + ), + } + + pixels = compositing.composite_frame_pixels( + ( + source("Active", (0.0, 0.0, 1.0, 1.0), True), + source("Other", (1.0, 0.0, 0.0, 1.0), False), + ), + frame, + (2, 2), + ) + result = np.asarray(pixels).reshape((2, 2, 4)) + + np.testing.assert_allclose(result[:, :, 2], 1.0, atol=1e-6) + np.testing.assert_allclose(result[:, :, 0], 0.0, atol=1e-6) + + + def test_frame_composites_orthographic_samples_at_negative_depth(self): + import numpy as np + + pixels = np.empty((2, 2, 4), dtype=np.float32) + pixels[:] = (0.25, 0.5, 0.75, 1.0) + source = { + "active": True, + "image_size": (2, 2), + "name": "Active", + "perspective": False, + "pixels": pixels, + "rgba": pixels.copy(), + "region_size": (2.0, 2.0), + "screen_to_source": np.asarray( + ((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0)) + ), + "source_to_view": np.asarray( + ( + (2.0, 0.0, -1.0), + (0.0, -2.0, 1.0), + (0.0, 0.0, 1.0), + (0.0, 0.0, 1.0), + ) + ), + } + + result = compositing.composite_frame_pixels( + (source,), + ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)), + (2, 2), + ) + + np.testing.assert_allclose( + np.asarray(result).reshape((2, 2, 4)), + pixels, + atol=1e-6, + ) + + + def test_frame_preserves_masked_rgb_and_can_restore_alpha(self): + import numpy as np + + rgba = np.random.default_rng(7).random((4, 4, 4)).astype(np.float32) + mask = selection.SelectionMask(np.ones((4, 4)), (0, 0, 4, 4)) + removed = mask_tool.apply_alpha_mask(rgba, mask, "SUBTRACT") + source = self._frame_pixel_source(np.flipud(removed.reshape(rgba.shape))) + framed = compositing.composite_frame_pixels( + (source,), ((0, 4), (4, 4), (4, 0), (0, 0)), (4, 4) + ) + result = np.flipud(framed.reshape(rgba.shape)) + np.testing.assert_allclose(result[..., :3], rgba[..., :3]) + np.testing.assert_array_equal(result[..., 3], 0) + restored = mask_tool.apply_alpha_mask(result, mask, "EXTEND") + restored = np.flipud(restored.reshape(rgba.shape)) + np.testing.assert_allclose(restored[..., :3], rgba[..., :3]) + np.testing.assert_array_equal(restored[..., 3], 1) + + + def test_frame_hidden_rgb_uses_bottom_valid_depth_and_stable_ties(self): + import numpy as np + + blue = np.tile((0, 0, 1, 0), (2, 2, 1)) + red = np.tile((1, 0, 0, 0), (2, 2, 1)) + frame = ((0, 2), (2, 2), (2, 0), (0, 0)) + for depth in (2.0, 1.0, -1.0): + with self.subTest(depth=depth): + bottom = self._frame_pixel_source(blue, depth=depth, name="A", active=False) + top = self._frame_pixel_source(red, depth=min(depth, 1.0)) + result = compositing.composite_frame_pixels((top, bottom), frame, (2, 2)) + np.testing.assert_allclose(result.reshape((2, 2, 4)), blue) + middle = self._frame_pixel_source(red, name="B", active=False) + bottom = self._frame_pixel_source(blue, name="A", active=False) + top = self._frame_pixel_source(red) + result = compositing.composite_frame_pixels((middle, top, bottom), frame, (2, 2)) + np.testing.assert_allclose(result.reshape((2, 2, 4)), blue) + bottom["screen_to_source"][0, 2] += 0.5 + result = np.flipud(compositing.composite_frame_pixels( + (top, bottom), frame, (2, 2) + ).reshape((2, 2, 4))) + np.testing.assert_allclose(result[:, 0], blue[:, 0]) + np.testing.assert_allclose(result[:, 1], red[:, 1]) + bottom["perspective"] = True + bottom["source_to_view"][2, 2] = 1.0 + result = compositing.composite_frame_pixels((top, bottom), frame, (2, 2)) + np.testing.assert_allclose(result.reshape((2, 2, 4)), red) + + + def test_frame_hidden_rgb_tracks_crossing_planes(self): + import numpy as np + + bottom = self._frame_pixel_source(np.tile((0, 0, 1, 0), (2, 2, 1)), active=False) + top = self._frame_pixel_source(np.tile((1, 0, 0, 0), (2, 2, 1))) + bottom["source_to_view"][2] = (2, 0, -2) + result = np.flipud(compositing.composite_frame_pixels( + (top, bottom), ((0, 2), (2, 2), (2, 0), (0, 0)), (2, 2) + ).reshape((2, 2, 4))) + np.testing.assert_allclose(result[:, 0], np.tile((0, 0, 1, 0), (2, 1))) + np.testing.assert_allclose(result[:, 1], np.tile((1, 0, 0, 0), (2, 1))) + + + def test_frame_blends_rgb_over_bottom_color_with_independent_alpha(self): + import numpy as np + + for bottom_alpha in (0.0, 0.5, 1.0): + blue = np.tile((0, 0, 1, bottom_alpha), (2, 5, 1)).astype(np.float32) + red = np.tile((1, 0, 0, 0), (2, 5, 1)).astype(np.float32) + red[..., 3] = (0, 0.25, 0.5, 0.75, 1) + sources = (self._frame_pixel_source(blue, depth=2, active=False), + self._frame_pixel_source(red)) + result = np.flipud(compositing.composite_frame_pixels( + sources, ((0, 2), (5, 2), (5, 0), (0, 0)), (5, 2) + ).reshape((2, 5, 4))) + alpha = red[..., 3] + bottom_alpha * (1 - red[..., 3]) + expected = np.zeros_like(red) + expected[..., 0] = red[..., 3] + expected[..., 2] = 1 - red[..., 3] + np.testing.assert_allclose(result[..., 3], alpha) + np.testing.assert_allclose(result[..., :3], expected[..., :3]) + + + def test_frame_three_layers_preserve_foreground_gradient_over_white(self): + import numpy as np + + white = np.tile((1, 1, 1, 0), (2, 6, 1)).astype(np.float32) + red = np.tile((1, 0, 0, 0), (2, 6, 1)).astype(np.float32) + red[..., 3] = (0, 0.01, 0.1, 0.25, 0.5, 1) + blue = np.tile((0, 0, 1, 0.25), (2, 6, 1)).astype(np.float32) + sources = ( + self._frame_pixel_source(white, depth=3, active=False), + self._frame_pixel_source(red, depth=2, active=False), + self._frame_pixel_source(blue), + ) + result = np.flipud(compositing.composite_frame_pixels( + sources, ((0, 2), (6, 2), (6, 0), (0, 0)), (6, 2) + ).reshape((2, 6, 4))) + red_alpha = red[..., 3:4] + expected = ((red[..., :3] * red_alpha + 1 - red_alpha) * 0.75 + + blue[..., :3] * 0.25) + np.testing.assert_allclose(result[..., :3], expected) + np.testing.assert_allclose(result[..., 3], 0.25 + red[..., 3] * 0.75) + + + def test_frame_foreground_resampling_excludes_hidden_black(self): + import numpy as np + + white = self._frame_pixel_source( + np.tile((1, 1, 1, 0), (1, 2, 1)), depth=2, active=False + ) + foreground = self._frame_pixel_source( + np.asarray([[(1, 0, 0, 1), (0, 0, 0, 0)]]) + ) + result = compositing.composite_frame_pixels( + (foreground, white), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1) + ) + np.testing.assert_allclose(result, (1, 0.5, 0.5, 0.5)) + + + def test_frame_raw_sampling_and_empty_canvas_are_chunk_independent(self): + import numpy as np + + rgba = np.tile((0, 1, 0, 0), (4, 4, 1)).astype(np.float32) + rgba[1:3, 2:] = (1, 0, 0, 1) + source = self._frame_pixel_source(rgba) + frame = ((-3, 7), (7, 7), (7, -3), (-3, -3)) + results = [] + for rows in (1, 2, 256): + with patch.object(compositing, "PROJECTIVE_CHUNK_ROWS", rows): + results.append(np.flipud(compositing.composite_frame_pixels( + (source,), frame, (10, 10) + ).reshape((10, 10, 4)))) + for result in results: + np.testing.assert_array_equal(result, results[0]) + np.testing.assert_allclose(result[3:7, 3:7], rgba) + np.testing.assert_array_equal(result[4, 7], 0) + np.testing.assert_array_equal(result[0], 0) + transparent = np.asarray([[(1, 0, 0, 0), (0, 0, 1, 0)]], dtype=np.float32) + source = self._frame_pixel_source(transparent) + framed = compositing.composite_frame_pixels( + (source,), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1) + ) + np.testing.assert_allclose(framed, (0.5, 0, 0.5, 0)) + sampled = image_data.sample_rgba( + transparent, np.asarray(((1, 0.5), (-1, 0.5))) + ) + np.testing.assert_allclose(sampled, ((0.5, 0, 0.5, 0), (0, 0, 0, 0))) + transparent[0, 0, 3] = 1 + source = self._frame_pixel_source(transparent) + framed = compositing.composite_frame_pixels( + (source,), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1) + ) + np.testing.assert_allclose(framed, (0.5, 0, 0.5, 0.5)) + + + def _frame_pixel_source(self, rgba, *, depth=1.0, name="Active", active=True): + import numpy as np + + rgba = np.asarray(rgba, dtype=np.float32) + height, width = rgba.shape[:2] + world = np.identity(4) + world[2, 3] = -depth + source = frame_projection.frame_source_projection( + np.identity(4), np.identity(4), world, + (-1.0, 1.0, -1.0, 1.0), (width, height), perspective=False, + ) + source.update( + active=active, name=name, image_size=(width, height), rgba=rgba, + pixels=image_data.premultiplied_rgba( + np.flipud(rgba).ravel(), (width, height) + ), + ) + return source diff --git a/tests/anyimage/operators/test_frame_projection.py b/tests/anyimage/operators/test_frame_projection.py index 87850ed..bbda675 100644 --- a/tests/anyimage/operators/test_frame_projection.py +++ b/tests/anyimage/operators/test_frame_projection.py @@ -1,9 +1,11 @@ +import unittest +from types import SimpleNamespace +from anyimage.operators.frame_tool import projection as frame_projection import numpy as np import pytest from anyimage.common.selection import clip_polygon_halfplanes -from anyimage.operators import frame_tool as frame @pytest.mark.parametrize("focal", [1, 1.7, 2, 4]) @@ -16,12 +18,12 @@ def test_frame_overlap_matches_finite_front_samples(focal, angle, depth): world = np.eye(4) world[:3, :3] = ((c, 0, s), (0, 1, 0), (-s, 0, c)) world[2, 3] = -depth - source = frame.frame_source_projection( + source = frame_projection.frame_source_projection( projection, np.eye(4), world, (-1, 1, -1, 1), (100, 100), perspective=True ) source["image_size"] = (100, 100) - assert frame.frame_source_coordinates(source, np.array(((50, 50),)))[2][0] - overlap = frame.frame_source_overlap(source, ((0, 100), (100, 100), (100, 0), (0, 0))) + assert frame_projection.frame_source_coordinates(source, np.array(((50, 50),)))[2][0] + overlap = frame_projection.frame_source_overlap(source, ((0, 100), (100, 100), (100, 0), (0, 0))) assert len(overlap) >= 3 assert np.isfinite(overlap).all() @@ -34,3 +36,289 @@ def test_halfplane_clipping_is_scale_invariant(scale): assert clip_polygon_halfplanes(square, ((1, 0, -3),)) == () edge = clip_polygon_halfplanes(square, ((1, 0, -2),)) assert all(x == 2 for x, y in edge) + + +class FrameProjectionTest(unittest.TestCase): + def test_frame_output_size_uses_viewport_pixels(self): + frame = ( + (-50.0, 150.0), + (150.0, 150.0), + (150.0, -50.0), + (-50.0, -50.0), + ) + + self.assertEqual( + frame_projection.frame_output_size( + frame, + (1000, 800), + 2048, + ), + (200, 200), + ) + + + def test_frame_output_size_obeys_region_and_preference_limits(self): + frame = ( + (0.0, 1000.0), + (2000.0, 1000.0), + (2000.0, 0.0), + (0.0, 0.0), + ) + + self.assertEqual( + frame_projection.frame_output_size(frame, (1000, 800), 2048), + (1000, 500), + ) + self.assertEqual( + frame_projection.frame_output_size(frame, (4000, 2000), 500), + (500, 250), + ) + + + def test_frame_requires_positive_valid_projection_overlap(self): + import numpy as np + + source = frame_projection.frame_source_projection( + np.identity(4), + np.identity(4), + np.identity(4), + (-1.0, 1.0, -1.0, 1.0), + (100, 100), + perspective=False, + ) + + self.assertTrue( + frame_projection.frame_source_overlap( + source, + ((50.0, 150.0), (150.0, 150.0), (150.0, 50.0), (50.0, 50.0)), + ) + ) + self.assertFalse( + frame_projection.frame_source_overlap( + source, + ((100.0, 100.0), (200.0, 100.0), (200.0, 0.0), (100.0, 0.0)), + ) + ) + + + def test_frame_orthographic_projection_accepts_non_positive_depth(self): + import numpy as np + + matrix_world = np.identity(4) + matrix_world[2, 3] = 1.0 + source = frame_projection.frame_source_projection( + np.identity(4), + np.identity(4), + matrix_world, + (-1.0, 1.0, -1.0, 1.0), + (100, 100), + perspective=False, + ) + source["image_size"] = (100, 100) + points, depth, valid, normalized = frame_projection.frame_source_coordinates( + source, + np.asarray(((25.0, 75.0),)), + ) + + np.testing.assert_allclose(points[0], (25.0, 25.0)) + np.testing.assert_allclose(normalized[0], (0.25, 0.25)) + self.assertEqual(depth[0], -1.0) + self.assertTrue(valid[0]) + self.assertTrue( + frame_projection.frame_source_overlap( + source, + ((0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)), + ) + ) + + + def test_frame_rejects_an_orthographic_plane_parallel_to_view_rays(self): + import numpy as np + + matrix_world = np.asarray( + ( + (0.0, 0.0, 1.0, 0.0), + (0.0, 1.0, 0.0, 0.0), + (-1.0, 0.0, 0.0, 0.0), + (0.0, 0.0, 0.0, 1.0), + ) + ) + + with self.assertRaisesRegex(ValueError, "edge-on"): + frame_projection.frame_source_projection( + np.identity(4), + np.identity(4), + matrix_world, + (-1.0, 1.0, -1.0, 1.0), + (100, 100), + perspective=False, + ) + + + def test_frame_perspective_projection_samples_front_of_crossing_image(self): + import numpy as np + + projection = np.identity(4) + projection[3] = (0.0, 0.0, -1.0, 0.0) + matrix_world = np.identity(4) + matrix_world[0, 3] = 1.0 + matrix_world[2, 0] = 1.0 + matrix_world[2, 3] = 0.2 + source = frame_projection.frame_source_projection( + projection, + np.identity(4), + matrix_world, + (-1.0, 1.0, -1.0, 1.0), + (100, 100), + perspective=True, + ) + source["image_size"] = (100, 100) + front_points, front_depth, front_valid, _normalized = ( + frame_projection.frame_source_coordinates( + source, + np.asarray(((75.0, 50.0),)), + ) + ) + _rear_points, rear_depth, rear_valid, _normalized = ( + frame_projection.frame_source_coordinates( + source, + np.asarray(((-50.0, 50.0),)), + ) + ) + overlap = frame_projection.frame_source_overlap( + source, + ((50.0, 100.0), (100.0, 100.0), (100.0, 0.0), (50.0, 0.0)), + ) + + self.assertTrue(front_valid[0]) + self.assertGreater(front_depth[0], 0.0) + self.assertGreaterEqual(front_points[0, 0], 0.0) + self.assertFalse(rear_valid[0]) + self.assertLess(rear_depth[0], 0.0) + self.assertTrue(overlap) + + _parallel_points, _parallel_depth, parallel_valid, _normalized = ( + frame_projection.frame_source_coordinates( + source, + np.asarray(((0.0, 50.0),)), + ) + ) + self.assertFalse(parallel_valid[0]) + + + def test_frame_perspective_projection_accepts_extremely_close_image(self): + import numpy as np + + projection = np.identity(4) + projection[3] = (0.0, 0.0, -1.0, 0.0) + matrix_world = np.identity(4) + matrix_world[2, 3] = -1e-14 + source = frame_projection.frame_source_projection( + projection, + np.identity(4), + matrix_world, + (-1.0, 1.0, -1.0, 1.0), + (100, 100), + perspective=True, + ) + source["image_size"] = (100, 100) + _points, depth, valid, _normalized = frame_projection.frame_source_coordinates( + source, + np.asarray(((50.0, 50.0),)), + ) + + self.assertTrue(valid[0]) + self.assertEqual(depth[0], 1e-14) + + + def test_frame_overlap_does_not_depend_on_output_pixel_centers(self): + import numpy as np + + source = frame_projection.frame_source_projection( + np.identity(4), + np.identity(4), + np.identity(4), + (-1.0, 1.0, -1.0, 1.0), + (100, 100), + perspective=False, + ) + + self.assertTrue( + frame_projection.frame_source_overlap( + source, + ( + (99.999, 60.0), + (100.001, 60.0), + (100.001, 40.0), + (99.999, 40.0), + ), + ) + ) + + + def test_frame_uses_crossing_active_overlap_for_visible_result_depth(self): + import numpy as np + + projection = np.identity(4) + projection[3] = (0.0, 0.0, -1.0, 0.0) + matrix_world = np.identity(4) + matrix_world[0, 3] = 1.0 + matrix_world[2, 0] = 1.0 + matrix_world[2, 3] = 0.2 + source = frame_projection.frame_source_projection( + projection, + np.identity(4), + matrix_world, + (-1.0, 1.0, -1.0, 1.0), + (100, 100), + perspective=True, + ) + source.update( + { + "image_size": (100, 100), + "object": SimpleNamespace(matrix_world=matrix_world), + } + ) + frame = ((50.0, 100.0), (100.0, 100.0), (100.0, 0.0), (50.0, 0.0)) + overlap = frame_projection.frame_source_overlap(source, frame) + depth_location = frame_projection.frame_depth_location(source, overlap) + + self.assertLess(depth_location[2], 0.0) + + + def test_frame_keeps_positive_active_origin_as_result_depth_location(self): + import numpy as np + + marker = SimpleNamespace(copy=lambda: "active-origin") + source = { + "object": SimpleNamespace( + matrix_world=SimpleNamespace(translation=marker), + ), + "origin_depth": 1e-14, + "perspective": True, + } + + self.assertEqual( + frame_projection.frame_depth_location(source, ((0.0, 0.0),)), + "active-origin", + ) + + + def test_frame_geometry_faces_the_view_and_uses_frame_center(self): + import numpy as np + + matrix, display_size = frame_projection.view_facing_frame_geometry( + ( + (-2.0, 1.0, 5.0), + (2.0, 1.0, 5.0), + (2.0, -1.0, 5.0), + (-2.0, -1.0, 5.0), + ), + (400, 200), + ) + + np.testing.assert_allclose(matrix[:3, 0], (1.0, 0.0, 0.0)) + np.testing.assert_allclose(matrix[:3, 1], (0.0, 1.0, 0.0)) + np.testing.assert_allclose(matrix[:3, 2], (0.0, 0.0, 1.0)) + np.testing.assert_allclose(matrix[:3, 3], (0.0, 0.0, 5.0)) + self.assertEqual(display_size, 4.0) diff --git a/tests/anyimage/operators/test_frame_tool.py b/tests/anyimage/operators/test_frame_tool.py index d487182..fd13a1b 100644 --- a/tests/anyimage/operators/test_frame_tool.py +++ b/tests/anyimage/operators/test_frame_tool.py @@ -15,271 +15,6 @@ class FrameToolTest(BlenderTestCase): ) - def test_frame_output_size_uses_viewport_pixels(self): - frame = ( - (-50.0, 150.0), - (150.0, 150.0), - (150.0, -50.0), - (-50.0, -50.0), - ) - - self.assertEqual( - self.image_frame.frame_output_size( - frame, - (1000, 800), - 2048, - ), - (200, 200), - ) - - - def test_frame_output_size_obeys_region_and_preference_limits(self): - frame = ( - (0.0, 1000.0), - (2000.0, 1000.0), - (2000.0, 0.0), - (0.0, 0.0), - ) - - self.assertEqual( - self.image_frame.frame_output_size(frame, (1000, 800), 2048), - (1000, 500), - ) - self.assertEqual( - self.image_frame.frame_output_size(frame, (4000, 2000), 500), - (500, 250), - ) - - - def test_frame_requires_positive_valid_projection_overlap(self): - import numpy as np - - source = self.image_frame.frame_source_projection( - np.identity(4), - np.identity(4), - np.identity(4), - (-1.0, 1.0, -1.0, 1.0), - (100, 100), - perspective=False, - ) - - self.assertTrue( - self.image_frame.frame_source_overlap( - source, - ((50.0, 150.0), (150.0, 150.0), (150.0, 50.0), (50.0, 50.0)), - ) - ) - self.assertFalse( - self.image_frame.frame_source_overlap( - source, - ((100.0, 100.0), (200.0, 100.0), (200.0, 0.0), (100.0, 0.0)), - ) - ) - - - def test_frame_orthographic_projection_accepts_non_positive_depth(self): - import numpy as np - - matrix_world = np.identity(4) - matrix_world[2, 3] = 1.0 - source = self.image_frame.frame_source_projection( - np.identity(4), - np.identity(4), - matrix_world, - (-1.0, 1.0, -1.0, 1.0), - (100, 100), - perspective=False, - ) - source["image_size"] = (100, 100) - points, depth, valid, normalized = self.image_frame.frame_source_coordinates( - source, - np.asarray(((25.0, 75.0),)), - ) - - np.testing.assert_allclose(points[0], (25.0, 25.0)) - np.testing.assert_allclose(normalized[0], (0.25, 0.25)) - self.assertEqual(depth[0], -1.0) - self.assertTrue(valid[0]) - self.assertTrue( - self.image_frame.frame_source_overlap( - source, - ((0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)), - ) - ) - - - def test_frame_rejects_an_orthographic_plane_parallel_to_view_rays(self): - import numpy as np - - matrix_world = np.asarray( - ( - (0.0, 0.0, 1.0, 0.0), - (0.0, 1.0, 0.0, 0.0), - (-1.0, 0.0, 0.0, 0.0), - (0.0, 0.0, 0.0, 1.0), - ) - ) - - with self.assertRaisesRegex(ValueError, "edge-on"): - self.image_frame.frame_source_projection( - np.identity(4), - np.identity(4), - matrix_world, - (-1.0, 1.0, -1.0, 1.0), - (100, 100), - perspective=False, - ) - - - def test_frame_perspective_projection_samples_front_of_crossing_image(self): - import numpy as np - - projection = np.identity(4) - projection[3] = (0.0, 0.0, -1.0, 0.0) - matrix_world = np.identity(4) - matrix_world[0, 3] = 1.0 - matrix_world[2, 0] = 1.0 - matrix_world[2, 3] = 0.2 - source = self.image_frame.frame_source_projection( - projection, - np.identity(4), - matrix_world, - (-1.0, 1.0, -1.0, 1.0), - (100, 100), - perspective=True, - ) - source["image_size"] = (100, 100) - front_points, front_depth, front_valid, _normalized = ( - self.image_frame.frame_source_coordinates( - source, - np.asarray(((75.0, 50.0),)), - ) - ) - _rear_points, rear_depth, rear_valid, _normalized = ( - self.image_frame.frame_source_coordinates( - source, - np.asarray(((-50.0, 50.0),)), - ) - ) - overlap = self.image_frame.frame_source_overlap( - source, - ((50.0, 100.0), (100.0, 100.0), (100.0, 0.0), (50.0, 0.0)), - ) - - self.assertTrue(front_valid[0]) - self.assertGreater(front_depth[0], 0.0) - self.assertGreaterEqual(front_points[0, 0], 0.0) - self.assertFalse(rear_valid[0]) - self.assertLess(rear_depth[0], 0.0) - self.assertTrue(overlap) - - _parallel_points, _parallel_depth, parallel_valid, _normalized = ( - self.image_frame.frame_source_coordinates( - source, - np.asarray(((0.0, 50.0),)), - ) - ) - self.assertFalse(parallel_valid[0]) - - - def test_frame_perspective_projection_accepts_extremely_close_image(self): - import numpy as np - - projection = np.identity(4) - projection[3] = (0.0, 0.0, -1.0, 0.0) - matrix_world = np.identity(4) - matrix_world[2, 3] = -1e-14 - source = self.image_frame.frame_source_projection( - projection, - np.identity(4), - matrix_world, - (-1.0, 1.0, -1.0, 1.0), - (100, 100), - perspective=True, - ) - source["image_size"] = (100, 100) - _points, depth, valid, _normalized = self.image_frame.frame_source_coordinates( - source, - np.asarray(((50.0, 50.0),)), - ) - - self.assertTrue(valid[0]) - self.assertEqual(depth[0], 1e-14) - - - def test_frame_overlap_does_not_depend_on_output_pixel_centers(self): - import numpy as np - - source = self.image_frame.frame_source_projection( - np.identity(4), - np.identity(4), - np.identity(4), - (-1.0, 1.0, -1.0, 1.0), - (100, 100), - perspective=False, - ) - - self.assertTrue( - self.image_frame.frame_source_overlap( - source, - ( - (99.999, 60.0), - (100.001, 60.0), - (100.001, 40.0), - (99.999, 40.0), - ), - ) - ) - - - def test_frame_uses_crossing_active_overlap_for_visible_result_depth(self): - import numpy as np - - projection = np.identity(4) - projection[3] = (0.0, 0.0, -1.0, 0.0) - matrix_world = np.identity(4) - matrix_world[0, 3] = 1.0 - matrix_world[2, 0] = 1.0 - matrix_world[2, 3] = 0.2 - source = self.image_frame.frame_source_projection( - projection, - np.identity(4), - matrix_world, - (-1.0, 1.0, -1.0, 1.0), - (100, 100), - perspective=True, - ) - source.update( - { - "image_size": (100, 100), - "object": SimpleNamespace(matrix_world=matrix_world), - } - ) - frame = ((50.0, 100.0), (100.0, 100.0), (100.0, 0.0), (50.0, 0.0)) - overlap = self.image_frame.frame_source_overlap(source, frame) - depth_location = self.image_frame.frame_depth_location(source, overlap) - - self.assertLess(depth_location[2], 0.0) - - - def test_frame_keeps_positive_active_origin_as_result_depth_location(self): - import numpy as np - - marker = SimpleNamespace(copy=lambda: "active-origin") - source = { - "object": SimpleNamespace( - matrix_world=SimpleNamespace(translation=marker), - ), - "origin_depth": 1e-14, - "perspective": True, - } - - self.assertEqual( - self.image_frame.frame_depth_location(source, ((0.0, 0.0),)), - "active-origin", - ) - - def test_frame_reports_missing_active_overlap_as_warning(self): active = SimpleNamespace(name="Active") context = SimpleNamespace( @@ -303,304 +38,6 @@ class FrameToolTest(BlenderTestCase): ) - def test_frame_geometry_faces_the_view_and_uses_frame_center(self): - import numpy as np - - matrix, display_size = self.image_frame.view_facing_frame_geometry( - ( - (-2.0, 1.0, 5.0), - (2.0, 1.0, 5.0), - (2.0, -1.0, 5.0), - (-2.0, -1.0, 5.0), - ), - (400, 200), - ) - - np.testing.assert_allclose(matrix[:3, 0], (1.0, 0.0, 0.0)) - np.testing.assert_allclose(matrix[:3, 1], (0.0, 1.0, 0.0)) - np.testing.assert_allclose(matrix[:3, 2], (0.0, 0.0, 1.0)) - np.testing.assert_allclose(matrix[:3, 3], (0.0, 0.0, 5.0)) - self.assertEqual(display_size, 4.0) - - - def test_frame_composites_far_to_near_with_active_on_top(self): - import numpy as np - - frame = ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)) - - def source(name, color, depth, active=False): - pixels = np.empty((2, 2, 4), dtype=np.float32) - pixels[:] = color - return { - "active": active, - "image_size": (2, 2), - "name": name, - "perspective": True, - "pixels": pixels, - "rgba": self.image_data.straight_rgba(pixels), - "region_size": (2.0, 2.0), - "screen_to_source": np.asarray( - ((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0)) - ), - "source_to_view": np.asarray( - ( - (2.0, 0.0, -1.0), - (0.0, -2.0, 1.0), - (0.0, 0.0, -depth), - (0.0, 0.0, 1.0), - ) - ), - } - - far = source("Far", (1.0, 0.0, 0.0, 1.0), 2.0) - active = source("Active", (0.0, 0.0, 0.5, 0.5), 1.0, active=True) - pixels = self.image_frame.composite_frame_pixels( - (active, far), frame, (2, 2) - ) - result = np.flipud(np.asarray(pixels).reshape((2, 2, 4))) - - np.testing.assert_allclose(result[:, :, 0], 0.5, atol=1e-6) - np.testing.assert_allclose(result[:, :, 2], 0.5, atol=1e-6) - np.testing.assert_allclose(result[:, :, 3], 1.0, atol=1e-6) - - - def test_frame_puts_active_above_an_equal_depth_source(self): - import numpy as np - - frame = ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)) - screen_to_source = np.asarray( - ((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0)) - ) - - def source(name, color, active): - pixels = np.empty((2, 2, 4), dtype=np.float32) - pixels[:] = color - return { - "active": active, - "image_size": (2, 2), - "name": name, - "perspective": True, - "pixels": pixels, - "rgba": pixels.copy(), - "region_size": (2.0, 2.0), - "screen_to_source": screen_to_source, - "source_to_view": np.asarray( - ( - (2.0, 0.0, -1.0), - (0.0, -2.0, 1.0), - (0.0, 0.0, -1.0), - (0.0, 0.0, 1.0), - ) - ), - } - - pixels = self.image_frame.composite_frame_pixels( - ( - source("Active", (0.0, 0.0, 1.0, 1.0), True), - source("Other", (1.0, 0.0, 0.0, 1.0), False), - ), - frame, - (2, 2), - ) - result = np.asarray(pixels).reshape((2, 2, 4)) - - np.testing.assert_allclose(result[:, :, 2], 1.0, atol=1e-6) - np.testing.assert_allclose(result[:, :, 0], 0.0, atol=1e-6) - - - def test_frame_composites_orthographic_samples_at_negative_depth(self): - import numpy as np - - pixels = np.empty((2, 2, 4), dtype=np.float32) - pixels[:] = (0.25, 0.5, 0.75, 1.0) - source = { - "active": True, - "image_size": (2, 2), - "name": "Active", - "perspective": False, - "pixels": pixels, - "rgba": pixels.copy(), - "region_size": (2.0, 2.0), - "screen_to_source": np.asarray( - ((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0)) - ), - "source_to_view": np.asarray( - ( - (2.0, 0.0, -1.0), - (0.0, -2.0, 1.0), - (0.0, 0.0, 1.0), - (0.0, 0.0, 1.0), - ) - ), - } - - result = self.image_frame.composite_frame_pixels( - (source,), - ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)), - (2, 2), - ) - - np.testing.assert_allclose( - np.asarray(result).reshape((2, 2, 4)), - pixels, - atol=1e-6, - ) - - - def test_frame_preserves_masked_rgb_and_can_restore_alpha(self): - import numpy as np - - rgba = np.random.default_rng(7).random((4, 4, 4)).astype(np.float32) - mask = self.image_selection.SelectionMask(np.ones((4, 4)), (0, 0, 4, 4)) - removed = self.edit_mask.apply_alpha_mask(rgba, mask, "SUBTRACT") - source = self._frame_pixel_source(np.flipud(removed.reshape(rgba.shape))) - framed = self.image_frame.composite_frame_pixels( - (source,), ((0, 4), (4, 4), (4, 0), (0, 0)), (4, 4) - ) - result = np.flipud(framed.reshape(rgba.shape)) - np.testing.assert_allclose(result[..., :3], rgba[..., :3]) - np.testing.assert_array_equal(result[..., 3], 0) - restored = self.edit_mask.apply_alpha_mask(result, mask, "ADD") - restored = np.flipud(restored.reshape(rgba.shape)) - np.testing.assert_allclose(restored[..., :3], rgba[..., :3]) - np.testing.assert_array_equal(restored[..., 3], 1) - - - def test_frame_hidden_rgb_uses_bottom_valid_depth_and_stable_ties(self): - import numpy as np - - blue = np.tile((0, 0, 1, 0), (2, 2, 1)) - red = np.tile((1, 0, 0, 0), (2, 2, 1)) - frame = ((0, 2), (2, 2), (2, 0), (0, 0)) - for depth in (2.0, 1.0, -1.0): - with self.subTest(depth=depth): - bottom = self._frame_pixel_source(blue, depth=depth, name="A", active=False) - top = self._frame_pixel_source(red, depth=min(depth, 1.0)) - result = self.image_frame.composite_frame_pixels((top, bottom), frame, (2, 2)) - np.testing.assert_allclose(result.reshape((2, 2, 4)), blue) - middle = self._frame_pixel_source(red, name="B", active=False) - bottom = self._frame_pixel_source(blue, name="A", active=False) - top = self._frame_pixel_source(red) - result = self.image_frame.composite_frame_pixels((middle, top, bottom), frame, (2, 2)) - np.testing.assert_allclose(result.reshape((2, 2, 4)), blue) - bottom["screen_to_source"][0, 2] += 0.5 - result = np.flipud(self.image_frame.composite_frame_pixels( - (top, bottom), frame, (2, 2) - ).reshape((2, 2, 4))) - np.testing.assert_allclose(result[:, 0], blue[:, 0]) - np.testing.assert_allclose(result[:, 1], red[:, 1]) - bottom["perspective"] = True - bottom["source_to_view"][2, 2] = 1.0 - result = self.image_frame.composite_frame_pixels((top, bottom), frame, (2, 2)) - np.testing.assert_allclose(result.reshape((2, 2, 4)), red) - - - def test_frame_hidden_rgb_tracks_crossing_planes(self): - import numpy as np - - bottom = self._frame_pixel_source(np.tile((0, 0, 1, 0), (2, 2, 1)), active=False) - top = self._frame_pixel_source(np.tile((1, 0, 0, 0), (2, 2, 1))) - bottom["source_to_view"][2] = (2, 0, -2) - result = np.flipud(self.image_frame.composite_frame_pixels( - (top, bottom), ((0, 2), (2, 2), (2, 0), (0, 0)), (2, 2) - ).reshape((2, 2, 4))) - np.testing.assert_allclose(result[:, 0], np.tile((0, 0, 1, 0), (2, 1))) - np.testing.assert_allclose(result[:, 1], np.tile((1, 0, 0, 0), (2, 1))) - - - def test_frame_blends_rgb_over_bottom_color_with_independent_alpha(self): - import numpy as np - - for bottom_alpha in (0.0, 0.5, 1.0): - blue = np.tile((0, 0, 1, bottom_alpha), (2, 5, 1)).astype(np.float32) - red = np.tile((1, 0, 0, 0), (2, 5, 1)).astype(np.float32) - red[..., 3] = (0, 0.25, 0.5, 0.75, 1) - sources = (self._frame_pixel_source(blue, depth=2, active=False), - self._frame_pixel_source(red)) - result = np.flipud(self.image_frame.composite_frame_pixels( - sources, ((0, 2), (5, 2), (5, 0), (0, 0)), (5, 2) - ).reshape((2, 5, 4))) - alpha = red[..., 3] + bottom_alpha * (1 - red[..., 3]) - expected = np.zeros_like(red) - expected[..., 0] = red[..., 3] - expected[..., 2] = 1 - red[..., 3] - np.testing.assert_allclose(result[..., 3], alpha) - np.testing.assert_allclose(result[..., :3], expected[..., :3]) - - - def test_frame_three_layers_preserve_foreground_gradient_over_white(self): - import numpy as np - - white = np.tile((1, 1, 1, 0), (2, 6, 1)).astype(np.float32) - red = np.tile((1, 0, 0, 0), (2, 6, 1)).astype(np.float32) - red[..., 3] = (0, 0.01, 0.1, 0.25, 0.5, 1) - blue = np.tile((0, 0, 1, 0.25), (2, 6, 1)).astype(np.float32) - sources = ( - self._frame_pixel_source(white, depth=3, active=False), - self._frame_pixel_source(red, depth=2, active=False), - self._frame_pixel_source(blue), - ) - result = np.flipud(self.image_frame.composite_frame_pixels( - sources, ((0, 2), (6, 2), (6, 0), (0, 0)), (6, 2) - ).reshape((2, 6, 4))) - red_alpha = red[..., 3:4] - expected = ((red[..., :3] * red_alpha + 1 - red_alpha) * 0.75 - + blue[..., :3] * 0.25) - np.testing.assert_allclose(result[..., :3], expected) - np.testing.assert_allclose(result[..., 3], 0.25 + red[..., 3] * 0.75) - - - def test_frame_foreground_resampling_excludes_hidden_black(self): - import numpy as np - - white = self._frame_pixel_source( - np.tile((1, 1, 1, 0), (1, 2, 1)), depth=2, active=False - ) - foreground = self._frame_pixel_source( - np.asarray([[(1, 0, 0, 1), (0, 0, 0, 0)]]) - ) - result = self.image_frame.composite_frame_pixels( - (foreground, white), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1) - ) - np.testing.assert_allclose(result, (1, 0.5, 0.5, 0.5)) - - - def test_frame_raw_sampling_and_empty_canvas_are_chunk_independent(self): - import numpy as np - - rgba = np.tile((0, 1, 0, 0), (4, 4, 1)).astype(np.float32) - rgba[1:3, 2:] = (1, 0, 0, 1) - source = self._frame_pixel_source(rgba) - frame = ((-3, 7), (7, 7), (7, -3), (-3, -3)) - results = [] - for rows in (1, 2, 256): - with patch.object(self.image_frame, "PROJECTIVE_CHUNK_ROWS", rows): - results.append(np.flipud(self.image_frame.composite_frame_pixels( - (source,), frame, (10, 10) - ).reshape((10, 10, 4)))) - for result in results: - np.testing.assert_array_equal(result, results[0]) - np.testing.assert_allclose(result[3:7, 3:7], rgba) - np.testing.assert_array_equal(result[4, 7], 0) - np.testing.assert_array_equal(result[0], 0) - transparent = np.asarray([[(1, 0, 0, 0), (0, 0, 1, 0)]], dtype=np.float32) - source = self._frame_pixel_source(transparent) - framed = self.image_frame.composite_frame_pixels( - (source,), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1) - ) - np.testing.assert_allclose(framed, (0.5, 0, 0.5, 0)) - sampled = self.image_data.sample_rgba( - transparent, np.asarray(((1, 0.5), (-1, 0.5))) - ) - np.testing.assert_allclose(sampled, ((0.5, 0, 0.5, 0), (0, 0, 0, 0))) - transparent[0, 0, 3] = 1 - source = self._frame_pixel_source(transparent) - framed = self.image_frame.composite_frame_pixels( - (source,), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1) - ) - np.testing.assert_allclose(framed, (0.5, 0, 0.5, 0.5)) - - def test_frame_uses_selected_still_images_and_ignores_other_objects(self): active = SimpleNamespace( name="Active", @@ -888,23 +325,3 @@ class FrameToolTest(BlenderTestCase): create_result.assert_not_called() replace_result.assert_not_called() self.assertIs(active.data, active_image) - - - def _frame_pixel_source(self, rgba, *, depth=1.0, name="Active", active=True): - import numpy as np - - rgba = np.asarray(rgba, dtype=np.float32) - height, width = rgba.shape[:2] - world = np.identity(4) - world[2, 3] = -depth - source = self.image_frame.frame_source_projection( - np.identity(4), np.identity(4), world, - (-1.0, 1.0, -1.0, 1.0), (width, height), perspective=False, - ) - source.update( - active=active, name=name, image_size=(width, height), rgba=rgba, - pixels=self.image_data.premultiplied_rgba( - np.flipud(rgba).ravel(), (width, height) - ), - ) - return source diff --git a/tests/anyimage/operators/test_hdr_background.py b/tests/anyimage/operators/test_hdr_background.py index 51adb61..73e88d0 100644 --- a/tests/anyimage/operators/test_hdr_background.py +++ b/tests/anyimage/operators/test_hdr_background.py @@ -104,12 +104,12 @@ def test_hdr_request_uses_alpha_output_and_current_still(hdr_texture): operation = operators.RunBackgroundRemoval snapshot = HdrBackgroundInput.prepare(hdr_texture.image) operator = SimpleNamespace(input_path=str(snapshot.path), _hdr_input=snapshot, - _image_target=SimpleNamespace(start_frame=17)) - context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=12))) + _image_target=SimpleNamespace()) + context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=SimpleNamespace())) try: with patch.object(operators, "require_environment"), patch.object(operators, "require_model"), patch.object(operators, "production_device", return_value="CPU"): parameters = operation.request(operator, context) assert parameters["output_kind"] == "alpha" - assert parameters["start_frame"] == parameters["video_frames"] == 1 + assert "start_frame" not in parameters and "video_frames" not in parameters finally: cleanup_image_input(snapshot.path, True) diff --git a/tests/anyimage/operators/test_image_edit_integration.py b/tests/anyimage/operators/test_image_edit_integration.py index f2d2b6e..aeee090 100644 --- a/tests/anyimage/operators/test_image_edit_integration.py +++ b/tests/anyimage/operators/test_image_edit_integration.py @@ -7,7 +7,8 @@ from mathutils import Matrix from anyimage.common import image as image_data from anyimage.common.selection import SelectionMask from anyimage.operators import mask_tool as edit_mask -from anyimage.operators import frame_tool as edit_frame +from anyimage.operators.frame_tool import operators as edit_frame +from anyimage.operators.frame_tool.compositing import composite_frame_pixels def test_image_edit_result_uses_complete_shared_source_name(): @@ -58,7 +59,7 @@ def test_image_edit_result_recovers_unshared_source_name(): assert bpy.data.images.get("Poster.png") == result_image -def test_mask_frame_mask_add_preserves_packed_hidden_rgb(): +def test_mask_frame_mask_extend_preserves_packed_hidden_rgb(): from io import BytesIO from PIL import Image @@ -87,7 +88,7 @@ def test_mask_frame_mask_add_preserves_packed_hidden_rgb(): ) np.testing.assert_allclose(descriptor["rgba"][..., :3], expected[..., :3]) np.testing.assert_array_equal(descriptor["pixels"], 0) - framed = edit_frame.composite_frame_pixels( + framed = composite_frame_pixels( (descriptor,), ((0, 4), (4, 4), (4, 0), (0, 0)), (4, 4) ) result = image_data.create_image_edit_result(source.data, framed, (4, 4)) @@ -100,7 +101,7 @@ def test_mask_frame_mask_add_preserves_packed_hidden_rgb(): np.testing.assert_array_equal(packed[..., 3], 0) result.reload() assert result.alpha_mode == "STRAIGHT" - operator.mode = "ADD" + operator.mode = "EXTEND" assert edit_mask.EditImageAlpha._apply_mask( operator, SimpleNamespace(), source, selection ) == {"FINISHED"} @@ -140,7 +141,7 @@ def test_frame_restored_alpha_keeps_foreground_blended_into_white(): source_alpha = front["rgba"][..., 3:4] expected = front["rgba"][..., :3] * source_alpha + 1 - source_alpha # The image aspect makes its orthographic screen projection two thirds of a pixel high. - pixels = edit_frame.composite_frame_pixels( + pixels = composite_frame_pixels( sources, ((0, 4 / 3), (6, 4 / 3), (6, 2 / 3), (0, 2 / 3)), (6, 2) ) result = image_data.create_image_edit_result(front["object"].data, pixels, (6, 2)) @@ -149,7 +150,7 @@ def test_frame_restored_alpha_keeps_foreground_blended_into_white(): np.testing.assert_allclose(encoded[..., :3], expected, atol=1 / 255) np.testing.assert_allclose(encoded[..., 3:4], source_alpha, atol=1 / 255) selection = SelectionMask(np.ones((2, 6), dtype=np.float32), (0, 0, 6, 2)) - operator = SimpleNamespace(mode="ADD", report=lambda *_args: None) + operator = SimpleNamespace(mode="EXTEND", report=lambda *_args: None) assert edit_mask.EditImageAlpha._apply_mask( operator, SimpleNamespace(), front["object"], selection ) == {"FINISHED"} diff --git a/tests/anyimage/operators/test_mask_tool.py b/tests/anyimage/operators/test_mask_tool.py index 7fa42aa..885b495 100644 --- a/tests/anyimage/operators/test_mask_tool.py +++ b/tests/anyimage/operators/test_mask_tool.py @@ -46,7 +46,7 @@ class MaskToolTest(BlenderTestCase): def test_mask_freezes_scene_settings_on_invoke(self): settings = SimpleNamespace( mask_gesture="BRUSH", - mask_mode="ADD", + mask_mode="EXTEND", mask_radius=64, ) context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings)) @@ -55,52 +55,22 @@ class MaskToolTest(BlenderTestCase): self.edit_mask.EditImageAlpha._read_gesture_settings(operator, context) self.assertEqual(operator.gesture, "BRUSH") - self.assertEqual(operator.mode, "ADD") + self.assertEqual(operator.mode, "EXTEND") self.assertEqual(operator.radius, 64) - def test_mask_has_no_letter_option_shortcut(self): - operator = SimpleNamespace( - gesture="LASSO", - mode="SUBTRACT", - radius=25, - active_tool_id="anyimage.mask", - ) - context = SimpleNamespace( - workspace=SimpleNamespace(status_text_set=Mock()) - ) - event = SimpleNamespace(type="F", value="PRESS") - - with patch.object( - self.image_interaction, - "active_view3d_tool_id", - return_value=None, - ): - result = self.image_interaction.ImageGesture.modal( - operator, - context, - event, - ) - self.edit_mask.EditImageAlpha._set_status(operator, context) - - self.assertEqual(result, {"PASS_THROUGH"}) - status = context.workspace.status_text_set.call_args.args[0] - self.assertIn("Subtract", status) - self.assertNotIn("F", status) - - def test_mask_polyline_status_explains_point_controls(self): context = SimpleNamespace( workspace=SimpleNamespace(status_text_set=Mock()) ) - operator = SimpleNamespace(gesture="POLYLINE", mode="ADD", radius=25) + operator = SimpleNamespace(gesture="POLYLINE", mode="EXTEND", radius=25) self.edit_mask.EditImageAlpha._set_status(operator, context) status = context.workspace.status_text_set.call_args.args[0] self.assertIn("click start, double-click, or Enter", status) self.assertIn("Backspace removes", status) - self.assertIn("Add", status) + self.assertIn("Extend", status) def test_mask_and_rectify_start_after_an_empty_pick(self): @@ -147,8 +117,8 @@ class MaskToolTest(BlenderTestCase): ), patch.object( self.image_interaction, - "video_total_frames", - return_value=1, + "is_animated_image", + return_value=False, ), patch.object( self.image_interaction, @@ -169,8 +139,8 @@ class MaskToolTest(BlenderTestCase): ), patch.object( self.rectify, - "video_total_frames", - return_value=1, + "is_animated_image", + return_value=False, ), patch.object( self.rectify, @@ -325,7 +295,7 @@ class MaskToolTest(BlenderTestCase): full_mask = mask.full_values((3, 2)) expected_alpha = { "SET": source[:, :, 3] * full_mask, - "ADD": np.maximum(source[:, :, 3], full_mask), + "EXTEND": np.maximum(source[:, :, 3], full_mask), "SUBTRACT": source[:, :, 3] * (1.0 - full_mask), } @@ -349,7 +319,7 @@ class MaskToolTest(BlenderTestCase): transparent = np.flipud(removed.reshape(source.shape)) self.assertFalse(np.any(transparent[:, :, 3])) - added = self.edit_mask.apply_alpha_mask(transparent, mask, "ADD") + added = self.edit_mask.apply_alpha_mask(transparent, mask, "EXTEND") restored = np.flipud(added.reshape(source.shape)) np.testing.assert_array_equal(restored[:, :, 3], 1.0) @@ -1014,7 +984,7 @@ class MaskToolTest(BlenderTestCase): def test_mask_brush_overlay_matches_lasso_fill_and_outline(self): for mode, color in ( ("SUBTRACT", (1.0, 0.25, 0.2, 0.16)), - ("ADD", (1.0, 1.0, 1.0, 0.16)), + ("EXTEND", (1.0, 1.0, 1.0, 0.16)), ): cache = Mock() operator = SimpleNamespace( diff --git a/tests/anyimage/operators/test_plane_conversion.py b/tests/anyimage/operators/test_plane_conversion.py index eea681d..c89c69c 100644 --- a/tests/anyimage/operators/test_plane_conversion.py +++ b/tests/anyimage/operators/test_plane_conversion.py @@ -38,7 +38,7 @@ def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surf modifier = result.modifiers[0] assert modifier[inputs["Thickness"].identifier] == 0 assert modifier[inputs["Depth Image"].identifier] == image - assert modifier[inputs["Valid Only"].identifier] is True + assert modifier[inputs["Depth Mask"].identifier] is True assert modifier[inputs["Reference Depth"].identifier] == pytest.approx( 2 * modifier[inputs["Uniform Scale"].identifier]) assert result.data.materials[0] == material diff --git a/tests/anyimage/operators/test_plane_operators.py b/tests/anyimage/operators/test_plane_operators.py index 904f9a5..0873c74 100644 --- a/tests/anyimage/operators/test_plane_operators.py +++ b/tests/anyimage/operators/test_plane_operators.py @@ -55,7 +55,6 @@ class PlaneOperatorsTest(BlenderTestCase): patch.object(module.bpy, "ops", SimpleNamespace(anyimage=SimpleNamespace(generate_depth_plane=generate)), create=True), ): self.assertEqual(operator_class.execute(operator, context), {"FINISHED"}) - self.assertNotIn("depth_mode", generate.call_args.kwargs) self.assertEqual(generate.call_args.kwargs["source_object_name"], "Source") self.assertEqual(generate.call_args.kwargs["plane_type"], plane_type) diff --git a/tests/anyimage/operators/test_rectify_tool.py b/tests/anyimage/operators/test_rectify_tool.py index 33f8b30..0109bcb 100644 --- a/tests/anyimage/operators/test_rectify_tool.py +++ b/tests/anyimage/operators/test_rectify_tool.py @@ -84,7 +84,7 @@ class RectifyToolTest(BlenderTestCase): colorspace_settings=SimpleNamespace(name="Non-Color"), ) try: - result = self.rectify.create_perspective_preview_image( + result = self.rectify_preview.create_perspective_preview_image( source, pixels ) finally: @@ -99,10 +99,10 @@ class RectifyToolTest(BlenderTestCase): def test_rectify_preview_skips_cursor_at_last_clicked_point(self): points = ((10.0, 10.0), (80.0, 10.0), (70.0, 60.0)) - stationary = self.rectify.interactive_preview_polygon( + stationary = self.rectify_preview.interactive_preview_polygon( points, points[-1] ) - moving = self.rectify.interactive_preview_polygon( + moving = self.rectify_preview.interactive_preview_polygon( points, (20.0, 70.0) ) @@ -111,7 +111,7 @@ class RectifyToolTest(BlenderTestCase): def test_rectify_draws_scene_linear_texture_as_srgb_in_blender_5(self): - draw_options = self.rectify.preview_texture_draw_options + draw_options = self.rectify_preview.preview_texture_draw_options self.assertEqual(draw_options((4, 5, 3)), {}) self.assertEqual( @@ -126,7 +126,7 @@ class RectifyToolTest(BlenderTestCase): top_down = np.zeros((4, 4, 4), dtype=np.float32) top_down[1:3, 1:3] = (0.2, 0.4, 0.6, 1.0) pixels, output_size, placement_bounds = ( - self.rectify.extract_perspective_pixels( + self.rectify_geometry.extract_perspective_pixels( np.flipud(top_down).ravel(), (4, 4), ((0, 0), (4, 0), (4, 4), (0, 4)), @@ -142,7 +142,7 @@ class RectifyToolTest(BlenderTestCase): def test_rectify_rejects_non_convex_corners(self): with self.assertRaisesRegex(ValueError, "convex"): - self.rectify.validate_perspective_quad( + self.rectify_geometry.validate_perspective_quad( ((0, 0), (10, 0), (4, 4), (0, 10)) ) @@ -153,16 +153,16 @@ class RectifyToolTest(BlenderTestCase): import numpy as np corners = ((10, 20), (80, 15), (90, 70), (5, 75)) - ordered = self.rectify.canonical_perspective_quad(corners) + ordered = self.rectify_geometry.canonical_perspective_quad(corners) for permutation in itertools.permutations(corners): np.testing.assert_allclose( - self.rectify.canonical_perspective_quad(permutation), + self.rectify_geometry.canonical_perspective_quad(permutation), ordered, ) def test_rectify_detects_source_image_overlap(self): - overlaps = self.rectify.perspective_quad_overlaps_image + overlaps = self.rectify_geometry.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))) @@ -182,22 +182,22 @@ class RectifyToolTest(BlenderTestCase): def test_rectify_mouse_adjusts_preview_aspect_exponentially(self): - adjust = self.rectify.aspect_ratio_from_mouse + adjust = self.rectify_preview.aspect_ratio_from_mouse self.assertAlmostEqual(adjust(1.5, 240.0), 3.0) self.assertAlmostEqual(adjust(1.5, -240.0), 0.75) self.assertEqual( adjust(1.0, 100000.0), - self.rectify.MAX_INTERACTIVE_ASPECT, + self.rectify_geometry.MAX_INTERACTIVE_ASPECT, ) self.assertEqual( - self.rectify.snapped_aspect_ratio(1.7), + self.rectify_preview.snapped_aspect_ratio(1.7), (16.0 / 9.0, "16 : 9"), ) def test_rectify_preview_is_centered_at_requested_aspect(self): - left, bottom, width, height = self.rectify.preview_draw_bounds( + left, bottom, width, height = self.rectify_preview.preview_draw_bounds( (1000, 800), 2.0 ) @@ -207,7 +207,7 @@ class RectifyToolTest(BlenderTestCase): def test_rectify_uses_quad_edges_for_initial_aspect(self): - ratio = self.rectify.perspective_quad_aspect( + ratio = self.rectify_geometry.perspective_quad_aspect( ((0, 0), (8, 0), (8, 4), (0, 4)) ) @@ -228,7 +228,7 @@ class RectifyToolTest(BlenderTestCase): def test_rectify_maps_crop_to_source_plane_bounds(self): - bounds = self.rectify.perspective_placement_bounds( + bounds = self.rectify_geometry.perspective_placement_bounds( ((10, 20), (30, 20), (30, 40), (10, 40)), (100, 50), (-10, -5, 110, 55), diff --git a/tests/anyimage/operators/test_texture_node_actions.py b/tests/anyimage/operators/test_texture_node_actions.py index 08f2a3d..98f41b7 100644 --- a/tests/anyimage/operators/test_texture_node_actions.py +++ b/tests/anyimage/operators/test_texture_node_actions.py @@ -51,7 +51,7 @@ def test_shader_editor_pin_and_shared_group_target(shader_editor, tmp_path): source = nested.image target = ImageEditTarget.capture(context) write_result(tmp_path) - target.apply(tmp_path) + target.apply(tmp_path / "foreground.png") assert nested.image == source assert tuple(source.size) == (8, 6) assert second_instance.node_tree.nodes[nested.name].image == source @@ -98,23 +98,23 @@ def test_texture_poll_applies_busy_and_size_limits(texture, busy, limit, expecte assert remove_background.RemoveImageBackground.poll(context) == (not busy) -@pytest.mark.parametrize("operation,prefix", [(upscale.UpscaleImage, "upscale."), (remove_background.RunBackgroundRemoval, "image.")]) +@pytest.mark.parametrize("operation,filename", [(upscale.UpscaleImage, "upscale.png"), (remove_background.RunBackgroundRemoval, "foreground.png")]) @pytest.mark.parametrize("shared", [False, True]) -def test_texture_action_undo_redo_restores_image_and_binding(texture, tmp_path, operation, prefix, shared): +def test_texture_action_undo_redo_restores_image_and_binding(texture, tmp_path, operation, filename, shared): source_name = texture.image.name source_pixels = images.image_pixels(texture.image) if shared: other = texture.id_data.nodes.new("ShaderNodeTexImage") other.image = texture.image other.name = "Other texture" - write_result(tmp_path, prefix) + write_result(tmp_path, filename) def execute(operator, context): node = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"] operator._image_target = ImageEditTarget.capture(texture_context(node)) if operation == upscale.UpscaleImage: operator.model = "HAT_GAN_X4_SHARPER" - operator.response(context, SimpleNamespace(directory=tmp_path)) + operator.response(context, SimpleNamespace(file=lambda key: tmp_path / f"{key}.png")) return {"FINISHED"} bpy.context.preferences.edit.use_global_undo = True @@ -145,22 +145,23 @@ def test_texture_action_undo_redo_restores_image_and_binding(texture, tmp_path, bpy.utils.unregister_class(operation) -@pytest.mark.parametrize("operation,module,prefix", [ - (upscale.UpscaleImage, upscale, "upscale."), - (remove_background.RunBackgroundRemoval, remove_background, "image."), +@pytest.mark.parametrize("operation,module,filename", [ + (upscale.UpscaleImage, upscale, "upscale.png"), + (remove_background.RunBackgroundRemoval, remove_background, "foreground.png"), ]) @pytest.mark.parametrize("outcome", ["succeeded", "cancelled", "failed", "invalid_target"]) -def test_texture_job_request_and_completion(texture, tmp_path, operation, module, prefix, outcome): +def test_texture_job_request_and_completion(texture, tmp_path, operation, module, filename, outcome): context = texture_context(texture) context.window = object() context.window_manager = SimpleNamespace( progress_begin=Mock(), progress_end=Mock(), event_timer_add=Mock(return_value=object()), event_timer_remove=Mock(), modal_handler_add=Mock(), ) - write_result(tmp_path, prefix) + write_result(tmp_path, filename) controller = SimpleNamespace( submit=Mock(return_value={"job_id": "texture-job", "directory": str(tmp_path)}), - status=Mock(return_value={"state": "failed" if outcome == "invalid_target" else outcome}), + status=Mock(return_value={"state": "failed" if outcome == "invalid_target" else outcome, + "result": {Path(filename).stem: filename}}), mark_job_complete=Mock(), cancel=Mock(), ) original_pixels = images.image_pixels(texture.image) @@ -183,7 +184,7 @@ def test_texture_job_request_and_completion(texture, tmp_path, operation, module assert not job.start_thread.is_alive() kind, parameters = controller.submit.call_args.args assert kind == operation.job_type - assert parameters["start_frame"] == 1 + assert "start_frame" not in parameters and "video_frames" not in parameters assert parameters["device"] == "cpu" assert parameters["model"] == ("HAT_GAN_X4_SHARPER" if operation == upscale.UpscaleImage else "BIREFNET_LITE") input_path = operator.input_path @@ -193,7 +194,7 @@ def test_texture_job_request_and_completion(texture, tmp_path, operation, module texture.id_data.nodes.active = other if outcome == "invalid_target": texture.image = None - controller.status.return_value = {"state": "succeeded"} + controller.status.return_value = {"state": "succeeded", "result": {Path(filename).stem: filename}} bpy.ops.ed.undo_push(message="Before asynchronous texture result") result = operator.modal(context, SimpleNamespace(type="TIMER")) assert result == ({"FINISHED"} if outcome == "succeeded" else {"CANCELLED"}) diff --git a/tests/anyimage/operators/test_upscale_undo.py b/tests/anyimage/operators/test_upscale_undo.py index 5f2f9b1..b7280e2 100644 --- a/tests/anyimage/operators/test_upscale_undo.py +++ b/tests/anyimage/operators/test_upscale_undo.py @@ -19,12 +19,12 @@ def test_upscale_result_replacement_undo_restores_source(tmp_path): bpy.context.scene.collection.objects.link(owner) owner_name, source_name = owner.name, source.name before = image_pixels(source) - Image.new("RGBA", (16,16), (32,64,96,128)).save(tmp_path / "upscale.0001.png") + Image.new("RGBA", (16,16), (32,64,96,128)).save(tmp_path / "upscale.png") def execute(operator, context): operator._image_target = ImageEditTarget.capture(SimpleNamespace(object=owner)) operator.model = "HAT_GAN_X4_SHARPER" - operator.response(context, SimpleNamespace(directory=tmp_path)) + operator.response(context, SimpleNamespace(file=lambda key: tmp_path / f"{key}.png")) return {"FINISHED"} bpy.context.preferences.edit.use_global_undo = True diff --git a/tests/anyimage/server/runtime_support.py b/tests/anyimage/server/runtime_support.py index 03d5d4c..71f6fc3 100644 --- a/tests/anyimage/server/runtime_support.py +++ b/tests/anyimage/server/runtime_support.py @@ -1,28 +1,14 @@ -from tests.support.paths import PROJECT_ROOT -import importlib.util -import sys +import importlib import tempfile import time import unittest from pathlib import Path -def load_server_module(): - server_directory = PROJECT_ROOT / "src" / "anyimage" / "server" - specification = importlib.util.spec_from_file_location( - "server", - server_directory / "__init__.py", - submodule_search_locations=[str(server_directory)], - ) - module = importlib.util.module_from_spec(specification) - sys.modules["server"] = module - specification.loader.exec_module(module) - return module - class ServerTestCase(unittest.TestCase): @classmethod def setUpClass(cls): - cls.server_module = load_server_module() + cls.server_module = importlib.import_module("server") importlib.import_module("server.geometry.prediction_artifacts") importlib.import_module("server.jobs.cutout") from blendjob import JobServer @@ -33,8 +19,7 @@ class ServerTestCase(unittest.TestCase): cls.ModelManager, cls.ModelSessionCache = ModelManager, ModelSessionCache cls.onnx_runtime = onnx_runtime - cls.server = cls.server_module.server - cls.app_module = sys.modules["server.app"] + cls.app_module = importlib.import_module("server.app") def setUp(self): @@ -50,6 +35,7 @@ class ServerTestCase(unittest.TestCase): def create_server(self): source = self.server_module.server server = self.JobServer("AnyImage Job Server", storage_root=self.storage_root) + self.addCleanup(server.close) server.handlers.update(source.handlers) server.resource_factories.update(source.resource_factories) server.bind(self.storage_root) diff --git a/tests/anyimage/server/support.py b/tests/anyimage/server/support.py index 5468356..d7b47eb 100644 --- a/tests/anyimage/server/support.py +++ b/tests/anyimage/server/support.py @@ -1,7 +1,6 @@ from tests.support.paths import PROJECT_ROOT import sys from pathlib import Path -from types import SimpleNamespace def load_server_module(name): @@ -13,49 +12,6 @@ def load_server_module(name): sys.path.remove(str(addon_directory)) -def _make_fake_av(frame_count): - """Build a fake av module yielding frame_count integer-valued frames.""" - class FakeImage: - def __init__(self, value): - self.value = value - - def save(self, path, format=None): - Path(path).write_text(str(self.value), encoding="utf-8") - - class FakeFrame: - def __init__(self, value): - self.value = value - - def to_image(self): - return FakeImage(self.value) - - class FakeContainer: - def __init__(self): - self.position = 0 - self.closed = False - self.streams = SimpleNamespace(video=[object()]) - - def decode(self, _stream): - while self.position < frame_count: - frame = FakeFrame(self.position) - self.position += 1 - yield frame - - def close(self): - self.closed = True - - class FakeAv: - def __init__(self): - self.containers = [] - - def open(self, _path): - container = FakeContainer() - self.containers.append(container) - return container - - return FakeAv() - - class FakeModels: def __init__(self, directory="BEN2-ONNX"): self.model_directory = Path(directory) diff --git a/tests/anyimage/server/test_background_models.py b/tests/anyimage/server/test_background_models.py index 06b963d..949c651 100644 --- a/tests/anyimage/server/test_background_models.py +++ b/tests/anyimage/server/test_background_models.py @@ -13,19 +13,14 @@ from tests.anyimage.server.support import FakeJobContext @pytest.mark.parametrize("key", model_catalog.BACKGROUND_MODELS) -@pytest.mark.parametrize("kind", ["foreground", "frames", "alpha"]) -def test_models_dispatch_keep_alpha_and_reuse_sessions(tmp_path, key, kind): +@pytest.mark.parametrize("kind", ["foreground", "alpha"]) +def test_models_dispatch_keep_alpha(tmp_path, key, kind): source = tmp_path / "source.png" Image.new("RGBA", (7, 5), (60, 120, 180, 128)).save(source) - if kind == "frames": - source = tmp_path / "frames" - source.mkdir() - for index in (1, 2): - Image.new("RGBA", (7, 5), (60, 120, 180, 128)).save(source / f"{index}.png") manager = ModelManager(tmp_path / "models") context = FakeJobContext(tmp_path / "job", "remove-background", manager) adapter = background.model_adapter(manager.directory(key)) - parameters = {"input": str(source), "model": key, "device": "directml", "video_frames": 2} + parameters = {"input": str(source), "model": key, "device": "directml"} if kind == "alpha": parameters["output_kind"] = kind with patch.object(adapter, "create_session", return_value=object()) as create, patch.object( @@ -33,7 +28,7 @@ def test_models_dispatch_keep_alpha_and_reuse_sessions(tmp_path, key, kind): ) as infer: remove_background.run(context, parameters) create.assert_called_once_with(manager.directory(key), "cpu" if key == "BIREFNET_HR_MATTING" else "directml") - assert infer.call_count == (2 if kind == "frames" else 1) + infer.assert_called_once() if kind == "alpha": np.testing.assert_allclose(np.load(context.directory / "alpha.npy"), .4) else: @@ -63,13 +58,3 @@ def test_readiness_checks_model_and_license_corruption(tmp_path, key): assert manager.ready(key) (directory / "LICENSE.txt").write_bytes(b"damaged!") assert not manager.ready(key) - - -@pytest.mark.parametrize("key", ["PISA_SR", "SAFMN_REAL_X4", "MOGE2_VITL_NORMAL"]) -def test_retired_job_keys_fail_before_loading(key): - from server.models import upscale - - with pytest.raises(ValueError, match="Unknown upscale model"): - upscale.infer_one(None, {"model": key}, "unused.png") - with pytest.raises(ValueError, match="Unknown background removal model"): - remove_background.run(None, {"model": key}) diff --git a/tests/anyimage/server/test_geometry_artifacts.py b/tests/anyimage/server/test_geometry_artifacts.py index e46db3e..a98c62a 100644 --- a/tests/anyimage/server/test_geometry_artifacts.py +++ b/tests/anyimage/server/test_geometry_artifacts.py @@ -149,73 +149,6 @@ class GeometryArtifactsTest(ServerTestCase): np.testing.assert_array_equal(outputs[0], first[..., :3]) - def test_geometry_writers_reject_missing_or_non_finite_fields(self): - import numpy as np - - depth = importlib.import_module("server.geometry.depth_texture") - normal = importlib.import_module("server.geometry.normal_texture") - GeometryFrame = importlib.import_module( - "server.models.geometry" - ).GeometryFrame - base = { - "depth": np.ones((1, 2), dtype=np.float32), - "validity": np.ones((1, 2), dtype=np.float32), - "intrinsics": np.eye(3, dtype=np.float32), - } - with self.assertRaisesRegex(ValueError, "point field"): - depth.write_depth_texture( - GeometryFrame(**base), - self.storage_root / "missing-points.exr", - alpha=base["validity"], - ) - point_frame = GeometryFrame( - **base, - points=np.asarray([[(0.0, 0.0, 1.0), (np.nan, 0.0, 1.0)]], dtype=np.float32), - ) - with self.assertRaisesRegex(ValueError, "Point field"): - depth.write_depth_texture( - point_frame, - self.storage_root / "nan-points.exr", - alpha=base["validity"], - ) - depth_frame = GeometryFrame( - **base, points=np.asarray([[(0, 0, 1), (0, 0, np.inf)]], dtype=np.float32), - ) - with self.assertRaisesRegex(ValueError, "Point field"): - depth.write_depth_texture( - depth_frame, - self.storage_root / "inf-depth.exr", - alpha=base["validity"], - ) - with self.assertRaisesRegex(ValueError, "normal field"): - normal.write_camera_normal_texture( - GeometryFrame(**base), - np.ones((1, 2), dtype=bool), - (0, 0), - (2, 1), - (2, 1), - self.storage_root / "missing-normal.png", - ) - normal_frame = GeometryFrame( - **base, - normal=np.asarray( - [[(0.0, 0.0, 1.0), (0.0, np.nan, 1.0)]], - dtype=np.float32, - ), - ) - with self.assertRaisesRegex(ValueError, "Normal field"): - normal.write_camera_normal_texture( - normal_frame, - np.ones((1, 2), dtype=bool), - (0, 0), - (2, 1), - (2, 1), - self.storage_root / "nan-normal.png", - ) - self.assertFalse(any(self.storage_root.glob("*.exr"))) - self.assertFalse(any(self.storage_root.glob("*-normal.png"))) - - def test_moge_artifact_orchestration_uses_one_geometry_frame(self): import numpy as np from PIL import Image @@ -246,7 +179,7 @@ class GeometryArtifactsTest(ServerTestCase): texture = importlib.import_module("server.geometry.depth_texture") with ( - patch.object(artifacts.moge, "infer", return_value=[frame]) as infer, + patch.object(artifacts.moge, "infer", return_value=frame) as infer, patch.object(texture, "write_float_exr", wraps=texture.write_float_exr) as write, ): result = artifacts.generate_moge_artifacts( diff --git a/tests/anyimage/server/test_input_validation.py b/tests/anyimage/server/test_input_validation.py index 832e6c7..6283a43 100644 --- a/tests/anyimage/server/test_input_validation.py +++ b/tests/anyimage/server/test_input_validation.py @@ -1,5 +1,3 @@ -from types import SimpleNamespace -from unittest.mock import Mock import pytest from PIL import Image @@ -12,54 +10,19 @@ def media(): return load_server_module("media.input") -@pytest.mark.parametrize("suffix", [".PNG", ".MP4", ".webp", ".mts"]) +@pytest.mark.parametrize("suffix", [".PNG", ".JPG", ".webp", ".jpeg"]) def test_supported_format_is_case_insensitive(media, tmp_path, suffix): path = tmp_path / f"input{suffix}" path.touch() assert media.validate_input_path(path) == path -@pytest.mark.parametrize("suffix", [".tiff", ""]) -def test_frontend_and_backend_share_format_reason(media, tmp_path, suffix): - path = tmp_path / f"input{suffix}" - path.touch() - with pytest.raises(RuntimeError) as preflight: - media.validate_input_path(path) - with pytest.raises(RuntimeError) as backend: - media.prepare_input(path, 16, tmp_path) - assert str(preflight.value) == str(backend.value) - assert f"Unsupported input format: {suffix or '(no extension)'}" in str(backend.value) - assert "Supported images: .jpeg, .jpg, .png, .webp." in str(backend.value) - assert "Supported videos:" in str(backend.value) - - -@pytest.mark.parametrize("suffix,kind", [(".png", "image"), (".mp4", "video")]) +@pytest.mark.parametrize("suffix,kind", [(".png", "image"), (".mp4", "image")]) def test_missing_path_precedes_format_validation(media, tmp_path, suffix, kind): with pytest.raises(RuntimeError, match=f"Source {kind} file does not exist"): media.validate_input_path(tmp_path / f"missing{suffix}") -def test_directory_filtering_and_empty_reason(media, tmp_path): - (tmp_path / "note.txt").touch() - (tmp_path / "frame.exr").touch() - with pytest.raises(RuntimeError, match="No supported image frames found"): - media.validate_input_path(tmp_path) - for name in ["b.PNG", "a.jpg"]: - (tmp_path / name).touch() - frames, temporary = media.prepare_input(tmp_path, 1, tmp_path, start_frame=2) - assert frames == [tmp_path / "b.PNG"] - assert temporary is None - - -def test_image_subset_rejects_video(media, tmp_path): - path = tmp_path / "clip.mp4" - path.touch() - with pytest.raises(RuntimeError) as error: - media.validate_input_path(path, images_only=True) - assert "Supported images:" in str(error.value) - assert "Supported videos:" not in str(error.value) - - def test_image_decode_error_keeps_cause(media, tmp_path): path = tmp_path / "broken.png" path.write_bytes(b"broken") @@ -79,15 +42,33 @@ def test_processing_error_is_not_relabelled(media, tmp_path): assert error.value is failure -def test_video_decode_error_closes_container(media, tmp_path): - failure = ValueError("broken packet") - def frames(): - raise failure - yield - container = SimpleNamespace(streams=SimpleNamespace(video=[object()]), - decode=lambda _stream: frames(), close=Mock()) - with pytest.raises(RuntimeError, match="Unable to read video") as error: - media.extract_video_frames("broken.mp4", tmp_path, 2, - SimpleNamespace(open=lambda _path: container)) - assert error.value.__cause__ is failure - container.close.assert_called_once() + + +@pytest.mark.parametrize("suffix", [".mp4", ".mts", ".gif", ".tiff", ""]) +def test_unsupported_formats_are_rejected(media, tmp_path, suffix): + path = tmp_path / f"input{suffix}" + path.touch() + with pytest.raises(RuntimeError, match="Unsupported input format"): + media.validate_input_path(path) + + +def test_frame_directory_is_rejected(media, tmp_path): + Image.new("RGB", (2, 2)).save(tmp_path / "frame.png") + with pytest.raises(RuntimeError, match="Source image file does not exist"): + media.validate_input_path(tmp_path) + + +def test_animated_png_is_rejected(media, tmp_path): + path = tmp_path / "animated.png" + Image.new("RGB", (2, 2), "red").save( + path, save_all=True, append_images=[Image.new("RGB", (2, 2), "blue")], duration=100, + ) + with pytest.raises(RuntimeError, match="Only still images"): + with media.open_image(path): + pytest.fail("Animated images must not reach processing") + + +def test_ai_tasks_support_cooperative_cancellation(media): + with pytest.raises(media.TaskCancelled): + media.check_cancelled(lambda: True) + media.check_cancelled(lambda: False) diff --git a/tests/anyimage/server/test_job_server.py b/tests/anyimage/server/test_job_server.py index 25415b6..6d6fccd 100644 --- a/tests/anyimage/server/test_job_server.py +++ b/tests/anyimage/server/test_job_server.py @@ -7,6 +7,7 @@ from tests.anyimage.server.runtime_support import ServerTestCase class JobServerTest(ServerTestCase): def test_dedicated_server_runs_only_one_job_at_a_time(self): server = self.JobServer("Test", storage_root=self.storage_root) + self.addCleanup(server.close) first_started = threading.Event() release_first = threading.Event() order = [] @@ -26,16 +27,15 @@ class JobServerTest(ServerTestCase): self.assertIsNotNone(second) self.assertTrue(server.snapshot("instance")["busy"]) self.assertEqual(server.snapshot("instance")["queued_jobs"], 1) - self.assertNotIn("clients", server.snapshot("instance")) release_first.set() self.wait_for_job(first) self.wait_for_job(second) self.assertEqual(order, ["first", "second"]) - server.close() def test_queued_job_can_be_cancelled_before_it_runs(self): server = self.JobServer("Test", storage_root=self.storage_root) + self.addCleanup(server.close) first_started = threading.Event() release_first = threading.Event() executed = [] @@ -58,7 +58,6 @@ class JobServerTest(ServerTestCase): self.assertEqual(second_status["state"], "cancelled") self.assertEqual(executed, ["first"]) - server.close() def test_cancelling_job_keeps_server_and_model_cache_alive(self): @@ -94,6 +93,7 @@ class JobServerTest(ServerTestCase): def test_job_progress_is_kept_in_server_memory(self): server = self.JobServer("Test", storage_root=self.storage_root) + self.addCleanup(server.close) @server.job("example") def example(context, _parameters): @@ -111,6 +111,7 @@ class JobServerTest(ServerTestCase): def test_runtime_storage_root_owns_the_jobs_directory(self): storage_root = self.storage_root server = self.JobServer("Test") + self.addCleanup(server.close) server.bind(storage_root) @server.job("example") @@ -133,6 +134,7 @@ class JobServerTest(ServerTestCase): close=lambda: events.append("close"), ) server = self.JobServer("Test", storage_root=self.storage_root) + self.addCleanup(server.close) server.add_resource("model_manager", resource) @server.job("example") diff --git a/tests/anyimage/server/test_media_input.py b/tests/anyimage/server/test_media_input.py deleted file mode 100644 index 2731e73..0000000 --- a/tests/anyimage/server/test_media_input.py +++ /dev/null @@ -1,167 +0,0 @@ -import tempfile -import unittest -from pathlib import Path -from tests.anyimage.server.support import load_server_module, _make_fake_av - - -class MediaInputTest(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.media = load_server_module("media.input") - - - def test_collect_images_is_sorted_and_filters_suffixes(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "b.PNG").touch() - (root / "a.jpg").touch() - (root / "notes.txt").touch() - - images = self.media.collect_images(root) - - self.assertEqual([path.name for path in images], ["a.jpg", "b.PNG"]) - - - def test_ai_tasks_support_cooperative_cancellation(self): - with self.assertRaises(self.media.TaskCancelled): - self.media.check_cancelled(lambda: True) - - self.media.check_cancelled(lambda: False) - - - def test_prepares_a_single_image_file(self): - with tempfile.TemporaryDirectory() as directory: - image_path = Path(directory) / "input.png" - image_path.touch() - - images, temporary_directory = self.media.prepare_input( - image_path, - 16, - directory, - ) - - self.assertEqual(images, [image_path]) - self.assertIsNone(temporary_directory) - - - def test_prepares_a_frame_directory(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "frame-0001.jpg").touch() - (root / "frame-0002.png").touch() - (root / "notes.txt").touch() - - images, temporary_directory = self.media.prepare_input( - root, - 16, - directory, - ) - - self.assertEqual( - [path.name for path in images], - ["frame-0001.jpg", "frame-0002.png"], - ) - self.assertIsNone(temporary_directory) - - - def test_prepares_first_frames_of_a_frame_directory(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - for index in range(1, 6): - (root / f"frame-{index:04d}.jpg").touch() - - images, temporary_directory = self.media.prepare_input( - root, - 3, - directory, - ) - - self.assertEqual( - [path.name for path in images], - ["frame-0001.jpg", "frame-0002.jpg", "frame-0003.jpg"], - ) - self.assertIsNone(temporary_directory) - - - def test_prepares_first_frames_after_offset_in_a_frame_directory(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - for index in range(1, 6): - (root / f"frame-{index:04d}.jpg").touch() - - images, temporary_directory = self.media.prepare_input( - root, - 3, - directory, - start_frame=2, - ) - - self.assertEqual( - [path.name for path in images], - ["frame-0002.jpg", "frame-0003.jpg", "frame-0004.jpg"], - ) - self.assertIsNone(temporary_directory) - - - def test_extract_video_frames_reads_frames_sequentially(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - fake_av = _make_fake_av(100) - image_paths = self.media.extract_video_frames( - root / "clip.mkv", - root / "out", - 10, - fake_av, - ) - - self.assertEqual( - [path.name for path in image_paths], - [f"frame.{index:04d}.jpg" for index in range(1, 11)], - ) - self.assertEqual(len(fake_av.containers), 1) - self.assertEqual(fake_av.containers[0].position, 10) - - - def test_extract_video_frames_starts_after_offset(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - fake_av = _make_fake_av(100) - image_paths = self.media.extract_video_frames( - root / "clip.mkv", - root / "out", - 10, - fake_av, - start_frame=71, - ) - extracted = [ - Path(path).read_text(encoding="utf-8") - for path in image_paths - ] - - self.assertEqual(extracted, [str(frame) for frame in range(70, 80)]) - self.assertEqual(fake_av.containers[0].position, 80) - - - def test_extract_video_frames_tolerates_short_video_tail(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - image_paths = self.media.extract_video_frames( - root / "clip.mkv", - root / "out", - 10, - _make_fake_av(99), - ) - - self.assertEqual( - [path.name for path in image_paths], - [f"frame.{index:04d}.jpg" for index in range(1, 11)], - ) - - - def test_rejects_unsupported_input_file(self): - with tempfile.TemporaryDirectory() as directory: - input_path = Path(directory) / "input.txt" - input_path.touch() - - with self.assertRaisesRegex(RuntimeError, "Unsupported input format: .txt"): - self.media.prepare_input(input_path, 16, directory) diff --git a/tests/anyimage/server/test_model_birefnet_onnx.py b/tests/anyimage/server/test_model_birefnet_onnx.py index caf7701..00a606b 100644 --- a/tests/anyimage/server/test_model_birefnet_onnx.py +++ b/tests/anyimage/server/test_model_birefnet_onnx.py @@ -23,14 +23,3 @@ def test_probability_alpha_keeps_transparency_and_source_dimensions(size): assert run.call_args.kwargs["release_memory"] is False feed = run.call_args.args[2]["image"] np.testing.assert_allclose(feed[0, :, 0, 0], (np.array([64, 128, 192]) / 255 - [.485, .456, .406]) / [.229, .224, .225], atol=1e-6) - - -@pytest.mark.parametrize("value", [np.nan, np.inf, -0.1, 1.1]) -def test_invalid_alpha_is_rejected(value): - session = SimpleNamespace( - get_inputs=lambda: [SimpleNamespace(name="image", shape=[1, 3, 1024, 1024])], - get_outputs=lambda: [SimpleNamespace(name="alpha")], - ) - with patch.object(onnx_birefnet, "run_session", return_value=[np.full((1, 1, 1024, 1024), value)]): - with pytest.raises(RuntimeError, match="invalid alpha"): - onnx_birefnet.infer_alpha(session, Image.new("RGB", (2, 3))) diff --git a/tests/anyimage/server/test_model_catalog.py b/tests/anyimage/server/test_model_catalog.py deleted file mode 100644 index 3075f8d..0000000 --- a/tests/anyimage/server/test_model_catalog.py +++ /dev/null @@ -1,104 +0,0 @@ -from tests.support.paths import PROJECT_ROOT -import importlib.util -import unittest - - -def load_module(name, relative_path): - specification = importlib.util.spec_from_file_location( - name, - PROJECT_ROOT / relative_path, - ) - module = importlib.util.module_from_spec(specification) - specification.loader.exec_module(module) - return module - - -class ModelCatalogTest(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.models = load_module( - "anyimage_server_models", - "src/anyimage/server/model_catalog.py", - ) - - - def test_unknown_model_is_rejected(self): - with self.assertRaisesRegex(ValueError, "Unknown model"): - self.models.get_downloadable_model("UNKNOWN") - - def test_three_tiers_and_retired_keys(self): - self.assertEqual(list(self.models.MOGE_MODELS), ["MOGE2_VITS_NORMAL", "MOGE2_VITB_NORMAL", "MOGE3_VITL"]) - self.assertEqual(list(self.models.BACKGROUND_MODELS), ["BIREFNET_LITE", "BEN2_BASE", "BIREFNET_HR_MATTING"]) - self.assertEqual(list(self.models.UPSCALE_MODELS), ["REALESRGAN_GENERAL_WDN_X4V3", "REALESRGAN_X4PLUS", "HAT_GAN_X4_SHARPER"]) - self.assertEqual(len(self.models.DOWNLOADABLE_MODELS), 9) - self.assertEqual(self.models.DEFAULT_MODEL_KEYS, ("MOGE2_VITS_NORMAL", "BIREFNET_LITE", "REALESRGAN_GENERAL_WDN_X4V3")) - for key in ("DA3_BASE", "DA3_SMALL", "MOGE2_VITL_NORMAL", "SAFMN_REAL_X4", "PISA_SR"): - with self.assertRaisesRegex(ValueError, "Unknown model"): - self.models.get_downloadable_model(key) - - def test_ben2_download_only_includes_required_hugging_face_files(self): - model = self.models.get_downloadable_model("BEN2_BASE") - - self.assertEqual(model.repository, "onnx-community/BEN2-ONNX") - self.assertEqual(model.license_name, "Apache 2.0") - self.assertEqual(model.download_patterns, ("onnx/model_fp16.onnx",)) - self.assertEqual(model.ready_patterns, ("onnx/model_fp16.onnx",)) - self.assertEqual(model.r2_files[0][0], "onnx/model_fp16.onnx") - - - def test_ben2_model_directory_uses_repository_name(self): - self.assertEqual( - self.models.get_downloadable_model("BEN2_BASE").directory_name, - "BEN2-ONNX", - ) - - def test_upscale_models_use_verified_onnx_artifacts(self): - expected = { - "REALESRGAN_GENERAL_WDN_X4V3": ("realesr-general-wdn-x4v3.onnx", 4867419, "b021a837"), - "HAT_GAN_X4_SHARPER": ("model.onnx", 45874438, "032d8b03"), - "REALESRGAN_X4PLUS": ("realesrgan-x4plus.onnx", 67051618, "4ed6a45a"), - } - self.assertEqual(set(self.models.UPSCALE_MODELS), set(expected)) - for key, (filename, size, checksum_prefix) in expected.items(): - with self.subTest(model=key): - model = self.models.get_downloadable_model(key) - self.assertIn(filename, model.download_patterns) - self.assertIn(filename, model.ready_patterns) - self.assertEqual(model.r2_files[0][:2], (filename, size)) - self.assertTrue(model.r2_files[0][2].startswith(checksum_prefix)) - self.assertEqual(self.models.model_record(model)["family"], "upscale") - wdn = self.models.UPSCALE_MODELS["REALESRGAN_GENERAL_WDN_X4V3"] - self.assertEqual(wdn.repository, "xinntao/Real-ESRGAN") - self.assertEqual(wdn.huggingface_repository, "") - self.assertEqual( - self.models.UPSCALE_MODELS["HAT_GAN_X4_SHARPER"].huggingface_repository, "" - ) - - - def test_moge2_models_use_official_onnx_models(self): - for key, variant in (("MOGE2_VITS_NORMAL", "vits"), - ("MOGE2_VITB_NORMAL", "vitb")): - with self.subTest(key=key): - model = self.models.get_downloadable_model(key) - self.assertEqual( - model.repository, - f"Ruicheng/moge-2-{variant}-normal-onnx", - ) - self.assertEqual(model.license_name, "MIT") - self.assertEqual(model.download_patterns, ("model.onnx",)) - self.assertEqual(model.ready_patterns, ("model.onnx",)) - self.assertEqual(model.r2_directory, model.repository.split("/")[-1]) - self.assertEqual(model.r2_files[0][0], "model.onnx") - - def test_moge2_models_match_uploaded_r2_variants(self): - self.assertEqual( - list(self.models.MOGE2_MODELS), - ["MOGE2_VITS_NORMAL", "MOGE2_VITB_NORMAL"], - ) - self.assertEqual( - [model.r2_directory for model in self.models.MOGE2_MODELS.values()], - [ - "moge-2-vits-normal-onnx", - "moge-2-vitb-normal-onnx", - ], - ) diff --git a/tests/anyimage/server/test_model_geometry.py b/tests/anyimage/server/test_model_geometry.py index acca780..908a21c 100644 --- a/tests/anyimage/server/test_model_geometry.py +++ b/tests/anyimage/server/test_model_geometry.py @@ -3,26 +3,9 @@ import numpy as np from anyimage.server.models import moge as moge2_model -from anyimage.server.models.geometry import GeometryFrame -def test_geometry_frame_requires_matching_float32_fields(): - with np.testing.assert_raises_regex(ValueError, "validity"): - GeometryFrame( - depth=np.ones((2, 2), dtype=np.float32), - validity=np.ones((1, 2), dtype=np.float32), - intrinsics=np.eye(3, dtype=np.float32), - ) - with np.testing.assert_raises_regex(ValueError, "normal"): - GeometryFrame( - depth=np.ones((2, 2), dtype=np.float32), - validity=np.ones((2, 2), dtype=np.float32), - intrinsics=np.eye(3, dtype=np.float32), - normal=np.ones((2, 2, 2), dtype=np.float32), - ) - - -def test_moge_infer_returns_geometry_frames_with_model_validity(): +def test_moge_infer_returns_geometry_frame_with_model_validity(): mask = np.asarray(((1.0, 0.2), (0.7, 0.0)), dtype=np.float32) prediction = { "depth": np.ones((2, 2), dtype=np.float32), @@ -36,10 +19,8 @@ def test_moge_infer_returns_geometry_frames_with_model_validity(): } with patch.object(moge2_model, "infer_one", return_value=prediction): - frames = moge2_model.infer(None, {}, ["image"], include_points=True) + frame = moge2_model.infer(None, {}, "image", include_points=True) - assert len(frames) == 1 - frame = frames[0] assert np.array_equal(frame.validity, mask.astype(np.float32)) assert np.array_equal(frame.normal, prediction["normal"]) assert np.array_equal(frame.points, prediction["points"]) @@ -59,7 +40,7 @@ def test_moge_z_depth_frame_does_not_require_points(): } with patch.object(moge2_model, "infer_one", return_value=prediction): - frame = moge2_model.infer(None, {}, ["image"], include_points=False)[0] + frame = moge2_model.infer(None, {}, "image", include_points=False) assert frame.points is None assert frame.validity.all() diff --git a/tests/anyimage/server/test_model_inference.py b/tests/anyimage/server/test_model_inference.py index 9890909..8e818bf 100644 --- a/tests/anyimage/server/test_model_inference.py +++ b/tests/anyimage/server/test_model_inference.py @@ -19,7 +19,7 @@ class ModelInferenceTest(unittest.TestCase): cls.onnx_upscale = load_server_module("models.onnx_upscale") - def test_moge2_batch_releases_only_after_final_image(self): + def test_moge2_infers_one_geometry_frame(self): prediction = { "points": np.ones((2, 2, 3), dtype=np.float32), "depth": np.ones((2, 2), dtype=np.float32), @@ -32,18 +32,15 @@ class ModelInferenceTest(unittest.TestCase): "infer_one", return_value=prediction, ) as infer_one: - frames = self.moge2_model.infer( + frame = self.moge2_model.infer( FakeModels(), {"model": "MOGE2_VITS_NORMAL", "device": "cpu"}, - [Path("a.png"), Path("b.png")], + Path("input.png"), include_points=True, ) - self.assertEqual(len(frames), 2) - self.assertEqual( - [call.kwargs["release_memory"] for call in infer_one.call_args_list], - [False, True], - ) + self.assertEqual(frame.depth.shape, (2, 2)) + infer_one.assert_called_once() def test_upscale_resource_error_releases_session_and_is_readable(self): diff --git a/tests/anyimage/server/test_model_manager.py b/tests/anyimage/server/test_model_manager.py index 7fb7809..fa8621d 100644 --- a/tests/anyimage/server/test_model_manager.py +++ b/tests/anyimage/server/test_model_manager.py @@ -73,7 +73,6 @@ class ModelManagerTest(ServerTestCase): }, ) self.assertIn("server.model_catalog", sys.modules) - self.assertNotIn("model_catalog", sys.modules) def test_background_model_is_reused_for_matching_runtime_key(self): diff --git a/tests/anyimage/server/test_model_onnx_runtime.py b/tests/anyimage/server/test_model_onnx_runtime.py index 6879218..3709e3a 100644 --- a/tests/anyimage/server/test_model_onnx_runtime.py +++ b/tests/anyimage/server/test_model_onnx_runtime.py @@ -122,12 +122,6 @@ class OnnxRuntimeTest(unittest.TestCase): ["CPUExecutionProvider"], ) - def test_old_mps_device_is_rejected(self): - with self.assertRaises(KeyError): - onnx_runtime.select_providers( - "mps", - ["CoreMLExecutionProvider", "CPUExecutionProvider"], - ) def test_completing_cuda_run_shrinks_memory_arena(self): configs = [] diff --git a/tests/anyimage/server/test_model_upscale_onnx.py b/tests/anyimage/server/test_model_upscale_onnx.py index 117b683..d5fa7ec 100644 --- a/tests/anyimage/server/test_model_upscale_onnx.py +++ b/tests/anyimage/server/test_model_upscale_onnx.py @@ -33,10 +33,6 @@ class UpscaleOnnxTest(unittest.TestCase): expected = np.repeat(np.repeat(pixels, 4, axis=0), 4, axis=1) np.testing.assert_array_equal(np.asarray(result), expected) - def test_nonfinite_pixels_fail_before_conversion(self): - with patch.object(onnx_upscale, "run_session", return_value=[np.full((1, 3, 1024, 1024), np.nan)]): - with self.assertRaisesRegex(RuntimeError, "non-finite"): - onnx_upscale.infer(FakeSession(), Image.new("RGB", (7, 5))) def test_preprocess_matches_dynamic_rgb_layout(self): image = Image.new("RGB", (7, 5), (64, 128, 192)) @@ -95,16 +91,6 @@ class UpscaleOnnxTest(unittest.TestCase): self.assertAlmostEqual(red[16, 22], 120 / 255.0) self.assertAlmostEqual(red[16, 23], 100 / 255.0) - def test_infer_rejects_non_four_x_output(self): - class InvalidSession(FakeSession): - def run(self, _output_names, feeds): - return [feeds["input"]] - - with self.assertRaisesRegex(RuntimeError, "unexpected output shape"): - onnx_upscale.infer( - InvalidSession(), - Image.new("RGB", (7, 5)), - ) def test_all_models_split_large_inputs_into_fixed_tiles(self): class CountingSession(FakeSession): diff --git a/tests/anyimage/server/test_panorama.py b/tests/anyimage/server/test_panorama.py index cc2f38f..e3886e2 100644 --- a/tests/anyimage/server/test_panorama.py +++ b/tests/anyimage/server/test_panorama.py @@ -65,7 +65,7 @@ def panorama_job(tmp_path, monkeypatch): write = job.write_float_exr monkeypatch.setattr(job, "write_float_exr", lambda rgba, path: (pixels.append(rgba.copy()), write(rgba, path))[1]) parameters = {"input": str(source), "model": "MOGE2", "device": "cpu", "resolution_level": 3, - "max_input_size": 32, "video_frames": 1, "start_frame": 1} + "max_input_size": 32} return source, context, parameters, calls, pixels @@ -128,6 +128,6 @@ def test_failed_jobs_leave_no_partial_artifacts(panorama_job, monkeypatch, kind) with pytest.raises((ValueError, RuntimeError)): job.run(context, parameters) assert not list(context.directory.glob("depth.*")) - assert not (context.directory / "image.0001.png").exists() + assert not (context.directory / "foreground.png").exists() if kind == "transparent": assert not calls diff --git a/tests/anyimage/server/test_prediction_artifacts.py b/tests/anyimage/server/test_prediction_artifacts.py index 3a2266d..26d7a79 100644 --- a/tests/anyimage/server/test_prediction_artifacts.py +++ b/tests/anyimage/server/test_prediction_artifacts.py @@ -73,7 +73,7 @@ def test_artifacts_preserve_original_geometry_with_alpha( check_cancelled=lambda: None, ) with ( - patch.object(artifacts.moge, "infer", return_value=[frame]), + patch.object(artifacts.moge, "infer", return_value=frame), patch.object( artifacts, "write_depth_texture", return_value=tmp_path / "depth.exr", diff --git a/tests/anyimage/server/test_server_jobs.py b/tests/anyimage/server/test_server_jobs.py index af0aceb..2bc9346 100644 --- a/tests/anyimage/server/test_server_jobs.py +++ b/tests/anyimage/server/test_server_jobs.py @@ -1,9 +1,6 @@ -import sys import tempfile import unittest -from contextlib import nullcontext from pathlib import Path -from types import ModuleType, SimpleNamespace from unittest.mock import patch import numpy as np from tests.anyimage.server.support import load_server_module, FakeJobContext @@ -25,17 +22,6 @@ class ServerJobsTest(unittest.TestCase): cls.upscale_job = load_server_module("jobs.upscale") - def test_upscale_progress_omits_single_frame_count(self): - self.assertEqual( - self.upscale_job.frame_progress_message("Upscaling image", 0, 1), - "Upscaling image", - ) - self.assertEqual( - self.upscale_job.frame_progress_message("Upscaling image", 1, 3), - "Upscaling image (2/3)", - ) - - def test_plane_job_requests_depth_and_matching_normal_space(self): for plane_type, normal_space in (("DEPTH", "OBJECT"), ("RELIEF", "TANGENT")): normal_key = f"{normal_space.lower()}_normal" @@ -51,8 +37,8 @@ class ServerJobsTest(unittest.TestCase): with ( patch.object( self.depth_plane_job, - "input_frames", - return_value=nullcontext([input_path]), + "validate_input_path", + return_value=input_path, ), patch.object( self.depth_plane_job, @@ -96,8 +82,10 @@ class ServerJobsTest(unittest.TestCase): patch.object(onnx_ben2, "infer_alpha", return_value=np.full((1, 2), 128 / 255, np.float32)) as infer, patch.object(Image.Image, "save", autospec=True, side_effect=Image.Image.save) as save, ): - self.background.run(context, {"input": str(input_path), "model": "BEN2_BASE", "device": "cpu"}) - with Image.open(root / "result" / "image.0001.png") as image: + result = self.background.run(context, {"input": str(input_path), "model": "BEN2_BASE", "device": "cpu"}) + self.assertEqual(result, {"foreground": "foreground.png"}) + self.assertEqual([p.name for p in context.directory.glob("*.png")], ["foreground.png"]) + with Image.open(root / "result" / "foreground.png") as image: self.assertEqual(image.getpixel((0, 0)), (255, 64, 32, 64)) self.assertEqual(infer.call_args.kwargs, {"release_memory": True}) self.assertEqual(save.call_args.kwargs, {"format": "PNG", "compress_level": 1}) @@ -152,27 +140,6 @@ class ServerJobsTest(unittest.TestCase): ) - def test_ben2_background_removal_processes_each_frame(self): - from PIL import Image - onnx_ben2 = load_server_module("models.onnx_ben2") - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - frames = root / "frames" - frames.mkdir() - for index in (1, 2): - Image.new("RGB", (2, 1), (index * 50, 0, 0)).save(frames / f"frame.{index:04d}.png") - context = FakeJobContext(root / "result", "remove-background") - with ( - patch.object(onnx_ben2, "create_session", return_value=object()), - patch.object(onnx_ben2, "infer_alpha", return_value=np.ones((1, 2), np.float32)) as infer, - ): - self.background.run(context, {"input": str(frames), "model": "BEN2_BASE", "device": "cpu", "video_frames": 8}) - self.assertEqual([call.kwargs["release_memory"] for call in infer.call_args_list], [False, True]) - for index in (1, 2): - with Image.open(context.directory / f"image.{index:04d}.png") as image: - self.assertEqual(image.getpixel((0, 0)), (index * 50, 0, 0, 255)) - - def test_background_alpha_job_preserves_float_precision(self): from PIL import Image onnx_ben2 = load_server_module("models.onnx_ben2") @@ -192,15 +159,13 @@ class ServerJobsTest(unittest.TestCase): self.assertFalse(list(context.directory.glob("*.png"))) - def test_upscale_job_saves_importable_upscale_sequence(self): + def test_upscale_job_returns_one_image_file(self): from PIL import Image with tempfile.TemporaryDirectory() as directory: root = Path(directory) - frames = root / "frames" - frames.mkdir() - (frames / "a.png").touch() - (frames / "b.png").touch() + input_path = root / "input.png" + input_path.touch() context = FakeJobContext(root / "result", "upscale-image") with patch.object( self.upscale_job, @@ -210,23 +175,19 @@ class ServerJobsTest(unittest.TestCase): result = self.upscale_job.run( context, { - "input": str(frames), + "input": str(input_path), "model": "REALESRGAN_GENERAL_WDN_X4V3", - "video_frames": 2, + "device": "cpu", }, ) - self.assertEqual(infer_one.call_count, 2) - self.assertEqual( - [call.kwargs["release_memory"] for call in infer_one.call_args_list], - [False, True], - ) - self.assertEqual(result, {"upscale": "."}) - colors = sorted(context.directory.glob("upscale.*.png")) + self.assertEqual(infer_one.call_count, 1) + self.assertEqual(result, {"upscale": "upscale.png"}) + colors = sorted(context.directory.glob("*.png")) self.assertEqual( [path.name for path in colors], - ["upscale.0001.png", "upscale.0002.png"], + ["upscale.png"], ) self.assertIn( ( @@ -261,12 +222,12 @@ class ServerJobsTest(unittest.TestCase): { "input": str(source_path), "model": "REALESRGAN_GENERAL_WDN_X4V3", - "video_frames": 1, + "device": "cpu", }, ) - with Image.open(context.directory / "upscale.0001.png") as result: + with Image.open(context.directory / "upscale.png") as result: self.assertEqual(result.mode, "RGBA") self.assertEqual(result.size, (10, 6)) alpha = np.asarray(result.getchannel("A")) diff --git a/tests/anyimage/test_addon_registration.py b/tests/anyimage/test_addon_registration.py index 76c922d..259446c 100644 --- a/tests/anyimage/test_addon_registration.py +++ b/tests/anyimage/test_addon_registration.py @@ -17,7 +17,7 @@ class AddonRegistrationTest(BlenderTestCase): self.assertFalse(name.startswith("anyimage_"), (operator, name)) - def test_semantic_mesh_operators_are_registered_without_dialog_options(self): + def test_mesh_operators_and_tools_are_registered(self): from anyimage.operators.convert_to_panorama import ConvertToPanorama, GeneratePanorama self.assertIn(ConvertToPanorama, self.anyimage.CLASSES) @@ -32,12 +32,8 @@ class AddonRegistrationTest(BlenderTestCase): self.assertIn(operator, self.anyimage.CLASSES) self.assertIn(self.convert_to_plane.ConvertToDepthPlane, self.anyimage.CLASSES) self.assertIn(self.convert_to_plane.ConvertToReliefPlane, self.anyimage.CLASSES) - self.assertFalse(hasattr(self.convert_to_plane, "ConvertToDepthSurface")) self.assertIn(self.cutout_main.CutoutSelectionToShape, self.anyimage.CLASSES) self.assertEqual(operator.bl_idname, "anyimage.convert_to_plane") - self.assertNotIn("shadeless", operator.__annotations__) - self.assertNotIn("invoke", operator.__dict__) - self.assertNotIn("draw", operator.__dict__) self.assertIn("REGISTER", operator.bl_options) self.assertIn("UNDO", operator.bl_options) self.assertEqual(operator.__annotations__["mesh_detail"]["default"], 0) @@ -80,14 +76,11 @@ class AddonRegistrationTest(BlenderTestCase): self.rectify.RectifyImagePerspective, self.anyimage.CLASSES, ) - self.assertNotIn("selection_path_json", operator.__annotations__) mask_tool = self.edit_mask.MaskTool self.assertNotIn(mask_tool, self.anyimage.CLASSES) self.assertEqual(mask_tool.bl_operator, operator.bl_idname) self.assertEqual(operator.bl_options, {"UNDO"}) - self.assertNotIn("draw", operator.__dict__) self.assertEqual(mask_tool.bl_icon, "ops.sculpt.lasso_mask") - self.assertNotIn("bl_options", mask_tool.__dict__) self.assertEqual(mask_tool.bl_cursor, "PAINT_CROSS") self.assertEqual( mask_tool.bl_keymap[0][1]["value"], @@ -102,7 +95,6 @@ class AddonRegistrationTest(BlenderTestCase): self.assertEqual(frame_tool.bl_operator, frame_operator.bl_idname) self.assertEqual(frame_tool.bl_icon, "ops.sculpt.border_mask") self.assertEqual(frame_operator.bl_options, {"UNDO"}) - self.assertNotIn("bl_options", frame_tool.__dict__) self.assertEqual(frame_tool.bl_keymap[0][1]["value"], "CLICK_DRAG") self.assertIsNone(frame_tool.bl_keymap[0][2]) self.assertEqual( @@ -117,7 +109,6 @@ class AddonRegistrationTest(BlenderTestCase): frame_tool.bl_keymap[2][2], {"properties": [("toggle", True)]}, ) - self.assertNotIn("draw_settings", frame_tool.__dict__) self.assertIn("draw_settings", mask_tool.__dict__) settings_value = SimpleNamespace( mask_gesture="LASSO", @@ -151,25 +142,16 @@ class AddonRegistrationTest(BlenderTestCase): cutout_tool.bl_icon, "ops.mesh.primitive_sphere_add_gizmo", ) - self.assertNotIn("bl_options", cutout_tool.__dict__) - self.assertEqual(cutout_tool.bl_keymap[0][1]["value"], "CLICK_DRAG") + self.assertEqual(cutout_tool.bl_keymap[0][1]["value"], "PRESS") self.assertEqual( [entry[0] for entry in cutout_tool.bl_keymap[1:]], - ["view3d.select", "view3d.select"], + ["view3d.select"], ) self.assertEqual( cutout_tool.bl_keymap[1][2], - {"properties": [("deselect_all", False)]}, - ) - self.assertEqual( - cutout_tool.bl_keymap[2][2], {"properties": [("toggle", True)]}, ) cutout_module = sys.modules[cutout_tool.__module__] - self.assertNotIn( - "selection_path_json", - cutout_module.SelectCutoutSelection.__annotations__, - ) self.assertIn( "selection_path_json", cutout_module.CutoutSelectionToShape.__annotations__, @@ -195,11 +177,11 @@ class AddonRegistrationTest(BlenderTestCase): self.assertEqual( drawn_settings, [ - "cutout_generate_normal", + "cutout_gesture", "cutout_mesh_detail", "cutout_fine_outline", "cutout_alpha_threshold", - "cutout_inward_axis", + "cutout_generate_normal", ], ) self.assertEqual(operator.__annotations__["gesture"]["default"], "LASSO") @@ -211,11 +193,24 @@ class AddonRegistrationTest(BlenderTestCase): ("POLYLINE", "Polyline"), ], ) - self.assertEqual(operator.__annotations__["mode"]["default"], "SUBTRACT") + self.assertEqual(operator.__annotations__["mode"]["default"], "SET") self.assertEqual(operator.__annotations__["radius"]["default"], 25) settings = self.anyimage.properties.AnyImageSettings.__annotations__ self.assertEqual(settings["mask_gesture"]["default"], "LASSO") - self.assertEqual(settings["mask_mode"]["default"], "SUBTRACT") + self.assertEqual(settings["cutout_gesture"]["default"], "LASSO") + self.assertEqual( + [item[0] for item in settings["cutout_gesture"]["items"]], + ["LASSO", "POLYLINE"], + ) + self.assertEqual( + cutout_module.SelectCutoutSelection.__annotations__["gesture"]["default"], + "LASSO", + ) + self.assertEqual( + [(item[0], item[1], item[4]) for item in settings["mask_mode"]["items"]], + [("SET", "Set", 0), ("EXTEND", "Extend", 1), ("SUBTRACT", "Subtract", 2)], + ) + self.assertEqual(settings["mask_mode"]["default"], "SET") self.assertEqual(settings["mask_radius"]["default"], 25) self.assertEqual( {name for name in settings if name.startswith("mask_")}, @@ -250,8 +245,6 @@ class AddonRegistrationTest(BlenderTestCase): ], ) self.assertFalse(settings["cutout_generate_normal"]["default"]) - self.assertEqual(settings["cutout_inward_axis"]["default"], "NEGATIVE_Z") - self.assertFalse(any("crop" in name for name in settings)) self.assertEqual( settings["cutout_mesh_detail"]["name"], "Mesh Detail" ) @@ -259,34 +252,21 @@ class AddonRegistrationTest(BlenderTestCase): settings["cutout_generate_normal"]["name"], "Normal Map", ) - self.assertNotIn("image_extract_continuous", settings) - self.assertNotIn("image_extract_padding", settings) - self.assertNotIn("image_extract_remove_background", settings) def test_rectify_tool_registration_and_properties(self): operator = self.rectify.RectifyImagePerspective tool = self.rectify.RectifyTool - settings = self.anyimage.properties.AnyImageSettings.__annotations__ - self.assertIn(operator, self.anyimage.CLASSES) self.assertNotIn(tool, self.anyimage.CLASSES) self.assertEqual(tool.bl_operator, operator.bl_idname) self.assertEqual(tool.bl_icon, "ops.sculpt.line_project") - self.assertNotIn("bl_options", tool.__dict__) self.assertEqual(tool.bl_keymap[0][1]["value"], "PRESS") self.assertEqual( tool.bl_keymap[1][1], {"type": "LEFTMOUSE", "value": "CLICK", "shift": True}, ) - self.assertNotIn("bl_cursor", tool.__dict__) - self.assertNotIn("draw_settings", tool.__dict__) - self.assertNotIn("rectify_aspect_mode", settings) - self.assertNotIn("rectify_aspect_ratio", settings) - self.assertNotIn("aspect_mode", operator.__annotations__) self.assertIn("aspect_ratio", operator.__annotations__) - self.assertNotIn("continuous", operator.__annotations__) - self.assertNotIn("padding", operator.__annotations__) def test_registration_and_reverse_cleanup_preserve_other_addons(self): @@ -305,12 +285,11 @@ class AddonRegistrationTest(BlenderTestCase): self.outliner_menu_draws.append(existing_outliner_draw) self.node_menu_draws.append(existing_node_draw) - self.assertFalse(any("debug" in cls.bl_idname.lower() for cls in self.anyimage.CLASSES if hasattr(cls, "bl_idname"))) settings = self.anyimage.properties.AnyImageSettings.__annotations__ self.assertEqual(set(settings), { - "cutout_alpha_threshold", "cutout_mesh_detail", "cutout_fine_outline", - "cutout_generate_normal", "cutout_inward_axis", "mask_gesture", - "mask_mode", "mask_radius", "video_frames", + "cutout_gesture", "cutout_alpha_threshold", "cutout_mesh_detail", "cutout_fine_outline", + "cutout_generate_normal", "mask_gesture", + "mask_mode", "mask_radius", }) self.anyimage.register() system_classes = self.anyimage.runtime.operator_classes() @@ -356,7 +335,6 @@ class AddonRegistrationTest(BlenderTestCase): self.outliner_menu_draws[0], self.anyimage.draw_image_context_menu, ) - self.assertFalse(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source")) self.assertEqual(self.node_menu_draws, [self.anyimage.draw_texture_node_context_menu, existing_node_draw]) self.assertIn(self.anyimage.AnyImageTextureNodeMenu, self.registered) @@ -382,4 +360,3 @@ class AddonRegistrationTest(BlenderTestCase): self.assertEqual(self.node_menu_draws, [self.anyimage.draw_texture_node_context_menu, existing_node_draw]) self.anyimage.unregister() self.assertEqual(self.node_menu_draws, [existing_node_draw]) - self.assertFalse(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source")) diff --git a/tests/anyimage/test_job_operator.py b/tests/anyimage/test_job_operator.py index ed75258..bd2e5dc 100644 --- a/tests/anyimage/test_job_operator.py +++ b/tests/anyimage/test_job_operator.py @@ -99,9 +99,6 @@ class JobOperatorTest(BlenderTestCase): self.assertEqual(Path(command[1]).parent.name, "installer") self.assertIn("--storage-root", command) self.assertIn("--config", command) - self.assertNotIn("--venv-dir", command) - self.assertNotIn("--models-dir", command) - self.assertNotIn("--stages", command) def test_runtime_request_uses_request_response_shape(self): diff --git a/tests/anyimage/test_preferences.py b/tests/anyimage/test_preferences.py index fcd9b7e..b23114d 100644 --- a/tests/anyimage/test_preferences.py +++ b/tests/anyimage/test_preferences.py @@ -78,24 +78,6 @@ class PreferencesTest(BlenderTestCase): ) - def test_manifest_rejects_old_mps_device(self): - properties = self.anyimage.properties - with ( - patch.object( - self.anyimage.runtime, - "environment_manifest", - return_value={"devices": ["mps", "cpu"]}, - ), - patch.object(properties.sys, "platform", "darwin"), - patch.object( - properties.platform, - "machine", - return_value="arm64", - ), - ): - self.assertEqual(properties.supported_devices(), ["cpu"]) - - def test_windows_keeps_default_directml_device(self): properties = self.anyimage.properties with ( @@ -112,38 +94,17 @@ class PreferencesTest(BlenderTestCase): ) - def test_geometry_model_choices_match_moge_catalog(self): - properties = self.anyimage.properties - expected = tuple(properties.shared_model_catalog.MOGE_MODELS) - geometry = properties.geometry_model_enum_items(None, None) - self.assertEqual(tuple(item[0] for item in geometry), expected) - - def test_model_tiers_only_appear_in_choices(self): - properties = self.anyimage.properties - preferences = self.anyimage.preferences - catalog = {model["key"]: model for model in properties.model_catalog()} - for items in ( - properties.geometry_model_enum_items(None, None), - preferences.BACKGROUND_MODEL_ITEMS, - preferences.UPSCALE_MODEL_ITEMS, - ): - for item, tier in zip(items, ("Fast", "Base", "Pro")): - label = catalog[item[0]]["label"] - self.assertNotIn(" | ", label) - self.assertEqual(item[1], f"{tier} | {label}") - self.assertEqual(properties.upscale_model_label("HAT_GAN_X4_SHARPER"), "HAT Sharper") - - def test_model_preferences_keep_valid_choices_and_normalize_retired_keys(self): + def test_model_preferences_keep_valid_choices_and_reset_unknown_keys(self): module = self.anyimage.preferences stored = self.fake_bpy.context.preferences.addons["anyimage"].preferences - for attribute, reader, valid, retired, default in ( - ("geometry_model", module.configured_geometry_model, "MOGE3_VITL", "MOGE2_VITL_NORMAL", "MOGE2_VITS_NORMAL"), - ("background_model", module.configured_background_model, "BEN2_BASE", "UNKNOWN", "BIREFNET_LITE"), - ("upscale_model", module.configured_upscale_model, "REALESRGAN_X4PLUS", "PISA_SR", "REALESRGAN_GENERAL_WDN_X4V3"), + for attribute, reader, valid, default in ( + ("geometry_model", module.configured_geometry_model, "MOGE3_VITL", "MOGE2_VITS_NORMAL"), + ("background_model", module.configured_background_model, "BEN2_BASE", "BIREFNET_LITE"), + ("upscale_model", module.configured_upscale_model, "REALESRGAN_X4PLUS", "REALESRGAN_GENERAL_WDN_X4V3"), ): setattr(stored, attribute, valid) self.assertEqual(reader(), valid) - setattr(stored, attribute, retired) + setattr(stored, attribute, "UNKNOWN") self.assertEqual(reader(), default) self.assertEqual(getattr(stored, attribute), default) self.assertEqual({item[0]: item[-1] for item in module.BACKGROUND_MODEL_ITEMS}["BEN2_BASE"], 0) @@ -205,21 +166,18 @@ class PreferencesTest(BlenderTestCase): def test_preferences_draws_upscale_model_download(self): preference_module = importlib.import_module("anyimage.preferences") downloads = [] - properties = [] - labels = [] - boxes = [] class Layout: enabled = True def prop(self, _owner, name, **_options): - properties.append(name) + pass def separator(self, **_options): return None def label(self, **_options): - labels.append(_options.get("text")) + pass def row(self, **_options): return self @@ -231,7 +189,6 @@ class PreferencesTest(BlenderTestCase): return self def box(self): - boxes.append(self) return self def operator(self, identifier, **_options): @@ -283,106 +240,10 @@ class PreferencesTest(BlenderTestCase): [download.model for download in downloads], ["MOGE2_VITS_NORMAL", "BEN2_BASE", "HAT_GAN_X4_SHARPER"], ) - self.assertEqual( - [ - label - for label in labels - if label - in { - "Server", - "AI", - "Tools", - "Frame Tool", - "Depth & Normal", - "Cutout Tool", - } - ], - [ - "Server", - "AI", - "Tools", - "Frame Tool", - "Depth & Normal", - "Cutout Tool", - ], - ) - self.assertEqual(len(boxes), 4) - self.assertIn("Shading", labels) - self.assertIn("adapt_material_to_view_transform", properties) - adaptation = preference_module.AnyImagePreferences.__annotations__[ - "adapt_material_to_view_transform" - ] - self.assertTrue(adaptation["default"]) - self.assertNotIn("update", adaptation) - self.assertIn(preference_module.AnyImagePreferences, self.anyimage.CLASSES) - self.assertNotIn("Normal Model", labels) - self.assertIn("background_model", properties) - self.assertIn("upscale_model", properties) - self.assertIn("max_ai_input_size", properties) - self.assertIn("max_frame_resolution", properties) - self.assertIn("max_cutout_mesh_resolution", properties) - self.assertIn("balloon_profile_algorithm", properties) - balloon_profile = preference_module.AnyImagePreferences.__annotations__[ - "balloon_profile_algorithm" - ] - self.assertEqual(balloon_profile["default"], "POISSON") - self.assertEqual( - tuple(item[0] for item in balloon_profile["items"]), - ("POISSON", "TEDDY"), - ) - self.assertEqual( - preference_module.AnyImagePreferences.__annotations__["upscale_model"][ - "name" - ], - "Upscale Image Model", - ) - self.assertEqual( - preference_module.AnyImagePreferences.__annotations__["geometry_model"][ - "name" - ], - "Generate Depth Map Model", - ) - self.assertEqual( - preference_module.AnyImagePreferences.__annotations__["background_model"][ - "name" - ], - "Remove Background Model", - ) - - - def test_ready_models_show_their_ai_feature(self): - preference_module = importlib.import_module("anyimage.preferences") - labels = [] - - class Layout: - def row(self, **_options): - return self - - def label(self, **options): - labels.append((options.get("text"), options.get("icon"))) - - preferences = preference_module.AnyImagePreferences() - model = {"ready": True} - for ready_label in ( - "AI Generate Depth Map", - "AI Remove Background", - "AI Upscale Image", - ): - preferences._draw_model_status(Layout(), model, ready_label) - - self.assertEqual( - labels, - [ - ("AI Generate Depth Map", "CHECKMARK"), - ("AI Remove Background", "CHECKMARK"), - ("AI Upscale Image", "CHECKMARK"), - ], - ) def test_preferences_hide_ai_until_server_is_installed(self): preference_module = importlib.import_module("anyimage.preferences") - labels = [] operators = [] class Layout: @@ -392,7 +253,7 @@ class PreferencesTest(BlenderTestCase): return self def label(self, **options): - labels.append(options.get("text")) + pass def prop(self, *_args, **_options): return None @@ -424,10 +285,6 @@ class PreferencesTest(BlenderTestCase): ): preferences.draw(None) - self.assertEqual( - labels, - ["Server", "Shading", "Tools", "Frame Tool", "Depth & Normal", "Cutout Tool"], - ) self.assertEqual( operators, ["anyimage.setup_ai_environment", "anyimage.open_server_log"], @@ -445,7 +302,7 @@ class PreferencesTest(BlenderTestCase): operator.resolution_level = 5 operator.max_input_size = 1536 context = SimpleNamespace( - scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1)) + scene=SimpleNamespace(anyimage_settings=SimpleNamespace()) ) with ( patch.object(self.convert_to_plane, "require_environment"), @@ -456,15 +313,12 @@ class PreferencesTest(BlenderTestCase): parameters = operator.request(context) self.assertEqual(parameters["max_input_size"], 1536) - self.assertNotIn("shape", parameters) - self.assertNotIn("polygon", parameters) def test_upscale_execute_reads_model_and_size_from_preferences(self): source_image = SimpleNamespace( name="Original", source="FILE", - anyimage_upscale_source=None, ) source_object = SimpleNamespace( name="Source", @@ -475,7 +329,7 @@ class PreferencesTest(BlenderTestCase): ) context = SimpleNamespace( object=source_object, - scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1)), + scene=SimpleNamespace(anyimage_settings=SimpleNamespace()), ) operator = self.upscale.UpscaleImage() with ( diff --git a/tests/anyimage/test_server_panel.py b/tests/anyimage/test_server_panel.py index 4c247fa..1c405c5 100644 --- a/tests/anyimage/test_server_panel.py +++ b/tests/anyimage/test_server_panel.py @@ -18,6 +18,10 @@ class ServerPanelTest(BlenderTestCase): self.assertIn(("row", label, icon), events.labels) self.assertIn("anyimage.restart_server", operators) self.assertIn("anyimage.stop_server", operators) + self.assertIn(("row", "No Models Loaded", None), events.labels) + self.assertIn( + ("anyimage.clear_models", state == "READY"), events.operators + ) for state in ("STOPPED", "STARTING", "ERROR"): with self.subTest(state=state): @@ -27,31 +31,6 @@ class ServerPanelTest(BlenderTestCase): self.assertNotIn("anyimage.stop_server", operators) - def test_server_panel_wraps_multiple_cached_models(self): - for cached_names, expected_labels in ( - ((), [("row", "Cached: None")]), - (("Model A",), [("row", "Cached: Model A")]), - ( - ("Model A", "Model B"), - [ - ("column", "Cached:"), - ("column", "Model A"), - ("column", "Model B"), - ], - ), - ): - with self.subTest(cached_names=cached_names): - events = self._draw_server_panel("READY", cached_names) - cached_labels = [ - (kind, text) - for kind, text, _icon in events.labels - if text != "Running" - ] - self.assertEqual(cached_labels, expected_labels) - operators = [identifier for identifier, _enabled in events.operators] - self.assertIn("anyimage.clear_models", operators) - - def test_status_bar_reads_runtime_state(self): calls = [] diff --git a/tests/support/blender.py b/tests/support/blender.py index 8d4a36a..ec4428d 100644 --- a/tests/support/blender.py +++ b/tests/support/blender.py @@ -136,11 +136,13 @@ class BlenderTestCase(unittest.TestCase): ) cls.image_selection = importlib.import_module("anyimage.common.selection") cls.image_frame = importlib.import_module( - "anyimage.operators.frame_tool" + "anyimage.operators.frame_tool.operators" ) cls.rectify = importlib.import_module( - "anyimage.operators.rectify_tool" + "anyimage.operators.rectify_tool.operators" ) + cls.rectify_geometry = importlib.import_module("anyimage.operators.rectify_tool.geometry") + cls.rectify_preview = importlib.import_module("anyimage.operators.rectify_tool.preview") cls.cutout_geometry = importlib.import_module( "anyimage.operators.cutout_tool.geometry" ) @@ -194,5 +196,3 @@ class BlenderTestCase(unittest.TestCase): ) if hasattr(self.fake_bpy.types.Scene, "anyimage_settings"): del self.fake_bpy.types.Scene.anyimage_settings - if hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source"): - del self.fake_bpy.types.Image.anyimage_upscale_source diff --git a/tests/support/depth_surface.py b/tests/support/depth_surface.py index faad5c4..2047912 100644 --- a/tests/support/depth_surface.py +++ b/tests/support/depth_surface.py @@ -65,7 +65,7 @@ def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False): ("Depth Scale", 1.0), ("Thickness", 0.0), ("Front Inflation", 0.0), - ("Split Threshold", 0.0), + ("Depth Split", 0.0), ("Mode", 1), ("Smooth", 0), ("Boundary Smooth", 0), @@ -73,7 +73,7 @@ def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False): set_value(name, value) if wrapper: set_value("Cleanup Threshold", 0.0) - set_value("Inward Axis", 0) + set_value("Depth Axis", 0) set_value("Smooth", 0) return obj, set_value diff --git a/tests/support/image_objects.py b/tests/support/image_objects.py index 39cd2d7..9b78336 100644 --- a/tests/support/image_objects.py +++ b/tests/support/image_objects.py @@ -35,7 +35,7 @@ def _create_cutout_shape( depth_image=None, depth_metadata=None, selection_values=None, - inward_axis=cutout_object.CutoutInwardAxis.NEGATIVE_Z, + gesture="LASSO", ): if bounds is None: bounds = (0, 0, *tuple(int(value) for value in source.data.size)) @@ -66,7 +66,7 @@ def _create_cutout_shape( base_shape, depth_image=depth_image, depth_metadata=depth_metadata, - inward_axis=inward_axis, + gesture=gesture, color_image=color_image, ) @@ -117,7 +117,7 @@ def _add_cutout_modifier(obj, shape): object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(group, "Mode"), 1) object_data.set_modifier_input( modifier, - object_data.modifier_input_identifier(group, "Inward Axis"), + object_data.modifier_input_identifier(group, "Depth Axis"), 0, ) object_data.set_modifier_input( @@ -171,7 +171,10 @@ def _depth_surface_step_object(): ): object_data.set_modifier_input( modifier, - object_data.modifier_input_identifier(modifier.node_group, name), + object_data.modifier_input_identifier( + modifier.node_group, name, + subtype="DISTANCE" if name == "Thickness" else None, + ), value, ) return obj, modifier diff --git a/tests/support/image_texture.py b/tests/support/image_texture.py index 0dbc7b1..dd1325b 100644 --- a/tests/support/image_texture.py +++ b/tests/support/image_texture.py @@ -35,11 +35,11 @@ def texture_context(node, **space_options): edit_tree=node.id_data, id=bpy.data.materials["Texture material"], **space_options), ), object=None, - scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=16)), + scene=SimpleNamespace(anyimage_settings=SimpleNamespace()), ) -def write_result(directory, prefix="image.", count=1): - for index in range(1, count + 1): - Image.new("RGBA", (8, 6), (128, 64, 32, 80)).save(directory / f"{prefix}{index:04d}.png") - +def write_result(directory, filename="foreground.png"): + path = directory / filename + Image.new("RGBA", (8, 6), (128, 64, 32, 80)).save(path) + return path diff --git a/tests/support/nodes.py b/tests/support/nodes.py index af55ded..07802aa 100644 --- a/tests/support/nodes.py +++ b/tests/support/nodes.py @@ -9,14 +9,3 @@ CUTOUT_BUILDERS = { "DEPTH_SOLID": build_image_depth_cutout_group, "DEPTH_BALLOON": build_image_depth_balloon_group, } - - -def validate_node_structure(node_group): - """Check visible sockets, default titles, and paired execution endpoints.""" - nodes = [node for node in node_group.nodes if node.bl_idname not in {"NodeReroute", "NodeFrame"}] - assert all(not node.label for node in nodes), node_group.name - assert not any(node.bl_idname == "FunctionNodeInputString" for node in nodes), node_group.name - assert all(socket.is_linked or socket.hide for node in node_group.nodes for socket in node.outputs), node_group.name - for node in nodes: - if node.bl_idname in {"GeometryNodeRepeatInput", "GeometryNodeSimulationInput", "GeometryNodeForeachGeometryElementInput"}: - assert node.paired_output in node_group.nodes.values(), (node_group.name, node.name) diff --git a/tests/support/planes.py b/tests/support/planes.py index 957b9fe..40dfc52 100644 --- a/tests/support/planes.py +++ b/tests/support/planes.py @@ -1,38 +1,29 @@ """Evaluate complete-image projection and rectangular surface topology.""" -from tests.support.paths import PROJECT_ROOT import bpy import numpy as np import pytest -ROOT = PROJECT_ROOT from tools.nodes.groups.image_plane import build_image_plane_group from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group -@pytest.fixture(params=("definition", "asset")) -def surface(request): - return create_surface(request.param, "DEPTH") +@pytest.fixture +def surface(): + return create_surface("DEPTH") -@pytest.fixture(params=("definition", "asset")) -def relief(request): - return create_surface(request.param, "RELIEF") +@pytest.fixture +def relief(): + return create_surface("RELIEF") -def create_surface(source, plane_type): +def create_surface(plane_type): bpy.ops.wm.read_factory_settings(use_empty=True) - group_name = f"O Image {plane_type.title()} Plane" - if source == "asset": - with bpy.data.libraries.load(str(ROOT / "src/anyimage/assets/O_AnyImage.blend")) as (library, target): - target.node_groups = [group_name] - group = target.node_groups[0] - plane = group.nodes["Group"].node_tree - else: - plane = build_image_plane_group() - builder = build_image_depth_plane_group if plane_type == "DEPTH" else build_image_relief_plane_group - group = builder(plane) + plane = build_image_plane_group() + builder = build_image_depth_plane_group if plane_type == "DEPTH" else build_image_relief_plane_group + group = builder(plane) mesh = bpy.data.meshes.new("Rectangle") mesh.from_pydata([(-2, -1, 0), (2, -1, 0), (2, 1, 0), (-2, 1, 0)], [], [(0, 1, 2, 3)]) material = bpy.data.materials.new("Image") @@ -65,8 +56,8 @@ def create_surface(source, plane_type): set_value(name, value) if "Boundary Smooth" in inputs: set_value("Boundary Smooth", 0) - if "Split Threshold" in inputs: - set_value("Split Threshold", 0.0) + if "Depth Split" in inputs: + set_value("Depth Split", 0.0) return obj, set_value, plane, inputs, image diff --git a/tests/tools/nodes/test_arrangement.py b/tests/tools/nodes/test_arrangement.py index 904a188..058c437 100644 --- a/tests/tools/nodes/test_arrangement.py +++ b/tests/tools/nodes/test_arrangement.py @@ -7,23 +7,16 @@ import pytest from tools.nodes.groups.image_plane import build_image_plane_group from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group -from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group -from tests.support.nodes import CUTOUT_BUILDERS -from tools.nodes.arrangement.arrange import BRANCH_GAP, FIELD_HEIGHT, ROUTE_RUN, arrange_nodes -from tools.nodes.arrangement.flow import geometry_paths, origin_socket, peer_links -from tools.nodes.arrangement.dimensions import RULES, estimate_node_dimensions +from tools.nodes.groups.image_cutout import build_image_cutout_group +from tools.nodes.arrangement.arrange import arrange_nodes -@pytest.fixture(params=("plane", "depth_plane", "relief_plane", "SOLID", "DEPTH_SOLID", "DEPTH_BALLOON")) +@pytest.fixture(params=("depth_plane", "cutout")) def node_group(request): bpy.ops.wm.read_factory_settings(use_empty=True) - if request.param == "plane": - return build_image_plane_group() if request.param == "depth_plane": return build_image_depth_plane_group(build_image_plane_group()) - if request.param == "relief_plane": - return build_image_relief_plane_group(build_image_plane_group()) - return CUTOUT_BUILDERS[request.param]() + return build_image_cutout_group() def _source(socket): @@ -98,7 +91,7 @@ def _surface(node_group): image.pixels.foreach_set(pixels.ravel()) values = {"Subdivide": 1, "Thickness": 0.2, "Depth Image": image, "Uniform Scale": 1.0, "Reference Depth": 2.0, "Depth Scale": 0.8, - "Smooth": 1, "Normal Smooth": 2, "Split Threshold": 0.25} + "Smooth": 1, "Normal Smooth": 2, "Depth Split": 0.25} mode = None for item in node_group.interface.items_tree: if item.item_type != "SOCKET" or item.in_out != "INPUT": @@ -128,1163 +121,12 @@ def _surface(node_group): return evaluate -def test_arrangement_preserves_each_asset_program(node_group): - from tools.nodes.arrangement.connections import curve_footprint, link_endpoints - +def test_arrangement_preserves_program_and_geometry(node_group): program = _program(node_group) evaluate = _surface(node_group) geometry = evaluate() arrange_nodes(node_group) assert _program(node_group) == program - - for node in node_group.nodes: - if node.bl_idname == "NodeGroupInput": - targets = {link.to_node for socket in node.outputs for link in socket.links} - while ports := {target for target in targets if target.bl_idname == "NodeReroute"}: - targets.difference_update(ports) - targets.update(link.to_node for port in ports for link in port.outputs[0].links) - assert len(targets) == 1, node.name - assert all(socket.hide for socket in node.outputs if not socket.is_linked) - for (before_points, before_faces), (after_points, after_faces) in zip(geometry, evaluate()): np.testing.assert_allclose(after_points, before_points, atol=1e-6) assert after_faces == before_faces - positions = { - node.name: tuple(node.location_absolute) for node in node_group.nodes - if node.bl_idname not in {"NodeReroute", "NodeGroupInput"} - } - arrange_nodes(node_group) - assert _program(node_group) == program - assert {name: tuple(node_group.nodes[name].location_absolute) for name in positions} == positions - locations = {node: tuple(node.location_absolute) for node in node_group.nodes} - sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes} - tracks = [] - for link in node_group.links: - if (link.from_socket.type != "GEOMETRY" and link.from_node.bl_idname != "NodeReroute" - and link.to_node.get("arrangement_reference")): - start, end = link_endpoints(link, locations, sizes) - assert end[0] >= start[0], (link.from_node.name, link.to_node.name) - if link.from_socket.type != "GEOMETRY": - continue - start, end = link_endpoints(link, locations, sizes) - consumers, pending = set(), [link.to_node] - while pending: - node = pending.pop() - if node in consumers: - continue - consumers.add(node) - if node.bl_idname == "NodeReroute": - pending.extend(edge.to_node for edge in node.outputs[0].links) - allowed = {origin_socket(link.from_socket).node.parent, *(node.parent for node in consumers)} - footprint = curve_footprint(start, end) - for frame in node_group.nodes: - if frame.bl_idname != "NodeFrame" or frame in allowed: - continue - x, y = locations[frame] - assert not any(left < x + frame.width - 0.01 and right > x + 0.01 - and top > y - frame.height + 0.01 and bottom < y - 0.01 - for left, right, top, bottom in footprint), (link.from_node.name, link.to_node.name, frame.name) - if abs(start[1] - end[1]) < 0.01: - assert end[0] - start[0] >= 20, (link.from_node.name, link.to_node.name) - tracks.append((start, end)) - for index, (start, end) in enumerate(tracks): - for other_start, other_end in tracks[index + 1:]: - assert (abs(start[1] - other_start[1]) >= 1 - or min(end[0], other_end[0]) - max(start[0], other_start[0]) <= 1) - for node in node_group.nodes: - sources = [link.from_socket for link in peer_links(node)] - if len(sources) < 2: - continue - # Measure branch geometry rather than obstacle-avoidance waypoints. - sources = [origin_socket(socket) if socket.node.get("arrangement_reference") else socket for socket in sources] - levels = [socket.node.location_absolute.y - estimate_node_dimensions(socket.node).sockets[socket] for socket in sources] - size = estimate_node_dimensions(node) - output = next(socket for socket in node.outputs if socket in size.sockets) - assert node.location_absolute.y - size.sockets[output] == pytest.approx((min(levels) + max(levels)) / 2, abs=0.001), node.name - assert all(first >= second - 0.001 for first, second in zip(levels, levels[1:])), node.name - - -def test_dimensions_include_unlinked_vector_controls_and_collapsed_sockets(): - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = bpy.data.node_groups.new("Dimensions", "GeometryNodeTree") - source = node_group.nodes.new("GeometryNodeInputPosition") - vector = node_group.nodes.new("ShaderNodeVectorMath") - vector.operation = "ADD" - expanded = estimate_node_dimensions(vector) - node_group.links.new(source.outputs[0], vector.inputs[0]) - linked = estimate_node_dimensions(vector) - assert expanded.height - linked.height == 3 * RULES["row"] - vector.hide = True - collapsed = estimate_node_dimensions(vector) - assert collapsed.height < linked.height - assert all(0 < offset < collapsed.height for offset in collapsed.sockets.values()) - - -def test_local_inputs_preserve_identifiers_with_duplicate_display_names(): - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = bpy.data.node_groups.new("Controls", "GeometryNodeTree") - first = node_group.interface.new_socket(name="Thickness", in_out="INPUT", socket_type="NodeSocketFloat") - second = node_group.interface.new_socket(name="Thickness", in_out="INPUT", socket_type="NodeSocketFloat") - first.default_value, second.default_value = 0.25, 0.75 - node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - controls = node_group.nodes.new("NodeGroupInput") - grid = node_group.nodes.new("GeometryNodeMeshGrid") - output = node_group.nodes.new("NodeGroupOutput") - node_group.links.new(controls.outputs[first.identifier], grid.inputs["Size X"]) - node_group.links.new(controls.outputs[second.identifier], grid.inputs["Size Y"]) - node_group.links.new(grid.outputs["Mesh"], output.inputs["Geometry"]) - program = _program(node_group) - arrange_nodes(node_group) - assert _program(node_group) == program - - -@pytest.mark.parametrize("branch_lengths", [(1, 1), (1, 3), (3, 1), (1, 2, 3)]) -def test_geometry_paths_order_join_branches_and_separate_their_shared_input(branch_lengths): - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = bpy.data.node_groups.new("Parallel Geometry", "GeometryNodeTree") - node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - source = node_group.nodes.new("GeometryNodeMeshGrid") - join = node_group.nodes.new("GeometryNodeJoinGeometry") - output = node_group.nodes.new("NodeGroupOutput") - branches = [] - for length in branch_lengths: - geometry = source.outputs["Mesh"] - branch = [] - for _ in range(length): - position = node_group.nodes.new("GeometryNodeSetPosition") - node_group.links.new(geometry, position.inputs["Geometry"]) - geometry = position.outputs["Geometry"] - branch.append(position) - node_group.links.new(geometry, join.inputs["Geometry"]) - branches.append(branch) - node_group.links.new(source.outputs["Mesh"], join.inputs["Geometry"]) - node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"]) - - geometry_parents, paths = geometry_paths(node_group) - assert set(geometry_parents) == {join, output, *(node for branch in branches for node in branch)} - assert [join, output] in paths - assert all(branch in paths for branch in branches) - assert source not in geometry_parents - assert sum(len(path) for path in paths) == len(geometry_parents) - program = _program(node_group) - arrange_nodes(node_group) - assert _program(node_group) == program - levels = [link.from_node.location_absolute.y - estimate_node_dimensions(link.from_node).sockets[link.from_socket] - for link in peer_links(join)] - assert levels == sorted(levels, reverse=True) - - if branch_lengths == (1, 2, 3): - heights = {node: node.location_absolute.y for node in geometry_parents} - field = node_group.nodes.new("GeometryNodeInputPosition").outputs["Position"] - calculations = [field.node] - for _ in range(30): - operation = node_group.nodes.new("ShaderNodeVectorMath") - operation.operation = "ADD" - node_group.links.new(field, operation.inputs[0]) - field = operation.outputs["Vector"] - calculations.append(operation) - node_group.links.new(field, branches[0][0].inputs["Offset"]) - program = _program(node_group) - arrange_nodes(node_group) - assert _program(node_group) == program - assert {node: node.location_absolute.y for node in geometry_parents} == heights - assert max(node.location_absolute.y for node in calculations) - min( - node.location_absolute.y - estimate_node_dimensions(node).height for node in calculations - ) <= FIELD_HEIGHT * 3 - assert max(node.location_absolute.x + node.width for node in calculations) - min( - node.location_absolute.x for node in calculations - ) < 4000 - frames = {node.parent for node in calculations} - assert len(frames) == 1 - frame = frames.pop() - assert frame is not None and frame.bl_idname == "NodeFrame" - from tools.nodes.preview.export import _node_group_data - data = _node_group_data(node_group) - record = next(item for item in data["frames"] if item["title"] == frame.label) - for index in record["nodes"]: - node = data["nodes"][index] - assert record["x"] <= node["x"] < node["x"] + node["w"] <= record["x"] + record["w"] + 0.01 - assert record["y"] <= node["y"] < node["y"] + node["h"] <= record["y"] + record["h"] + 0.01 - - -def test_geometry_used_for_field_sampling_stays_with_the_field_branch(): - from tools.nodes.preview.export import _node_group_data - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = bpy.data.node_groups.new("Sampled Geometry", "GeometryNodeTree") - node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - source = node_group.nodes.new("GeometryNodeMeshGrid") - separate = node_group.nodes.new("GeometryNodeSeparateGeometry") - sample = node_group.nodes.new("GeometryNodeSampleIndex") - sample.data_type = "FLOAT_VECTOR" - position = node_group.nodes.new("GeometryNodeSetPosition") - output = node_group.nodes.new("NodeGroupOutput") - node_group.links.new(source.outputs["Mesh"], separate.inputs["Geometry"]) - node_group.links.new(separate.outputs["Selection"], sample.inputs["Geometry"]) - node_group.links.new(sample.outputs["Value"], position.inputs["Position"]) - node_group.links.new(source.outputs["Mesh"], position.inputs["Geometry"]) - node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"]) - - geometry_parents, paths = geometry_paths(node_group) - assert set(geometry_parents) == {source, position, output} - assert paths == [[source, position, output]] - program = _program(node_group) - arrange_nodes(node_group) - assert _program(node_group) == program - roles = {node["name"]: node["role"] for node in _node_group_data(node_group)["nodes"]} - assert roles[separate.name] == roles[sample.name] == "field" - assert roles[position.name] == "geometry" - node_group.links.new(separate.outputs["Selection"], output.inputs["Geometry"]) - roles = {node["name"]: node["role"] for node in _node_group_data(node_group)["nodes"]} - assert roles[separate.name] == "geometry" - - -def test_shared_field_references_branch_near_their_consumers(): - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = bpy.data.node_groups.new("Shared Field", "GeometryNodeTree") - node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - source = node_group.nodes.new("GeometryNodeInputPosition") - geometry = node_group.nodes.new("GeometryNodeMeshGrid").outputs["Mesh"] - consumers = [] - for index in range(8): - node = node_group.nodes.new("GeometryNodeSetPosition") - node_group.links.new(geometry, node.inputs["Geometry"]) - if index in (0, 4, 7): - node_group.links.new(source.outputs["Position"], node.inputs["Offset"]) - consumers.append(node) - geometry = node.outputs["Geometry"] - output = node_group.nodes.new("NodeGroupOutput") - node_group.links.new(geometry, output.inputs["Geometry"]) - program = _program(node_group) - arrange_nodes(node_group) - assert _program(node_group) == program - for node in consumers[1:]: - branch = node.inputs["Offset"].links[0].from_node - assert branch.bl_idname == "NodeReroute" - assert node.location_absolute.x - branch.location_absolute.x >= ROUTE_RUN - assert any(node.bl_idname == "NodeReroute" and len(node.outputs[0].links) > 1 - for node in node_group.nodes) - assert all(link.to_node.location_absolute.x > link.from_node.location_absolute.x - for link in node_group.links - if link.from_node.bl_idname == link.to_node.bl_idname == "NodeReroute") - - -@pytest.mark.parametrize("side", ["upper", "lower"]) -def test_field_modules_reserve_lane_and_exit_clearance(side): - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = bpy.data.node_groups.new("Branch Rectangles", "GeometryNodeTree") - node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - source = node_group.nodes.new("GeometryNodeMeshGrid") - join = node_group.nodes.new("GeometryNodeJoinGeometry") - output = node_group.nodes.new("NodeGroupOutput") - branches = [] - for _ in range(2): - geometry, branch = source.outputs["Mesh"], [] - for _ in range(5): - node = node_group.nodes.new("GeometryNodeSetPosition") - node_group.links.new(geometry, node.inputs["Geometry"]) - geometry = node.outputs["Geometry"] - branch.append(node) - branches.append(branch) - node_group.links.new(geometry, join.inputs["Geometry"]) - node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"]) - arrange_nodes(node_group) - branches.sort(key=lambda branch: branch[0].location_absolute.y, reverse=True) - lane = branches[0 if side == "upper" else -1] - heights = [node.location_absolute.y for branch in branches for node in branch] - fields = [] - for target in (lane[1], lane[3]): - value = node_group.nodes.new("GeometryNodeInputPosition").outputs[0] - branch = [value.node] - for _ in range(8): - node = node_group.nodes.new("ShaderNodeVectorMath") - node.operation = "ADD" - node_group.links.new(value, node.inputs[0]) - value = node.outputs["Vector"] - branch.append(node) - node_group.links.new(value, target.inputs["Offset"]) - fields.append(branch) - program = _program(node_group) - arrange_nodes(node_group) - assert _program(node_group) == program - assert [node.location_absolute.y for branch in branches for node in branch] == heights - boxes = [(min(node.location_absolute.x for node in branch), - max(node.location_absolute.x + estimate_node_dimensions(node).width for node in branch), - max(node.location_absolute.y for node in branch), - min(node.location_absolute.y - estimate_node_dimensions(node).height for node in branch)) for branch in fields] - core, _ = geometry_paths(node_group) - core_left = min(node.location_absolute.x for node in core) - for _, right, top, bottom in boxes: - if right + BRANCH_GAP <= core_left + 0.001: - continue - if side == "upper": - assert bottom >= lane[0].location_absolute.y + BRANCH_GAP - 0.001 - else: - assert top <= lane[0].location_absolute.y - estimate_node_dimensions(lane[0]).height - BRANCH_GAP + 0.001 - first, second = boxes - assert (first[1] + BRANCH_GAP <= second[0] + 0.001 or second[1] + BRANCH_GAP <= first[0] + 0.001 - or first[3] >= second[2] + BRANCH_GAP - 0.001 or second[3] >= first[2] + BRANCH_GAP - 0.001) - if side == "lower": - assert any(right + BRANCH_GAP <= core_left + 0.001 for _, right, _, _ in boxes) - for branch, target, other in zip(fields, (lane[1], lane[3]), reversed(boxes)): - terminal = branch[-1] - assert terminal.location_absolute.x + terminal.width < target.location_absolute.x - socket = terminal.outputs["Vector"] - x1 = terminal.location_absolute.x + terminal.width - y1 = terminal.location_absolute.y - estimate_node_dimensions(terminal).sockets[socket] - x2 = target.location_absolute.x - y2 = target.location_absolute.y - estimate_node_dimensions(target).sockets[target.inputs["Offset"]] - bend = max(40, (x2 - x1) * 0.42) - t = np.linspace(0, 1, 200) - x = (1-t)**3*x1 + 3*(1-t)**2*t*(x1+bend) + 3*(1-t)*t**2*(x2-bend) + t**3*x2 - y = (1-t)**3*y1 + 3*(1-t)**2*t*y1 + 3*(1-t)*t**2*y2 + t**3*y2 - left, right, top, bottom = other - assert not np.any((left < x) & (x < right) & (bottom < y) & (y < top)) - - -def test_boolean_operands_follow_socket_order_and_center_the_junction(): - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = bpy.data.node_groups.new("Boolean Operands", "GeometryNodeTree") - node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - comparisons = [node_group.nodes.new("FunctionNodeCompare") for _ in range(2)] - operation = node_group.nodes.new("FunctionNodeBooleanMath") - operation.operation = "OR" - grid = node_group.nodes.new("GeometryNodeMeshGrid") - output = node_group.nodes.new("NodeGroupOutput") - for source, socket in zip(comparisons, operation.inputs): - node_group.links.new(source.outputs[0], socket) - node_group.links.new(operation.outputs[0], grid.inputs["Size X"]) - node_group.links.new(grid.outputs["Mesh"], output.inputs["Geometry"]) - program = _program(node_group) - arrange_nodes(node_group) - assert _program(node_group) == program - levels = [node.location_absolute.y - estimate_node_dimensions(node).sockets[node.outputs[0]] for node in comparisons] - assert levels[0] > levels[1] - assert comparisons[0].location_absolute.x + comparisons[0].width == comparisons[1].location_absolute.x + comparisons[1].width - assert operation.location_absolute.y - estimate_node_dimensions(operation).sockets[operation.outputs[0]] == pytest.approx(sum(levels) / 2) - - -def test_preview_is_read_only_and_escapes_node_names(tmp_path): - from tools.nodes.preview.export import write_node_preview - - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = build_image_plane_group() - arrange_nodes(node_group) - node_group.nodes[0].name = '' - program = _program(node_group) - positions = {node.name: tuple(node.location_absolute) for node in node_group.nodes} - destination = write_node_preview([node_group], tmp_path / "nodes.html") - markup = destination.read_text(encoding="utf-8") - assert markup.startswith("") - assert '__NODE_DATA__' not in markup - assert '' not in markup - assert '\\u003c/script>' in markup - assert _program(node_group) == program - assert {node.name: tuple(node.location_absolute) for node in node_group.nodes} == positions - - -def test_geometry_bypass_has_two_level_corners_next_to_processing(): - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = bpy.data.node_groups.new("Geometry Bypass", "GeometryNodeTree") - node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - source = node_group.nodes.new("GeometryNodeMeshGrid") - transform = node_group.nodes.new("GeometryNodeTransform") - switch = node_group.nodes.new("GeometryNodeSwitch") - switch.input_type = "GEOMETRY" - output = node_group.nodes.new("NodeGroupOutput") - node_group.links.new(source.outputs["Mesh"], transform.inputs["Geometry"]) - node_group.links.new(source.outputs["Mesh"], switch.inputs["False"]) - node_group.links.new(transform.outputs["Geometry"], switch.inputs["True"]) - node_group.links.new(switch.outputs["Output"], output.inputs["Geometry"]) - program = _program(node_group) - arrange_nodes(node_group) - exit_port = switch.inputs["False"].links[0].from_node - entry_port = exit_port.inputs[0].links[0].from_node - assert entry_port.bl_idname == exit_port.bl_idname == "NodeReroute" - assert entry_port.location_absolute.y == exit_port.location_absolute.y - assert source.location_absolute.x + source.width < entry_port.location_absolute.x < exit_port.location_absolute.x < switch.location_absolute.x - assert exit_port.location_absolute.x + 16 == pytest.approx(transform.location_absolute.x + transform.width) - assert _program(node_group) == program - - -@pytest.mark.parametrize("zone_type", ["Repeat", "Simulation", "ForeachGeometryElement"]) -def test_preview_zone_includes_repeat_value_branch_with_side_clearance(zone_type): - from tools.nodes.preview.export import _node_group_data - - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = bpy.data.node_groups.new("Zone Preview", "GeometryNodeTree") - source = node_group.nodes.new("GeometryNodeMeshGrid") - entry = node_group.nodes.new(f"GeometryNode{zone_type}Input") - end = node_group.nodes.new(f"GeometryNode{zone_type}Output") - entry.pair_with_output(end) - body = node_group.nodes.new("GeometryNodeSetPosition") - node_group.links.new(source.outputs["Mesh"], entry.inputs["Geometry"]) - geometry = next(socket for socket in entry.outputs if socket.type == "GEOMETRY") - node_group.links.new(geometry, body.inputs["Geometry"]) - geometry_input = next(socket for socket in end.inputs if socket.type == "GEOMETRY") - node_group.links.new(body.outputs["Geometry"], geometry_input) - sample = node_group.nodes.new("GeometryNodeSampleIndex") - sample.data_type = "FLOAT_VECTOR" - node_group.links.new(geometry, sample.inputs["Geometry"]) - node_group.links.new(sample.outputs["Value"], body.inputs["Position"]) - sample.location_absolute = (-1200, -400) - entry.location_absolute, body.location_absolute, end.location_absolute = (0, 0), (300, 0), (700, 0) - if zone_type == "Repeat": - entry.location_absolute = (-1600, 0) - data = _node_group_data(node_group) - zone = data["zones"][0] - names = {data["nodes"][index]["name"] for index in zone["nodes"]} - expected = {entry.name, body.name, end.name} - if zone_type == "Repeat": - expected.add(sample.name) - assert names == expected - padding = 60 if zone_type == "Repeat" else 40 - boxes = [data["nodes"][index] for index in zone["nodes"]] - assert min(node["x"] for node in boxes) - zone["x"] == padding - assert zone["x"] + zone["w"] - max(node["x"] + node["w"] for node in boxes) == padding - for index in zone["nodes"]: - node = data["nodes"][index] - assert zone["x"] <= node["x"] and node["x"] + node["w"] <= zone["x"] + zone["w"] - assert zone["y"] <= node["y"] and node["y"] + node["h"] <= zone["y"] + zone["h"] - - -def test_repeat_geometry_collector_stays_in_the_local_body(): - from tools.nodes.arrangement.zones import node_zones - - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = CUTOUT_BUILDERS["SOLID"]() - arrange_nodes(node_group) - entry = node_group.nodes["Repeat Input"] - end = entry.paired_output - collector = node_group.nodes["Join Geometry.001"] - assert collector in next(members for start, _, members in node_zones(node_group) if start == entry) - assert entry.location_absolute.x < collector.location_absolute.x < end.location_absolute.x - assert abs(collector.location_absolute.y - entry.location_absolute.y) <= FIELD_HEIGHT + BRANCH_GAP - assert entry.location_absolute.y == end.location_absolute.y - - -@pytest.mark.parametrize("length", [1, 20]) -def test_repeat_places_value_nodes_between_its_boundaries(length): - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = bpy.data.node_groups.new("Compact Repeat", "GeometryNodeTree") - node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - source = node_group.nodes.new("GeometryNodeMeshGrid") - entry = node_group.nodes.new("GeometryNodeRepeatInput") - end = node_group.nodes.new("GeometryNodeRepeatOutput") - entry.pair_with_output(end) - body = node_group.nodes.new("GeometryNodeSetPosition") - output = node_group.nodes.new("NodeGroupOutput") - node_group.links.new(source.outputs["Mesh"], entry.inputs["Geometry"]) - node_group.links.new(entry.outputs["Geometry"], body.inputs["Geometry"]) - node_group.links.new(body.outputs["Geometry"], end.inputs["Geometry"]) - node_group.links.new(end.outputs["Geometry"], output.inputs["Geometry"]) - sample = node_group.nodes.new("GeometryNodeSampleIndex") - sample.data_type = "FLOAT_VECTOR" - node_group.links.new(entry.outputs["Geometry"], sample.inputs["Geometry"]) - value = sample.outputs["Value"] - for _ in range(length): - operation = node_group.nodes.new("ShaderNodeVectorMath") - operation.operation = "NORMALIZE" - node_group.links.new(value, operation.inputs[0]) - value = operation.outputs[0] - node_group.links.new(value, body.inputs["Position"]) - program = _program(node_group) - arrange_nodes(node_group) - from tools.nodes.arrangement.zones import node_zones - members = next(members for start, _, members in node_zones(node_group) if start == entry) - for node in members - {entry, end}: - if node.bl_idname == "NodeReroute": - continue - assert node.location_absolute.x >= entry.location_absolute.x + entry.width + 159.99 - assert node.location_absolute.x + estimate_node_dimensions(node).width <= end.location_absolute.x - 159.99 - # The exit also reserves the short run needed by its lower geometry input. - assert 100 <= end.location_absolute.x - body.location_absolute.x - body.width <= 200 - assert entry.location_absolute.y == body.location_absolute.y == end.location_absolute.y - assert _program(node_group) == program - - -def test_depth_cutout_repeat_precedes_downstream_geometry_through_shared_fields(): - from tools.nodes.arrangement.flow import graph - from tools.nodes.arrangement.zones import node_zones - - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = CUTOUT_BUILDERS["DEPTH_SOLID"]() - arrange_nodes(node_group) - core, _ = geometry_paths(node_group) - _, _, _, ancestors = graph(node_group) - entries = [node for node in core if node.bl_idname == "GeometryNodeRepeatInput"] - assert entries - for entry, end, members in node_zones(node_group): - for node in members - {entry, end}: - if node.bl_idname == "NodeReroute": - assert entry.location_absolute.x <= node.location_absolute.x, node.name - assert node.location_absolute.x + estimate_node_dimensions(node).width <= end.location_absolute.x + end.width, node.name - continue - assert node.location_absolute.x >= entry.location_absolute.x + entry.width + 159.99, node.name - assert node.location_absolute.x + estimate_node_dimensions(node).width <= end.location_absolute.x - 159.99, node.name - spans = [] - for entry in entries: - end = entry.paired_output - body = origin_socket(end.inputs["Geometry"].links[0].from_socket).node - assert body.bl_idname == "GeometryNodeSetPosition" - spans.append(end.location_absolute.x - entry.location_absolute.x) - assert spans[-1] > 1000 - value = origin_socket(body.inputs["Position"].links[0].from_socket).node - assert 100 <= body.location_absolute.x - value.location_absolute.x - estimate_node_dimensions(value).width <= 1000 - for node in core: - if end in ancestors[node]: - assert end.location_absolute.x + end.width < node.location_absolute.x, node.name - assert max(spans) > 1000 - - from tools.nodes.arrangement.zones import repeat_regions - from tools.nodes.arrangement.connections import link_endpoints - from tools.nodes.arrangement.passages import _intersects - positions = {node: tuple(node.location_absolute) for node in node_group.nodes} - sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes} - for members, box in repeat_regions(node_group, positions, sizes): - left, right, top, bottom = box - for node in node_group.nodes: - if node in members or node.bl_idname == "NodeFrame": - continue - x, y = positions[node] - assert x + sizes[node].width <= left or x >= right or y <= bottom or y - sizes[node].height >= top, node.name - for link in node_group.links: - source = origin_socket(link.from_socket).node - consumers, pending = set(), [link.to_node] - while pending: - node = pending.pop() - if node in consumers: - continue - consumers.add(node) - if node.bl_idname == "NodeReroute": - pending.extend(edge.to_node for edge in node.outputs[0].links) - if source not in members and not consumers & members: - assert not _intersects(*link_endpoints(link, positions, sizes), [box]), (source.name, link.to_node.name) - - -def test_shared_geometry_uses_one_forward_trunk_with_late_branches(): - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = bpy.data.node_groups.new("Shared Geometry References", "GeometryNodeTree") - node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - shared = node_group.nodes.new("GeometryNodeMeshGrid") - geometry = node_group.nodes.new("GeometryNodeMeshGrid").outputs["Mesh"] - samplers = [] - for index in range(10): - node = node_group.nodes.new("GeometryNodeSetPosition") - node_group.links.new(geometry, node.inputs["Geometry"]) - geometry = node.outputs["Geometry"] - if index in (0, 5, 9): - sample = node_group.nodes.new("GeometryNodeSampleIndex") - sample.data_type = "FLOAT_VECTOR" - node_group.links.new(shared.outputs["Mesh"], sample.inputs["Geometry"]) - node_group.links.new(sample.outputs["Value"], node.inputs["Offset"]) - samplers.append(sample) - output = node_group.nodes.new("NodeGroupOutput") - node_group.links.new(geometry, output.inputs["Geometry"]) - program = _program(node_group) - arrange_nodes(node_group) - for sample in samplers[1:]: - branch = sample.inputs["Geometry"].links[0].from_node - assert branch.bl_idname == "NodeReroute" - assert branch.location_absolute.x > shared.location_absolute.x + (sample.location_absolute.x - shared.location_absolute.x) / 2 - assert any(node.bl_idname == "NodeReroute" and len(node.outputs[0].links) > 1 - for node in node_group.nodes if node.inputs and node.inputs[0].is_linked - and origin_socket(node.inputs[0].links[0].from_socket).node == shared) - assert _program(node_group) == program - - -def test_reused_geometry_processing_keeps_its_upstream_result_path(): - from tools.nodes.arrangement.flow import node_roles - - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = bpy.data.node_groups.new("Reused Processing", "GeometryNodeTree") - node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - grid = node_group.nodes.new("GeometryNodeMeshGrid") - transform = node_group.nodes.new("GeometryNodeTransform") - position = node_group.nodes.new("GeometryNodeSetPosition") - output = node_group.nodes.new("NodeGroupOutput") - node_group.links.new(grid.outputs["Mesh"], transform.inputs["Geometry"]) - node_group.links.new(transform.outputs["Geometry"], position.inputs["Geometry"]) - node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"]) - for name in ("Selection", "Offset"): - separate = node_group.nodes.new("GeometryNodeSeparateGeometry") - sample = node_group.nodes.new("GeometryNodeSampleIndex") - node_group.links.new(transform.outputs["Geometry"], separate.inputs["Geometry"]) - node_group.links.new(separate.outputs["Selection"], sample.inputs["Geometry"]) - node_group.links.new(sample.outputs["Value"], position.inputs[name]) - arrange_nodes(node_group) - roles = node_roles(node_group) - assert all(roles[node] == "geometry" for node in (grid, transform, position, output)) - assert grid.location_absolute.y == transform.location_absolute.y == position.location_absolute.y == output.location_absolute.y - assert all(roles[node] == "field" for node in node_group.nodes - if node.bl_idname in {"GeometryNodeSampleIndex", "GeometryNodeSeparateGeometry"}) - - -def test_geometry_preparation_shared_by_independent_paths_becomes_a_public_input(): - from tools.nodes.arrangement.flow import node_roles - - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = bpy.data.node_groups.new("Public Prepared Geometry", "GeometryNodeTree") - node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - grid = node_group.nodes.new("GeometryNodeMeshGrid") - prepare = node_group.nodes.new("GeometryNodeTransform") - join = node_group.nodes.new("GeometryNodeJoinGeometry") - output = node_group.nodes.new("NodeGroupOutput") - node_group.links.new(grid.outputs["Mesh"], prepare.inputs["Geometry"]) - branches = [] - for _ in range(3): - node = node_group.nodes.new("GeometryNodeSetPosition") - node_group.links.new(prepare.outputs["Geometry"], node.inputs["Geometry"]) - node_group.links.new(node.outputs["Geometry"], join.inputs["Geometry"]) - branches.append(node) - node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"]) - program = _program(node_group) - arrange_nodes(node_group) - assert _program(node_group) == program - roles = node_roles(node_group) - assert roles[grid] == roles[prepare] == "field" - assert all(roles[node] == "geometry" for node in branches) - assert prepare.location_absolute.x + prepare.width < min(node.location_absolute.x for node in branches) - - -def test_shared_source_moves_towards_consumers_when_space_is_available(): - from tools.nodes.arrangement.sources import place_shared_sources - - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Shared Source Placement", "GeometryNodeTree") - source = group.nodes.new("ShaderNodeMath") - target = group.nodes.new("ShaderNodeMath") - group.links.new(source.outputs[0], target.inputs[0]) - positions = {source: (-10000, 0), target: (2000, 0)} - sizes = {node: estimate_node_dimensions(node) for node in positions} - bodies = [({node}, (x, x + sizes[node].width, y, y - sizes[node].height)) - for node, (x, y) in positions.items()] - place_shared_sources(bodies, {source}, positions, sizes, gap=160) - assert 0 < positions[source][0] < positions[target][0] - sizes[source].width - - -def test_repeat_value_branches_leave_geometry_corridors_clear(): - from tools.nodes.arrangement.connections import link_endpoints - - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = CUTOUT_BUILDERS["SOLID"]() - arrange_nodes(node_group) - core, _ = geometry_paths(node_group) - sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes} - positions = {node: tuple(node.location_absolute) for node in node_group.nodes} - frames = [node for node in node_group.nodes if node.bl_idname == "NodeFrame"] - for index, frame in enumerate(frames): - left, top = positions[frame] - for other in frames[index + 1:]: - x, y = positions[other] - assert (left + frame.width <= x or x + other.width <= left - or top - frame.height >= y or y - other.height >= top) - for port in core: - if not port.get("arrangement_input") or port.outputs[0].type != "GEOMETRY": - continue - source = origin_socket(port.outputs[0]).node - if source not in core: - continue - left = source.location_absolute.x + sizes[source].width - right, level = port.location_absolute.x + 8, port.location_absolute.y - 8 - for node in node_group.nodes: - if node in core or node.bl_idname in {"NodeReroute", "NodeFrame"}: - continue - if left < node.location_absolute.x + sizes[node].width and node.location_absolute.x < right: - assert not node.location_absolute.y - sizes[node].height < level < node.location_absolute.y, node.name - for link in node_group.links: - if link.from_socket.type != "GEOMETRY": - continue - start, target = link_endpoints(link, positions, sizes) - if abs(start[1] - target[1]) > 0.01: - continue - for node in node_group.nodes: - if node in core or node.bl_idname in {"NodeReroute", "NodeFrame"} or node in {link.from_node, link.to_node}: - continue - if start[0] < node.location_absolute.x + sizes[node].width and node.location_absolute.x < target[0]: - assert not node.location_absolute.y - sizes[node].height < start[1] < node.location_absolute.y, node.name - - -def test_expression_skip_links_have_passages_between_intermediate_nodes(): - from tools.nodes.arrangement.connections import link_endpoints - - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = bpy.data.node_groups.new("Expression Passages", "GeometryNodeTree") - node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - grid = node_group.nodes.new("GeometryNodeMeshGrid") - position = node_group.nodes.new("GeometryNodeSetPosition") - output = node_group.nodes.new("NodeGroupOutput") - seed = node_group.nodes.new("GeometryNodeInputIndex") - previous = node_group.nodes.new("GeometryNodeInputMeshFaceArea").outputs[0] - fields = [seed, previous.node] - for _ in range(24): - operation = node_group.nodes.new("ShaderNodeMath") - operation.operation = "ADD" - node_group.links.new(previous, operation.inputs[0]) - node_group.links.new(seed.outputs[0], operation.inputs[1]) - previous = operation.outputs[0] - fields.append(operation) - node_group.links.new(grid.outputs["Mesh"], position.inputs["Geometry"]) - node_group.links.new(previous, position.inputs["Offset"]) - node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"]) - program = _program(node_group) - arrange_nodes(node_group) - assert _program(node_group) == program - operations = [node for node in fields if node.bl_idname == "ShaderNodeMath"] - assert all(first.location_absolute.x < last.location_absolute.x - for first, last in zip(operations, operations[1:])) - sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes} - positions = {node: tuple(node.location_absolute) for node in node_group.nodes} - assert any(node.get("arrangement_branch") for node in node_group.nodes) - for link in node_group.links: - if origin_socket(link.from_socket).node not in fields: - continue - (x1, y1), (x2, y2) = link_endpoints(link, positions, sizes) - bend = max(40, abs(x2 - x1) * 0.42) - for t in np.linspace(0, 1, 100): - x = (1-t)**3*x1 + 3*(1-t)**2*t*(x1+bend) + 3*(1-t)*t*t*(x2-bend) + t**3*x2 - y = (1-t)**3*y1 + 3*(1-t)**2*t*y1 + 3*(1-t)*t*t*y2 + t**3*y2 - for node in fields: - if node in {link.from_node, link.to_node}: - continue - left, top = positions[node] - assert not (left < x < left + sizes[node].width and top - sizes[node].height < y < top), node.name - - -@pytest.mark.parametrize("offset", [260, 3500]) -def test_geometry_reference_tracks_have_slanted_entry_and_exit(offset): - from tools.nodes.arrangement.connections import link_endpoints - from tools.nodes.arrangement.references import TRACK_GAP, route_references - - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Slanted References", "GeometryNodeTree") - for index in range(2): - source = group.nodes.new("GeometryNodeMeshGrid") - source.location = (index * offset, index * 3200 if offset > 260 else 0) - for x, y in ((1800, 900), (2600, -900)): - target = group.nodes.new("GeometryNodeSampleIndex") - target.location = (x + index * offset, y - (index * 3000 if offset > 260 else 0)) - group.links.new(source.outputs["Mesh"], target.inputs["Geometry"]) - program = _program(group) - route_references(group, []) - assert _program(group) == program - positions = {node: tuple(node.location_absolute) for node in group.nodes} - sizes = {node: estimate_node_dimensions(node) for node in group.nodes} - tracks = [] - for link in group.links: - start, end = link_endpoints(link, positions, sizes) - dx, dy = end[0] - start[0], end[1] - start[1] - if abs(dy) > 100: - assert abs(dx) >= abs(dy) * 0.79 - elif abs(dy) < 0.01 and abs(dx) > 100: - assert dx > 0 - tracks.append((min(start[0], end[0]), max(start[0], end[0]), start[1], origin_socket(link.from_socket))) - assert tracks - for index, (left, right, y, source) in enumerate(tracks): - for a, b, level, other in tracks[index + 1:]: - if source != other and left < b and a < right: - assert abs(y - level) >= TRACK_GAP - 0.01 - - -@pytest.mark.parametrize("source_x", [0, 3000]) -def test_geometry_reference_trunk_never_retraces_itself(source_x): - from tools.nodes.arrangement.connections import link_endpoints - from tools.nodes.arrangement.references import route_references - - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Direct Return", "GeometryNodeTree") - source = group.nodes.new("GeometryNodeMeshGrid") - source.location = (source_x, 0) - for x, y in ((0, -800), (600, -1000)): - target = group.nodes.new("GeometryNodeSampleIndex") - target.location = (x, y) - group.links.new(source.outputs["Mesh"], target.inputs["Geometry"]) - program = _program(group) - route_references(group, []) - assert _program(group) == program - corners = [node for node in group.nodes if node.bl_idname == "NodeReroute"] - assert corners - if source_x: - assert all(node.location_absolute.x < source.location_absolute.x for node in corners) - positions = {node: tuple(node.location_absolute) for node in group.nodes} - sizes = {node: estimate_node_dimensions(node) for node in group.nodes} - segments = [] - for link in group.links: - start, end = link_endpoints(link, positions, sizes) - if abs(start[1] - end[1]) < 0.01: - assert end[0] >= start[0] - segments.append((start, end)) - for index, (start, end) in enumerate(segments): - for other_start, other_end in segments[index + 1:]: - assert (abs(start[1] - other_start[1]) >= 0.01 - or min(end[0], other_end[0]) <= max(start[0], other_start[0])) - - -def test_return_curve_stays_apart_from_the_nearly_horizontal_main_path(): - from tools.nodes.arrangement.connections import link_endpoints - from tools.nodes.arrangement.references import TRACK_GAP, route_references - - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Parallel Return", "GeometryNodeTree") - first = group.nodes.new("GeometryNodeMeshGrid") - source = group.nodes.new("GeometryNodeTransform") - consumer = group.nodes.new("GeometryNodeSampleIndex") - first.location = (0, 0) - source.location = (5000, 0) - consumer.location = (2000, -400) - group.links.new(first.outputs["Mesh"], source.inputs["Geometry"]) - group.links.new(source.outputs["Geometry"], consumer.inputs["Geometry"]) - program = _program(group) - route_references(group, []) - assert _program(group) == program - positions = {node: tuple(node.location_absolute) for node in group.nodes} - sizes = {node: estimate_node_dimensions(node) for node in group.nodes} - main = link_endpoints(source.inputs["Geometry"].links[0], positions, sizes) - start, end = link_endpoints(source.outputs["Geometry"].links[0], positions, sizes) - midpoint_y = (start[1] + end[1]) / 2 - assert (midpoint_y <= min(point[1] for point in main) - TRACK_GAP - or midpoint_y >= max(point[1] for point in main) + TRACK_GAP) - - -def test_geometry_trunk_clears_the_frame_of_its_own_consumers(): - from tools.nodes.arrangement.connections import link_endpoints - from tools.nodes.arrangement.frames import FRAME_PADDING, FRAME_HEADING - from tools.nodes.arrangement.references import FRAME_CLEARANCE, route_references - - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Frame Trunk Clearance", "GeometryNodeTree") - source = group.nodes.new("GeometryNodeMeshGrid") - consumers = [] - for x, y in ((6000, 900), (8000, -900)): - target = group.nodes.new("GeometryNodeSampleIndex") - target.location = (x, y) - group.links.new(source.outputs["Mesh"], target.inputs["Geometry"]) - consumers.append(target) - body = (5800, 8500, 1000, -1200) - program = _program(group) - route_references(group, [(set(consumers), body)]) - assert _program(group) == program - positions = {node: tuple(node.location_absolute) for node in group.nodes} - sizes = {node: estimate_node_dimensions(node) for node in group.nodes} - horizontal = [] - for link in group.links: - start, end = link_endpoints(link, positions, sizes) - if abs(start[1] - end[1]) < 0.01 and abs(start[0] - end[0]) > 100: - horizontal.append((start, end)) - if min(start[0], end[0]) < body[1] + FRAME_PADDING and max(start[0], end[0]) > body[0] - FRAME_PADDING: - assert (start[1] >= body[2] + FRAME_HEADING + FRAME_CLEARANCE - 0.01 - or start[1] <= body[3] - FRAME_PADDING - FRAME_CLEARANCE + 0.01) - assert horizontal - - -def test_independent_selection_and_offset_calculations_get_separate_frames(): - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Independent Inputs", "GeometryNodeTree") - group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - grid = group.nodes.new("GeometryNodeMeshGrid") - position = group.nodes.new("GeometryNodeSetPosition") - output = group.nodes.new("NodeGroupOutput") - group.links.new(grid.outputs["Mesh"], position.inputs["Geometry"]) - group.links.new(position.outputs["Geometry"], output.inputs["Geometry"]) - branches = [] - for socket, seed_type, node_type, operation in ( - ("Selection", "GeometryNodeInputIndex", "ShaderNodeMath", "ABSOLUTE"), - ("Offset", "GeometryNodeInputPosition", "ShaderNodeVectorMath", "NORMALIZE"), - ): - value = group.nodes.new(seed_type).outputs[0] - branch = [value.node] - for _ in range(12): - node = group.nodes.new(node_type) - node.operation = operation - group.links.new(value, node.inputs[0]) - value = node.outputs[0] - branch.append(node) - group.links.new(value, position.inputs[socket]) - branches.append(branch) - program = _program(group) - arrange_nodes(group) - assert _program(group) == program - frames = [branch[0].parent for branch in branches] - assert all(frame is not None for frame in frames) and frames[0] != frames[1] - for branch, frame in zip(branches, frames): - assert all(node.parent == frame for node in branch) - - -@pytest.mark.parametrize("sample_geometry", [False, True]) -def test_main_path_reserves_only_space_needed_after_geometry_source(sample_geometry): - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Local Field Delivery", "GeometryNodeTree") - group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - grid = group.nodes.new("GeometryNodeMeshGrid") - transform = group.nodes.new("GeometryNodeTransform") - consumer = group.nodes.new("GeometryNodeSetPosition") - output = group.nodes.new("NodeGroupOutput") - group.links.new(grid.outputs["Mesh"], transform.inputs["Geometry"]) - group.links.new(transform.outputs["Geometry"], consumer.inputs["Geometry"]) - group.links.new(consumer.outputs["Geometry"], output.inputs["Geometry"]) - arrange_nodes(group) - original_x = consumer.location_absolute.x - sample = group.nodes.new("GeometryNodeSampleIndex" if sample_geometry else "GeometryNodeInputPosition") - if sample_geometry: - sample.data_type = "FLOAT_VECTOR" - group.links.new(transform.outputs["Geometry"], sample.inputs["Geometry"]) - value = sample.outputs[0] - for _ in range(18): - node = group.nodes.new("ShaderNodeVectorMath") - node.operation = "ADD" - group.links.new(value, node.inputs[0]) - value = node.outputs[0] - group.links.new(value, consumer.inputs["Offset"]) - program = _program(group) - arrange_nodes(group) - assert _program(group) == program - if sample_geometry: - assert consumer.location_absolute.x > original_x + 700 - assert sample.location_absolute.x >= transform.location_absolute.x + transform.width - else: - assert consumer.location_absolute.x == original_x - assert 100 <= consumer.location_absolute.x - value.node.location_absolute.x - value.node.width <= 1000 - assert consumer.inputs["Geometry"].links[0].from_node == transform - -@pytest.mark.parametrize("socket_type", ["FLOAT", "VECTOR", "BOOLEAN", "RGBA", "ROTATION", "GEOMETRY"]) -def test_shared_results_use_one_peripheral_pipe(socket_type): - from tools.nodes.arrangement.connections import link_endpoints - from tools.nodes.arrangement.references import route_references, FRAME_CLEARANCE - - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Peripheral Results", "GeometryNodeTree") - source = group.nodes.new("GeometryNodeSwitch") - source.input_type = socket_type - consumers = [] - for x, y in ((2400, 500), (4800, -500), (7200, 200)): - node = group.nodes.new("GeometryNodeSwitch") - node.input_type = socket_type - node.location = (x, y) - group.links.new(source.outputs["Output"], node.inputs["False"]) - consumers.append(node) - program = _program(group) - route_references(group, [(set(consumers), (2200, 7400, 900, -900))]) - assert _program(group) == program - assert len(source.outputs["Output"].links) == 1 - assert all(node.inputs["False"].links[0].from_node.bl_idname == "NodeReroute" for node in consumers) - positions = {node: tuple(node.location_absolute) for node in group.nodes} - sizes = {node: estimate_node_dimensions(node) for node in group.nodes} - horizontal = [] - for link in group.links: - start, end = link_endpoints(link, positions, sizes) - if abs(start[1] - end[1]) < 0.01: - assert end[0] > start[0] - assert start[1] >= 900 + FRAME_CLEARANCE or start[1] <= -900 - FRAME_CLEARANCE - horizontal.append(link) - assert horizontal - - -@pytest.mark.parametrize("consumes_frame", [False, True]) -def test_geometry_only_enters_frames_that_consume_it(consumes_frame): - from tools.nodes.arrangement.connections import link_endpoints - from tools.nodes.arrangement.passages import route_field_passages - from tools.nodes.arrangement.frames import FRAME_PADDING, FRAME_HEADING - - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Frame Passage", "GeometryNodeTree") - source = group.nodes.new("GeometryNodeMeshGrid") - consumer = group.nodes.new("GeometryNodeSampleIndex") - consumer.location = (2500 if consumes_frame else 5000, 0) - group.links.new(source.outputs["Mesh"], consumer.inputs["Geometry"]) - members = {group.nodes.new("ShaderNodeMath") for _ in range(12)} - if consumes_frame: - members.add(consumer) - program = _program(group) - route_field_passages(group, [(members, (2000, 3000, 400, -600))]) - assert _program(group) == program - if consumes_frame: - assert consumer.inputs["Geometry"].links[0].from_node == source - return - assert consumer.inputs["Geometry"].links[0].from_node.bl_idname == "NodeReroute" - positions = {node: tuple(node.location_absolute) for node in group.nodes} - sizes = {node: estimate_node_dimensions(node) for node in group.nodes} - for link in group.links: - (x, y), (u, v) = link_endpoints(link, positions, sizes) - assert u > x - bend = max(40, abs(u - x) * 0.42) - for t in np.linspace(0, 1, 65): - px = (1-t)**3*x + 3*(1-t)**2*t*(x+bend) + 3*(1-t)*t*t*(u-bend) + t**3*u - py = (1-t)**3*y + 3*(1-t)**2*t*y + 3*(1-t)*t*t*v + t**3*v - assert not (2000-FRAME_PADDING < px < 3000+FRAME_PADDING - and -600-FRAME_PADDING < py < 400+FRAME_HEADING) - - -@pytest.mark.parametrize("shared", [False, True]) -@pytest.mark.parametrize("obstacle", [False, True]) -def test_reference_detour_removes_reversal_but_preserves_shared_junction(shared, obstacle): - from tools.nodes.arrangement.connections import link_endpoints - from tools.nodes.arrangement.passages import route_field_passages - - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Reference Detour", "GeometryNodeTree") - source = group.nodes.new("GeometryNodeMeshGrid") - consumer = group.nodes.new("GeometryNodeSampleIndex") - consumer.location = (5000, -1000) - corner = group.nodes.new("NodeReroute") - corner["arrangement_reference"] = True - corner.location = (6000, 0) - approach = group.nodes.new("NodeReroute") - approach["arrangement_reference"] = True - approach.location = (1000, 0) - group.links.new(source.outputs["Mesh"], approach.inputs[0]) - group.links.new(approach.outputs[0], corner.inputs[0]) - group.links.new(corner.outputs[0], consumer.inputs["Geometry"]) - if shared: - other = group.nodes.new("GeometryNodeSampleIndex") - other.location = (7000, -1000) - group.links.new(corner.outputs[0], other.inputs["Geometry"]) - members = {group.nodes.new("ShaderNodeMath") for _ in range(12)} if obstacle else set() - program = _program(group) - route_field_passages(group, [(members, (2000, 3000, 400, -1400))] if obstacle else []) - assert _program(group) == program - if shared: - assert corner in group.nodes.values() - else: - positions = {node: tuple(node.location_absolute) for node in group.nodes} - sizes = {node: estimate_node_dimensions(node) for node in group.nodes} - assert all(link_endpoints(link, positions, sizes)[1][0] > link_endpoints(link, positions, sizes)[0][0] - for link in group.links) - - -def test_value_passage_keeps_forward_entrance_when_consumer_is_left(): - from tools.nodes.arrangement.passages import route_field_passages - - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Forward Value Entrance", "GeometryNodeTree") - source = group.nodes.new("ShaderNodeMath") - consumer = group.nodes.new("ShaderNodeMath") - consumer.location = (-3000, -1500) - corner = group.nodes.new("NodeReroute") - corner["arrangement_reference"] = True - corner.location = (1000, 0) - group.links.new(source.outputs[0], corner.inputs[0]) - group.links.new(corner.outputs[0], consumer.inputs[0]) - route_field_passages(group, []) - assert source.outputs[0].links[0].to_node == corner - - -def test_obstacle_detour_starts_to_the_right_of_a_value_source(): - from tools.nodes.arrangement.passages import _passage - - path = _passage((0, 0), (100, -2000), [(-300, 300, -300, -1600)], [], forward_start=True) - assert path[0][0] > 0 - - -def test_compact_control_stays_near_switch_without_crossing_geometry_bypass(): - from tools.nodes.arrangement.connections import curve_footprint, link_endpoints - - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Compact Switch Control", "GeometryNodeTree") - group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - source = group.nodes.new("GeometryNodeMeshGrid") - transform = group.nodes.new("GeometryNodeTransform") - switch = group.nodes.new("GeometryNodeSwitch") - switch.input_type = "GEOMETRY" - control = group.nodes.new("GeometryNodeMenuSwitch") - control.data_type = "BOOLEAN" - output = group.nodes.new("NodeGroupOutput") - group.links.new(source.outputs["Mesh"], transform.inputs["Geometry"]) - group.links.new(source.outputs["Mesh"], switch.inputs["False"]) - group.links.new(transform.outputs["Geometry"], switch.inputs["True"]) - group.links.new(control.outputs[0], switch.inputs["Switch"]) - group.links.new(switch.outputs[0], output.inputs[0]) - program = _program(group) - arrange_nodes(group) - assert _program(group) == program - positions = {node: tuple(node.location_absolute) for node in group.nodes} - sizes = {node: estimate_node_dimensions(node) for node in group.nodes} - start, end = link_endpoints(switch.inputs["Switch"].links[0], positions, sizes) - assert 100 <= end[0] - start[0] <= 160 - control_curve = curve_footprint(start, end) - for link in group.links: - if link.from_socket.type != "GEOMETRY": - continue - geometry_curve = curve_footprint(*link_endpoints(link, positions, sizes)) - assert not any(a < d and b > c and top > lower and bottom < upper - for a, b, top, bottom in control_curve - for c, d, upper, lower in geometry_curve) - - -def test_long_geometry_return_does_not_swing_over_later_processing(): - from tools.nodes.arrangement.connections import curve_footprint, link_endpoints - from tools.nodes.arrangement.passages import route_field_passages - - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Long Geometry Return", "GeometryNodeTree") - source = group.nodes.new("GeometryNodeMeshGrid") - source.location = (17000, 0) - consumer = group.nodes.new("GeometryNodeSampleIndex") - consumer.location = (0, -100) - group.links.new(source.outputs["Mesh"], consumer.inputs["Geometry"]) - program = _program(group) - route_field_passages(group, []) - assert _program(group) == program - positions = {node: tuple(node.location_absolute) for node in group.nodes} - sizes = {node: estimate_node_dimensions(node) for node in group.nodes} - source_right = positions[source][0] + sizes[source].width - for link in group.links: - start, end = link_endpoints(link, positions, sizes) - assert end[0] < start[0] - assert max(box[1] for box in curve_footprint(start, end)) < source_right + 160 - - -def test_reused_value_for_inputs_of_one_node_stays_local(): - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Local Shared Value", "GeometryNodeTree") - group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - source = group.nodes.new("ShaderNodeMath") - cube = group.nodes.new("GeometryNodeMeshCube") - output = group.nodes.new("NodeGroupOutput") - for name in ("Vertices X", "Vertices Y", "Vertices Z"): - group.links.new(source.outputs[0], cube.inputs[name]) - group.links.new(cube.outputs["Mesh"], output.inputs[0]) - program = _program(group) - arrange_nodes(group) - assert _program(group) == program - assert len(source.outputs[0].links) == 3 - assert all(link.to_node == cube for link in source.outputs[0].links) - - -def test_shared_calculation_sits_outside_consumers_and_starts_its_pipe_forward(): - bpy.ops.wm.read_factory_settings(use_empty=True) - group = bpy.data.node_groups.new("Peripheral Calculation", "GeometryNodeTree") - group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") - source = group.nodes.new("GeometryNodeInputPosition") - producers = [source] - for _ in range(3): - node = group.nodes.new("ShaderNodeVectorMath") - node.operation = "ADD" - group.links.new(source.outputs[0], node.inputs[0]) - producers.append(node) - source = node - geometry = group.nodes.new("GeometryNodeMeshGrid").outputs["Mesh"] - for _ in range(3): - value = source.outputs[0] - for _ in range(12): - node = group.nodes.new("ShaderNodeVectorMath") - node.operation = "ADD" - group.links.new(value, node.inputs[0]) - value = node.outputs[0] - consumer = group.nodes.new("GeometryNodeSetPosition") - group.links.new(geometry, consumer.inputs["Geometry"]) - group.links.new(value, consumer.inputs["Offset"]) - geometry = consumer.outputs["Geometry"] - output = group.nodes.new("NodeGroupOutput") - group.links.new(geometry, output.inputs["Geometry"]) - program = _program(group) - arrange_nodes(group) - assert _program(group) == program - frames = [node for node in group.nodes if node.bl_idname == "NodeFrame"] - assert len(frames) == 3 - top = max(node.location_absolute.y for node in producers) - bottom = min(node.location_absolute.y - estimate_node_dimensions(node).height for node in producers) - assert (bottom >= max(frame.location_absolute.y for frame in frames) - or top <= min(frame.location_absolute.y - frame.height for frame in frames)) - assert len(source.outputs[0].links) == 1 - entrance = source.outputs[0].links[0].to_node - assert entrance.bl_idname == "NodeReroute" - assert entrance.location_absolute.x > source.location_absolute.x + estimate_node_dimensions(source).width diff --git a/tests/tools/nodes/test_boundary_depth.py b/tests/tools/nodes/test_boundary_depth.py index ef18452..dd66288 100644 --- a/tests/tools/nodes/test_boundary_depth.py +++ b/tests/tools/nodes/test_boundary_depth.py @@ -70,16 +70,12 @@ def test_camera_blur_preserves_rays_interior_protection_and_disconnected_surface camera[valid, :2] / camera[valid, 2:3], atol=1e-6) -@pytest.mark.parametrize("source", ["definition", "asset"]) -def test_cutout_fine_outline_blurs_depth_without_moving_projection(source): + +def test_cutout_fine_outline_blurs_depth_without_moving_projection(): from tests.support.depth_surface import surface from anyimage.operators.cutout_tool.fine_mesh import build_fine_mesh - from anyimage.operators.cutout_tool.object import load_cutout_node_group obj, set_value = surface(step=0, resolution=8) - if source == "asset": - bpy.data.node_groups.remove(obj.modifiers[0].node_group, do_unlink=True) - obj.modifiers[0].node_group = load_cutout_node_group("DEPTH_SOLID") yy, xx = np.mgrid[:32, :64] alpha = ((xx - 32)**2 / 27**2 + (yy - 16)**2 / 13**2 < 1).astype(float) points, faces, _, _ = build_fine_mesh(alpha, 3, .9) @@ -111,11 +107,10 @@ def test_cutout_fine_outline_blurs_depth_without_moving_projection(source): np.testing.assert_array_equal(evaluated(obj)[0], before) -@pytest.mark.parametrize("source", ["definition", "asset"]) -def test_plane_validity_hole_blurs_depth_and_protects_rectangle(source): +def test_plane_validity_hole_blurs_depth_and_protects_rectangle(): from tests.support.planes import create_surface, evaluated as plane_mesh - obj, set_value, _, _, image = create_surface(source, "DEPTH") + obj, set_value, _, _, image = create_surface("DEPTH") pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4) pixels[..., 2] = 2 pixels[2:6, 6:10, 3] = 0 @@ -136,17 +131,15 @@ def test_plane_validity_hole_blurs_depth_and_protects_rectangle(source): np.testing.assert_array_equal(after[outer], before[outer]) np.testing.assert_allclose(after[:, :2] / (1.2 - after[:, 2:3]), before[:, :2] / (1.2 - before[:, 2:3]), atol=1e-6) - set_value("Valid Only", False) + set_value("Depth Mask", False) intact = plane_mesh(obj)[0] set_value("Boundary Smooth", 0) np.testing.assert_array_equal(plane_mesh(obj)[0], intact) -@pytest.mark.parametrize("source", ["definition", "asset"]) @pytest.mark.parametrize("region", ["seam", "pole"]) -def test_panorama_blurs_opening_radially_and_keeps_invalid_distance(source, region): +def test_panorama_blurs_opening_radially_and_keeps_invalid_distance(region): from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh - from tools.nodes.build import OUTPUT_PATH yy, xx = np.mgrid[:64, :128] distance = np.minimum(xx, 128 - xx) @@ -154,11 +147,6 @@ def test_panorama_blurs_opening_radially_and_keeps_invalid_distance(source, regi spike = ((distance < 4) & (yy > 40) & (yy < 44)) if region == "seam" else ((yy > 50) & (xx < 8)) depth = np.where(spike, 10., 3.) obj, set_value = panorama(depth, (~hole).astype(float), subdivide=5) - if source == "asset": - obj.modifiers[0].node_group.name = "Panorama definition" - with bpy.data.libraries.load(str(OUTPUT_PATH)) as (_, target): - target.node_groups = ["O Image Depth Panorama"] - obj.modifiers[0].node_group = target.node_groups[0] set_value("Smooth Weight", 0) before, faces, uv = panorama_mesh(obj) set_value("Boundary Smooth", 12) @@ -174,7 +162,7 @@ def test_panorama_blurs_opening_radially_and_keeps_invalid_distance(source, regi interior = [i for i in range(len(before)) if i not in band] assert interior np.testing.assert_array_equal(after[interior], before[interior]) - set_value("Valid Only", False) + set_value("Depth Mask", False) near = panorama_mesh(obj)[0] set_value("Invalid Distance", 250) far = panorama_mesh(obj)[0] diff --git a/tests/tools/nodes/test_boundary_smoothing.py b/tests/tools/nodes/test_boundary_smoothing.py index 8459a35..30259d0 100644 --- a/tests/tools/nodes/test_boundary_smoothing.py +++ b/tests/tools/nodes/test_boundary_smoothing.py @@ -24,7 +24,7 @@ def diagonal_surface(triangles=False): pixels[..., 2] = 1 + (xx > 18 + yy * 0.7) * 6 image.pixels.foreach_set(pixels.ravel()) image.update() - set_value("Split Threshold", 0.5) + set_value("Depth Split", 0.5) set_value("Depth Scale", 0) return obj, set_value @@ -85,16 +85,9 @@ def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles): np.testing.assert_array_equal(evaluated(obj)[0], original) -@pytest.mark.parametrize("source", ["definition", "asset"]) -def test_smoothing_without_split_moves_original_boundary_only_within_band(source): +def test_smoothing_without_split_moves_original_boundary_only_within_band(): obj, set_value = diagonal_surface() - if source == "asset": - from anyimage.common.node import node_asset_path - - with bpy.data.libraries.load(str(node_asset_path())) as (_, target): - target.node_groups = ["O Image Depth Cutout"] - obj.modifiers[0].node_group = target.node_groups[0] - set_value("Split Threshold", 0) + set_value("Depth Split", 0) original, faces = evaluated(obj) set_value("Boundary Smooth", 20) result, result_faces = evaluated(obj) @@ -136,7 +129,7 @@ def test_panorama_smoothing_preserves_interior_and_uvs(): yy, xx = np.mgrid[:64, :128] depth = np.where(xx > 35 + yy * 0.6, 8.0, 2.0) obj, set_value = panorama(depth, subdivide=4) - set_value("Split Threshold", 0.5) + set_value("Depth Split", 0.5) original, faces, uv = panorama_mesh(obj) rim = boundary_neighbors(faces) band = edge_band(faces, rim) @@ -150,11 +143,10 @@ def test_panorama_smoothing_preserves_interior_and_uvs(): assert np.max(np.linalg.norm(result-original, axis=1)) > 1e-4 -@pytest.mark.parametrize("source", ["definition", "asset"]) -def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness(source): +def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness(): from tests.support.planes import create_surface, evaluated as plane_mesh - obj, set_value, _, inputs, image = create_surface(source, "DEPTH") + obj, set_value, _, inputs, image = create_surface("DEPTH") assert inputs["Boundary Smooth"].default_value == 2 assert inputs["Boundary Smooth"].parent.name == "Options" pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4) @@ -164,7 +156,7 @@ def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness(source): image.update() set_value("Subdivide", 5) set_value("Depth Scale", 1) - set_value("Split Threshold", 0.5) + set_value("Depth Split", 0.5) original, faces, uv, *_ = plane_mesh(obj) set_value("Boundary Smooth", 4) result, result_faces, result_uv, *_ = plane_mesh(obj) @@ -175,76 +167,13 @@ def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness(source): assert_closed(plane_mesh(obj)[1]) -def test_edge_iterations_reuse_boundary_topology_and_mapping(): - from tools.nodes.arrangement.flow import origin_socket - from tools.nodes.arrangement.zones import node_zones - - obj, _ = diagonal_surface() - group = obj.modifiers[0].node_group - entry, _, members = next( - zone for zone in node_zones(group) - if origin_socket(zone[0].inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth" - ) - nearest = next(node for node in group.nodes if node.bl_idname == "GeometryNodeSampleNearest" - and any(link.to_node in members for link in node.outputs["Index"].links)) - fixed_position = origin_socket(nearest.inputs["Sample Position"].links[0].from_socket).node - assert fixed_position.bl_idname == "GeometryNodeSampleIndex" - source = origin_socket(entry.inputs["Geometry"].links[0].from_socket) - assert origin_socket(fixed_position.inputs["Geometry"].links[0].from_socket) == source - assert fixed_position.domain == "POINT" - assert any(node.bl_idname == "GeometryNodeSeparateGeometry" for node in members) - - -@pytest.mark.parametrize("source", ["definition", "asset"]) -def test_every_split_node_enables_boundary_smoothing_by_default(source): - from tools.nodes.build import OUTPUT_PATH, build_node_groups - from tools.nodes.arrangement.flow import origin_socket - - bpy.ops.wm.read_factory_settings(use_empty=True) - if source == "definition": - groups = build_node_groups() - else: - with bpy.data.libraries.load(str(OUTPUT_PATH)) as (library, target): - target.node_groups = list(library.node_groups) - groups = target.node_groups - split_groups = [] - for group in groups: - inputs = {item.name: item for item in group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT"} - assert "Edge Smooth" not in inputs - if "Split Threshold" not in inputs: - continue - split_groups.append(group.name) - smooth = inputs["Boundary Smooth"] - assert smooth.default_value == (5 if group.name == "O Image Depth Panorama" else 2) - assert (smooth.min_value, smooth.max_value) == (1 if "Outline Depth Fix" in inputs else 0, 20) - assert smooth.parent.name == "Options" - assert not {"Depth Blur Iterations", "Depth Band Rings", "Depth Blur Strength", "Skip Inactive Geometry"} & inputs.keys() - depth_blurs = [n for n in group.nodes if n.bl_idname == "GeometryNodeBlurAttribute" - and n.data_type == "FLOAT" and n.inputs["Iterations"].is_linked - and origin_socket(n.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth"] - assert len(depth_blurs) == 1 - if "Outline Depth Fix" in inputs: - assert inputs["Outline Depth Fix"].default_value == 16 - assert origin_socket(depth_blurs[0].inputs["Weight"].links[0].from_socket).node.bl_idname == "GeometryNodeSampleIndex" - continue - depth_bypass = [n for n in group.nodes if n.bl_idname == "GeometryNodeSwitch" - and n.input_type == "FLOAT" and n.inputs["Switch"].is_linked - and origin_socket(n.inputs["Switch"].links[0].from_socket).name == "Boundary Smooth"] - assert len(depth_bypass) == 1 - assert set(split_groups) == { - "O Image Depth Plane", "O Image Depth Cutout", "O Image Depth Panorama", - } - - -@pytest.mark.parametrize("source", ["definition", "asset"]) -def test_validity_cut_smoothing_preserves_original_outline_and_interior(source): +def test_validity_cut_smoothing_preserves_original_outline_and_interior(): from tests.support.planes import create_surface, evaluated as plane_mesh - obj, set_value, _, _, image = create_surface(source, "DEPTH") + obj, set_value, _, _, image = create_surface("DEPTH") set_value("Subdivide", 5) - set_value("Split Threshold", 0) - set_value("Valid Only", False) + set_value("Depth Split", 0) + set_value("Depth Mask", False) whole = plane_mesh(obj)[0] low, high = whole.min(axis=0), whole.max(axis=0) pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4) @@ -252,7 +181,7 @@ def test_validity_cut_smoothing_preserves_original_outline_and_interior(source): pixels[..., 3] = xx < 5 + yy * 0.7 image.pixels.foreach_set(pixels.ravel()) image.update() - set_value("Valid Only", True) + set_value("Depth Mask", True) before, faces, uv, *_ = plane_mesh(obj) boundary = boundary_neighbors(faces) outer = np.any(np.isclose(before[:, :2], low[:2]) | np.isclose(before[:, :2], high[:2]), axis=1) @@ -274,7 +203,7 @@ def test_validity_cut_smoothing_preserves_original_outline_and_interior(source): set_value("Thickness", 0.03) assert_closed(plane_mesh(obj)[1]) set_value("Thickness", 0) - set_value("Valid Only", False) + set_value("Depth Mask", False) np.testing.assert_array_equal(plane_mesh(obj)[0], whole) @@ -284,7 +213,7 @@ def test_panorama_validity_cut_smoothing_stays_in_two_ring_band(): yy, xx = np.mgrid[:64, :128] alpha = ((xx-64)**2 + (yy-32)**2 > 15**2).astype(np.float32) obj, set_value = panorama(np.full((64, 128), 3), alpha, subdivide=4) - set_value("Split Threshold", 0) + set_value("Depth Split", 0) before, faces, uv = panorama_mesh(obj) band = edge_band(faces, boundary_neighbors(faces)) outside = [i for i in range(len(before)) if i not in band] @@ -294,45 +223,3 @@ def test_panorama_validity_cut_smoothing_stays_in_two_ring_band(): np.testing.assert_array_equal(result_uv, uv) np.testing.assert_array_equal(after[outside], before[outside]) assert np.max(np.linalg.norm(after-before, axis=1)) > 1e-4 - - -@pytest.mark.parametrize("source", ["definition", "asset"]) -@pytest.mark.parametrize("kind", ["cutout", "plane", "panorama"]) -@pytest.mark.parametrize("iterations,weight", [(0, 1.0), (8, 1.0), (8, 0.0)]) -def test_boundary_loop_bypass_uses_iterations_only(source, kind, iterations, weight): - from anyimage.common.node import node_asset_path - from tests.support.planes import create_surface - from tests.tools.nodes.test_image_depth_panorama import panorama - from tools.nodes.arrangement.flow import origin_socket - - if kind == "cutout": - obj, set_value = surface(resolution=4) - elif kind == "plane": - obj, set_value, *_ = create_surface("definition", "DEPTH") - else: - obj, set_value = panorama(subdivide=2) - if source == "asset": - with bpy.data.libraries.load(str(node_asset_path())) as (_, target): - target.node_groups = [obj.modifiers[0].node_group.name] - obj.modifiers[0].node_group = target.node_groups[0].copy() - set_value("Split Threshold", 0) - set_value("Smooth Weight", weight) - set_value("Boundary Smooth", iterations) - group = obj.modifiers[0].node_group - entry = next(n for n in group.nodes if n.bl_idname == "GeometryNodeRepeatInput" - and origin_socket(n.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth") - bypass = next(n for n in group.nodes if n.bl_idname == "GeometryNodeSwitch" - and n.input_type == "GEOMETRY" and n.inputs["True"].is_linked - and origin_socket(n.inputs["True"].links[0].from_socket).node == entry.paired_output) - control = origin_socket(bypass.inputs["Switch"].links[0].from_socket) - assert control.name == "Boundary Smooth" - assert control.identifier == origin_socket(entry.inputs["Iterations"].links[0].from_socket).identifier - assert origin_socket(bypass.inputs["False"].links[0].from_socket) == origin_socket(entry.inputs["Geometry"].links[0].from_socket) - original, faces = evaluated(obj) - group.links.remove(bypass.inputs["Switch"].links[0]) - bypass.inputs["Switch"].default_value = True - obj.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - unskipped, unskipped_faces = evaluated(obj) - np.testing.assert_array_equal(original, unskipped) - assert faces == unskipped_faces diff --git a/tests/tools/nodes/test_cli.py b/tests/tools/nodes/test_cli.py index 8cb04c7..8c3736a 100644 --- a/tests/tools/nodes/test_cli.py +++ b/tests/tools/nodes/test_cli.py @@ -70,69 +70,31 @@ def test_blender_path_reports_missing_configuration(tmp_path): launcher.blender_path() -def test_launcher_runs_asset_build_and_validations_before_tests(monkeypatch, tmp_path): - launcher = load_launcher() - executable = tmp_path / "blender" - executable.touch() - calls = [] - monkeypatch.setattr(launcher, "blender_path", lambda _: executable) - monkeypatch.setattr(launcher, "run", calls.append) - monkeypatch.setattr("sys.argv", ["node-group", "build"]) - - launcher.main() - - assert len(calls) == 3 - assert "runpy.run_module('tools.nodes.build', run_name='__main__')" in calls[0][-1] - assert "runpy.run_module('tools.nodes.check', run_name='__main__')" in calls[1][-1] - assert all(command[1:6] == ("--factory-startup", "--background", "--python-exit-code", "1", "--python-expr") for command in calls[:-1]) - assert calls[-1][1:] == ("-m", "pytest") - - -@pytest.mark.parametrize("skip_tests", [False, True]) -def test_check_validates_without_building(monkeypatch, skip_tests): - launcher = load_launcher() - calls = [] - monkeypatch.setattr(launcher, "blender_path", lambda _: "blender") - monkeypatch.setattr(launcher, "run", calls.append) - monkeypatch.setattr("sys.argv", ["node-group", "check", *(["--skip-tests"] if skip_tests else [])]) - launcher.main() - assert len(calls) == (1 if skip_tests else 2) - assert "tools.nodes.check" in calls[0][-1] - assert "tools.nodes.build" not in calls[0][-1] - - -def test_build_stops_after_blender_failure(monkeypatch): - launcher = load_launcher() - monkeypatch.setattr(launcher, "blender_path", lambda _: "blender") - monkeypatch.setattr("sys.argv", ["node-group", "build"]) - with patch.object(launcher, "run", side_effect=RuntimeError("failed")) as run: - with pytest.raises(RuntimeError, match="failed"): - launcher.main() - assert run.call_count == 1 - - -@pytest.mark.parametrize("arguments,opens", [([], True), (["--no-open"], False), (["--fragment"], False)]) -def test_preview_passes_isolated_arguments_and_opens_result(monkeypatch, tmp_path, arguments, opens): +@pytest.mark.parametrize("arguments,modules,run_tests", [ + (["build"], ("tools.nodes.build", "tools.nodes.check"), True), + (["check", "--skip-tests"], ("tools.nodes.check",), False), + (["preview", "--fragment"], ("tools.nodes.preview.export",), False), +]) +def test_commands_dispatch_requested_work(monkeypatch, tmp_path, arguments, modules, run_tests): launcher = load_launcher() output = tmp_path / "node groups.html" - calls = [] + command = ["node-group", *arguments] + if arguments[0] == "preview": + command += ["--output", str(output)] + monkeypatch.setattr("sys.argv", command) monkeypatch.setattr(launcher, "blender_path", lambda _: "blender") - monkeypatch.setattr(launcher, "run", calls.append) - monkeypatch.setattr("sys.argv", ["node-group", "preview", "--output", str(output), *arguments]) - with patch.object(launcher.webbrowser, "open") as open_browser: + with ( + patch.object(launcher, "run_blender_modules") as blender, + patch.object(launcher, "run") as run, + patch.object(launcher.webbrowser, "open") as open_browser, + ): launcher.main() - assert len(calls) == 1 - expression = calls[0][-1] - import ast - assignment = next( - node for node in ast.parse(expression).body - if isinstance(node, ast.Assign) and ast.unparse(node.targets[0]) == "sys.argv" - ) - assert ast.literal_eval(assignment.value) == [ - "tools.nodes.preview.export", "--output", str(output), - *(["--fragment"] if "--fragment" in arguments else []), - ] - if opens: - open_browser.assert_called_once_with(output.as_uri()) - else: + if arguments[0] == "preview": + blender.assert_called_once_with("blender", modules, ["--output", str(output), "--fragment"]) open_browser.assert_not_called() + else: + blender.assert_called_once_with("blender", modules) + if run_tests: + run.assert_called_once_with((launcher.sys.executable, "-m", "pytest")) + else: + run.assert_not_called() diff --git a/tests/tools/nodes/test_cutout_boundary.py b/tests/tools/nodes/test_cutout_boundary.py index 3c3c0f8..28aba7c 100644 --- a/tests/tools/nodes/test_cutout_boundary.py +++ b/tests/tools/nodes/test_cutout_boundary.py @@ -37,7 +37,7 @@ def test_boundary_triangle_cleanup_is_one_pass(): assert all(len(f) == 3 for f in faces) -@pytest.mark.parametrize("iterations", [0, 5, 12, 20]) +@pytest.mark.parametrize("iterations", [0, 20]) def test_cleaned_sawtooth_smoothing_preserves_face_orientation(iterations): obj, set_value = diagonal_surface(triangles=True) original, faces = evaluated(obj) @@ -50,8 +50,7 @@ def test_cleaned_sawtooth_smoothing_preserves_face_orientation(iterations): assert np.all(np.sum(normals(original)*normals(points), axis=1) > 0) -@pytest.mark.parametrize("resolution", [12, 24]) -@pytest.mark.parametrize("aspect", [.5, 2]) +@pytest.mark.parametrize("resolution,aspect", [(12, .5), (24, 2)]) def test_split_profile_tapers_within_boundary_band(resolution, aspect): obj, set_value = surface(step=6, resolution=resolution) for vertex in obj.data.vertices: @@ -65,7 +64,7 @@ def test_split_profile_tapers_within_boundary_band(resolution, aspect): "value", np.full(len(obj.data.vertices), .3, dtype=np.float32)) set_value("Depth Scale", 0) np.testing.assert_allclose(profile_values(obj), .3) - set_value("Split Threshold", .1) + set_value("Depth Split", .1) points, faces = evaluated(obj) boundary = boundary_neighbors(faces) cut = [i for i in boundary if 0 < points[i,1] < 1 and .5*aspect < points[i,0] < 1.5*aspect] @@ -78,7 +77,7 @@ def test_split_profile_tapers_within_boundary_band(resolution, aspect): np.testing.assert_allclose(values[outside], .3) assert np.isfinite(values).all() assert values.max() == pytest.approx(.3) - set_value("Split Threshold", 0) + set_value("Depth Split", 0) np.testing.assert_allclose(profile_values(obj), .3) @@ -87,13 +86,13 @@ def test_split_profile_tapers_within_boundary_band(resolution, aspect): def test_normal_blend_converges_to_raw_and_reaches_full_smoothing(mode, reference): obj, set_value = surface(resolution=2) group = obj.modifiers[0].node_group - # The production interface renames the uniform socket after building. + # The production interface renames the Shell thickness socket after building. socket = next(s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT" and s.name == "Thickness" and s.subtype == "DISTANCE") - socket.name = "Uniform Thickness" - uniform = group.nodes.new("FunctionNodeInputBool") - uniform.boolean = bool(mode) - weight = build_normal_weight(group, uniform.outputs[0]) + socket.name = "Shell Thickness" + is_shell = group.nodes.new("FunctionNodeInputBool") + is_shell.boolean = bool(mode) + weight = build_normal_weight(group, is_shell.outputs[0]) raw = group.nodes.new("ShaderNodeCombineXYZ") raw.inputs["Z"].default_value = 1 blurred = group.nodes.new("ShaderNodeCombineXYZ") diff --git a/tests/tools/nodes/test_cutouts.py b/tests/tools/nodes/test_cutouts.py index c1b72bd..2a8bdce 100644 --- a/tests/tools/nodes/test_cutouts.py +++ b/tests/tools/nodes/test_cutouts.py @@ -4,67 +4,36 @@ import bpy import numpy as np import pytest +from tools.nodes.groups.image_cutout import build_image_cutout_group from tests.support.nodes import CUTOUT_BUILDERS -from tools.nodes.arrangement.flow import origin_socket - from anyimage.operators.cutout_tool.object import load_cutout_node_group + from anyimage.common.object import modifier_input_identifier, set_modifier_input -from tests.support.nodes import validate_node_structure - -@pytest.mark.parametrize("source", ["definition", "asset"]) -def test_cutout_interfaces_and_thickness_modes(source): +@pytest.mark.parametrize("source", ("definition", "asset")) +@pytest.mark.parametrize("shape", ("SOLID", "DEPTH_SOLID", "DEPTH_BALLOON")) +def test_depth_axis_is_visible_in_options(shape, source): + bpy.ops.wm.read_factory_settings(use_empty=True) + group = CUTOUT_BUILDERS[shape]() if source == "definition" else load_cutout_node_group(shape) + inputs = {item.name: item for item in group.interface.items_tree + if item.item_type == "SOCKET" and item.in_out == "INPUT"} + axis = inputs["Depth Axis"] + assert axis.parent.name == "Options" + assert axis.menu_expanded + assert not axis.hide_in_modifier + assert axis.default_value == "-Z" + assert "Inward Axis" not in inputs + options = [item.name for item in inputs.values() if item.parent == axis.parent] + predecessor = "Smooth Weight" if shape == "SOLID" else "Reference Depth" + assert options[options.index(predecessor) + 1] == "Depth Axis" + if shape == "SOLID": + assert not any(item.item_type == "PANEL" and item.name == "Data" for item in group.interface.items_tree) + + +def test_cutout_thickness_modes(): bpy.ops.wm.read_factory_settings(use_empty=True) - groups = {shape: (CUTOUT_BUILDERS[shape]() if source == "definition" else load_cutout_node_group(shape)) for shape in ("FLAT", "SOLID", "DEPTH_SOLID", "DEPTH_BALLOON")} - assert groups["FLAT"] == groups["SOLID"] - assert len(set(groups.values())) == 3 - for group in set(groups.values()): - validate_node_structure(group) - revision = 23 if group == groups["DEPTH_SOLID"] else 12 if group == groups["DEPTH_BALLOON"] else 11 - assert group.is_modifier and group.get("anyimage_asset_revision") == revision - assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes) - inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"} - assert "Shape" not in inputs and "Rim Thickness" not in inputs - assert next(s for s in group.interface.items_tree if s.name == "Thickness").default_value == 1 - assert all(item.structure_type == "SINGLE" for item in inputs.values()) - if "Mode" in inputs: - assert inputs["Thickness"].subtype == "DISTANCE" - assert inputs["Mode"].default_value == "Balloon" - if "Double Sided" in inputs: - assert inputs["Double Sided"].default_value - if group == groups["DEPTH_SOLID"]: - assert np.isclose(inputs["Split Threshold"].default_value, 0.1) - assert not {"Edge Round", "Normal Smooth"} & inputs.keys() - normal_blurs = [n for n in group.nodes if n.bl_idname == "GeometryNodeBlurAttribute" and n.data_type == "FLOAT_VECTOR" - and not n.inputs["Iterations"].is_linked and n.inputs["Iterations"].default_value == 100] - assert len(normal_blurs) == 2 - assert inputs["Front Inflation"].default_value == 0 - assert inputs["Front Inflation"].subtype == "FACTOR" - assert (inputs["Front Inflation"].min_value, inputs["Front Inflation"].max_value) == (0, 1) - assert inputs["Front Inflation"].parent.name == "Options" - assert not set(inputs) & {"Edge Round Range", "Back Rim Range", "Normal Influence", "Edge Round Amount", "Back Rim Fullness"} - options = [s.name for s in inputs.values() if s.parent and s.parent.name == "Options"] - expected = ["Cleanup Threshold"] - if group == groups["DEPTH_SOLID"]: - expected += ["Smooth Weight", "Reference Depth", "Boundary Smooth", "Outline Depth Fix", "Front Inflation"] - assert inputs["Outline Depth Fix"].default_value == 16 - assert not set(inputs) & {"Ratio Lower", "Ratio Upper", "Weight Blur Iterations", "Weight Update Rounds", "Depth Steps Per Round"} - elif group == groups["DEPTH_BALLOON"]: - expected += ["Smooth Weight", "Reference Depth"] - if group not in (groups["DEPTH_SOLID"], groups["DEPTH_BALLOON"]): - expected += ["Smooth Weight"] - if group == groups["DEPTH_BALLOON"]: - expected += ["Depth Direction"] - assert options == expected, (group.name, options) - assert inputs["Smooth"].socket_type == "NodeSocketInt" and inputs["Smooth"].default_value == 0 - assert inputs["Smooth Weight"].default_value == 1 - incoming = [link for link in group.links - if link.to_node.bl_idname != "NodeReroute" - and origin_socket(link.from_socket).node.bl_idname == "NodeGroupInput" - and origin_socket(link.from_socket).name == "Geometry"] - assert len(incoming) == 1 and incoming[0].to_node.bl_idname == "GeometryNodeDeleteGeometry" mesh = bpy.data.meshes.new("Profile") mesh.from_pydata([(-1,-1,0), (1,-1,0), (1,1,0), (-1,1,0), (0,0,0)], [], [(0,1,4), (1,2,4), (2,3,4), (3,0,4)]) profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT") @@ -72,7 +41,7 @@ def test_cutout_interfaces_and_thickness_modes(source): obj = bpy.data.objects.new("Profile", mesh) bpy.context.collection.objects.link(obj) modifier = obj.modifiers.new("Cutout", "NODES") - modifier.node_group = groups["SOLID"] + modifier.node_group = build_image_cutout_group() for mode, thickness in ((0, 0), (0, 1), (1, 0), (1, 0.4)): for name, value in (("Mode", mode), ("Thickness", thickness)): subtype = ("DISTANCE" if mode else "NONE") if name == "Thickness" else None @@ -88,8 +57,8 @@ def test_cutout_interfaces_and_thickness_modes(source): assert len(result.polygons) == 4 and np.max(abs(z)) < 1e-7 assert all(abs(item.value - 1) < 1e-7 for item in result.attributes["o_normal_scale"].data) else: - radius = 0.6 if mode == 0 else 0.2 - assert np.isclose(z.min(), -radius) and np.isclose(z.max(), radius) + back, front = (-0.6, 0.6) if mode == 0 else (-thickness, 0.0) + assert np.isclose(z.min(), back) and np.isclose(z.max(), front) uses = Counter(tuple(sorted((a,b))) for face in result.polygons for a,b in zip(face.vertices, (*face.vertices[1:],face.vertices[0]))) assert set(uses.values()) == {2} finally: diff --git a/tests/tools/nodes/test_depth_cutout.py b/tests/tools/nodes/test_depth_cutout.py index d9ec097..98cb608 100644 --- a/tests/tools/nodes/test_depth_cutout.py +++ b/tests/tools/nodes/test_depth_cutout.py @@ -35,7 +35,7 @@ def set_test_structure(monkeypatch, value): monkeypatch.setattr(image_depth_cutout, "build_front_fields", fields) -def test_uniform_thickness_follows_the_tilted_front_normal(): +def test_shell_thickness_extends_behind_the_tilted_front(): from scipy.spatial import cKDTree from tests.support.depth_surface import surface, evaluated, assert_closed obj, set_value = surface(step=0, resolution=4) @@ -48,6 +48,7 @@ def test_uniform_thickness_follows_the_tilted_front_normal(): bpy.context.view_layer.update() front, _ = evaluated(obj) set_value("Thickness", 0.2) + set_value("Front Inflation", 0.5) points, faces = evaluated(obj) expected = front - 0.2 * np.array((0.4, 0, 1)) / np.sqrt(1.16) assert cKDTree(points).query(expected)[0].max() < 1e-5 @@ -140,12 +141,12 @@ def test_depth_cutout_normal_map_only_affects_front_faces(): evaluated.to_mesh_clear() -@pytest.mark.parametrize("structure", [0, 1, 4]) -@pytest.mark.parametrize("thickness", [0.0, 0.02, 0.1]) -@pytest.mark.parametrize("inflation", [0.0, 0.0001, 0.5, 1.0]) -def test_uniform_front_inflation_scales_with_distance(thickness, structure, inflation, monkeypatch): +@pytest.mark.parametrize("thickness,inflation", [ + (0.0, 1.0), (0.02, 0.0), (0.02, 0.0001), + (0.02, 0.5), (0.1, 0.5), (0.1, 1.0), +]) +def test_shell_ignores_front_inflation(thickness, inflation): from tests.support.depth_surface import surface, evaluated, assert_closed - set_test_structure(monkeypatch, structure) obj, set_value = surface(step=0, resolution=10) xy = np.array([v.co[:2] for v in obj.data.vertices]) profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1]) @@ -157,14 +158,9 @@ def test_uniform_front_inflation_scales_with_distance(thickness, structure, infl set_value("Front Inflation", inflation) inflated, inflated_faces = evaluated(obj) assert faces == inflated_faces - delta = np.linalg.norm(inflated - baseline, axis=1) - front = np.isclose(baseline[:, 2], baseline[:, 2].max(), atol=1e-6) - np.testing.assert_allclose(delta[front], thickness * inflation, atol=2e-6) - np.testing.assert_allclose(delta[~front], 0, atol=2e-6) + np.testing.assert_array_equal(inflated, baseline) if thickness: assert_closed(inflated_faces) - else: - np.testing.assert_array_equal(inflated, baseline) def test_balloon_suppression_is_stronger_without_a_startup_threshold(monkeypatch): diff --git a/tests/tools/nodes/test_depth_relief_planes.py b/tests/tools/nodes/test_depth_relief_planes.py index 91fae6c..0a34a66 100644 --- a/tests/tools/nodes/test_depth_relief_planes.py +++ b/tests/tools/nodes/test_depth_relief_planes.py @@ -6,8 +6,6 @@ import numpy as np import pytest from tests.support.planes import surface, relief, evaluated -from tests.support.nodes import validate_node_structure -from tools.nodes.arrangement.flow import origin_socket @pytest.mark.parametrize("subdivide", [0, 1, 3]) @@ -59,7 +57,7 @@ def test_shell_keeps_front_and_closes_rectangular_boundary(surface, thickness): set_value("Depth Scale", 1.0) front = evaluated(obj)[0] set_value("Thickness", thickness) - points, faces, uv, materials, indices, attributes = evaluated(obj) + points, faces, uv, materials, indices, _ = evaluated(obj) assert len(points) == 2 * len(front) edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0]))) assert set(edges.values()) == {2} @@ -70,7 +68,6 @@ def test_shell_keeps_front_and_closes_rectangular_boundary(surface, thickness): assert cKDTree(points).query(expected)[0].max() < 1e-6 assert np.isfinite(points).all() and np.isfinite(uv).all() assert materials[0] == "Image" and set(indices) == {0} - assert not any(name.startswith("o_depth_") for name in attributes) @pytest.mark.parametrize("valid_only", [False, True]) @@ -84,16 +81,15 @@ def test_depth_split_preserves_faces_and_closes_each_shell(surface, valid_only): image.pixels.foreach_set(pixels.ravel()) image.update() set_value("Depth Scale", 1.0) - set_value("Valid Only", valid_only) + set_value("Depth Mask", valid_only) base = evaluated(obj) - set_value("Split Threshold", 1.0) - points, faces, uv, materials, indices, attributes = evaluated(obj) + set_value("Depth Split", 1.0) + points, faces, uv, materials, indices, _ = evaluated(obj) assert len(points) > len(base[0]) assert len(faces) == len(base[1]) assert component_count(faces) > component_count(base[1]) np.testing.assert_allclose(uv, base[2]) assert (materials, indices) == base[3:5] - assert not any(name.startswith("o_depth_") for name in attributes) set_value("Thickness", 0.2) shell_points, shell_faces, shell_uv, *_ = evaluated(obj) assert len(shell_points) == 2 * len(points) @@ -102,7 +98,7 @@ def test_depth_split_preserves_faces_and_closes_each_shell(surface, valid_only): assert set(edges.values()) == {2} assert np.isfinite(shell_points).all() and np.isfinite(shell_uv).all() set_value("Thickness", 0.0) - set_value("Split Threshold", 0.0) + set_value("Depth Split", 0.0) restored = evaluated(obj) np.testing.assert_allclose(restored[0], base[0]) assert restored[1] == base[1] @@ -197,35 +193,6 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface): assert roughness(normals) < roughness(raw_normals) -def test_surface_interface_and_node_structure(surface): - obj, _, _, inputs, _ = surface - expected = ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Split Threshold", "Smooth", "Valid Only", - "Smooth Weight", "Reference Depth", "Boundary Smooth", "Validity Threshold", "Uniform Scale", "Depth Image"] - assert list(inputs) == expected - assert inputs["Split Threshold"].default_value == pytest.approx(0.1) - assert inputs["Split Threshold"].min_value == 0 - assert inputs["Split Threshold"].max_value == 1 - assert inputs["Split Threshold"].subtype == "FACTOR" - assert inputs["Split Threshold"].parent == inputs["Smooth"].parent - assert inputs["Split Threshold"].parent.name != "Options" - assert inputs["Validity Threshold"].default_value == pytest.approx(0.9) - assert inputs["Validity Threshold"].parent.name == "Options" - assert inputs["Validity Threshold"].subtype == "FACTOR" - assert inputs["Valid Only"].default_value is True - assert inputs["Smooth"].default_value == 0 - assert inputs["Smooth"].min_value == 0 and inputs["Smooth"].max_value == 20 - assert inputs["Smooth Weight"].default_value == 1 - assert inputs["Smooth Weight"].subtype == "FACTOR" - assert inputs["Smooth Weight"].parent.name == "Options" - assert not {"Normal Smooth", "Cleanup Threshold"} & inputs.keys() - assert inputs["Thickness"].default_value == 0 - assert inputs["Subdivide"].default_value == 6 - assert inputs["Depth Scale"].default_value == 1 - assert all(item.structure_type == "SINGLE" for item in inputs.values()) - assert inputs["Thickness"].subtype == inputs["Reference Depth"].subtype == "DISTANCE" - assert inputs["Uniform Scale"].hide_in_modifier and inputs["Depth Image"].hide_in_modifier - validate_node_structure(obj.modifiers[0].node_group) - @pytest.mark.parametrize("thickness", [0.0, 0.4]) @pytest.mark.parametrize("direction", [(0.2, -0.1, 1), (-0.2, 0.1, 1), (0.4, -0.2, 2), (0, 0, 0), (1, 0, 0)]) @@ -278,30 +245,13 @@ def test_relief_smooth_preserves_topology_uv_and_materials(relief, thickness): np.testing.assert_allclose(evaluated(obj)[0], base[0], atol=1e-6) -def test_relief_interface_and_node_structure(relief): - obj, _, _, inputs, _ = relief - assert list(inputs) == ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Smooth", - "Reference Depth", "Smooth Weight", "Depth Direction", "Uniform Scale", "Depth Image"] - assert inputs["Smooth"].default_value == 0 - assert (inputs["Smooth"].min_value, inputs["Smooth"].max_value) == (0, 20) - assert inputs["Smooth Weight"].default_value == 1 - assert inputs["Smooth Weight"].subtype == "FACTOR" - assert tuple(inputs["Depth Direction"].default_value) == (0, 0, 1) - assert inputs["Depth Direction"].subtype == "DIRECTION" - assert inputs["Thickness"].default_value == 0.5 - assert inputs["Subdivide"].default_value == 6 - assert inputs["Depth Scale"].default_value == 0.5 - assert all(item.structure_type == "SINGLE" for item in inputs.values()) - assert inputs["Thickness"].subtype == inputs["Reference Depth"].subtype == "DISTANCE" - assert inputs["Uniform Scale"].hide_in_modifier and inputs["Depth Image"].hide_in_modifier - validate_node_structure(obj.modifiers[0].node_group) - - -@pytest.mark.parametrize("threshold", [0.0, 0.4, 0.9, 1.0]) -@pytest.mark.parametrize("pattern", ["valid", "invalid", "partial", "half", "stripe"]) +@pytest.mark.parametrize("pattern,threshold", [ + ("valid", 1.0), ("invalid", 0.0), ("invalid", 0.9), + ("partial", 0.4), ("partial", 0.9), ("half", 0.9), ("stripe", 0.9), +]) def test_valid_only_deletes_invalid_points_and_wire_edges(surface, pattern, threshold): obj, set_value, _, inputs, _ = surface - set_value("Validity Threshold", threshold) + set_value("Mask Threshold", threshold) depth_image = obj.modifiers[0][inputs["Depth Image"].identifier] pixels = np.ones((8, 16, 4), dtype=np.float32) alpha = np.ones(16, dtype=np.float32) @@ -319,18 +269,18 @@ def test_valid_only_deletes_invalid_points_and_wire_edges(surface, pattern, thre depth_image.update() for subdivisions in (0, 2, 5): set_value("Subdivide", subdivisions) - set_value("Valid Only", False) + set_value("Depth Mask", False) base = evaluated(obj) u = (base[0][:, 0] + 2) / 4 sampled = np.interp(u * 16 - 0.5, np.arange(16), alpha) expected = [face for face in base[1] if np.all(sampled[list(face)] >= threshold)] - set_value("Valid Only", True) + set_value("Depth Mask", True) culled = evaluated(obj) assert len(culled[1]) == len(expected) expected_points = {tuple(base[0][index]) for face in expected for index in face} assert {tuple(point) for point in culled[0]} == expected_points assert np.isfinite(culled[2]).all() - set_value("Valid Only", False) + set_value("Depth Mask", False) np.testing.assert_allclose(evaluated(obj)[0], base[0]) @@ -351,26 +301,6 @@ def test_valid_only_precedes_closed_shell_generation(surface): assert np.isfinite(points).all() and np.isfinite(uv).all() -def test_validity_graph_matches_reference_domains_and_connections(surface): - obj, _, _, _, _ = surface - group = obj.modifiers[0].node_group - delete = next(node for node in group.nodes if node.bl_idname == "GeometryNodeDeleteGeometry") - assert (delete.domain, delete.mode) == ("POINT", "ALL") - compare = origin_socket(delete.inputs["Selection"].links[0].from_socket).node - assert (compare.bl_idname, compare.data_type, compare.operation) == ("FunctionNodeCompare", "FLOAT", "LESS_THAN") - assert origin_socket(compare.inputs["B"].links[0].from_socket).name == "Validity Threshold" - alpha = origin_socket(compare.inputs["A"].links[0].from_socket) - assert alpha.name == "Alpha" and alpha.node.bl_idname == "FunctionNodeSeparateColor" - color = origin_socket(alpha.node.inputs["Color"].links[0].from_socket).node - assert origin_socket(color.inputs["Image"].links[0].from_socket).name == "Depth Image" - consumers = [link for link in group.links if link.to_node.bl_idname != "NodeReroute" - and origin_socket(link.from_socket) == color.outputs["Color"]] - assert any(link.to_node == alpha.node for link in consumers) - protected = origin_socket(delete.inputs["Geometry"].links[0].from_socket).node - assert protected.bl_idname == "GeometryNodeGroup" - assert protected.node_tree.name == "O Image Plane" - - @pytest.mark.parametrize("thickness", [0.0, 0.2]) def test_depth_smooth_changes_positions_and_preserves_topology(surface, thickness): obj, set_value, _, _, depth_image = surface @@ -394,21 +324,3 @@ def test_depth_smooth_changes_positions_and_preserves_topology(surface, thicknes set_value("Smooth Weight", 1.0) set_value("Smooth", 0) np.testing.assert_allclose(evaluated(obj)[0], base[0], atol=1e-6) - - -@pytest.mark.parametrize("kind", ["DEPTH", "RELIEF"]) -@pytest.mark.parametrize("source", ["definition", "asset"]) -def test_plane_interface_panels(source, kind): - from tests.support.planes import create_surface - obj, _, _, _, _ = create_surface(source, kind) - node_group = obj.modifiers[0].node_group - panels = [item for item in node_group.interface.items_tree if item.item_type == "PANEL"] - assert [item.name for item in panels] == ["Options", "Data"] - controls = ["Reference Depth"] - if kind == "DEPTH": - controls = ["Smooth Weight", "Reference Depth", "Boundary Smooth", "Validity Threshold"] - else: - controls += ["Smooth Weight", "Depth Direction"] - assert [item.name for item in panels[0].interface_items] == controls - assert [item.name for item in panels[1].interface_items] == ["Uniform Scale", "Depth Image"] - assert all(panel.default_closed for panel in panels) diff --git a/tests/tools/nodes/test_depth_strip_cleanup.py b/tests/tools/nodes/test_depth_strip_cleanup.py index 2ceea68..8304636 100644 --- a/tests/tools/nodes/test_depth_strip_cleanup.py +++ b/tests/tools/nodes/test_depth_strip_cleanup.py @@ -5,9 +5,6 @@ import numpy as np import pytest from tools.nodes.common.depth_surface import _remove_strip_faces -from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group -from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group -from tools.nodes.groups.image_plane import build_image_plane_group def cleaned_faces(vertices, faces, *, triangles=False): @@ -122,30 +119,13 @@ def test_triangle_cleanup_marks_both_faces_without_recursive_peeling(): assert cleaned_faces(vertices, faces, triangles=True) == {0, 2, 3} -@pytest.mark.parametrize("kind", ["plane", "cutout"]) -def test_split_input_precedes_smooth_without_cleanup_switch(kind): - bpy.ops.wm.read_factory_settings(use_empty=True) - group = (build_image_depth_plane_group(build_image_plane_group()) if kind == "plane" - else build_image_depth_cutout_group()) - inputs = [s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"] - names = [s.name for s in inputs] - assert names.count("Split Threshold") == 1 - assert names[names.index("Smooth") - 1] == "Split Threshold" - split = inputs[names.index("Split Threshold")] - assert split.parent == inputs[names.index("Smooth")].parent - assert split.parent.name != "Options" - assert split.default_value == pytest.approx(0.1) - assert (split.min_value, split.max_value, split.subtype) == (0, 1, "FACTOR") - assert "Remove Strip Faces" not in names - - @pytest.mark.parametrize("kind", ["plane", "cutout"]) def test_strips_are_removed_before_projection_and_closed_thickness(kind): from tests.support.depth_surface import surface, evaluated, assert_closed, component_count from tests.support.planes import create_surface if kind == "plane": - obj, set_value, _, _, image = create_surface("definition", "DEPTH") + obj, set_value, _, _, image = create_surface("DEPTH") set_value("Subdivide", 1) else: obj, set_value = surface(step=0.0) @@ -159,7 +139,7 @@ def test_strips_are_removed_before_projection_and_closed_thickness(kind): set_value("Depth Scale", 1.0) set_value("Reference Depth", 11.0) assert len(evaluated(obj)[1]) == 8 - set_value("Split Threshold", 0.5) + set_value("Depth Split", 0.5) vertices, faces = evaluated(obj) assert len(faces) == 4 and len(vertices) == 9 assert component_count(faces) == 1 @@ -183,18 +163,18 @@ def test_zero_split_bypasses_cleanup_and_positive_split_can_empty_mesh(kind): from tests.support.planes import create_surface if kind == "plane": - obj, set_value, _, _, _ = create_surface("definition", "DEPTH") + obj, set_value, _, _, _ = create_surface("DEPTH") set_value("Subdivide", 0) else: obj, set_value = surface(step=0.0, resolution=1) original, faces = evaluated(obj) assert len(faces) == 2 - set_value("Split Threshold", 0.001) + set_value("Depth Split", 0.001) assert not evaluated(obj)[1] set_value("Thickness", 0.2) assert not evaluated(obj)[1] set_value("Thickness", 0.0) - set_value("Split Threshold", 0.0) + set_value("Depth Split", 0.0) restored, restored_faces = evaluated(obj) assert restored_faces == faces np.testing.assert_array_equal(restored, original) @@ -212,7 +192,7 @@ def test_cutout_removes_an_all_boundary_triangle_strip(): mesh.free() # Every vertex lies on the boundary, even before any depth separation. for split in (0.0, 0.001, 0.0): - set_value("Split Threshold", split) + set_value("Depth Split", split) for thickness in (0.0, 0.2): set_value("Thickness", thickness) points, faces = evaluated(obj) diff --git a/tests/tools/nodes/test_depth_surface_split.py b/tests/tools/nodes/test_depth_surface_split.py index 780477a..dc05837 100644 --- a/tests/tools/nodes/test_depth_surface_split.py +++ b/tests/tools/nodes/test_depth_surface_split.py @@ -7,7 +7,6 @@ import numpy as np import pytest -from tests.support.nodes import CUTOUT_BUILDERS from tests.support.depth_surface import assert_closed, component_count, evaluated, surface @@ -20,7 +19,7 @@ def test_shell_walls_directly_connect_caps(mode, split): obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set( "value", np.full(len(obj.data.vertices), 0.3) ) - set_value("Split Threshold", split) + set_value("Depth Split", split) front, faces = evaluated(obj) edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0]))) boundary_count = sum(count == 1 for count in edges.values()) @@ -51,7 +50,7 @@ def test_shell_walls_directly_connect_caps(mode, split): @pytest.mark.parametrize("split", [0.0, 0.5]) def test_surface_caps_and_segmented_walls_are_connected(thickness, split): obj, set_value = surface() - set_value("Split Threshold", split) + set_value("Depth Split", split) set_value("Thickness", thickness) coords, faces = evaluated(obj) assert np.isfinite(coords).all() @@ -69,7 +68,7 @@ def test_split_mapping_and_uniform_scale(size): # The normalized ray jump crosses the threshold only at full strength. counts = [] for split in (0.0, 0.25, 0.5, 1.0): - set_value("Split Threshold", split) + set_value("Depth Split", split) coords, faces = evaluated(obj) counts.append(len(coords)) assert len(faces) == 8 @@ -82,7 +81,7 @@ def test_split_mapping_and_uniform_scale(size): def test_midpoint_selects_relative_ray_jump_and_preserves_faces(): obj, set_value = surface(step=4.0) - set_value("Split Threshold", 0.5) + set_value("Depth Split", 0.5) coords, faces = evaluated(obj) assert len(coords) == 18 and len(faces) == 8 assert set(np.round(coords[:, 2], 4)) == {2.0, -2.0} @@ -92,7 +91,7 @@ def test_cleanup_keeps_split_parts_of_a_large_source_island(): obj, set_value = surface(wrapper=True) set_value("Cleanup Threshold", 110.0) assert len(evaluated(obj)[1]) == 8 - set_value("Split Threshold", 0.5) + set_value("Depth Split", 0.5) assert len(evaluated(obj)[1]) == 8 set_value("Thickness", 0.2) assert_closed(evaluated(obj)[1]) @@ -103,7 +102,7 @@ def test_cleanup_keeps_split_parts_of_a_large_source_island(): @pytest.mark.parametrize("depth_scale", [0.0, 0.25, 1.0, 2.0, 4.0]) def test_split_topology_uses_depth_before_display_scaling(depth_scale): obj, set_value = surface(step=4.0) - set_value("Split Threshold", 0.5) + set_value("Depth Split", 0.5) original, original_faces = evaluated(obj) set_value("Depth Scale", depth_scale) coords, faces = evaluated(obj) @@ -112,18 +111,9 @@ def test_split_topology_uses_depth_before_display_scaling(depth_scale): np.testing.assert_allclose(coords[:, 2], original[:, 2] * depth_scale, atol=1e-6) -@pytest.mark.parametrize("shape", ["SOLID", "DEPTH_SOLID", "DEPTH_BALLOON"]) -def test_each_asset_cleans_the_original_geometry_before_shaping(shape): - bpy.ops.wm.read_factory_settings(use_empty=True) - group = CUTOUT_BUILDERS[shape]() - links = [link for link in group.links if link.from_node.bl_idname == "NodeGroupInput" and link.from_socket.name == "Geometry"] - assert len(links) == 1 - assert links[0].to_node.bl_idname == "GeometryNodeDeleteGeometry" - - def test_full_wrapper_smooth_keeps_closed_shell(): obj, set_value = surface(wrapper=True) - set_value("Split Threshold", 0.5) + set_value("Depth Split", 0.5) set_value("Thickness", 0.2) set_value("Smooth", 2) coords, faces = evaluated(obj) @@ -183,7 +173,7 @@ def test_shell_source_indices_cross_identity_grid_rows(): def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution): obj, set_value = surface(step=0.0, resolution=resolution) original, original_faces = evaluated(obj) - set_value("Split Threshold", 1.0) + set_value("Depth Split", 1.0) coords, faces = evaluated(obj) if resolution == 1: assert len(coords) == 0 and not faces @@ -196,7 +186,7 @@ def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution): def test_small_positive_split_preserves_unsplit_projection(): obj, set_value = surface(step=2.0) original, original_faces = evaluated(obj) - set_value("Split Threshold", 0.01) + set_value("Depth Split", 0.01) coords, faces = evaluated(obj) np.testing.assert_allclose(coords, original, atol=1e-6) assert faces == original_faces @@ -205,7 +195,7 @@ def test_small_positive_split_preserves_unsplit_projection(): def test_cut_vertices_preserve_camera_rays_and_other_vertices(): obj, set_value = surface(step=4.0) original, original_faces = evaluated(obj) - set_value("Split Threshold", 0.5) + set_value("Depth Split", 0.5) coords, faces = evaluated(obj) for source_face, result_face in zip(original_faces, faces): for source_index, result_index in zip(source_face, result_face): @@ -220,7 +210,7 @@ def test_cut_vertices_preserve_camera_rays_and_other_vertices(): def test_source_surface_sampling_preserves_depth_on_side_walls(): obj, set_value = surface(step=4.0) - set_value("Split Threshold", 0.5) + set_value("Depth Split", 0.5) set_value("Thickness", 0.1) coords, faces = evaluated(obj) assert_closed(faces) @@ -240,7 +230,7 @@ def test_temporary_fields_are_removed_and_user_attributes_survive(thickness, spl obj.data.attributes.new(".o_anyimage_user_weight", "FLOAT", "POINT") for value in attribute.data: value.value = 0.75 - set_value("Split Threshold", split) + set_value("Depth Split", split) set_value("Thickness", thickness) set_value("Smooth", 2) result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) @@ -279,7 +269,7 @@ def test_zero_center_distance_does_not_split_or_produce_nan(): for li in face.loop_indices: uv.data[li].uv = (0.25 if face.index == 0 else 0.75, 0.5) obj.data = mesh - set_value("Split Threshold", 1.0) + set_value("Depth Split", 1.0) coords, faces = evaluated(obj) assert len(coords) == 4 and len(faces) == 2 assert np.isfinite(coords).all() @@ -297,7 +287,7 @@ def test_triangle_corner_weights_do_not_open_sloped_side_walls(): pixels[..., 2] += np.arange(32)[:, None] * 0.02 image.pixels.foreach_set(pixels.ravel()) image.update() - set_value("Split Threshold", 0.5) + set_value("Depth Split", 0.5) set_value("Thickness", 0.8) coords, faces = evaluated(obj) assert_closed(faces) @@ -319,34 +309,28 @@ def test_relative_distance_slope_has_density_independent_selection(resolution): pixels[..., :2] = 0 image.pixels.foreach_set(pixels.ravel()) image.update() - set_value("Split Threshold", 0.25) + set_value("Depth Split", 0.25) assert len(evaluated(obj)[0]) == len(obj.data.vertices) - set_value("Split Threshold", 1.0) + set_value("Depth Split", 1.0) assert len(evaluated(obj)[0]) > len(obj.data.vertices) @pytest.mark.parametrize("kind", ["plane", "cutout"]) -@pytest.mark.parametrize("source", ["definition", "asset"]) -def test_split_uses_ray_distance_and_ignores_absolute_distance_scale(kind, source): +def test_split_uses_ray_distance_and_ignores_absolute_distance_scale(kind): from tests.support.planes import create_surface if kind == "plane": - obj, set_value, _, _, image = create_surface(source, "DEPTH") + obj, set_value, _, _, image = create_surface("DEPTH") set_value("Subdivide", 1) else: obj, set_value = surface(step=0) - if source == "asset": - from tests.support.paths import PROJECT_ROOT - with bpy.data.libraries.load(str(PROJECT_ROOT / "src/anyimage/assets/O_AnyImage.blend")) as (_, target): - target.node_groups = ["O Image Depth Cutout"] - obj.modifiers[0].node_group = target.node_groups[0] image = bpy.data.images["Camera"] output_split_mesh(obj) set_value("Reference Depth", 3) - set_value("Split Threshold", 0) + set_value("Depth Split", 0) width, height = image.size pixels = np.ones((height, width, 4), np.float32) original_count = len(evaluated(obj)[0]) - set_value("Split Threshold", 1) + set_value("Depth Split", 1) # Equal axial Z, but a tenfold ray-distance jump: Z-only splitting misses it. pixels[..., :3] = (0, 0, 1) pixels[:, width // 2:, 0] = np.sqrt(99) diff --git a/tests/tools/nodes/test_image_depth_panorama.py b/tests/tools/nodes/test_image_depth_panorama.py index 4542b9a..7f3aaa9 100644 --- a/tests/tools/nodes/test_image_depth_panorama.py +++ b/tests/tools/nodes/test_image_depth_panorama.py @@ -8,15 +8,11 @@ import pytest from anyimage.common.object import modifier_input_identifier, set_modifier_input from tools.nodes.groups.image_depth_panorama import build_image_depth_panorama_group -from tests.support.nodes import validate_node_structure def panorama(depth=3, alpha=1, subdivide=3): bpy.ops.wm.read_factory_settings(use_empty=True) group = build_image_depth_panorama_group() - assert group.name == "O Image Depth Panorama" - validate_node_structure(group) - assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes) image = bpy.data.images.new("Radial Depth", width=128, height=64, float_buffer=True) image.colorspace_settings.name = "Non-Color" image.alpha_mode = "CHANNEL_PACKED" @@ -37,6 +33,7 @@ def panorama(depth=3, alpha=1, subdivide=3): for name, value in (("Depth Image", image), ("Subdivide", subdivide)): set_value(name, value) set_value("Boundary Smooth", 0) + set_value("Depth Split", 0) return obj, set_value @@ -47,7 +44,6 @@ def evaluated(obj): points = np.array([v.co[:] for v in mesh.vertices]).reshape(-1, 3) faces = [tuple(f.vertices) for f in mesh.polygons] uv = np.array([v.uv[:] for v in mesh.uv_layers.active.data]) if mesh.uv_layers.active else np.empty((0, 2)) - assert not any(a.name.startswith(("o_depth_", "o_panorama_")) for a in mesh.attributes) return points, faces, uv finally: result.to_mesh_clear() @@ -75,10 +71,10 @@ def test_quad_sphere_topology_and_radial_endpoints(subdivide): def test_depth_alpha_culls_faces_without_double_multiplication(): obj, set_value = panorama(alpha=0.5) assert not len(evaluated(obj)[0]) - set_value("Valid Only", False) + set_value("Depth Mask", False) assert len(evaluated(obj)[1]) == 384 - set_value("Valid Only", True) - set_value("Validity Threshold", 0.49) + set_value("Depth Mask", True) + set_value("Mask Threshold", 0.49) assert len(evaluated(obj)[1]) == 384 @@ -115,7 +111,7 @@ def test_invalid_distance_removes_faces_between_valid_and_invalid_regions(sky_al assert np.linalg.norm(points, axis=1).max() < 3.01 set_value("Invalid Distance", 250) assert evaluated(obj)[1] == original_faces - set_value("Valid Only", False) + set_value("Depth Mask", False) for background in (100, 250): set_value("Invalid Distance", background) points, faces, _ = evaluated(obj) @@ -132,14 +128,13 @@ def test_controls_smoothing_and_material_slot_inheritance(): obj, set_value = panorama() group = obj.modifiers[0].node_group inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"} - assert inputs["Validity Threshold"].parent.name == "Options" + assert inputs["Mask Threshold"].parent.name == "Options" assert inputs["Invalid Distance"].parent.name == "Options" assert inputs["Invalid Distance"].default_value == 100 assert inputs["Invalid Distance"].subtype == "DISTANCE" assert inputs["Smooth Weight"].parent.name == "Options" - assert inputs["Valid Only"].parent == inputs["Subdivide"].parent - assert inputs["Valid Only"].default_value - assert "Material" not in inputs + assert inputs["Depth Mask"].parent == inputs["Subdivide"].parent + assert inputs["Depth Mask"].default_value obj.data.from_pydata([(0, 0, 0)], [], []) material = bpy.data.materials.new("Slot Material") obj.data.materials.append(material) @@ -193,7 +188,7 @@ def test_split_strength_and_distance_scale_invariance(): obj, set_value = panorama(depth * scale) counts = [] for split in (0, 0.1, 0.5, 1): - set_value("Split Threshold", split) + set_value("Depth Split", split) points, faces, uv = evaluated(obj) assert np.isfinite(points).all() and np.isfinite(uv).all() edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0]))) @@ -209,7 +204,7 @@ def test_continuous_spherical_slope_does_not_split_at_seam_or_poles(): depth = 3 + 0.3 * np.cos(2 * np.pi * u) * np.sin(np.pi * v) obj, set_value = panorama(depth) original = evaluated(obj) - set_value("Split Threshold", 1) + set_value("Depth Split", 1) set_value("Boundary Smooth", 12) points, faces, uv = evaluated(obj) np.testing.assert_allclose(points, original[0], atol=1e-6) @@ -226,7 +221,7 @@ def test_split_corners_keep_their_rays_and_own_face_distance(): original, _, _ = evaluated(obj) original_radii = np.linalg.norm(original, axis=1) original_rays = original / original_radii[:, None] - set_value("Split Threshold", 1) + set_value("Depth Split", 1) points, _, uv = evaluated(obj) radii = np.linalg.norm(points, axis=1) distance, indices = cKDTree(original_rays).query(points / radii[:, None]) @@ -245,7 +240,7 @@ def test_zero_split_bypasses_single_snapshot_strip_cleanup(monkeypatch): with monkeypatch.context() as patch: patch.setattr(depth_surface, "_remove_strip_faces", lambda group, geometry, **kw: geometry) obj, set_value = panorama(depth) - set_value("Split Threshold", 1) + set_value("Depth Split", 1) _, faces, _ = evaluated(obj) edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0]))) strips = 0 @@ -255,7 +250,7 @@ def test_zero_split_bypasses_single_snapshot_strip_cleanup(monkeypatch): assert strips > 0 obj, set_value = panorama(depth) original_faces = evaluated(obj)[1] - set_value("Split Threshold", 1) + set_value("Depth Split", 1) assert len(evaluated(obj)[1]) == len(faces) - strips - set_value("Split Threshold", 0) + set_value("Depth Split", 0) assert evaluated(obj)[1] == original_faces diff --git a/tests/tools/nodes/test_image_layer.py b/tests/tools/nodes/test_image_layer.py index ae44c50..3b4feee 100644 --- a/tests/tools/nodes/test_image_layer.py +++ b/tests/tools/nodes/test_image_layer.py @@ -50,9 +50,6 @@ def test_alpha_curve_preserves_endpoints_and_suppresses_soft_edges(image_layer): def test_depth_layer_keeps_foreground_alpha_threshold(image_layer): depth = build_image_depth_layer_group() - assert "Alpha Fix" not in { - item.name for item in depth.interface.items_tree if item.item_type == "SOCKET" - } output = next(node for node in depth.nodes if node.type == "GROUP_OUTPUT") threshold = output.inputs["Alpha"].links[0].from_node assert threshold.operation == "GREATER_THAN" diff --git a/tests/tools/nodes/test_image_plane.py b/tests/tools/nodes/test_image_plane.py index 975c718..23834c2 100644 --- a/tests/tools/nodes/test_image_plane.py +++ b/tests/tools/nodes/test_image_plane.py @@ -20,7 +20,7 @@ def evaluated_mesh(obj): @pytest.mark.parametrize("source_count", [0, 4, 9]) def test_material_inheritance_removes_only_source_vertices(source_count): - from tools.nodes.common.material import inherit_material_slots + from tools.nodes.common.material_slots import inherit_material_slots from tools.nodes.arrangement.arrange import arrange_nodes bpy.ops.wm.read_factory_settings(use_empty=True) diff --git a/tests/tools/nodes/test_inventory.py b/tests/tools/nodes/test_inventory.py index 30d8417..84fdb27 100644 --- a/tests/tools/nodes/test_inventory.py +++ b/tests/tools/nodes/test_inventory.py @@ -1,27 +1,85 @@ """Check the saved library's public inventory and persistence.""" import bpy import pytest -from anyimage.common.node import load_node_group, node_asset_path -from tools.nodes.check import GEOMETRY_NAMES, SHADER_NAMES +from anyimage.common.node import node_asset_path +from tools.nodes.check import GEOMETRY_NAMES -def test_saved_asset_inventory(): +def test_saved_assets_evaluate(): + from tools.nodes.check import check_asset_library + bpy.ops.wm.read_factory_settings(use_empty=True) - with bpy.data.libraries.load(str(node_asset_path())) as (source, _): - assert set(source.node_groups) == GEOMETRY_NAMES | SHADER_NAMES - for name in sorted(GEOMETRY_NAMES | SHADER_NAMES): - bpy.ops.wm.read_factory_settings(use_empty=True) - node_group = load_node_group(name) - assert node_group.bl_idname == ("GeometryNodeTree" if name in GEOMETRY_NAMES else "ShaderNodeTree") - assert node_group.use_fake_user and node_group.users > 0 - if name in GEOMETRY_NAMES: - assert node_group.is_modifier - if bpy.app.version >= (5, 0, 0): - assert node_group.library is not None - if name in {"O Image Depth Layer", "O Shadeless"}: - expected = ["Color", "Alpha", "Depth", "Bump Scale"] if name == "O Image Depth Layer" else ["Color", "Alpha"] - assert [item.name for item in node_group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT"] == expected + check_asset_library(node_asset_path()) + + +def test_saved_parameter_descriptions_match_definitions(): + from tools.nodes.build import build_node_groups + + parameters = { + "O Image Plane": {"Subdivide"}, + "O Image Depth Plane": { + "Subdivide", "Thickness", "Depth Split", "Smooth", "Smooth Weight", + "Depth Mask", "Mask Threshold", "Boundary Smooth", + }, + "O Image Relief Plane": {"Subdivide", "Thickness", "Smooth", "Smooth Weight"}, + "O Image Cutout": {"Thickness", "Smooth", "Smooth Weight"}, + "O Image Depth Cutout": { + "Thickness", "Depth Split", "Smooth", "Smooth Weight", + "Boundary Smooth", "Outline Depth Fix", "Front Inflation", + }, + "O Image Depth Balloon": {"Thickness", "Smooth", "Smooth Weight"}, + "O Image Depth Panorama": { + "Subdivide", "Depth Scale", "Depth Split", "Smooth", "Smooth Weight", + "Depth Mask", "Mask Threshold", "Boundary Smooth", "Invalid Distance", + }, + "O Image Layer": {"Alpha Fix", "Normal Scale", "Bump Scale", "Object Space"}, + "O Image Depth Layer": {"Bump Scale"}, + } + + def descriptions(groups): + result = {} + for group in groups: + if group.name not in parameters: + continue + sockets = [ + item for item in group.interface.items_tree + if item.item_type == "SOCKET" and item.in_out == "INPUT" + and item.name in parameters[group.name] + ] + assert {item.name for item in sockets} == parameters[group.name] + assert all(item.description for item in sockets), group.name + if group.name == "O Image Depth Panorama": + defaults = {item.name: item.default_value for item in sockets + if item.name in {"Subdivide", "Depth Split", "Boundary Smooth"}} + assert defaults == pytest.approx({ + "Subdivide": 8, "Depth Split": 0.1, "Boundary Smooth": 2, + }) + if "Depth Mask" in parameters[group.name]: + names = [item.name for item in group.interface.items_tree + if item.item_type == "SOCKET" and item.in_out == "INPUT"] + assert names.index("Depth Mask") == names.index("Depth Split") + 1 + if group.name in {"O Image Cutout", "O Image Depth Cutout"}: + mode = next(node for node in group.nodes + if node.bl_idname == "GeometryNodeMenuSwitch" + and node.inputs["Menu"].links[0].from_socket.name == "Mode") + assert [item.name for item in mode.enum_items] == ["Balloon", "Shell"] + thickness = [item.description for item in sockets if item.name == "Thickness"] + assert len(thickness) == 2 and len(set(thickness)) == 2 + if group.name == "O Image Depth Cutout": + inflation = next(item for item in sockets if item.name == "Front Inflation") + assert inflation.default_value == 0.5 + result[group.name] = [ + (item.identifier, item.name, item.description) for item in sockets + ] + assert result.keys() == parameters.keys() + return result + + bpy.ops.wm.read_factory_settings(use_empty=True) + expected = descriptions(build_node_groups()) + bpy.ops.wm.read_factory_settings(use_empty=True) + with bpy.data.libraries.load(str(node_asset_path())) as (library, target): + target.node_groups = list(library.node_groups) + assert descriptions(target.node_groups) == expected @pytest.mark.parametrize("source", ["definition", "asset"]) diff --git a/tests/tools/nodes/test_saved_layout.py b/tests/tools/nodes/test_saved_layout.py deleted file mode 100644 index 95e110c..0000000 --- a/tests/tools/nodes/test_saved_layout.py +++ /dev/null @@ -1,37 +0,0 @@ -from tests.support.paths import PROJECT_ROOT -import unittest - - -SOURCE_ROOT = PROJECT_ROOT / "src" / "anyimage" - - -class NodeAssetsTest(unittest.TestCase): - def test_geometry_assets_preserve_node_structure(self): - import bpy - - from tests.support.nodes import validate_node_structure - from tools.nodes.arrangement.arrange import arrange_nodes - - bpy.ops.wm.read_factory_settings(use_empty=True) - with bpy.data.libraries.load( - str(SOURCE_ROOT / "assets" / "O_AnyImage.blend") - ) as (source, target): - target.node_groups = list(source.node_groups) - groups = [ - group - for group in target.node_groups - if group.bl_idname == "GeometryNodeTree" - ] - self.assertEqual(len(groups), 7) - for group in groups: - validate_node_structure(group) - positions = {node.name: tuple(node.location_absolute) for node in group.nodes - if node.bl_idname not in {"NodeReroute", "NodeGroupInput"}} - arrange_nodes(group) - for name, position in positions.items(): - for actual, expected in zip(group.nodes[name].location_absolute, position): - self.assertAlmostEqual(actual, expected, delta=0.01, msg=f"{group.name}: {name}") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tools/nodes/test_temporary_attributes.py b/tests/tools/nodes/test_temporary_attributes.py index 4c6c57d..ba9e362 100644 --- a/tests/tools/nodes/test_temporary_attributes.py +++ b/tests/tools/nodes/test_temporary_attributes.py @@ -3,30 +3,22 @@ import bpy import pytest -from anyimage.common.node import node_asset_path from tests.support.depth_surface import surface from tests.support.planes import create_surface from tests.tools.nodes.test_image_depth_panorama import panorama -@pytest.mark.parametrize("source", ["definition", "asset"]) @pytest.mark.parametrize("kind", ["cutout", "plane", "panorama"]) -def test_depth_attribute_cleanup_has_no_warnings(source, kind): +def test_depth_attribute_cleanup_has_no_warnings(kind): if kind == "cutout": obj, set_value = surface() elif kind == "plane": - obj, set_value, *_ = create_surface("definition", "DEPTH") + obj, set_value, *_ = create_surface("DEPTH") else: obj, set_value = panorama(subdivide=2) modifier = obj.modifiers[0] - if source == "asset": - name = modifier.node_group.name - modifier.node_group.name += " Definition" - with bpy.data.libraries.load(str(node_asset_path())) as (_, target): - target.node_groups = [name] - modifier.node_group = target.node_groups[0] for split in (0.0, 0.5, 0.0): - set_value("Split Threshold", split) + set_value("Depth Split", split) for smooth in (0, 5): set_value("Boundary Smooth", smooth) evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) diff --git a/tests/tools/test_commands.py b/tests/tools/test_commands.py deleted file mode 100644 index 30c07a8..0000000 --- a/tests/tools/test_commands.py +++ /dev/null @@ -1,43 +0,0 @@ -from tests.support.paths import PROJECT_ROOT -import tomllib -import unittest -import importlib -from unittest.mock import patch - -import pytest - - -@pytest.mark.parametrize("module_name", ["tools.nodes.cli", "tools.models.cli"]) -@pytest.mark.parametrize("arguments,code", [([], None), (["--help"], 0), (["unknown"], 2), (["--bad-option"], 2)]) -def test_help_and_invalid_arguments_do_not_start_work(module_name, arguments, code, capsys): - module = importlib.import_module(module_name) - with patch("sys.argv", ["tool", *arguments]), patch.object(module.subprocess, "run") as run: - if code is None: - module.main() - else: - with pytest.raises(SystemExit) as error: - module.main() - assert error.value.code == code - run.assert_not_called() - captured = capsys.readouterr() - assert "usage:" in captured.out + captured.err - - -class ToolCommandsTests(unittest.TestCase): - def test_repository_tools_have_uv_commands(self): - project = tomllib.loads( - (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8") - )["project"] - self.assertEqual( - project["scripts"], - { - "pack": "tools.pack:main", - "node-group": "tools.nodes.cli:main", - "model": "tools.models.cli:main", - "docs": "tools.docs:main", - }, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tools/test_docs.py b/tests/tools/test_docs.py deleted file mode 100644 index 415ab39..0000000 --- a/tests/tools/test_docs.py +++ /dev/null @@ -1,42 +0,0 @@ -from tests.support.paths import PROJECT_ROOT -import importlib.util - - -SCRIPT_PATH = PROJECT_ROOT / "tools" / "docs.py" -SPEC = importlib.util.spec_from_file_location("docs_script", SCRIPT_PATH) -docs = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(docs) - - -def test_documentation_command_deploys_project_version(monkeypatch): - monkeypatch.setattr(docs, "project_version", lambda: "1.2.3") - - assert docs.documentation_command() == [ - "mike", - "deploy", - "--update-aliases", - "1.2.3", - "latest", - ] - - -def test_documentation_command_previews_current_source(): - assert docs.documentation_command(dev=True) == ["mkdocs", "serve"] - - -def test_main_runs_from_project_root(monkeypatch): - calls = [] - monkeypatch.setattr( - docs.subprocess, - "run", - lambda *args, **kwargs: calls.append((args, kwargs)), - ) - - docs.main(["-dev"]) - - assert calls == [ - ( - (["mkdocs", "serve"],), - {"cwd": docs.PROJECT_ROOT, "check": True}, - ) - ] diff --git a/tests/tools/test_manifest.py b/tests/tools/test_manifest.py index 0ea0e0c..af19b0e 100644 --- a/tests/tools/test_manifest.py +++ b/tests/tools/test_manifest.py @@ -15,7 +15,6 @@ class ExtensionManifestTest(unittest.TestCase): self.assertEqual(self.manifest["schema_version"], "1.0.0") self.assertEqual(self.manifest["id"], "anyimage") self.assertEqual(self.manifest["type"], "add-on") - self.assertNotIn("bl_info", self.manifest) def test_declares_required_environment_permissions(self): self.assertEqual( diff --git a/tests/tools/test_pack.py b/tests/tools/test_pack.py index 17c9dcd..58a691f 100644 --- a/tests/tools/test_pack.py +++ b/tests/tools/test_pack.py @@ -104,19 +104,19 @@ class ExtensionPackagingTest(unittest.TestCase): "operators/convert_to_panorama/__init__.py", "operators/convert_to_panorama/operators.py", "operators/convert_to_panorama/object.py", - "operators/frame_tool.py", + "operators/frame_tool/__init__.py", + "operators/frame_tool/operators.py", + "operators/frame_tool/projection.py", + "operators/frame_tool/compositing.py", "operators/mask_tool.py", - "operators/rectify_tool.py", + "operators/rectify_tool/__init__.py", + "operators/rectify_tool/operators.py", + "operators/rectify_tool/geometry.py", + "operators/rectify_tool/preview.py", "operators/ai_setup.py", "operators/remove_background.py", ): self.assertIn(path, names) - self.assertNotIn("operators/convert_to_plane/nodes.py", names) - self.assertNotIn("operators/cutout/__init__.py", names) - self.assertNotIn("operators/image_mesh/__init__.py", names) - self.assertNotIn("operators/mesh/__init__.py", names) - self.assertNotIn("operators/image_edit/__init__.py", names) - self.assertNotIn("operators/background.py", names) self.assertIn( "assets/O_AnyImage.blend", names, @@ -132,26 +132,12 @@ class ExtensionPackagingTest(unittest.TestCase): self.assertIn("server/geometry/depth_texture.py", names) self.assertIn("server/geometry/prediction_artifacts.py", names) self.assertIn("server/geometry/normal_texture.py", names) - self.assertNotIn( - "server/geometry/generate_geometry_and_normal.py", names - ) - self.assertNotIn("common/image_selection.py", names) - self.assertNotIn("server/selection/cutout_images.py", names) - self.assertNotIn("server/selection/polygon_mask.py", names) - self.assertNotIn("server/jobs/prepare_geometry_data.py", names) - self.assertNotIn("server/geometry/cutout.py", names) self.assertIn("server/models/background.py", names) - self.assertNotIn("server/models/da3.py", names) self.assertIn("server/models/moge.py", names) - self.assertNotIn("server/jobs/debug_predictions.py", names) - self.assertNotIn("operators/debug.py", names) - self.assertFalse(any(name.startswith("server/outputs/") for name in names)) self.assertIn("server/model_catalog.py", names) self.assertIn("server/model_download.py", names) self.assertIn("server/models/onnx_ben2.py", names) self.assertIn("server/models/onnx_birefnet.py", names) - self.assertNotIn("server/models/onnx_pisa_sr.py", names) - self.assertNotIn("server/models/onnx_da3.py", names) self.assertIn("server/models/onnx_moge2.py", names) self.assertIn("server/models/onnx_moge3.py", names) self.assertIn("server/models/onnx_upscale.py", names) @@ -163,10 +149,7 @@ class ExtensionPackagingTest(unittest.TestCase): "wheels/blendjob-0.1.17-py3-none-any.whl", names, ) - self.assertFalse(any(name.startswith("blendjob/") for name in names)) self.assertNotIn("anyimage/__init__.py", names) - self.assertFalse(any(name.startswith("sources/") for name in names)) - self.assertFalse(any(name.startswith("vendor/") for name in names)) self.assertIn(f'platforms = ["{platform}"]', manifest) self.assertNotIn( "windows-x64" if platform != "windows-x64" else "macos-arm64", diff --git a/tools/nodes/check.py b/tools/nodes/check.py index 98d0753..b49d3e7 100644 --- a/tools/nodes/check.py +++ b/tools/nodes/check.py @@ -59,7 +59,7 @@ def check_asset_library(asset_path): modifier.node_group = node_group values = {"Subdivide": 0, "Thickness": 0.2, "Reference Depth": 2.0, "Depth Image": depth_image, "Uniform Scale": 1.0, "Normal Smooth": 0, - "Split Threshold": 0.0} + "Depth Split": 0.0} for item in node_group.interface.items_tree: if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name in values: modifier[item.identifier] = values[item.name] diff --git a/tools/nodes/common/cutout_boundary.py b/tools/nodes/common/cutout_boundary.py index a52a91c..4b0c509 100644 --- a/tools/nodes/common/cutout_boundary.py +++ b/tools/nodes/common/cutout_boundary.py @@ -36,8 +36,8 @@ def taper_split_profile(group, original, geometry): rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") protected = sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"]) influence = boundary_influence(group, protected) - controls = group_input(nodes, {"Split Threshold"}) - band = _math(group, "MULTIPLY", influence, _math(group, "GREATER_THAN", controls.outputs["Split Threshold"], 0)) + controls = group_input(nodes, {"Depth Split"}) + band = _math(group, "MULTIPLY", influence, _math(group, "GREATER_THAN", controls.outputs["Depth Split"], 0)) retained = _math(group, "SQRT", _math(group, "MAXIMUM", _math(group, "SUBTRACT", 1, band), 0)) profile = _math(group, "MULTIPLY", read_float_attribute(group, "o_balloon"), retained) return store_float_attribute(group, geometry, "o_balloon", profile) diff --git a/tools/nodes/common/material.py b/tools/nodes/common/material_slots.py similarity index 100% rename from tools/nodes/common/material.py rename to tools/nodes/common/material_slots.py diff --git a/tools/nodes/common/materials.py b/tools/nodes/common/shader.py similarity index 100% rename from tools/nodes/common/materials.py rename to tools/nodes/common/shader.py diff --git a/tools/nodes/groups/image_cutout.py b/tools/nodes/groups/image_cutout.py index e885d7e..f655b64 100644 --- a/tools/nodes/groups/image_cutout.py +++ b/tools/nodes/groups/image_cutout.py @@ -107,19 +107,19 @@ def _set_segmented_layer_z( return merge.outputs["Geometry"] -def _build_cutout(group, geometry, thickness): +def _build_shell(group, geometry, thickness): nodes = group.nodes links = group.links - half = math_node(nodes, "MULTIPLY") - half.inputs[1].default_value = 0.5 - links.new(thickness, half.inputs[0]) + front = math_node(nodes, "MULTIPLY") + front.inputs[1].default_value = 0.0 + links.new(thickness, front.inputs[0]) back = math_node(nodes, "MULTIPLY") back.inputs[1].default_value = -1.0 - links.new(half.outputs[0], back.inputs[0]) + links.new(thickness, back.inputs[0]) shaped = _set_segmented_layer_z( group, geometry, - half.outputs[0], + front.outputs[0], back.outputs[0], thickness, ) @@ -158,26 +158,26 @@ def build_image_cutout_group(): group = bpy.data.node_groups.new(name, 'GeometryNodeTree') group.is_modifier, group.use_fake_user = (True, True) group.color_tag = 'GEOMETRY' - group['anyimage_asset_revision'] = 11 + group['anyimage_asset_revision'] = 13 interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry') interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu') - float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet' - float_input(group, 'Uniform Thickness', 0.0, subtype='DISTANCE').description = 'Distance used only by Uniform thickness mode' + float_input(group, 'Thickness', 1.0).description = 'Control how much the surface bulges in Balloon mode. Zero removes the bulge.' + float_input(group, 'Shell Thickness', 0.0, subtype='DISTANCE').description = 'Add thickness behind the original surface while keeping the front surface in place.' iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt') + iterations.description = 'Set the number of mesh smoothing passes. Higher values smooth the surface more but may soften details. Zero disables smoothing.' iterations.default_value = 0 iterations.min_value, iterations.max_value = (0, 20) options = group.interface.new_panel(name='Options', default_closed=True) float_input(group, 'Cleanup Threshold', 0.0, parent=options) - float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options) - data = group.interface.new_panel(name='Data', default_closed=True) - interface_socket(group, 'Inward Axis', 'INPUT', 'NodeSocketMenu', data).hide_in_modifier = True + float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options).description = 'Control the strength of each mesh smoothing pass. Higher values move vertices more.' + interface_socket(group, 'Depth Axis', 'INPUT', 'NodeSocketMenu', options).menu_expanded = True interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry') controls = group_input(group.nodes, set()) balloon = _build_balloon(group, controls.outputs['Geometry'], controls.outputs['Thickness']) - uniform = _build_cutout(group, controls.outputs['Geometry'], controls.outputs['Uniform Thickness']) - choose = menu_switch(group, 'Mode', ('Balloon', 'Uniform'), 'GEOMETRY') + shell = _build_shell(group, controls.outputs['Geometry'], controls.outputs['Shell Thickness']) + choose = menu_switch(group, 'Mode', ('Balloon', 'Shell'), 'GEOMETRY') group.links.new(balloon, choose.inputs['Balloon']) - group.links.new(uniform, choose.inputs['Uniform']) + group.links.new(shell, choose.inputs['Shell']) geometry = choose.outputs[0] planar_inputs = [link.to_socket for link in group.links if link.from_node.bl_idname == 'NodeGroupInput' and link.from_socket.name == 'Geometry'] base = group_input(group.nodes, {'Geometry', 'Cleanup Threshold'}) @@ -198,15 +198,15 @@ def build_image_cutout_group(): inflated.inputs['False'].default_value = 1 group.links.new(enabled.outputs[0], inflated.inputs['Switch']) group.links.new(profile, inflated.inputs['True']) - mode = menu_switch(group, 'Mode', ('Balloon', 'Uniform'), 'FLOAT') - mode.inputs['Uniform'].default_value = 1 + mode = menu_switch(group, 'Mode', ('Balloon', 'Shell'), 'FLOAT') + mode.inputs['Shell'].default_value = 1 group.links.new(inflated.outputs[0], mode.inputs['Balloon']) scale = mode.outputs[0] geometry = store_float_attribute(group, geometry, NORMAL_SCALE_ATTRIBUTE_NAME, scale) orient = group.nodes.new('GeometryNodeTransform') orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler() group.links.new(geometry, orient.inputs['Geometry']) - axis = menu_switch(group, 'Inward Axis', ('-Z', '+X'), 'GEOMETRY') + axis = menu_switch(group, 'Depth Axis', ('-Z', '+X'), 'GEOMETRY') group.links.new(geometry, axis.inputs['-Z']) group.links.new(orient.outputs[0], axis.inputs['+X']) _shade_output(group, axis.outputs[0]) @@ -216,8 +216,8 @@ def build_image_cutout_group(): continue if item.name == 'Mode': item.default_value = 'Balloon' - elif item.name == 'Inward Axis': + elif item.name == 'Depth Axis': item.default_value = '-Z' - elif item.name == 'Uniform Thickness': + elif item.name == 'Shell Thickness': item.name = 'Thickness' return group diff --git a/tools/nodes/groups/image_depth_balloon.py b/tools/nodes/groups/image_depth_balloon.py index 333441d..b8d4d38 100644 --- a/tools/nodes/groups/image_depth_balloon.py +++ b/tools/nodes/groups/image_depth_balloon.py @@ -110,21 +110,22 @@ def build_image_depth_balloon_group(): group = bpy.data.node_groups.new(name, 'GeometryNodeTree') group.is_modifier, group.use_fake_user = (True, True) group.color_tag = 'GEOMETRY' - group['anyimage_asset_revision'] = 12 + group['anyimage_asset_revision'] = 14 interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry') - float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet' + float_input(group, 'Thickness', 1.0).description = 'Control how much the surface bulges. Zero removes the bulge.' float_input(group, 'Depth Scale', 1.0) bool_input(group, 'Double Sided', True) iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt') + iterations.description = 'Set the number of mesh smoothing passes. Higher values smooth the surface more but may soften details. Zero disables smoothing.' iterations.default_value = 0 iterations.min_value, iterations.max_value = (0, 20) options = group.interface.new_panel(name='Options', default_closed=True) float_input(group, 'Cleanup Threshold', 0.0, parent=options) - float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options) + float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options).description = 'Control the strength of each mesh smoothing pass. Higher values move vertices more.' float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options) + interface_socket(group, 'Depth Axis', 'INPUT', 'NodeSocketMenu', options).menu_expanded = True vector_input(group, 'Depth Direction', (0, 0, 1), subtype='DIRECTION', parent=options) data = group.interface.new_panel(name='Data', default_closed=True) - interface_socket(group, 'Inward Axis', 'INPUT', 'NodeSocketMenu', data).hide_in_modifier = True float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True interface_socket(group, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry') @@ -145,7 +146,7 @@ def build_image_depth_balloon_group(): orient = group.nodes.new('GeometryNodeTransform') orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler() group.links.new(geometry, orient.inputs['Geometry']) - axis = menu_switch(group, 'Inward Axis', ('-Z', '+X'), 'GEOMETRY') + axis = menu_switch(group, 'Depth Axis', ('-Z', '+X'), 'GEOMETRY') group.links.new(geometry, axis.inputs['-Z']) group.links.new(orient.outputs[0], axis.inputs['+X']) _shade_output(group, axis.outputs[0]) @@ -155,8 +156,6 @@ def build_image_depth_balloon_group(): continue if item.name == 'Mode': item.default_value = 'Balloon' - elif item.name == 'Inward Axis': + elif item.name == 'Depth Axis': item.default_value = '-Z' - elif item.name == 'Uniform Thickness': - item.name = 'Thickness' return group diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py index 1245f4c..e69adab 100644 --- a/tools/nodes/groups/image_depth_cutout.py +++ b/tools/nodes/groups/image_depth_cutout.py @@ -15,14 +15,14 @@ from ..common.cutout_boundary import remove_boundary_triangles, taper_split_prof NODE_GROUP_NAME = "O Image Depth Cutout" -def build_normal_weight(group, uniform): +def build_normal_weight(group, is_shell): """Scale normal smoothing by the active mode's relative thickness.""" - control = group_input(group.nodes, {"Thickness", "Uniform Thickness", "Reference Depth"}) + control = group_input(group.nodes, {"Thickness", "Shell Thickness", "Reference Depth"}) amount = group.nodes.new("GeometryNodeSwitch") amount.input_type = "FLOAT" - group.links.new(uniform, amount.inputs["Switch"]) + group.links.new(is_shell, amount.inputs["Switch"]) group.links.new(control.outputs["Thickness"], amount.inputs["False"]) - ratio = _math(group, "DIVIDE", control.outputs["Uniform Thickness"], + ratio = _math(group, "DIVIDE", control.outputs["Shell Thickness"], _math(group, "MAXIMUM", control.outputs["Reference Depth"], 1e-8)) group.links.new(ratio, amount.inputs["True"]) ramp = group.nodes.new("ShaderNodeMapRange") @@ -73,7 +73,7 @@ def build_volume_fields(group): return {"rim": rim, "center": center, "thickness": thickness} -def build_shell_fields(group, geometry, fields, uniform, normal_weight=1): +def build_shell_fields(group, geometry, fields, is_shell, normal_weight=1): """Evaluate rear directions on the center surface and select shell thickness.""" nodes, links = group.nodes, group.links position = nodes.new("GeometryNodeInputPosition") @@ -93,12 +93,12 @@ def build_shell_fields(group, geometry, fields, uniform, normal_weight=1): links.new(normal.outputs[0], blur.inputs["Value"]) normalized = blend_normal(group, normal.outputs[0], blur.outputs[0], normal_weight) rear_normal = sample_field(group, surface.outputs[0], normalized, "FLOAT_VECTOR") - control = group_input(nodes, {"Uniform Thickness"}) + control = group_input(nodes, {"Shell Thickness"}) thickness = nodes.new("GeometryNodeSwitch") thickness.input_type = "FLOAT" - links.new(uniform, thickness.inputs["Switch"]) + links.new(is_shell, thickness.inputs["Switch"]) links.new(fields["thickness"], thickness.inputs["False"]) - links.new(control.outputs["Uniform Thickness"], thickness.inputs["True"]) + links.new(control.outputs["Shell Thickness"], thickness.inputs["True"]) return {"thickness": thickness.outputs[0], "normal": rear_normal} @@ -145,8 +145,8 @@ def build_front_fields(group, rim, front_normal, normal_weight=1): return {"structure": structure, "inflation_normal": normalized} -def build_inflation_position(group, position, distance, uniform, fields): - """Grow both modes continuously while leaving the rear cap in place.""" +def build_inflation_position(group, position, distance, fields): + """Inflate the Balloon front while leaving the rear cap in place.""" links = group.links structure = fields["structure"] @@ -170,15 +170,7 @@ def build_inflation_position(group, position, distance, uniform, fields): profile = group.nodes.new("GeometryNodeInputNamedAttribute") profile.data_type = "FLOAT" profile.inputs["Name"].default_value = "o_balloon" - balloon = _math(group, "MULTIPLY", profile.outputs["Attribute"], response) - uniform_control = group.nodes.new("NodeGroupInput") - uniform_amount = _math(group, "MULTIPLY", uniform_control.outputs["Uniform Thickness"], uniform_control.outputs["Front Inflation"]) - selected = group.nodes.new("GeometryNodeSwitch") - selected.input_type = "FLOAT" - links.new(uniform, selected.inputs["Switch"]) - links.new(balloon, selected.inputs["False"]) - links.new(uniform_amount, selected.inputs["True"]) - displacement = selected.outputs[0] + displacement = _math(group, "MULTIPLY", profile.outputs["Attribute"], response) height = fields["thickness"] layer = _math(group, "DIVIDE", distance, height) layer = _math(group, "ADD", 1, layer) @@ -213,7 +205,7 @@ def build_front_normal(group, normal_weight): return evaluate_field(group, normalized, "FLOAT_VECTOR", "POINT") -def build_shell_position(group, position, front, uniform, fields): +def build_shell_position(group, position, front, is_shell, fields): links = group.links def connect(value, target): @@ -234,13 +226,13 @@ def build_shell_position(group, position, front, uniform, fields): links.new(value, result.inputs[0]) return result.outputs["Z"] - # Uniform and the thin end of Balloon share the actual front normal. + # Shell and the thin end of Balloon share the actual front normal. front_z = z(front) raw_z = z(position) distance = _math(group, "SUBTRACT", raw_z, front_z) normal = fields["front_normal"] thin_offset = vector("SCALE", normal, distance) - uniform_position = vector("ADD", front, thin_offset) + shell_position = vector("ADD", front, thin_offset) control = group_input(group.nodes, {"Thickness"}) clamp = group.nodes.new("ShaderNodeClamp") @@ -267,16 +259,17 @@ def build_shell_position(group, position, front, uniform, fields): balloon_position = vector("ADD", front, mix.outputs["Result"]) choose = group.nodes.new("GeometryNodeSwitch") choose.input_type = "VECTOR" - links.new(uniform, choose.inputs["Switch"]) - links.new(balloon_position, choose.inputs["False"]) - links.new(uniform_position, choose.inputs["True"]) - return build_inflation_position(group, choose.outputs[0], distance, uniform, fields) + links.new(is_shell, choose.inputs["Switch"]) + inflated = build_inflation_position(group, balloon_position, distance, fields) + links.new(inflated, choose.inputs["False"]) + links.new(shell_position, choose.inputs["True"]) + return choose.outputs[0] def _build_depth_surface(group, geometry, controls): """Project the mesh and sample front-surface fields while shaping the shell.""" links = group.links - split = controls.outputs["Split Threshold"] + split = controls.outputs["Depth Split"] image = controls.outputs["Depth Image"] scale = controls.outputs["Uniform Scale"] original = geometry @@ -292,15 +285,15 @@ def _build_depth_surface(group, geometry, controls): projection = smooth_cut_boundary(group, projection) projection = taper_split_profile(group, original, projection) # The mode field is shared by the geometry and thickness choices. - mode = menu_switch(group, "Mode", ("Balloon", "Uniform"), "BOOLEAN") + mode = menu_switch(group, "Mode", ("Balloon", "Shell"), "BOOLEAN") mode.inputs["Balloon"].default_value = False - mode.inputs["Uniform"].default_value = True - uniform = mode.outputs[0] + mode.inputs["Shell"].default_value = True + is_shell = mode.outputs[0] volume = build_volume_fields(group) - normal_weight = build_normal_weight(group, uniform) + normal_weight = build_normal_weight(group, is_shell) normal = build_front_normal(group, normal_weight) front = build_front_fields(group, volume["rim"], normal, normal_weight) - shell_fields = build_shell_fields(group, projection, volume, uniform, normal_weight) + shell_fields = build_shell_fields(group, projection, volume, is_shell, normal_weight) thickness = sample_field(group, projection, shell_fields["thickness"], "FLOAT") shell = thicken_depth_surface( group, projection, thickness, back_mask=NORMAL_SCALE_ATTRIBUTE_NAME, weld_zero_thickness=True, @@ -317,13 +310,13 @@ def _build_depth_surface(group, geometry, controls): index=shell["source_index"]) for name, value in shell_fields.items() }) - oriented = build_shell_position(group, shell["position"], shell["front"], uniform, fields) + oriented = build_shell_position(group, shell["position"], shell["front"], is_shell, fields) links.new(oriented, shell["position_input"]) amount = group.nodes.new("GeometryNodeSwitch") amount.input_type = "FLOAT" - links.new(uniform, amount.inputs["Switch"]) + links.new(is_shell, amount.inputs["Switch"]) links.new(controls.outputs["Thickness"], amount.inputs["False"]) - links.new(controls.outputs["Uniform Thickness"], amount.inputs["True"]) + links.new(controls.outputs["Shell Thickness"], amount.inputs["True"]) return _remove_zero_thickness(group, projection, shell["geometry"], amount.outputs[0]) @@ -335,29 +328,30 @@ def build_image_depth_cutout_group(): group = bpy.data.node_groups.new(name, 'GeometryNodeTree') group.is_modifier, group.use_fake_user = (True, True) group.color_tag = 'GEOMETRY' - group['anyimage_asset_revision'] = 23 + group['anyimage_asset_revision'] = 26 interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry') interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu') - float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet' - float_input(group, 'Uniform Thickness', 0.0, subtype='DISTANCE').description = 'Distance used only by Uniform thickness mode' + float_input(group, 'Thickness', 1.0).description = 'Control how much the surface bulges in Balloon mode. Zero removes the bulge.' + float_input(group, 'Shell Thickness', 0.5, subtype='DISTANCE').description = 'Add thickness behind the original surface while keeping the front surface in place.' float_input(group, 'Depth Scale', 1.0) - float_input(group, 'Split Threshold', 0.1, maximum=1, subtype='FACTOR') + float_input(group, 'Depth Split', 0.1, maximum=1, subtype='FACTOR').description = 'Split the mesh where depth changes abruptly. Higher values detect smaller depth jumps. Zero disables splitting.' iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt') + iterations.description = 'Set the number of mesh smoothing passes. Higher values smooth the surface more but may soften details. Zero disables smoothing.' iterations.default_value = 0 iterations.min_value, iterations.max_value = (0, 20) options = group.interface.new_panel(name='Options', default_closed=True) float_input(group, 'Cleanup Threshold', 0.0, parent=options) - float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options) + float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options).description = 'Control the strength of each mesh smoothing pass. Higher values move vertices more.' float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options) + interface_socket(group, 'Depth Axis', 'INPUT', 'NodeSocketMenu', options).menu_expanded = True boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options) boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 2, 1, 20 - boundary_smooth.description = 'Boundary geometry iterations and depth blur steps per outline smoothing round' + boundary_smooth.description = 'Control boundary smoothing within each Outline Depth Fix pass. Higher values apply more smoothing per pass.' outline_smooth = interface_socket(group, 'Outline Depth Fix', 'INPUT', 'NodeSocketInt', options) outline_smooth.default_value, outline_smooth.min_value, outline_smooth.max_value = 16, 0, 200 - outline_smooth.description = 'Adaptive ray-distance smoothing rounds around the original outline; preserves split boundary distance' - float_input(group, 'Front Inflation', 0.0, maximum=1, subtype='FACTOR', parent=options) + outline_smooth.description = 'Reduce abrupt depth changes near the original Cutout outline. Higher values apply more correction passes. Zero disables correction.' + float_input(group, 'Front Inflation', 0.5, maximum=1, subtype='FACTOR', parent=options).description = 'Control how much the front surface bulges in Balloon mode. Zero keeps the original front surface.' data = group.interface.new_panel(name='Data', default_closed=True) - interface_socket(group, 'Inward Axis', 'INPUT', 'NodeSocketMenu', data).hide_in_modifier = True float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True interface_socket(group, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry') @@ -373,7 +367,7 @@ def build_image_depth_cutout_group(): orient = group.nodes.new('GeometryNodeTransform') orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler() group.links.new(geometry, orient.inputs['Geometry']) - axis = menu_switch(group, 'Inward Axis', ('-Z', '+X'), 'GEOMETRY') + axis = menu_switch(group, 'Depth Axis', ('-Z', '+X'), 'GEOMETRY') group.links.new(geometry, axis.inputs['-Z']) group.links.new(orient.outputs[0], axis.inputs['+X']) _shade_output(group, axis.outputs[0]) @@ -383,8 +377,8 @@ def build_image_depth_cutout_group(): continue if item.name == 'Mode': item.default_value = 'Balloon' - elif item.name == 'Inward Axis': + elif item.name == 'Depth Axis': item.default_value = '-Z' - elif item.name == 'Uniform Thickness': + elif item.name == 'Shell Thickness': item.name = 'Thickness' return group diff --git a/tools/nodes/groups/image_depth_layer.py b/tools/nodes/groups/image_depth_layer.py index 87d9a65..85e71d6 100644 --- a/tools/nodes/groups/image_depth_layer.py +++ b/tools/nodes/groups/image_depth_layer.py @@ -1,7 +1,7 @@ """Build O Image Depth Layer.""" import bpy from anyimage.common.material import DEPTH_MATERIAL_NODE_GROUP_NAME -from ..common.materials import build_material_nodes, connect_material_outputs +from ..common.shader import build_material_nodes, connect_material_outputs MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement" BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth" @@ -27,6 +27,7 @@ def build_image_depth_layer_group(): socket.max_value = 1.0 socket.subtype = 'FACTOR' elif name == 'Bump Scale': + socket.description = 'Control bump detail derived from image brightness. Negative values reverse the bumps. Zero disables the effect.' socket.default_value = 0.0 socket.min_value = -10000.0 socket.max_value = 10000.0 diff --git a/tools/nodes/groups/image_depth_panorama.py b/tools/nodes/groups/image_depth_panorama.py index 5ae1034..2dd21cd 100644 --- a/tools/nodes/groups/image_depth_panorama.py +++ b/tools/nodes/groups/image_depth_panorama.py @@ -10,7 +10,7 @@ from ..common.depth_surface import ( _math, split_depth_surface, build_surface_camera, CUT_ATTRIBUTE, ) from ..common.nodes import float_input, group_input, interface_socket, prepare_node_group, evaluate_field, remove_attributes -from ..common.material import inherit_material_slots +from ..common.material_slots import inherit_material_slots from ..common.smoothing import expand_smooth from ..common.boundary_smoothing import smooth_boundary_depth, smooth_cut_boundary @@ -42,19 +42,24 @@ def build_image_depth_panorama_group(): group["anyimage_asset_revision"] = 15 interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") subdivide = interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt") - subdivide.default_value, subdivide.min_value, subdivide.max_value = 7, 0, 8 - float_input(group, "Depth Scale", 1, maximum=1, subtype="FACTOR") - float_input(group, "Split Threshold", 0, maximum=1, subtype="FACTOR") + subdivide.description = "Increase mesh density to represent finer shapes. Higher levels create more vertices and require more processing time." + subdivide.default_value, subdivide.min_value, subdivide.max_value = 8, 0, 8 + float_input(group, "Depth Scale", 1, maximum=1, subtype="FACTOR").description = "Blend between a sphere at zero and the estimated depth surface at one." + float_input(group, "Depth Split", 0.1, maximum=1, subtype="FACTOR").description = "Split the mesh where depth changes abruptly. Higher values detect smaller depth jumps. Zero disables splitting." smooth = interface_socket(group, "Smooth", "INPUT", "NodeSocketInt") + smooth.description = "Set the number of mesh smoothing passes. Higher values smooth the surface more but may soften details. Zero disables smoothing." smooth.default_value, smooth.min_value, smooth.max_value = 0, 0, 20 - interface_socket(group, "Valid Only", "INPUT", "NodeSocketBool").default_value = True + valid_only = interface_socket(group, "Depth Mask", "INPUT", "NodeSocketBool") + valid_only.default_value = True + valid_only.description = "Remove mesh areas marked invalid by the depth mask." + group.interface.move(valid_only, smooth.position) options = group.interface.new_panel("Options", default_closed=True) boundary_smooth = interface_socket(group, "Boundary Smooth", "INPUT", "NodeSocketInt", options) - boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 5, 0, 20 - boundary_smooth.description = "Smooth boundary distance and cut-edge geometry on the sphere" - float_input(group, "Smooth Weight", 1, maximum=1, subtype="FACTOR", parent=options) - float_input(group, "Validity Threshold", 0.9, maximum=1, subtype="FACTOR", parent=options) - float_input(group, "Invalid Distance", 100, minimum=1e-5, subtype="DISTANCE", parent=options) + boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 2, 0, 20 + boundary_smooth.description = "Smooth depth and geometry near cut edges on the panorama surface. Zero disables boundary smoothing." + float_input(group, "Smooth Weight", 1, maximum=1, subtype="FACTOR", parent=options).description = "Control the strength of each mesh smoothing pass. Higher values move vertices more." + float_input(group, "Mask Threshold", 0.9, maximum=1, subtype="FACTOR", parent=options).description = "Set the minimum depth mask value treated as valid. Higher values exclude more partially valid boundary areas." + float_input(group, "Invalid Distance", 100, minimum=1e-5, subtype="DISTANCE", parent=options).description = "Set the fallback distance from the panorama center for invalid depth areas kept when Depth Mask is disabled." data = group.interface.new_panel("Data", default_closed=True) interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage", data).hide_in_modifier = True interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") @@ -131,9 +136,9 @@ def build_image_depth_panorama_group(): pixel = evaluate_field(group, pixel, "FLOAT_VECTOR", "POINT") nearest = nodes.new("FunctionNodeSeparateColor") links.new(texture(coordinates, "Closest"), nearest.inputs["Color"]) - controls = group_input(nodes, {"Validity Threshold"}) + controls = group_input(nodes, {"Mask Threshold"}) invalid = _math(group, "MAXIMUM", - _math(group, "LESS_THAN", nearest.outputs["Alpha"], controls.outputs["Validity Threshold"]), + _math(group, "LESS_THAN", nearest.outputs["Alpha"], controls.outputs["Mask Threshold"]), _math(group, "LESS_THAN", nearest.outputs["Red"], 1e-5)) face_invalid = evaluate_field(group, invalid, "FLOAT", "POINT") any_invalid = _math(group, "GREATER_THAN", face_invalid, 0) @@ -147,11 +152,11 @@ def build_image_depth_panorama_group(): links.new(mixed, delete.inputs["Selection"]) selection = nodes.new("GeometryNodeSwitch") selection.input_type = "GEOMETRY" - links.new(group_input(nodes, {"Valid Only"}).outputs["Valid Only"], selection.inputs["Switch"]) + links.new(group_input(nodes, {"Depth Mask"}).outputs["Depth Mask"], selection.inputs["Switch"]) links.new(delete.outputs[0], selection.inputs["False"]) links.new(culled, selection.inputs["True"]) - controls = group_input(nodes, {"Split Threshold"}) - strength = controls.outputs["Split Threshold"] + controls = group_input(nodes, {"Depth Split"}) + strength = controls.outputs["Depth Split"] face_camera = depth(combine(face_u, face_v))[0] radial = nodes.new("ShaderNodeSeparateXYZ") links.new(face_camera, radial.inputs[0]) diff --git a/tools/nodes/groups/image_depth_plane.py b/tools/nodes/groups/image_depth_plane.py index bc259e5..386e8e8 100644 --- a/tools/nodes/groups/image_depth_plane.py +++ b/tools/nodes/groups/image_depth_plane.py @@ -29,6 +29,7 @@ def build_image_depth_plane_group(image_plane_group): geometry = _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") geometry.structure_type = "SINGLE" subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt") + subdivide.description = "Increase mesh density to represent finer shapes. Higher levels create more vertices and require more processing time." subdivide.default_value = 6 subdivide.min_value = 0 subdivide.max_value = 10 @@ -36,23 +37,27 @@ def build_image_depth_plane_group(image_plane_group): thickness.default_value = 0.0 thickness.min_value = 0.0 thickness.subtype = "DISTANCE" - thickness.description = "Inward shell distance" + thickness.description = "Add shell thickness inward from the depth surface. Zero keeps a single surface." depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat") depth_scale.default_value = 1.0 depth_scale.min_value = 0.0 - float_input(group, "Split Threshold", 0.1, maximum=1, subtype="FACTOR") + float_input(group, "Depth Split", 0.1, maximum=1, subtype="FACTOR").description = "Split the mesh where depth changes abruptly. Higher values detect smaller depth jumps. Zero disables splitting." iterations = _interface_socket(group, "Smooth", "INPUT", "NodeSocketInt") + iterations.description = "Set the number of mesh smoothing passes. Higher values smooth the surface more but may soften details. Zero disables smoothing." iterations.default_value = 0 iterations.min_value, iterations.max_value = 0, 20 - valid_only = _interface_socket(group, "Valid Only", "INPUT", "NodeSocketBool") + valid_only = _interface_socket(group, "Depth Mask", "INPUT", "NodeSocketBool") + valid_only.description = "Remove mesh areas marked invalid by the depth mask." valid_only.default_value = True + group.interface.move(valid_only, iterations.position) options = group.interface.new_panel(name="Options", default_closed=True) - float_input(group, "Smooth Weight", 1.0, maximum=1, subtype="FACTOR", parent=options) + float_input(group, "Smooth Weight", 1.0, maximum=1, subtype="FACTOR", parent=options).description = "Control the strength of each mesh smoothing pass. Higher values move vertices more." float_input(group, "Reference Depth", 1.0, subtype="DISTANCE", parent=options) boundary_smooth = _interface_socket(group, "Boundary Smooth", "INPUT", "NodeSocketInt", parent=options) boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 2, 0, 20 - boundary_smooth.description = "Smooth boundary depth and cut-edge geometry while preserving the original image border" - validity_threshold = _interface_socket(group, "Validity Threshold", "INPUT", "NodeSocketFloat", parent=options) + boundary_smooth.description = "Smooth depth and geometry near cut edges while preserving the original image border. Zero disables boundary smoothing." + validity_threshold = _interface_socket(group, "Mask Threshold", "INPUT", "NodeSocketFloat", parent=options) + validity_threshold.description = "Set the minimum depth mask value treated as valid. Higher values exclude more partially valid boundary areas." validity_threshold.default_value = 0.9 validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0 validity_threshold.subtype = "FACTOR" @@ -93,13 +98,13 @@ def build_image_depth_plane_group(image_plane_group): texture.interpolation, texture.extension = "Linear", "EXTEND" links.new(uv.outputs["Attribute"], texture.inputs["Vector"]) links.new(texture_input.outputs["Depth Image"], texture.inputs["Image"]) - threshold_input = _group_input(nodes, {"Validity Threshold"}) + threshold_input = _group_input(nodes, {"Mask Threshold"}) channels = nodes.new("FunctionNodeSeparateColor") channels.mode = "RGB" links.new(texture.outputs["Color"], channels.inputs["Color"]) invalid = nodes.new("FunctionNodeCompare") invalid.data_type, invalid.operation = "FLOAT", "LESS_THAN" - links.new(threshold_input.outputs["Validity Threshold"], invalid.inputs["B"]) + links.new(threshold_input.outputs["Mask Threshold"], invalid.inputs["B"]) links.new(channels.outputs["Alpha"], invalid.inputs["A"]) # Image Plane's normalized UV rectangle retains its original outline after cuts. uv_axes = nodes.new("ShaderNodeSeparateXYZ") @@ -110,28 +115,28 @@ def build_image_depth_plane_group(image_plane_group): original_boundary = evaluate_field(group, _math(group, "LESS_THAN", margin, 1e-6), "BOOLEAN", "POINT") protected = plane.outputs["Geometry"] culled = delete_invalid_points(group, protected, invalid.outputs["Result"]) - cull_controls = _group_input(nodes, {"Valid Only"}) + cull_controls = _group_input(nodes, {"Depth Mask"}) cull = nodes.new("GeometryNodeSwitch") cull.input_type = "GEOMETRY" - links.new(cull_controls.outputs["Valid Only"], cull.inputs["Switch"]) + links.new(cull_controls.outputs["Depth Mask"], cull.inputs["Switch"]) links.new(culled, cull.inputs["True"]) links.new(protected, cull.inputs["False"]) - split_input = _group_input(nodes, {"Split Threshold"}) + split_input = _group_input(nodes, {"Depth Split"}) reference_input = _group_input(nodes, {"Reference Depth"}) geometry, corner_camera, cut_vertex = split_depth_surface( group, cull.outputs["Output"], sample_face_camera(group, texture_input.outputs["Depth Image"]), - split_input.outputs["Split Threshold"], reference_input.outputs["Reference Depth"], + split_input.outputs["Depth Split"], reference_input.outputs["Reference Depth"], ) camera = build_surface_camera( group, texture.outputs["Color"], corner_camera, cut_vertex, - split_input.outputs["Split Threshold"], + split_input.outputs["Depth Split"], ) depth_input = _group_input(nodes, {"Reference Depth", "Depth Scale"}) camera = smooth_boundary_camera(group, camera, original_boundary) surface = project_depth_surface( group, geometry, camera, scale_input.outputs["Uniform Scale"], depth_input.outputs["Reference Depth"], - depth_input.outputs["Depth Scale"], split_input.outputs["Split Threshold"], + depth_input.outputs["Depth Scale"], split_input.outputs["Depth Split"], ) surface = smooth_cut_boundary(group, surface, original_boundary) shell_input = _group_input(nodes, {"Thickness"}) diff --git a/tools/nodes/groups/image_layer.py b/tools/nodes/groups/image_layer.py index 7204d8a..54779cf 100644 --- a/tools/nodes/groups/image_layer.py +++ b/tools/nodes/groups/image_layer.py @@ -1,7 +1,7 @@ """Build O Image Layer.""" import bpy from anyimage.common.material import IMAGE_MATERIAL_NODE_GROUP_NAME -from ..common.materials import build_material_nodes, connect_material_outputs +from ..common.shader import build_material_nodes, connect_material_outputs def build_image_layer_group(): @@ -26,17 +26,22 @@ def build_image_layer_group(): socket.max_value = 1.0 socket.subtype = 'FACTOR' elif name == 'Bump Scale': + socket.description = 'Control bump detail derived from image brightness. Negative values reverse the bumps. Zero disables the effect.' socket.default_value = 0.0 socket.min_value = -10000.0 socket.max_value = 10000.0 elif name == 'Normal': socket.default_value = (0.5, 0.5, 1.0, 1.0) elif name == 'Normal Scale': + socket.description = 'Control the strength of surface detail from the normal map.' socket.default_value = 1.0 socket.min_value = 0.0 socket.max_value = 10.0 elif name == 'Object Space': + socket.description = 'Interpret the normal map in object space when enabled, or tangent space when disabled.' socket.default_value = False + if name == 'Alpha Fix': + socket.description = 'Reduce semi-transparent fringes around image edges. Higher values remove more faint edge pixels. Zero keeps the original transparency.' output_sockets = [('Color', 'NodeSocketColor'), ('Roughness', 'NodeSocketFloat'), ('Alpha', 'NodeSocketFloat'), ('Normal', 'NodeSocketVector')] for name, socket_type in output_sockets: interface.new_socket(name=name, in_out='OUTPUT', socket_type=socket_type) diff --git a/tools/nodes/groups/image_plane.py b/tools/nodes/groups/image_plane.py index 3b6d964..0b20f54 100644 --- a/tools/nodes/groups/image_plane.py +++ b/tools/nodes/groups/image_plane.py @@ -3,7 +3,7 @@ import bpy from ..common.nodes import prepare_node_group -from ..common.material import inherit_material_slots +from ..common.material_slots import inherit_material_slots IMAGE_PLANE_GROUP_NAME = "O Image Plane" @@ -61,6 +61,7 @@ def build_image_plane_group(): geometry = new_socket(interface, "Geometry", "INPUT", "NodeSocketGeometry") geometry.structure_type = "SINGLE" subdivide = new_socket(interface, "Subdivide", "INPUT", "NodeSocketInt") + subdivide.description = "Increase mesh density to represent finer shapes. Higher levels create more vertices and require more processing time." subdivide.default_value = 0 subdivide.min_value = 0 subdivide.max_value = 10 diff --git a/tools/nodes/groups/image_relief_plane.py b/tools/nodes/groups/image_relief_plane.py index 01350fa..6a91249 100644 --- a/tools/nodes/groups/image_relief_plane.py +++ b/tools/nodes/groups/image_relief_plane.py @@ -29,6 +29,7 @@ def build_image_relief_plane_group(image_plane_group): geometry = _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") geometry.structure_type = "SINGLE" subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt") + subdivide.description = "Increase mesh density to represent finer shapes. Higher levels create more vertices and require more processing time." subdivide.default_value = 6 subdivide.min_value = 0 subdivide.max_value = 10 @@ -36,11 +37,12 @@ def build_image_relief_plane_group(image_plane_group): thickness.default_value = 0.5 thickness.min_value = 0.0 thickness.subtype = "DISTANCE" - thickness.description = "Fixed relief base thickness" + thickness.description = "Set the thickness of the flat base beneath the relief." depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat") depth_scale.default_value = 0.5 depth_scale.min_value = 0.0 smooth = _interface_socket(group, "Smooth", "INPUT", "NodeSocketInt") + smooth.description = "Set the number of mesh smoothing passes. Higher values smooth the surface more but may soften details. Zero disables smoothing." smooth.default_value, smooth.min_value, smooth.max_value = 0, 0, 20 options = group.interface.new_panel(name="Options", default_closed=True) base_plane_depth = _interface_socket( @@ -53,7 +55,7 @@ def build_image_relief_plane_group(image_plane_group): base_plane_depth.default_value = 1.0 base_plane_depth.min_value = 0.0 base_plane_depth.subtype = "DISTANCE" - float_input(group, "Smooth Weight", 1.0, maximum=1, subtype="FACTOR", parent=options) + float_input(group, "Smooth Weight", 1.0, maximum=1, subtype="FACTOR", parent=options).description = "Control the strength of each mesh smoothing pass. Higher values move vertices more." vector_input(group, "Depth Direction", (0, 0, 1), subtype="DIRECTION", parent=options) data = group.interface.new_panel(name="Data", default_closed=True) uniform_scale = _interface_socket( diff --git a/uv.lock b/uv.lock index bec871e..c172d20 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "anyimage" -version = "0.13.2" +version = "0.13.3" source = { editable = "." } dependencies = [ { name = "blendjob" }, @@ -23,7 +23,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "av" }, { name = "bpy" }, { name = "huggingface-hub" }, { name = "mike" }, @@ -48,7 +47,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "av", specifier = "==17.0.0" }, { name = "bpy", specifier = "==4.5.3" }, { name = "huggingface-hub", specifier = "==1.4.1" }, { name = "mike", specifier = ">=2.1.3,<3" }, @@ -77,22 +75,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] -[[package]] -name = "av" -version = "17.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/eb/abca886df3a091bc406feb5ff71b4c4f426beaae6b71b9697264ce8c7211/av-17.0.0.tar.gz", hash = "sha256:c53685df73775a8763c375c7b2d62a6cb149d992a26a4b098204da42ade8c3df", size = 4410769, upload-time = "2026-03-14T14:38:45.868Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/fb/55e3b5b5d1fc61466292f26fbcbabafa2642f378dc48875f8f554591e1a4/av-17.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ed4013fac77c309a4a68141dcf6148f1821bb1073a36d4289379762a6372f711", size = 23238424, upload-time = "2026-03-14T14:38:05.856Z" }, - { url = "https://files.pythonhosted.org/packages/52/03/9ace1acc08bc9ae38c14bf3a4b1360e995e4d999d1d33c2cbd7c9e77582a/av-17.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:e44b6c83e9f3be9f79ee87d0b77a27cea9a9cd67bd630362c86b7e56a748dfbb", size = 18709043, upload-time = "2026-03-14T14:38:08.288Z" }, - { url = "https://files.pythonhosted.org/packages/00/c0/637721f3cd5bb8bd16105a1a08efd781fc12f449931bdb3a4d0cfd63fa55/av-17.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b440da6ac47da0629d509316f24bcd858f33158dbdd0f1b7293d71e99beb26de", size = 34018780, upload-time = "2026-03-14T14:38:10.45Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/d19bc3257dd985d55337d7f0414c019414b97e16cd3690ebf9941a847543/av-17.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1060cba85f97f4a337311169d92c0b5e143452cfa5ca0e65fa499d7955e8592e", size = 36358757, upload-time = "2026-03-14T14:38:13.092Z" }, - { url = "https://files.pythonhosted.org/packages/52/6c/a1f4f2677bae6f2ade7a8a18e90ebdcf70690c9b1c4e40e118aa30fa313f/av-17.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:deda202e6021cfc7ba3e816897760ec5431309d59a4da1f75df3c0e9413d71e7", size = 35195281, upload-time = "2026-03-14T14:38:15.789Z" }, - { url = "https://files.pythonhosted.org/packages/90/ea/52b0fc6f69432c7bf3f5fbe6f707113650aa40a1a05b9096ffc2bba4f77d/av-17.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffaf266a1a9c2148072de0a4b5ae98061465178d2cfaa69ee089761149342974", size = 37444817, upload-time = "2026-03-14T14:38:18.563Z" }, - { url = "https://files.pythonhosted.org/packages/34/ad/d2172966282cb8f146c13b6be7416efefde74186460c5e1708ddfc13dba6/av-17.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:45a35a40b2875bf2f98de7c952d74d960f92f319734e6d28e03b4c62a49e6f49", size = 28888553, upload-time = "2026-03-14T14:38:21.223Z" }, - { url = "https://files.pythonhosted.org/packages/b0/bb/c5a4c4172c514d631fb506e6366b503576b8c7f29809cf42aca73e28ff01/av-17.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:3d32e9b5c5bbcb872a0b6917b352a1db8a42142237826c9b49a36d5dbd9e9c26", size = 21916910, upload-time = "2026-03-14T14:38:23.706Z" }, -] - [[package]] name = "babel" version = "2.18.0"