diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/AGENTS.md b/AGENTS.md index ed9df49..480acf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,34 +3,28 @@ ## 总则 - 说明性内容(提交说明、文档、注释、Mermaid 等)以中文为主,专有名词和约定俗成的表达可使用英文 -- 动手前先读 `docs/index.md`;程序边界、调用流程和模块职责见 `docs/development.md#程序设计` +- 动手前先读 `docs/index.md`;程序边界、调用流程和模块职责见 `docs/internals/index.md` 与 `docs/internals/architecture.md` - 修改代码、依赖或执行方式后,同步更新 `README.md` 与对应主题文档,保持文档、测试和实现一致 - 优先选择依赖少、直接、易验证的实现;保留工作区中与当前任务无关的已有改动 -## 项目结构 +## 内部实现文档 -```text -. -├── README.md # 用户入口与快速上手 -├── docs/ # 按功能拆分的设计与开发文档 -├── pack.py # 构建各平台 Blender Extension ZIP -├── sync_models.py # 准备并同步 ONNX 模型 -├── src/anyimage/ -│ ├── __init__.py # Blender 注册入口 -│ ├── operators.py # Job Operator 与结果导入 -│ ├── panel.py # 3D View 面板 -│ ├── preferences.py # Extension Preferences -│ ├── properties.py # Scene 设置与动态枚举 -│ ├── job_runtime.py # BlendJob Runtime 配置 -│ ├── blender/ # Depth Plane、Surface 的 Blender 侧实现 -│ ├── server/ # Job、模型、ONNX 推理与输出实现 -│ ├── assets/O_AnyImage.blend # Geometry Nodes 与材质资产 -│ └── wheels/ # 随扩展分发的 BlendJob wheel -├── tools/ # Blender 资产构建与验证脚本 -└── tests/ # Python 自动化测试 -``` +- [内部实现总览](docs/internals/index.md):内部实现文档的统一入口 +- [程序架构](docs/internals/architecture.md):进程边界、职责与调用主链 +- [Operator 实现索引](docs/internals/operators/index.md):源码功能与实现页的对应关系 -文档按主题定位:功能与使用 `docs/features.md`、Depth Plane `docs/depth-plane.md`、Surface `docs/surface.md`、输出格式 `docs/outputs.md`、Job Server `docs/server.md`、程序设计与开发 `docs/development.md`。 +修改完整工作流时同步更新对应实现页。`operators/common/` 不单独建立页面,共享边界写入程序架构。 + +## 模块与目录边界 + +- 目录结构应能直接表达业务模块和职责;优先按用户可理解的功能或稳定能力组织,不按模糊的数据类型、临时步骤或界面摆放机械分组 +- 一个模块只承担一个可描述的职责。功能出现多个相对独立的交互、算法或构建阶段时再建立子目录,并让同一抽象层的实现并列 +- 公共层来自已经存在的复用,不来自对未来的猜测。没有真实共享消费者的代码留在所属模块;抽取后只暴露消费者需要的最小接口 +- 模块边界优先服从运行进程、依赖方向、数据所有权和生命周期。只在一侧处理的数据不为形式上的“共享”跨越边界;确因加载机制产生的例外应在架构文档中说明 +- 编排层只组合步骤和定义公开协议,原子能力放在所属领域模块。编排模块之间不互相导入实现,共享逻辑应下沉 +- 公开任务与界面入口不要求一一对应;边界由可复用计算、输入输出协议和失败语义决定。名称必须描述实际动作与产物,不暗示未承担的职责 +- 没有生产消费者的入口、兼容层和辅助协议应删除,不为测试便利单独保留 +- 移动或拆分模块时同步更新注册、导入、打包、测试和文档,不留下旧路径转发层 ## 常见命令 @@ -53,27 +47,51 @@ uv run python pack.py uv lock --refresh ``` -资产脚本必须由 Blender 执行;具体入口与验证范围见 `docs/development.md#资产构建与验证`,不要用普通 Python 解释器运行。 +资产脚本必须由 Blender 执行;具体入口与验证范围见 [节点资产](docs/internals/node-assets.md),不要用普通 Python 解释器运行。 + +`tools/` 中的脚本按维护对象分组,并统一使用能够区分“构建”和“验证”等动作的命名。每个脚本都应有明确入口、文档或测试消费者;没有实际用途的脚本应删除。 ## 命名规范 - 变量、函数、Docstring 使用英文;同一概念在代码和文档中使用统一表达 +- 名称应说明业务职责,并与用户概念、公开协议和界面术语保持一致;不要使用脱离上下文后无法判断用途的宽泛名称 +- 文件和目录名应反映职责层级:集合层使用复数,单个功能或动作使用单数或动宾结构;同一层级保持一致 - Python 类使用大驼峰,不添加项目名前缀。Operator 使用动宾结构,如 `ConvertToDepthPlane`、`RemoveImageBackground`;Menu、Panel、AddonPreferences、PropertyGroup 分别使用实际类型后缀,如 `AnyImageImageMenu`、`ServerPanel`、`AnyImagePreferences`、`AnyImageSettings` - `bl_idname` 与挂载到公共 Blender RNA 类型的 Property 使用 `anyimage` 项目前缀:Operator 如 `anyimage.convert_to_depth_plane`,Menu 如 `ANYIMAGE_MT_image_context`,Panel 如 `ANYIMAGE_PT_server`,公共 Property 如 `Scene.anyimage_settings` - Operator 自身 Property 不加项目前缀,使用业务名称,如 `input_path`、`process_res`、`resolution_level` - 扩展包名使用 Manifest 中的真实名称、版本和平台,例如 `AnyImage.v0.11.2.windows-x64.zip`;节点组名称沿用资产中的 `O DA3 Depth Plane`、`O MoGe2 Surface` 等既有名称 +- 常量应表达领域约束、共享数值或稳定映射,不为字面量增加没有额外语义的同名别名;需要类型约束时使用集中映射或 Enum +- 同一发布单元的版本只保留一个事实来源。只有存在独立兼容周期和实际消费者时,才引入额外版本或迁移状态 ## 代码规范 - 避免不必要的嵌套,优先使用提前返回和小而明确的函数 - 只为特殊、非常规或存在兼容性原因的实现写注释,不复述代码本身 - 删除功能时同时删除无效参数、分支、测试和文档,不保留没有实际消费者的兼容层 -- Blender 侧不载入 AI 依赖:UI、Operator 和结果导入放在 `src/anyimage/` 或 `src/anyimage/blender/`,模型下载、ONNX 推理和输出放在 `src/anyimage/server/` +- Blender 侧不载入 AI 依赖:UI、Operator 和结果导入放在 `src/anyimage/` 或 `src/anyimage/operators/`,模型下载、ONNX 推理和输出放在 `src/anyimage/server/` - 扩展级设置及其读取入口统一放在 `preferences.py`;使用 `addon_preferences()` 读取,不把 Blender Add-on Preferences 的定位、默认值或路径解析放进专门管理 BlendJob 的 `runtime.py` - Job 参数使用可序列化的普通字典;Blender 数据只能在主线程的 Operator 响应阶段读写 - 新增或修改 Blender 类型时同步检查 `src/anyimage/__init__.py` 的注册与逆序注销,并补充对应测试 - 依赖版本、wheel 或 Manifest 改变时同步检查 `pyproject.toml`、`uv.lock`、`blender_manifest.toml` 和打包测试 +## 执行流程与复用 + +- 按数据依赖安排步骤:先完成会改变后续输入或分析范围的处理,再执行依赖该结果的计算 +- 对相同输入和参数的昂贵确定性计算只执行一次,从同一中间结果派生多个产物 +- 实现路径由当前功能的输入范围和质量要求决定,不为无实际用途的全局兼容选项增加分支 +- 名称、参数和进度文案应反映当前阶段的真实职责,并明确跨进程流程中每一侧负责的最终操作 +- 临时资源的创建与清理由同一所有者管理;共享输入解析和生命周期逻辑,不在各消费者中重复实现 + +## 清理、测试与重构验证 + +- 删除前先用 `rg` 查找生产调用方、注册入口、测试、文档和打包清单。只有注册或测试、没有真实功能消费者的入口视为孤立代码,应连同协议一起删除 +- 测试应保护当前功能、公开协议、关键算法和已发生过的回归;不要为了保留旧文件结构、无消费者兼容层或实现细节而写测试 +- 删除功能时删除对应测试;移动原子能力时把测试迁到新的职责模块。测试名称也要使用当前业务术语,不能继续出现已废弃的模块名或 Job ID +- 打包测试应验证最终产物包含必要文件且不包含已删除结构;文件移动后同步更新 +- 结构重构完成后至少执行旧名称与旧路径搜索、差异检查、完整测试和文档构建;涉及 Blender 注册、运行时行为或节点资产时,再使用目标 Blender 验证 +- 不混用开发环境、独立运行环境和 Blender 自带 Python;每类验证使用项目声明的对应解释器 +- 重构应减少模块、分支、协议和重复计算的数量。新抽象只有在职责稳定或存在真实复用时才成立 + ## Blender 节点图规范 - 不修改节点的 `name`,也不使用 `label` 覆盖节点标题;保留 Blender 为节点类型生成的默认名称和外观,避免读图时无法判断节点原型。用途应通过节点类型、参数、空间布局和必要的少量注释表达 diff --git a/README.md b/README.md index e2df08d..ac29025 100644 --- a/README.md +++ b/README.md @@ -1,164 +1,71 @@ # AnyImage -AnyImage 是一个 Blender 扩展,可以把一张照片、一段视频或一组序列帧快速变成有真实深度的立体面片。 +AnyImage 是一款 Blender Extension,用于把图片带入可编辑的 2.5D 工作流:粘贴和清理图片、创建普通 Plane 或 Depth Plane、从套索生成 Cutout Shape,并通过独立 AI Environment 去除背景或进行 4× 高清放大。 -完整使用说明请从[文档首页](docs/index.md)开始;首次使用请依次阅读 -[安装与首次配置](docs/installation.md)和[五分钟上手](docs/quick-start.md)。 +完整说明从[文档首页](docs/index.md)开始;首次使用请阅读[安装与首次配置](docs/getting-started/installation.md)和[五分钟上手](docs/getting-started/quick-start.md)。 +## 主要功能 -## 它能做什么 +- 在 3D View、Node Editor 与绘画模式中用 `Ctrl+V`(macOS 为 `Cmd+V`)粘贴并 Pack 剪贴板图片 +- 将 Image Empty 转换为带 `O Mesh Plane` Geometry Nodes 的普通 Plane +- 使用 MoGe-2 或 DA3 将静态图转换为可调 Depth Plane +- 用 Cutout 套索在图片可见 Alpha 内生成 Surface、Balloon、Depth Balloon 或 Depth Surface,并通过统一的 `O Image Cutout` 修改器切换 Shape、按创建分辨率清理碎岛和调整表面 +- 用 Image Box、Lasso、Line、Polyline 和 Perspective 清理或矫正静态图片 +- 用 BEN2 为单图、视频或序列帧去除背景 +- 用三档 ONNX 模型将单图、视频或序列帧放大 4× -- 在 3D View、Node Editor 与绘画模式中按 `Ctrl+V`(macOS 为 `Cmd+V`)直接粘贴并 Pack 剪贴板图片 -- 不运行 AI,把 Image Empty 转换为可实时调整 Thickness 与 Subdivide 的 Mesh Plane -- 用 Cutout 套索从 Image Empty 生成 Surface、Balloon、Depth Balloon 或 Depth Surface;四种结果共用单面基础薄片、局部方向与清理逻辑 -- 用 Image Box / Lasso / Line / Polyline 直接清理 Image Empty;按 `F` 反选,也可用 BEN2 细化选区 -- Image 编辑会直接更新未共享的图片;图片被多个 Image Empty 共用时,只为当前 Empty 分离编辑结果 -- 用 Image Perspective 勾勒四个角点,把透视四边形矫正为矩形裁切图,并沿用源 Image Empty 的空间朝向 -- 一键把 Image Empty 转换为立体面片:让 AI 估算深度,或直接用原图作为深度 -- 把单张 Image Empty 转换为透视纹理网格,并自动匹配原对象的 Transform 与 Offset -- 一键去除图片背景,成成透明前景图 -- 用三档 ONNX 模型将图片、视频或序列帧逐帧放大 4 倍 -- 支持视频与序列帧输入,逐帧处理,动画也能得到逐帧对应的立体面片 -- 主要转换操作集中在右键菜单,剪贴板图片可直接用系统粘贴快捷键导入 +AI 推理运行在 Blender 之外的独立 Python 3.12 Environment 中。Blender 进程只负责 UI、任务提交和结果导入,不加载 ONNX Runtime 或模型权重。 + +全局 **Maximum AI Input Size** 默认限制 Upscale、Depth 与 Normal 分析的长边为 2048 px;超过时等比缩放分析输入,但不会降低颜色贴图分辨率。Cutout 在套索裁切后应用该限制,并以同一分析尺度计算网格密度。 ## 安装 -1. 下载对应系统的扩展压缩包 -2. 打开 Blender,进入 `Preferences > Extensions` -3. 点击 `Install from Disk`,选择下载的压缩包 -4. 在扩展偏好设置中确认 Storage Root 与 Device(保持默认即可);Device 默认取本机 - 最快的 DirectML provider;Apple M4 或更新芯片可选 MPS/CoreML,其他 Mac 只提供 CPU -5. 选中 Image Empty,在右键 `AnyImage` 子菜单中点击 `Set Up AI Environment…`;也可以从扩展偏好设置进入 +1. 下载与系统匹配的扩展包: + - `AnyImage.v.windows-x64.zip` + - `AnyImage.v.macos-arm64.zip` + - `AnyImage.v.linux-x64.zip` +2. 在 Blender 打开 **Edit > Preferences > Extensions**,选择 **Install from Disk** 并直接安装 ZIP。 +3. 选中 Image Empty,在右键 **AnyImage** 菜单中点击 **Set Up AI Environment…**。 +4. 安装完成后,在扩展 Preferences 中确认默认 MoGe-2、BEN2 与 Real-ESRGAN WDN 模型显示 Ready。 -扩展不内置 uv 或 AI 运行库。`Set Up AI Environment…` 会先说明下载内容、约 600 MB 的下载量、约 1 GB 的磁盘占用和预计等待时间,并可勾选 `Use Mirror`。确认后会下载并校验固定版本的 uv,自动安装轻量的 ONNX Runtime 环境,再下载 `MoGe-2 ViT-S Normal`、`BEN2 Base` 和默认高清模型 `Real-ESRGAN General WDN x4v3`;不安装 PyTorch,也不下载各模型的 Python 源码包。`Use Mirror` 只切换 uv、Python 和 Python 包的下载源,模型仍使用自己的下载源与备用镜像规则。 +普通 Plane、基础 Image 编辑,以及不带 Refine / Normal 的 Cutout Surface 与 Balloon 无需 AI Environment。Cutout 的 Depth 与 Normal 统一使用 MoGe-2;两者同时启用时共享同一次 Prediction,不会重复运行模型。 -Environment Bootstrap 使用连续的 0–100%:uv 为 1–20%、Python 为 20–50%、packages 为 50–98%,最终验证为 98–100%。三个阶段显示真实的 `1/3`、`2/3`、`3/3`,下载占各阶段的 84–87%;开始与最终验证不显示编号,单阶段状态不显示 `1/1`。MoGe-2 与 BEN2 属于 post-install,各自使用独立进度,不计入 Environment 的这轮 0–100%。任一必需模型在两个来源都下载失败时,安装任务会明确报错;Environment 本身仍会保留。 +## 快速使用 -选中 Image Empty 后,`AnyImage` 会显示在右键菜单最上方。Mesh / Depth / Surface 转换为一组,Remove Background 与 Upscale 作为图片处理归为下一组,环境入口位于最后。Environment 或必需模型未就绪时,点击 `Convert to Depth Plane…`、`Convert to Surface…`、`Remove Background` 或 `Upscale` 会打开对应准备对话框:Environment 未安装时入口显示 `Set Up AI Environment…`,安装 Environment 与默认模型;Environment 已安装但默认模型缺失时入口显示 `Download Required Models…`,只补齐缺失模型。全部就绪后,菜单入口改为 `AI Environment Settings…`,并打开 AnyImage 扩展偏好设置。 +选中 Image Empty 后,右键 **AnyImage** 菜单提供: -扩展偏好设置先显示 Environment 状态与 Diagnostics,再依次提供默认展开、可折叠的 Depth & Normal、Remove Background、Upscale 和 Cutout 功能区。Depth Model 按三档 MoGe-2、两档 DA3 排列;选择 DA3 时会提示该模型不能生成 normal。可选模型的状态与下载按钮位于对应模型选择项下方,BEN2 则归在 Remove Background。共享的 AI 状态检测直接检查 Environment 和 Storage Root 的 `models/` 文件,不会仅因打开菜单或设置而启动 Job Server。 +- **Convert to Plane**:同步创建普通图片网格; +- **Convert to Depth Plane**:使用 Preferences 中选择的深度模型处理静态图; +- **Remove Background**:生成透明图片或序列; +- **Upscale**:使用默认高清模型生成 4× 结果。 -## 快速上手 +3D Viewport 工具栏提供 Image Mask、Perspective 与 Cutout。工作流对比和当前输入范围见[选择工作流](docs/guides/workflows.md)。 -### 粘贴剪贴板图片 +## 源码结构 -复制图片后,在 3D View、Shader Editor、Compositor 或 Geometry Nodes 中按 `Ctrl+V`;macOS 使用 `Cmd+V`。AnyImage 会根据当前上下文创建 Reference Image、带材质的 Plane、图片节点或笔刷 Texture,并将图片 Pack 到当前 `.blend`。完整说明见[粘贴剪贴板图片](docs/clipboard-image.md)。 +```text +src/anyimage/ +├── operators/ # Blender 交互、任务提交与结果导入 +├── server/ # 无 Blender 依赖的 Job、媒体、选区、几何与模型能力 +├── assets/ # Geometry Nodes 与 Shader 资产 +├── runtime.py # BlendJob Runtime 配置 +└── __init__.py # Blender 注册入口 +``` -### 转换为普通图片平面 +程序边界、功能文件结构和调用流程见[内部实现总览](docs/internals/index.md)。文档按开始使用、功能指南、参考和内部实现分层,不在 README 重复内部算法细节。 -选中 Image Empty 后,右键选择 `AnyImage > Convert to Mesh Plane`。该操作不运行 AI,会创建带图片材质和 `O Mesh Plane` Geometry Nodes 修改器的基础四边形。`Thickness` 与 `Subdivide` 都在修改器中实时生成;`Subdivide` 使用二分级别(`3` 为短边 `8` 段)。`Thickness = 0` 时结果为真正的单面。操作会立即执行;创建后可在 Last Operation 中调整 `Shadeless` 和 `Keep Original`,调整时保持源图片的世界位置、旋转与缩放。完整说明见 [Mesh Plane](docs/mesh-plane.md)。 +## 开发 -### 转换为立体面片 +仓库开发环境使用 CPython 3.11: -1. 在 3D 视图中选中一个 Image Empty(空物体,显示方式为 Image) -2. 右键打开 `AnyImage` 子菜单,选择 `Convert to Depth Plane` -3. 在对话框中选择: - - `Depth from`:`Original` 直接用原图,`DA3` 使用现有 DA3 流程,`MoGe-2` 成成 metric depth、normal 与有效区域 mask - - `Resolution Scale` 使用 `1–100%`,按原图长边计算 Process Res;结果会限制在 `252–1008` 并自动对齐到 14 的倍数 - - `Remove Background` 默认关闭;开启后在转换期间使用 BEN2 删除背景 - - `Shadeless` 使用 `O Shadeless` 节点组显示不受场景灯光影响的贴图色色 - - `Keep Original` 默认关闭,位于分割线下方;开启后保留源 Image Empty -4. 点击确认,稍等片刻就得到带位移的立体面片。`O DA3 Depth Plane` 与 `O MoGe2 Depth Plane` 都使用色色图片的 `Alpha > 0.5` 划分前景范围,范围外由 `Background Depth` 控制。 +```bash +uv sync +uv run pytest +uv run python pack.py +``` -### 编辑图片选区 +节点资产脚本必须由 Blender 执行,具体命令和验证范围见[节点资产](docs/internals/node-assets.md)。完整开发、打包与 CI 说明见[开发与发布](docs/internals/development.md)。 -在 3D Viewport 左侧工具栏选择 Image Mask 工具组。Box 拖出矩形,Lasso 手绘轮廓,Line 清理有向直线的一侧,Polyline 逐点构建多边形。点击另一张 Image Empty 时只切换选择;点击当前 Active 图片、空白处或非图片物体时,才开始编辑当前图片。没有 Active 图片时会提示先选择 Image Empty,切换其他工具会取消当前绘制。 +## 许可证 -Lasso 绘制时与 Box、Line、Polyline 一样显示白色虚线轮廓和淡灰半透明填充;凹形选区不显示内部投射线,自交选区则按最终操作的填充规则显示交叠区域。Cutout 预览与最终几何都使用非零环绕规则:同方向重叠保留,反方向抵消成孔。Lasso 只有在鼠标移动至少 2 px 时才采点,停留不会持续增加点数;近似共线的连续点会在线合并,填充和虚线段也只在轨迹变化时重算。绘制中按 `F` 切换反选:默认保留选区内,反选后保留选区外。Line 按住 `Ctrl` 以 5° 吸附;Polyline 可单击起点圆环或按 `Enter` 完成。`Refine Selection` 只用于 Box / Lasso:先用 BEN2 将几何选区收紧到其中的前景,再应用反选。结果直接替换当前 Empty 的图片,按最终非零 Alpha 紧裁切,并更新显示尺寸与 Image Offset;对象 Transform 不变。透明区保留源 RGB,避免纹理过滤产成黑边。未形成有效区域的单击会静默结束;有效选区没有可见像素时只显示警告,不修改图片。 - -### 四点透视矫正 - -`Image Perspective` 是紧随 Image Mask 工具组之后的独立工具。四个角可以按任意顺序点击,完成后会在源图片坐标中自动求出不交错的凸四边形,并以原图 X/Y 方向确定输出旋转。绘制时显示白色虚线和半透明选区面,`Backspace` 删除最近一个角点,右键或 `Esc` 取消;切换其他工具也会立即退出。首次点击遵循同一选择规则。Perspective 不依赖当前 View 的相机内参,因此透视和正交 View 都可以使用。 - -第四个角点按下时会在 Viewport 中央显示带蓝色边框的矫正图片预览,中央显示当前比例,下方显示鼠标操作提示;鼠标同时移到 Viewport 中心,为左右两个方向保留相同的调节空间。水平移动连续调整宽高比,按住 `Ctrl` 吸附到 `1:1`、`4:3`、`3:4`、`16:9`、`9:16`,下一次左键确认并创建;`Backspace` 返回第三点,右键或 `Esc` 取消。初始比例取四边形两组对边的平均长度比。预览使用一次成成的 512 × 512 临时图片,移动鼠标时只改变 UI 显示比例。 - -确认后,工具把选区透视矫正为矩形图片,按最终非零 Alpha 范围裁切并 Pack 到 `.blend`。结果 Empty 保持源图的朝向和深度,以四边形在源图上的 bounding box 近似确定中心与显示范围,不再尝试从当前 View 反解真实空间矩形。四边形完全位于源图范围外,或矫正结果没有可见 Alpha 像素时,只显示警告且不创建结果。输出最长边不超过 8192 px,总像素不超过约 16 MP。 - -Image Perspective 是 deform 工具,不提供 Refine 或反选。它与其他 Image 工具共享选图、绘制样式、紧裁切和 Empty 写回逻辑。 - -完整操作说明见 [Image 编辑工具](docs/image-tools.md)。 - -### Cutout 基础面与四种形状 - -在 Object Mode 的 3D Viewport 左侧工具栏选择 `Cutout`,沿 Image Empty 绘制后可从四个方向选择 Surface、Balloon、Depth Balloon 或 Depth Surface;双击会选择完整图片。`Point Spacing` 以完整原图像素为单位控制规则三角格点间距,裁切范围大小不会改变密度。未启用 `Refine Selection` 时,几何只跟随套索;启用后才使用 BEN2 Alpha 收紧选区。`Normal` 默认关闭;启用后才通过 MoGe-2 生成 normal 纹理。未启用 Refine 且关闭 Normal 时,Surface 与 Balloon 完全在 Blender 内生成,不启动 BlendJob。 - -四种结果的源网格统一为真正的单面薄片,并分别使用 `O Image Surface`、`O Image Balloon`、`O Image Depth Balloon`、`O Image Depth Surface` 四个独立 Geometry Nodes。局部纸面统一位于 YZ 平面,局部 `+X` 朝纸面内侧,局部 `-X` 是外侧和深度箭头方向;对象原点统一位于裁切图片中心,对象矩阵补偿后薄片仍落在源 Image Empty 的原位置。`Thickness = 0` 时保留单面;Surface 与 Depth Surface 的 Thickness 是场景距离,Balloon 与 Depth Balloon 的 Thickness 是 `o_balloon` 高度倍率,默认 `1` 保留算法原值。加厚按整片连通区域处理,内部三角边不生成侧壁。Surface 与 Depth Surface 会以边界三角边的中位长度为目标宽度,沿 Thickness 方向自动增加侧壁分段,使侧壁面片尽量接近正方形;Balloon 与 Depth Balloon 不增加这些分段线。 - -四个修改器都有默认折叠的 `Options` 面板,`Cleanup` 统一放在其中。节点按 Mesh Island 累计离散面面积,并以基础面三角形的中位面积自动估算 Point Spacing 尺度;默认 `1` 会删除面积小于约一个点间距平方的孤立小面,因此无需在 Cutout 创建后写入绝对场景单位阈值。Depth Balloon 的 `Base Plane`,以及 Depth Surface 的 `Depth Continuity`、`Smooth` 也收纳在该面板。 - -四种 Cutout 形态都会在创建基础网格时计算 Balloon profile,并统一写入 `FLOAT/POINT` 属性 `o_balloon`;不再写入 `o_boundary`。AnyImage 扩展 Preferences 的 **Balloon Profile** 可选择 Poisson 或 Teddy,两个 Balloon 节点组只读取属性,不再自行估算轮廓。两个 Depth 形状都从内部打包的浮点深度图片采样,不把深度写成点属性;内部深度 Image datablock 统一使用 `_depth.exr` 后缀,`Depth Image` socket 在修改器中隐藏。 - -Depth Balloon 的 Base Plane 位于当前裁切后网格的中心,而不是原图中心。默认朝向由选区内有效 MoGe 深度进行稳健平面回归;细长选区会保留长轴趋势,同时抑制缺少空间支撑的短轴倾角。Single Arrow 沿局部 `-X` 指向实际深度方向;沿箭头移动的场景距离与前表面的同向位移严格一致。Geometry Nodes 会先用 Base Plane 校正深度,再让 `Depth Scale` 乘整个校正结果;因此 `Depth Scale = 0` 时 Base Plane 和深度图都不再改变深度分支。`Double Sided` 默认关闭,使背面保持 Balloon 厚度,开启后则把受 Base Plane 调整后的最终前表面关于对象局部 YZ 平面完整镜像;Base Plane 只定义正面的朝向与基线,不会移动对称中心。Depth Surface 先按深度图片位移和断层剔除,再清理新产生的小岛、加厚并执行展开的 O Smooth 结构;`Iterations` 默认 `1`,`Depth Continuity` 默认 `0`。 - -Surface、Balloon 与 Depth Balloon 的 MoGe normal 会依据最终网格 TBN 烘成 tangent-space 纹理;Depth Surface 直接使用适配新局部轴向的 object-space normal。完整说明见 [Cutout](docs/cutout.md)。 - -### 去除背景 - -选中 Image Empty 后,右键打开 `AnyImage` 子菜单并选择 `Remove Background`,完成后 Empty 会自动 -换成透明前景图。 - -### 高清放大 - -选中 Image Empty 后,在右键 `AnyImage` 菜单中点击 `Upscale`。它使用扩展偏好设置中选择的模型进行 4× 放大,支持单图、Movie 和 Image Sequence,并保留源 Alpha。 - - -[阅读高清放大指南](docs/upscale.md) - -### 转换为透视网格 - -选中单张图片的 Image Empty,右键打开 `AnyImage` 子菜单并选择 `Convert to Surface`。可选择 MoGe-2 -ViT-S、ViT-B、ViT-L 或 DA3 Small、Base,其中 MoGe-2 ViT-S 为默认模型。AnyImage 会按模型输出的 -相机内参与三维点建立透视网格,并匹配原 Empty 的可见尺寸、Offset 与 Transform。Server 通过统一的 -Surface 输出流程构建 DA3 与 MoGe-2 源网格:MoGe-2 使用深度断层 mask,DA3 保留 confidence 与边拉伸 -数据。两者再由独立的 Geometry Nodes -修改器完成剔面、快速重拓扑、Relax 与 UV 重投;顶层 `Iterations` 可设为 `0`。开启 Remove Background -时,BEN2 Alpha 大于等于 `0.5` 才视为前景;三角形三个顶点必须全部为前景,任一顶点 Alpha 小于 `0.5` -都会删除该面并且不再交给 Geometry Nodes;Surface 材质使用成成的色色图片。匹配相机默认不创建;开启后会作为 Surface 的子级。 -MoGe-2 的像素级 normal 会作为 object-space normal 贴图接入 `O Image Layer`,并与原图 -Bump 合并;网格本身不写入 custom normal,因此重拓扑降低顶点数后仍能保留细腻的法线细节。 -当前此功能仅支持单张图片。 - -MoGe-2 ViT-S、ViT-B、ViT-L、DA3 Small、DA3 Base、BEN2 与 Upscale 使用的 Real-ESRGAN General WDN x4v3、SAFMN Real x4、Real-ESRGAN x4plus 全部使用 ONNX。模型执行、相机与深度恢复、 -点云重投影和网格成成均不依赖 PyTorch。Windows 使用 DirectML 加速;Apple M4 或更新芯片可使用 CoreML 的 `MLProgram` 模型格式,较早或无法识别的 Mac 只提供 CPU;provider 不可用时会回退到 ONNX Runtime CPU, -并在任务状态中显示实际 provider。 - -转换对话框提供模型与 `Create Matching Camera`、`Remove Background`、`Shadeless`;DA3 显示 -`Resolution Scale`,MoGe-2 显示 `Resolution Level`, -以及分割线下方的 `Keep Original`。 -界面会显示计算所得的实际 Process Res。剔除和重拓扑参数直接在 -Geometry Nodes 修改器中调整;`Shadeless` 与 Depth Plane 共用 `O Shadeless` 材质节点组。色色贴图始终使用统一的图片转换产物;Remove Background 只决定是否由 BEN2 修改 Alpha。色色贴图会按当前场景的 View Transform 选择对应输入色彩空间:AgX、ACES 1.3、ACES 2.0、Filmic、Khronos PBR Neutral 分别使用 `AgX Base sRGB`、`ACES 1.3 sRGB`、`ACES 2.0 sRGB`、`Filmic sRGB`、`Khronos PBR Neutral sRGB`;无法匹配当前 View Transform 时回退 `sRGB`。普通材质的 -Bump Scale 会在材质组内乘以 `0.001`,并按透视表面的深度空间缩放。Surface 与 Depth Plane 的 -Layer Color 同时驱动 Principled BSDF 的 Base Color、Subsurface Radius 和 Emission Color,Layer Normal -同时驱动 Normal 与 Coat Normal;Subsurface、Emission、Coat 的其他设置保持默认。Surface attributes -统一使用 `o_` 前缀;Server 准备过程显示 -`Preparing surface data n/6`,可看到当前阶段与总步骤;Surface 与 Depth 单帧模型推理都不显示多余的 `1/1`。 - -### 视频与序列帧 - -生成的颜色、深度和法线 Image datablock 分别使用 `_color.png`、`_depth.exr`、`_normal.png` 和 `_upscale.png` 后缀,让 Blender 保持对真实图片格式的识别。 - -AnyImage 支持视频(mp4、mkv、mov 等)和序列帧,会逐帧处理并成成逐帧对应的立体面片。`_color.png` 与 `_depth.exr` Image datablock 按源 Empty Image 的类型载入:普通单图为 `IMAGE`,Movie/Sequence 即使只处理一帧也仍为 `SEQUENCE`。处理范围默认 -跟随 Empty 上设置的 `Image Frames` 与 `Offset`,不需要额外配置。DA3 的转换任务复用同一个 ONNX -Session 逐帧推理,显存峰值不随视频帧数平方增长。Upscale 也会逐帧成成受输出上限约束的 4× 色色序列并写回 Empty。Debug 只保存模型 API 的直接返回:DA3 多帧输入 -保留联合推理,MoGe-2、BEN2 与三档高清模型按各自的单图 API 逐帧保存。高清 Debug 输出 4× RGB `prediction*.png`,不做强度混合或图片替换;三个模型都由适配层自动分块,因此接受任意图片宽高。 - -## 任务结果保存在哪里 - -任务结果保存在 Runtime Storage Root 的 `jobs/` 下,默认是 `~/.anyimage/jobs/`。Job Server 接受任务时自动成成 opaque Job ID,并创建同名文件夹;Blender 不预先申请 ID 或目录。Packed Image 和序列帧的临时输入由 Blender 在任务结束后清理,Server 产物直接写入该 Job 目录。普通图片层使用 `image.0001.*` 起的文件名,导入后沿用源 Image 名称并由 Blender 自动添加 `.001`;Upscale 单独使用 `upscale.0001.png` 和 `_upscale.png` 数据块后缀。视频或序列帧成成的面片会引用其中的序列帧,请保留对应文件夹,不要随意删除。 - -每个任务目录中的 `job.log` 保存任务细节和完整 traceback;Storage Root 下的 `server.log` 只保留带时间、Job ID 与 Job Type 的成命周期主线,以及 Server 自身异常。 - -## 常见问题 - -- 首次使用需要下载模型;Environment 已安装但默认模型下载慢或失败时,点击 `Download Required Models…` 可补齐缺失项,也可在 AnyImage 扩展偏好设置中逐项重试 -- 安装 Environment 时下载状态会显示平均速度;网络适合国内镜像时在确认对话框勾选 `Use Mirror`,否则保持关闭。选择的来源固定用于 uv、Python 和 Python 包,失败时不会自动切换 -- 默认模型下载会在 Hugging Face 与 OmooLab 镜像之间自动回退;某一来源暂时断线不会阻止后续模型尝试另一来源 -- 模型直链建连超过 10 秒,或连接后连续 60 秒没有收到数据,会切换到下一来源;不限制总下载时长 -- 找不到右键入口时,请确认选中了 Empty,且显示方式为 Image -- 动画面片不动:检查 Empty 的 `Image Frames` 是否大于 1,并确认播放场景帧 - -## 技术文档 - -Blender 侧只负责界面、任务提交与结果导入,AI 与文件输出由独立 Job Server 执行。源码将插件级 `runtime.py`、`menu.py`、`panel.py` 保持在顶层,具体功能与共享图片、材质能力统一放在 `operators/`;每种任务的结果回写与对应 Operator 放在一起。详细功能见 -[文档索引](docs/index.md),程序设计、调用流程、项目结构与开发命令见 -[程序设计、开发与许可证](docs/development.md)。CI 在 `main` push 与 Pull Request 上运行测试,仅由 `v*` tag 构建并发布扩展包;文档在 `main` 的文档相关文件变化时部署。 +AnyImage Extension 代码采用 GPL-3.0-or-later。模型权重使用各自上游许可证。 diff --git a/docs/animation.md b/docs/animation.md deleted file mode 100644 index d72afdf..0000000 --- a/docs/animation.md +++ /dev/null @@ -1,36 +0,0 @@ -# 视频与序列帧 - -AnyImage 可以逐帧处理视频和序列帧,并生成随场景时间播放的 Depth Plane 或透明 Image Empty。 - -## 支持的工作流 - -- **Convert to Depth Plane**:支持视频和序列帧。 -- **Remove Background**:支持视频和序列帧。 -- **Convert to Surface**:当前只支持单张图片。 - -## 设置处理范围 - -处理范围来自 Image Empty 自身的图片设置: - -- **Image Frames** 决定处理的帧数。 -- **Offset** 决定从源素材的哪个位置开始。 -- **Start** 决定结果从场景的哪一帧开始播放。 -- **Cyclic** 会保留到生成的颜色与深度序列中。 - -显式把 Image Frames 设为 `1` 时,AnyImage 只处理一帧。范围超过视频长度时,AnyImage 会把窗口收回有效范围内。 - -## 性能建议 - -- 先用较少帧数和较低分辨率测试效果。 -- DA3 和 MoGe-2 的 Depth Plane 会逐帧推理并复用模型 Session,显存峰值主要由单帧分辨率决定。 -- 视频越长,处理时间和任务目录占用越大。 -- 任务运行期间可以从 Blender 状态栏查看进度并取消。 - -## 保存和移动工程 - -Blender 无法把整组动画图片序列完整打包进 `.blend`。AnyImage 因此让动画结果直接引用 Storage Root 的 Job 文件夹。 - -!!! danger - 删除、移动或清理仍被工程引用的 Job 文件夹,会导致动画颜色或深度序列丢失。 - -需要把工程移动到另一台机器时,请连同对应 Job 文件夹一起复制,并在 Blender 中重新定位缺失文件。也可以先把需要长期保留的序列复制到项目自己的素材目录,再用 Blender 的外部数据工具重新映射路径。 diff --git a/docs/cutout.md b/docs/cutout.md deleted file mode 100644 index 51f217a..0000000 --- a/docs/cutout.md +++ /dev/null @@ -1,37 +0,0 @@ -# Cutout - -Cutout 用套索从 Image Empty 中选择范围,再从四个方向选择生成结果:左侧 **Surface**、右侧 **Balloon**、下方 **Depth Balloon**、上方 **Depth Surface**。双击图片会把完整画面作为选区。 - -## 绘制选区 - -在 Object Mode 的 3D Viewport 左侧工具栏选择 **Cutout**,按住左键绘制,松开后选择形状。绘制时按 `F` 切换反选,右键或 `Esc` 取消当前轨迹。 - -自交轨迹使用非零环绕填充规则。沿相同方向围住的区域即使重叠多次也会保留;只有反方向环绕使计数抵消时才形成孔洞。因此交错部分不会再被当成洞扣除,同时仍能表达中间的反向内环孔洞。普通选区可以越过 Image Empty 边框,框外轨迹仍会生成网格;选区必须与原图中的可见像素相交才有效。反选仍限制在原图范围,避免把框外无限区域截成任意矩形。Cutout 的 **Refine Selection** 独立保存,不与 Image Box 或 Image Lasso 联动。未启用时,选区边缘会做轻量抗锯齿;启用后,BEN2 先在同一个几何选区内计算前景 Alpha,再应用反选。 - -## 基础面密度 - -**Point Spacing** 表示基础面相邻采样点大约对应多少源图片像素,可设为 `4`–`32`,默认 `32`。数值越小,规则三角格点越密,后续形状能表达的细节越多,同时网格点数和约束三角化成本也越高。点间距始终根据完整源图和 Image Empty 尺寸换算;局部裁切只改变位置、UV 与贴图范围,不会重新归一化网格密度。最长边的采样级别限制在 `4`–`1024`。 - -基础面先从最终 Alpha 提取外边界与孔洞,再以接近等边三角形的六角格点填充内部。轮廓按目标间距重采样和平滑,边界点仍作为约束参与 Delaunay,因此内部点保持均匀,外缘不会退化成规则栅格的阶梯锯齿。 - -四种结果都启用平滑着色。Surface 与 Depth Surface 的修改器也在输出阶段显式设置 Smooth Shading;Balloon 与 Depth Balloon 在创建源网格时设置。 - -Surface 的基础三角面统一按图片正面逆时针排列,Thickness 沿图片平面法线向前后各偏移一半;背面与轮廓侧壁使用相反绕序,所有封闭面的法线都朝向几何外部。Depth Surface 则保持正面深度表面不动,只沿法线反方向挤出背面。Surface 与 Depth Surface 的 Thickness 是 Blender 场景中的实际距离;其侧壁分段数按 `ceil(Thickness / 边界三角边中位长度)` 自动计算,并限制在最多 128 段,使大多数侧壁单元尽量接近正方形。分段只发生在连接前后表面的侧壁环带。Balloon 与 Depth Balloon 的 Thickness 是 `o_balloon` 高度倍率,`1` 保持算法原值,`0` 保持单面薄片;这两种形态不加入自适应分段,因此分段线不会改变 Balloon profile。 - -四个形状节点组统一采用从左到右的几何主线;参数使用靠近消费者的局部 Group Input,未连接 socket 全部隐藏。Cleanup、profile、深度采样、Base Plane、分段计数和平滑等字段分支放在主线上下,并在就近节点汇入;Repeat Zone 的输入、内部处理和输出保持清楚的包裹顺序。节点不使用自定义 label 或彩色 Frame,也不允许反向连线和节点重叠。`Surface` 对应节点组名称为 `O Image Surface`;载入旧资产时会把 `O Image Cutout` 原位迁移为新名称。 - -Depth Surface 的 `O Image Depth Surface` 按“深度 → 断层剔除 → 向后厚度 → 零厚度清理 → 展开的 O Smooth 结构 → 平滑着色”从左到右排列,使内置平滑与在完整输出后连接 `O Smooth` 获得相同的输入几何。平滑段把 O Essentials 参考资产的 Repeat Zone、两条不同求值域的边界判断、边界采样与法线一致性权重节点直接放入主节点图,每轮重新计算后再移动点,不再嵌套 `O Smooth` 节点组;内部固定按 `Pin Sharp = 1`、`Pin Boundary = 1` 计算,不暴露这两个参数,`Iterations` 默认 `1`、最小可设为 `0`。Depth Continuity 使用与 `O MoGe2 Surface` 相同的百分比阈值曲线,默认 `0`,对应最宽松的断层阈值;提高数值会逐步剔除更多过远的深度跳变面。 - -两个 Depth 节点组的内部浮点深度 Image datablock 统一使用源图片基础名称加 `_depth.exr` 后缀,不再使用旧 `.depth` 名称。四个节点组都提供默认折叠的 `Options` 面板,并把 `Cleanup` 收纳其中。`O Image Depth Balloon` 的 `Base Plane` 也位于 Options;`O Image Depth Surface` 的 `Depth Continuity` 与 `Smooth` 同样位于 Options。Depth Balloon 先计算相对 Base Plane 的校正深度,再用 `Depth Scale` 乘整个校正结果;设为 `0` 时,Base Plane 的位置和旋转以及深度图都不再改变深度分支。开启 `Double Sided` 后,Base Plane 只影响正面的朝向与基线,背面再由最终正面关于对象局部 YZ 平面(`X = 0`)镜像得到;Base Plane 不会改变前后对称中心。 - -Base Plane 的默认朝向使用选区内有效 MoGe 深度进行四轮稳健平面回归。回归在图像坐标的主轴和短轴上分别评估空间支撑;当细长结构的短轴方差过小时,会限制该方向的反演增益,避免少量横向深度噪声被放大成极端倾角,同时保留长轴上有充分样本支持的深度趋势。Blender 将归一化平面换算到裁切网格的本地坐标时执行同样的稳定处理。 - -## 计算说明 - -未启用 **Refine Selection** 时,几何范围只使用抗锯齿套索 mask,原图 Alpha 不参与轮廓;原图 Alpha 只用于确认选区内至少存在可见像素,并继续控制材质透明度。无论是否启用 **Normal**,Job 都会为真实出框的套索建立可带负坐标的扩展选区画布,因此图片边框外的几何不会被原图尺寸裁掉。套索或 Refine 的最终 Alpha 会确定紧凑矩形裁剪范围,并以 `0.5` 为几何边界:小于 `0.5` 的区域不建面。Color 贴图使用当前 Image Empty 在同一矩形内的原始像素,不乘套索或 Refine Alpha,只保留图片本身已有的 Alpha,图片外像素保持透明。深度和法线分析使用裁剪后的局部分析图:最终选区内保留当前图片的原始 RGB 与有效 Alpha,图片外和选区外 RGBA 全部置零。若当前图片来自 Upscale,材质、深度和法线都直接使用高清图所选区域,不再回溯放大前原图。Color 与 normal 允许保持裁剪后的非正方形尺寸;Blender 按裁剪 bounds 调整局部平面范围和 UV,使结果仍落在源 Image Empty 的原位置。关闭 **Normal** 时,Surface 与 Balloon 直接在 Blender 内处理,不准备临时输入、不启动 BlendJob;启用 **Normal** 后才提交 Job。MoGe 返回的相机轴法线先按源 Image Empty 的对象坐标编码。Depth Surface 原样保留这张 object-space 贴图;Surface、Balloon 与 Depth Balloon 则在最终几何节点结果可用后,使用成品网格的 UV、平滑角法线和切线基底重新烘成 tangent-space 贴图。启用 Refine 时,Job 先计算 BEN2 Alpha 并返回裁剪后的几何 mask。Depth 类型始终通过 Job 取得深度,但也只在启用 **Normal** 时写入 normal。四种形状的网格都在 Blender 主线程构建,不再通过 `contour.json` 传递边界。 - -最终 Alpha 先由 NumPy 批量找出四向边界,只沿实际周长追踪轮廓,并按包含关系把孔洞归入外轮廓。未 Refine 时直接复用套索 mask;只有真实出框时才建立扩展几何画布。启用 Refine 后完全使用原图画布内的最终 AI Alpha,所有框外套索区域都会被裁掉。格点准备使用批量数组生成;点内判断按 Y 范围分桶,只比较当前扫描带可能相交的轮廓边;边界安全距离也只检查每条边附近的少量格点。短轮廓会自动使用不大于周长八分之一的边界采样间距,避免有效小区域因当前 `Point Spacing` 较大而消失;内部格点仍严格使用用户设置的 `Point Spacing`。UV 与封边边界使用 Blender `foreach_*` 和 NumPy 批量写入,不逐元素遍历。Pillow 只会加速占比较小的 Alpha 模糊和缩放,因此 Blender 本地路径保持纯 NumPy,不新增 wheel。最终约束 Delaunay 和网格创建仍随实际点数增长,极密网格应按成品需要使用。 - -Cutout 当前只支持静态图片。**Refine Selection**、Depth 与 normal 分析需要已配置的 AI Environment 和对应模型。 - -Surface、Balloon、Depth Balloon、Depth Surface 以及普通 Surface 的图片材质共用 `O Image Layer`。节点组始终提供 Normal 输入和 Object Space 开关;没有 normal 纹理时使用 `[0.5, 0.5, 1.0, 1.0]` 中性默认值。这个开关只选择 Blender Normal Map 节点的解码空间,不承担 Object-to-Tangent 转换:三种 tangent-space 结果在接入材质前已经完成像素烘制,Depth Surface 才打开 Object Space 并直接使用 MoGe 原图。 diff --git a/docs/depth-plane.md b/docs/depth-plane.md deleted file mode 100644 index 78ba346..0000000 --- a/docs/depth-plane.md +++ /dev/null @@ -1,66 +0,0 @@ -# Depth Plane - -Depth Plane 把图片映射为带深度位移的面片,适合快速获得可调节的立体效果。操作方法见[Depth Plane 使用指南](guide-depth-plane.md)。 - -## 数据流 - -```mermaid -flowchart LR - Input["Image Empty"] --> Depth{"深度来源"} - Depth --> DA3["DA3"] - Depth --> MoGe["MoGe-2"] - Depth --> Original["Original"] - DA3 --> Layers["颜色层 + 深度层"] - MoGe --> Layers - Original --> Layers - Layers --> Import["Blender 导入"] - Import --> Mesh["基础面片"] - Import --> Nodes["Geometry Nodes"] - Import --> Material["材质"] -``` - -Server 为每帧准备颜色、深度和可选前景 Alpha。Blender 导入这些数据,创建基础面片,并用 Geometry Nodes 与材质分别完成几何位移和表面细节。 - -## 深度来源 - -| 来源 | 深度含义 | 额外数据 | 适合 | -| --- | --- | --- | --- | -| DA3 | 相对深度 | 相机内参与置信度 | 通用单图与动画 | -| MoGe-2 | Metric depth | 法线、Mask、相机内参 | 更稳定的尺度与表面法线 | -| Original | 原图亮度 | 可选 BEN2 Alpha | 已准备好高度图的素材 | - -DA3 和 MoGe-2 都以未经抠图的 RGB 估计深度。启用 Remove Background 后,BEN2 只分析输入 Alpha 内的可见 RGB,颜色 Alpha 使用“输入 Alpha × BEN2 Alpha”,并作为前景 Mask;深度本身保持模型输出。 - -视频与序列帧逐帧推理并复用同一个模型 Session,因此显存主要由单帧分辨率决定。DA3 Debug 的多帧联合推理属于另一条工作流。 - -## Blender 结果 - -转换会创建以下数据: - -- 与源 Image Empty 对齐的面片 -- 使用 `_depth.exr` 与 `_color.png` 后缀的 Image datablock -- 使用 `O DA3 Depth Plane` 或 `O MoGe2 Depth Plane` 的 Geometry Nodes 修改器 -- 连接颜色、Alpha、Bump 与材质位移的材质 - -Geometry Nodes 提供深度范围、细分、重拓扑、平滑、背景深度和位移强度等控制。深度 Mask 在几何和材质路径中使用相同阈值,以保持轮廓一致。 - -材质与图片名称以源 Image datablock 为基名。生成贴图将业务后缀放在真实文件扩展名前,避免 Blender 将其误判为源图数据块。对象 Transform、显示尺寸和 Offset 会转换为面片的空间位置与比例。 - -## 单图与动画文件 - -单图深度使用 OpenEXR Pack 到 `.blend`,以保留浮点范围。Packed Image 会先导出临时输入,任务结束后清理,源 Image 保持不变。 - -动画使用 Job 目录中的外部序列: - -```text -/ -├── image.0001.jpg/png -├── depth.0001.exr -└── results.npz -``` - -MoGe-2 动画的深度序列使用 `moge_depth.0001.exr`。这些文件是 `.blend` 的运行时依赖,详见[动画与序列帧](animation.md)。 - -## 适用范围 - -Depth Plane 是固定平面上的深度位移,不是完整的逐像素 camera-space 重建。它强调可编辑性和动画支持;需要透视网格时使用 [Surface](surface.md)。 diff --git a/docs/development.md b/docs/development.md deleted file mode 100644 index 3fcfb3b..0000000 --- a/docs/development.md +++ /dev/null @@ -1,135 +0,0 @@ -# 程序设计、开发与许可证 - -## 程序设计 - -AnyImage 分为 Blender 交互层、BlendJob Runtime 和 Job Server 计算层。 - -```mermaid -flowchart LR - UI["菜单 / Panel"] --> Operator["Feature Operator"] - Operator --> Runtime["JobRuntime"] - Runtime --> Job["Server Job"] - Job --> Files["Job Directory"] - Files --> Response["Operator Response"] - Response --> Scene["Blender Scene"] -``` - -Blender 交互层管理 UI、RNA 状态、任务参数和结果导入。Runtime 管理 Environment、Server、队列、进度与取消。Server Job 组合媒体、模型、几何和输出模块。完整运行设计见 [Job Server](server.md)。 - -### 代码组织 - -| 位置 | 内容 | -| --- | --- | -| `__init__.py` | Blender 类型、菜单与 Runtime 注册 | -| `clipboard_image/` | 剪贴板读取、上下文粘贴与快捷键 | -| `menu.py`、`panel.py` | 用户入口 | -| `preferences.py`、`properties.py` | 扩展配置与 Scene 状态 | -| `runtime.py` | JobRuntime 配置 | -| `operators/` | Blender 功能与结果导入 | -| `server/jobs/` | Server 工作流 | -| `server/models/` | ONNX 模型适配 | -| `server/geometry/` | 纯数据几何处理 | -| `server/outputs/` | 文件序列化 | -| `server/media/` | 媒体输入 | -| `assets/O_AnyImage.blend` | Geometry Nodes 与材质资产 | - -Mesh Plane、Depth Plane、Surface、Background 和 Balloon 按功能组织。一个功能的入口、请求和 Blender 回写放在一起;跨功能的图片、材质和参数处理放在 `operators/common.py`、`operators/image.py` 与 `operators/material.py`。 - -Server 的模型模块返回 Prediction,几何模块处理数组与网格,输出模块写文件。Blender 数据只在 Operator Response 的主线程阶段创建。Cutout 与 Balloon 在未启用 Refine 和 `Normal` 时不提交 Job:Operator 直接在内存中合成最终 Alpha,并调用 `server/geometry/cutout.py` 的纯数据轮廓函数后创建网格。Refine、Normal 与 Depth Job 只返回最终图片 Alpha 和被选中的 AI 数组或纹理;`normal_analysis` 为假时不请求 MoGe-2 normal 模型,也不写入 `normal.png`。外轮廓、孔洞及基础面始终在 Blender 主线程生成,不使用 `contour.json` 作为中间结果。Cutout 的 Point Spacing 由完整源图尺寸换算到平面空间;裁切 bounds 只参与轮廓坐标、局部平面和 UV 映射。 - -扩展级设置统一通过 `preferences.addon_preferences()` 读取,不归属 BlendJob Runtime;Storage Root 路径回调也由 Preferences 模块提供,Runtime 只消费回调结果。扩展包标识直接使用根模块中的 `__package__`,以兼容 Blender 实际的 `bl_ext..anyimage` 命名空间;不得按第一个点截断,否则会错误查找 `bl_ext` 并静默回退默认值。Preferences 的设备枚举和面板数据按需导入,避免循环依赖。 - -正式 Upscale 使用仅具备 `UNDO` 的 Job Operator 完成输入准备、任务提交和结果回写,不注册 Last Operation 重做面板。每次 `execute()` 都从 Preferences 读取默认模型与输出长边上限,再写入隐藏的可序列化 Operator 参数。高清结果只通过 `Image.anyimage_upscale_source` 保存原始 Image 引用;Operator 自身仅以隐藏的 `StringProperty` 保存原图名称,并在主线程响应时从 `bpy.data.images` 重新解析,避免保留已被移除的 RNA 指针。再次执行时从原图引用准备输入并替换当前高清数据块;无其他用户的旧高清结果会在新结果配置成功后移除,因此同一 Empty 只保留一个高清版本。模型只处理 RGB;源图 Alpha 经过同样的输入尺寸限制后独立放大并合并回结果。 - -Blender 侧需要落盘的 Packed Image 与浮点深度 EXR 都通过 `Image.save(save_copy=True)` 导出,不调用 `save_render`,避免把 Scene Render 的 View Transform、Look 或 Exposure 耦合到模型输入和 Non-Color 数据;颜色结果载入后继承源 Image 的 Color Space。Server 中的 `PIL.Image.save()` 只负责普通文件编码,不属于 Blender Render 色彩管理。 - -Image Box、Lasso、Line、Polyline 与 Perspective 位于 `operators/image_edit/` 功能包。Cutout、Image Box 与 Image Lasso 分别读取 `Scene.anyimage_settings.cutout_refine_selection`、`image_box_refine_selection` 与 `image_lasso_refine_selection`,三个工具的 Refine 状态互不联动;Line 与 Polyline 在公共设置入口中强制关闭 Refine。模块职责、交互状态、像素管线、Refine 和 Empty 定位见 [Image 工具技术实现](image-tools-technical.md)。 - -## 项目结构 - -```text -. -├── README.md -├── docs/ -├── pack.py -├── sync_models.py -├── src/anyimage/ -│ ├── blender_manifest.toml -│ ├── runtime.py -│ ├── menu.py -│ ├── panel.py -│ ├── preferences.py -│ ├── properties.py -│ ├── clipboard_image/ -│ ├── operators/ -│ ├── server/ -│ ├── assets/ -│ └── wheels/ -├── tests/ -└── tools/ -``` - -## 开发命令 - -在项目根目录使用 Git Bash: - -```bash -uv sync -uv run pytest -uv run pytest tests/test_addon.py tests/test_manifest.py tests/test_pack.py -v -uv run python pack.py -``` - -`pack.py` 为 Windows x64、macOS Apple Silicon 与 Linux x64 构建扩展 ZIP 到 `dist/`。下载项目依赖时会把 `src/anyimage/wheels/` 作为优先可用的 wheel 来源,因此尚未发布到 PyPI 的本地 BlendJob 构建也能进入三个平台包。依赖、wheel 或 Manifest 改变后,应同步检查 `pyproject.toml`、`uv.lock`、`blender_manifest.toml` 与打包测试。 - -CI 分为三条流程: - -- `test.yml`:在 push、Pull Request 与手动触发时运行测试 -- `build.yml`:响应 `v*` tag,测试后构建并上传 Release -- `docs.yml`:文档变化后构建并部署站点 - -## 文档 - -本地预览和严格构建: - -```bash -uv run --only-group docs mkdocs serve -uv run --only-group docs mkdocs build -``` - -版本化发布可使用 Mike: - -```bash -uv run --only-group docs mike deploy -p -uv run --only-group docs mike set-default -p -``` - -自动部署将 MkDocs 站点发布到专用 Cloudflare Worker。仓库需要配置 `CLOUDFLARE_ACCOUNT_ID`、`CLOUDFLARE_API_TOKEN` 和 `CLOUDFLARE_WORKER_NAME`。Worker 路由对应 `docs.omoolab.xyz/anyimage/*`。 - -## 资产构建与验证 - -`tools/` 中的资产脚本依赖 `bpy`,应由 Blender 执行: - -```bash -BLENDER_BIN="/c/Program Files/Blender Foundation/Blender 4.5/blender.exe" - -"$BLENDER_BIN" --background --python tools/build_geometry_nodes_asset.py -"$BLENDER_BIN" --background --python tools/validate_depth_plane_asset.py -"$BLENDER_BIN" --background --python tools/validate_mesh_plane_asset.py -"$BLENDER_BIN" --background --python tools/validate_geometry_nodes_asset.py -"$BLENDER_BIN" --background --python tools/validate_cutout_asset.py -"$BLENDER_BIN" --background --python tools/validate_moge_depth_plane.py -"$BLENDER_BIN" --background --python tools/validate_moge_surface.py -``` - -修改节点组、材质组或资产接口后,运行对应验证脚本和完整测试。 Cutout 四个形状节点组还会验证所有连线从左向右、节点之间保留可读间距、局部 Group Input 未使用输出隐藏,以及 Repeat Zone 数量和几何求值不变。Python、文档或打包逻辑变化无需重建 `.blend` 资产。 - -## 上游与许可证 - -- Depth Anything 3 代码采用 Apache 2.0 -- BEN2 ONNX 模型采用其仓库声明的 Apache 2.0 License -- Real-ESRGAN General WDN x4v3 与 Real-ESRGAN x4plus ONNX 权重采用其模型仓库声明的 BSD 3-Clause License -- SAFMN Real x4 ONNX 权重采用其模型仓库声明的 Apache 2.0 License -- 模型目录显式声明可用的 Hugging Face 仓库;未声明时只同步经过大小与 SHA-256 校验的 R2 制品 -- AnyImage Extension 代码采用 GPL-3.0-or-later -- 模型权重许可证由所选 Hugging Face 模型仓库决定 diff --git a/docs/features.md b/docs/features.md deleted file mode 100644 index 1f78cb8..0000000 --- a/docs/features.md +++ /dev/null @@ -1,76 +0,0 @@ -# 功能参考 - -本页概览 AnyImage 的能力。安装步骤见[安装与首次配置](installation.md),实际操作见[工作流选择](workflows.md)与各使用指南。 - -## 工作流 - -| 工作流 | 适合 | 输入 | 结果 | -| --- | --- | --- | --- | -| Clipboard Image | 无需先另存即可使用系统剪贴板图片 | 剪贴板中的静态图片 | Reference Image、Plane、图片节点或笔刷 Texture | -| Mesh Plane | 不需要深度的图片卡片与背景板 | 单图、视频、序列帧 | 带图片材质和 Geometry Nodes 的普通平面 | -| Depth Plane | 快速制作可调节的立体图片 | 单图、视频、序列帧 | 带颜色、深度与 Geometry Nodes 的面片 | -| Surface | 从单张图片恢复透视空间 | 单图 | 相机空间网格、材质与可选匹配相机 | -| Remove Background | 提取透明前景 | 单图、视频、序列帧 | 透明图片或透明序列 | -| Upscale | 在安全输出上限内增加图片像素 | 单图、视频、序列帧 | 三档模型生成的高清图片或序列 | -| Image Mask | 用 Box、Lasso、Line 或 Polyline 直接清理图片 | 静态 Image Empty | 原位更新并 Pack 的透明裁切图 | -| Image Perspective | 四点透视矫正裁切 | 静态 Image Empty | Pack 的矩形裁切图 | -| Cutout | 套索选择区域并生成四种网格形状 | 静态 Image Empty | 带孔洞与原图 UV 的规则三角基础面 | -| Balloon | 沿轮廓绘制鼓起造型 | 静态 Image Empty | 带原图 UV 的封闭网格 | -| Debug | 检查模型原始输出 | 模型支持的图片或帧序列 | 保存在 Job 目录中的原始 Prediction | - -Depth Plane 与 Surface 可使用 DA3 或 MoGe-2。DA3 提供相对深度和相机信息;MoGe-2 还提供 metric depth、法线与有效区域。BEN2 为去背景和可选前景处理提供 Alpha。 - -## Blender 交互 - -复制图片后,在支持的编辑器中按 `Ctrl+V`(macOS 为 `Cmd+V`)即可粘贴;图片会 Pack 到当前 `.blend`。3D View 的 Object Mode 默认创建 Reference Image,也可在 Redo 面板切换为 Plane;Shader Editor、Compositor 与 Geometry Nodes 创建对应图片节点,Sculpt、Vertex Paint 与 Texture Paint 则设置笔刷 Texture。详见[粘贴剪贴板图片](clipboard-image.md)。 - -主要功能位于 Image Empty 的 `AnyImage` 右键菜单;AnyImage 使用 prepend 注册,显示在右键菜单最上方。 - -`Convert to Mesh Plane` 位于 `Convert to Depth Plane` 上方,不需要 AI Environment。它生成基础四边形,并由 `O Mesh Plane` 修改器实时处理 `Thickness` 与 `Subdivide`;`Thickness` 为 `0` 时保持单面。操作会立即执行;创建后可在 Last Operation 中调整 `Shadeless` 与 `Keep Original`。 - -Cutout 复用 Image Lasso 的反选与 Refine 流程,但自交选区使用非零环绕填充:同方向重叠保留,反方向内环形成孔洞。基础面以均匀六角格点填充平滑约束轮廓,`Point Spacing` 以完整源图像素为单位控制内部密度,不随局部裁切范围重新归一化;短轮廓会自适应边界采样以免有效小区域消失。未启用 Refine 的 Surface 与 Balloon 只按套索建面,不把原图 Alpha 计入几何;同时关闭 `Normal` 时在 Blender 内完成遮罩、轮廓和建面,不启动 BlendJob。套索或 Refine Alpha 以 `0.5` 为几何剔除阈值,并确定紧凑矩形裁剪范围。材质 Color 使用当前 Empty Image 在该范围内的原始像素,不乘选择 Alpha,只保留图片原有 Alpha;MoGe 深度与法线则使用同一裁剪范围的局部分析图,选区内保留当前图片 RGB,范围外 RGBA 置零。Upscale 图片直接使用高清图的所选区域进行材质与 AI 分析。裁剪贴图可以是非正方形,局部平面范围和 UV 会映射回源图对应区域。`Normal` 默认关闭,启用后才用 MoGe-2 写入 normal;Depth Surface 保留 MoGe 原生 object-space 像素,Surface、Balloon 与 Depth Balloon 根据成品网格 TBN 烘成 tangent-space 像素。Depth 类型始终使用 Job 生成深度,但同样由该选项决定是否写入 normal。Refine 启用后由 BEN2 返回裁剪后的最终 Alpha,Blender 再从该 Alpha 建面。详细操作与计算边界见 [Cutout](cutout.md)。 - -`O Image Depth Surface` 的平滑阶段直接展开 O Essentials `O Smooth` 的 Repeat Zone 与双域边界判断结构,不嵌套组节点;内部固定按 `Pin Sharp = 1`、`Pin Boundary = 1` 计算,不暴露这两个参数,`Iterations` 默认 `1`、最小可设为 `0`。`Depth Continuity` 默认 `0`;Thickness 固定正面,只向背面挤出。 - -`Depth Plane` 与普通 `Surface` 的推理仍可沿用 Upscale 保存的原图引用;Cutout 四种形状则以当前高清 Image 的所选区域作为 Color、深度和法线输入。 - -转换任务完成后,AnyImage 在一个 Undo 步骤中创建网格、图片、材质和修改器。源 Image Empty 是否保留由转换选项决定。 - -Image Box、Lasso、Line 与 Polyline 组成 Mask 工具组,并共用 `EditImageMask`。四者用 `F` 切换反选;`Refine Selection` 只用于 Box / Lasso。 - -Perspective 是紧随 Mask 工具组之后的独立工具,将任意顺序输入的四个角矫正为指定比例的矩形。结果直接更新当前 Image Empty,并 Pack 到 `.blend`。 - -操作步骤见 [Image 编辑工具](image-tools.md);处理管线与模块职责见 [Image 工具技术实现](image-tools-technical.md)。 - -Cutout 工具从四个方向生成 Surface、Balloon、Depth Balloon 与 Depth Surface。四种结果使用相同的单面基础薄片、局部 YZ 纸面方向和裁切中心原点,并统一写入 `o_balloon` 点属性;Preferences 可在 Poisson 与 Teddy 两种 Balloon Profile 算法之间选择。 - -四个 Geometry Nodes 修改器都提供默认折叠的 `Options` 面板,并把 `Cleanup` 收纳其中。Depth Balloon 额外把 `Base Plane` 放入 Options;Depth Surface 把 `Depth Continuity` 与 `Smooth` 放入 Options。Depth Balloon 使用 `Depth Scale` 控制相对基面的深度;`Double Sided` 开启后,先让 Base Plane 决定正面的朝向与基线,再把最终正面关于对象局部 YZ 平面完整镜像;Base Plane 不会改变对称中心。 - -## 动画 - -Depth Plane、Remove Background 和 Upscale 支持 Movie 与 Image Sequence。它们按 Image Empty 的帧范围处理素材,并生成与场景帧对应的颜色或深度序列。 - -Blender 无法整体 Pack 图片序列,因此动画结果会继续引用 Job 目录。保存或移动 `.blend` 时,应同时保留这些外部文件。详见[动画与序列帧](animation.md)。 - -## 运行环境 - -AI 任务在独立 Python Environment 中通过 ONNX Runtime 执行。Blender 负责界面、任务提交和结果导入,Job Server 负责模型与文件处理。 - -默认 Storage Root 为 `~/.anyimage`,其中保存 Environment、模型、日志和 Job 结果。连续使用同一模型会复用已加载的 Session;切换到另一模型族时释放旧 Session,使 Server 最多保留当前使用的一套模型。 - -安装、下载和推理都在后台运行,并通过状态栏提供进度与取消操作。详细设计见[Job Server](server.md)。 - -## 模型与调试 - -正式功能使用以下 ONNX 模型: - -- MoGe-2 ViT-S、ViT-B、ViT-L Normal -- DA3 Small、DA3 Base -- BEN2 Base -- Real-ESRGAN General WDN x4v3、SAFMN Real x4、Real-ESRGAN x4plus - -Upscale 提供 Real-ESRGAN General WDN x4v3、SAFMN Real x4 和 Real-ESRGAN x4plus 三档 4× RGB 模型。默认 WDN 偏速度和保守细节,SAFMN 居中,x4plus 的细节更明显;三者也保留对应 Debug 入口用于模型直通比较。正式功能会独立放大并合并源 Alpha,默认把输出长边限制在 4096 px,可在 Preferences 提高到 8192 px,并始终受约 32 MP 硬上限约束。 - -首次安装会准备 MoGe-2 ViT-S Normal、BEN2 Base 与 Real-ESRGAN General WDN x4v3,其他模型可从 AnyImage 扩展偏好设置按需下载。 - -Debug 工作流保存模型 API 的直接返回,适合验证模型、设备与输入。三档高清模型对每张输入保存 `prediction*.png`,不加入降噪强度、Alpha 合成或 Blender 图片写回。三个模型统一由适配层以 256×256、16 像素上下文执行重叠分块,因而都接受任意图片宽高。Depth Plane 和 Surface 则在模型结果之上继续生成 Blender 可用的贴图与几何。文件字段见[输出格式](outputs.md)。 diff --git a/docs/installation.md b/docs/getting-started/installation.md similarity index 76% rename from docs/installation.md rename to docs/getting-started/installation.md index 555f51e..c0b0fd2 100644 --- a/docs/installation.md +++ b/docs/getting-started/installation.md @@ -25,9 +25,9 @@ AnyImage 的扩展偏好设置按功能分组。Environment 区包含: Environment 检测后显示 Diagnostics。Depth & Normal、Remove Background、Upscale 与 Cutout 是默认展开的可折叠功能区,各自包含对应设置和模型状态。其中: - **Default Upscale Model**:Upscale 使用的模型,默认是 General WDN x4v3。 -- **Maximum Upscale Size**:Upscale 输出长边上限,默认 4096 px,最高 8192 px。 +- **Maximum AI Input Size**:Upscale、Depth 与 Normal 分析输入的长边上限,默认 2048 px;只缩放分析输入,不降低颜色贴图分辨率。 -Depth Model 依次列出三档 MoGe-2 与两档 DA3;选择 DA3 时会提示该模型不能生成 normal。可选模型的 **Ready** 状态或 **Download** 按钮直接位于模型选择项下方;BEN2 等固定模型则显示在所属功能区。Cutout 区只保留 Balloon Profile。 +Depth Plane 的 Depth Model 依次列出三档 MoGe-2 与两档 DA3;选择 DA3 时会提示该模型不能生成 normal。Cutout 固定使用 MoGe-2:全局选择 MoGe-2 时沿用对应档位,选择 DA3 时回退默认 MoGe-2。可选模型的 **Ready** 状态或 **Download** 按钮直接位于模型选择项下方;BEN2 等固定模型则显示在所属功能区。Cutout 区只保留 Balloon Profile。 如果准备把 Storage Root 放到其他磁盘,建议在首次安装 Environment 之前修改。更换目录后,旧目录中的环境和模型不会自动移动。 @@ -54,7 +54,7 @@ Depth Model 依次列出三档 MoGe-2 与两档 DA3;选择 DA3 时会提示该 - AnyImage 扩展偏好设置中的 MoGe-2 ViT-S Normal、BEN2 Base 和 Real-ESRGAN General WDN x4v3 显示 **Ready**。 - 选中 Image Empty 后,右键菜单底部显示 **AI Environment Settings…**。 -**Convert to Depth Plane…**、**Convert to Surface…** 和 **Remove Background** 在 AI 未就绪时仍可点击,点击后会根据状态转入 **Set Up AI Environment…** 或 **Download Required Models…**。Balloon 的本地套索与几何生成功能不要求安装 AI Environment;未就绪时,Tool Settings 只会禁用 **Contour**、**Depth** 与 Depth Model,并显示安装按钮。 +**Convert to Depth Plane**、**Remove Background** 和 **Upscale** 在 AI 未就绪时仍可点击,点击后会根据状态转入 **Set Up AI Environment…** 或 **Download Required Models…**。普通 Plane、Image 编辑,以及未启用 Refine / Normal 的 Cutout Surface 与 Balloon 不要求安装 AI Environment。 ## 下一步 diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md new file mode 100644 index 0000000..041145d --- /dev/null +++ b/docs/getting-started/quick-start.md @@ -0,0 +1,40 @@ +# 五分钟上手 + +这个流程会把一张静态图片转换为可继续编辑的 Depth Plane。 + +## 1. 导入图片 + +在 3D View 中选择 **Add > Image > Reference**,导入一张主体清晰、前后关系明确的图片。Blender 会创建一个 Image Empty。 + +## 2. 检查深度设置 + +打开 **Edit > Preferences > Extensions > AnyImage**。第一次使用建议保持默认: + +- **Mesh Depth Model**:MoGe-2 ViT-S Normal +- **MoGe Resolution Level**:5 +- **Device**:使用自动列出的首选设备 + +这些是扩展级默认值,转换时不会再弹出参数对话框。 + +## 3. 开始转换 + +选中 Image Empty,在 3D View 中右键选择 **AnyImage > Convert to Depth Plane**。如果环境或必需模型尚未就绪,AnyImage 会先引导完成安装或下载。 + +任务运行时,状态栏会显示准备输入、模型推理和结果导入进度。转换完成后,源 Image Empty 会被新的网格对象替代。 + +## 4. 调整结果 + +打开对象的 `O Image Depth Plane` Geometry Nodes 修改器: + +- **Subdivide** 控制网格密度; +- **Thickness** 增加背面和侧壁,`0` 保持单面; +- **Depth Amount** 控制深度位移强度。 + +建议先用较低 Subdivide 调整 Depth Amount,再按成品需要增加细分。 + +## 接下来可以尝试 + +- 只需要普通图片卡片:[Plane](../guides/mesh-plane.md) +- 先获得透明前景:[去除背景](../guides/remove-background.md) +- 从局部选择生成透视表面:[Cutout](../guides/cutout.md) +- 了解深度数据如何进入节点:[Convert to Plane 实现原理](../internals/operators/convert-to-plane.md) diff --git a/docs/guide-depth-plane.md b/docs/guide-depth-plane.md deleted file mode 100644 index 8f3d1a5..0000000 --- a/docs/guide-depth-plane.md +++ /dev/null @@ -1,57 +0,0 @@ -# Depth Plane - -Depth Plane 将图片转换为带位移的立体面片。它适合快速场景搭建、2.5D 合成、立体插画以及视频素材。 - -## 创建 Depth Plane - -1. 选中 Image Empty。 -2. 在 3D View 中右键,选择 **AnyImage > Convert to Depth Plane**。 -3. 选择深度来源和需要的选项。 -4. 确认后等待任务完成。 - -## 选择 Depth From - -### MoGe-2 - -默认推荐。MoGe-2 会估算尺度一致的深度、法线和有效区域,通常适合单张图片和逐帧动画。模型越大,细节潜力和资源占用通常越高。 - -- **Resolution Level** 越高,处理更细致,也会更慢并占用更多内存。 -- 第一次使用建议保持默认值 `5`。 - -### DA3 - -适合尝试不同的深度估算结果。DA3 使用 **Resolution Scale** 控制处理尺寸;界面会显示实际 Process Res。 - -- 较低比例适合预览和显存有限的设备。 -- 较高比例可能保留更多细节,但不保证每张图片都更好。 - -### Original - -直接把原图亮度作为深度来源,不运行深度估算模型。它适合已经准备好灰度高度图或手绘深度图的情况。 - -## 转换选项 - -- **Remove Background**:转换期间使用 BEN2 去除背景,让 Alpha 同时控制可见区域和背景深度。 -- **Shadeless**:让贴图颜色不受场景灯光影响,适合插画、合成和需要保持原图观感的场景。 -- **Keep Original**:保留源 Image Empty。默认关闭,转换成功后由新网格替代原对象。 - -## 调整结果 - -生成对象的 Geometry Nodes 修改器提供以下常用参数: - -- **Displacement**:`Geometry` 直接移动网格;`Material` 保持平面并使用材质置换。 -- **Subdivision**:控制网格密度。数值越高,深度细节越多,场景也越重。 -- **Background Depth**:设置背景区域的位置。 -- **Depth Scale**:控制深度强度;负值可以反转前后关系。 - -调整时建议先降低 Subdivision 提高交互速度,确定 Depth Scale 后再提高细分。 - -## 动画输入 - -Depth Plane 支持视频和序列帧。处理范围跟随 Image Empty 的 **Image Frames** 与 **Offset**,生成后的颜色和深度会随场景帧播放。 - -动画结果会引用任务目录中的图片序列。移动工程或清理磁盘前,请先阅读[视频与序列帧](animation.md)和[模型、设备与存储](models-and-storage.md)。 - -## 进一步了解 - -需要查看节点、数据字段、色彩空间和位移计算方式时,请阅读[Depth Plane 实现参考](depth-plane.md)。 diff --git a/docs/guide-surface.md b/docs/guide-surface.md deleted file mode 100644 index 88bb766..0000000 --- a/docs/guide-surface.md +++ /dev/null @@ -1,46 +0,0 @@ -# Surface - -Surface 从单张图片构建带透视关系的纹理网格。它适合建筑、室内、街景和需要匹配原始观察视角的画面。 - -!!! warning - Surface 当前只支持单张图片。视频和序列帧请使用 [Depth Plane](guide-depth-plane.md)。 - -## 创建 Surface - -1. 选中只包含一帧的 Image Empty。 -2. 在 3D View 中右键,选择 **AnyImage > Convert to Surface**。 -3. 选择模型和转换选项。 -4. 确认后等待网格、材质和 Geometry Nodes 修改器创建完成。 - -## 模型与分辨率 - -默认的 **MoGe-2 ViT-S Normal** 适合多数图片。MoGe-2 使用 **Resolution Level**,DA3 使用 **Resolution Scale**。提高分辨率会增加耗时和内存占用,建议先用默认值判断整体结构。 - -如果某个模型名称后显示 **(Missing)**,请在 AnyImage 扩展偏好设置中下载它。右键菜单底部的 **AI Environment Settings…** 可以直接打开对应设置。 - -## 转换选项 - -- **Create Matching Camera**:创建与图片投影相匹配的透视相机。相机会成为 Surface 的子级,移动 Surface 时仍保持对应关系。 -- **Remove Background**:在生成网格前删除背景三角面。转换后关闭节点中的 Mask 也不会恢复这些面。 -- **Shadeless**:让贴图颜色不受场景灯光影响。 -- **Keep Original**:保留源 Image Empty。 - -## 调整网格 - -生成对象的 Geometry Nodes 修改器负责剔除深度断层、合并网格、修复拓扑、平滑和重新投影 UV。常用参数包括: - -- **Iterations**:控制重拓扑迭代次数。设为 `0` 可以跳过迭代,便于观察原始结构或提高速度。 -- **Resolution**:控制合并后的网格密度。值越大,结果越简化。 -- **Smooth**:控制表面平滑程度。 -- **Depth Continuity**:剔除深度断层处的面。提高数值会更积极地清理边缘。 -- **Confidence**:DA3 Surface 可用,用于过滤低置信度区域。 - -先调整 Culling 让主体边缘干净,再调整 Resolution 和 Smooth,通常更容易获得稳定结果。 - -## 匹配相机的使用方式 - -开启 **Create Matching Camera** 后,切换到生成的相机视图即可重新对齐原图构图。Surface 与相机存在父子关系,建议把二者作为一个整体移动或缩放。 - -## 进一步了解 - -需要查看源网格属性、重拓扑过程、相机匹配和材质法线实现时,请阅读[Surface 实现参考](surface.md)。 diff --git a/docs/guides/animation.md b/docs/guides/animation.md new file mode 100644 index 0000000..906b974 --- /dev/null +++ b/docs/guides/animation.md @@ -0,0 +1,33 @@ +# 视频与序列帧 + +AnyImage 对 Movie 与 Image Sequence 提供三类工作流:直接转换为普通 Plane、逐帧去除背景、逐帧高清放大。Depth Plane 与 Cutout 当前只支持静态图片。 + +## 支持范围 + +| 工作流 | 视频 / 序列帧 | 结果 | +| --- | --- | --- | +| Convert to Plane | 支持 | 保留源 Image 的播放设置 | +| Remove Background | 支持 | 透明 PNG 序列 | +| Upscale | 支持 | 4× PNG 序列 | +| Convert to Depth Plane | 不支持 | 仅静态图片 | +| Cutout / Image Tools | 不支持 | 仅静态图片 | + +## 处理范围 + +逐帧 Job 从 Image Empty 的图片设置读取: + +- **Image Frames**:处理帧数; +- **Offset**:源素材起始偏移; +- **Start**:结果在场景中的播放起点; +- **Cyclic**:是否循环播放。 + +显式把 Image Frames 设为 `1` 时,只处理一帧。Remove Background 与 Upscale 复用同一模型 Session 逐帧执行,内存峰值主要取决于单帧尺寸。 + +## 保存工程 + +Blender 无法把整组图片序列完整 Pack 到 `.blend`。Remove Background 与 Upscale 的动画结果会继续引用 Storage Root 中的 Job 目录。 + +!!! danger + 删除或移动仍被工程引用的 Job 目录,会导致动画序列丢失。 + +长期保存时,可以连同对应 Job 目录一起复制工程,或先把序列移动到项目素材目录,再用 Blender 的外部数据工具重新定位路径。 diff --git a/docs/clipboard-image.md b/docs/guides/clipboard-image.md similarity index 100% rename from docs/clipboard-image.md rename to docs/guides/clipboard-image.md diff --git a/docs/guides/cutout.md b/docs/guides/cutout.md new file mode 100644 index 0000000..e95c43f --- /dev/null +++ b/docs/guides/cutout.md @@ -0,0 +1,59 @@ +# Cutout + +Cutout 用套索从一张 Image Empty 中选择区域,再生成 Surface、Balloon、Depth Balloon 或 Depth Surface。它适合从图片局部制作薄片、鼓包或带透视深度的网格。 + +## 创建 + +1. 在 Object Mode 的 3D Viewport 左侧工具栏选择 **Cutout**。 +2. 沿 Image Empty 按住左键绘制;双击图片可选择完整画面。 +3. 松开左键后,从饼菜单选择 Shape。 + +绘制时按 `F` 切换反选,右键或 `Esc` 取消。Cutout 使用非零环绕填充:反向绘制的内环可以形成孔洞。 + +## 选择 Shape + +| Shape | 结果特点 | 是否需要深度模型 | +| --- | --- | --- | +| Surface | 保持平面轮廓,可增加真实厚度 | 否 | +| Balloon | 按轮廓平滑鼓起 | 否 | +| Depth Balloon | 在 Balloon 上加入图片深度高度 | 是,固定使用 MoGe-2 | +| Depth Surface | 使用相机点形成透视表面 | 是,使用 Preferences 中的 Mesh Depth Model | + +四种结果都保留源 Image Empty,并在同一位置创建新网格。它们共享选区轮廓、UV、材质和基础三角网格,因此可以从同一张图多次生成不同 Shape 进行比较。 + +无论是否启用 Refine Selection,套索超出图片的部分都不会生成网格,源图中 `Alpha <= 0.1` 的像素也始终从几何中排除。双击选择完整画面时同样只为 `Alpha > 0.1` 的区域生成网格。 + +## 工具设置 + +- **Point Spacing**:相邻网格点大约对应多少源图像素。数值越小,网格越密,轮廓和变形细节越多。 +- **Normal**:使用 MoGe-2 生成法线贴图。默认关闭。 +- **Refine Selection**:用 BEN2 把几何套索收紧到其中的前景内容。默认关闭。 + +Surface 与 Balloon 在 Normal、Refine 都关闭时完全在 Blender 中生成,不需要 AI Environment。开启任一 AI 选项,或选择 Depth Shape 时,会提交后台任务。 + +Point Spacing 先按完整源图换算基础密度;选区裁切后的长边若超过全局 **Maximum AI Input Size**,网格密度会按分析图的等比缩放比例降低。颜色贴图始终保留原裁切分辨率,不会使用限制后的临时分析图。建议先用默认 `32` 观察形状;轮廓需要更细时再逐步减小。 + +## 调整修改器 + +四个 Shape 都使用同一个 `O Image Cutout` Geometry Nodes 修改器。创建时 Shape 菜单已经设为饼菜单所选类型;切换 Shape 后,Blender 只显示该类型实际使用的输入。常用参数包括: + +- **Thickness**:Surface / Depth Surface 使用场景距离;Balloon / Depth Balloon 使用鼓起高度倍率。 +- **Cleanup**:绝对面积阈值;创建时根据 Point Spacing 和图片平面尺寸自动写入,面积小于该值的 Mesh Island 会在生成任意 Shape 前移除。 +- **Smooth Iterations**:位于外层,控制平滑次数,默认 `0`;**Smooth** 位于 Options 中,控制每次平滑的混合强度。四种 Shape 都可使用。 +- **Depth Scale**:Depth Shape 的深度强度;`0` 回到基础平面或 Balloon。 + +两个 Depth Shape 共用 **Reference Depth**;Depth Balloon 还提供 **Depth Direction**。插件自动写入的 Depth Image 与 Uniform Scale 位于折叠的 Data 面板,一般无需手动修改。 + +Balloon 的轮廓高度算法由扩展 Preferences 中的 **Balloon Profile** 控制:Poisson 更平滑,Teddy 更强调轮廓骨架。修改默认值只影响之后创建的 Shape。 + +## Refine 与 Normal + +Refine Selection 只在“套索与源图可见 Alpha 的交集”内收紧用于建网格的最终选择,不把 AI 生成的颜色写回原图。材质继续使用当前 Image Empty 的源颜色和原 Alpha。 + +Normal 关闭时使用几何法线;开启后,Surface、Balloon 与 Depth Balloon 使用适配成品网格的 Tangent Space 法线,Depth Surface 使用 Object Space 法线。Cutout 始终使用 MoGe-2;Depth 与 Normal 同时需要时共享同一次 Prediction,不会重复运行模型。 + +## 当前范围 + +Cutout 只支持静态图片。图片工具创建的透明结果和 Upscale 结果都可以继续作为输入;Cutout 始终以当前 Image Empty 的实际图片为颜色来源。 + +轮廓、深度场、节点资产与法线空间的实现见 [Cutout Tool 实现原理](../internals/operators/cutout-tool.md)。 diff --git a/docs/guides/depth-plane.md b/docs/guides/depth-plane.md new file mode 100644 index 0000000..4a22c27 --- /dev/null +++ b/docs/guides/depth-plane.md @@ -0,0 +1,42 @@ +# Depth Plane + +Depth Plane 将一张静态图片转换为带深度位移的网格,适合快速场景搭建、2.5D 合成和立体插画。 + +## 创建 + +1. 在 3D View 中选中 Image Empty。 +2. 右键选择 **AnyImage > Convert to Depth Plane**。 +3. 等待状态栏提示任务完成。 + +转换不会弹出设置对话框。深度模型和分辨率使用 **Edit > Preferences > Extensions > AnyImage > Depth & Normal** 中的默认值。 + +## 选择模型 + +### MoGe-2 + +默认推荐。MoGe-2 估算尺度一致的深度与相机信息,使用 **MoGe Resolution Level** 控制处理精度。第一次使用建议保持 `5`。 + +### DA3 + +DA3 提供另一种深度结果,使用 **DA3 Resolution Scale** 按原图长边计算处理尺寸。比例越高,通常会保留更多细节,也会增加耗时和内存占用。 + +切换模型后,如果对应文件尚未下载,先在同一设置区点击 **Download**。 + +## 调整结果 + +生成对象使用运行时创建的 `O Image Depth Plane` Geometry Nodes 修改器: + +- **Subdivide**:控制基础平面细分级别; +- **Thickness**:增加背面和侧壁,`0` 输出单面; +- **Depth Amount**:控制图片深度位移强度。 + +结果颜色来自当前 Image Empty,深度由所选模型分析。转换会以一个 Undo 步骤替换源 Empty;如果需要保留原对象,请在转换前复制一份。 + +## 当前输入范围 + +Depth Plane 当前只支持静态图片。视频与序列帧可以转换为普通 [Plane](mesh-plane.md),或使用 [Remove Background / Upscale](animation.md) 逐帧处理。 + +实现中的深度场、坐标锚定和节点流程见 [Convert to Plane 实现原理](../internals/operators/convert-to-plane.md)。 +## 分析分辨率与颜色 + +Depth 输入还受 Environment 区的 **Maximum AI Input Size** 约束,默认最大长边为 `2048 px`。超过时只会等比缩放模型分析输入;结果材质仍使用原始颜色图片,不会改用限制后的临时图。 diff --git a/docs/faq.md b/docs/guides/faq.md similarity index 71% rename from docs/faq.md rename to docs/guides/faq.md index 6aeba50..98b4d24 100644 --- a/docs/faq.md +++ b/docs/guides/faq.md @@ -9,7 +9,6 @@ AnyImage 菜单只会在选中 Image Empty 时出现。确认对象类型是 Emp 通常有以下原因: - 另一个 AnyImage 任务正在运行。 -- Surface 的输入不是单张图片。 - 当前对象不是 Image Empty。 Environment 或默认模型缺失不会让四个 AI 功能变灰;点击功能会根据状态打开 **Set Up AI Environment…** 或 **Download Required Models…**。Balloon 的 **Contour**、**Depth** 与 Depth Model 会在 AI 未就绪时单独禁用,同一位置提供安装按钮。 @@ -33,17 +32,14 @@ Environment 或默认模型缺失不会让四个 AI 功能变灰;点击功能 ## Upscale 很卡或提示内存不足 -三个高清模型都使用分块推理,单次 ONNX 调用不会把完整图片送进 provider;但完整输出数组、PNG 编码和 Blender 图片载入仍取决于最终分辨率。正式 Upscale 默认将输出长边限制为 4096 px,可在 Preferences 中调整 **Maximum Upscale Size**,最高 8192 px,并始终限制在约 32 MP。DirectML 或其他 provider 无法分配资源时,任务会提示降低该值或改用 CPU,而不会显示底层的 UTF-8 解码异常。 - -对已经高清的结果再次执行不会继续放大该结果。AnyImage 只保留原始 Image 引用,并从原图重新运行所选模型;开始和完成时会在状态栏说明。 - +三个高清模型都使用分块推理,但完整输出数组、PNG 编码和 Blender 图片载入仍取决于输入与 4× 结果尺寸。Environment 区的 **Maximum AI Input Size** 默认限制输入长边为 `2048 px`;可以降低该值,或改用 CPU。DirectML 或其他 provider 无法分配资源时,任务也会提示降低这个全局值。 ## Depth Plane 太平或深度方向反了 在 Geometry Nodes 修改器中调整 **Depth Scale**。增大绝对值会加强深度,使用负值可以反转前后关系。 -## Surface 边缘破碎或细节太多 +## Cutout Depth Surface 边缘破碎或细节太多 -先调整 **Depth Continuity** 清理断层,再提高 **Resolution** 简化网格,并用 **Smooth** 稳定表面。必要时尝试另一个模型或降低输入分辨率。 +先增大 **Point Spacing** 降低基础网格密度,再调整 **Cleanup** 清理小块,并用 **Smooth** 稳定表面。必要时降低模型分辨率。 ## 动画面片不播放 diff --git a/docs/image-tools.md b/docs/guides/image-tools.md similarity index 97% rename from docs/image-tools.md rename to docs/guides/image-tools.md index fb61e25..b4b94bb 100644 --- a/docs/image-tools.md +++ b/docs/guides/image-tools.md @@ -79,4 +79,4 @@ Perspective 只执行图片 Homography 与紧裁切,不做 Refine,也不尝 目标文件:`images/image-tools/perspective-aspect.png` -实现结构与像素规则见 [Image 工具技术实现](image-tools-technical.md)。 +实现结构与像素规则见 [Image Tool 实现原理](../internals/operators/image-tool.md)。 diff --git a/docs/guides/mesh-plane.md b/docs/guides/mesh-plane.md new file mode 100644 index 0000000..c3f3e80 --- /dev/null +++ b/docs/guides/mesh-plane.md @@ -0,0 +1,27 @@ +# Plane + +Plane 把 Image Empty 转换为带图片材质的普通网格,不运行 AI,也不生成深度估算。它适合广告牌、图片卡片、背景板,以及视频或序列贴图。 + +## 创建 + +1. 在 3D View 中选中 Image Empty。 +2. 右键选择 **AnyImage > Convert to Plane**。 +3. 在 Last Operation 中按需调整 **Subdivide**。 + +转换会立即创建结果并替换源 Empty。新对象继承原对象的 Transform、图片显示比例和 Offset;需要保留源 Empty 时,请先复制对象。 + +## Geometry Nodes + +结果使用 `O Mesh Plane` 修改器: + +- **Subdivide** 使用二分级别控制网格密度,`3` 表示短边约 `8` 段; +- **Thickness** 沿平面法线增加厚度,`0` 输出真正的单面; +- **Material** 由 AnyImage 自动连接。 + +原始 Mesh 数据只有一个四边形,细分和厚度都由修改器实时生成,可以在转换后继续调整。 + +## 剪贴板 Plane + +在 3D View 中粘贴剪贴板图片,并在 Last Operation 把 **Import As** 改为 **Plane**,也会创建同样的基础网格和 `O Mesh Plane` 修改器。该入口还可选择 **Shadeless**;Image Empty 的 Convert to Plane 入口不提供 Shadeless 选项。 + +模块与节点分工见 [Convert to Plane 实现原理](../internals/operators/convert-to-plane.md)。 diff --git a/docs/models-and-storage.md b/docs/guides/models-and-storage.md similarity index 80% rename from docs/models-and-storage.md rename to docs/guides/models-and-storage.md index 8ca5122..e927ce3 100644 --- a/docs/models-and-storage.md +++ b/docs/guides/models-and-storage.md @@ -18,9 +18,12 @@ AnyImage 把运行环境、模型和任务结果放在 Blender 之外,多个 B 较大的模型不一定适合所有图片。建议先用默认模型完成构图,再针对细节与性能需求比较其他模型。 +## 全局 AI 输入尺寸 + +Environment 区的 **Maximum AI Input Size** 控制 Upscale、Depth 与 Normal 分析输入的最大长边,默认 `2048 px`,可在 `256–8192 px` 之间调整。超过上限的输入会保持宽高比缩小;颜色贴图仍使用原始分辨率。Cutout 在完成套索裁切后再应用上限,因此整张原图很大但选区较小时不会被无谓缩小。 ## Device -扩展偏好设置中的 **Device** 控制 Depth Plane、Surface、Remove Background 和 Upscale 等正式任务。 +扩展偏好设置中的 **Device** 控制 Depth Plane、Cutout 的 AI 增强、Remove Background 和 Upscale 等正式任务。 - Windows 通常优先使用 DirectML。 - Apple Silicon macOS 通常优先使用 CoreML。 @@ -30,7 +33,7 @@ Debug 面板中的 **Debug Device** 只影响 Debug 任务,不会覆盖正式 ## Session 缓存 -连续使用同一模型和 Device 时,Server 会复用已经加载的 ONNX Session。切换到 BEN2、深度或高清等另一模型族时,旧 Session 会被释放,因此 Server 最多保留当前使用的一套模型;如果高清推理遇到 provider 资源错误,也会立即卸载该 Session。这样保留连续操作的速度,同时避免多个模型长期占用显存或系统内存。 +连续使用同一模型和 Device 时,Server 会复用已经加载的 ONNX Session。缓存分为 BEN2、几何模型和 Upscale 三个族,每个族只保留最近使用的一套;同族切换模型或 Device 时释放旧 Session。如果高清推理遇到 provider 资源错误,也会立即卸载该 Session。 ## Storage Root diff --git a/docs/guides/remove-background.md b/docs/guides/remove-background.md new file mode 100644 index 0000000..f1d4c5e --- /dev/null +++ b/docs/guides/remove-background.md @@ -0,0 +1,23 @@ +# 去除背景 + +Remove Background 使用 BEN2 为 Image Empty 生成透明前景,不创建网格。 + +## 使用 + +1. 选中 Image Empty。 +2. 在 3D View 中右键选择 **AnyImage > Remove Background**。 +3. 等待状态栏提示任务完成。 + +结果替换当前 Empty 的图片,对象 Transform、显示尺寸、Offset 和播放设置保持不变。单图结果会 Pack 到 `.blend`;视频或序列帧结果继续引用 Job 目录。 + +## Alpha 处理 + +推理前,AnyImage 会用输入 Alpha 遮住透明区域中的 RGB。结果颜色仍来自源图,只采用 BEN2 生成的前景 Alpha;源图已有透明度时,两者会相乘。因此 Image Mask 或外部软件已经清理的范围不会被重新扩张。 + +当前入口不提供 BEN2 的额外细化参数。需要手动控制范围时,可以先使用 [Image Box / Lasso](image-tools.md),也可以在结果材质中继续调整 Alpha。 + +## 图片序列 + +Remove Background 支持单图、Movie 和 Image Sequence。处理帧数、偏移和播放起点见[视频与序列帧](animation.md)。 + +任务与结果替换流程见 [Remove Background 实现原理](../internals/operators/remove-background.md)。 diff --git a/docs/guides/upscale.md b/docs/guides/upscale.md new file mode 100644 index 0000000..d058951 --- /dev/null +++ b/docs/guides/upscale.md @@ -0,0 +1,27 @@ +# 高清放大 + +Upscale 用 ONNX 模型把 Image Empty 的图片放大 4 倍,不创建网格。它支持单图、Movie 和 Image Sequence,并保留源 Alpha。 + +## 使用 + +1. 在 AnyImage 扩展偏好设置中选择 **Default Upscale Model**。 +2. 按需要设置 Environment 区的 **Maximum AI Input Size**;默认 `2048 px`,最高 `8192 px`。 +3. 选中 Image Empty,在右键 **AnyImage** 菜单中点击 **Upscale**。 + +输入长边超过全局上限时,会先保持宽高比缩小,再执行 4× 放大。因此默认设置下结果长边最多为 `8192 px`。完成后,当前 Empty 会改用名称以 `_upscale.png` 结尾的结果图片。 + +## 模型选择 + +- **Real-ESRGAN General WDN x4v3**:默认模型,速度快,细节处理较保守。 +- **SAFMN Real x4**:速度与细节较均衡。 +- **Real-ESRGAN x4plus**:生成的细节更明显。 + +后两项可在扩展偏好设置中按需下载。 + +对高清结果再次执行 Upscale 时,AnyImage 会从最初的原图重新生成并替换当前结果,不会连续放大已有高清图。 + +把高清结果继续转换为 Depth Plane 时,颜色材质可以继续使用当前高清图,分析输入则遵守全局上限;Cutout 同样只缩放裁切后的分析输入,不会把限制后的临时图用作 Color。 + +如果显存或内存不足,请降低 **Maximum AI Input Size**,或改用 CPU。 + +分块推理、Alpha 合并与原图引用见 [Upscale 实现原理](../internals/operators/upscale.md)。 diff --git a/docs/guides/workflows.md b/docs/guides/workflows.md new file mode 100644 index 0000000..d726815 --- /dev/null +++ b/docs/guides/workflows.md @@ -0,0 +1,41 @@ +# 选择工作流 + +先确定需要的是图片数据、普通平面、整图深度,还是局部网格。 + +| 工作流 | 输入 | 结果 | AI | +| --- | --- | --- | --- | +| Clipboard Image | 剪贴板静态图 | Reference、Plane、节点或笔刷纹理 | 不需要 | +| Convert to Plane | 单图、Movie、Sequence | 带材质的普通网格 | 不需要 | +| Convert to Depth Plane | 静态图 | 带深度位移的整图面片 | DA3 或 MoGe-2 | +| Cutout | 静态图局部选区 | 四种局部 Shape | 按 Shape 与选项决定 | +| Remove Background | 单图、Movie、Sequence | 透明图片或序列 | BEN2 | +| Upscale | 单图、Movie、Sequence | 4× 图片或序列 | Upscale 模型 | +| Image Mask / Perspective | 静态图 | 原位更新的裁切或矫正图 | Refine 时使用 BEN2 | + +## 普通 Plane + +只需要图片卡片、背景板或视频贴图时,选择 **Convert to Plane**。它不分析深度,Thickness 与 Subdivide 可以在 Geometry Nodes 中随时调整。 + +[阅读 Plane 指南](mesh-plane.md) + +## Depth Plane + +需要把整张静态图快速变成可调浮雕时,选择 **Convert to Depth Plane**。深度模型与分辨率在扩展 Preferences 中设置。 + +[阅读 Depth Plane 指南](depth-plane.md) + +## Cutout + +只需要图片局部,或希望按轮廓生成 Balloon / Depth Surface 时,使用工具栏中的 **Cutout**。普通 Surface 与 Balloon 可以完全在 Blender 内完成,Refine、Normal 与 Depth Shape 按需调用 AI。 + +[阅读 Cutout 指南](cutout.md) + +## 图片处理 + +只想得到透明前景,不需要网格时使用 [Remove Background](remove-background.md)。需要增加像素尺寸时使用 [Upscale](upscale.md)。两者都支持 Movie 和 Image Sequence。 + +## Image 编辑 + +Box、Lasso、Line 和 Polyline 适合直接清理 Alpha;Perspective 适合把图片中的四边形内容矫正为矩形。结果都会写回当前 Image Empty,并保持对象 Transform。 + +[阅读 Image 编辑工具](image-tools.md) diff --git a/docs/image-tools-technical.md b/docs/image-tools-technical.md deleted file mode 100644 index b593a98..0000000 --- a/docs/image-tools-technical.md +++ /dev/null @@ -1,90 +0,0 @@ -# Image 工具技术实现 - -Image 工具位于 `operators/image_edit/`。Box、Lasso、Line 与 Polyline 共享直接编辑管线;Perspective 在工具栏中独立显示并处理 Homography,但复用源图选择、绘制和 Empty 写回。 - -## 模块职责 - -```mermaid -flowchart LR - Tools["box / lasso / line / polyline\nWorkspaceTool"] --> Mask["mask.py\n手势、Refine、Invert"] - Perspective["perspective.py\n四点、比例、Homography"] - Mask --> Common["common.py\n选择、绘制、裁切、Empty 写回"] - Perspective --> Common - Mask --> Job["refine-image-mask\nBEN2 内容 mask"] -``` - -- `box.py`、`lasso.py`、`line.py`、`polyline.py` 只声明 WorkspaceTool。 -- `mask.py` 用 `EditImageMask` 统一四种手势;`RefineImageMask` 负责异步 BEN2 响应。 -- `perspective.py` 维护四点输入、比例预览和全分辨率重采样。 -- `common.py` 只放跨两条管线实际共用的逻辑。 - -## 首次点击 - -`resolve_image_edit_click()` 临时调用 Viewport Select 判断点击目标: - -| 点击目标 | 行为 | -| --- | --- | -| 另一张 Image Empty | 选择并设为 Active,本次不编辑 | -| 当前 Active 图片、空白或非图片对象 | 恢复原选择并开始编辑当前图片 | - -这避免工具在无有效源图时退化为对象移动,也让用户无需切回 Select Tool。 - -## Mask 管线 - -```mermaid -flowchart TD - Gesture["屏幕手势"] --> Polygon["源图片像素 polygon / mask"] - Polygon --> Refine{"Refine Selection?"} - Refine -- 否 --> Content["几何 mask"] - Refine -- 是 --> Server["选区外 RGBA 清零 → BEN2"] - Server --> Content2["BEN2 Alpha × 几何 mask"] - Content --> Invert{"Invert?"} - Content2 --> Invert - Invert -- 否 --> Final["final mask"] - Invert -- 是 --> Reverse["1 - final mask"] - Final --> Apply["源 Alpha × mask"] - Reverse --> Apply - Apply --> Crop["按非零 Alpha 紧裁切\n保留源 RGB"] - Crop --> Write["替换 Image 并更新 Empty 显示"] -``` - -Cutout、Box 与 Lasso 分别使用 `Scene.anyimage_settings` 上的独立 Refine 属性,工具之间不传递状态。Box / Lasso 的顺序固定为 **Refine → Invert**。BEN2 只提供内容 Alpha;最终 RGB 始终来自源图。Blender 侧只修改 Alpha,不清理透明区 RGB,以避免 Viewport 纹理过滤产生黑边。Line / Polyline 调用公共设置入口时强制关闭 Refine,只执行几何 mask 与 Invert。 - -未形成有效区域的单击直接返回 `CANCELLED`,不报告消息;最终 Alpha 全空时,Operator 报告 `WARNING` 并保持源图不变。其他数据、状态或运行时异常仍报告 `ERROR`。 - -Box、Lasso、Line 与 Polyline 的屏幕预览统一绘制白色虚线轮廓和淡灰半透明填充。简单选区使用 Blender 的凹多边形三角剖分,不使用从首点展开的 Triangle Fan;通过屏幕网格快速检测到自交后改用 2 px 扫描填充,并读取 Operator 的最终填充规则。Image Mask 使用偶奇规则,Cutout 使用与最终几何一致的非零环绕规则。Lasso 只在游标相对末点移动至少 2 px 时采点,停留产生的重复 `MOUSEMOVE` 不增加轨迹;新点与前一线段的偏差不超过 0.75 px 时直接替换末点,在线消除近似共线点。填充三角形和闭合虚线段都按当前屏幕 polygon 缓存,额外重绘不重复计算。松开鼠标后使用这条已简化的闭合 polygon 生成几何 mask。 - -Line 的屏幕坐标 Y 轴与图片坐标 Y 轴方向相反,因此像素阶段会反转线段端点,使半透明预览侧与实际保留侧一致;`Ctrl` 只影响屏幕端点的 5° 吸附。零长度点击在投影前直接取消,投影结果不足两个点时也会安全终止。Polyline 接近起点时绘制闭合圆环,并支持单击起点或 `Enter` 完成;完成时显式闭合,不把当前游标位置作为额外顶点。 - -## Perspective 管线 - -四点先按源图片 X/Y 方向规范为凸四边形,输入顺序不参与输出方向。第四点后只生成一次 512 × 512 临时纹理;移动鼠标改变 UI 比例,确认时才进行全分辨率 Homography。 - -预览引用在删除前先从 Operator 清空;若 Undo 或 Blender 已移除临时 Image,清理会忽略失效的 RNA 引用,因此切换工具和重复结束都安全。 - -透视重采样使用预乘 Alpha 插值,随后恢复 RGB、清理透明像素并紧裁切。输出最长边限制为 8192 px,总像素限制约 16 MP。 - -Perspective 不调用 BEN2,也不支持 Invert。它是 deform 工具,不属于 mask 清理语义。 - -## Empty 写回 - -`replace_empty_image_pixels()` 先创建并验证完整结果,再根据引用源 Image 的 Image Empty 数量选择提交方式:只有一个引用时,把原 Image 的用户重映射到结果、删除旧 datablock 并恢复原名,最终不留下额外图片;两个或更多引用时,只替换当前 Empty 的 `data`,其他 Empty 继续使用原图。共享图片的新结果沿用源 Image 基础名,不附加 `.mask`、`.perspective` 或其他工具后缀;同名冲突交给 Blender 自动添加 `.001`。两种方式都不复制对象,也不修改 `matrix_world`。 - -`empty_display_alignment()` 在写入像素前,根据源画布、旧 `plane_bounds`、结果尺寸和放置 bounds 计算显示参数;写回函数随后更新: - -- `empty_display_size`; -- `empty_image_offset`。 - -Box、Lasso、Line 与 Polyline 的放置 bounds 等于最终裁切范围,因此像素与原图片平面严格对齐。Perspective 的结果比例可能不同于四边形轴对齐范围,因此只保持中心和覆盖尺度近似一致。 - -## Server 边界 - -普通 Mask 和 Perspective 完全在 Blender 侧执行。只有 Box / Lasso 的 **Refine Selection** 提交 `refine-image-mask`: - -1. 根据 polygon 创建局部透明画布; -2. 清空选区外 RGBA;BEN2 公共入口再以输入 Alpha 遮住透明 RGB; -3. 使用共享的 `remove_image_background()` 运行 BEN2; -4. 再乘几何 selection mask; -5. 返回紧裁切的 `image.png` 与源图坐标 `bounds.json`。 - -Blender 只读取返回图片的 Alpha,并把它应用到原图 RGB。相同的 BEN2 输入清理规则也用于 Remove Background、Depth Plane、Surface 和 Balloon;正式输出使用源 RGB 与 BEN2 Alpha。Blender 数据只在主线程 Operator 中读写,Job 参数保持为可序列化字典。 diff --git a/docs/index.md b/docs/index.md index 3363a38..61aea4b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,20 +2,17 @@ 把图片里的空间感带进 Blender。 -AnyImage 是一款 Blender 扩展,可以把照片、视频或序列帧转换为带真实深度变化的立体面片。你只需导入图片、选中 Image Empty,然后从右键菜单选择需要的结果。 +AnyImage 是一款 Blender 扩展,可以粘贴和编辑图片、创建普通 Plane 或 Depth Plane、从局部选区生成 Cutout Shape,并在独立 AI Environment 中完成去背景与高清放大。 -- 按 **Ctrl+V**(macOS 为 **Cmd+V**)把剪贴板图片直接粘贴到当前编辑器 -- 用 **Mesh Plane** 无需 AI 地创建可调厚度和细分的图片网格 -- 用 **Depth Plane** 快速得到可调节的浮雕式面片 -- 用 **Surface** 从单张图片生成透视纹理网格 -- 用 **Remove Background** 一键获得透明前景 -- 用 **Upscale** 选择三档 ONNX 模型进行 4× 高清放大 -- 用 **Image Box / Lasso / Line / Polyline** 直接清理图片,或用 **Image Perspective** 做四点透视矫正 -- 处理视频和序列帧,生成可随时间播放的 Depth Plane -- 使用 Geometry Nodes 继续调整深度、细分、重拓扑与平滑效果 +- 按 **Ctrl+V**(macOS 为 **Cmd+V**)把剪贴板图片粘贴到当前编辑器 +- 用 **Plane** 创建可调厚度和细分的图片网格 +- 用 **Depth Plane** 把静态图片转换为可调节的立体面片 +- 用 **Cutout** 从套索选区生成 Surface、Balloon 与两种 Depth Shape +- 用 **Image Tools / Perspective** 直接清理或矫正 Image Empty +- 用 **Remove Background / Upscale** 处理单图、视频和序列帧 !!! info "运行环境与 Blender 分离" - AnyImage 的 AI 计算运行在独立环境中,不会把 ONNX Runtime 等计算依赖装进 Blender Python。首次配置完成后,日常操作都可以从 Image Empty 的右键菜单完成。 + AI 计算运行在独立 Environment 中,不会把 ONNX Runtime 等依赖安装进 Blender Python。Blender 只负责界面、任务提交和结果导入。 ## 从这里开始 @@ -23,27 +20,27 @@ AnyImage 是一款 Blender 扩展,可以把照片、视频或序列帧转换 - **安装 AnyImage** - 下载与你的系统匹配的扩展包,完成环境和默认模型配置。 + 下载与你的系统匹配的扩展包,完成 Environment 与默认模型配置。 - [阅读安装指南](installation.md) + [阅读安装指南](getting-started/installation.md) - **五分钟上手** - 从导入一张图片开始,完成第一次 Depth Plane 转换。 + 从导入一张静态图片开始,创建第一个 Depth Plane。 - [开始第一个作品](quick-start.md) + [开始第一个作品](getting-started/quick-start.md) -- **选择合适的结果** +- **选择工作流** - 了解 Depth Plane、Surface、去背景和高清放大之间的区别。 + 对比 Plane、Depth Plane、Cutout、去背景、高清放大和图片编辑。 - [查看工作流对比](workflows.md) + [查看工作流](guides/workflows.md) -- **遇到问题?** +- **了解实现** - 检查模型下载、菜单状态、动画文件和运行日志。 + 从项目主干、进程边界和功能模块了解内部实现。 - [查看常见问题](faq.md) + [阅读内部实现总览](internals/index.md) @@ -51,8 +48,7 @@ AnyImage 是一款 Blender 扩展,可以把照片、视频或序列帧转换 - Blender 4.5 或更高版本 - Windows x64、macOS Apple Silicon 或 Linux x64 -- 首次安装环境与模型时需要网络连接 -- Depth Plane、Surface、Remove Background、Upscale 与 Balloon 的 AI 增强需要独立 Environment,以及默认的 MoGe-2、BEN2 和 Real-ESRGAN General WDN x4v3 模型;Balloon 基础功能无需安装 +- 安装 Environment 与模型时需要网络连接 +- AI 功能需要对应模型;普通 Plane、基础 Image 编辑与不带 AI 选项的 Cutout Surface / Balloon 可离线使用 -!!! tip - 不确定从哪里开始时,先使用默认设置将一张人物或物体照片转换为 Depth Plane。它速度快、参数直观,也最适合熟悉 AnyImage 的基本工作方式。 +遇到安装、模型或任务问题时,查看[常见问题](guides/faq.md)。 diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md new file mode 100644 index 0000000..453e4f6 --- /dev/null +++ b/docs/internals/architecture.md @@ -0,0 +1,65 @@ +# 程序架构 + +AnyImage 由 Blender 交互层、BlendJob Runtime 和独立 Job Server 三部分组成。这个分层让 Blender Python 不需要加载 ONNX Runtime、Pillow 或模型权重,同时保证所有 Blender 数据仍在主线程创建和修改。 + +## 对应文件 + +```text +src/anyimage/ +├── __init__.py # 类型、工具、菜单与 Runtime 注册 +├── menu.py # Image Empty 上下文入口 +├── panel.py # Server 与 Debug 面板 +├── preferences.py # Storage Root、Device 与功能默认值 +├── properties.py # Scene 状态、模型状态与动态枚举 +├── runtime.py # Environment 和 JobRuntime +├── operators/ # Blender 侧业务入口 +└── server/ # 独立计算端 +``` + +## 职责边界 + +| 层次 | 负责 | 不负责 | +| --- | --- | --- | +| Blender 交互层 | UI、手势、RNA 状态、输入准备、结果导入、Undo | 在 Blender 进程加载 AI 依赖 | +| BlendJob Runtime | Environment、Server 生命周期、队列、进度、取消、Job 目录 | AnyImage 的图像与几何业务 | +| Job Server | 媒体解码、ONNX 推理、数组派生、文件输出 | 创建 Blender 对象或访问 `bpy` | + +Operator 向 Server 发送普通字典。Server 返回文件名和 Job 目录;Operator 的 `response()` 再读取这些文件,并在 Blender 主线程中创建 Image、Mesh、Material 和 Geometry Nodes Modifier。 + +## 注册与状态 + +```mermaid +flowchart TD + Register["anyimage.register()"] --> Types["注册 Preferences / PropertyGroup / Operator / Panel"] + Types --> Tools["注册 Image Tools 与 Cutout WorkspaceTool"] + Tools --> RNA["挂载 Scene 与 Image Property"] + RNA --> Menu["挂载上下文菜单和剪贴板快捷键"] + Menu --> Runtime["注册 JobRuntime"] +``` + +`__init__.py` 按依赖顺序注册,并在注销时逆序清理。扩展级设置统一由 `preferences.addon_preferences()` 读取;`runtime.py` 只消费 `configured_storage_root` 回调,不持有 Blender 偏好设置的定位逻辑。交互中的临时状态和 Debug 参数保存在 `Scene.anyimage_settings`,Upscale 的原图引用保存在 `Image.anyimage_upscale_source`。 全局 `max_ai_input_size` 也在 `preferences.py` 定义与读取,由 Operator 冻结到每次 Job 请求。 + +## 同步与异步路径 + +无需 AI 或媒体处理的功能直接在 Blender 中完成,例如 Plane、普通 Image Mask、Perspective,以及未启用 Refine/Normal 的 Surface 和 Balloon。需要模型或独立文件处理时,功能 Operator 派生自 `JobOperatorBase`: + +```mermaid +sequenceDiagram + participant User as 用户 + participant Op as Blender Operator + participant RT as JobRuntime + participant Job as Server Job + User->>Op: 执行功能 + Op->>Op: 准备文件与普通字典 + Op->>RT: 提交 Job + RT->>Job: 排队执行 + Job-->>RT: 写入产物并返回索引 + RT-->>Op: 主线程 response + Op->>Op: 导入数据并提交 Undo +``` + +这条边界也决定了模块依赖方向:`server/` 不导入 `bpy` 或 `operators/`;Blender 侧可以读取 Server 产物,但不会复用 Server 中的 Blender 无关算法来创建 RNA 数据。 + +## 共享能力 + +`operators/common/` 保存多个功能真实复用的能力:图片输入与命名、像素写回、手势状态、Mask 运算、Image Empty 坐标、材质、节点资产、法线烘焙和 Job 参数辅助。共享模块只承载跨功能概念;完整工作流仍留在各自功能包中,以便从用户入口追踪到实现。 diff --git a/docs/internals/development.md b/docs/internals/development.md new file mode 100644 index 0000000..f677388 --- /dev/null +++ b/docs/internals/development.md @@ -0,0 +1,81 @@ +# 开发与发布 + +AnyImage 的仓库环境用于测试 Blender 侧代码、Server 纯 Python 模块、Manifest 和打包逻辑。AI Runtime 使用另一套 Python 3.12 Environment,不应与仓库开发环境混为一谈。 + +## 对应文件 + +```text +. +├── pyproject.toml +├── uv.lock +├── .python-version +├── pack.py +├── sync_models.py +├── src/anyimage/blender_manifest.toml +├── tests/ +├── tools/node_assets/ +└── .github/workflows/ + ├── test.yml + ├── build.yml + └── docs.yml +``` + +## 开发环境 + +仓库固定使用 CPython 3.11,因为开发依赖 `bpy==4.5.3` 对应这一解释器版本。项目根目录执行: + +```bash +uv sync +uv run pytest +``` + +快速检查注册、Manifest 和打包时使用: + +```bash +uv run pytest tests/test_addon.py tests/test_manifest.py tests/test_pack.py -v +``` + +测试按边界组织:`test_addon.py` 以 Blender Stub 验证注册和 Operator 流程;`test_mesh.py`、`test_image.py` 等验证可脱离 Blender 的算法;`test_server.py`、`test_models.py` 和各 ONNX 测试覆盖计算端;`test_pack.py` 与 `test_manifest.py` 固定发布包结构。 + +## 扩展打包 + +```bash +uv run python pack.py +``` + +`pack.py` 为 `windows-x64`、`macos-arm64` 和 `linux-x64` 分别解析依赖 wheel,生成平台专属 Manifest,再把 `src/anyimage/` 与 wheel 写入 `dist/`: + +```text +dist/ +├── AnyImage.v.windows-x64.zip +├── AnyImage.v.macos-arm64.zip +└── AnyImage.v.linux-x64.zip +``` + +本地 `src/anyimage/wheels/` 是 wheel 解析的优先来源,用于携带尚未发布的 BlendJob 构建。依赖、wheel 或 Manifest 变化后,需要同时检查 `pyproject.toml`、`uv.lock`、`blender_manifest.toml` 和打包测试;本地 wheel 内容变化后运行 `uv lock --refresh`。 + +## 文档 + +```bash +uv run --only-group docs mkdocs serve +uv run --only-group docs mkdocs build --strict +``` + +站点导航由 `mkdocs.yml` 显式维护,因此移动页面时必须同时更新导航和相对链接。Mike 仍可用于本地版本化发布;CI 则直接构建严格模式站点并部署到 Cloudflare Worker。 + +## CI + +```mermaid +flowchart TD + Main["push main / Pull Request"] --> Test["uv run --frozen pytest"] + Tag["push v* tag"] --> Release["测试 → pack.py → Release ZIP"] + Docs["main 上文档变更"] --> Site["mkdocs --strict → Cloudflare"] +``` + +`test.yml` 在 main push、Pull Request 与手动触发时运行完整测试。`build.yml` 只响应 `v*` tag,测试通过后构建并上传三个平台包。`docs.yml` 只在文档相关路径变化或手动触发时部署站点。 + +## 许可证 + +AnyImage Extension 代码采用 GPL-3.0-or-later,完整文本见仓库 `LICENSE`。模型权重保持各自上游许可证;模型目录声明下载来源、文件校验和许可信息,技术文档不复制可能变化的制品清单。 + +Geometry Nodes 资产必须由 Blender 构建和验证,见[节点资产](node-assets.md)。 diff --git a/docs/internals/index.md b/docs/internals/index.md new file mode 100644 index 0000000..a8633fd --- /dev/null +++ b/docs/internals/index.md @@ -0,0 +1,48 @@ +# 内部实现总览 + +AnyImage 的实现围绕一条清晰边界展开:Blender 进程负责交互与数据创建,独立 Job Server 负责模型推理和文件处理。两侧通过 BlendJob 传递可序列化参数与 Job 目录中的产物,不跨进程传递 Blender RNA 数据。 + +```mermaid +flowchart TD + Input["Image Empty / 手势"] --> Operator["Blender Operator"] + Operator --> Choice{"需要独立计算?"} + Choice -- "否" --> Build["Blender 主线程构建"] + Choice -- "是" --> Runtime["BlendJob Runtime"] + Runtime --> Server["Job Server"] + Server --> Files["Job 产物"] + Files --> Build + Build --> Result["Image / Mesh / Material / Modifier"] +``` + +## 项目主干 + +总览只展示稳定边界;各功能包的具体文件在对应实现页中展开。 + +```text +. +├── src/anyimage/ +│ ├── __init__.py # Blender 注册入口 +│ ├── menu.py # Image Empty 右键菜单 +│ ├── preferences.py # 扩展级配置 +│ ├── properties.py # Scene 状态与模型枚举 +│ ├── runtime.py # BlendJob Runtime 配置 +│ ├── operators/ # 交互、任务提交与结果导入 +│ ├── server/ # 无 Blender 依赖的计算端 +│ ├── assets/ # 随扩展分发的节点资产 +│ └── wheels/ # 随扩展分发的 BlendJob wheel +├── tools/ # Blender 资产构建与验证 +├── tests/ # Python 自动化测试 +├── pack.py # 多平台扩展打包 +└── sync_models.py # ONNX 模型同步 +``` + +## 文档分层 + +- [程序架构](architecture.md):注册、配置、线程和模块边界。 +- [Job Runtime 与 Server](runtime.md):任务生命周期、媒体输入和模型 Session。 +- [Operator 实现索引](operators/index.md):按 `src/anyimage/operators/` 中的完整功能拆分实现页。 +- [Job 产物](outputs.md):文件用途和 Blender 导入关系。 +- [开发与发布](development.md):本地环境、测试、打包和 CI。 +- [节点资产](node-assets.md):Geometry Nodes 的构建、加载与验证。 + +内部实现文档描述模块之间的职责和主要数据流。函数级分支、参数校验与异常处理以源码和测试为准,避免文档重复实现细节。 diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md new file mode 100644 index 0000000..3bab46b --- /dev/null +++ b/docs/internals/node-assets.md @@ -0,0 +1,58 @@ +# 节点资产 + +AnyImage 将稳定、可复用的 Geometry Nodes 和 Shader 节点组构建到 `O_AnyImage.blend`,运行时按名称加载。Depth Plane 节点图是例外:它由 Python 在当前 `.blend` 中按需创建。 + +## 对应文件 + +```text +src/anyimage/ +├── assets/O_AnyImage.blend +└── operators/common/ + ├── node_asset.py # 资产路径、加载与 Modifier 输入 + └── node_graph.py # 共享节点图构造函数 + +tools/node_assets/ +├── build.py # 统一构建入口 +├── build_mesh_plane.py +├── build_shadeless.py +├── build_cutout.py +├── validate.py # 资产清单与基础接口 +├── validate_mesh_plane.py +└── validate_cutout.py +``` + +## 构建与加载 + +```mermaid +flowchart TD + Scripts["tools/node_assets/build_*.py"] --> Build["Blender 后台构建"] + Build --> Blend["assets/O_AnyImage.blend"] + Blend --> Loader["operators/common/node_asset.py"] + Loader --> Modifier["运行时 Node Group / Modifier"] +``` + +使用 Blender 而不是普通 Python 执行: + +```bash +BLENDER_BIN="/c/Program Files/Blender Foundation/Blender 4.5/blender.exe" + +"$BLENDER_BIN" --background --python tools/node_assets/build.py +"$BLENDER_BIN" --background --python tools/node_assets/validate.py +"$BLENDER_BIN" --background --python tools/node_assets/validate_mesh_plane.py +"$BLENDER_BIN" --background --python tools/node_assets/validate_cutout.py +``` + +`build.py` 从空 Factory 文件开始,依次构建 `O Mesh Plane`、`O Shadeless`、四个 Cutout 内部节点组与公开修改器节点组 `O Image Cutout`,再保存唯一资产文件。`O Image Cutout` 用 Menu Switch 包装四个内部组;运行时 Loader 只加载这个稳定入口,Blender 4.5 根据 Shape 菜单依赖只显示当前分支实际使用的输入。 + +## 验证范围 + +基础验证检查发布资产清单、节点树类型、Modifier 标记、接口顺序和 Fake User。专项验证继续检查: + +- Mesh Plane 的接口、细分、Thickness 和几何求值; +- `O Image Cutout` 的公开接口、Shape 菜单和四个内部组的依赖关系; +- 节点不重叠、主要连线保持从左向右; +- 局部 Group Input 未使用输出隐藏; +- Cleanup 只在四条分支之前执行,Smooth 只在菜单选择之后执行; +- Repeat Zone 数量和四种 Shape 的关键几何求值稳定。 + +节点资产接口变化时,应运行全部四个 Blender 入口和完整 Python 测试。只修改 Python、Markdown 或打包逻辑时无需重建 `.blend`。Blender 自动生成的 `.blend1` 等备份不属于发布资产。 diff --git a/docs/internals/operators/ai-setup.md b/docs/internals/operators/ai-setup.md new file mode 100644 index 0000000..3959d8e --- /dev/null +++ b/docs/internals/operators/ai-setup.md @@ -0,0 +1,36 @@ +# AI 环境管理 + +本页对应 `src/anyimage/operators/ai_setup.py`。该模块把环境安装、必需模型下载和模型缓存清理组织成 Blender 可调用的 Operator。 + +## 对应文件 + +```text +src/anyimage/ +├── operators/ai_setup.py # 环境与模型管理入口 +├── preferences.py # 环境位置和扩展级设置 +├── properties.py # 环境状态与模型目录 +├── runtime.py # BlendJob Runtime 访问入口 +└── server/ + ├── app.py # 下载 Job 注册 + ├── model_catalog.py # 模型元数据 + ├── model_download.py # 模型文件下载 + └── model_manager.py # 模型资源与 Session 缓存 +``` + +## 主流程 + +```mermaid +flowchart TD + Entry["Set Up AI Environment"] --> Status["读取环境与必需模型状态"] + Status --> Ready{"是否全部就绪?"} + Ready -- "是" --> Done["结束"] + Ready -- "否" --> Environment{"运行环境是否就绪?"} + Environment -- "否" --> Install["安装隔离 Python 环境"] + Environment -- "是" --> Download["下载缺失的必需模型"] + Install --> Download + Download --> Done +``` + +`SetupAIEnvironment` 是统一入口:首次使用时选择普通源或镜像安装隔离环境;环境已存在时,只补齐必需模型。单模型下载和必需模型批量下载都通过 Job Server 执行,因此状态、错误与进度沿用同一套 Runtime 通道。 + +`OpenAIEnvironmentSettings` 将用户带到扩展首选项。`ClearModels` 清除 Runtime 中的 `model_manager` 资源,使已加载的模型 Session 释放;它不负责删除磁盘上的模型文件。 diff --git a/docs/internals/operators/clipboard-image.md b/docs/internals/operators/clipboard-image.md new file mode 100644 index 0000000..2d1dc7b --- /dev/null +++ b/docs/internals/operators/clipboard-image.md @@ -0,0 +1,34 @@ +# 剪贴板图片 + +剪贴板功能把操作系统中的图片转换为 Packed Blender Image,再根据当前编辑器创建对象、节点或画笔纹理。它完全运行在 Blender 进程中,不使用 Job Server。 + +## 对应文件 + +```text +operators/clipboard_image/ +├── __init__.py # Operator、原生复制优先级与快捷键 +├── clipboard.py # Windows、macOS、Linux 剪贴板读取 +└── actions.py # Image 复用、Pack 与各编辑器落点 +``` + +## 处理流程 + +```mermaid +flowchart TD + Paste["Ctrl/Cmd + V"] --> Priority{"刚执行过 Blender Copy?"} + Priority -- "是" --> Native["交回原生 Paste"] + Priority -- "否" --> Read["读取系统图片"] + Read --> Image["按内容哈希复用或创建 Packed Image"] + Image --> Target{"当前编辑器"} + Target --> View["Reference / Plane"] + Target --> Node["Image Node"] + Target --> Paint["Brush Texture"] +``` + +`TrackNativeCopy` 在相同快捷键下记录系统剪贴板变化标记。如果剪贴板没有在 Blender Copy 之后变化,Paste Operator 返回 `PASS_THROUGH`,让 Blender 原生对象或节点粘贴优先执行。 + +`clipboard.py` 封装平台差异:Windows 读取 DIB/DIBV5,macOS 与 Linux 调用平台提供的图片导出命令。模块最终都返回编码后的图片字节和扩展名,后续流程不再关心来源平台。 + +`actions.py` 以 SHA-256 标记 Packed Image,相同内容可复用已有数据块。3D View 中的 Reference 和 Plane 使用鼠标位置或视图落点;Plane 复用 `convert_to_plane/mesh_plane.py` 的基础网格与 `O Mesh Plane` 资产。Node Editor 根据树类型选择对应 Image 节点,绘制模式则为当前画笔配置 Texture。 + +快捷键只注册到 3D View 与 Node Editor;编辑器内部的具体支持范围由 `target_for_context()` 决定。创建成功后图片始终 Pack 到当前 `.blend`,避免依赖临时剪贴板文件。 diff --git a/docs/internals/operators/convert-to-plane.md b/docs/internals/operators/convert-to-plane.md new file mode 100644 index 0000000..3c500d5 --- /dev/null +++ b/docs/internals/operators/convert-to-plane.md @@ -0,0 +1,58 @@ +# Plane 转换 + +Plane 转换包含两条路径:普通 Plane 在 Blender 内同步完成;Depth Plane 先通过 `prepare-geometry-data` Job 取得深度场,再回到 Blender 构建面片。两者共享图片命名、材质与基础四边形能力,但使用不同的 Geometry Nodes。 + +## 对应文件 + +```text +operators/convert_to_plane/ +├── __init__.py # 对外导出 +├── conversion.py # 两个入口 Operator 与 Depth Job +├── build.py # 颜色、深度场、材质和对象导入 +├── mesh_plane.py # 基础四边形与 O Mesh Plane 修改器 +└── nodes.py # 运行时 O Image Depth Plane 节点图 + +server/ +├── jobs/prepare_geometry_data.py +├── models/da3.py +├── models/moge2.py +└── geometry/projection.py +``` + +## 总体流程 + +```mermaid +flowchart TD + Empty["Image Empty"] --> Kind{"转换类型"} + Kind -- "Plane" --> Copy["复制 Image 与材质"] + Copy --> Quad["创建四边形 + O Mesh Plane"] + Kind -- "Depth Plane" --> Input["准备颜色与分析输入"] + Input --> Job["prepare-geometry-data"] + Job --> Field["image.0001 + field.npz"] + Field --> Anchor["按 Empty 范围锚定相机点"] + Anchor --> Depth["创建深度 Image + 材质 + 节点修改器"] + Quad --> Replace["替换源 Empty"] + Depth --> Replace +``` + +## 普通 Plane + +`ConvertToPlane` 复制当前 Image 数据,保留 Movie / Sequence 的 Image User 设置,创建与 Image Empty 显示范围一致的四边形,再加载内置 `O Mesh Plane` 节点组。原始 Mesh 始终只有一个四边形;`Subdivide`、`Thickness` 和材质赋值由修改器执行。 + +对象继承源 Empty 的 `matrix_world`。转换完成后删除源 Empty,并让新 Plane 沿用对象名称。剪贴板的 Plane 导入复用同一构建函数,因此两种入口得到一致的基础网格与修改器。 + +## Depth Plane + +`ConvertToDepthPlane` 当前只接受静态图片。深度模型和分辨率来自扩展 Preferences:MoGe-2 使用 Resolution Level,DA3 使用按原图尺寸换算并对齐到模型 Patch 的处理分辨率。 + +`GenerateDepthPlane` 把颜色输入、分析输入和模型参数提交给 `prepare-geometry-data`。Server 将模型结果统一为 `GeometryFrame`,写出包含相机点、深度、内参、有效区域和参考深度的 `field.npz`。这一步不创建网格。 `max_input_size` 同时传入 Job;Server 通过 `media/resolution.py` 在模型推理前限制分析图长边,默认 `2048`。颜色输入仍由未缩放的原图复制,受限分析图不会成为材质 Color。 + +Blender 响应阶段执行三件事: + +1. 根据 Image Empty 的平面范围、相机内参和参考深度,把相机点等比锚定到对象局部空间。 +2. 将锚定点的局部 Z 写入 Packed 浮点 `_depth.exr` Image,并创建颜色材质。 +3. 创建基础四边形和运行时 `O Image Depth Plane` 节点组,以 UV 采样深度并移动正面。 + +Depth 节点组先复用 `O Mesh Plane` 完成细分与材质,再生成背面厚度、限制正面向内穿透,最后在 Thickness 为零时删除背面和侧面。`Depth Amount` 控制位移强度;`Depth Image` 与 `Material` 是由插件填入的内部输入。 + +与 Cutout 的 Depth Surface 不同,Depth Plane 只使用锚定后的 Z 高度,不在 Geometry Nodes 中重建完整相机空间 XYZ。它强调一张图上的可编辑浮雕,而不是局部透视表面。 diff --git a/docs/internals/operators/cutout-tool.md b/docs/internals/operators/cutout-tool.md new file mode 100644 index 0000000..ab6687b --- /dev/null +++ b/docs/internals/operators/cutout-tool.md @@ -0,0 +1,80 @@ +# Cutout + +Cutout 从一条图片空间套索生成规则三角基础面,再交给统一的 `O Image Cutout` Geometry Nodes 修改器形成 Surface、Balloon、Depth Balloon 或 Depth Surface。公开节点组用 Shape 菜单路由到四个原有内部节点组;轮廓和 Mesh 始终由 Blender 主线程创建,Server 只在需要时准备 Refine、深度或法线数据。 + +## 对应文件 + +```text +operators/cutout_tool/ +├── selection_tool.py # 套索交互、设置与 Shape 饼菜单 +├── shape_conversion.py # 本地 / Job 路径选择和任务参数 +├── build_shapes.py # 裁切贴图、Mesh、材质与修改器 +├── shape_types.py # Shape 与节点组映射 +├── mask_regions.py # Alpha 外轮廓与孔洞 +├── planar_mesh.py # 规则采样与约束三角化 +├── balloon_profile.py # Poisson / Teddy profile +├── depth_calibration.py # Reference 与 Direction +└── node_assets.py # Cutout 节点资产加载 + +server/ +├── jobs/prepare_geometry_data.py # Cutout / Depth Plane 数据编排 +├── selection/ +│ ├── polygon_mask.py # Polygon 栅格化与选区裁切 +│ └── cutout_images.py # Refine 与 Cutout 局部图片 +└── geometry/normal_texture.py # 相机轴法线编码 +``` + +## 路径选择 + +```mermaid +flowchart TD + Lasso["套索 + Shape"] --> Refine{"Refine Selection?"} + Refine -- "是" --> Ben2["BEN2 × 1"] + Refine -- "否" --> Selection["套索 Selection"] + Ben2 --> Selection + Selection --> NeedMoge{"需要 Depth 或 Normal?"} + NeedMoge -- "否" --> Mesh["轮廓 → 规则点 → 约束三角化"] + NeedMoge -- "是" --> Moge["MoGe-2 × 1\\n同一 Prediction"] + Moge --> Data["Depth Field / Camera Normal"] + Data --> Mesh + Mesh --> Profile["计算 o_balloon"] + Profile --> Nodes["加载 O Image Cutout 并设置 Shape"] + Nodes --> Bake["按需烘焙最终 Tangent Normal"] +``` + +Cutout 只接受静态图片。Refine Selection 是网格生成前唯一可选的选区分析:开启时执行一次 BEN2,关闭时直接使用套索 Selection。Surface 与 Balloon 在不需要 Normal 时不运行 MoGe-2;两个 Depth Shape 和任意开启 Normal 的 Shape 都统一使用 MoGe-2。Depth 与 Normal 同时启用时只执行一次推理,同一个 Prediction 同时提供相机点、深度和图片空间法线。 + +Cutout 不使用 DA3。若全局 Depth Model 当前选择 DA3,Cutout 会回退到默认 MoGe-2;选择 MoGe-2 档位时则沿用该档位和 Resolution Level。DA3 仍可用于独立的 Depth Plane 流程。 + +## 选择与局部数据 + +Cutout 复用 `ImageMaskGesture` 的采点和屏幕预览,并复用 `operators/common/image_mask.py` 的可见选区语义:最终几何范围始终是套索、图片边界与源图 `Alpha > 0.1` 区域的交集。套索采用 NONZERO 填充规则,使反向内环可以表达孔洞。`Point Spacing` 先根据完整源图最长边换算为基础 `mesh_detail`;获得裁切 Selection 后,再按 `min(1, max_input_size / crop_long_edge)` 缩放有效 `mesh_detail`。因此只有裁切图超过全局上限时网格才会变稀,Color Image 仍使用原始裁切尺寸。 + +Job 路径将数据拆成三层:源颜色、最终选择和只供模型分析的 RGBA。写出最终选择前会再次以 `Alpha > 0.1` 与源图求交,因此未 Refine、Refine 和双击全图遵循相同边界。`bounds.json` 把可见选区的局部矩形映射回源图,Blender 据此恢复局部平面范围和 UV。未走 Job 时,`build_shapes.py` 在内存中完成相同的求交、裁切与坐标换算。 + +## 基础网格 + +最终 Alpha 先转为带包含关系的外轮廓与孔洞。`planar_mesh.py` 在内部生成近似六角排列的规则点,并用轮廓作为约束进行 Delaunay 三角化。边界按目标间距重采样,短轮廓会提高边界采样密度,内部点仍保持用户设置的间距。 + +四种 Shape 共享同一张单面基础网格、UV 和 `o_balloon` 点属性。创建阶段以目标 Point Spacing 的平方作为一个采样单元面积,再除以 Image Empty 图片平面从局部到世界的面积缩放,得到修改器局部坐标中的 Cleanup 阈值。这个值只取决于工具分辨率和图片尺寸/变换,不受碎片数量或碎面面积分布影响。Balloon Profile 由 Preferences 选择 Poisson 或 Teddy;节点组只消费计算后的属性,不再重复推导轮廓。 + +## 统一修改器与四种 Shape + +创建结果时始终添加 `O Image Cutout`,并按饼菜单选择写入 Shape。节点组先统一执行 Cleanup,再由 Menu Switch 仅求值当前 Shape 的内部节点组,最后统一执行 Smooth;因此四种 Shape 都可使用 Cleanup、Smooth 和 Smooth Iterations。各分支独有输入直接连接到对应内部组,Blender 4.5 会在修改器中隐藏当前 Shape 不使用的输入。Cleanup 直接比较 Mesh Island 总面积与创建时写入的绝对面积阈值,不再统计当前网格面的中位面积。 + +| Shape | 内部节点组 | 主要几何来源 | +| --- | --- | --- | +| Surface | `O Image Surface` | 基础薄片与可调 Thickness | +| Balloon | `O Image Balloon` | `o_balloon` 鼓起高度 | +| Depth Balloon | `O Image Depth Balloon` | Balloon profile + 相对基准面的非负深度高度 | +| Depth Surface | `O Image Depth Surface` | MoGe-2 相机点的完整投影 | + +四个内部节点组不再标记为 Modifier,也不再各自执行 Cleanup。原本只在 `O Image Depth Surface` 内的 Smooth 后处理已移到公开节点组;统一节点组的 Smooth 默认值为 `1`;外层 `Smooth Iterations` 默认值为 `0`,只有用户增加迭代次数后才执行平滑。两个 Depth 内部节点组也统一使用 `Reference Depth`,总节点组只暴露一个共用输入。 + +两个 Depth 形状把相机点 RGB 写入 Packed 浮点 `_depth.exr`,并以隐藏的 `Uniform Scale` 匹配 Image Empty 尺寸。未启用 Refine 时 Reference 使用固定值,Depth Balloon Direction 使用默认轴向;启用 Refine 后,`calibration_valid` 限制在模型有效区域与最终选择的交集,Reference 使用其深度第 95 百分位,Depth Balloon 还拟合局部基准方向。 + +Depth Balloon 保留图片平面 XY,只使用相机 Z 形成向外高度;Depth Surface 将完整 XYZ 映射到 Cutout 局部轴,因此能表达透视位移。两者都只采样深度 Image 的 RGB,Alpha 仅保存有效性和深度跳变信息。 + +## 法线空间 + +MoGe-2 在 Job 阶段输出图片空间的相机轴法线预测;这一步不依赖 Blender 网格。Depth Surface 在导入时将它重排到 Cutout 对象轴并以 Object Space 使用;Surface、Balloon 与 Depth Balloon 则先创建基础网格并完成 Geometry Nodes 求值,再根据成品网格 UV、法线和切线基底烘成 Tangent Space。也就是说,网格前产生的是模型 Prediction,最终法线贴图的空间转换仍发生在网格生成之后。 diff --git a/docs/internals/operators/debug.md b/docs/internals/operators/debug.md new file mode 100644 index 0000000..d586e7a --- /dev/null +++ b/docs/internals/operators/debug.md @@ -0,0 +1,27 @@ +# Debug Operator + +本页对应 `src/anyimage/operators/debug.py`。这些 Operator 为开发者直接验证 DA3、MoGe-2、BEN2 和 Upscale 的 Server 接口,不参与面向用户的对象创建流程。 + +## 对应文件 + +```text +src/anyimage/ +├── operators/debug.py # 调试请求与结果目录打开 +├── panel.py # Debug 面板入口 +└── server/ + ├── jobs/debug_predictions.py # 四种公开 Debug Job 的共享编排 + └── outputs/ # 调试产物写出 +``` + +## 主流程 + +```mermaid +flowchart TD + Panel["Debug 面板"] --> Operator["选择模型调试 Operator"] + Operator --> Request["从 Scene 设置组装请求"] + Request --> Job["提交对应 debug Job"] + Job --> Output["写出原始预测产物"] + Output --> Directory["打开 Job 目录"] +``` + +调试入口读取 Scene 中的输入路径、设备与模型参数,直接调用对应 Server Job。任务完成后只打开 Job 目录供检查,不把结果导入为 Blender Image、Mesh 或节点资产。因此它适合验证模型、设备和输出格式,不代表正式功能的用户流程。 diff --git a/docs/internals/operators/image-tool.md b/docs/internals/operators/image-tool.md new file mode 100644 index 0000000..e765665 --- /dev/null +++ b/docs/internals/operators/image-tool.md @@ -0,0 +1,46 @@ +# Image Tool + +`operators/image_tool/` 对应工具栏中的 Box、Lasso、Line 与 Polyline。四个 WorkspaceTool 共用一个 `EditImageMask` Operator,把屏幕手势转换为图片 Alpha;只有 Box 与 Lasso 可以用 BEN2 细化选择。 + +## 对应文件 + +```text +operators/image_tool/ +├── __init__.py # 类型清单与对外导出 +├── box.py # Box WorkspaceTool +├── lasso.py # Lasso WorkspaceTool +├── line.py # Line WorkspaceTool +├── polyline.py # Polyline WorkspaceTool +└── edit_mask.py # 本地编辑与 Refine Job 响应 +``` + +手势、坐标和像素能力由 `operators/common/` 提供,四点矫正则属于独立的 [`operators/perspective_tool/`](perspective-tool.md)。 + +## 编辑流程 + +```mermaid +flowchart TD + Tool["Box / Lasso / Line / Polyline"] --> Gesture["ImageMaskGesture"] + Gesture --> Polygon["图片像素 Polygon"] + Polygon --> Refine{"Refine Selection?"} + Refine -- "否" --> Mask["本地几何 Mask"] + Refine -- "是" --> Job["refine-image-mask"] + Job --> Mask + Mask --> Invert["可选反选"] + Invert --> Alpha["源 Alpha × 最终 Mask"] + Alpha --> Crop["紧裁切并写回 Empty"] +``` + +`EditImageMask` 只接受静态图片。未启用 Refine 时,Polygon 在 Blender 侧直接栅格化;启用后,Operator 准备单图输入并提交 Job,响应阶段读取局部 Alpha 与源图 `bounds`,再映射回完整画布。反选始终在最终 Mask 上执行。 + +Box 和 Lasso 分别保存自己的 Refine 状态。Line 与 Polyline 强制关闭 Refine,只使用几何 Mask;Line 将有向线扩展为半平面,Polyline 显式闭合用户确认的顶点。 + +## 手势与预览 + +四个工具使用相同的白色虚线轮廓和半透明填充。Lasso 按移动距离采点,并在线合并近似共线点;普通凹多边形使用三角剖分,自交路径使用 EVEN_ODD 扫描填充。预览缓存只在轨迹变化时更新。 + +首次点击通过公共选择逻辑决定是切换 Active Image Empty,还是开始编辑当前图片。这样工具不需要替代 Blender 的普通对象选择。 + +## 写回结果 + +最终像素保留源 RGB,只修改 Alpha,再按非零 Alpha 紧裁切。公共写回函数只替换当前 Empty 的 Image,并更新 `empty_display_size` 与 `empty_image_offset`,因此剩余像素继续对齐原图片平面,对象 `matrix_world` 不变。 diff --git a/docs/internals/operators/index.md b/docs/internals/operators/index.md new file mode 100644 index 0000000..ce9bc0a --- /dev/null +++ b/docs/internals/operators/index.md @@ -0,0 +1,18 @@ +# Operator 实现索引 + +`src/anyimage/operators/` 负责 Blender 侧交互、Job 请求组装和结果导入。内部实现按完整功能包或顶层业务模块拆页;只提供共享能力的 `common/` 不单独成页,其职责统一放在[程序架构](../architecture.md)中说明。 + +| Operator 路径 | 实现页 | 主要职责 | +| --- | --- | --- | +| `operators/__init__.py` | 本页 | 汇总 Blender 类型注册顺序 | +| `operators/ai_setup.py` | [AI 环境管理](ai-setup.md) | 环境安装、模型下载与缓存释放 | +| `operators/clipboard_image/` | [Clipboard Image](clipboard-image.md) | 系统剪贴板读取、粘贴与快捷键 | +| `operators/convert_to_plane/` | [Convert to Plane](convert-to-plane.md) | Mesh Plane 与 Depth Plane 创建 | +| `operators/cutout_tool/` | [Cutout Tool](cutout-tool.md) | 区域选择、轮廓生成与 Shape 构建 | +| `operators/debug.py` | [Debug Operator](debug.md) | 模型接口与原始产物验证 | +| `operators/image_tool/` | [Image Tool](image-tool.md) | Box、Lasso、Line 与 Polyline 编辑 | +| `operators/perspective_tool/` | [Perspective Tool](perspective-tool.md) | 四点透视框与透视校正 | +| `operators/remove_background.py` | [Remove Background](remove-background.md) | BEN2 去背与结果替换 | +| `operators/upscale.py` | [Upscale](upscale.md) | 4× 高清与原图引用维护 | + +`operators/__init__.py` 只聚合注册类,不承载独立业务流程。具体类的注册和逆序注销由扩展入口统一管理,整体边界见[程序架构](../architecture.md)。 diff --git a/docs/internals/operators/perspective-tool.md b/docs/internals/operators/perspective-tool.md new file mode 100644 index 0000000..5316451 --- /dev/null +++ b/docs/internals/operators/perspective-tool.md @@ -0,0 +1,32 @@ +# Perspective Tool + +`operators/perspective_tool/` 对应独立的 Image Perspective WorkspaceTool。它采集四个图片角点,把透视四边形重采样为指定宽高比的矩形,不使用 Job Server。 + +## 对应文件 + +```text +operators/perspective_tool/ +├── __init__.py # 类型清单与对外导出 +└── edit_perspective.py # 四点交互、预览、Homography 与写回 +``` + +图片选择、裁切和 Empty 写回复用 `operators/common/` 的基础能力,不与 Image Mask Operator 共享业务状态。 + +## 矫正流程 + +```mermaid +flowchart TD + Click["任意顺序的四个角点"] --> Quad["规范为凸四边形"] + Quad --> Preview["生成一次 512 × 512 预览"] + Preview --> Ratio["鼠标调整目标宽高比"] + Ratio --> Size["计算受限输出尺寸"] + Size --> Warp["全分辨率 Homography"] + Warp --> Crop["按可见 Alpha 紧裁切"] + Crop --> Write["Pack 并写回当前 Empty"] +``` + +四点输入顺序不决定输出方向;规范化阶段依据源图 X/Y 方向得到不交错的凸四边形。第四点之后只创建一次低分辨率纹理,交互阶段改变 UI 宽高比,确认时才执行全分辨率重采样。 + +Homography 使用预乘 Alpha 插值,避免透明边缘混入黑色。输出最长边限制为 8192 px,总像素限制为 16,777,216,并按 256 行分块计算,控制中间数组大小。 + +Perspective 是 deform 工具,不提供 Refine 或反选。结果比例可能不同于原四边形的轴对齐范围,因此 Empty 写回保持中心和覆盖尺度近似一致,而不是假设当前 3D View 相机等于图片拍摄相机。 diff --git a/docs/internals/operators/remove-background.md b/docs/internals/operators/remove-background.md new file mode 100644 index 0000000..dee4864 --- /dev/null +++ b/docs/internals/operators/remove-background.md @@ -0,0 +1,31 @@ +# Remove Background + +本页对应 `src/anyimage/operators/remove_background.py`。该模块负责从 Image Empty 准备媒体输入、提交 BEN2 去背任务,并用结果 Image 替换当前图像;模型推理留在 Server 进程。 + +## 对应文件 + +```text +src/anyimage/ +├── operators/ +│ ├── remove_background.py # Blender 入口、任务参数与结果替换 +│ └── common/image.py # 媒体输入准备与结果 Image 创建 +└── server/ + ├── jobs/remove_background.py # 单图与多帧任务编排 + └── models/ben2.py # BEN2 前景适配 +``` + +## 主流程 + +```mermaid +flowchart TD + Empty["Image Empty"] --> Prepare["准备图片、视频或序列输入"] + Prepare --> Operator["RunBackgroundRemoval"] + Operator --> Job["remove-background Job"] + Job --> Frames["生成 PNG 结果序列"] + Frames --> Import["创建结果 Image"] + Import --> Replace["替换 Empty.data"] +``` + +Packed 图片或内存中修改过的单图会先导出为临时输入;文件图片、视频和序列则沿用可读取的源路径。多帧输入由统一媒体层展开,结果保持连续帧命名,导入后继续沿用源 Image User 的帧起点、偏移与循环设置。 + +BEN2 推理前使用输入 Alpha 屏蔽透明区域,模型输出的前景 Alpha 再与源 Alpha 合成,RGB 保持为源图内容。响应阶段只替换当前 Empty 的 Image 数据;当旧 Image 没有其他用户和 Fake User 时,旧数据块会被清理并由结果继承名称。 diff --git a/docs/internals/operators/upscale.md b/docs/internals/operators/upscale.md new file mode 100644 index 0000000..dcffe00 --- /dev/null +++ b/docs/internals/operators/upscale.md @@ -0,0 +1,36 @@ +# Upscale + +本页对应 `src/anyimage/operators/upscale.py`。该模块读取扩展首选项中的模型与全局 AI 输入长边上限,提交 4× 高清任务,并维护高清结果到原始 Image 的引用。 + +## 对应文件 + +```text +src/anyimage/ +├── operators/ +│ ├── upscale.py # Blender 入口、任务参数与结果替换 +│ └── common/image.py # 媒体输入准备与结果 Image 创建 +└── server/ + ├── jobs/upscale.py # 单图与多帧任务编排 + ├── media/resolution.py # 共用长边限制与等比缩放 + ├── models/upscale.py # 输入约束与 Alpha 合并 + └── models/onnx_upscale.py # 分块 4× ONNX 推理 +``` + +## 主流程 + +```mermaid +flowchart TD + Empty["Image Empty"] --> Source["解析原始 Image"] + Source --> Prepare["准备媒体输入"] + Prepare --> Job["upscale-image Job"] + Job --> Limit["按全局输入长边上限等比缩放"] + Limit --> RGB["分块执行 4× RGB 推理"] + Limit --> Alpha["独立缩放 Alpha"] + RGB --> Merge["合并 RGBA 结果"] + Alpha --> Merge + Merge --> Replace["导入并替换当前 Image"] +``` + +模型以 256×256 分块处理 RGB,并带入相邻上下文以减弱块边界;Alpha 按同一受限输入尺寸单独缩放,最后合并为 RGBA。`max_input_size` 来自全局 **Maximum AI Input Size**,默认 `2048`,只约束送入模型的长边;4× 输出长边因此默认为最多 `8192`。输入尺寸不超过上限时不会发生预缩放。 + +结果以 `_upscale.png` 结尾,并通过 `Image.anyimage_upscale_source` 保存原图引用。再次对高清结果执行时,Operator 会从原图重新生成,而不是继续放大上一次输出。模型与全局输入上限在每次执行时从 Preferences 读取,再写入可序列化的隐藏属性交给 Job。Provider 资源错误会释放当前 Upscale Session,并提示降低全局输入上限或改用 CPU。 diff --git a/docs/internals/outputs.md b/docs/internals/outputs.md new file mode 100644 index 0000000..7d6064b --- /dev/null +++ b/docs/internals/outputs.md @@ -0,0 +1,72 @@ +# Job 产物 + +Job 目录是 Server 与 Blender Operator 的交换边界。Server 写入普通图片、JSON 或 NPZ;Blender 只根据 Job 返回值读取需要的文件,并创建最终数据块。 + +## 产物流向 + +```mermaid +flowchart TD + Input["媒体输入"] --> Job["Server Job"] + Job --> Color["PNG 颜色 / Alpha"] + Job --> Field["NPZ 深度场"] + Job --> Meta["JSON bounds"] + Color --> Import["Blender response()"] + Field --> Import + Meta --> Import + Import --> Data["Image / Mesh / Material"] +``` + +## 正式 Job + +| Job | 主要文件 | 消费方 | +| --- | --- | --- | +| `remove-background` | `image.0001.png` 起 | 替换 Image Empty 的颜色数据 | +| `upscale-image` | `upscale.0001.png` 起 | 创建 `_upscale.png` Image 并记录原图引用 | +| `refine-image-mask` | `selection-input.png`、`image.png`、`bounds.json` | Image Box / Lasso 读取 Alpha 与原图坐标 | +| `prepare-geometry-data`:Depth Plane | `image.0001.*`、`field.npz` | 创建颜色图、深度图、材质和 Plane | +| `prepare-geometry-data`:Cutout | `image.0001.png`、`selection.png`、`analysis.png`、`bounds.json`,以及按形状生成的 `field.npz` / `normal.png` | Blender 主线程构建局部网格与贴图 | + +`prepare-geometry-data` 不输出 Blender 网格。它只准备颜色、选区和模型数组;轮廓提取、约束三角化、Geometry Nodes Modifier 与法线烘焙都在 Blender 响应阶段完成。 + +## 深度场 + +`field.npz` 是 Depth Plane、Depth Balloon 与 Depth Surface 共用的模型中间结果: + +| 字段 | 含义 | +| --- | --- | +| `points` | 每个模型像素的相机空间 XYZ | +| `depth` | 相机深度 | +| `intrinsics` | 像素坐标下的 3×3 相机内参 | +| `valid` | 模型与数值检查后的有效区域 | +| `calibration_valid` | 用于结果定位或深度基准的区域;Cutout 时与选择相交 | +| `image_size` | 深度场宽高 | +| `reference_depth` | 校准区域中的参考深度 | + +Server 保留模型空间数据,不提前换算 Blender 单位。Depth Plane 在导入时根据 Image Empty 平面范围和相机内参锚定点场;Cutout 深度形状则计算 `Uniform Scale` 和局部坐标,再把相机点写入 Packed 浮点 Image 供 Geometry Nodes 采样。 + +## Cutout 三类图片 + +Cutout 把“显示内容”“几何范围”和“模型分析”分开: + +- `image.0001.png` 保存裁切矩形中的源颜色与原 Alpha,用作材质。 +- `selection.png` 保存最终选择 Alpha,用于轮廓与网格。 +- `analysis.png` 只保留选择内的有效 RGBA,供深度或法线模型分析。 + +`bounds.json` 把裁切矩形映射回源图像素坐标。这样 Server 可以只处理局部数据,Blender 仍能恢复局部平面范围、UV 和世界位置。 + +## Debug 产物 + +Debug Job 保留模型适配层的直接输出,不执行正式功能的 Blender 导入: + +| Job | 文件 | +| --- | --- | +| `debug-da3` | `prediction.npz` | +| `debug-moge2` | `prediction*.npz` | +| `debug-ben2` | `foreground*.png` | +| `debug-upscale` | `prediction*.png` | + +这些文件用于验证模型、Device 和适配层。正式 Job 会继续派生选择、Alpha、深度场或可导入图片,因此两者的文件结构有意不同。 + +## 单图与序列 + +正式颜色序列从 `image.0001.*` 开始,Upscale 使用独立的 `upscale.0001.png` 前缀。Remove Background 与 Upscale 的动画结果继续引用 Job 目录;单图结果通常在 Blender 导入后 Pack 到 `.blend`。不要清理仍被工程引用的序列目录。 diff --git a/docs/internals/runtime.md b/docs/internals/runtime.md new file mode 100644 index 0000000..1d3d476 --- /dev/null +++ b/docs/internals/runtime.md @@ -0,0 +1,86 @@ +# Job Runtime 与 Server + +AnyImage 使用 BlendJob 管理独立 Python Environment、Server 进程和任务队列。Blender 侧只配置 Runtime 与提交参数;`server/` 是不依赖 `bpy` 的计算包,可以在独立解释器中导入。 + +## 对应文件 + +```text +src/anyimage/ +├── runtime.py # Environment 与 JobRuntime 配置 +└── server/ + ├── app.py # Job 注册和共享资源 + ├── model_catalog.py # 模型声明 + ├── model_download.py # 下载、校验与镜像回退 + ├── model_manager.py # 模型目录与 Session 缓存 + ├── media/input.py # 图片、视频和序列输入 + ├── jobs/ # app.py 可提交的公开任务 + ├── selection/ # 选区栅格化与 Cutout 图片准备 + ├── models/ # 模型适配与 ONNX 推理 + ├── geometry/ # 深度反投影与法线纹理编码 + └── outputs/ # Debug Prediction 序列化 +``` + +## 任务生命周期 + +```mermaid +flowchart TD + Request["Operator request()"] --> Runtime["JobRuntime"] + Runtime --> Queue["单 Worker 队列"] + Queue --> Job["app.py 注册的 Job"] + Job --> Resource["ModelManager / Media"] + Resource --> Directory["jobs//"] + Directory --> Response["Operator response()"] +``` + +Runtime 在首次任务或显式启动时拉起 Server。任务在队列中串行执行,进度、取消和日志由 BlendJob 提供;AnyImage Job 在阶段边界调用 `context.check_cancelled()`,并把业务产物写入当前 Job 目录。Operator 完成响应后再清理 Blender 侧为 Packed Image 准备的临时输入。 + +`server/app.py` 是任务清单的唯一入口: + +| Job | 主要用途 | +| --- | --- | +| `download-model`、`download-required-models` | 准备模型文件 | +| `remove-background` | BEN2 透明前景 | +| `upscale-image` | 4× 图片或帧序列 | +| `refine-image-mask` | 为 Image Box / Lasso 细化选区 | +| `prepare-geometry-data` | 为 Depth Plane 或 Cutout 准备颜色、选区、深度和法线数据 | +| `debug-*` | 保存模型适配层的直接输出 | + +## 媒体输入 + +`server/media/input.py` 把单图、视频和图片目录统一为有序帧路径,并用上下文管理器负责临时帧目录的生命周期。它还支持将颜色输入与分析输入分开:例如 Upscale 结果可以作为颜色来源,同时使用它保存的原图引用进行 Depth Plane 分析。帧展开是媒体输入的内部能力,不再暴露没有业务消费者的独立 Job。 `server/media/resolution.py` 提供默认 `2048` 的共用输入长边限制;Upscale 直接限制每帧,`prepare-geometry-data` 则在 Cutout 裁切和分析输入选择完成后限制 Depth / Normal 模型输入。该临时缩放图不参与颜色产物写入。 + +当前 `prepare-geometry-data` 只接受一帧,因此 Depth Plane 与 Cutout 都是静态图工作流。Remove Background 与 Upscale 通过同一媒体入口处理单图、视频和序列帧。 + +公开 Job 不与 Operator 一一对应:Depth Plane 和 Cutout 共享 `prepare-geometry-data`,四个 Debug Job 共享 `jobs/debug_predictions.py`。Job 只负责协议和编排;可复用的输入、选区、模型与几何数据能力分别位于 `media/`、`selection/`、`models/` 和 `geometry/`,Job 之间不互相导入实现。 + +## 模型层 + +Job 只编排流程,模型适配层把不同推理 API 统一为内存对象: + +- DA3 适配为深度、置信度、相机内外参与相机点。 +- MoGe-2 适配为深度、法线、有效区域、相机内参与可选相机点。 +- BEN2 返回带 Alpha 的前景图。 +- Upscale 返回保留源 Alpha 的 4× RGBA 图。 + +`ModelManager` 管理下载目录和 ONNX Session。缓存按 BEN2、几何模型和 Upscale 三个族分开;每个族只保留最近一次使用的模型与 Device,切换同族模型时释放旧 Session。这样连续任务可以复用模型,同时避免同一类模型重复占用资源。 + +## Environment 与 Storage Root + +`runtime.py` 声明 Python 3.12 环境和固定版本的 NumPy、Pillow、PyAV、trimesh、Hugging Face Hub 与 ONNX Runtime。Windows 安装 DirectML 版本,其他平台安装标准 ONNX Runtime。首次安装后依次下载默认 MoGe-2、BEN2 和 Real-ESRGAN WDN 模型。 + +Storage Root 默认是 `~/.anyimage`: + +```text +~/.anyimage/ +├── .venv/ # 独立 Environment +├── models/ # 模型目录 +├── jobs/ # Job 产物与 job.log +├── environment.json +├── manifest.json +├── install.log +└── server.log +``` + +Storage Root 由扩展偏好设置提供给 Runtime。切换路径等同于切换整套 Environment、模型和任务数据,不会自动迁移旧目录。 + +各 Job 的稳定产物见 [Job 产物](outputs.md)。 diff --git a/docs/mesh-plane.md b/docs/mesh-plane.md deleted file mode 100644 index 6efb743..0000000 --- a/docs/mesh-plane.md +++ /dev/null @@ -1,24 +0,0 @@ -# Mesh Plane - -Mesh Plane 把 Image Empty 转换为带图片材质的普通网格平面,不运行 AI,也不生成深度置换。它适合广告牌、贴图卡片、背景板,以及只需要图片比例和透明材质的场景。 - -## 创建 Mesh Plane - -1. 在 3D View 中选中 Image Empty。 -2. 右键选择 **AnyImage > Convert to Mesh Plane**,结果会立即创建,不弹出对话框。 -3. 创建后在 **Last Operation** 中按需调整 **Shadeless** 与 **Keep Original**;重执行会保持源图片的世界位置、旋转与缩放。 - -结果会继承源对象的 Transform、图片显示尺寸、比例和 Offset。转换不需要 AI Environment 或模型。 - -## Geometry Nodes - -结果使用 `O Mesh Plane` Geometry Nodes 修改器: - -- **Thickness**:沿平面法线增加居中厚度;设为 `0` 时输出真正的单面。 -- **Subdivide**:使用二分级别控制图片短边段数,即 `2^Subdivide`;例如 `3` 会把短边分为 `8` 段。长边按图片比例增加分段,厚度方向也会按表面单元尺寸增加分层,尽量保持接近方形的网格。 - -原始 Mesh 数据始终只有一个四边形;厚度和细分由修改器实时生成,因此可以随时调整,不需要重新转换。 - -## 与 Ctrl+V Plane 一致 - -在 3D View 中用 `Ctrl+V`(macOS 为 `Cmd+V`)粘贴剪贴板图片并选择 **Plane** 时,也会创建同样的基础四边形和 `O Mesh Plane` 修改器。Redo 面板只保留 **Import As** 与 **Shadeless**;**Thickness** 和 **Subdivide** 直接在 `O Mesh Plane` 修改器中调整。 diff --git a/docs/outputs.md b/docs/outputs.md deleted file mode 100644 index 9d42cf6..0000000 --- a/docs/outputs.md +++ /dev/null @@ -1,34 +0,0 @@ -# 输出格式 - -本页记录 Job 产物的稳定用途与主要字段。日常使用只需了解 [Job 文件夹](models-and-storage.md)。 - -## 文件概览 - -| Job | 主要文件 | 内容 | -| --- | --- | --- | -| `generate-depth` | `results.npz`、深度与颜色序列 | Depth Plane 所需深度、相机和贴图 | -| `generate-surface` | `mesh.npz`、`normal.png`、颜色层 | Surface 网格、相机与材质输入 | -| `remove-background` | `image.0001.png` 起 | 带 Alpha 的图片层 | -| `upscale-image` | `upscale.0001.png` 起 | 所选高清模型在输出上限内生成的 4× 高清层,并保留源图 Alpha | -| `refine-image-mask` | `selection-input.png`、`image.png`、`bounds.json` | BEN2 细化后的局部内容 mask 与原图坐标 bounds | -| `generate-mesh` | `image.0001.png`、`selection.png`、`bounds.json`,按形状输出 `normal.png`、`depth.npz` 或 `field.npz` | 当前图片所选区域的矩形 Color、几何 mask、原图坐标 bounds,以及同尺寸的局部深度或法线结果 | -| `debug-da3` | `prediction.npz` | DA3 API 返回 | -| `debug-moge2` | `prediction*.npz` | MoGe-2 API 返回 | -| `debug-ben2` | `foreground*.png` | BEN2 API 返回 | -| `debug-upscale` | `prediction*.png` | 请求模型的 4× RGB API 返回 | - -## NPZ 字段 - -DA3 Depth 结果包含 `depth`、`conf`、`extrinsics` 与 `intrinsics`。MoGe-2 Depth 结果包含 `depth`、`normal`、`mask` 与 `intrinsics`。 - -Balloon Depth 的 `depth.npz` 包含相对 `depth_signal`、归一化拟合平面 `baseline_vector`、`valid`、`reference_depth`、取样原点与输入尺寸。Cutout Job 先按套索或 Refine 最终 Alpha 紧裁出局部矩形:`image.0001.png` 保留当前图片在矩形内的原始 RGB 与原有 Alpha,不乘选择 mask;`selection.png` 保存几何 Alpha;供 MoGe 使用的内部分析图仅在选区内保留像素,范围外 RGBA 为零。深度、normal 与 Color 使用相同的非正方形局部尺寸,Blender 根据 `bounds.json` 恢复位置和 UV。若输入是 Upscale 图片,推理直接使用高清图的所选区域。几何与 Surface 前景均以 Alpha `0.5` 为边界,小于 `0.5` 的顶点或区域会被剔除。Blender 创建并评估成品网格后,再用其 UV、平滑角法线与切线基底烘成实际使用的 tangent-space Image。 - -Surface 的 `mesh.npz` 包含 `vertices`、`faces`、`uv`、`intrinsics`、`image_size` 与 `reference_depth`,以及 Geometry Nodes 使用的网格属性。 - -Debug 文件保持模型 API 的数据形态,用于比较推理结果;正式 Job 则提供 Blender 导入所需的派生数据。三档高清模型的浮点 RGB 返回只做 `[0, 1]` 限制和 8-bit PNG 序列化,不做色调保护或额外画质处理;三个模型都使用 256×256、16 像素上下文的重叠分块,外边界以镜像像素提供模型上下文。输入含 Alpha 时,Alpha 不进入 RGB 模型,而是在相同输入限制后独立进行 4× 高质量插值并合并回 RGBA 结果。正式 `upscale-image` 还会在推理前应用输出长边与总像素上限,再把结果命名为 `upscale.0001.png` 起的序列并交给 Blender 写回 Image Empty;结果 Image 数据块始终基于原图名称使用 `_upscale.png` 后缀,重复执行时替换当前高清版本。 - -## 图片序列 - -正式图片层使用 `image.0001.*` 起始的文件名,Upscale 层单独使用 `upscale.0001.png`。导入 Blender 后,颜色、深度与法线 Image datablock 分别使用 `_color.png`、`_depth.exr` 与 `_normal.png` 后缀;重名时由 Blender 自动添加 `.001`。深度动画使用 EXR 序列以保存浮点值。源 Empty 为 Movie 或 Sequence 时,Blender 按序列载入这些文件,即使任务只输出一帧。 - -MoGe-2 Surface 的原生相机法线单独保存为 `normal.png`,Blender 以 object-space 解码。Depth Surface 同样直接保留该像素编码。Cutout、Balloon 与 Depth Balloon 不会把同一张图直接当作 tangent-space 解码,而是在 Blender 端依据各自最终评估网格的 TBN 重新生成 tangent-space 像素;材质的空间选择只负责匹配已经生成好的贴图。 diff --git a/docs/quick-start.md b/docs/quick-start.md deleted file mode 100644 index 4f474fc..0000000 --- a/docs/quick-start.md +++ /dev/null @@ -1,48 +0,0 @@ -# 五分钟上手 - -这个流程会把一张图片转换为可继续编辑的 Depth Plane。 - -## 1. 导入图片 - -在 3D View 中选择 **Add > Image > Reference**,导入一张图片。Blender 会创建一个显示方式为 Image 的 Empty。 - -建议第一次尝试使用主体清晰、前后景关系明确的图片。人物、产品、建筑立面和插画通常都比较适合。 - -## 2. 打开 AnyImage 菜单 - -保持 Image Empty 处于选中状态,在 3D View 中点击右键,选择 **AnyImage > Convert to Depth Plane**。 - -如果菜单不存在,请确认当前对象是 Image Empty;如果转换入口是灰色,请先完成[安装与首次配置](installation.md)。 - -## 3. 使用默认设置转换 - -第一次使用建议保留默认设置: - -- **Depth From**:MoGe-2 ViT-S Normal -- **Resolution Level**:5 -- **Remove Background**:关闭 -- **Shadeless**:关闭 -- **Keep Original**:关闭 - -点击确认后,状态栏会显示模型加载和处理进度。首次推理需要加载模型,之后连续处理通常会更快。 - -## 4. 查看和调整结果 - -转换完成后,原 Image Empty 会被新的网格对象替代。打开 Geometry Nodes 修改器,可以调整: - -- **Displacement**:选择直接改变几何,或改用材质置换。 -- **Subdivision**:控制面片细分程度。 -- **Background Depth**:控制透明区域或背景区域的深度。 -- **Depth Scale**:放大、减小或反转深度变化。 - -如果希望保留原 Image Empty,转换前开启 **Keep Original**。 - -## 5. 让画面不受灯光影响 - -希望颜色尽量贴近原图时,在转换对话框中开启 **Shadeless**。需要让结果参与场景光照、阴影和材质表现时,则保持关闭。 - -## 接下来可以尝试 - -- 主体需要透明背景:[转换时去除背景](remove-background.md) -- 想要更强的透视结构:[转换为 Surface](guide-surface.md) -- 输入是视频或序列帧:[制作动画 Depth Plane](animation.md) diff --git a/docs/reference/features.md b/docs/reference/features.md new file mode 100644 index 0000000..da46326 --- /dev/null +++ b/docs/reference/features.md @@ -0,0 +1,56 @@ +# 功能参考 + +本页集中列出 AnyImage 当前实现的主要入口。操作建议见[工作流选择](../guides/workflows.md),内部设计见[内部实现总览](../internals/index.md)。 + +## Blender 入口 + +| 入口 | 位置 | 输入 | +| --- | --- | --- | +| Paste Clipboard Image | 3D View / Node Editor 的 `Ctrl+V` 或 `Cmd+V` | 剪贴板静态图 | +| Convert to Plane | Image Empty 右键菜单 | 单图、Movie、Sequence | +| Convert to Depth Plane | Image Empty 右键菜单 | 静态图 | +| Remove Background | Image Empty 右键菜单 | 单图、Movie、Sequence | +| Upscale | Image Empty 右键菜单 | 单图、Movie、Sequence | +| Image Box / Lasso / Line / Polyline | 3D Viewport 工具栏 | 静态 Image Empty | +| Image Perspective | 3D Viewport 工具栏 | 静态 Image Empty | +| Cutout | 3D Viewport 工具栏 | 静态 Image Empty | + +右键菜单只在当前对象是 Image Empty 时出现。AI 功能会在 Environment 或默认模型未就绪时转入安装流程;普通 Plane、非 Refine 的 Image Tools 与基础 Cutout Surface / Balloon 不依赖 Environment。 + +## 结果类型 + +### Plane + +普通 Plane 使用内置 `O Mesh Plane` Geometry Nodes,提供 Subdivide、Thickness 和 Material。Depth Plane 使用运行时生成的 `O Image Depth Plane`,增加 Depth Amount 与内部 Depth Image。 + +### Cutout Shape + +Cutout 提供四种结果: + +| Shape | `O Image Cutout` 内部分支 | 深度 | +| --- | --- | --- | +| Surface | `O Image Surface` | 无 | +| Balloon | `O Image Balloon` | Balloon Profile | +| Depth Balloon | `O Image Depth Balloon` | MoGe-2 高度 | +| Depth Surface | `O Image Depth Surface` | MoGe-2 相机点 | + +四种 Shape 共用 `O Image Cutout` 修改器、规则三角基础面、UV 和 `o_balloon` 属性。Shape 菜单选择内部几何分支,Cleanup 在分支前执行,Smooth 在分支后执行;Point Spacing 控制网格密度,Normal 与 Refine Selection 按需启用 MoGe-2 和 BEN2。 + +### 图片结果 + +Image Mask 和 Perspective 直接更新当前 Empty 的图片并 Pack。Remove Background 输出透明颜色图或序列。Upscale 通过三档 ONNX 模型输出 4× RGBA,并保存原图引用,重复执行时从原图重新生成。 + +## 模型 + +- MoGe-2 ViT-S / ViT-B / ViT-L Normal +- DA3 Small / Base +- BEN2 Base +- Real-ESRGAN General WDN x4v3 +- SAFMN Real x4 +- Real-ESRGAN x4plus + +默认安装准备 MoGe-2 ViT-S Normal、BEN2 Base 和 Real-ESRGAN General WDN x4v3。其余模型在扩展 Preferences 中按需下载。 + +## Debug + +Server 面板提供 DA3、MoGe-2、BEN2 与 Upscale Debug Job。它们保存模型适配层的直接 Prediction,不创建正式功能对象。文件说明见 [Job 产物](../internals/outputs.md)。 diff --git a/docs/remove-background.md b/docs/remove-background.md deleted file mode 100644 index c6e82ec..0000000 --- a/docs/remove-background.md +++ /dev/null @@ -1,38 +0,0 @@ -# 去除背景 - -Remove Background 使用 BEN2 为 Image Empty 生成透明前景,不创建网格。它适合在转换前检查抠图效果,或继续把结果当作普通 Image Empty 使用。 - -## 使用方法 - -1. 选中 Image Empty。 -2. 在 3D View 中右键,选择 **AnyImage > Remove Background**。 -3. 等待状态栏提示任务完成。 - -AnyImage 会生成透明颜色图。原图只被当前 Empty 引用时,结果直接替换原图并沿用名称;原图被多个对象引用时,只为当前 Empty 创建新图,其他对象继续使用原图。对象的 Transform、显示尺寸、Offset 和其他显示设置保持不变。 - -## 在转换时去除背景 - -Depth Plane 和 Surface 的转换对话框也提供 **Remove Background**: - -- Depth Plane 会使用透明 Alpha 区分前景和背景深度。 -- Surface 会在写出网格前删除背景三角面。 -- 深度估算仍使用未经抠图的原始 RGB,避免透明区域提前影响模型判断。 - -## 图片、视频和序列帧 - -普通图片会得到一张透明 PNG。视频或序列帧会得到透明图片序列,并从源 Empty 的 Start 位置开始播放。处理范围跟随 **Image Frames** 和 **Offset**。 - -!!! note - 动画结果引用 Storage Root 中任务目录里的序列帧。不要在仍使用这些素材时删除对应的 Job 文件夹。 - -## 常见问题 - -### Remove Background 是灰色的 - -确认 Environment 已安装,且 **MoGe-2 ViT-S Normal**、**BEN2 Base** 与 **Real-ESRGAN General WDN x4v3** 都显示 Ready。正式功能会等默认必需模型全部就绪后启用。 - -### 透明边缘需要调整 - -当前正式入口不提供 BEN2 refinement 参数。可以在结果材质中继续处理 Alpha,或在外部图像软件中修整生成的颜色图。 - -所有 BEN2 任务都会先用输入 Alpha 遮住推理输入中的透明 RGB。正式输出只采用 BEN2 的 Alpha,颜色继续使用源 RGB;已有 Alpha 时,结果 Alpha 为“输入 Alpha × BEN2 Alpha”。因此 Image Box / Lasso 或外部工具创建的透明范围不会被去背景重新扩张,软边缘也会继续保留,同时避免透明黑参与 Viewport 纹理过滤。 diff --git a/docs/server.md b/docs/server.md deleted file mode 100644 index 42a3d4b..0000000 --- a/docs/server.md +++ /dev/null @@ -1,98 +0,0 @@ -# Job Server - -AnyImage 把 AI 与媒体处理放在独立 Job Server 中运行,使 Blender Python 保持轻量。安装和使用问题见[安装与首次配置](installation.md)与[常见问题](faq.md)。 - -## 架构 - -```mermaid -flowchart LR - Blender["Blender Operator"] --> Runtime["JobRuntime"] - Runtime --> Queue["FIFO Queue"] - Queue --> Job["AnyImage Job"] - Job --> Media["Media"] - Job --> Models["ModelManager"] - Job --> Geometry["Geometry"] - Job --> Outputs["Outputs"] - Outputs --> Directory["Job Directory"] - Directory --> Blender -``` - -每个 Blender 实例拥有自己的 Server 进程,连接绑定到本机随机端口和 Instance ID。多个实例可以共享 Storage Root,但各自维护进程、队列和内存中的模型 Session。 - -BlendJob 提供 Runtime、HTTP transport、队列、状态和取消协议。AnyImage 的 Operator 负责业务参数,Server Job 负责工作流编排,结果由 Operator 在 Blender 主线程中导入。 - -## Storage Root - -默认位置为 `~/.anyimage`: - -```text -~/.anyimage/ -├── .venv/ Python Environment -├── models/ ONNX 模型 -├── jobs/ 任务产物与 job.log -├── environment.json 环境声明 -├── manifest.json 已安装环境指纹 -├── install.log 安装日志 -└── server.log 带 Job ID 的任务主线与 Server 日志 -``` - -更改 Storage Root 相当于切换整套 Environment、模型和任务数据。目录说明见[模型、设备与存储](models-and-storage.md)。 - -## 模块关系 - -| 模块 | 职责 | -| --- | --- | -| `runtime.py` | Environment、Server 生命周期、任务状态与 Storage Root | -| `server/jobs/` | 按功能编排完整任务 | -| `server/media/` | 图片、视频和序列输入 | -| `server/models/` | ONNX Session 与模型 Prediction | -| `server/geometry/` | 投影、网格和 Surface 数据转换 | -| `server/outputs/` | NPZ、EXR、PNG 与序列文件 | -| `server/model_catalog.py` | Blender UI 与 Server 共用的模型声明 | - -调用方向从 Job 指向媒体、模型、几何和输出模块。模型层返回内存数据,输出层管理文件,Blender 数据始终由 Operator 的 Response 创建。 - -## Environment 与模型 - -Environment 使用 Python 3.12、ONNX Runtime、NumPy、Pillow、PyAV、trimesh、FastAPI 和 Uvicorn。Windows 使用 DirectML 或 CPU;其他平台根据支持情况使用 CoreML、OpenVINO 或 CPU。 - -Cutout 的 `generate-mesh` Job 会把套索或 BEN2 Alpha 收紧为裁切范围,同时分别写出未乘选区 Alpha 的 Color、几何 selection 与局部分析图。Cutout 和 Balloon 只在启用 Normal 时运行 MoGe-2;Depth Balloon 返回浮点深度分析,Depth Surface 返回深度场。Blender 主线程统一创建单面基础网格、计算 `o_balloon`、绑定内部深度图片并组装四个独立 Geometry Nodes;Cutout、Balloon 与 Depth Balloon 的 camera-axis normal 会在最终网格求值后烘成 tangent-space Image。 - -Depth Balloon 的基面拟合使用四轮稳健加权回归,并对细长选区中方差不足的短轴设置相对主轴的方差下限。该约束只处理病态方向,不会移除长轴上由多数有效深度点支持的趋势。Blender 会把回归平面的符号和半深度倍率转换到 Cutout 的局部 YZ 纸面坐标;Geometry Nodes 计算 `(Base Plane - 深度基值) × Depth Scale`,因此 `Depth Scale = 0` 会同时消除深度图和 Base Plane 对深度分支的影响。 - -Image Box 与 Lasso 的 `Refine Selection` 使用 `refine-image-mask` Job。它与标准 `remove-background` Job 共用单图 BEN2 函数;Server 在推理前清空 polygon 外的 RGBA,推理后再次应用几何 mask,并返回局部 Alpha 与源图 bounds。Line、Polyline 与 Perspective 不提交 Job。 - -所有 BEN2 调用都在公共模型入口用输入 Alpha 遮住透明 RGB。Remove Background、Depth Plane、Surface 与 Image Refine 的正式结果保留源 RGB,只采用 BEN2 Alpha;Cutout 的 Refine Selection 同样只消费 BEN2 Alpha。该边界既防止模型识别透明区隐藏内容,也避免把透明黑写回 Blender 后产生过滤黑边。 - -正式功能支持: - -- MoGe-2 ViT-S、ViT-B、ViT-L Normal -- DA3 Small、DA3 Base -- BEN2 Base -- Real-ESRGAN General WDN x4v3、SAFMN Real x4、Real-ESRGAN x4plus - -Upscale 使用上述三档 4× RGB 模型。正式 `upscale-image` Job 把结果写成 `upscale.0001.png` 起的序列并回写为 `_upscale.png` Image 数据块;对应 Debug Job 保留模型直通输出,便于横向比较。 - -首次安装会下载 MoGe-2 ViT-S Normal、BEN2 Base 与默认的 Real-ESRGAN General WDN x4v3。SAFMN Real x4 和 Real-ESRGAN x4plus 可在扩展偏好设置按需下载。模型保存在 `models/`,可由多个 Blender 版本共享。Server 会复用连续任务所需的同一 Session;切换模型族时释放旧 Session,推理资源异常时立即卸载当前高清 Session。 - -模型按目录声明的 Hugging Face 仓库下载,并以 OmooLab 镜像作为回退;未声明 Hugging Face 仓库的模型直接使用镜像。AnyImage 用同一个准备 Operator 封装 BlendJob 安装任务,并按状态显示 `Set Up AI Environment…` 或 `Download Required Models…`。 - -`upscale-image` 与 `debug-upscale` 共用模型推理。三个模型在模型层分块处理任意宽高输入;正式 Job 还应用输出上限、保留 Alpha 并导入 Blender。 - -## 任务生命周期 - -Server 在第一次提交 Job 或手动 Start 时启动。任务进入单 Worker FIFO Queue,同一时间执行一个推理、下载或媒体任务。 - -Job 被接受时,Runtime 创建 opaque ID 和对应目录。进度保存在 Server 内存中,Blender 定期轮询状态。取消会在下载分块或一次 ONNX 调用结束后的检查点生效。 - -`server.log` 记录带时间、Job ID 与 Job Type 的排队、开始、成功、取消和失败主线,以及 Server 自身异常。Handler 细节与完整 traceback 写入对应 `jobs//job.log`,因此可先从主线定位 Job ID,再查看单个任务。 - -Blender 正常退出或禁用扩展时会停止所属 Server;Server 也监视父进程,在异常退出后自行结束。 - -Environment 安装发生在 Server 启动前,使用 `install.log` 记录过程。安装完成后的模型下载是普通 Job,与推理共享队列、进度和取消机制。 - -## DA3 多帧限制 - -DA3 ONNX 的联合注意力显存随帧数和分辨率快速增长。Depth Plane 动画采用逐帧推理并复用 Session,使峰值显存接近单帧任务;代价是各帧不共享相机位姿与深度尺度。 - -DA3 Debug 保留联合推理,用于检查模型原始多帧行为。Server 会在预计注意力张量过大时提前报错,此时应减少帧数或 Resolution Scale。 diff --git a/docs/surface.md b/docs/surface.md deleted file mode 100644 index d4e6e59..0000000 --- a/docs/surface.md +++ /dev/null @@ -1,66 +0,0 @@ -# Surface - -Surface 从单张图片生成带透视关系的相机空间网格。它比 Depth Plane 更接近真实空间结构,适合需要匹配相机或继续编辑拓扑的场景。操作方法见[Surface 使用指南](guide-surface.md)。 - -## 数据流 - -```mermaid -flowchart LR - Image["单张 Image Empty"] --> Model["DA3 / MoGe-2"] - Model --> Builder["Surface Builder"] - Builder --> Mesh["mesh.npz"] - Mesh --> Blender["Blender 导入"] - Blender --> Nodes["Geometry Nodes 后处理"] - Nodes --> Result["Surface + Material"] - Blender --> Camera["可选匹配相机"] -``` - -模型提供深度、相机和有效区域,Surface Builder 将其转换为统一的相机空间网格。Blender 导入网格后,用 Geometry Nodes 完成清理、重拓扑、平滑和 UV 重投影。 - -## 模型数据 - -| 来源 | 用于建网格的数据 | 材质附加数据 | -| --- | --- | --- | -| DA3 | Depth、confidence、intrinsics | 原图颜色 | -| MoGe-2 | Metric depth、mask、intrinsics | 原图颜色与 normal | - -两种模型共享同一 Surface Builder 和网格文件格式。启用 Remove Background 时,输入 Alpha 与 BEN2 Alpha 相乘后的结果会在建网格阶段移除背景三角面;这是几何裁剪,转换后关闭材质 Mask 不会恢复已删除的面。 - -## 源网格 - -`mesh.npz` 保存: - -- camera-space `vertices` 与 `faces` -- 原图坐标对应的 `uv` -- `intrinsics` 与 `image_size` -- 用于整体尺度的 `reference_depth` -- Geometry Nodes 使用的深度、Mask、置信度或连续性属性 - -源网格保持模型空间信息,视觉清理集中在 Geometry Nodes 中完成。这样可以调整参数、比较结果,也可以 Apply 修改器后继续普通建模。 - -## Geometry Nodes - -DA3 使用 `O DA3 Surface`,MoGe-2 使用 `O MoGe2 Surface`。两者采用相同的处理顺序: - -1. 根据有效区域、深度连续性和可用置信度剔除不可靠面 -2. 合并和修复拓扑 -3. 清理小岛并平滑表面 -4. 从源网格重新投影 UV - -常用控制包括 Iterations、Resolution、Smooth、Depth Continuity、Confidence 和 Mask。节点资产由扩展复用,多个 Surface 不会各自复制一套节点图。 - -## 空间与相机 - -网格在 camera space 中生成,再通过源 Image Empty 的 Transform 放入场景。导入过程会匹配图片可见尺寸、宽高比、Offset 与主点位置。 - -启用 Create Matching Camera 时,AnyImage 根据模型 intrinsics 创建相机并使用相同坐标变换,使网格与相机视图保持一致。模型估计的焦距和主点决定透视关系;`reference_depth` 决定整体尺度。 - -## 材质与文件 - -颜色统一写为 `image.0001.*`,导入时沿用源 Image 名称,由 Blender 自动解决同名冲突。MoGe-2 还会输出 `normal.png`,作为 object-space normal 接入统一的 `O Image Layer`;材质会自动打开 Object Space,未提供 normal 的图片材质使用 `[0.5, 0.5, 1.0, 1.0]` 中性默认值。材质名称以源 Image datablock 为基名。 - -单图结果可以 Pack 到 `.blend`。模型原始结果与 `mesh.npz` 保留在 Job 目录中,便于调试和重新检查。 - -## 适用范围 - -Surface 面向单张图片。它生成可编辑的透视网格,但遮挡区域仍来自单视图估计,无法恢复图片中不可见的真实几何。视频和序列帧应使用 [Depth Plane](depth-plane.md)。 diff --git a/docs/upscale.md b/docs/upscale.md deleted file mode 100644 index eea91ff..0000000 --- a/docs/upscale.md +++ /dev/null @@ -1,25 +0,0 @@ -# 高清放大 - -Upscale 用 ONNX 模型把 Image Empty 的图片放大 4 倍,不创建网格。它支持单图、Movie 和 Image Sequence,并保留源 Alpha。 - -## 使用 - -1. 在 AnyImage 扩展偏好设置中选择 **Default Upscale Model**。 -2. 按需要设置 **Maximum Upscale Size**;默认 4096 px,最高 8192 px。 -3. 选中 Image Empty,在右键 **AnyImage** 菜单中点击 **Upscale**。 - -较大的输入会先按比例缩小,使输出不超过尺寸和总像素上限。完成后,当前 Empty 会改用名称以 `_upscale.png` 结尾的结果图片。 - -## 模型选择 - -- **Real-ESRGAN General WDN x4v3**:默认模型,速度快,细节处理较保守。 -- **SAFMN Real x4**:速度与细节较均衡。 -- **Real-ESRGAN x4plus**:生成的细节更明显。 - -后两项可在扩展偏好设置中按需下载。 - -对高清结果再次执行 Upscale 时,AnyImage 会从最初的原图重新生成并替换当前结果,不会连续放大已有高清图。 - -把高清结果继续转换为 Depth Plane、Surface 或 Cutout 时,颜色材质仍使用当前高清图;深度与法线推理会读取保存的放大前原图,以减少计算量并避免凭空增加不必要的几何细节。 - -如果显存或内存不足,请降低 **Maximum Upscale Size**,或改用 CPU。 diff --git a/docs/workflows.md b/docs/workflows.md deleted file mode 100644 index 291ef52..0000000 --- a/docs/workflows.md +++ /dev/null @@ -1,46 +0,0 @@ -# 选择转换方式 - -AnyImage 提供多种日常工作流。选择结果时,先考虑你需要的是普通图片平面、可调节的“深度面片”、带透视结构的“空间网格”、透明前景,还是 4× 高清图片。 - -| 工作流 | 适合场景 | 输入 | 结果 | 主要优点 | -| --- | --- | --- | --- | --- | -| Mesh Plane | 广告牌、图片卡片、背景板、不需要 AI 深度的素材 | 图片、视频、序列帧 | 带材质和 Geometry Nodes 的普通平面 | 无需 AI,Thickness 与 Subdivide 可随时调整 | -| Depth Plane | 人物、产品、插画、动态背景、快速场景搭建 | 图片、视频、序列帧 | 带深度位移的平面 | 快速、可动画、Geometry Nodes 参数直观 | -| Surface | 建筑、室内、街景、需要匹配视角的单图 | 单张图片 | 透视纹理网格 | 空间感更强,可创建匹配相机 | -| Remove Background | 抠出人物或物体,继续作为 Image Empty 使用 | 图片、视频、序列帧 | 带透明 Alpha 的颜色图 | 不创建网格,保留 Empty 的位置和显示设置 | -| Upscale | 摄影、插画、二次元等素材的 4× 高清放大 | 图片、视频、序列帧 | 4× RGB 颜色图 | 三档 ONNX 模型可选,直接替换 Empty 图片 | -| Image Box / Lasso / Line / Polyline | 用几何选区直接清理图片 | 静态图片 | 原位更新的透明裁切图 | `F` 反选,可用 BEN2 细化选区 | -| Image Perspective | 海报、屏幕、门窗等四边形内容的透视矫正裁切 | 静态图片 | 原位更新的矩形图片 | 四点任意顺序,交互调整比例 | - -## 什么时候选择 Mesh Plane - -只需要图片网格、不希望估算或使用深度时,选择 **Convert to Mesh Plane**。它保留图片比例与对象 Transform,并把 Thickness 和 Subdivide 留在 Geometry Nodes 中;Thickness 为 0 时是单面。 - -[阅读 Mesh Plane 指南](mesh-plane.md) - -## 什么时候选择 Depth Plane - -Depth Plane 把估算深度作为面片位移,特别适合需要快速获得层次感、又希望继续用少量参数控制结果的场景。它也支持视频和序列帧,是制作动态立体素材的首选。 - -[阅读 Depth Plane 指南](guide-depth-plane.md) - -## 什么时候选择 Surface - -Surface 会根据模型估算的相机和深度构建透视网格。相较 Depth Plane,它能更自然地表达近大远小和画面内部的空间关系,但目前只支持单张图片。 - -[阅读 Surface 指南](guide-surface.md) - -## 什么时候只去除背景 - -如果只想把 Image Empty 换成透明前景图,不需要任何几何结构,直接选择 **Remove Background**。对象的 Transform、显示尺寸和 Offset 会保留。 - -[阅读去除背景指南](remove-background.md) - -## 什么时候选择 Upscale - -需要增加像素尺寸而不创建网格时,选择 **Upscale**。它支持单图、Movie 和 Image Sequence;模型选择、输出限制与重复执行行为见[高清放大指南](upscale.md)。 - - -## 什么时候使用 Image Perspective - -当图片中的矩形物体因为透视变成四边形,并且希望得到一张正视矩形裁切图时,使用 3D Viewport 工具栏中的 **Image Perspective**。它只把四点当作图片裁切与 Homography 输入,不假设当前 View 的焦距与原图拍摄相机一致。四点顺序不影响输出方向;结果 Empty 保持源图 Transform,并按源图中的选区范围近似更新显示位置与尺寸。 diff --git a/mkdocs.yml b/mkdocs.yml index 5c407a9..26b8783 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,29 +10,42 @@ nav: - AnyImage: - index.md - 开始使用: - - 安装与首次配置: installation.md - - 五分钟上手: quick-start.md - - 粘贴剪贴板图片: clipboard-image.md + - 安装与首次配置: getting-started/installation.md + - 五分钟上手: getting-started/quick-start.md + - 粘贴剪贴板图片: guides/clipboard-image.md - 使用指南: - - 选择转换方式: workflows.md - - Image 编辑工具: image-tools.md - - Cutout: cutout.md - - Mesh Plane: mesh-plane.md - - Depth Plane: guide-depth-plane.md - - Surface: guide-surface.md - - 去除背景: remove-background.md - - 高清放大: upscale.md - - 视频与序列帧: animation.md - - 模型、设备与存储: models-and-storage.md - - 常见问题: faq.md - - 参考与开发: - - 功能参考: features.md - - Image 工具技术实现: image-tools-technical.md - - Depth Plane 实现: depth-plane.md - - Surface 实现: surface.md - - 输出格式: outputs.md - - Job Server: server.md - - 程序设计与开发: development.md + - 选择工作流: guides/workflows.md + - Image 编辑工具: guides/image-tools.md + - Cutout: guides/cutout.md + - Plane: guides/mesh-plane.md + - Depth Plane: guides/depth-plane.md + - 去除背景: guides/remove-background.md + - 高清放大: guides/upscale.md + - 视频与序列帧: guides/animation.md + - 模型、设备与存储: guides/models-and-storage.md + - 常见问题: guides/faq.md + - 参考: + - 功能参考: reference/features.md + - 内部实现: + - 总览: internals/index.md + - 程序架构: internals/architecture.md + - 基础设施: + - Job Runtime 与 Server: internals/runtime.md + - Job 产物: internals/outputs.md + - Operators: + - 总览: internals/operators/index.md + - AI 环境管理: internals/operators/ai-setup.md + - Clipboard Image: internals/operators/clipboard-image.md + - Convert to Plane: internals/operators/convert-to-plane.md + - Cutout Tool: internals/operators/cutout-tool.md + - Debug Operator: internals/operators/debug.md + - Image Tool: internals/operators/image-tool.md + - Perspective Tool: internals/operators/perspective-tool.md + - Remove Background: internals/operators/remove-background.md + - Upscale: internals/operators/upscale.md + - 开发维护: + - 开发与发布: internals/development.md + - 节点资产: internals/node-assets.md theme: name: material diff --git a/pack.py b/pack.py index 6bd9db6..680cf83 100644 --- a/pack.py +++ b/pack.py @@ -105,6 +105,11 @@ def _build_extension(platform, wheels): continue if "__pycache__" in source_path.parts: continue + if ( + source_path.suffix.startswith(".blend") + and source_path.suffix != ".blend" + ): + continue relative_path = source_path.relative_to(SOURCE_DIR) if relative_path.parts[0] == "wheels": continue diff --git a/pyproject.toml b/pyproject.toml index e7e2029..8eb1540 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "anyimage" version = "0.12.15" description = "Fast image-guided structure building tools for Blender" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.11,<3.12" license = "GPL-3.0-or-later" dependencies = ["blendjob==0.1.14"] diff --git a/src/anyimage/__init__.py b/src/anyimage/__init__.py index c4c4623..6895bab 100644 --- a/src/anyimage/__init__.py +++ b/src/anyimage/__init__.py @@ -1,17 +1,17 @@ import bpy -from . import clipboard_image -from .clipboard_image import CLASSES as CLIPBOARD_CLASSES +from .operators import clipboard_image +from .operators.clipboard_image import CLASSES as CLIPBOARD_CLASSES from .menu import AnyImageImageMenu, draw_image_context_menu from .operators import CLASSES as OPERATOR_CLASSES -from .operators.cutout import CutoutTool -from .operators.image_edit import ( +from .operators.cutout_tool import CutoutTool +from .operators.image_tool import ( ImageBoxTool, ImageLassoTool, ImageLineTool, - ImagePerspectiveTool, ImagePolylineTool, ) +from .operators.perspective_tool import ImagePerspectiveTool from .panel import ( Ben2RemovalDebugPanel, Da3OutputDebugPanel, diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 34f8346..a8523b9 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:18eb3d8e67a608adaacdd2a2175f8e9bc806b5b699980b0989f53d39d7f0f4a1 -size 1567146 +oid sha256:c0340770d371f8e97896cd6905a7666b0faf18be09a9c2b58192c3582c9bb37c +size 1443288 diff --git a/src/anyimage/blender_manifest.toml b/src/anyimage/blender_manifest.toml index 0e8d0b1..6bea52c 100644 --- a/src/anyimage/blender_manifest.toml +++ b/src/anyimage/blender_manifest.toml @@ -20,5 +20,6 @@ files = "Read source and clipboard images, and store environment data" paths_exclude_pattern = [ "__pycache__/", ".*", + "*.blend[0-9]*", "*.zip", ] diff --git a/src/anyimage/menu.py b/src/anyimage/menu.py index 1dff836..56543cc 100644 --- a/src/anyimage/menu.py +++ b/src/anyimage/menu.py @@ -1,6 +1,6 @@ import bpy -from .operators.image import is_image_empty +from .operators.common.image import is_image_empty class AnyImageImageMenu(bpy.types.Menu): diff --git a/src/anyimage/operators/__init__.py b/src/anyimage/operators/__init__.py index 6c3f676..6185b2a 100644 --- a/src/anyimage/operators/__init__.py +++ b/src/anyimage/operators/__init__.py @@ -1,14 +1,19 @@ -from .background import ProcessImageBackground, RemoveImageBackground -from .cutout import ActivateCutoutTool, SelectCutoutRegion -from .debug import DebugBen2, DebugDa3, DebugMoge2, DebugUpscale -from .image_edit import CLASSES as IMAGE_EDIT_CLASSES -from .mesh import ( +from .remove_background import RemoveImageBackground, RunBackgroundRemoval +from .cutout_tool import ( + ActivateCutoutTool, ConvertCutoutShape, + GenerateCutoutShape, + SelectCutoutRegion, +) +from .debug import DebugBen2, DebugDa3, DebugMoge2, DebugUpscale +from .image_tool import CLASSES as IMAGE_TOOL_CLASSES +from .convert_to_plane import ( ConvertToDepthPlane, ConvertToPlane, - GenerateMesh, + GenerateDepthPlane, ) -from .models import ( +from .perspective_tool import CLASSES as PERSPECTIVE_CLASSES +from .ai_setup import ( ClearModels, DownloadModel, DownloadRequiredModels, @@ -27,13 +32,15 @@ CLASSES = ( DebugMoge2, DebugBen2, DebugUpscale, - GenerateMesh, + GenerateDepthPlane, + GenerateCutoutShape, SelectCutoutRegion, ConvertCutoutShape, ActivateCutoutTool, - ProcessImageBackground, + RunBackgroundRemoval, ClearModels, - *IMAGE_EDIT_CLASSES, + *IMAGE_TOOL_CLASSES, + *PERSPECTIVE_CLASSES, RemoveImageBackground, UpscaleImage, ConvertToPlane, diff --git a/src/anyimage/operators/models.py b/src/anyimage/operators/ai_setup.py similarity index 98% rename from src/anyimage/operators/models.py rename to src/anyimage/operators/ai_setup.py index f548c02..2b51c75 100644 --- a/src/anyimage/operators/models.py +++ b/src/anyimage/operators/ai_setup.py @@ -1,5 +1,6 @@ import bpy +from ..preferences import ADDON_PACKAGE from ..runtime import JobOperatorBase, runtime from ..properties import ( REQUIRED_MODEL_KEYS, @@ -7,7 +8,7 @@ from ..properties import ( model_download_size, model_record, ) -from .common import ADDON_PACKAGE, HIDDEN, require_environment +from .common.operator import HIDDEN, require_environment def format_download_size(size): diff --git a/src/anyimage/clipboard_image/__init__.py b/src/anyimage/operators/clipboard_image/__init__.py similarity index 100% rename from src/anyimage/clipboard_image/__init__.py rename to src/anyimage/operators/clipboard_image/__init__.py diff --git a/src/anyimage/clipboard_image/actions.py b/src/anyimage/operators/clipboard_image/actions.py similarity index 99% rename from src/anyimage/clipboard_image/actions.py rename to src/anyimage/operators/clipboard_image/actions.py index 394c283..5ded87a 100644 --- a/src/anyimage/clipboard_image/actions.py +++ b/src/anyimage/operators/clipboard_image/actions.py @@ -145,7 +145,7 @@ def add_image_plane( if width <= 0 or height <= 0: raise PasteTargetError("The clipboard image has invalid dimensions") - from ..operators.mesh_plane import create_mesh_plane_object + from ..convert_to_plane.mesh_plane import create_mesh_plane_object half_width, half_height = _plane_half_dimensions(width, height) material = _create_image_material(image, shadeless) diff --git a/src/anyimage/clipboard_image/clipboard.py b/src/anyimage/operators/clipboard_image/clipboard.py similarity index 100% rename from src/anyimage/clipboard_image/clipboard.py rename to src/anyimage/operators/clipboard_image/clipboard.py diff --git a/src/anyimage/operators/common/__init__.py b/src/anyimage/operators/common/__init__.py new file mode 100644 index 0000000..3f87aad --- /dev/null +++ b/src/anyimage/operators/common/__init__.py @@ -0,0 +1 @@ +"""Shared Blender-side image, material, normal, and operator helpers.""" diff --git a/src/anyimage/operators/image.py b/src/anyimage/operators/common/image.py similarity index 99% rename from src/anyimage/operators/image.py rename to src/anyimage/operators/common/image.py index 17def9f..29f47a1 100644 --- a/src/anyimage/operators/image.py +++ b/src/anyimage/operators/common/image.py @@ -4,6 +4,7 @@ import tempfile from pathlib import Path import bpy + DA3_PATCH_SIZE = 14 DA3_MIN_PROCESS_RES = 252 DA3_MAX_PROCESS_RES = 1008 diff --git a/src/anyimage/operators/image_edit/common.py b/src/anyimage/operators/common/image_edit.py similarity index 99% rename from src/anyimage/operators/image_edit/common.py rename to src/anyimage/operators/common/image_edit.py index 2367ec6..d43d7d2 100644 --- a/src/anyimage/operators/image_edit/common.py +++ b/src/anyimage/operators/common/image_edit.py @@ -9,8 +9,8 @@ from ...properties import ( ai_setup_label, ai_status, ) -from ..common import production_device, require_environment, require_model -from ..image import image_base_name, is_image_empty, plane_bounds +from .operator import production_device, require_environment, require_model +from .image import image_base_name, is_image_empty, plane_bounds class ImageEditWarning(ValueError): diff --git a/src/anyimage/operators/image_edit/mask.py b/src/anyimage/operators/common/image_gesture.py similarity index 59% rename from src/anyimage/operators/image_edit/mask.py rename to src/anyimage/operators/common/image_gesture.py index 8765c3c..1de148e 100644 --- a/src/anyimage/operators/image_edit/mask.py +++ b/src/anyimage/operators/common/image_gesture.py @@ -3,42 +3,26 @@ import math import bpy -from ...properties import ( - refine_selection_property, -) -from ...runtime import JobOperatorBase -from .interaction import ( - deserialize_matrix, - drawing_in_region, - hidden_string_property, - screen_path_to_image_pixels, - serialize_matrix, -) -from ..common import HIDDEN, cleanup_temporary_input, push_undo -from ..image import ( - is_image_empty, - plane_bounds, - prepare_image_input, - remove_temporary_input, - video_total_frames, -) -from .common import ( - ImageEditWarning, +from .image import is_image_empty, plane_bounds, video_total_frames +from .image_edit import ( active_view3d_tool_id, apply_image_mask_settings, - crop_pixels_to_alpha, dashed_line_segments, draw_dashed_line, draw_image_mask_operator_settings, draw_polygon_fill, image_edit_poll, - image_mask_refine_request, polygon_bounds, preview_fill_triangles, - replace_empty_image_pixels, report_image_edit_exception, resolve_image_edit_click, ) +from .image_interaction import ( + deserialize_matrix, + drawing_in_region, + screen_path_to_image_pixels, + serialize_matrix, +) GESTURE_TOOL_IDS = { "BOX": "anyimage.image_box", @@ -88,145 +72,6 @@ def append_lasso_point(path, point): path.append(point) return True - -def points_inside_polygon(points, polygon): - import numpy as np - - points = np.asarray(points, dtype=np.float64) - polygon = np.asarray(polygon, dtype=np.float64) - inside = np.zeros(len(points), dtype=bool) - previous = polygon[-1] - for current in polygon: - crosses = (current[1] > points[:, 1]) != (previous[1] > points[:, 1]) - edge_x = (previous[0] - current[0]) * (points[:, 1] - current[1]) / ( - previous[1] - current[1] + 1e-300 - ) + current[0] - inside ^= crosses & (points[:, 0] < edge_x) - previous = current - return inside - - -def polygon_mask(image_size, polygon, fill_rule="EVEN_ODD"): - import numpy as np - - width, height = (int(value) for value in image_size) - if width <= 0 or height <= 0: - raise ValueError("The source image has no readable dimensions") - if fill_rule not in {"EVEN_ODD", "NONZERO"}: - raise ValueError(f"Unknown polygon fill rule: {fill_rule}") - left, top, right, bottom = polygon_bounds(polygon) - left = max(left, 0) - top = max(top, 0) - right = min(right, width) - bottom = min(bottom, height) - mask = np.zeros((height, width), dtype=bool) - if right <= left or bottom <= top: - return mask - - points = np.asarray(polygon, dtype=np.float64) - x = np.arange(left, right, dtype=np.float64) + 0.5 - for row_start in range(top, bottom, 128): - row_end = min(row_start + 128, bottom) - y = np.arange(row_start, row_end, dtype=np.float64) + 0.5 - state = np.zeros( - (len(y), len(x)), - dtype=bool if fill_rule == "EVEN_ODD" else np.int16, - ) - previous = points[-1] - for current in points: - crosses = (current[1] > y) != (previous[1] > y) - edge_x = (previous[0] - current[0]) * (y - current[1]) / ( - previous[1] - current[1] + 1e-300 - ) + current[0] - selected = crosses[:, None] & (x[None, :] < edge_x[:, None]) - if fill_rule == "EVEN_ODD": - state ^= selected - else: - winding = 1 if current[1] > previous[1] else -1 - state += selected * winding - previous = current - mask[row_start:row_end, left:right] = state != 0 - return mask - -def refined_mask_from_pixels(mask_pixels, mask_size, bounds, source_size): - import numpy as np - - mask_width, mask_height = (int(value) for value in mask_size) - source_width, source_height = (int(value) for value in source_size) - local = np.flipud( - np.asarray(mask_pixels, dtype=np.float32).reshape((mask_height, mask_width, 4)) - )[:, :, 3] - result = np.zeros((source_height, source_width), dtype=np.float32) - left, top, right, bottom = (int(value) for value in bounds) - source_left = max(left, 0) - source_top = max(top, 0) - source_right = min(right, source_width) - source_bottom = min(bottom, source_height) - if source_right <= source_left or source_bottom <= source_top: - return result - result[source_top:source_bottom, source_left:source_right] = local[ - source_top - top : source_bottom - top, - source_left - left : source_right - left, - ] - return result - - -def edit_image_mask_pixels(source_pixels, source_size, selection_mask, invert=False): - """Apply a mask while preserving source RGB, then tightly crop the canvas.""" - import numpy as np - - width, height = (int(value) for value in source_size) - rgba = np.flipud( - np.asarray(source_pixels, dtype=np.float32).reshape((height, width, 4)) - ) - mask = np.asarray(selection_mask, dtype=np.float32) - if mask.shape != (height, width): - raise ValueError("The refined selection does not match the source image") - mask = np.clip(mask, 0.0, 1.0) - if invert: - mask = 1.0 - mask - rgba[:, :, 3] *= mask - return crop_pixels_to_alpha(np.flipud(rgba).ravel(), (0, 0, width, height)) - - -def edit_image_object(source_object, selection_mask, invert=False): - import numpy as np - - image = source_object.data - source_size = tuple(image.size) - source_pixels = np.empty(len(image.pixels), dtype=np.float32) - image.pixels.foreach_get(source_pixels) - pixels, crop_bounds = edit_image_mask_pixels( - source_pixels, source_size, selection_mask, invert - ) - left, top, right, bottom = crop_bounds - return replace_empty_image_pixels( - source_object, - pixels, - (right - left, bottom - top), - crop_bounds, - ) - - -def selected_image_rgba( - source_object, polygon, invert=False, fill_rule="EVEN_ODD" -): - import numpy as np - - image = source_object.data - width, height = (int(value) for value in image.size) - pixels = np.empty(len(image.pixels), dtype=np.float32) - image.pixels.foreach_get(pixels) - rgba = np.flipud(pixels.reshape((height, width, 4))) - selection = polygon_mask((width, height), polygon, fill_rule) - if invert: - selection = ~selection - rgba[:, :, 3] *= selection - if not np.any(rgba[:, :, 3] > 0.0): - raise ImageEditWarning("The selection contains no visible image pixels") - return rgba - - def gesture_property(): return bpy.props.EnumProperty( items=( @@ -573,122 +418,3 @@ class ImageMaskGesture: report_image_edit_exception(self, error) self._finish(context) return {"CANCELLED"} - - def execute(self, context): - source_object = bpy.data.objects.get(self.source_object_name) - if not is_image_empty(source_object): - self.report({"ERROR"}, "The source Image Empty is no longer available") - return {"CANCELLED"} - try: - polygon = json.loads(self.selection_data) - polygon_bounds(polygon) - if video_total_frames(source_object.data) > 1: - raise RuntimeError("Image tools only support a still image") - if self.refine_selection: - source_path, delete_input = prepare_image_input( - source_object.data, context.scene - ) - result = bpy.ops.anyimage.refine_image_mask( - input_path=str(source_path), - delete_input=delete_input, - source_object_name=self.source_object_name, - selection_data=self.selection_data, - invert=self.invert, - ) - if "CANCELLED" in result: - if delete_input: - remove_temporary_input(source_path) - return {"CANCELLED"} - return {"FINISHED"} - edit_image_object( - source_object, - polygon_mask(tuple(source_object.data.size), polygon), - self.invert, - ) - except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error: - report_image_edit_exception(self, error) - return {"CANCELLED"} - return {"FINISHED"} - - -class EditImageMask(ImageMaskGesture, bpy.types.Operator): - bl_idname = "anyimage.edit_image_mask" - bl_label = "Edit Image Mask" - bl_description = "Edit and tightly crop an Image Empty with a gesture" - bl_options = {"REGISTER", "UNDO"} - - source_object_name: hidden_string_property() - source_matrix_data: hidden_string_property() - selection_data: hidden_string_property() - refine_selection: refine_selection_property() - invert: bpy.props.BoolProperty( - name="Invert", - description="Keep the opposite side of the refined gesture mask", - default=False, - options={"SKIP_SAVE"}, - ) - gesture: gesture_property() - - -class RefineImageMask(JobOperatorBase, bpy.types.Operator): - bl_idname = "anyimage.refine_image_mask" - bl_label = "Refine Image Mask" - job_type = "refine-image-mask" - starting_message = "Refining image selection" - - input_path: bpy.props.StringProperty(options=HIDDEN) - delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN) - source_object_name: hidden_string_property() - selection_data: hidden_string_property() - invert: bpy.props.BoolProperty(default=False, options=HIDDEN) - - def request(self, _context): - return image_mask_refine_request(self.input_path, self.selection_data) - - def response(self, _context, result): - source_object = bpy.data.objects.get(self.source_object_name) - if not is_image_empty(source_object): - raise RuntimeError("The source Image Empty is no longer available") - import numpy as np - - bounds = tuple(json.loads(result.file("bounds").read_text(encoding="utf-8"))) - refined_image = bpy.data.images.load( - str(result.file("image")), check_existing=False - ) - try: - refined_pixels = np.empty(len(refined_image.pixels), dtype=np.float32) - refined_image.pixels.foreach_get(refined_pixels) - mask = refined_mask_from_pixels( - refined_pixels, - tuple(refined_image.size), - bounds, - tuple(source_object.data.size), - ) - finally: - bpy.data.images.remove(refined_image, do_unlink=True) - try: - edit_image_object(source_object, mask, self.invert) - except ImageEditWarning as error: - self.report({"WARNING"}, str(error)) - return str(error) - push_undo("Edit Image Mask") - return "Image selection refined" - - def cleanup(self): - cleanup_temporary_input(self.input_path, self.delete_input) - - -class ActivateImageEditTool(bpy.types.Operator): - bl_idname = "anyimage.activate_image_edit_tool" - bl_label = "Image Edit Tool" - bl_description = "Activate the Image Box tool in the 3D Viewport" - bl_options = {"INTERNAL"} - - @classmethod - def poll(cls, context): - return image_edit_poll(context) - - def execute(self, _context): - return bpy.ops.wm.tool_set_by_id( - name="anyimage.image_box", space_type="VIEW_3D" - ) diff --git a/src/anyimage/operators/image_edit/interaction.py b/src/anyimage/operators/common/image_interaction.py similarity index 100% rename from src/anyimage/operators/image_edit/interaction.py rename to src/anyimage/operators/common/image_interaction.py diff --git a/src/anyimage/operators/common/image_mask.py b/src/anyimage/operators/common/image_mask.py new file mode 100644 index 0000000..828d68b --- /dev/null +++ b/src/anyimage/operators/common/image_mask.py @@ -0,0 +1,141 @@ +from .image_edit import ( + ImageEditWarning, + crop_pixels_to_alpha, + polygon_bounds, + replace_empty_image_pixels, +) + +def polygon_mask(image_size, polygon, fill_rule="EVEN_ODD"): + import numpy as np + + width, height = (int(value) for value in image_size) + if width <= 0 or height <= 0: + raise ValueError("The source image has no readable dimensions") + if fill_rule not in {"EVEN_ODD", "NONZERO"}: + raise ValueError(f"Unknown polygon fill rule: {fill_rule}") + left, top, right, bottom = polygon_bounds(polygon) + left = max(left, 0) + top = max(top, 0) + right = min(right, width) + bottom = min(bottom, height) + mask = np.zeros((height, width), dtype=bool) + if right <= left or bottom <= top: + return mask + + points = np.asarray(polygon, dtype=np.float64) + x = np.arange(left, right, dtype=np.float64) + 0.5 + for row_start in range(top, bottom, 128): + row_end = min(row_start + 128, bottom) + y = np.arange(row_start, row_end, dtype=np.float64) + 0.5 + state = np.zeros( + (len(y), len(x)), + dtype=bool if fill_rule == "EVEN_ODD" else np.int16, + ) + previous = points[-1] + for current in points: + crosses = (current[1] > y) != (previous[1] > y) + edge_x = (previous[0] - current[0]) * (y - current[1]) / ( + previous[1] - current[1] + 1e-300 + ) + current[0] + selected = crosses[:, None] & (x[None, :] < edge_x[:, None]) + if fill_rule == "EVEN_ODD": + state ^= selected + else: + winding = 1 if current[1] > previous[1] else -1 + state += selected * winding + previous = current + mask[row_start:row_end, left:right] = state != 0 + return mask + +def refined_mask_from_pixels(mask_pixels, mask_size, bounds, source_size): + import numpy as np + + mask_width, mask_height = (int(value) for value in mask_size) + source_width, source_height = (int(value) for value in source_size) + local = np.flipud( + np.asarray(mask_pixels, dtype=np.float32).reshape((mask_height, mask_width, 4)) + )[:, :, 3] + result = np.zeros((source_height, source_width), dtype=np.float32) + left, top, right, bottom = (int(value) for value in bounds) + source_left = max(left, 0) + source_top = max(top, 0) + source_right = min(right, source_width) + source_bottom = min(bottom, source_height) + if source_right <= source_left or source_bottom <= source_top: + return result + result[source_top:source_bottom, source_left:source_right] = local[ + source_top - top : source_bottom - top, + source_left - left : source_right - left, + ] + return result + + +def visible_selection_mask( + source_alpha, selection_mask, invert=False, alpha_threshold=0.0 +): + """Restrict a selection to source pixels above an Alpha threshold.""" + import numpy as np + + alpha = np.asarray(source_alpha) + mask = np.asarray(selection_mask, dtype=np.float32) + if mask.shape != alpha.shape: + raise ValueError("The selection does not match the source image") + mask = np.clip(mask, 0.0, 1.0) + if invert: + mask = 1.0 - mask + return np.where(alpha > float(alpha_threshold), mask, 0.0) + + +def edit_image_mask_pixels(source_pixels, source_size, selection_mask, invert=False): + """Apply a mask while preserving source RGB, then tightly crop the canvas.""" + import numpy as np + + width, height = (int(value) for value in source_size) + rgba = np.flipud( + np.asarray(source_pixels, dtype=np.float32).reshape((height, width, 4)) + ) + mask = visible_selection_mask(rgba[:, :, 3], selection_mask, invert) + rgba[:, :, 3] *= mask + return crop_pixels_to_alpha(np.flipud(rgba).ravel(), (0, 0, width, height)) + + +def edit_image_object(source_object, selection_mask, invert=False): + import numpy as np + + image = source_object.data + source_size = tuple(image.size) + source_pixels = np.empty(len(image.pixels), dtype=np.float32) + image.pixels.foreach_get(source_pixels) + pixels, crop_bounds = edit_image_mask_pixels( + source_pixels, source_size, selection_mask, invert + ) + left, top, right, bottom = crop_bounds + return replace_empty_image_pixels( + source_object, + pixels, + (right - left, bottom - top), + crop_bounds, + ) + + +def selected_image_rgba( + source_object, polygon, invert=False, fill_rule="EVEN_ODD" +): + import numpy as np + + image = source_object.data + width, height = (int(value) for value in image.size) + pixels = np.empty(len(image.pixels), dtype=np.float32) + image.pixels.foreach_get(pixels) + rgba = np.flipud(pixels.reshape((height, width, 4))) + selection = visible_selection_mask( + rgba[:, :, 3], + polygon_mask((width, height), polygon, fill_rule), + invert, + ) + rgba[:, :, 3] *= selection + if not np.any(rgba[:, :, 3] > 0.0): + raise ImageEditWarning("The selection contains no visible image pixels") + return rgba + + diff --git a/src/anyimage/operators/material.py b/src/anyimage/operators/common/material.py similarity index 56% rename from src/anyimage/operators/material.py rename to src/anyimage/operators/common/material.py index 1ea4687..66433ce 100644 --- a/src/anyimage/operators/material.py +++ b/src/anyimage/operators/common/material.py @@ -1,30 +1,16 @@ -from pathlib import Path - import bpy from .image import configure_image_user, is_animated_image +from .node_asset import load_shadeless_node_group DISPLACEMENT_STRENGTH = 1.0 DISPLACEMENT_MIDLEVEL = 0.0 -ORIGINAL_DISPLACEMENT_STRENGTH = 1.0 -ORIGINAL_DISPLACEMENT_MIDLEVEL = 0.0 BUMP_BASE_DISTANCE = 0.001 -DEPTH_PLANE_NODE_GROUP_NAME = "O DA3 Depth Plane" -MOGE_DEPTH_PLANE_NODE_GROUP_NAME = "O MoGe2 Depth Plane" -MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane" -NODE_ASSET_NAME = "O_AnyImage.blend" MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement" BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth" DEPTH_SCALE_ATTRIBUTE = "o_depth_scale" IMAGE_MATERIAL_NODE_GROUP_NAME = "O Image Layer" -IMAGE_MATERIAL_NODE_GROUP_VERSION = 6 -SURFACE_MATERIAL_NODE_GROUP_NAME = IMAGE_MATERIAL_NODE_GROUP_NAME -TEXTURE_MATERIAL_NODE_GROUP_NAME = IMAGE_MATERIAL_NODE_GROUP_NAME -MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME = IMAGE_MATERIAL_NODE_GROUP_NAME -BALLOON_MATERIAL_NODE_GROUP_NAME = IMAGE_MATERIAL_NODE_GROUP_NAME -DEPTH_PLANE_MATERIAL_NODE_GROUP_NAME = "O DA3 Depth Plane Layer" -MOGE_MATERIAL_NODE_GROUP_NAME = "O MoGe2 Depth Plane Layer" -SHADELESS_NODE_GROUP_NAME = "O Shadeless" +DEPTH_MATERIAL_NODE_GROUP_NAME = "O Image Depth Layer" VIEW_TRANSFORM_COLOR_SPACES = ( ("ACES 1.3", "ACES 1.3 sRGB"), ("ACES 2.0", "ACES 2.0 sRGB"), @@ -77,196 +63,14 @@ def set_material_color_space(image, scene=None): return settings.name -def moge_material_node_group(): - existing = bpy.data.node_groups.get(MOGE_MATERIAL_NODE_GROUP_NAME) - if existing is not None: - return existing - group = bpy.data.node_groups.new( - MOGE_MATERIAL_NODE_GROUP_NAME, - "ShaderNodeTree", - ) - for name, socket_type in ( - ("Color", "NodeSocketColor"), - ("Alpha", "NodeSocketFloat"), - ("Depth", "NodeSocketFloat"), - ("Bump Scale", "NodeSocketFloat"), - ): - socket = group.interface.new_socket( - name=name, - in_out="INPUT", - socket_type=socket_type, - ) - if name == "Alpha": - socket.default_value = 1.0 - if name == "Bump Scale": - socket.default_value = 1.0 - for name, socket_type in ( - ("Color", "NodeSocketColor"), - ("Roughness", "NodeSocketFloat"), - ("Alpha", "NodeSocketFloat"), - ("Normal", "NodeSocketVector"), - ("Displacement", "NodeSocketVector"), - ): - group.interface.new_socket( - name=name, - in_out="OUTPUT", - socket_type=socket_type, - ) - nodes = group.nodes - links = group.links - group_input = nodes.new("NodeGroupInput") - group_input.location = (-900.0, 100.0) - bump = nodes.new("ShaderNodeBump") - bump.location = (250.0, -100.0) - bump_distance = nodes.new("ShaderNodeMath") - bump_distance.operation = "MULTIPLY" - bump_distance.inputs[1].default_value = BUMP_BASE_DISTANCE - bump_distance.location = (0.0, -100.0) - background = nodes.new("ShaderNodeAttribute") - background.attribute_type = "GEOMETRY" - background.attribute_name = BACKGROUND_DEPTH_ATTRIBUTE - background.location = (-650.0, -400.0) - material_mode = nodes.new("ShaderNodeAttribute") - material_mode.attribute_type = "GEOMETRY" - material_mode.attribute_name = MATERIAL_DISPLACEMENT_ATTRIBUTE - material_mode.location = (-650.0, -600.0) - strength = nodes.new("ShaderNodeAttribute") - strength.attribute_type = "GEOMETRY" - strength.attribute_name = DEPTH_SCALE_ATTRIBUTE - strength.location = (-400.0, -600.0) - clamp_depth = nodes.new("ShaderNodeMath") - clamp_depth.operation = "MAXIMUM" - clamp_depth.location = (-350.0, -350.0) - enable_depth = nodes.new("ShaderNodeMath") - enable_depth.operation = "MULTIPLY" - enable_depth.location = (-100.0, -350.0) - scale_depth = nodes.new("ShaderNodeMath") - scale_depth.operation = "MULTIPLY" - scale_depth.location = (150.0, -350.0) - displacement = nodes.new("ShaderNodeDisplacement") - displacement.inputs["Midlevel"].default_value = 0.0 - displacement.inputs["Scale"].default_value = 1.0 - displacement.location = (400.0, -350.0) - group_output = nodes.new("NodeGroupOutput") - group_output.location = (300.0, 100.0) - links.new(group_input.outputs["Color"], group_output.inputs["Color"]) - links.new(group_input.outputs["Alpha"], group_output.inputs["Alpha"]) - links.new(group_input.outputs["Color"], bump.inputs["Height"]) - links.new(group_input.outputs["Bump Scale"], bump_distance.inputs[0]) - links.new(bump_distance.outputs[0], bump.inputs["Distance"]) - links.new(bump.outputs["Normal"], group_output.inputs["Normal"]) - links.new(group_input.outputs["Depth"], clamp_depth.inputs[0]) - links.new(background.outputs["Fac"], clamp_depth.inputs[1]) - links.new(clamp_depth.outputs[0], enable_depth.inputs[0]) - links.new(material_mode.outputs["Fac"], enable_depth.inputs[1]) - links.new(enable_depth.outputs[0], scale_depth.inputs[0]) - links.new(strength.outputs["Fac"], scale_depth.inputs[1]) - links.new(scale_depth.outputs[0], displacement.inputs["Height"]) - links.new(displacement.outputs["Displacement"], group_output.inputs["Displacement"]) - group_output.inputs["Roughness"].default_value = 0.5 - group.color_tag = "SHADER" - group.description = "Apply MoGe-2 color and derive bump detail from it." - group.use_fake_user = True - return group - - -def create_moge_material( - name, - color_image, - depth_image, - depth_scale, - shadeless=False, - frame_count=1, - frame_start=1, - use_cyclic=False, - scene=None, -): - material = bpy.data.materials.new(name) - material.use_nodes = True - if hasattr(material, "surface_render_method"): - material.surface_render_method = "DITHERED" - nodes = material.node_tree.nodes - nodes.clear() - links = material.node_tree.links - uv = nodes.new("ShaderNodeUVMap") - uv.uv_map = "UVMap" - uv.location = (-900.0, 200.0) - color = nodes.new("ShaderNodeTexImage") - set_material_color_space(color_image, scene) - color.image = color_image - color.interpolation = "Linear" - color.extension = "EXTEND" - color.location = (-650.0, 300.0) - depth = nodes.new("ShaderNodeTexImage") - depth.image = depth_image - depth.interpolation = "Linear" - depth.extension = "EXTEND" - depth.location = (-650.0, -100.0) - if frame_count > 1: - configure_image_user( - color.image_user, - frame_count, - frame_start=frame_start, - use_cyclic=use_cyclic, - ) - configure_image_user( - depth.image_user, - frame_count, - frame_start=frame_start, - use_cyclic=use_cyclic, - ) - layer = nodes.new("ShaderNodeGroup") - layer.node_tree = moge_material_node_group() - layer.name = MOGE_MATERIAL_NODE_GROUP_NAME - layer.location = (-300.0, 200.0) - output = nodes.new("ShaderNodeOutputMaterial") - output.location = (500.0, 200.0) - links.new(uv.outputs["UV"], color.inputs["Vector"]) - links.new(uv.outputs["UV"], depth.inputs["Vector"]) - links.new(color.outputs["Color"], layer.inputs["Color"]) - links.new(color.outputs["Alpha"], layer.inputs["Alpha"]) - links.new(depth.outputs["Color"], layer.inputs["Depth"]) - layer.inputs["Bump Scale"].default_value = depth_scale - if shadeless: - shader = nodes.new("ShaderNodeGroup") - shader.node_tree = load_shadeless_node_group() - shader.name = SHADELESS_NODE_GROUP_NAME - transparent = nodes.new("ShaderNodeBsdfTransparent") - mix = nodes.new("ShaderNodeMixShader") - links.new(layer.outputs["Color"], shader.inputs["Color"]) - links.new(layer.outputs["Alpha"], mix.inputs["Fac"]) - links.new(transparent.outputs["BSDF"], mix.inputs[1]) - links.new(shader.outputs["Shader"], mix.inputs[2]) - links.new(mix.outputs["Shader"], output.inputs["Surface"]) - return material - shader = nodes.new("ShaderNodeBsdfPrincipled") - links.new(layer.outputs["Color"], shader.inputs["Base Color"]) - links.new(layer.outputs["Color"], shader.inputs["Subsurface Radius"]) - links.new(layer.outputs["Color"], shader.inputs["Emission Color"]) - links.new(layer.outputs["Alpha"], shader.inputs["Alpha"]) - links.new(layer.outputs["Roughness"], shader.inputs["Roughness"]) - links.new(layer.outputs["Normal"], shader.inputs["Normal"]) - links.new(layer.outputs["Normal"], shader.inputs["Coat Normal"]) - links.new(shader.outputs["BSDF"], output.inputs["Surface"]) - links.new(layer.outputs["Displacement"], output.inputs["Displacement"]) - set_displacement_only(material) - return material - - -def material_node_group(depth_plane=False, surface_group_name=None): +def material_node_group(depth_plane=False): group_name = ( - DEPTH_PLANE_MATERIAL_NODE_GROUP_NAME + DEPTH_MATERIAL_NODE_GROUP_NAME if depth_plane - else surface_group_name or SURFACE_MATERIAL_NODE_GROUP_NAME + else IMAGE_MATERIAL_NODE_GROUP_NAME ) existing = bpy.data.node_groups.get(group_name) - rebuild_image_layer = ( - existing is not None - and group_name == IMAGE_MATERIAL_NODE_GROUP_NAME - and existing.get("anyimage_image_layer_version") - != IMAGE_MATERIAL_NODE_GROUP_VERSION - ) - if existing is not None and not rebuild_image_layer: + if existing is not None: for item in existing.interface.items_tree: if ( item.item_type == "SOCKET" @@ -275,12 +79,7 @@ def material_node_group(depth_plane=False, surface_group_name=None): ): item.default_value = (0.5, 0.5, 1.0, 1.0) return existing - if existing is None: - group = bpy.data.node_groups.new(group_name, "ShaderNodeTree") - else: - group = existing - group.nodes.clear() - group.interface.clear() + group = bpy.data.node_groups.new(group_name, "ShaderNodeTree") interface = group.interface input_sockets = [ ("Color", "NodeSocketColor"), @@ -444,13 +243,11 @@ def material_node_group(depth_plane=False, surface_group_name=None): ) group.color_tag = "SHADER" group.description = ( - "Prepare DA3 depth-plane material outputs." + "Prepare image depth material outputs." if depth_plane else f"Prepare {group_name} material outputs." ) group.use_fake_user = True - if group_name == IMAGE_MATERIAL_NODE_GROUP_NAME: - group["anyimage_image_layer_version"] = IMAGE_MATERIAL_NODE_GROUP_VERSION return group @@ -469,7 +266,6 @@ def create_material( depth_frame_offset=0, depth_frame_duration=None, use_cyclic=None, - surface_group_name=None, normal_image=None, normal_space="TANGENT", scene=None, @@ -517,10 +313,7 @@ def create_material( alpha = color_texture.outputs["Alpha"] material_inputs = nodes.new("ShaderNodeGroup") is_depth_plane = displacement_image is not None - material_inputs.node_tree = material_node_group( - is_depth_plane, - surface_group_name=surface_group_name, - ) + material_inputs.node_tree = material_node_group(is_depth_plane) material_inputs.location = (-300.0, 350.0) material_inputs.inputs["Bump Scale"].default_value = depth_scale if "Object Space" in material_inputs.inputs: @@ -641,116 +434,3 @@ def set_displacement_only(material): if owner is not None and hasattr(owner, property_name): setattr(owner, property_name, "DISPLACEMENT") return - - -def node_asset_path(): - return Path(__file__).resolve().parents[1] / "assets" / NODE_ASSET_NAME - - -def load_asset_node_group(group_name): - existing = bpy.data.node_groups.get(group_name) - if existing is not None: - for item in existing.interface.items_tree: - if ( - item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Normal" - ): - item.default_value = (0.5, 0.5, 1.0, 1.0) - for node in existing.nodes: - if node.bl_idname == "ShaderNodeNormalMap": - node.space = "TANGENT" - return existing - asset_path = node_asset_path() - if not asset_path.is_file(): - raise RuntimeError(f"Missing node asset: {asset_path}") - use_pack = bpy.app.version >= (5, 0, 0) - if not use_pack: - directory = str(asset_path) + "/NodeTree/" - result = bpy.ops.wm.append( - filepath=directory + group_name, - directory=directory, - filename=group_name, - link=False, - do_reuse_local_id=True, - ) - if "FINISHED" not in result: - raise RuntimeError("Unable to append the node asset") - node_group = bpy.data.node_groups.get(group_name) - if node_group is None: - raise RuntimeError("Appended node asset is unavailable") - node_group.use_fake_user = True - return node_group - with bpy.data.libraries.load(str(asset_path), link=True) as (source, target): - if group_name not in source.node_groups: - raise RuntimeError(f"Node asset is missing {group_name}") - target.node_groups = [group_name] - linked_group = target.node_groups[0] - node_group = bpy.data.pack_linked_ids_hierarchy(root_id=linked_group) - if linked_group != node_group and linked_group.users == 0: - bpy.data.node_groups.remove(linked_group) - node_group.use_fake_user = True - return node_group - - -def load_shadeless_node_group(): - return load_asset_node_group(SHADELESS_NODE_GROUP_NAME) - - -def load_depth_plane_node_group(group_name=DEPTH_PLANE_NODE_GROUP_NAME): - return load_asset_node_group(group_name) - - -def load_mesh_plane_node_group(): - return load_asset_node_group(MESH_PLANE_NODE_GROUP_NAME) - - -def modifier_input_identifier(node_group, name): - for item in node_group.interface.items_tree: - if item.item_type != "SOCKET" or item.in_out != "INPUT": - continue - if item.name == name: - return item.identifier - raise RuntimeError(f"Geometry Nodes input is missing: {name}") - - -def modifier_input_slots(modifier): - """Return the runtime input collection, or None for the legacy API.""" - properties = getattr(modifier, "properties", None) - if properties is None: - return None - return getattr(properties, "inputs", None) - - -def set_modifier_input(modifier, identifier, value): - """Assign a value to a Geometry Nodes modifier input socket.""" - inputs = modifier_input_slots(modifier) - if inputs is not None: - # Blender 5.2+ exposes inputs as runtime RNA keyed by socket identifier. - getattr(inputs, identifier).value = value - else: - # Blender 4.x exposes inputs as modifier ID properties. - modifier[identifier] = value - - -def get_modifier_input(modifier, identifier): - """Read a Geometry Nodes modifier input socket value.""" - inputs = modifier_input_slots(modifier) - if inputs is not None: - return getattr(inputs, identifier).value - return modifier[identifier] - - -def modifier_input_driver_path(modifier, identifier): - """Return the driver data path for a modifier input socket.""" - if modifier_input_slots(modifier) is not None: - return f'modifiers["{modifier.name}"].properties.inputs.{identifier}.value' - return f'modifiers["{modifier.name}"]["{identifier}"]' - - -def drive_modifier_input(owner_object, modifier, identifier, expression="frame"): - """Add a scripted driver to a modifier input so it follows the scene frame.""" - driver = owner_object.driver_add(modifier_input_driver_path(modifier, identifier)) - driver.driver.type = "SCRIPTED" - driver.driver.expression = expression - return driver diff --git a/src/anyimage/operators/common/node_asset.py b/src/anyimage/operators/common/node_asset.py new file mode 100644 index 0000000..5823b11 --- /dev/null +++ b/src/anyimage/operators/common/node_asset.py @@ -0,0 +1,119 @@ +from pathlib import Path + +import bpy + + +NODE_ASSET_NAME = "O_AnyImage.blend" +MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane" +SHADELESS_NODE_GROUP_NAME = "O Shadeless" + + +def node_asset_path(): + return Path(__file__).resolve().parents[2] / "assets" / NODE_ASSET_NAME + + +def load_asset_node_group(group_name): + existing = bpy.data.node_groups.get(group_name) + if existing is not None: + for item in existing.interface.items_tree: + if ( + item.item_type == "SOCKET" + and item.in_out == "INPUT" + and item.name == "Normal" + ): + item.default_value = (0.5, 0.5, 1.0, 1.0) + for node in existing.nodes: + if node.bl_idname == "ShaderNodeNormalMap": + node.space = "TANGENT" + return existing + + asset_path = node_asset_path() + if not asset_path.is_file(): + raise RuntimeError(f"Missing node asset: {asset_path}") + if bpy.app.version < (5, 0, 0): + directory = str(asset_path) + "/NodeTree/" + result = bpy.ops.wm.append( + filepath=directory + group_name, + directory=directory, + filename=group_name, + link=False, + do_reuse_local_id=True, + ) + if "FINISHED" not in result: + raise RuntimeError("Unable to append the node asset") + node_group = bpy.data.node_groups.get(group_name) + if node_group is None: + raise RuntimeError("Appended node asset is unavailable") + node_group.use_fake_user = True + return node_group + + with bpy.data.libraries.load(str(asset_path), link=True) as (source, target): + if group_name not in source.node_groups: + raise RuntimeError(f"Node asset is missing {group_name}") + target.node_groups = [group_name] + linked_group = target.node_groups[0] + node_group = bpy.data.pack_linked_ids_hierarchy(root_id=linked_group) + if linked_group != node_group and linked_group.users == 0: + bpy.data.node_groups.remove(linked_group) + node_group.use_fake_user = True + return node_group + + +def load_shadeless_node_group(): + return load_asset_node_group(SHADELESS_NODE_GROUP_NAME) + + +def load_mesh_plane_node_group(): + return load_asset_node_group(MESH_PLANE_NODE_GROUP_NAME) + + +def modifier_input_identifier(node_group, name): + for item in node_group.interface.items_tree: + if item.item_type != "SOCKET" or item.in_out != "INPUT": + continue + if item.name == name: + return item.identifier + raise RuntimeError(f"Geometry Nodes input is missing: {name}") + + +def modifier_input_slots(modifier): + """Return the runtime input collection, or None for the legacy API.""" + properties = getattr(modifier, "properties", None) + if properties is None: + return None + return getattr(properties, "inputs", None) + + +def set_modifier_input(modifier, identifier, value): + """Assign a value to a Geometry Nodes modifier input socket.""" + inputs = modifier_input_slots(modifier) + if inputs is not None: + # Blender 5.2+ exposes inputs as runtime RNA keyed by socket identifier. + getattr(inputs, identifier).value = value + else: + # Blender 4.x exposes inputs as modifier ID properties. + modifier[identifier] = value + + +def get_modifier_input(modifier, identifier): + """Read a Geometry Nodes modifier input socket value.""" + inputs = modifier_input_slots(modifier) + if inputs is not None: + return getattr(inputs, identifier).value + return modifier[identifier] + + +def modifier_input_driver_path(modifier, identifier): + """Return the driver data path for a modifier input socket.""" + if modifier_input_slots(modifier) is not None: + return f'modifiers["{modifier.name}"].properties.inputs.{identifier}.value' + return f'modifiers["{modifier.name}"]["{identifier}"]' + + +def drive_modifier_input(owner_object, modifier, identifier, expression="frame"): + """Add a scripted driver to a modifier input so it follows the scene frame.""" + path = modifier_input_driver_path(modifier, identifier) + driver = owner_object.driver_add(path) + driver.driver.type = "SCRIPTED" + driver.driver.expression = expression + return driver diff --git a/src/anyimage/operators/common/node_graph.py b/src/anyimage/operators/common/node_graph.py new file mode 100644 index 0000000..dc6fe08 --- /dev/null +++ b/src/anyimage/operators/common/node_graph.py @@ -0,0 +1,192 @@ +"""Shared Geometry Nodes construction helpers.""" + +def interface_socket(group, name, in_out, socket_type): + socket = group.interface.new_socket( + name=name, + in_out=in_out, + socket_type=socket_type, + ) + if in_out == "INPUT" and hasattr(socket, "structure_type"): + socket.structure_type = "SINGLE" + return socket + + +def group_input(nodes, location, visible_names): + node = nodes.new("NodeGroupInput") + node.location = location + visible = set(visible_names) + for output in node.outputs: + output.hide = output.name not in visible + return node + + +def edge_boundary_field(nodes, links, location): + x, y = location + vertex_neighbors = nodes.new("GeometryNodeInputMeshVertexNeighbors") + vertex_neighbors.location = (x, y + 150.0) + loose_vertex = nodes.new("FunctionNodeCompare") + loose_vertex.data_type = "INT" + loose_vertex.operation = "EQUAL" + loose_vertex_a = next( + socket for socket in loose_vertex.inputs if socket.identifier == "A_INT" + ) + loose_vertex_b = next( + socket for socket in loose_vertex.inputs if socket.identifier == "B_INT" + ) + loose_vertex_b.default_value = 1 + loose_vertex.location = (x + 200.0, y + 150.0) + links.new(vertex_neighbors.outputs["Vertex Count"], loose_vertex_a) + + edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") + edge_neighbors.location = (x, y - 150.0) + boundary_edge = nodes.new("FunctionNodeCompare") + boundary_edge.data_type = "INT" + boundary_edge.operation = "EQUAL" + boundary_edge_a = next( + socket for socket in boundary_edge.inputs if socket.identifier == "A_INT" + ) + boundary_edge_b = next( + socket for socket in boundary_edge.inputs if socket.identifier == "B_INT" + ) + boundary_edge_b.default_value = 1 + boundary_edge.location = (x + 200.0, y - 150.0) + links.new(edge_neighbors.outputs["Face Count"], boundary_edge_a) + edge_domain = nodes.new("GeometryNodeFieldOnDomain") + edge_domain.data_type = "BOOLEAN" + edge_domain.domain = "EDGE" + edge_domain.location = (x + 400.0, y - 150.0) + links.new(boundary_edge.outputs["Result"], edge_domain.inputs["Value"]) + + is_boundary = nodes.new("FunctionNodeBooleanMath") + is_boundary.operation = "OR" + is_boundary.location = (x + 600.0, y) + links.new(edge_domain.outputs["Value"], is_boundary.inputs[0]) + links.new(loose_vertex.outputs["Result"], is_boundary.inputs[1]) + return is_boundary.outputs["Boolean"] + + +def expand_smooth( + group, geometry, x_offset=0.0, iterations_name="Iterations" +): + nodes = group.nodes + links = group.links + existing_nodes = set(nodes) + iteration_controls = group_input(nodes, (550.0, 1150.0), {iterations_name}) + weight_controls = group_input(nodes, (2300.0, 1450.0), {"Smooth"}) + repeat_output = nodes.new("GeometryNodeRepeatOutput") + repeat_output.location = (3050.0, 550.0) + repeat_input = nodes.new("GeometryNodeRepeatInput") + repeat_input.pair_with_output(repeat_output) + repeat_output.repeat_items.new("GEOMETRY", "Geometry") + repeat_input.location = (550.0, 550.0) + links.new( + iteration_controls.outputs[iterations_name], + repeat_input.inputs["Iterations"], + ) + links.new(geometry, repeat_input.inputs["Geometry"]) + + boundary_field = edge_boundary_field(nodes, links, (750.0, -350.0)) + boundary_points = nodes.new("GeometryNodeFieldOnDomain") + boundary_points.data_type = "BOOLEAN" + boundary_points.domain = "POINT" + boundary_points.location = (1550.0, -350.0) + links.new(boundary_field, boundary_points.inputs["Value"]) + + boundary_geometry = nodes.new("GeometryNodeSeparateGeometry") + boundary_geometry.domain = "POINT" + boundary_geometry.location = (1750.0, -150.0) + links.new(repeat_input.outputs["Geometry"], boundary_geometry.inputs["Geometry"]) + links.new(boundary_points.outputs["Value"], boundary_geometry.inputs["Selection"]) + + position = nodes.new("GeometryNodeInputPosition") + position.location = (750.0, 850.0) + smooth_all = nodes.new("GeometryNodeBlurAttribute") + smooth_all.data_type = "FLOAT_VECTOR" + smooth_all.inputs["Iterations"].default_value = 1 + smooth_all.inputs["Weight"].default_value = 1.0 + smooth_all.location = (950.0, 850.0) + links.new(position.outputs["Position"], smooth_all.inputs["Value"]) + + boundary_position = nodes.new("GeometryNodeInputPosition") + boundary_position.location = (1350.0, 250.0) + smooth_boundary = nodes.new("GeometryNodeBlurAttribute") + smooth_boundary.data_type = "FLOAT_VECTOR" + smooth_boundary.inputs["Iterations"].default_value = 1 + smooth_boundary.inputs["Weight"].default_value = 0.5 + smooth_boundary.location = (1550.0, 250.0) + links.new(boundary_position.outputs["Position"], smooth_boundary.inputs["Value"]) + nearest_boundary = nodes.new("GeometryNodeSampleNearest") + nearest_boundary.domain = "POINT" + nearest_boundary.location = (1800.0, 150.0) + links.new(boundary_geometry.outputs["Selection"], nearest_boundary.inputs["Geometry"]) + sample_boundary = nodes.new("GeometryNodeSampleIndex") + sample_boundary.data_type = "FLOAT_VECTOR" + sample_boundary.domain = "POINT" + sample_boundary.clamp = False + sample_boundary.location = (2050.0, 250.0) + links.new(boundary_geometry.outputs["Selection"], sample_boundary.inputs["Geometry"]) + links.new(smooth_boundary.outputs["Value"], sample_boundary.inputs["Value"]) + links.new(nearest_boundary.outputs["Index"], sample_boundary.inputs["Index"]) + + choose_boundary = nodes.new("GeometryNodeSwitch") + choose_boundary.input_type = "VECTOR" + choose_boundary.location = (2300.0, 350.0) + direct_boundary = edge_boundary_field(nodes, links, (750.0, 450.0)) + links.new(direct_boundary, choose_boundary.inputs["Switch"]) + links.new(smooth_all.outputs["Value"], choose_boundary.inputs["False"]) + links.new(sample_boundary.outputs["Value"], choose_boundary.inputs["True"]) + boundary_mix = nodes.new("ShaderNodeMix") + boundary_mix.data_type = "VECTOR" + boundary_mix.inputs[0].default_value = 1.0 + boundary_mix.location = (2550.0, 400.0) + links.new(smooth_all.outputs["Value"], boundary_mix.inputs[4]) + links.new(choose_boundary.outputs["Output"], boundary_mix.inputs[5]) + + normal = nodes.new("GeometryNodeInputNormal") + normal.location = (1350.0, 1050.0) + smooth_normal = nodes.new("GeometryNodeBlurAttribute") + smooth_normal.data_type = "FLOAT_VECTOR" + smooth_normal.inputs["Iterations"].default_value = 10 + smooth_normal.inputs["Weight"].default_value = 1.0 + smooth_normal.location = (1550.0, 1050.0) + links.new(normal.outputs["Normal"], smooth_normal.inputs["Value"]) + normal_coherence = nodes.new("ShaderNodeVectorMath") + normal_coherence.operation = "LENGTH" + normal_coherence.location = (1800.0, 1050.0) + links.new(smooth_normal.outputs["Value"], normal_coherence.inputs[0]) + sharp_switch = nodes.new("GeometryNodeSwitch") + sharp_switch.input_type = "FLOAT" + sharp_switch.inputs["Switch"].default_value = True + sharp_switch.inputs["False"].default_value = 1.0 + sharp_switch.location = (2050.0, 1050.0) + links.new(normal_coherence.outputs["Value"], sharp_switch.inputs["True"]) + smooth_weight = nodes.new("ShaderNodeMath") + smooth_weight.operation = "MULTIPLY" + smooth_weight.location = (2300.0, 1050.0) + links.new(weight_controls.outputs["Smooth"], smooth_weight.inputs[0]) + links.new(sharp_switch.outputs["Output"], smooth_weight.inputs[1]) + + target_position = nodes.new("GeometryNodeSwitch") + target_position.input_type = "VECTOR" + target_position.inputs["Switch"].default_value = True + target_position.location = (2550.0, 700.0) + links.new(smooth_all.outputs["Value"], target_position.inputs["False"]) + links.new(boundary_mix.outputs["Result"], target_position.inputs["True"]) + current_position = nodes.new("GeometryNodeInputPosition") + current_position.location = (2300.0, 850.0) + mix_position = nodes.new("ShaderNodeMix") + mix_position.data_type = "VECTOR" + mix_position.location = (2800.0, 850.0) + links.new(smooth_weight.outputs[0], mix_position.inputs[0]) + links.new(current_position.outputs["Position"], mix_position.inputs[4]) + links.new(target_position.outputs["Output"], mix_position.inputs[5]) + set_position = nodes.new("GeometryNodeSetPosition") + set_position.location = (2800.0, 550.0) + links.new(repeat_input.outputs["Geometry"], set_position.inputs["Geometry"]) + links.new(mix_position.outputs["Result"], set_position.inputs["Position"]) + links.new(set_position.outputs["Geometry"], repeat_output.inputs["Geometry"]) + for node in set(nodes) - existing_nodes: + node.location.x += x_offset + return repeat_output.outputs["Geometry"] + + diff --git a/src/anyimage/operators/normal_map.py b/src/anyimage/operators/common/normal_map.py similarity index 100% rename from src/anyimage/operators/normal_map.py rename to src/anyimage/operators/common/normal_map.py diff --git a/src/anyimage/operators/common.py b/src/anyimage/operators/common/operator.py similarity index 95% rename from src/anyimage/operators/common.py rename to src/anyimage/operators/common/operator.py index 27b882b..6a44752 100644 --- a/src/anyimage/operators/common.py +++ b/src/anyimage/operators/common/operator.py @@ -3,11 +3,10 @@ from pathlib import Path import bpy +from ...preferences import addon_preferences +from ...properties import ai_ready, model_ready, model_record +from ...runtime import runtime from .image import extract_frame_range -from ..preferences import ADDON_PACKAGE, addon_preferences -from ..runtime import runtime -from ..properties import ai_ready, model_ready, model_record - BEN2_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"} BEN2_VIDEO_SUFFIXES = { diff --git a/src/anyimage/operators/convert_to_plane/__init__.py b/src/anyimage/operators/convert_to_plane/__init__.py new file mode 100644 index 0000000..073935a --- /dev/null +++ b/src/anyimage/operators/convert_to_plane/__init__.py @@ -0,0 +1,13 @@ +"""Mesh Plane and Depth Plane conversion operators.""" + +from .conversion import ( + ConvertToDepthPlane, + ConvertToPlane, + GenerateDepthPlane, +) + +__all__ = ( + "ConvertToDepthPlane", + "ConvertToPlane", + "GenerateDepthPlane", +) diff --git a/src/anyimage/operators/convert_to_plane/build.py b/src/anyimage/operators/convert_to_plane/build.py new file mode 100644 index 0000000..7656a6f --- /dev/null +++ b/src/anyimage/operators/convert_to_plane/build.py @@ -0,0 +1,209 @@ +import bpy + +from ..common.image import ( + color_data_name, + create_color_image, + depth_data_name, + is_image_empty, + material_data_name, + normal_data_name, + plane_bounds, +) +from ..common.material import create_material +from ..common.node_asset import modifier_input_identifier, set_modifier_input +from ..common.normal_map import bake_tangent_normal_image +from ..common.operator import push_undo +from .mesh_plane import create_mesh_plane_mesh, create_mesh_plane_object +from .nodes import depth_plane_node_group + + +def copy_color_image(source_object): + source = source_object.data + image = source.copy() + if getattr(source, "source", None) == "GENERATED" or getattr( + source, "is_dirty", False + ): + import numpy as np + + pixels = np.empty(len(source.pixels), dtype=np.float32) + source.pixels.foreach_get(pixels) + image.pixels.foreach_set(pixels) + image.update() + image.name = color_data_name(source_object) + return image + + +def build_mesh_plane(context, source_object, subdivisions, material): + source_name = source_object.name + plane = create_mesh_plane_object( + context, + source_name, + plane_bounds(source_object), + material, + matrix_world=source_object.matrix_world, + subdivisions=subdivisions, + ) + for polygon in plane.data.polygons: + polygon.use_smooth = True + plane["anyimage_mesh_shape"] = "PLANE" + for selected in context.selected_objects: + selected.select_set(False) + plane.select_set(True) + context.view_layer.objects.active = plane + bpy.data.objects.remove(source_object, do_unlink=True) + plane.name = source_name + return plane + + +def load_depth_field(path): + import numpy as np + + with np.load(path, allow_pickle=False) as archive: + points = np.asarray(archive["points"], dtype=np.float32) + intrinsics = np.asarray(archive["intrinsics"], dtype=np.float32) + valid = np.asarray(archive["valid"], dtype=bool) + calibration_valid = np.asarray(archive["calibration_valid"], dtype=bool) + image_size = tuple(int(value) for value in archive["image_size"]) + reference_depth = float(archive["reference_depth"]) + if ( + points.ndim != 3 + or points.shape[2] != 3 + or valid.shape != points.shape[:2] + or calibration_valid.shape != points.shape[:2] + ): + raise ValueError("The mesh point field is invalid") + if intrinsics.shape != (3, 3) or not reference_depth > 0.0: + raise ValueError("The mesh camera field is invalid") + return points, intrinsics, valid, calibration_valid, image_size, reference_depth + + +def anchored_point_field(source_object, field): + import numpy as np + + points, intrinsics, valid, _calibration, image_size, reference_depth = field + width, height = image_size + x_min, x_max, y_min, y_max = plane_bounds(source_object) + plane_width = x_max - x_min + plane_height = y_max - y_min + fx, fy = float(intrinsics[0, 0]), float(intrinsics[1, 1]) + cx, cy = float(intrinsics[0, 2]), float(intrinsics[1, 2]) + if fx <= 0.0 or fy <= 0.0: + raise ValueError("Camera intrinsics contain an invalid focal length") + scale_x = plane_width * fx / (max(width - 1, 1) * reference_depth) + scale_y = plane_height * fy / (max(height - 1, 1) * reference_depth) + scale_z = float(np.sqrt(scale_x * scale_y)) + camera_x = x_min + plane_width * cx / max(width - 1, 1) + camera_y = y_max - plane_height * cy / max(height - 1, 1) + anchored = np.empty_like(points, dtype=np.float32) + anchored[..., 0] = camera_x + points[..., 0] * scale_x + anchored[..., 1] = camera_y - points[..., 1] * scale_y + anchored[..., 2] = (reference_depth - points[..., 2]) * scale_z + return anchored, valid, points[..., 2] + + +def create_depth_image(source_object, anchored_points): + import numpy as np + + values = np.asarray(anchored_points, dtype=np.float32)[..., 2] + height, width = values.shape + pixels = np.empty((height, width, 4), dtype=np.float32) + pixels[:, :, :3] = values[:, :, None] + pixels[:, :, 3] = 1.0 + image = bpy.data.images.new( + depth_data_name(source_object), + width=width, + height=height, + alpha=True, + float_buffer=True, + ) + image.colorspace_settings.name = "Non-Color" + image.pixels.foreach_set(np.flipud(pixels).ravel()) + image.pack() + return image + + +def load_normal_image(path, source_object): + image = bpy.data.images.load(str(path), check_existing=False) + image.name = normal_data_name(source_object) + image.colorspace_settings.name = "Non-Color" + image.pack() + return image + + +def build_depth_plane(context, source_object, subdivisions, material, field): + source_name = source_object.name + mesh = create_mesh_plane_mesh(source_name, plane_bounds(source_object)) + for polygon in mesh.polygons: + polygon.use_smooth = True + mesh.materials.append(material) + plane = bpy.data.objects.new(source_name, mesh) + context.collection.objects.link(plane) + plane.matrix_world = source_object.matrix_world.copy() + plane["anyimage_mesh_shape"] = "DEPTH_PLANE" + + node_group = depth_plane_node_group() + modifier = plane.modifiers.new(name=node_group.name, type="NODES") + modifier.node_group = node_group + modifier.show_group_selector = False + depth_image = create_depth_image( + source_object, anchored_point_field(source_object, field)[0] + ) + for name, value in ( + ("Subdivide", max(0, min(10, int(subdivisions)))), + ("Material", material), + ("Thickness", 0.1), + ("Depth Amount", 1.0), + ("Depth Image", depth_image), + ): + set_modifier_input( + modifier, + modifier_input_identifier(node_group, name), + value, + ) + for selected in context.selected_objects: + selected.select_set(False) + plane.select_set(True) + context.view_layer.objects.active = plane + bpy.data.objects.remove(source_object, do_unlink=True) + plane.name = source_name + return plane + + +def build_depth_plane_response(context, result, operator): + source_object = bpy.data.objects.get(operator.source_object_name) + if not is_image_empty(source_object): + raise RuntimeError("The source Image Empty no longer exists") + color_image = create_color_image( + source_object, + result.directory, + assign_to_source=False, + ) + color_image.name = color_data_name(source_object) + normal_path = result.directory / "normal.png" + normal_image = ( + load_normal_image(normal_path, source_object) + if normal_path.is_file() + else None + ) + material = create_material( + material_data_name(source_object), + color_image, + 0.0, + normal_image=normal_image, + scene=context.scene, + ) + plane = build_depth_plane( + context, + source_object, + operator.mesh_detail, + material, + load_depth_field(result.file("field")), + ) + if normal_image is not None: + bake_tangent_normal_image( + context, + source_object, + plane, + normal_image, + ) + push_undo("Convert to Depth Plane") diff --git a/src/anyimage/operators/convert_to_plane/conversion.py b/src/anyimage/operators/convert_to_plane/conversion.py new file mode 100644 index 0000000..34d88ce --- /dev/null +++ b/src/anyimage/operators/convert_to_plane/conversion.py @@ -0,0 +1,213 @@ +import bpy + +from ...preferences import addon_preferences, configured_max_ai_input_size +from ...properties import DEFAULT_MOGE2_MODEL_KEY +from ...runtime import JobOperatorBase +from ...server.media.resolution import ( + DEFAULT_MAX_AI_INPUT_SIZE, + MAX_MAX_AI_INPUT_SIZE, + MIN_MAX_AI_INPUT_SIZE, +) +from ..common.image import ( + analysis_source_image, + image_user_settings, + is_image_empty, + material_data_name, + prepare_image_analysis_inputs, + remove_temporary_input, + scaled_process_resolution, + video_total_frames, +) +from ..common.material import create_material +from ..common.operator import ( + HIDDEN, + cleanup_temporary_input, + da3_parameters, + input_path, + invoke_ai_setup_if_needed, + moge2_parameters, + production_device, + require_environment, + require_model, + source_start_frame, +) +from .build import build_depth_plane_response, build_mesh_plane, copy_color_image + + +def depth_plane_settings(context): + preferences = addon_preferences(context) + return ( + getattr(preferences, "mesh_depth_model", DEFAULT_MOGE2_MODEL_KEY), + float(getattr(preferences, "mesh_process_res_factor", 100.0)), + int(getattr(preferences, "mesh_resolution_level", 5)), + ) + + +class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator): + bl_idname = "anyimage.generate_depth_plane" + bl_label = "Generate Depth Plane" + job_type = "prepare-geometry-data" + starting_message = "Preparing Depth Plane" + + input_path: bpy.props.StringProperty(options=HIDDEN) + analysis_input_path: bpy.props.StringProperty(options=HIDDEN) + source_object_name: bpy.props.StringProperty(options=HIDDEN) + mesh_detail: bpy.props.IntProperty(default=0, min=0, max=10, options=HIDDEN) + model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=HIDDEN) + process_res: bpy.props.IntProperty(default=1008, min=1, 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, + min=MIN_MAX_AI_INPUT_SIZE, + max=MAX_MAX_AI_INPUT_SIZE, + options=HIDDEN, + ) + delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN) + delete_analysis_input: bpy.props.BoolProperty(default=False, options=HIDDEN) + + def request(self, context): + require_environment() + require_model(self.model) + parameters = { + "input": str(input_path(self.input_path)), + "analysis_input": str(input_path(self.analysis_input_path)), + "shape": "DEPTH_PLANE", + "polygon": None, + "mesh_detail": self.mesh_detail, + "device": production_device().lower(), + "video_frames": 1, + "start_frame": source_start_frame(self.source_object_name), + "max_input_size": int(self.max_input_size), + "refine_selection": False, + "normal_analysis": False, + "invert": False, + } + settings = context.scene.anyimage_settings + if self.model.startswith("MOGE2_"): + parameters.update( + moge2_parameters( + settings, + input_path(self.input_path), + self.model, + self.resolution_level, + source_start_frame(self.source_object_name), + ) + ) + else: + parameters.update( + da3_parameters( + settings, + input_path(self.input_path), + self.model, + self.process_res, + source_start_frame(self.source_object_name), + ) + ) + parameters.update(shape="DEPTH_PLANE", polygon=None) + return parameters + + def response(self, context, result): + build_depth_plane_response(context, result, self) + return "Depth Plane is ready" + + def cleanup(self): + cleanup_temporary_input(self.input_path, self.delete_input) + cleanup_temporary_input( + self.analysis_input_path, + self.delete_analysis_input, + ) + + +def start_depth_plane_job(operator, context): + source_object = context.object + if video_total_frames(source_object.data) != 1: + operator.report({"ERROR"}, "Depth Plane currently supports still images") + return {"CANCELLED"} + setup_result = invoke_ai_setup_if_needed() + if setup_result is not None: + return setup_result + try: + source_path, delete_input, analysis_path, delete_analysis_input = ( + prepare_image_analysis_inputs(source_object.data, context.scene) + ) + analysis_image = analysis_source_image(source_object.data) + model, process_factor, resolution_level = depth_plane_settings(context) + process_res = ( + scaled_process_resolution(analysis_image, process_factor) + if not model.startswith("MOGE2_") + else 1 + ) + except (RuntimeError, ValueError) as error: + operator.report({"ERROR"}, str(error)) + return {"CANCELLED"} + result = bpy.ops.anyimage.generate_depth_plane( + input_path=str(source_path), + analysis_input_path=str(analysis_path), + source_object_name=source_object.name, + mesh_detail=operator.mesh_detail, + model=model, + process_res=process_res, + resolution_level=resolution_level, + max_input_size=configured_max_ai_input_size(context), + delete_input=delete_input, + delete_analysis_input=delete_analysis_input, + ) + if "CANCELLED" in result: + if delete_input: + remove_temporary_input(source_path) + if delete_analysis_input: + remove_temporary_input(analysis_path) + return {"CANCELLED"} if "CANCELLED" in result else {"FINISHED"} + + +class ConvertToPlane(bpy.types.Operator): + bl_idname = "anyimage.convert_to_plane" + bl_label = "Convert to Plane" + bl_description = "Convert the complete Image Empty into a rectangular plane" + bl_options = {"REGISTER", "UNDO"} + + mesh_detail: bpy.props.IntProperty(name="Subdivide", default=0, min=0, max=10) + + @classmethod + def poll(cls, context): + return is_image_empty(context.object) + + def execute(self, context): + source_object = context.object + color_image = copy_color_image(source_object) + frame_start, frame_offset, frame_duration, use_cyclic = image_user_settings( + source_object + ) + material = create_material( + material_data_name(source_object), + color_image, + 1.0, + frame_start=frame_start, + color_frame_offset=frame_offset, + color_frame_duration=frame_duration, + use_cyclic=use_cyclic, + scene=context.scene, + ) + build_mesh_plane(context, source_object, self.mesh_detail, material) + return {"FINISHED"} + + +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 front-projected relief" + bl_options = {"REGISTER", "UNDO"} + + mesh_detail: bpy.props.IntProperty(name="Subdivide", default=0, min=0, max=10) + + @classmethod + def poll(cls, context): + from ...runtime import runtime + + return is_image_empty(context.object) and not runtime.server_busy() + + def invoke(self, context, _event): + return self.execute(context) + + def execute(self, context): + return start_depth_plane_job(self, context) diff --git a/src/anyimage/operators/convert_to_plane/mesh_plane.py b/src/anyimage/operators/convert_to_plane/mesh_plane.py new file mode 100644 index 0000000..9c942e9 --- /dev/null +++ b/src/anyimage/operators/convert_to_plane/mesh_plane.py @@ -0,0 +1,86 @@ +"""Create Mesh Plane objects used by conversion and clipboard paste.""" + +import bpy + +from ..common.node_asset import ( + load_mesh_plane_node_group, + modifier_input_identifier, + set_modifier_input, +) + + +MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane" + + +def create_mesh_plane_mesh(name, bounds): + x_min, x_max, y_min, y_max = bounds + if x_max <= x_min or y_max <= y_min: + raise ValueError("The plane has invalid dimensions") + + mesh = bpy.data.meshes.new(name) + mesh.from_pydata( + ( + (x_min, y_min, 0.0), + (x_max, y_min, 0.0), + (x_max, y_max, 0.0), + (x_min, y_max, 0.0), + ), + (), + ((0, 1, 2, 3),), + ) + mesh.update() + uv_layer = mesh.uv_layers.new(name="UVMap") + for loop, uv in zip( + mesh.loops, + ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)), + ): + uv_layer.data[loop.index].uv = uv + return mesh + + +def add_mesh_plane_processing( + plane_object, + material, + subdivisions=0, + thickness=0.0, +): + node_group = load_mesh_plane_node_group() + modifier = plane_object.modifiers.new( + name=MESH_PLANE_NODE_GROUP_NAME, + type="NODES", + ) + modifier.node_group = node_group + if hasattr(modifier, "show_group_selector"): + modifier.show_group_selector = False + for name, value in ( + ("Subdivide", max(0, int(subdivisions))), + ("Thickness", max(0.0, float(thickness))), + ("Material", material), + ): + identifier = modifier_input_identifier(node_group, name) + set_modifier_input(modifier, identifier, value) + return modifier + + +def create_mesh_plane_object( + context, + name, + bounds, + material, + matrix_world=None, + subdivisions=0, + thickness=0.0, +): + mesh = create_mesh_plane_mesh(name, bounds) + mesh.materials.append(material) + plane = bpy.data.objects.new(name, mesh) + context.collection.objects.link(plane) + if matrix_world is not None: + plane.matrix_world = matrix_world.copy() + add_mesh_plane_processing( + plane, + material, + subdivisions=subdivisions, + thickness=thickness, + ) + return plane diff --git a/src/anyimage/operators/convert_to_plane/nodes.py b/src/anyimage/operators/convert_to_plane/nodes.py new file mode 100644 index 0000000..85ccbac --- /dev/null +++ b/src/anyimage/operators/convert_to_plane/nodes.py @@ -0,0 +1,156 @@ +"""Runtime Geometry Nodes for Depth Plane.""" + +import bpy + +from ..common.node_asset import load_mesh_plane_node_group +from ..common.node_graph import group_input, interface_socket + +def depth_plane_node_group(): + group_name = "O Image Depth Plane" + group = bpy.data.node_groups.get(group_name) + if group is not None: + return group + if group is None: + group = bpy.data.node_groups.new(group_name, "GeometryNodeTree") + else: + group.nodes.clear() + group.interface.clear() + group.is_modifier = True + interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") + subdivide = interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt") + subdivide.default_value = 7 + subdivide.min_value = 0 + subdivide.max_value = 10 + thickness = interface_socket(group, "Thickness", "INPUT", "NodeSocketFloat") + thickness.default_value = 0.1 + thickness.min_value = 0.0 + thickness.subtype = "DISTANCE" + depth_amount = interface_socket(group, "Depth Amount", "INPUT", "NodeSocketFloat") + depth_amount.default_value = 1.0 + depth_amount.min_value = 0.0 + depth_image = interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage") + depth_image.hide_in_modifier = True + material = interface_socket(group, "Material", "INPUT", "NodeSocketMaterial") + material.hide_in_modifier = True + interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") + + nodes = group.nodes + links = group.links + geometry_input = group_input( + nodes, + (-1850.0, 450.0), + {"Geometry", "Subdivide", "Material"}, + ) + shape_input = group_input( + nodes, + (-1250.0, 850.0), + {"Thickness", "Depth Amount"}, + ) + image_input = group_input(nodes, (-1050.0, 50.0), {"Depth Image"}) + cleanup_input = group_input(nodes, (650.0, -200.0), {"Thickness"}) + group_output = nodes.new("NodeGroupOutput") + group_output.location = (1600.0, 450.0) + mesh_plane = nodes.new("GeometryNodeGroup") + mesh_plane.node_tree = load_mesh_plane_node_group() + mesh_plane.inputs["Thickness"].default_value = 0.0 + mesh_plane.location = (-1550.0, 450.0) + links.new(geometry_input.outputs["Geometry"], mesh_plane.inputs["Geometry"]) + links.new(geometry_input.outputs["Subdivide"], mesh_plane.inputs["Subdivide"]) + links.new(geometry_input.outputs["Material"], mesh_plane.inputs["Material"]) + + extrude = nodes.new("GeometryNodeExtrudeMesh") + extrude.mode = "FACES" + extrude.inputs["Offset"].default_value = (0.0, 0.0, -1.0) + extrude.inputs["Individual"].default_value = False + extrude.location = (-1200.0, 450.0) + links.new(mesh_plane.outputs["Geometry"], extrude.inputs["Mesh"]) + links.new(shape_input.outputs["Thickness"], extrude.inputs["Offset Scale"]) + geometry = extrude.outputs["Mesh"] + + uv = nodes.new("GeometryNodeInputNamedAttribute") + uv.data_type = "FLOAT_VECTOR" + uv.inputs["Name"].default_value = "UVMap" + uv.location = (-1000.0, 50.0) + texture = nodes.new("GeometryNodeImageTexture") + texture.location = (-800.0, 50.0) + links.new(image_input.outputs["Depth Image"], texture.inputs["Image"]) + links.new(uv.outputs["Attribute"], texture.inputs["Vector"]) + separate = nodes.new("ShaderNodeSeparateXYZ") + separate.location = (-600.0, 50.0) + links.new(texture.outputs["Color"], separate.inputs["Vector"]) + raw_depth = nodes.new("ShaderNodeMath") + raw_depth.operation = "MULTIPLY" + raw_depth.location = (-350.0, 150.0) + links.new(separate.outputs["X"], raw_depth.inputs[0]) + links.new(shape_input.outputs["Depth Amount"], raw_depth.inputs[1]) + inward_limit = nodes.new("ShaderNodeMath") + inward_limit.operation = "MULTIPLY" + inward_limit.inputs[1].default_value = -0.98 + inward_limit.location = (-350.0, -50.0) + links.new(shape_input.outputs["Thickness"], inward_limit.inputs[0]) + clamp = nodes.new("ShaderNodeMath") + clamp.operation = "MAXIMUM" + clamp.location = (-100.0, 50.0) + links.new(raw_depth.outputs[0], clamp.inputs[0]) + links.new(inward_limit.outputs[0], clamp.inputs[1]) + has_thickness = nodes.new("ShaderNodeMath") + has_thickness.operation = "GREATER_THAN" + has_thickness.inputs[1].default_value = 1e-6 + has_thickness.location = (-350.0, -250.0) + links.new(shape_input.outputs["Thickness"], has_thickness.inputs[0]) + choose_depth = nodes.new("GeometryNodeSwitch") + choose_depth.input_type = "FLOAT" + choose_depth.location = (150.0, 50.0) + links.new(has_thickness.outputs[0], choose_depth.inputs["Switch"]) + links.new(raw_depth.outputs[0], choose_depth.inputs["False"]) + links.new(clamp.outputs[0], choose_depth.inputs["True"]) + combine = nodes.new("ShaderNodeCombineXYZ") + combine.location = (350.0, 50.0) + links.new(choose_depth.outputs["Output"], combine.inputs["Z"]) + front = nodes.new("FunctionNodeBooleanMath") + front.operation = "NOT" + front.location = (150.0, 300.0) + links.new(extrude.outputs["Top"], front.inputs[0]) + set_position = nodes.new("GeometryNodeSetPosition") + set_position.location = (550.0, 450.0) + links.new(geometry, set_position.inputs["Geometry"]) + links.new(front.outputs[0], set_position.inputs["Selection"]) + links.new(combine.outputs["Vector"], set_position.inputs["Offset"]) + geometry = set_position.outputs["Geometry"] + + zero_thickness = nodes.new("ShaderNodeMath") + zero_thickness.operation = "LESS_THAN" + zero_thickness.inputs[1].default_value = 1e-6 + zero_thickness.location = (750.0, -150.0) + links.new(cleanup_input.outputs["Thickness"], zero_thickness.inputs[0]) + back_or_side = nodes.new("FunctionNodeBooleanMath") + back_or_side.operation = "OR" + back_or_side.location = (750.0, 150.0) + links.new(extrude.outputs["Top"], back_or_side.inputs[0]) + links.new(extrude.outputs["Side"], back_or_side.inputs[1]) + delete_selection = nodes.new("FunctionNodeBooleanMath") + delete_selection.operation = "AND" + delete_selection.location = (950.0, 50.0) + links.new(zero_thickness.outputs[0], delete_selection.inputs[0]) + links.new(back_or_side.outputs[0], delete_selection.inputs[1]) + delete_geometry = nodes.new("GeometryNodeDeleteGeometry") + delete_geometry.domain = "FACE" + delete_geometry.location = (1100.0, 450.0) + links.new(geometry, delete_geometry.inputs["Geometry"]) + links.new(delete_selection.outputs[0], delete_geometry.inputs["Selection"]) + shade_smooth = nodes.new("GeometryNodeSetShadeSmooth") + shade_smooth.domain = "FACE" + shade_smooth.inputs["Shade Smooth"].default_value = True + shade_smooth.location = (1350.0, 450.0) + links.new(delete_geometry.outputs["Geometry"], shade_smooth.inputs["Geometry"]) + links.new(shade_smooth.outputs["Geometry"], group_output.inputs["Geometry"]) + group.use_fake_user = True + return group + + +def plane_node_group(shape): + if shape == "PLANE": + return load_mesh_plane_node_group() + if shape == "DEPTH_PLANE": + return depth_plane_node_group() + raise ValueError(f"Unsupported Plane shape: {shape}") diff --git a/src/anyimage/operators/cutout_depth.py b/src/anyimage/operators/cutout_depth.py deleted file mode 100644 index e3848ae..0000000 --- a/src/anyimage/operators/cutout_depth.py +++ /dev/null @@ -1,226 +0,0 @@ -"""MoGe-2 depth sampling and Depth Balloon base-plane controls.""" - -from pathlib import Path - -import bpy - - -PLANE_VARIANCE_FLOOR_RATIO = 0.025 - - -def load_depth_balloon_data(path): - import numpy as np - - try: - with np.load(Path(path), allow_pickle=False) as archive: - required = { - "depth_signal", - "baseline_vector", - "valid", - "origin", - "reference_depth", - "crop_size", - "source_size", - } - missing = required.difference(archive.files) - if missing: - raise ValueError( - f"Depth Balloon data is missing: {', '.join(sorted(missing))}" - ) - data = { - "depth_signal": np.asarray( - archive["depth_signal"], - dtype=np.float32, - ), - "baseline_vector": np.asarray( - archive["baseline_vector"], - dtype=np.float32, - ), - "valid": np.asarray(archive["valid"], dtype=bool), - "origin": tuple(int(value) for value in archive["origin"]), - "reference_depth": float(archive["reference_depth"]), - "crop_size": tuple(int(value) for value in archive["crop_size"]), - "source_size": tuple(int(value) for value in archive["source_size"]), - } - except OSError as error: - raise ValueError(f"Unable to read Depth Balloon data: {path}") from error - if data["depth_signal"].ndim != 2: - raise ValueError("Depth Balloon signal must be two-dimensional") - if data["valid"].shape != data["depth_signal"].shape: - raise ValueError("Depth Balloon mask dimensions do not match") - if data["baseline_vector"].shape != (3,): - raise ValueError("Depth Balloon base plane vector must have three components") - if any(value <= 0 for value in (*data["crop_size"], *data["source_size"])): - raise ValueError("Depth Balloon image dimensions must be positive") - return data - - -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 _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.zeros(3, dtype=np.float32) - center = coordinates.mean(axis=0) - value_center = float(values.mean()) - centered = coordinates - center - covariance = centered.T @ centered / len(centered) - cross = centered.T @ (values - value_center) / len(centered) - 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 depth_balloon_fields(vertices, vertex_uv, depth_data, depth_scale): - """Sample camera depth and fit its base plane at a fixed local scale.""" - 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") - depth_signal = np.asarray(depth_data["depth_signal"], dtype=np.float32) - valid = np.asarray(depth_data["valid"], dtype=bool) - crop_width, crop_height = depth_data["crop_size"] - depth_height, depth_width = depth_signal.shape - - crop_x = uv[:, 0] * max(crop_width - 1, 0) - crop_y = (1.0 - uv[:, 1]) * max(crop_height - 1, 0) - sample_x = crop_x * max(depth_width - 1, 0) / max(crop_width - 1, 1) - sample_y = crop_y * max(depth_height - 1, 0) / max(crop_height - 1, 1) - sampled, usable = _sample_valid_bilinear( - depth_signal, - valid, - sample_x, - sample_y, - ) - inside_crop = ( - (crop_x >= 0.0) - & (crop_x <= max(crop_width - 1, 0)) - & (crop_y >= 0.0) - & (crop_y <= max(crop_height - 1, 0)) - ) - usable &= inside_crop - sampled[~usable] = 0.0 - coordinates = np.stack( - ( - sample_x / max(depth_width - 1, 1) * 2.0 - 1.0, - sample_y / max(depth_height - 1, 1) * 2.0 - 1.0, - np.ones(len(vertices), dtype=np.float32), - ), - axis=1, - ).astype(np.float32) - - depth_scale = float(depth_scale) - if not depth_scale > 0.0: - raise ValueError("Depth Balloon reference scale must be positive") - normalized_plane = np.asarray( - depth_data["baseline_vector"], - dtype=np.float32, - ) - baseline_values = ( - coordinates @ normalized_plane * depth_scale * 0.5 - ) - local_coordinates = vertices[:, :2] - if np.count_nonzero(usable) >= 3: - local_plane = _stable_plane( - local_coordinates[usable], - baseline_values[usable], - ) - else: - local_plane = np.zeros(3, dtype=np.float32) - depth_values = sampled * depth_scale - if usable.any(): - plane_without_offset = local_coordinates @ local_plane[:2] - residual = depth_values - plane_without_offset - depth_values[usable] -= float(np.median(residual[usable])) - return depth_values, local_plane - - -def orient_depth_balloon_plane(local_plane): - slope_x, slope_y, offset = (float(value) for value in local_plane) - return -slope_x, slope_y, offset - - -def create_depth_balloon_base_plane(context, mesh_object, vertices, local_plane): - import numpy as np - from mathutils import Vector - - vertices = np.asarray(vertices, dtype=np.float32) - slope_y, slope_z, offset = (float(value) for value in local_plane) - extent = vertices[:, 1:].max(axis=0) - vertices[:, 1:].min(axis=0) - center_y = 0.0 - center_z = 0.0 - center_x = offset - normal = Vector((-1.0, slope_y, slope_z)).normalized() - - controller = bpy.data.objects.new(f"{mesh_object.name} Base Plane", None) - context.collection.objects.link(controller) - controller.parent = mesh_object - controller.location = (float(center_x), float(center_y), float(center_z)) - controller.rotation_euler = normal.to_track_quat("Z", "Y").to_euler() - controller.empty_display_type = "SINGLE_ARROW" - controller.empty_display_size = max(float(extent.max()) * 0.15, 0.01) - controller.show_in_front = True - controller.hide_render = True - controller["anyimage_source_type"] = "DEPTH_BALLOON_BASE_PLANE" - controller["anyimage_base_plane"] = (slope_y, slope_z, offset) - return controller - - diff --git a/src/anyimage/operators/cutout_profile.py b/src/anyimage/operators/cutout_profile.py deleted file mode 100644 index 0af2038..0000000 --- a/src/anyimage/operators/cutout_profile.py +++ /dev/null @@ -1,161 +0,0 @@ -import math - -from .cutout_teddy import teddy_balloon_heights - - -DEFAULT_INFLATION = 0.45 -DEFAULT_ALGORITHM = "POISSON" - - -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) - boundary_mask = np.asarray(boundary_mask, dtype=bool) - loads = np.zeros(len(points), dtype=np.float64) - edge_weights = {} - - def add_edge(start, end, weight): - edge = tuple(sorted((int(start), int(end)))) - edge_weights[edge] = edge_weights.get(edge, 0.0) + weight - - for face in planar_faces: - first, second, third = (int(index) for index in face) - a, b, c = points[[first, second, third]] - cross = abs( - (b[0] - a[0]) * (c[1] - a[1]) - - (b[1] - a[1]) * (c[0] - a[0]) - ) - if cross <= 1e-14: - continue - area = cross * 0.5 - loads[[first, second, third]] += area / 3.0 - cotangent_a = float((b - a).dot(c - a) / cross) - cotangent_b = float((a - b).dot(c - b) / cross) - cotangent_c = float((a - c).dot(b - c) / cross) - add_edge(second, third, cotangent_a * 0.5) - add_edge(first, third, cotangent_b * 0.5) - add_edge(first, second, cotangent_c * 0.5) - - 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) - diagonal = np.zeros(len(interior), dtype=np.float64) - edge_starts = [] - edge_ends = [] - weights = [] - # Constrained triangulation can create negative cotangent weights. - # Clamping them keeps the Dirichlet system positive definite. - for (start, end), weight in edge_weights.items(): - weight = max(float(weight), 0.0) - if weight <= 1e-14: - continue - start_compact = compact[start] - end_compact = compact[end] - if start_compact >= 0: - diagonal[start_compact] += weight - if end_compact >= 0: - diagonal[end_compact] += weight - if start_compact >= 0 and end_compact >= 0: - edge_starts.append(start_compact) - edge_ends.append(end_compact) - weights.append(weight) - if np.any(diagonal <= 1e-14): - raise ValueError("Unable to build a stable Balloon profile") - - edge_starts = np.asarray(edge_starts, dtype=np.int64) - edge_ends = np.asarray(edge_ends, dtype=np.int64) - weights = np.asarray(weights, dtype=np.float64) - - def multiply(values): - result = diagonal * values - if len(weights): - np.add.at(result, edge_starts, -weights * values[edge_ends]) - np.add.at(result, edge_ends, -weights * values[edge_starts]) - 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/__init__.py b/src/anyimage/operators/cutout_tool/__init__.py new file mode 100644 index 0000000..0a38c38 --- /dev/null +++ b/src/anyimage/operators/cutout_tool/__init__.py @@ -0,0 +1,12 @@ +"""Cutout selection, shape conversion, and geometry algorithms.""" + +from .selection_tool import ActivateCutoutTool, CutoutTool, SelectCutoutRegion +from .shape_conversion import ConvertCutoutShape, GenerateCutoutShape + +__all__ = ( + "ActivateCutoutTool", + "ConvertCutoutShape", + "CutoutTool", + "GenerateCutoutShape", + "SelectCutoutRegion", +) diff --git a/src/anyimage/operators/cutout_teddy.py b/src/anyimage/operators/cutout_tool/balloon_profile.py similarity index 56% rename from src/anyimage/operators/cutout_teddy.py rename to src/anyimage/operators/cutout_tool/balloon_profile.py index c891ff8..4c061bf 100644 --- a/src/anyimage/operators/cutout_teddy.py +++ b/src/anyimage/operators/cutout_tool/balloon_profile.py @@ -1,3 +1,7 @@ +import math + +DEFAULT_INFLATION = 0.45 + def distances_to_contour(points, contour): import numpy as np @@ -215,3 +219,158 @@ def teddy_balloon_heights( planar_faces, boundary_mask, ) +DEFAULT_ALGORITHM = "POISSON" + + +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) + boundary_mask = np.asarray(boundary_mask, dtype=bool) + loads = np.zeros(len(points), dtype=np.float64) + edge_weights = {} + + def add_edge(start, end, weight): + edge = tuple(sorted((int(start), int(end)))) + edge_weights[edge] = edge_weights.get(edge, 0.0) + weight + + for face in planar_faces: + first, second, third = (int(index) for index in face) + a, b, c = points[[first, second, third]] + cross = abs( + (b[0] - a[0]) * (c[1] - a[1]) + - (b[1] - a[1]) * (c[0] - a[0]) + ) + if cross <= 1e-14: + continue + area = cross * 0.5 + loads[[first, second, third]] += area / 3.0 + cotangent_a = float((b - a).dot(c - a) / cross) + cotangent_b = float((a - b).dot(c - b) / cross) + cotangent_c = float((a - c).dot(b - c) / cross) + add_edge(second, third, cotangent_a * 0.5) + add_edge(first, third, cotangent_b * 0.5) + add_edge(first, second, cotangent_c * 0.5) + + 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) + diagonal = np.zeros(len(interior), dtype=np.float64) + edge_starts = [] + edge_ends = [] + weights = [] + # Constrained triangulation can create negative cotangent weights. + # Clamping them keeps the Dirichlet system positive definite. + for (start, end), weight in edge_weights.items(): + weight = max(float(weight), 0.0) + if weight <= 1e-14: + continue + start_compact = compact[start] + end_compact = compact[end] + if start_compact >= 0: + diagonal[start_compact] += weight + if end_compact >= 0: + diagonal[end_compact] += weight + if start_compact >= 0 and end_compact >= 0: + edge_starts.append(start_compact) + edge_ends.append(end_compact) + weights.append(weight) + if np.any(diagonal <= 1e-14): + raise ValueError("Unable to build a stable Balloon profile") + + edge_starts = np.asarray(edge_starts, dtype=np.int64) + edge_ends = np.asarray(edge_ends, dtype=np.int64) + weights = np.asarray(weights, dtype=np.float64) + + def multiply(values): + result = diagonal * values + if len(weights): + np.add.at(result, edge_starts, -weights * values[edge_ends]) + np.add.at(result, edge_ends, -weights * values[edge_starts]) + 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/build_shapes.py b/src/anyimage/operators/cutout_tool/build_shapes.py new file mode 100644 index 0000000..48fc970 --- /dev/null +++ b/src/anyimage/operators/cutout_tool/build_shapes.py @@ -0,0 +1,814 @@ +import json + +import bpy + +from ...preferences import addon_preferences, configured_max_ai_input_size +from ...server.media.resolution import limited_image_size +from ..common.image import ( + color_data_name, + create_color_image, + depth_data_name, + is_image_empty, + material_data_name, + normal_data_name, + plane_bounds, + video_total_frames, +) +from ..common.material import create_material +from ..common.node_asset import modifier_input_identifier, set_modifier_input +from ..common.normal_map import bake_tangent_normal_image +from ..common.operator import ( + push_undo, +) +from .depth_calibration import ( + DEFAULT_DEPTH_DIRECTION, + UNREFINED_DEPTH_REFERENCE, + depth_balloon_projection_parameters, + metric_depth_reference, +) +from .planar_mesh import ( + metric_transform, + prepare_planar_regions, + triangulate_planar_regions, +) +from .balloon_profile import balloon_profile as calculate_balloon_profile +from .mask_regions import alpha_regions +from .node_assets import cutout_node_group +from ..common.image_edit import ( + ImageEditWarning, + polygon_bounds, +) +from ..common.image_mask import polygon_mask, visible_selection_mask +from .shape_types import ( + BALLOON_ATTRIBUTE_NAME, + CUTOUT_SHAPE_INDICES, +) + +CUTOUT_ALPHA_THRESHOLD = 0.1 + +CUTOUT_AXIS_TO_SOURCE = ( + (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), +) + + +def _surface_depth_image(source_object, points, valid, camera_depth): + import numpy as np + + points = np.asarray(points, dtype=np.float32) + valid = np.asarray(valid, dtype=bool) + height, width = valid.shape + if points.shape != (height, width, 3): + raise ValueError("Depth Surface point dimensions do not match") + discontinuity = _depth_discontinuity_map(camera_depth, valid) + discontinuity[~valid] = -1.0 + pixels = np.empty((height, width, 4), dtype=np.float32) + pixels[:, :, :3] = points + pixels[:, :, 3] = discontinuity + image = bpy.data.images.new( + depth_data_name(source_object), + width=width, + height=height, + alpha=True, + float_buffer=True, + ) + image.colorspace_settings.name = "Non-Color" + image.alpha_mode = "CHANNEL_PACKED" + image.pixels.foreach_set(np.flipud(pixels).ravel()) + image.pack() + return image + + +def _cutout_world_matrix(source_matrix, crop_center): + from mathutils import Matrix + + center_x, center_y = (float(value) for value in crop_center) + return ( + source_matrix + @ Matrix.Translation((center_x, center_y, 0.0)) + @ Matrix(CUTOUT_AXIS_TO_SOURCE) + ) + + +def _orient_cutout_vertices(vertices): + import numpy as np + + vertices = np.asarray(vertices, dtype=np.float64) + return np.column_stack( + ( + -vertices[:, 2], + -vertices[:, 0], + vertices[:, 1], + ) + ) + + +def _cutout_domain( + regions_pixels, + image_size, + bounds, + spacing, + world_metric, + balloon_algorithm="POISSON", +): + import numpy as np + + x_min, x_max, y_min, y_max = bounds + + regions, spacing = prepare_planar_regions( + regions_pixels, + image_size, + bounds, + spacing=spacing, + world_metric=world_metric, + ) + planar, planar_faces, boundary, components = triangulate_planar_regions( + regions, + spacing, + return_components=True, + ) + profile = calculate_balloon_profile( + balloon_algorithm, planar, planar_faces, boundary, components + ) + faces = np.asarray(planar_faces, dtype=np.int32) + local = planar @ np.linalg.inv(metric_transform(world_metric)) + uv = np.column_stack( + ( + (local[:, 0] - x_min) / (x_max - x_min), + (local[:, 1] - y_min) / (y_max - y_min), + ) + ).astype(np.float32) + crop_center = np.asarray( + ((x_min + x_max) * 0.5, (y_min + y_max) * 0.5), + dtype=np.float64, + ) + local -= crop_center + vertices = np.column_stack((local, np.zeros(len(local), dtype=np.float64))) + return vertices, faces, uv, boundary, planar, profile + + +def _world_plane_metric(matrix_world): + from mathutils import Vector + + linear = matrix_world.to_3x3() + x = linear @ Vector((1.0, 0.0, 0.0)) + y = linear @ Vector((0.0, 1.0, 0.0)) + z = linear @ Vector((0.0, 0.0, 1.0)) + return ((x.dot(x), x.dot(y)), (x.dot(y), y.dot(y))), z.length + + +def _cutout_cleanup_area(spacing, world_metric): + import numpy as np + + if spacing <= 0.0: + raise ValueError("Cutout point spacing must be greater than zero") + area_scale = abs(float(np.linalg.det(metric_transform(world_metric)))) + if area_scale <= 1e-12: + raise ValueError("The Image Empty has a degenerate world transform") + return float(spacing) ** 2 / area_scale + + +def _load_field(path): + import numpy as np + + with np.load(path, allow_pickle=False) as archive: + points = np.asarray(archive["points"], dtype=np.float32) + intrinsics = np.asarray(archive["intrinsics"], dtype=np.float32) + valid = np.asarray(archive["valid"], dtype=bool) + calibration_valid = np.asarray(archive["calibration_valid"], dtype=bool) + image_size = tuple(int(value) for value in archive["image_size"]) + reference_depth = float(archive["reference_depth"]) + if ( + points.ndim != 3 + or points.shape[2] != 3 + or valid.shape != points.shape[:2] + or calibration_valid.shape != points.shape[:2] + ): + raise ValueError("The mesh point field is invalid") + if intrinsics.shape != (3, 3) or not reference_depth > 0.0: + raise ValueError("The mesh camera field is invalid") + return points, intrinsics, valid, calibration_valid, image_size, reference_depth + + +def _surface_uniform_scale( + source_object, + field, + bounds=None, +): + import numpy as np + + ( + _points, + intrinsics, + _valid, + _calibration_valid, + image_size, + reference_depth, + ) = field + width, height = image_size + x_min, x_max, y_min, y_max = bounds or plane_bounds(source_object) + plane_width = x_max - x_min + plane_height = y_max - y_min + fx, fy = float(intrinsics[0, 0]), float(intrinsics[1, 1]) + if fx <= 0.0 or fy <= 0.0: + raise ValueError("Camera intrinsics contain an invalid focal length") + scale_x = plane_width * fx / (max(width - 1, 1) * reference_depth) + scale_y = plane_height * fy / (max(height - 1, 1) * reference_depth) + return float(np.sqrt(scale_x * scale_y)) + + +def _depth_discontinuity_map(depth, valid): + import numpy as np + + depth = np.asarray(depth, dtype=np.float32) + valid = np.asarray(valid, dtype=bool) & np.isfinite(depth) & (depth > 0.0) + padded_depth = np.pad(depth, 1, mode="edge") + padded_valid = np.pad(valid, 1, mode="edge") + windows = np.lib.stride_tricks.sliding_window_view(padded_depth, (3, 3)) + usable = np.lib.stride_tricks.sliding_window_view(padded_valid, (3, 3)) + minimum = np.min(np.where(usable, windows, np.inf), axis=(-2, -1)) + maximum = np.max(np.where(usable, windows, -np.inf), axis=(-2, -1)) + result = (maximum - minimum) / np.maximum(np.abs(depth), 1e-6) + result[~usable.all(axis=(-2, -1))] = 1.0 + return result.astype(np.float32) + + +def _counter_clockwise_faces(points, faces): + import numpy as np + + points = np.asarray(points, dtype=np.float64) + result = np.asarray(faces, dtype=np.int32).copy() + polygons = points[result] + following = np.roll(polygons, -1, axis=1) + signed_area = np.sum( + polygons[..., 0] * following[..., 1] + - polygons[..., 1] * following[..., 0], + axis=1, + ) + result[signed_area < 0.0] = result[signed_area < 0.0, ::-1] + return result + + +def _assign_uv(mesh, uv): + import numpy as np + + for layer in list(mesh.uv_layers): + mesh.uv_layers.remove(layer) + uv_layer = mesh.uv_layers.new(name="UVMap") + loop_vertices = np.empty(len(mesh.loops), dtype=np.int32) + mesh.loops.foreach_get("vertex_index", loop_vertices) + loop_uv = np.asarray(uv, dtype=np.float32)[loop_vertices] + uv_layer.data.foreach_set("uv", loop_uv.ravel()) + + +def _assign_float_point_attribute(mesh, name, values): + import numpy as np + + attribute = mesh.attributes.new(name=name, type="FLOAT", domain="POINT") + attribute.data.foreach_set("value", np.asarray(values, dtype=np.float32)) + + +def _image_rgba(image): + import numpy as np + + width, height = (int(value) for value in image.size) + pixels = np.empty(len(image.pixels), dtype=np.float32) + image.pixels.foreach_get(pixels) + return np.flipud(pixels.reshape((height, width, 4))) + + +def _mask_crop_bounds(alpha): + import numpy as np + + values = np.asarray(alpha) + visible_y, visible_x = np.nonzero(values > CUTOUT_ALPHA_THRESHOLD) + if not len(visible_x): + raise ValueError("The Cutout selection contains no visible pixels") + return ( + int(visible_x.min()), + int(visible_y.min()), + int(visible_x.max()) + 1, + int(visible_y.max()) + 1, + ) + + +def _crop_plane_bounds(source_object, crop_bounds, source_size): + source_width, source_height = (float(value) for value in source_size) + left, top, right, bottom = (float(value) for value in crop_bounds) + x_min, x_max, y_min, y_max = plane_bounds(source_object) + width = x_max - x_min + height = y_max - y_min + return ( + x_min + left * width / source_width, + x_min + right * width / source_width, + y_max - bottom * height / source_height, + y_max - top * height / source_height, + ) + + +def _cropped_color_image(source_object, crop_bounds): + import numpy as np + + source = source_object.data + rgba = _image_rgba(source) + left, top, right, bottom = crop_bounds + width = right - left + height = bottom - top + if width <= 0 or height <= 0: + raise ValueError("The Cutout crop dimensions must be positive") + cropped = np.zeros((height, width, 4), dtype=np.float32) + source_height, source_width = rgba.shape[:2] + source_left = max(0, left) + source_top = max(0, top) + source_right = min(source_width, right) + source_bottom = min(source_height, bottom) + if source_right > source_left and source_bottom > source_top: + cropped[ + source_top - top : source_bottom - top, + source_left - left : source_right - left, + ] = rgba[source_top:source_bottom, source_left:source_right] + image = bpy.data.images.new( + color_data_name(source_object), + width=width, + height=height, + alpha=True, + ) + try: + image.colorspace_settings.name = source.colorspace_settings.name + image.alpha_mode = source.alpha_mode + image.pixels.foreach_set(np.flipud(cropped).ravel()) + image.pack() + image.update() + except Exception: + bpy.data.images.remove(image, do_unlink=True) + raise + return image + + +def _cutout_regions( + alpha, mesh_detail, threshold=CUTOUT_ALPHA_THRESHOLD +): + contour_resolution = max(512, min(int(mesh_detail) * 8, 1024)) + return alpha_regions( + alpha, + max_resolution=contour_resolution, + threshold=threshold, + ) + + +def _antialias_selection(selection): + import numpy as np + + values = np.asarray(selection, dtype=np.float32) + kernel = np.asarray((0.029, 0.235, 0.472, 0.235, 0.029), dtype=np.float32) + radius = len(kernel) // 2 + horizontal = np.zeros_like(values) + padded = np.pad(values, ((0, 0), (radius, radius)), mode="edge") + for offset, weight in enumerate(kernel): + horizontal += padded[:, offset : offset + values.shape[1]] * weight + result = np.zeros_like(values) + padded = np.pad(horizontal, ((radius, radius), (0, 0)), mode="edge") + for offset, weight in enumerate(kernel): + result += padded[offset : offset + values.shape[0]] * weight + return result + + +def _geometry_cutout_regions( + alpha, + polygon, + mesh_detail, + invert=False, + refine_selection=False, + selection=None, +): + import numpy as np + + values = np.asarray(alpha, dtype=np.float32) + if values.ndim != 2: + raise ValueError("Cutout Alpha must be two-dimensional") + source_height, source_width = values.shape + source_size = (source_width, source_height) + if refine_selection: + geometry_alpha = np.where(values > CUTOUT_ALPHA_THRESHOLD, values, 0.0) + regions, _image_size = _cutout_regions(geometry_alpha, mesh_detail) + return regions, source_size + geometry_alpha = selection + if geometry_alpha is None: + geometry_alpha = _antialias_selection( + polygon_mask(source_size, polygon, "NONZERO") + ) + geometry_alpha = visible_selection_mask( + values, + geometry_alpha, + invert, + alpha_threshold=CUTOUT_ALPHA_THRESHOLD, + ) + regions, _image_size = _cutout_regions(geometry_alpha, mesh_detail) + return regions, source_size + +def _local_cutout_rgba(source_object, polygon, invert=False): + import numpy as np + + rgba = _image_rgba(source_object.data).copy() + height, width = rgba.shape[:2] + selection = _antialias_selection( + polygon_mask((width, height), polygon, "NONZERO") + ) + selection = visible_selection_mask( + rgba[:, :, 3], + selection, + invert, + alpha_threshold=CUTOUT_ALPHA_THRESHOLD, + ) + rgba[:, :, 3] *= selection + rgba[rgba[:, :, 3] <= 0.0, :3] = 0.0 + if not np.any(rgba[:, :, 3] > 0.0): + raise ImageEditWarning("The selection contains no visible image pixels") + return rgba, selection + + +def _new_material( + context, + source_object, + color_image, + normal_image=None, + normal_space="TANGENT", +): + return create_material( + material_data_name(source_object), + color_image, + 0.0, + normal_image=normal_image, + normal_space=normal_space, + scene=context.scene, + ) + + +def build_cutout_object( + context, + source_object, + shape, + detail, + material, + field=None, + regions=None, + image_size=None, + bounds=None, + keep_original=False, + refine_selection=False, +): + import numpy as np + + bounds = bounds or plane_bounds(source_object) + crop_center = ( + (bounds[0] + bounds[1]) * 0.5, + (bounds[2] + bounds[3]) * 0.5, + ) + world_metric, world_z_scale = _world_plane_metric(source_object.matrix_world) + depth_image = None + depth_reference = None + depth_uniform_scale = None + depth_direction = None + preferences = addon_preferences(context) + balloon_algorithm = getattr( + preferences, "balloon_profile_algorithm", "POISSON" + ) + point_spacing = _cutout_point_spacing(source_object, detail) + cleanup_area = _cutout_cleanup_area(point_spacing, world_metric) + domain = _cutout_domain( + regions, + image_size, + bounds, + point_spacing, + world_metric, + balloon_algorithm, + ) + source_vertices, faces, uv, boundary, planar, balloon_profile = domain + faces = _counter_clockwise_faces(planar, faces) + vertices = _orient_cutout_vertices(source_vertices) + if world_z_scale <= 1e-10: + raise ValueError("The Image Empty has a degenerate world transform") + balloon_profile /= world_z_scale + if shape in {"DEPTH_BALLOON", "DEPTH_SURFACE"}: + ( + points, + _intrinsics, + valid, + calibration_valid, + _image_size, + _reference, + ) = field + depth_image = _surface_depth_image( + source_object, points, valid, points[..., 2] + ) + depth_uniform_scale = _surface_uniform_scale( + source_object, + field, + bounds, + ) + if not refine_selection: + depth_reference = UNREFINED_DEPTH_REFERENCE + if shape == "DEPTH_BALLOON": + depth_direction = DEFAULT_DEPTH_DIRECTION + elif shape == "DEPTH_SURFACE": + depth_reference = metric_depth_reference( + points[..., 2], + calibration_valid, + depth_uniform_scale, + ) + else: + depth_reference, depth_direction = depth_balloon_projection_parameters( + vertices, + uv, + points[..., 2], + calibration_valid, + depth_uniform_scale, + ) + + source_name = source_object.name + mesh = bpy.data.meshes.new(source_name) + mesh.from_pydata(np.asarray(vertices).tolist(), [], faces) + for polygon in mesh.polygons: + polygon.use_smooth = True + _assign_uv(mesh, uv) + _assign_float_point_attribute(mesh, BALLOON_ATTRIBUTE_NAME, balloon_profile) + mesh.materials.append(material) + mesh.update() + + mesh_object = bpy.data.objects.new(source_name, mesh) + context.collection.objects.link(mesh_object) + mesh_object.matrix_world = _cutout_world_matrix( + source_object.matrix_world, crop_center + ) + mesh_object["anyimage_mesh_shape"] = shape + node_group = cutout_node_group(shape) + modifier = mesh_object.modifiers.new(name=node_group.name, type="NODES") + modifier.node_group = node_group + modifier.show_group_selector = False + set_modifier_input( + modifier, + modifier_input_identifier(node_group, "Shape"), + CUTOUT_SHAPE_INDICES[shape], + ) + set_modifier_input( + modifier, + modifier_input_identifier(node_group, "Cleanup"), + cleanup_area, + ) + set_modifier_input( + modifier, + modifier_input_identifier(node_group, "Smooth Iterations"), + 0, + ) + set_modifier_input( + modifier, + modifier_input_identifier(node_group, "Smooth"), + 1.0, + ) + thickness = 1.0 if shape in {"BALLOON", "DEPTH_BALLOON"} else 0.0 + set_modifier_input( + modifier, + modifier_input_identifier(node_group, "Thickness"), + thickness, + ) + if shape in {"DEPTH_BALLOON", "DEPTH_SURFACE"}: + set_modifier_input( + modifier, + modifier_input_identifier( + node_group, + "Depth Scale", + ), + 1.0, + ) + if shape == "DEPTH_SURFACE": + set_modifier_input( + modifier, + modifier_input_identifier(node_group, "Uniform Scale"), + depth_uniform_scale, + ) + set_modifier_input( + modifier, + modifier_input_identifier(node_group, "Reference Depth"), + depth_reference, + ) + if shape == "DEPTH_BALLOON": + set_modifier_input( + modifier, + modifier_input_identifier(node_group, "Reference Depth"), + depth_reference, + ) + set_modifier_input( + modifier, + modifier_input_identifier(node_group, "Depth Direction"), + depth_direction, + ) + set_modifier_input( + modifier, + modifier_input_identifier(node_group, "Uniform Scale"), + depth_uniform_scale, + ) + if depth_image is not None: + set_modifier_input( + modifier, + modifier_input_identifier(node_group, "Depth Image"), + depth_image, + ) + for selected in context.selected_objects: + selected.select_set(False) + mesh_object.select_set(True) + context.view_layer.objects.active = mesh_object + if not keep_original: + bpy.data.objects.remove(source_object, do_unlink=True) + mesh_object.name = source_name + return mesh_object + + +def _load_normal_image(path, source_object): + image = bpy.data.images.load(str(path), check_existing=False) + image.name = normal_data_name(source_object) + image.colorspace_settings.name = "Non-Color" + image.pack() + return image + + +def _orient_cutout_object_normal(image): + import numpy as np + + width, height = (int(value) for value in image.size) + pixels = np.empty(width * height * 4, dtype=np.float32) + image.pixels.foreach_get(pixels) + rgba = pixels.reshape((height, width, 4)) + source = rgba[:, :, :3] * 2.0 - 1.0 + oriented = np.empty_like(source) + oriented[:, :, 0] = -source[:, :, 2] + oriented[:, :, 1] = -source[:, :, 0] + oriented[:, :, 2] = source[:, :, 1] + rgba[:, :, :3] = np.clip(oriented * 0.5 + 0.5, 0.0, 1.0) + image.pixels.foreach_set(rgba.ravel()) + image.update() + + +def limited_cutout_mesh_detail(mesh_detail, crop_size, max_input_size): + crop_width, crop_height = (int(value) for value in crop_size) + limited_width, limited_height = limited_image_size( + (crop_width, crop_height), + max_input_size, + ) + scale = max(limited_width, limited_height) / max(crop_width, crop_height) + return max(4, min(1024, round(int(mesh_detail) * scale))) + +def _cutout_point_spacing(source_object, detail): + import math + + x_min, x_max, y_min, y_max = plane_bounds(source_object) + metric, _world_z_scale = _world_plane_metric(source_object.matrix_world) + width = (x_max - x_min) * math.sqrt(max(metric[0][0], 0.0)) + height = (y_max - y_min) * math.sqrt(max(metric[1][1], 0.0)) + return max(width, height) / max(int(detail), 8) + + +def build_local_cutout( + operator, + context, + source_object, + shape, + selection_data, + invert=False, +): + if video_total_frames(source_object.data) != 1: + raise ValueError("Mesh conversion currently supports still images") + polygon = json.loads(selection_data) if selection_data else None + if not isinstance(polygon, list) or len(polygon) < 3: + raise ValueError("The Cutout selection is invalid") + _rgba, selection = _local_cutout_rgba(source_object, polygon, invert) + source_size = tuple(source_object.data.size) + crop_bounds = _mask_crop_bounds(selection) + left, top, right, bottom = crop_bounds + cropped_selection = selection[top:bottom, left:right] + mesh_detail = limited_cutout_mesh_detail( + operator.mesh_detail, + (right - left, bottom - top), + configured_max_ai_input_size(context), + ) + regions, image_size = _cutout_regions( + cropped_selection, + mesh_detail, + threshold=CUTOUT_ALPHA_THRESHOLD, + ) + local_bounds = _crop_plane_bounds( + source_object, + crop_bounds, + source_size, + ) + color_image = None + material = None + try: + color_image = _cropped_color_image(source_object, crop_bounds) + material = _new_material(context, source_object, color_image) + build_cutout_object( + context, + source_object, + shape, + mesh_detail, + material, + regions=regions, + image_size=image_size, + bounds=local_bounds, + keep_original=True, + ) + except Exception: + if material is not None and material.users == 0: + bpy.data.materials.remove(material) + if color_image is not None and color_image.users == 0: + bpy.data.images.remove(color_image) + raise + push_undo(f"Convert to {shape.replace('_', ' ').title()}") + + +def build_cutout_response(context, result, operator): + source_object = bpy.data.objects.get(operator.source_object_name) + if not is_image_empty(source_object): + raise RuntimeError("The source Image Empty no longer exists") + source_size = tuple(source_object.data.size) + crop_bounds = tuple( + int(value) + for value in json.loads( + result.file("bounds").read_text(encoding="utf-8") + ) + ) + local_bounds = _crop_plane_bounds( + source_object, + crop_bounds, + source_size, + ) + selection_image = bpy.data.images.load( + str(result.file("selection")), + check_existing=False, + ) + try: + selection_alpha = _image_rgba(selection_image)[:, :, 3] + mesh_detail = limited_cutout_mesh_detail( + operator.mesh_detail, + selection_image.size, + operator.max_input_size, + ) + regions, image_size = _cutout_regions( + selection_alpha, + mesh_detail, + threshold=CUTOUT_ALPHA_THRESHOLD, + ) + finally: + bpy.data.images.remove(selection_image) + color_image = create_color_image( + source_object, + result.directory, + assign_to_source=False, + ) + normal_path = result.directory / "normal.png" + + normal_image = ( + _load_normal_image(normal_path, source_object) + if normal_path.is_file() + else None + ) + if normal_image is not None and operator.shape == "DEPTH_SURFACE": + _orient_cutout_object_normal(normal_image) + material = _new_material( + context, + source_object, + color_image, + normal_image, + normal_space=( + "OBJECT" + if operator.shape == "DEPTH_SURFACE" and normal_image is not None + else "TANGENT" + ), + ) + field = ( + _load_field(result.file("field")) + if operator.shape in {"DEPTH_BALLOON", "DEPTH_SURFACE"} + else None + ) + mesh_object = build_cutout_object( + context, + source_object, + operator.shape, + mesh_detail, + material, + field=field, + regions=regions, + image_size=image_size, + bounds=local_bounds, + keep_original=True, + refine_selection=operator.refine_selection, + ) + if normal_image is not None and operator.shape != "DEPTH_SURFACE": + bake_tangent_normal_image( + context, + source_object, + mesh_object, + normal_image, + ) + push_undo(f"Convert to {operator.shape.replace('_', ' ').title()}") 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..531af07 --- /dev/null +++ b/src/anyimage/operators/cutout_tool/depth_calibration.py @@ -0,0 +1,166 @@ +"""Calibrate metric depth for Cutout Depth Balloon and Depth Surface.""" + + +PLANE_VARIANCE_FLOOR_RATIO = 0.025 +DEPTH_REFERENCE_PERCENTILE = 95.0 +UNREFINED_DEPTH_REFERENCE = 8.0 +DEFAULT_DEPTH_DIRECTION = (0.0, 0.0, 1.0) + + +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[:, 1:] + 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/server/geometry/cutout.py b/src/anyimage/operators/cutout_tool/mask_regions.py similarity index 95% rename from src/anyimage/server/geometry/cutout.py rename to src/anyimage/operators/cutout_tool/mask_regions.py index 28e4ec6..07ada7d 100644 --- a/src/anyimage/server/geometry/cutout.py +++ b/src/anyimage/operators/cutout_tool/mask_regions.py @@ -1,3 +1,6 @@ +"""Convert a Cutout Alpha mask into outer contours and holes.""" + + def _choose_boundary_edge(start, direction, candidates): right = (-direction[1], direction[0]) left = (direction[1], -direction[0]) @@ -193,7 +196,13 @@ def alpha_regions(alpha, max_resolution=512, threshold=0.5): return ( [ [ - [[float(x) * scale_x, float(y) * scale_y] for x, y in contour] + [ + [ + min(max(float(x) * scale_x, 0.0), source_size[0]), + min(max(float(y) * scale_y, 0.0), source_size[1]), + ] + for x, y in contour + ] for contour in region ] for region in regions diff --git a/src/anyimage/operators/cutout_tool/node_assets.py b/src/anyimage/operators/cutout_tool/node_assets.py new file mode 100644 index 0000000..930ab14 --- /dev/null +++ b/src/anyimage/operators/cutout_tool/node_assets.py @@ -0,0 +1,10 @@ +"""Load Geometry Nodes assets used by Cutout shapes.""" + +from ..common.node_asset import load_asset_node_group +from .shape_types import CUTOUT_NODE_GROUP_NAME, CUTOUT_SHAPES + + +def cutout_node_group(shape): + if shape not in CUTOUT_SHAPES: + raise ValueError(f"Unsupported Cutout shape: {shape}") + return load_asset_node_group(CUTOUT_NODE_GROUP_NAME) diff --git a/src/anyimage/operators/cutout_geometry.py b/src/anyimage/operators/cutout_tool/planar_mesh.py similarity index 97% rename from src/anyimage/operators/cutout_geometry.py rename to src/anyimage/operators/cutout_tool/planar_mesh.py index 6065d89..f561e19 100644 --- a/src/anyimage/operators/cutout_geometry.py +++ b/src/anyimage/operators/cutout_tool/planar_mesh.py @@ -289,7 +289,11 @@ def prepare_planar_contours( if perimeter <= 1e-12: continue contour_spacing = min(spacing, perimeter / 8.0) - contours.append(smooth_lasso_contour(planar, contour_spacing)) + smoothed = smooth_lasso_contour(planar, contour_spacing) + smoothed_local = smoothed @ np.linalg.inv(transform) + smoothed_local[:, 0] = np.clip(smoothed_local[:, 0], x_min, x_max) + smoothed_local[:, 1] = np.clip(smoothed_local[:, 1], y_min, y_max) + contours.append(smoothed_local @ transform) if not contours: raise ValueError("The Cutout has no region large enough for this resolution") interior = uniform_interior_points_for_contours(contours, spacing) @@ -446,4 +450,3 @@ def triangulate_planar_regions(regions, spacing, return_components=False): ) return (*result, components) if return_components else result - diff --git a/src/anyimage/operators/cutout.py b/src/anyimage/operators/cutout_tool/selection_tool.py similarity index 94% rename from src/anyimage/operators/cutout.py rename to src/anyimage/operators/cutout_tool/selection_tool.py index dcc5569..9d3c932 100644 --- a/src/anyimage/operators/cutout.py +++ b/src/anyimage/operators/cutout_tool/selection_tool.py @@ -2,12 +2,11 @@ import json import bpy -from ..properties import ( +from ...properties import ( refine_selection_property, ) -from .image_edit.interaction import hidden_string_property, serialize_matrix -from .image import is_image_empty, video_total_frames -from .image_edit.common import ( +from ..common.image import is_image_empty, video_total_frames +from ..common.image_edit import ( ImageEditWarning, apply_image_mask_settings, draw_image_mask_settings, @@ -15,12 +14,9 @@ from .image_edit.common import ( report_image_edit_exception, resolve_image_edit_click, ) -from .image_edit.mask import ( - ImageMaskGesture, - gesture_property, - selected_image_rgba, -) -from .mesh import BALLOON, CUTOUT, DEPTH_BALLOON, DEPTH_SURFACE +from ..common.image_interaction import hidden_string_property, serialize_matrix +from ..common.image_gesture import ImageMaskGesture, gesture_property +from ..common.image_mask import selected_image_rgba CUTOUT_DETAIL_MIN = 4 CUTOUT_DETAIL_MAX = 1024 @@ -77,7 +73,7 @@ def _open_shape_pie( pie = menu.layout.menu_pie() _pie_button( pie, - CUTOUT, + "CUTOUT", "Surface", "MESH_GRID", source_name, @@ -89,7 +85,7 @@ def _open_shape_pie( ) _pie_button( pie, - BALLOON, + "BALLOON", "Balloon", "MESH_UVSPHERE", source_name, @@ -101,7 +97,7 @@ def _open_shape_pie( ) _pie_button( pie, - DEPTH_BALLOON, + "DEPTH_BALLOON", "Depth Balloon", "META_BALL", source_name, @@ -113,7 +109,7 @@ def _open_shape_pie( ) _pie_button( pie, - DEPTH_SURFACE, + "DEPTH_SURFACE", "Depth Surface", "SURFACE_NCURVE", source_name, diff --git a/src/anyimage/operators/cutout_tool/shape_conversion.py b/src/anyimage/operators/cutout_tool/shape_conversion.py new file mode 100644 index 0000000..db706b7 --- /dev/null +++ b/src/anyimage/operators/cutout_tool/shape_conversion.py @@ -0,0 +1,214 @@ +import json + +import bpy + +from ...preferences import addon_preferences, configured_max_ai_input_size +from ...properties import BEN2_MODEL_KEY, DEFAULT_MOGE2_MODEL_KEY +from ...runtime import JobOperatorBase +from ...server.media.resolution import ( + DEFAULT_MAX_AI_INPUT_SIZE, + MAX_MAX_AI_INPUT_SIZE, + MIN_MAX_AI_INPUT_SIZE, +) +from ..common.image import ( + is_image_empty, + prepare_image_input, + remove_temporary_input, + video_total_frames, +) +from ..common.image_edit import report_image_edit_exception +from ..common.operator import ( + HIDDEN, + cleanup_temporary_input, + input_path, + invoke_ai_setup_if_needed, + moge2_parameters, + production_device, + require_environment, + require_model, + source_start_frame, +) +from .build_shapes import build_cutout_response, build_local_cutout +from .shape_types import DEPTH_CUTOUT_SHAPES, LOCAL_CUTOUT_SHAPES + + +def cutout_moge_settings(context): + preferences = addon_preferences(context) + model = getattr(preferences, "mesh_depth_model", DEFAULT_MOGE2_MODEL_KEY) + if not model.startswith("MOGE2_"): + model = DEFAULT_MOGE2_MODEL_KEY + return ( + model, + int(getattr(preferences, "mesh_resolution_level", 5)), + ) + + +class GenerateCutoutShape(JobOperatorBase, bpy.types.Operator): + bl_idname = "anyimage.generate_cutout_shape" + bl_label = "Generate Cutout Shape" + job_type = "prepare-geometry-data" + starting_message = "Preparing Cutout shape" + + input_path: bpy.props.StringProperty(options=HIDDEN) + source_object_name: bpy.props.StringProperty(options=HIDDEN) + shape: bpy.props.StringProperty(options=HIDDEN) + selection_data: bpy.props.StringProperty(options=HIDDEN) + mesh_detail: bpy.props.IntProperty(default=128, min=4, max=1024, options=HIDDEN) + model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=HIDDEN) + resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=HIDDEN) + max_input_size: bpy.props.IntProperty( + default=DEFAULT_MAX_AI_INPUT_SIZE, + min=MIN_MAX_AI_INPUT_SIZE, + max=MAX_MAX_AI_INPUT_SIZE, + options=HIDDEN, + ) + normal_analysis: bpy.props.BoolProperty(default=False, options=HIDDEN) + delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN) + refine_selection: bpy.props.BoolProperty(default=False, options=HIDDEN) + invert: bpy.props.BoolProperty(default=False, options=HIDDEN) + + def request(self, context): + require_environment() + if self.shape in DEPTH_CUTOUT_SHAPES or self.normal_analysis: + require_model(self.model) + if self.refine_selection: + require_model(BEN2_MODEL_KEY) + polygon = json.loads(self.selection_data) if self.selection_data else None + if polygon is None or len(polygon) < 3: + raise ValueError("The Cutout selection is invalid") + parameters = { + "input": str(input_path(self.input_path)), + "analysis_input": str(input_path(self.input_path)), + "shape": self.shape, + "polygon": polygon, + "mesh_detail": self.mesh_detail, + "device": production_device().lower(), + "video_frames": 1, + "start_frame": source_start_frame(self.source_object_name), + "max_input_size": int(self.max_input_size), + "refine_selection": self.refine_selection, + "normal_analysis": self.normal_analysis, + "invert": self.invert, + } + if self.shape in DEPTH_CUTOUT_SHAPES or self.normal_analysis: + parameters.update( + model=self.model, + resolution_level=self.resolution_level, + ) + if self.refine_selection: + parameters["refine_model"] = BEN2_MODEL_KEY + if self.shape not in DEPTH_CUTOUT_SHAPES: + return parameters + settings = context.scene.anyimage_settings + parameters.update( + moge2_parameters( + settings, + input_path(self.input_path), + self.model, + self.resolution_level, + source_start_frame(self.source_object_name), + ) + ) + parameters.update(shape=self.shape, polygon=polygon) + return parameters + + def response(self, context, result): + build_cutout_response(context, result, self) + return f"{self.shape.replace('_', ' ').title()} is ready" + + def cleanup(self): + cleanup_temporary_input(self.input_path, self.delete_input) + + +def start_cutout_job( + operator, + context, + shape, + selection_data, + refine_selection=False, + normal_analysis=False, + invert=False, +): + source_object = context.object + if video_total_frames(source_object.data) != 1: + operator.report({"ERROR"}, "Cutout currently supports still images") + return {"CANCELLED"} + setup_result = invoke_ai_setup_if_needed() + if setup_result is not None: + return setup_result + try: + source_path, delete_input = prepare_image_input( + source_object.data, + context.scene, + ) + model, resolution_level = cutout_moge_settings(context) + except (RuntimeError, ValueError) as error: + operator.report({"ERROR"}, str(error)) + return {"CANCELLED"} + result = bpy.ops.anyimage.generate_cutout_shape( + input_path=str(source_path), + source_object_name=source_object.name, + shape=shape, + selection_data=selection_data, + mesh_detail=operator.mesh_detail, + model=model, + resolution_level=resolution_level, + max_input_size=configured_max_ai_input_size(context), + normal_analysis=normal_analysis, + delete_input=delete_input, + refine_selection=refine_selection, + invert=invert, + ) + if "CANCELLED" in result and delete_input: + remove_temporary_input(source_path) + return {"CANCELLED"} if "CANCELLED" in result else {"FINISHED"} + + +class ConvertCutoutShape(bpy.types.Operator): + bl_idname = "anyimage.convert_cutout_shape" + bl_label = "Convert Cutout Shape" + bl_description = "Convert the saved Cutout selection into the chosen shape" + bl_options = {"UNDO"} + + source_object_name: bpy.props.StringProperty(options=HIDDEN) + selection_data: bpy.props.StringProperty(options=HIDDEN) + shape: bpy.props.StringProperty(options=HIDDEN) + mesh_detail: bpy.props.IntProperty(default=128, min=4, max=1024, options=HIDDEN) + refine_selection: bpy.props.BoolProperty(default=False, options=HIDDEN) + normal_analysis: bpy.props.BoolProperty(default=False, options=HIDDEN) + invert: bpy.props.BoolProperty(default=False, options=HIDDEN) + + def execute(self, context): + source_object = bpy.data.objects.get(self.source_object_name) + if not is_image_empty(source_object): + self.report({"ERROR"}, "The source Image Empty is no longer available") + return {"CANCELLED"} + context.view_layer.objects.active = source_object + source_object.select_set(True) + if ( + self.shape in LOCAL_CUTOUT_SHAPES + and not self.refine_selection + and not self.normal_analysis + ): + try: + build_local_cutout( + self, + context, + source_object, + self.shape, + self.selection_data, + self.invert, + ) + except Exception as error: + report_image_edit_exception(self, error) + return {"CANCELLED"} + return {"FINISHED"} + return start_cutout_job( + self, + context, + self.shape, + self.selection_data, + self.refine_selection, + self.normal_analysis, + self.invert, + ) diff --git a/src/anyimage/operators/cutout_tool/shape_types.py b/src/anyimage/operators/cutout_tool/shape_types.py new file mode 100644 index 0000000..7e86470 --- /dev/null +++ b/src/anyimage/operators/cutout_tool/shape_types.py @@ -0,0 +1,23 @@ +"""Cutout shape identifiers and Geometry Nodes asset names.""" + +CUTOUT_NODE_GROUP_NAME = "O Image Cutout" +CUTOUT_NODE_GROUP_NAMES = { + "CUTOUT": "O Image Surface", + "BALLOON": "O Image Balloon", + "DEPTH_BALLOON": "O Image Depth Balloon", + "DEPTH_SURFACE": "O Image Depth Surface", +} +CUTOUT_SHAPE_LABELS = { + "CUTOUT": "Surface", + "BALLOON": "Balloon", + "DEPTH_BALLOON": "Depth Balloon", + "DEPTH_SURFACE": "Depth Surface", +} +CUTOUT_SHAPE_INDICES = { + shape: index for index, shape in enumerate(CUTOUT_NODE_GROUP_NAMES) +} + +CUTOUT_SHAPES = frozenset(CUTOUT_NODE_GROUP_NAMES) +LOCAL_CUTOUT_SHAPES = frozenset({"CUTOUT", "BALLOON"}) +DEPTH_CUTOUT_SHAPES = frozenset({"DEPTH_BALLOON", "DEPTH_SURFACE"}) +BALLOON_ATTRIBUTE_NAME = "o_balloon" diff --git a/src/anyimage/operators/debug.py b/src/anyimage/operators/debug.py index 0f30112..1f36901 100644 --- a/src/anyimage/operators/debug.py +++ b/src/anyimage/operators/debug.py @@ -2,7 +2,7 @@ import bpy from ..runtime import JobOperatorBase from ..properties import BEN2_MODEL_KEY, REALESRGAN_GENERAL_WDN_MODEL_KEY -from .common import ( +from .common.operator import ( background_input_path, da3_parameters, input_path, diff --git a/src/anyimage/operators/depth_plane/__init__.py b/src/anyimage/operators/depth_plane/__init__.py deleted file mode 100644 index cf871b4..0000000 --- a/src/anyimage/operators/depth_plane/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Depth-plane asset builders retained for asset validation.""" diff --git a/src/anyimage/operators/depth_plane/build.py b/src/anyimage/operators/depth_plane/build.py deleted file mode 100644 index 024a30e..0000000 --- a/src/anyimage/operators/depth_plane/build.py +++ /dev/null @@ -1,547 +0,0 @@ -import bpy - -from ..image import ( - copy_depth_image, - create_sequence_image, - depth_data_name, - image_user_settings, - is_animated_image, - material_data_name, - plane_bounds, - sorted_frame_paths, -) -from ..material import ( - DEPTH_PLANE_NODE_GROUP_NAME, - DISPLACEMENT_MIDLEVEL, - DISPLACEMENT_STRENGTH, - MOGE_DEPTH_PLANE_NODE_GROUP_NAME, - ORIGINAL_DISPLACEMENT_MIDLEVEL, - ORIGINAL_DISPLACEMENT_STRENGTH, - create_material, - create_moge_material, - drive_modifier_input, - load_depth_plane_node_group, - modifier_input_identifier, - set_modifier_input, -) -from .data import ( - camera_plane_bounds, - create_depth_image, - create_depth_sequence_image, - depth_displacement_values, - extract_depth_arrays, - extract_intrinsics_array, - prepare_depth_values, - scale_depth_values, -) -def plane_fit_transform(source_object, camera_bounds): - target_bounds = plane_bounds(source_object) - camera_width = camera_bounds[1] - camera_bounds[0] - camera_height = camera_bounds[3] - camera_bounds[2] - if camera_width <= 0.0 or camera_height <= 0.0: - raise ValueError("The camera plane has invalid dimensions") - - target_width = target_bounds[1] - target_bounds[0] - target_height = target_bounds[3] - target_bounds[2] - scale_x = target_width / camera_width - scale_y = target_height / camera_height - scale_z = (scale_x * scale_y) ** 0.5 - camera_center_x = (camera_bounds[0] + camera_bounds[1]) * 0.5 - camera_center_y = (camera_bounds[2] + camera_bounds[3]) * 0.5 - target_center_x = (target_bounds[0] + target_bounds[1]) * 0.5 - target_center_y = (target_bounds[2] + target_bounds[3]) * 0.5 - return ( - scale_x, - scale_y, - scale_z, - target_center_x - camera_center_x * scale_x, - target_center_y - camera_center_y * scale_y, - ) - - -def apply_plane_transform(plane_object, source_object, camera_bounds): - from mathutils import Matrix - - scale_x, scale_y, scale_z, offset_x, offset_y = plane_fit_transform( - source_object, - camera_bounds, - ) - fit_matrix = Matrix( - ( - (scale_x, 0.0, 0.0, offset_x), - (0.0, scale_y, 0.0, offset_y), - (0.0, 0.0, 1.0, 0.0), - (0.0, 0.0, 0.0, 1.0), - ) - ) - plane_object.matrix_world = source_object.matrix_world @ fit_matrix - return abs(float(plane_object.scale.z)) * abs(scale_z) - - -def apply_object_scale_to_mesh(plane_object): - """Bake object scale into the plane while keeping location and rotation.""" - from mathutils import Matrix - - scale = plane_object.scale.copy() - plane_object.data.transform( - Matrix.Diagonal((scale.x, scale.y, scale.z, 1.0)) - ) - plane_object.data.update() - plane_object.scale = (1.0, 1.0, 1.0) - - - -def grid_segments(bounds, subdivision_level): - if subdivision_level < 0: - raise ValueError("Subdivision must not be negative") - - width = bounds[1] - bounds[0] - height = bounds[3] - bounds[2] - if width <= 0.0 or height <= 0.0: - raise ValueError("The plane has invalid dimensions") - - short_segments = 2**subdivision_level - if width >= height: - return ( - max(short_segments, int(short_segments * width / height + 0.5)), - short_segments, - ) - return ( - short_segments, - max(short_segments, int(short_segments * height / width + 0.5)), - ) - - -def create_plane_mesh(source_object, bounds=None, subdivision_level=5): - x_min, x_max, y_min, y_max = ( - bounds if bounds is not None else plane_bounds(source_object) - ) - if subdivision_level is None: - x_segments, y_segments = 1, 1 - else: - x_segments, y_segments = grid_segments( - (x_min, x_max, y_min, y_max), - subdivision_level, - ) - vertices = [] - uv_coordinates = [] - for y_index in range(y_segments + 1): - v = y_index / y_segments - y = y_min + (y_max - y_min) * v - for x_index in range(x_segments + 1): - u = x_index / x_segments - x = x_min + (x_max - x_min) * u - vertices.append((x, y, 0.0)) - uv_coordinates.append((u, v)) - - faces = [] - row_width = x_segments + 1 - for y_index in range(y_segments): - for x_index in range(x_segments): - bottom_left = y_index * row_width + x_index - faces.append( - ( - bottom_left, - bottom_left + 1, - bottom_left + row_width + 1, - bottom_left + row_width, - ) - ) - - mesh = bpy.data.meshes.new(source_object.name) - mesh.from_pydata(vertices, (), faces) - mesh.update() - for polygon in mesh.polygons: - polygon.use_smooth = True - uv_layer = mesh.uv_layers.new(name="UVMap") - for loop in mesh.loops: - uv_layer.data[loop.index].uv = uv_coordinates[ - loop.vertex_index - ] - return mesh - - - -def add_depth_plane_processing( - plane_object, - depth_image, - color_image, - material, - frame_start=1, - frame_duration=1, - use_cyclic=False, - animate_frame=False, -): - node_group = load_depth_plane_node_group() - modifier = plane_object.modifiers.new( - name=DEPTH_PLANE_NODE_GROUP_NAME, - type="NODES", - ) - modifier.node_group = node_group - modifier.show_group_selector = False - for name, value in ( - ("Depth Image", depth_image), - ("Color Image", color_image), - ("Material", material), - ("Frame", 1), - ("Frame Start", frame_start), - ("Frames", max(int(frame_duration or 1), 1)), - ("Cyclic", use_cyclic), - ): - identifier = modifier_input_identifier(node_group, name) - set_modifier_input(modifier, identifier, value) - if animate_frame: - frame_identifier = modifier_input_identifier(node_group, "Frame") - drive_modifier_input(plane_object, modifier, frame_identifier) - plane_object.update_tag(refresh={"DATA"}) - return modifier - - -def add_moge_depth_plane_processing( - plane_object, - depth_image, - color_image, - material, - frame_start=1, - frame_duration=1, - use_cyclic=False, - animate_frame=False, -): - node_group = load_depth_plane_node_group( - MOGE_DEPTH_PLANE_NODE_GROUP_NAME - ) - modifier = plane_object.modifiers.new( - name=MOGE_DEPTH_PLANE_NODE_GROUP_NAME, - type="NODES", - ) - modifier.node_group = node_group - modifier.show_group_selector = False - for name, value in ( - ("Depth Image", depth_image), - ("Color Image", color_image), - ("Material", material), - ("Frame", 1), - ("Frame Start", frame_start), - ("Frames", max(int(frame_duration or 1), 1)), - ("Cyclic", use_cyclic), - ): - identifier = modifier_input_identifier(node_group, name) - set_modifier_input(modifier, identifier, value) - if animate_frame: - frame_identifier = modifier_input_identifier(node_group, "Frame") - drive_modifier_input(plane_object, modifier, frame_identifier) - plane_object.update_tag(refresh={"DATA"}) - return modifier - - -def finish_depth_plane( - context, - source_object, - plane_object, - delete_source, -): - source_name = source_object.name - for selected in context.selected_objects: - selected.select_set(False) - plane_object.select_set(True) - context.view_layer.objects.active = plane_object - if delete_source: - bpy.data.objects.remove(source_object, do_unlink=True) - plane_object.name = source_name - return plane_object - - -def build_image_depth_plane( - context, - source_object, - delete_source, - shadeless=False, - color_image=None, - depth_frames_directory=None, - depth_image=None, -): - source_name = source_object.name - frame_count = 1 - frames_image = None - if depth_frames_directory is not None: - frames_image = create_sequence_image( - depth_data_name(source_object), - depth_frames_directory, - frame_prefix="image.", - ) - frame_count = len( - sorted_frame_paths(depth_frames_directory, "image.") - ) - ( - source_frame_start, - source_frame_offset, - source_frame_duration, - source_use_cyclic, - ) = image_user_settings(source_object) - uses_extracted = frames_image is not None - texture_frame_count = ( - frame_count if uses_extracted else source_frame_duration - ) - texture_frame_offset = 0 if uses_extracted else source_frame_offset - depth_source_image = depth_image or source_object.data - if depth_image is None: - if frames_image is not None: - depth_source_image = frames_image - elif not is_animated_image(source_object.data): - depth_source_image = copy_depth_image(source_object) - material_image = color_image - if material_image is None: - raise RuntimeError("Depth Plane material image is required") - mesh = create_plane_mesh(source_object, subdivision_level=None) - plane_object = bpy.data.objects.new(source_name, mesh) - context.collection.objects.link(plane_object) - plane_object.matrix_world = source_object.matrix_world.copy() - - depth_scale = abs(float(plane_object.scale.z)) - material = create_material( - material_data_name(source_object), - material_image, - depth_scale, - displacement_image=depth_source_image, - displacement_midlevel=ORIGINAL_DISPLACEMENT_MIDLEVEL, - displacement_scale=ORIGINAL_DISPLACEMENT_STRENGTH, - shadeless=shadeless, - frame_count=frame_count, - frame_start=source_frame_start, - color_frame_offset=texture_frame_offset, - color_frame_duration=texture_frame_count, - depth_frame_offset=texture_frame_offset, - depth_frame_duration=texture_frame_count, - use_cyclic=source_use_cyclic, - scene=context.scene, - ) - mesh.materials.append(material) - add_depth_plane_processing( - plane_object, - depth_source_image, - material_image, - material, - frame_start=source_frame_start, - frame_duration=frame_count, - use_cyclic=source_use_cyclic, - animate_frame=is_animated_image(source_object.data), - ) - plane_object["anyimage_frame_count"] = frame_count - return finish_depth_plane( - context, - source_object, - plane_object, - delete_source, - ) - - -def build_depth_plane( - context, - source_object, - npz_path, - delete_source, - shadeless=False, - depth_name="", - color_image=None, - depth_frames_directory=None, -): - source_name = source_object.name - raw_depth_arrays = extract_depth_arrays(npz_path) - raw_depth = raw_depth_arrays[0] - intrinsics = extract_intrinsics_array(npz_path) - raw_depth_values = prepare_depth_values(raw_depth) - bounds, reference_depth = camera_plane_bounds( - source_object, - raw_depth_values, - intrinsics, - DISPLACEMENT_STRENGTH, - ) - depth_arrays = [ - depth_displacement_values(frame) for frame in raw_depth_arrays - ] - depth_name = depth_name or depth_data_name(source_object) - frame_count = len(depth_arrays) - material_image = color_image - if material_image is None: - raise RuntimeError("Depth Plane material image is required") - ( - source_frame_start, - source_frame_offset, - source_frame_duration, - source_use_cyclic, - ) = image_user_settings(source_object) - mesh = create_plane_mesh(source_object, bounds, None) - plane_object = bpy.data.objects.new( - source_name, - mesh, - ) - context.collection.objects.link(plane_object) - depth_scale = apply_plane_transform( - plane_object, - source_object, - bounds, - ) - apply_object_scale_to_mesh(plane_object) - scaled_depth_arrays = [ - scale_depth_values(frame, depth_scale) for frame in depth_arrays - ] - depth_image = create_depth_image( - depth_name, - scaled_depth_arrays[0], - ) - if ( - is_animated_image(source_object.data) - and depth_frames_directory is not None - ): - bpy.data.images.remove(depth_image, do_unlink=True) - depth_image = create_depth_sequence_image( - depth_name, - scaled_depth_arrays, - context.scene, - depth_frames_directory, - ) - - material = create_material( - material_data_name(source_object), - material_image, - depth_scale, - displacement_image=depth_image, - displacement_midlevel=DISPLACEMENT_MIDLEVEL, - displacement_scale=DISPLACEMENT_STRENGTH, - shadeless=shadeless, - frame_count=frame_count, - frame_start=source_frame_start, - color_frame_offset=0 if color_image is not None else source_frame_offset, - color_frame_duration=( - frame_count if color_image is not None else source_frame_duration - ), - depth_frame_offset=0, - depth_frame_duration=frame_count, - use_cyclic=source_use_cyclic, - scene=context.scene, - ) - mesh.materials.append(material) - add_depth_plane_processing( - plane_object, - depth_image, - material_image, - material, - frame_start=source_frame_start, - frame_duration=frame_count, - use_cyclic=source_use_cyclic, - animate_frame=is_animated_image(source_object.data), - ) - plane_object["anyimage_reference_depth"] = reference_depth - plane_object["anyimage_camera_plane_width"] = bounds[1] - bounds[0] - plane_object["anyimage_camera_plane_height"] = bounds[3] - bounds[2] - plane_object["anyimage_depth_scale"] = depth_scale - plane_object["anyimage_frame_count"] = frame_count - - return finish_depth_plane( - context, - source_object, - plane_object, - delete_source, - ) - - -def build_moge2_depth_plane( - context, - source_object, - npz_path, - delete_source, - shadeless=False, - color_image=None, - data_frames_directory=None, -): - import numpy as np - - try: - with np.load(npz_path, allow_pickle=False) as archive: - depth = np.asarray(archive["depth"], dtype=np.float32) - mask = np.asarray(archive["mask"], dtype=bool) - intrinsics = np.asarray(archive["intrinsics"], dtype=np.float32) - except (KeyError, OSError) as error: - raise ValueError(f"Invalid MoGe-2 NPZ: {npz_path}") from error - if depth.ndim == 2: - depth = depth[None, ...] - mask = mask[None, ...] - if intrinsics.ndim == 2: - intrinsics = intrinsics[None, ...] - if not (len(depth) == len(mask) == len(intrinsics)): - raise ValueError("MoGe-2 arrays have different frame counts") - - first_valid = mask[0] & np.isfinite(depth[0]) & (depth[0] > 0.0) - if not first_valid.any(): - raise ValueError("MoGe-2 mask contains no valid depth") - reference_depth = float(np.median(depth[0][first_valid])) - height, width = depth[0].shape - pixel_intrinsics = intrinsics[0].copy() - pixel_intrinsics[0] *= width - pixel_intrinsics[1] *= height - bounds, _ = camera_plane_bounds( - source_object, - depth[0], - pixel_intrinsics, - 1.0, - reference_depth=reference_depth, - ) - displacement = [reference_depth - frame for frame in depth] - source_frame_start, _, _, source_use_cyclic = image_user_settings( - source_object - ) - mesh = create_plane_mesh(source_object, bounds, None) - plane_object = bpy.data.objects.new(source_object.name, mesh) - context.collection.objects.link(plane_object) - depth_scale = apply_plane_transform(plane_object, source_object, bounds) - apply_object_scale_to_mesh(plane_object) - depth_image = create_depth_image( - depth_data_name(source_object), - displacement[0] * depth_scale, - ) - if ( - is_animated_image(source_object.data) - and data_frames_directory is not None - ): - bpy.data.images.remove(depth_image, do_unlink=True) - depth_image = create_depth_sequence_image( - depth_data_name(source_object), - [frame * depth_scale for frame in displacement], - context.scene, - data_frames_directory, - "moge_depth", - ) - material_image = color_image - if material_image is None: - raise RuntimeError("Depth Plane material image is required") - material = create_moge_material( - source_object.name, - material_image, - depth_image, - depth_scale, - shadeless=shadeless, - frame_count=len(depth), - frame_start=source_frame_start, - use_cyclic=source_use_cyclic, - scene=context.scene, - ) - mesh.materials.append(material) - modifier = add_moge_depth_plane_processing( - plane_object, - depth_image, - material_image, - material, - frame_start=source_frame_start, - frame_duration=len(depth), - use_cyclic=source_use_cyclic, - animate_frame=is_animated_image(source_object.data), - ) - plane_object["anyimage_reference_depth"] = reference_depth - plane_object["anyimage_depth_scale"] = depth_scale - plane_object["anyimage_frame_count"] = len(depth) - return finish_depth_plane( - context, - source_object, - plane_object, - delete_source, - ) diff --git a/src/anyimage/operators/depth_plane/data.py b/src/anyimage/operators/depth_plane/data.py deleted file mode 100644 index 9d8d2f2..0000000 --- a/src/anyimage/operators/depth_plane/data.py +++ /dev/null @@ -1,261 +0,0 @@ -from pathlib import Path - -import bpy - -from ..image import force_sequence_source, pack_sequence_if_possible -DEPTH_KEY_CANDIDATES = ("depth", "depths") -INTRINSICS_KEY_CANDIDATES = ("intrinsics", "ixts") - -def create_depth_sequence_image( - name, - depth_frames, - _scene, - frame_directory, - frame_prefix="depth", -): - """Write one EXR per depth frame and return a SEQUENCE image datablock.""" - import numpy as np - - frame_directory = Path(frame_directory) - frame_directory.mkdir(parents=True, exist_ok=True) - height, width = depth_frames[0].shape - image = bpy.data.images.new( - name, - width=width, - height=height, - alpha=False, - float_buffer=True, - ) - image.colorspace_settings.name = "Non-Color" - original_file_format = image.file_format - frame_paths = [] - try: - image.file_format = "OPEN_EXR" - for frame_index, depth_values in enumerate(depth_frames): - # NPZ arrays use a top-left origin, while Blender image pixels start below. - depth_pixels = np.flipud(depth_values) - rgba = np.empty((height, width, 4), dtype=np.float32) - rgba[..., 0] = depth_pixels - rgba[..., 1] = depth_pixels - rgba[..., 2] = depth_pixels - rgba[..., 3] = 1.0 - image.pixels.foreach_set(rgba.ravel()) - frame_path = ( - frame_directory / f"{frame_prefix}.{frame_index + 1:04d}.exr" - ) - image.save(filepath=str(frame_path), save_copy=True) - if not frame_path.is_file(): - raise RuntimeError(f"Blender did not write: {frame_path}") - frame_paths.append(frame_path) - finally: - image.file_format = original_file_format - bpy.data.images.remove(image, do_unlink=True) - if not frame_paths: - raise RuntimeError("Unable to write the depth frame sequence") - try: - sequence_image = bpy.data.images.load( - str(frame_paths[0]), - check_existing=False, - ) - force_sequence_source(sequence_image) - except (OSError, RuntimeError) as error: - if "sequence_image" in locals(): - bpy.data.images.remove(sequence_image, do_unlink=True) - raise RuntimeError( - "Unable to load the depth frame sequence" - ) from error - sequence_image.name = name - sequence_image.colorspace_settings.name = "Non-Color" - pack_sequence_if_possible(sequence_image, len(frame_paths)) - return sequence_image - - - -def scale_depth_values(depth, depth_scale): - return prepare_depth_values(depth) * depth_scale - - -def extract_depth_arrays(npz_path): - try: - import numpy as np - except ImportError as error: - raise RuntimeError("Blender Python does not provide NumPy") from error - - try: - with np.load(npz_path, allow_pickle=False) as archive: - depth_key = next( - ( - key - for key in DEPTH_KEY_CANDIDATES - if key in archive.files - ), - None, - ) - if depth_key is None: - keys = ", ".join(archive.files) or "none" - raise ValueError( - f"Mini NPZ has no depth array; available keys: {keys}" - ) - depth = np.asarray(archive[depth_key], dtype=np.float32) - except OSError as error: - raise ValueError(f"Unable to read Mini NPZ: {npz_path}") from error - - if depth.ndim >= 3 and depth.shape[-1] == 1: - depth = depth[..., 0] - if depth.ndim == 2: - depth = depth[None, ...] - if depth.ndim != 3: - raise ValueError( - f"Unsupported depth array shape: {tuple(depth.shape)}" - ) - return [depth[index] for index in range(depth.shape[0])] - - -def extract_depth_array(npz_path): - return extract_depth_arrays(npz_path)[0] - - -def extract_intrinsics_array(npz_path): - try: - import numpy as np - except ImportError as error: - raise RuntimeError("Blender Python does not provide NumPy") from error - - try: - with np.load(npz_path, allow_pickle=False) as archive: - intrinsics_key = next( - ( - key - for key in INTRINSICS_KEY_CANDIDATES - if key in archive.files - ), - None, - ) - if intrinsics_key is None: - keys = ", ".join(archive.files) or "none" - raise ValueError( - "Mini NPZ has no camera intrinsics; " - f"available keys: {keys}" - ) - intrinsics = np.asarray( - archive[intrinsics_key], - dtype=np.float32, - ) - except OSError as error: - raise ValueError(f"Unable to read Mini NPZ: {npz_path}") from error - - if intrinsics.ndim == 3: - intrinsics = intrinsics[0] - if intrinsics.shape != (3, 3): - raise ValueError( - f"Unsupported intrinsics shape: {tuple(intrinsics.shape)}" - ) - return intrinsics - - -def prepare_depth_values(depth): - import numpy as np - - valid = np.isfinite(depth) - if not valid.any(): - raise ValueError("Depth array contains no finite values") - values = depth.astype(np.float32, copy=True) - values[~valid] = 0.0 - return values - - -def depth_displacement_values(depth): - """Convert raw DA3 camera distance to positive outward displacement.""" - import numpy as np - - return 1.0 - np.asarray(depth, dtype=np.float32) - - -def camera_plane_bounds( - source_object, - depth, - intrinsics, - displacement_strength, - reference_depth=None, -): - import numpy as np - - scale = abs(float(displacement_strength)) - if scale <= 1e-8: - raise ValueError("Displacement Strength must not be zero") - - if reference_depth is None: - valid = np.isfinite(depth) & (depth > 0.0) - if not valid.any(): - raise ValueError("Depth array contains no positive finite values") - reference_depth = float(np.median(depth[valid])) - reference_distance = reference_depth * scale - - height, width = depth.shape - fx = float(intrinsics[0, 0]) - fy = float(intrinsics[1, 1]) - cx = float(intrinsics[0, 2]) - cy = float(intrinsics[1, 2]) - if fx <= 0.0 or fy <= 0.0: - raise ValueError("Camera intrinsics contain an invalid focal length") - - source_width, source_height = source_object.data.size - if source_width <= 0 or source_height <= 0: - raise ValueError("The source image has no readable dimensions") - source_aspect = source_width / source_height - - # Predicted fx/fy may differ even for square pixels. Preserve their projected - # area while enforcing the source image aspect ratio to avoid image stretch. - raw_width = width * reference_distance / fx - raw_height = height * reference_distance / fy - plane_area = raw_width * raw_height - plane_width = float(np.sqrt(plane_area * source_aspect)) - plane_height = float(np.sqrt(plane_area / source_aspect)) - - x_min = -(cx / width) * plane_width - x_max = (1.0 - cx / width) * plane_width - y_min = -(1.0 - cy / height) * plane_height - y_max = (cy / height) * plane_height - - # Preserve an intentional Image Empty origin offset around the camera frame. - offset = source_object.empty_image_offset - x_offset = (float(offset[0]) + 0.5) * plane_width - y_offset = (float(offset[1]) + 0.5) * plane_height - return ( - ( - x_min + x_offset, - x_max + x_offset, - y_min + y_offset, - y_max + y_offset, - ), - reference_depth, - ) - - -def create_depth_image(name, depth_values): - import numpy as np - - height, width = depth_values.shape - image = bpy.data.images.new( - name, - width=width, - height=height, - alpha=False, - float_buffer=True, - ) - image.colorspace_settings.name = "Non-Color" - image.file_format = "OPEN_EXR" - - # NPZ arrays use a top-left origin, while Blender image pixels start below. - depth_pixels = np.flipud(depth_values) - rgba = np.empty((height, width, 4), dtype=np.float32) - rgba[..., 0] = depth_pixels - rgba[..., 1] = depth_pixels - rgba[..., 2] = depth_pixels - rgba[..., 3] = 1.0 - image.pixels.foreach_set(rgba.ravel()) - # Pack as EXR so values outside 0..1 survive saving and reopening the blend. - image.pack() - return image - - diff --git a/src/anyimage/operators/depth_plane/operator.py b/src/anyimage/operators/depth_plane/operator.py deleted file mode 100644 index 09cc5d9..0000000 --- a/src/anyimage/operators/depth_plane/operator.py +++ /dev/null @@ -1,521 +0,0 @@ -import bpy - -from ...properties import BEN2_MODEL_KEY, DEFAULT_MOGE2_MODEL_KEY -from ...runtime import JobOperatorBase -from ..common import ( - HIDDEN, - background_input_path, - cleanup_temporary_input, - da3_parameters, - input_path, - invoke_ai_setup_if_needed, - moge2_parameters, - push_undo, - require_environment, - require_model, - source_start_frame, -) -from .build import build_depth_plane, build_image_depth_plane, build_moge2_depth_plane -from ..image import ( - analysis_source_image, - default_extract_frames, - depth_data_name, - draw_process_resolution, - is_image_empty, - prepare_image_analysis_inputs, - prepare_image_input, - process_resolution_scale_property, - remove_temporary_input, - scaled_process_resolution, - video_total_frames, -) - - -def image_depth_model_items(owner, context): - from ...properties import depth_plane_model_enum_items - - return depth_plane_model_enum_items(owner, context) - - - -class ConvertToDepthPlane(bpy.types.Operator): - bl_idname = "anyimage.convert_to_depth_plane" - bl_label = "Convert to Depth Plane" - bl_description = "Convert an image to a displaced depth plane" - bl_options = {"REGISTER"} - - shadeless: bpy.props.BoolProperty( - name="Shadeless", - description="Display the source color without scene lighting", - default=False, - ) - remove_background: bpy.props.BoolProperty( - name="Remove Background", - description="Remove the background during conversion with BEN2", - default=False, - ) - model: bpy.props.EnumProperty( - name="Depth From", - description="Model or original image used to build depth", - items=image_depth_model_items, - default=0, - ) - process_res_factor: process_resolution_scale_property() - resolution_level: bpy.props.IntProperty( - name="Resolution Level", - description="MoGe-2 inference resolution level; higher is finer and slower", - default=5, - min=0, - max=9, - ) - keep_original: bpy.props.BoolProperty( - name="Keep Original", - description="Keep the source Image Empty after conversion", - default=False, - ) - - @classmethod - def poll(cls, context): - from ...runtime import runtime - - return is_image_empty(context.object) and not runtime.server_busy() - - def invoke(self, context, _event): - setup_result = invoke_ai_setup_if_needed() - if setup_result is not None: - return setup_result - return context.window_manager.invoke_props_dialog(self, width=340) - - def draw(self, context): - layout = self.layout - layout.prop(self, "model") - if self.model in {"SMALL", "BASE"}: - draw_process_resolution( - layout, - self, - analysis_source_image(context.object.data), - ) - elif self.model.startswith("MOGE2_"): - layout.prop(self, "resolution_level") - layout.prop(self, "remove_background") - layout.prop(self, "shadeless") - layout.separator(type="LINE") - layout.prop(self, "keep_original") - - def execute(self, context): - source_object = context.object - if video_total_frames(source_object.data) > 1: - context.scene.anyimage_settings.video_frames = ( - default_extract_frames(source_object) - ) - if self.model == "ORIGINAL": - try: - source_path, delete_input = prepare_image_input( - source_object.data, - context.scene, - ) - except RuntimeError as error: - self.report({"ERROR"}, str(error)) - return {"CANCELLED"} - run_original = ( - bpy.ops.anyimage.generate_original_background - if self.remove_background - else bpy.ops.anyimage.extract_original_frames - ) - result = run_original( - input_path=str(source_path), - source_object_name=source_object.name, - delete_source=not self.keep_original, - delete_input=delete_input, - shadeless=self.shadeless, - ) - if "CANCELLED" not in result: - return {"FINISHED"} - if delete_input: - remove_temporary_input(source_path) - return {"CANCELLED"} - - if self.model.startswith("MOGE2_"): - try: - ( - source_path, - delete_input, - analysis_path, - delete_analysis_input, - ) = prepare_image_analysis_inputs( - source_object.data, - context.scene, - ) - except RuntimeError as error: - self.report({"ERROR"}, str(error)) - return {"CANCELLED"} - result = bpy.ops.anyimage.generate_moge2_depth( - input_path=str(source_path), - analysis_input_path=str(analysis_path), - source_object_name=source_object.name, - resolution_level=self.resolution_level, - model=self.model, - delete_source=not self.keep_original, - delete_input=delete_input, - delete_analysis_input=delete_analysis_input, - shadeless=self.shadeless, - remove_background=self.remove_background, - ) - if "CANCELLED" not in result: - return {"FINISHED"} - if delete_input: - remove_temporary_input(source_path) - if delete_analysis_input: - remove_temporary_input(analysis_path) - return {"CANCELLED"} - - image = analysis_source_image(source_object.data) - try: - process_res = scaled_process_resolution( - image, - self.process_res_factor, - ) - except ValueError as error: - self.report({"ERROR"}, str(error)) - return {"CANCELLED"} - try: - ( - source_path, - delete_input, - analysis_path, - delete_analysis_input, - ) = prepare_image_analysis_inputs( - source_object.data, - context.scene, - ) - except RuntimeError as error: - self.report({"ERROR"}, str(error)) - return {"CANCELLED"} - - result = bpy.ops.anyimage.generate_da3_depth( - input_path=str(source_path), - analysis_input_path=str(analysis_path), - source_object_name=source_object.name, - depth_data_name=depth_data_name(source_object), - model=self.model, - process_res=process_res, - delete_source=not self.keep_original, - delete_input=delete_input, - delete_analysis_input=delete_analysis_input, - shadeless=self.shadeless, - remove_background=self.remove_background, - ) - if "CANCELLED" in result: - if delete_input: - remove_temporary_input(source_path) - if delete_analysis_input: - remove_temporary_input(analysis_path) - return {"CANCELLED"} - return {"FINISHED"} - - - - - - -def build_da3_depth_response( - context, - result, - *, - source_object_name, - depth_data_name, - remove_background, - delete_source, - shadeless, -): - from .build import build_depth_plane - from ..image import ( - create_color_image, - is_animated_image, - is_image_empty, - ) - - directory = result.directory - source_object = bpy.data.objects.get(source_object_name) - if not is_image_empty(source_object): - raise RuntimeError("The source image empty is no longer available") - animated = is_animated_image(source_object.data) - color_image = create_color_image( - source_object, - directory, - assign_to_source=False, - ) - build_depth_plane( - context, - source_object, - result.file("depth"), - delete_source, - shadeless=shadeless, - depth_name=depth_data_name, - color_image=color_image, - depth_frames_directory=directory if animated else None, - ) - push_undo("Convert to Depth Plane") - - -def build_moge2_depth_response( - context, - result, - *, - source_object_name, - remove_background, - delete_source, - shadeless, -): - from .build import build_moge2_depth_plane - from ..image import create_color_image, is_image_empty - - directory = result.directory - source_object = bpy.data.objects.get(source_object_name) - if not is_image_empty(source_object): - raise RuntimeError("The source image empty is no longer available") - color_image = create_color_image( - source_object, - directory, - assign_to_source=False, - ) - build_moge2_depth_plane( - context, - source_object, - result.file("depth"), - delete_source, - shadeless=shadeless, - color_image=color_image, - data_frames_directory=directory, - ) - push_undo("Convert to Depth Plane") - - -def build_original_depth_response( - context, - result, - *, - source_object_name, - background, - delete_source, - shadeless, -): - from .build import build_image_depth_plane - from ..image import ( - create_color_image, - is_animated_image, - is_image_empty, - ) - - directory = result.directory - source_object = bpy.data.objects.get(source_object_name) - if not is_image_empty(source_object): - raise RuntimeError("The source image empty is no longer available") - animated = is_animated_image(source_object.data) - color_image = create_color_image( - source_object, - directory, - assign_to_source=False, - ) - build_image_depth_plane( - context, - source_object, - delete_source, - shadeless=shadeless, - color_image=color_image, - depth_frames_directory=directory if animated else None, - ) - push_undo("Convert to Depth Plane") - -class GenerateDa3Depth(JobOperatorBase, bpy.types.Operator): - bl_idname = "anyimage.generate_da3_depth" - bl_label = "Generate DA3 Depth" - job_type = "generate-depth" - starting_message = "Starting depth estimation" - - input_path: bpy.props.StringProperty(options=HIDDEN) - analysis_input_path: bpy.props.StringProperty(options=HIDDEN) - source_object_name: bpy.props.StringProperty(options=HIDDEN) - depth_data_name: bpy.props.StringProperty(options=HIDDEN) - model: bpy.props.StringProperty(default="BASE", options=HIDDEN) - process_res: bpy.props.IntProperty(default=1024, min=1, options=HIDDEN) - delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN) - delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN) - delete_analysis_input: bpy.props.BoolProperty(default=False, options=HIDDEN) - shadeless: bpy.props.BoolProperty(default=False, options=HIDDEN) - remove_background: bpy.props.BoolProperty(default=False, options=HIDDEN) - - def request(self, context): - require_environment() - require_model(self.model) - if self.remove_background: - require_model(BEN2_MODEL_KEY) - settings = context.scene.anyimage_settings - parameters = { - **da3_parameters( - settings, - input_path(self.input_path), - self.model, - self.process_res, - source_start_frame(self.source_object_name), - ), - "inference_mode": "per_frame", - "remove_background_model": ( - BEN2_MODEL_KEY if self.remove_background else "" - ), - } - analysis_value = getattr(self, "analysis_input_path", "") or self.input_path - parameters["analysis_input"] = str(input_path(analysis_value)) - return parameters - - def response(self, context, result): - build_da3_depth_response( - context, - result, - source_object_name=self.source_object_name, - depth_data_name=self.depth_data_name, - remove_background=self.remove_background, - delete_source=self.delete_source, - shadeless=self.shadeless, - ) - return "Depth plane is ready" - - def cleanup(self): - cleanup_temporary_input(self.input_path, self.delete_input) - cleanup_temporary_input( - getattr(self, "analysis_input_path", ""), - getattr(self, "delete_analysis_input", False), - ) - - -class GenerateMoge2Depth(JobOperatorBase, bpy.types.Operator): - bl_idname = "anyimage.generate_moge2_depth" - bl_label = "Generate MoGe-2 Depth" - job_type = "generate-depth" - starting_message = "Starting MoGe-2 inference" - - input_path: bpy.props.StringProperty(options=HIDDEN) - analysis_input_path: bpy.props.StringProperty(options=HIDDEN) - source_object_name: bpy.props.StringProperty(options=HIDDEN) - model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=HIDDEN) - resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=HIDDEN) - delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN) - delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN) - delete_analysis_input: bpy.props.BoolProperty(default=False, options=HIDDEN) - shadeless: bpy.props.BoolProperty(default=False, options=HIDDEN) - remove_background: bpy.props.BoolProperty(default=False, options=HIDDEN) - - def request(self, context): - require_environment() - require_model(self.model) - if self.remove_background: - require_model(BEN2_MODEL_KEY) - settings = context.scene.anyimage_settings - parameters = { - **moge2_parameters( - settings, - input_path(self.input_path), - self.model, - self.resolution_level, - source_start_frame(self.source_object_name), - ), - "remove_background_model": ( - BEN2_MODEL_KEY if self.remove_background else "" - ), - } - analysis_value = getattr(self, "analysis_input_path", "") or self.input_path - parameters["analysis_input"] = str(input_path(analysis_value)) - return parameters - - def response(self, context, result): - build_moge2_depth_response( - context, - result, - source_object_name=self.source_object_name, - remove_background=self.remove_background, - delete_source=self.delete_source, - shadeless=self.shadeless, - ) - return "MoGe-2 depth plane is ready" - - def cleanup(self): - cleanup_temporary_input(self.input_path, self.delete_input) - cleanup_temporary_input( - getattr(self, "analysis_input_path", ""), - getattr(self, "delete_analysis_input", False), - ) - - -class GenerateOriginalBackground(JobOperatorBase, bpy.types.Operator): - bl_idname = "anyimage.generate_original_background" - bl_label = "Generate Original Background" - job_type = "remove-background" - starting_message = "Starting background removal" - - input_path: bpy.props.StringProperty(options=HIDDEN) - source_object_name: bpy.props.StringProperty(options=HIDDEN) - delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN) - delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN) - shadeless: bpy.props.BoolProperty(default=False, options=HIDDEN) - - def request(self, context): - require_environment() - require_model(BEN2_MODEL_KEY) - settings = context.scene.anyimage_settings - return { - "input": str(background_input_path(self.input_path)), - "model": BEN2_MODEL_KEY, - "device": production_device().lower(), - "video_frames": settings.video_frames, - "start_frame": source_start_frame(self.source_object_name), - } - - def response(self, context, result): - build_original_depth_response( - context, - result, - source_object_name=self.source_object_name, - background=True, - delete_source=self.delete_source, - shadeless=self.shadeless, - ) - return "Depth plane is ready" - - def cleanup(self): - cleanup_temporary_input(self.input_path, self.delete_input) - - -class ExtractOriginalFrames(JobOperatorBase, bpy.types.Operator): - bl_idname = "anyimage.extract_original_frames" - bl_label = "Extract Original Frames" - job_type = "extract-frames" - starting_message = "Extracting source frames" - - input_path: bpy.props.StringProperty(options=HIDDEN) - source_object_name: bpy.props.StringProperty(options=HIDDEN) - delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN) - delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN) - shadeless: bpy.props.BoolProperty(default=False, options=HIDDEN) - - def request(self, context): - require_environment() - settings = context.scene.anyimage_settings - return { - "input": str(input_path(self.input_path)), - "video_frames": settings.video_frames, - "start_frame": source_start_frame(self.source_object_name), - } - - def response(self, context, result): - build_original_depth_response( - context, - result, - source_object_name=self.source_object_name, - background=False, - delete_source=self.delete_source, - shadeless=self.shadeless, - ) - return "Depth plane is ready" - - def cleanup(self): - cleanup_temporary_input(self.input_path, self.delete_input) diff --git a/src/anyimage/operators/image_edit/__init__.py b/src/anyimage/operators/image_tool/__init__.py similarity index 58% rename from src/anyimage/operators/image_edit/__init__.py rename to src/anyimage/operators/image_tool/__init__.py index 706ffd8..ec918ac 100644 --- a/src/anyimage/operators/image_edit/__init__.py +++ b/src/anyimage/operators/image_tool/__init__.py @@ -1,16 +1,14 @@ from .box import ImageBoxTool from .lasso import ImageLassoTool from .line import ImageLineTool -from .mask import ActivateImageEditTool, EditImageMask, RefineImageMask -from .perspective import EditImagePerspective, ImagePerspectiveTool +from .edit_mask import ActivateImageTool, EditImageMask, RefineImageMask from .polyline import ImagePolylineTool CLASSES = ( EditImageMask, RefineImageMask, - ActivateImageEditTool, - EditImagePerspective, + ActivateImageTool, ) __all__ = ( @@ -19,5 +17,4 @@ __all__ = ( "ImageLassoTool", "ImageLineTool", "ImagePolylineTool", - "ImagePerspectiveTool", ) diff --git a/src/anyimage/operators/image_edit/box.py b/src/anyimage/operators/image_tool/box.py similarity index 87% rename from src/anyimage/operators/image_edit/box.py rename to src/anyimage/operators/image_tool/box.py index 055ef0d..1c7d293 100644 --- a/src/anyimage/operators/image_edit/box.py +++ b/src/anyimage/operators/image_tool/box.py @@ -1,7 +1,7 @@ import bpy -from .common import draw_image_mask_settings -from .mask import EditImageMask +from ..common.image_edit import draw_image_mask_settings +from .edit_mask import EditImageMask class ImageBoxTool(bpy.types.WorkSpaceTool): diff --git a/src/anyimage/operators/image_tool/edit_mask.py b/src/anyimage/operators/image_tool/edit_mask.py new file mode 100644 index 0000000..297b15c --- /dev/null +++ b/src/anyimage/operators/image_tool/edit_mask.py @@ -0,0 +1,145 @@ +import json + +import bpy + +from ...properties import refine_selection_property +from ...runtime import JobOperatorBase +from ..common.image import ( + is_image_empty, + prepare_image_input, + remove_temporary_input, + video_total_frames, +) +from ..common.image_edit import ( + ImageEditWarning, + image_mask_refine_request, + polygon_bounds, + report_image_edit_exception, +) +from ..common.image_gesture import ImageMaskGesture, gesture_property +from ..common.image_interaction import hidden_string_property +from ..common.image_mask import ( + edit_image_object, + polygon_mask, + refined_mask_from_pixels, +) +from ..common.operator import HIDDEN, cleanup_temporary_input, push_undo + +class EditImageMask(ImageMaskGesture, bpy.types.Operator): + bl_idname = "anyimage.edit_image_mask" + bl_label = "Edit Image Mask" + bl_description = "Edit and tightly crop an Image Empty with a gesture" + bl_options = {"REGISTER", "UNDO"} + + source_object_name: hidden_string_property() + source_matrix_data: hidden_string_property() + selection_data: hidden_string_property() + refine_selection: refine_selection_property() + invert: bpy.props.BoolProperty( + name="Invert", + description="Keep the opposite side of the refined gesture mask", + default=False, + options={"SKIP_SAVE"}, + ) + gesture: gesture_property() + + + def execute(self, context): + source_object = bpy.data.objects.get(self.source_object_name) + if not is_image_empty(source_object): + self.report({"ERROR"}, "The source Image Empty is no longer available") + return {"CANCELLED"} + try: + polygon = json.loads(self.selection_data) + polygon_bounds(polygon) + if video_total_frames(source_object.data) > 1: + raise RuntimeError("Image tools only support a still image") + if self.refine_selection: + source_path, delete_input = prepare_image_input( + source_object.data, context.scene + ) + result = bpy.ops.anyimage.refine_image_mask( + input_path=str(source_path), + delete_input=delete_input, + source_object_name=self.source_object_name, + selection_data=self.selection_data, + invert=self.invert, + ) + if "CANCELLED" in result: + if delete_input: + remove_temporary_input(source_path) + return {"CANCELLED"} + return {"FINISHED"} + edit_image_object( + source_object, + polygon_mask(tuple(source_object.data.size), polygon), + self.invert, + ) + except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error: + report_image_edit_exception(self, error) + return {"CANCELLED"} + return {"FINISHED"} + + +class RefineImageMask(JobOperatorBase, bpy.types.Operator): + bl_idname = "anyimage.refine_image_mask" + bl_label = "Refine Image Mask" + job_type = "refine-image-mask" + starting_message = "Refining image selection" + + input_path: bpy.props.StringProperty(options=HIDDEN) + delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN) + source_object_name: hidden_string_property() + selection_data: hidden_string_property() + invert: bpy.props.BoolProperty(default=False, options=HIDDEN) + + def request(self, _context): + return image_mask_refine_request(self.input_path, self.selection_data) + + def response(self, _context, result): + source_object = bpy.data.objects.get(self.source_object_name) + if not is_image_empty(source_object): + raise RuntimeError("The source Image Empty is no longer available") + import numpy as np + + bounds = tuple(json.loads(result.file("bounds").read_text(encoding="utf-8"))) + refined_image = bpy.data.images.load( + str(result.file("image")), check_existing=False + ) + try: + refined_pixels = np.empty(len(refined_image.pixels), dtype=np.float32) + refined_image.pixels.foreach_get(refined_pixels) + mask = refined_mask_from_pixels( + refined_pixels, + tuple(refined_image.size), + bounds, + tuple(source_object.data.size), + ) + finally: + bpy.data.images.remove(refined_image, do_unlink=True) + try: + edit_image_object(source_object, mask, self.invert) + except ImageEditWarning as error: + self.report({"WARNING"}, str(error)) + return str(error) + push_undo("Edit Image Mask") + return "Image selection refined" + + def cleanup(self): + cleanup_temporary_input(self.input_path, self.delete_input) + + +class ActivateImageTool(bpy.types.Operator): + bl_idname = "anyimage.activate_image_tool" + bl_label = "Image Tool" + bl_description = "Activate the Image Box tool in the 3D Viewport" + bl_options = {"INTERNAL"} + + @classmethod + def poll(cls, context): + return image_edit_poll(context) + + def execute(self, _context): + return bpy.ops.wm.tool_set_by_id( + name="anyimage.image_box", space_type="VIEW_3D" + ) diff --git a/src/anyimage/operators/image_edit/lasso.py b/src/anyimage/operators/image_tool/lasso.py similarity index 87% rename from src/anyimage/operators/image_edit/lasso.py rename to src/anyimage/operators/image_tool/lasso.py index 1183e3c..c54fb0e 100644 --- a/src/anyimage/operators/image_edit/lasso.py +++ b/src/anyimage/operators/image_tool/lasso.py @@ -1,7 +1,7 @@ import bpy -from .common import draw_image_mask_settings -from .mask import EditImageMask +from ..common.image_edit import draw_image_mask_settings +from .edit_mask import EditImageMask class ImageLassoTool(bpy.types.WorkSpaceTool): diff --git a/src/anyimage/operators/image_edit/line.py b/src/anyimage/operators/image_tool/line.py similarity index 93% rename from src/anyimage/operators/image_edit/line.py rename to src/anyimage/operators/image_tool/line.py index c111d84..f59edd0 100644 --- a/src/anyimage/operators/image_edit/line.py +++ b/src/anyimage/operators/image_tool/line.py @@ -1,6 +1,6 @@ import bpy -from .mask import EditImageMask +from .edit_mask import EditImageMask class ImageLineTool(bpy.types.WorkSpaceTool): diff --git a/src/anyimage/operators/image_edit/polyline.py b/src/anyimage/operators/image_tool/polyline.py similarity index 93% rename from src/anyimage/operators/image_edit/polyline.py rename to src/anyimage/operators/image_tool/polyline.py index 01bafdc..c59c195 100644 --- a/src/anyimage/operators/image_edit/polyline.py +++ b/src/anyimage/operators/image_tool/polyline.py @@ -1,6 +1,6 @@ import bpy -from .mask import EditImageMask +from .edit_mask import EditImageMask class ImagePolylineTool(bpy.types.WorkSpaceTool): diff --git a/src/anyimage/operators/mesh.py b/src/anyimage/operators/mesh.py deleted file mode 100644 index 4bf727a..0000000 --- a/src/anyimage/operators/mesh.py +++ /dev/null @@ -1,1579 +0,0 @@ -import json -import bpy - -from ..preferences import addon_preferences -from ..properties import BEN2_MODEL_KEY, DEFAULT_MOGE2_MODEL_KEY -from ..runtime import JobOperatorBase -from ..server.geometry.cutout import alpha_regions -from . import cutout_nodes -from .cutout_depth import ( - depth_balloon_fields, - create_depth_balloon_base_plane, - load_depth_balloon_data, - orient_depth_balloon_plane, -) -from .cutout_geometry import ( - metric_transform, - prepare_planar_regions, - triangulate_planar_regions, -) -from .cutout_profile import balloon_profile as calculate_balloon_profile -from .common import ( - HIDDEN, - cleanup_temporary_input, - da3_parameters, - input_path, - invoke_ai_setup_if_needed, - moge2_parameters, - production_device, - push_undo, - require_environment, - require_model, - source_start_frame, -) -from .image import ( - analysis_source_image, - color_data_name, - create_color_image, - depth_data_name, - image_base_name, - image_user_settings, - is_image_empty, - material_data_name, - normal_data_name, - plane_bounds, - prepare_image_analysis_inputs, - prepare_image_input, - remove_temporary_input, - scaled_process_resolution, - video_total_frames, -) -from .image_edit.common import ( - ImageEditWarning, - polygon_bounds, - report_image_edit_exception, -) -from .image_edit.mask import polygon_mask -from .material import ( - IMAGE_MATERIAL_NODE_GROUP_NAME, - create_material, - load_mesh_plane_node_group, - modifier_input_identifier, - set_modifier_input, -) -from .normal_map import bake_tangent_normal_image - -PLANE = "PLANE" -DEPTH_PLANE = "DEPTH_PLANE" -CUTOUT = "CUTOUT" -BALLOON = "BALLOON" -DEPTH_BALLOON = "DEPTH_BALLOON" -DEPTH_SURFACE = "DEPTH_SURFACE" -CUTOUT_SHAPES = {CUTOUT, BALLOON, DEPTH_BALLOON, DEPTH_SURFACE} -LOCAL_CUTOUT_SHAPES = {CUTOUT, BALLOON} -DEPTH_SHAPES = {DEPTH_PLANE, DEPTH_BALLOON, DEPTH_SURFACE} -BALLOON_ATTRIBUTE = cutout_nodes.BALLOON_ATTRIBUTE -NODE_GROUP_VERSION = 13 -CUTOUT_AXIS_TO_SOURCE = ( - (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), -) -DEPTH_EDGE_THRESHOLD = 12.0 / 255.0 - - -def _plane_domain(bounds, detail, world_metric=None): - import numpy as np - - x_min, x_max, y_min, y_max = bounds - vertices = np.asarray( - ( - (x_min, y_min, 0.0), - (x_max, y_min, 0.0), - (x_max, y_max, 0.0), - (x_min, y_max, 0.0), - ), - dtype=np.float32, - ) - - faces = np.asarray(((0, 1, 2, 3),), dtype=np.int32) - uv = np.asarray(((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))) - boundary = np.ones(4, dtype=bool) - planar = vertices[:, :2] @ metric_transform(world_metric) - return vertices, faces, uv, boundary, planar - - -def _depth_field_image(source_object, anchored): - import numpy as np - - values = np.asarray(anchored, dtype=np.float32)[..., 2] - height, width = values.shape - pixels = np.empty((height, width, 4), dtype=np.float32) - pixels[:, :, :3] = values[:, :, None] - pixels[:, :, 3] = 1.0 - image = bpy.data.images.new( - depth_data_name(source_object), - width=width, - height=height, - alpha=True, - float_buffer=True, - ) - image.colorspace_settings.name = "Non-Color" - image.pixels.foreach_set(np.flipud(pixels).ravel()) - image.pack() - return image - - -def _cutout_depth_image(source_object, depth, auxiliary=None, valid=None): - import numpy as np - - depth = np.asarray(depth, dtype=np.float32) - height, width = depth.shape - pixels = np.zeros((height, width, 4), dtype=np.float32) - pixels[:, :, 0] = depth - if auxiliary is not None: - pixels[:, :, 1] = np.asarray(auxiliary, dtype=np.float32) - pixels[:, :, 3] = ( - np.asarray(valid, dtype=np.float32) - if valid is not None - else 1.0 - ) - image = bpy.data.images.new( - depth_data_name(source_object), - width=width, - height=height, - alpha=True, - float_buffer=True, - ) - image.colorspace_settings.name = "Non-Color" - image.pixels.foreach_set(np.flipud(pixels).ravel()) - image.pack() - return image - - -def _surface_depth_image(source_object, anchored, valid, camera_depth): - return _cutout_depth_image( - source_object, - anchored[..., 2], - _depth_discontinuity_map(camera_depth, valid), - valid, - ) - - -def _depth_balloon_image(source_object, depth_data): - return _cutout_depth_image( - source_object, - depth_data["depth_signal"], - valid=depth_data["valid"], - ) - - -def _cutout_world_matrix(source_matrix, crop_center): - from mathutils import Matrix - - center_x, center_y = (float(value) for value in crop_center) - return ( - source_matrix - @ Matrix.Translation((center_x, center_y, 0.0)) - @ Matrix(CUTOUT_AXIS_TO_SOURCE) - ) - - -def _orient_cutout_vertices(vertices): - import numpy as np - - vertices = np.asarray(vertices, dtype=np.float64) - return np.column_stack( - ( - -vertices[:, 2], - -vertices[:, 0], - vertices[:, 1], - ) - ) - - -def _depth_balloon_reference(vertices): - import numpy as np - - vertices = np.asarray(vertices, dtype=np.float64) - extents = np.ptp(vertices[:, :2], axis=0) - positive = extents[extents > 1e-8] - if not len(positive): - return 1e-6 - return max(float(positive.min()) * 0.25, 1e-6) - - -def _cutout_domain( - regions_pixels, - image_size, - bounds, - spacing, - world_metric, - balloon_algorithm="POISSON", -): - import numpy as np - - x_min, x_max, y_min, y_max = bounds - - regions, spacing = prepare_planar_regions( - regions_pixels, - image_size, - bounds, - spacing=spacing, - world_metric=world_metric, - ) - planar, planar_faces, boundary, components = triangulate_planar_regions( - regions, - spacing, - return_components=True, - ) - profile = calculate_balloon_profile( - balloon_algorithm, planar, planar_faces, boundary, components - ) - faces = np.asarray(planar_faces, dtype=np.int32) - local = planar @ np.linalg.inv(metric_transform(world_metric)) - uv = np.column_stack( - ( - (local[:, 0] - x_min) / (x_max - x_min), - (local[:, 1] - y_min) / (y_max - y_min), - ) - ).astype(np.float32) - crop_center = np.asarray( - ((x_min + x_max) * 0.5, (y_min + y_max) * 0.5), - dtype=np.float64, - ) - local -= crop_center - vertices = np.column_stack((local, np.zeros(len(local), dtype=np.float64))) - return vertices, faces, uv, boundary, planar, profile - - -def _world_plane_metric(matrix_world): - from mathutils import Vector - - linear = matrix_world.to_3x3() - x = linear @ Vector((1.0, 0.0, 0.0)) - y = linear @ Vector((0.0, 1.0, 0.0)) - z = linear @ Vector((0.0, 0.0, 1.0)) - return ((x.dot(x), x.dot(y)), (x.dot(y), y.dot(y))), z.length - - -def _load_field(path): - import numpy as np - - with np.load(path, allow_pickle=False) as archive: - points = np.asarray(archive["points"], dtype=np.float32) - intrinsics = np.asarray(archive["intrinsics"], dtype=np.float32) - valid = np.asarray(archive["valid"], dtype=bool) - image_size = tuple(int(value) for value in archive["image_size"]) - reference_depth = float(archive["reference_depth"]) - if points.ndim != 3 or points.shape[2] != 3 or valid.shape != points.shape[:2]: - raise ValueError("The mesh point field is invalid") - if intrinsics.shape != (3, 3) or not reference_depth > 0.0: - raise ValueError("The mesh camera field is invalid") - return points, intrinsics, valid, image_size, reference_depth - - -def _anchored_point_field(source_object, field, bounds=None): - import numpy as np - - points, intrinsics, valid, image_size, reference_depth = field - width, height = image_size - x_min, x_max, y_min, y_max = bounds or plane_bounds(source_object) - plane_width = x_max - x_min - plane_height = y_max - y_min - fx, fy = float(intrinsics[0, 0]), float(intrinsics[1, 1]) - cx, cy = float(intrinsics[0, 2]), float(intrinsics[1, 2]) - if fx <= 0.0 or fy <= 0.0: - raise ValueError("Camera intrinsics contain an invalid focal length") - scale_x = plane_width * fx / (max(width - 1, 1) * reference_depth) - scale_y = plane_height * fy / (max(height - 1, 1) * reference_depth) - scale_z = float(np.sqrt(scale_x * scale_y)) - camera_x = x_min + plane_width * cx / max(width - 1, 1) - camera_y = y_max - plane_height * cy / max(height - 1, 1) - anchored = np.empty_like(points, dtype=np.float32) - anchored[..., 0] = camera_x + points[..., 0] * scale_x - anchored[..., 1] = camera_y - points[..., 1] * scale_y - anchored[..., 2] = (reference_depth - points[..., 2]) * scale_z - return anchored, valid, points[..., 2] - - -def _depth_discontinuity_map(depth, valid): - import numpy as np - - depth = np.asarray(depth, dtype=np.float32) - valid = np.asarray(valid, dtype=bool) & np.isfinite(depth) & (depth > 0.0) - padded_depth = np.pad(depth, 1, mode="edge") - padded_valid = np.pad(valid, 1, mode="edge") - windows = np.lib.stride_tricks.sliding_window_view(padded_depth, (3, 3)) - usable = np.lib.stride_tricks.sliding_window_view(padded_valid, (3, 3)) - minimum = np.min(np.where(usable, windows, np.inf), axis=(-2, -1)) - maximum = np.max(np.where(usable, windows, -np.inf), axis=(-2, -1)) - result = (maximum - minimum) / np.maximum(np.abs(depth), 1e-6) - result[~usable.all(axis=(-2, -1))] = 1.0 - return result.astype(np.float32) - - -def _counter_clockwise_faces(points, faces): - import numpy as np - - points = np.asarray(points, dtype=np.float64) - result = np.asarray(faces, dtype=np.int32).copy() - polygons = points[result] - following = np.roll(polygons, -1, axis=1) - signed_area = np.sum( - polygons[..., 0] * following[..., 1] - - polygons[..., 1] * following[..., 0], - axis=1, - ) - result[signed_area < 0.0] = result[signed_area < 0.0, ::-1] - return result - - -def _assign_uv(mesh, uv): - import numpy as np - - for layer in list(mesh.uv_layers): - mesh.uv_layers.remove(layer) - uv_layer = mesh.uv_layers.new(name="UVMap") - loop_vertices = np.empty(len(mesh.loops), dtype=np.int32) - mesh.loops.foreach_get("vertex_index", loop_vertices) - loop_uv = np.asarray(uv, dtype=np.float32)[loop_vertices] - uv_layer.data.foreach_set("uv", loop_uv.ravel()) - - -def _assign_float_point_attribute(mesh, name, values): - import numpy as np - - attribute = mesh.attributes.new(name=name, type="FLOAT", domain="POINT") - attribute.data.foreach_set("value", np.asarray(values, dtype=np.float32)) - - -def _interface_socket(group, name, in_out, socket_type): - socket = group.interface.new_socket( - name=name, - in_out=in_out, - socket_type=socket_type, - ) - if in_out == "INPUT" and hasattr(socket, "structure_type"): - socket.structure_type = "SINGLE" - return socket - - -def _group_input(nodes, location, visible_names): - node = nodes.new("NodeGroupInput") - node.location = location - visible = set(visible_names) - for output in node.outputs: - output.hide = output.name not in visible - return node - - -def _edge_boundary_field(nodes, links, location): - x, y = location - vertex_neighbors = nodes.new("GeometryNodeInputMeshVertexNeighbors") - vertex_neighbors.location = (x, y + 150.0) - loose_vertex = nodes.new("FunctionNodeCompare") - loose_vertex.data_type = "INT" - loose_vertex.operation = "EQUAL" - loose_vertex_a = next( - socket for socket in loose_vertex.inputs if socket.identifier == "A_INT" - ) - loose_vertex_b = next( - socket for socket in loose_vertex.inputs if socket.identifier == "B_INT" - ) - loose_vertex_b.default_value = 1 - loose_vertex.location = (x + 200.0, y + 150.0) - links.new(vertex_neighbors.outputs["Vertex Count"], loose_vertex_a) - - edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") - edge_neighbors.location = (x, y - 150.0) - boundary_edge = nodes.new("FunctionNodeCompare") - boundary_edge.data_type = "INT" - boundary_edge.operation = "EQUAL" - boundary_edge_a = next( - socket for socket in boundary_edge.inputs if socket.identifier == "A_INT" - ) - boundary_edge_b = next( - socket for socket in boundary_edge.inputs if socket.identifier == "B_INT" - ) - boundary_edge_b.default_value = 1 - boundary_edge.location = (x + 200.0, y - 150.0) - links.new(edge_neighbors.outputs["Face Count"], boundary_edge_a) - edge_domain = nodes.new("GeometryNodeFieldOnDomain") - edge_domain.data_type = "BOOLEAN" - edge_domain.domain = "EDGE" - edge_domain.location = (x + 400.0, y - 150.0) - links.new(boundary_edge.outputs["Result"], edge_domain.inputs["Value"]) - - is_boundary = nodes.new("FunctionNodeBooleanMath") - is_boundary.operation = "OR" - is_boundary.location = (x + 600.0, y) - links.new(edge_domain.outputs["Value"], is_boundary.inputs[0]) - links.new(loose_vertex.outputs["Result"], is_boundary.inputs[1]) - return is_boundary.outputs["Boolean"] - - -def _expand_smooth(group, geometry, x_offset=0.0): - nodes = group.nodes - links = group.links - existing_nodes = set(nodes) - iteration_controls = _group_input(nodes, (550.0, 1150.0), {"Iterations"}) - weight_controls = _group_input(nodes, (2300.0, 1450.0), {"Smooth"}) - repeat_output = nodes.new("GeometryNodeRepeatOutput") - repeat_output.location = (3050.0, 550.0) - repeat_input = nodes.new("GeometryNodeRepeatInput") - repeat_input.pair_with_output(repeat_output) - repeat_input.location = (550.0, 550.0) - links.new(iteration_controls.outputs["Iterations"], repeat_input.inputs["Iterations"]) - links.new(geometry, repeat_input.inputs["Geometry"]) - - boundary_field = _edge_boundary_field(nodes, links, (750.0, -350.0)) - boundary_points = nodes.new("GeometryNodeFieldOnDomain") - boundary_points.data_type = "BOOLEAN" - boundary_points.domain = "POINT" - boundary_points.location = (1550.0, -350.0) - links.new(boundary_field, boundary_points.inputs["Value"]) - - boundary_geometry = nodes.new("GeometryNodeSeparateGeometry") - boundary_geometry.domain = "POINT" - boundary_geometry.location = (1750.0, -150.0) - links.new(repeat_input.outputs["Geometry"], boundary_geometry.inputs["Geometry"]) - links.new(boundary_points.outputs["Value"], boundary_geometry.inputs["Selection"]) - - position = nodes.new("GeometryNodeInputPosition") - position.location = (750.0, 850.0) - smooth_all = nodes.new("GeometryNodeBlurAttribute") - smooth_all.data_type = "FLOAT_VECTOR" - smooth_all.inputs["Iterations"].default_value = 1 - smooth_all.inputs["Weight"].default_value = 1.0 - smooth_all.location = (950.0, 850.0) - links.new(position.outputs["Position"], smooth_all.inputs["Value"]) - - boundary_position = nodes.new("GeometryNodeInputPosition") - boundary_position.location = (1350.0, 250.0) - smooth_boundary = nodes.new("GeometryNodeBlurAttribute") - smooth_boundary.data_type = "FLOAT_VECTOR" - smooth_boundary.inputs["Iterations"].default_value = 1 - smooth_boundary.inputs["Weight"].default_value = 0.5 - smooth_boundary.location = (1550.0, 250.0) - links.new(boundary_position.outputs["Position"], smooth_boundary.inputs["Value"]) - nearest_boundary = nodes.new("GeometryNodeSampleNearest") - nearest_boundary.domain = "POINT" - nearest_boundary.location = (1800.0, 150.0) - links.new(boundary_geometry.outputs["Selection"], nearest_boundary.inputs["Geometry"]) - sample_boundary = nodes.new("GeometryNodeSampleIndex") - sample_boundary.data_type = "FLOAT_VECTOR" - sample_boundary.domain = "POINT" - sample_boundary.clamp = False - sample_boundary.location = (2050.0, 250.0) - links.new(boundary_geometry.outputs["Selection"], sample_boundary.inputs["Geometry"]) - links.new(smooth_boundary.outputs["Value"], sample_boundary.inputs["Value"]) - links.new(nearest_boundary.outputs["Index"], sample_boundary.inputs["Index"]) - - choose_boundary = nodes.new("GeometryNodeSwitch") - choose_boundary.input_type = "VECTOR" - choose_boundary.location = (2300.0, 350.0) - direct_boundary = _edge_boundary_field(nodes, links, (750.0, 450.0)) - links.new(direct_boundary, choose_boundary.inputs["Switch"]) - links.new(smooth_all.outputs["Value"], choose_boundary.inputs["False"]) - links.new(sample_boundary.outputs["Value"], choose_boundary.inputs["True"]) - boundary_mix = nodes.new("ShaderNodeMix") - boundary_mix.data_type = "VECTOR" - boundary_mix.inputs[0].default_value = 1.0 - boundary_mix.location = (2550.0, 400.0) - links.new(smooth_all.outputs["Value"], boundary_mix.inputs[4]) - links.new(choose_boundary.outputs["Output"], boundary_mix.inputs[5]) - - normal = nodes.new("GeometryNodeInputNormal") - normal.location = (1350.0, 1050.0) - smooth_normal = nodes.new("GeometryNodeBlurAttribute") - smooth_normal.data_type = "FLOAT_VECTOR" - smooth_normal.inputs["Iterations"].default_value = 10 - smooth_normal.inputs["Weight"].default_value = 1.0 - smooth_normal.location = (1550.0, 1050.0) - links.new(normal.outputs["Normal"], smooth_normal.inputs["Value"]) - normal_coherence = nodes.new("ShaderNodeVectorMath") - normal_coherence.operation = "LENGTH" - normal_coherence.location = (1800.0, 1050.0) - links.new(smooth_normal.outputs["Value"], normal_coherence.inputs[0]) - sharp_switch = nodes.new("GeometryNodeSwitch") - sharp_switch.input_type = "FLOAT" - sharp_switch.inputs["Switch"].default_value = True - sharp_switch.inputs["False"].default_value = 1.0 - sharp_switch.location = (2050.0, 1050.0) - links.new(normal_coherence.outputs["Value"], sharp_switch.inputs["True"]) - smooth_weight = nodes.new("ShaderNodeMath") - smooth_weight.operation = "MULTIPLY" - smooth_weight.location = (2300.0, 1050.0) - links.new(weight_controls.outputs["Smooth"], smooth_weight.inputs[0]) - links.new(sharp_switch.outputs["Output"], smooth_weight.inputs[1]) - - target_position = nodes.new("GeometryNodeSwitch") - target_position.input_type = "VECTOR" - target_position.inputs["Switch"].default_value = True - target_position.location = (2550.0, 700.0) - links.new(smooth_all.outputs["Value"], target_position.inputs["False"]) - links.new(boundary_mix.outputs["Result"], target_position.inputs["True"]) - current_position = nodes.new("GeometryNodeInputPosition") - current_position.location = (2300.0, 850.0) - mix_position = nodes.new("ShaderNodeMix") - mix_position.data_type = "VECTOR" - mix_position.location = (2800.0, 850.0) - links.new(smooth_weight.outputs[0], mix_position.inputs[0]) - links.new(current_position.outputs["Position"], mix_position.inputs[4]) - links.new(target_position.outputs["Output"], mix_position.inputs[5]) - set_position = nodes.new("GeometryNodeSetPosition") - set_position.location = (2800.0, 550.0) - links.new(repeat_input.outputs["Geometry"], set_position.inputs["Geometry"]) - links.new(mix_position.outputs["Result"], set_position.inputs["Position"]) - links.new(set_position.outputs["Geometry"], repeat_output.inputs["Geometry"]) - for node in set(nodes) - existing_nodes: - node.location.x += x_offset - return repeat_output.outputs["Geometry"] - - -def _depth_plane_node_group(): - group_name = "O Image Depth Plane" - group = bpy.data.node_groups.get(group_name) - if group is not None and group.get("anyimage_mesh_version") == NODE_GROUP_VERSION: - return group - if group is None: - group = bpy.data.node_groups.new(group_name, "GeometryNodeTree") - else: - group.nodes.clear() - group.interface.clear() - group.is_modifier = True - _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") - subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt") - subdivide.default_value = 7 - subdivide.min_value = 0 - subdivide.max_value = 10 - thickness = _interface_socket(group, "Thickness", "INPUT", "NodeSocketFloat") - thickness.default_value = 0.1 - thickness.min_value = 0.0 - thickness.subtype = "DISTANCE" - depth_amount = _interface_socket(group, "Depth Amount", "INPUT", "NodeSocketFloat") - depth_amount.default_value = 1.0 - depth_amount.min_value = 0.0 - depth_image = _interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage") - depth_image.hide_in_modifier = True - material = _interface_socket(group, "Material", "INPUT", "NodeSocketMaterial") - material.hide_in_modifier = True - _interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") - - nodes = group.nodes - links = group.links - geometry_input = _group_input( - nodes, - (-1850.0, 450.0), - {"Geometry", "Subdivide", "Material"}, - ) - shape_input = _group_input( - nodes, - (-1250.0, 850.0), - {"Thickness", "Depth Amount"}, - ) - image_input = _group_input(nodes, (-1050.0, 50.0), {"Depth Image"}) - cleanup_input = _group_input(nodes, (650.0, -200.0), {"Thickness"}) - group_output = nodes.new("NodeGroupOutput") - group_output.location = (1600.0, 450.0) - mesh_plane = nodes.new("GeometryNodeGroup") - mesh_plane.node_tree = load_mesh_plane_node_group() - mesh_plane.inputs["Thickness"].default_value = 0.0 - mesh_plane.location = (-1550.0, 450.0) - links.new(geometry_input.outputs["Geometry"], mesh_plane.inputs["Geometry"]) - links.new(geometry_input.outputs["Subdivide"], mesh_plane.inputs["Subdivide"]) - links.new(geometry_input.outputs["Material"], mesh_plane.inputs["Material"]) - - extrude = nodes.new("GeometryNodeExtrudeMesh") - extrude.mode = "FACES" - extrude.inputs["Offset"].default_value = (0.0, 0.0, -1.0) - extrude.inputs["Individual"].default_value = False - extrude.location = (-1200.0, 450.0) - links.new(mesh_plane.outputs["Geometry"], extrude.inputs["Mesh"]) - links.new(shape_input.outputs["Thickness"], extrude.inputs["Offset Scale"]) - geometry = extrude.outputs["Mesh"] - - uv = nodes.new("GeometryNodeInputNamedAttribute") - uv.data_type = "FLOAT_VECTOR" - uv.inputs["Name"].default_value = "UVMap" - uv.location = (-1000.0, 50.0) - texture = nodes.new("GeometryNodeImageTexture") - texture.location = (-800.0, 50.0) - links.new(image_input.outputs["Depth Image"], texture.inputs["Image"]) - links.new(uv.outputs["Attribute"], texture.inputs["Vector"]) - separate = nodes.new("ShaderNodeSeparateXYZ") - separate.location = (-600.0, 50.0) - links.new(texture.outputs["Color"], separate.inputs["Vector"]) - raw_depth = nodes.new("ShaderNodeMath") - raw_depth.operation = "MULTIPLY" - raw_depth.location = (-350.0, 150.0) - links.new(separate.outputs["X"], raw_depth.inputs[0]) - links.new(shape_input.outputs["Depth Amount"], raw_depth.inputs[1]) - inward_limit = nodes.new("ShaderNodeMath") - inward_limit.operation = "MULTIPLY" - inward_limit.inputs[1].default_value = -0.98 - inward_limit.location = (-350.0, -50.0) - links.new(shape_input.outputs["Thickness"], inward_limit.inputs[0]) - clamp = nodes.new("ShaderNodeMath") - clamp.operation = "MAXIMUM" - clamp.location = (-100.0, 50.0) - links.new(raw_depth.outputs[0], clamp.inputs[0]) - links.new(inward_limit.outputs[0], clamp.inputs[1]) - has_thickness = nodes.new("ShaderNodeMath") - has_thickness.operation = "GREATER_THAN" - has_thickness.inputs[1].default_value = 1e-6 - has_thickness.location = (-350.0, -250.0) - links.new(shape_input.outputs["Thickness"], has_thickness.inputs[0]) - choose_depth = nodes.new("GeometryNodeSwitch") - choose_depth.input_type = "FLOAT" - choose_depth.location = (150.0, 50.0) - links.new(has_thickness.outputs[0], choose_depth.inputs["Switch"]) - links.new(raw_depth.outputs[0], choose_depth.inputs["False"]) - links.new(clamp.outputs[0], choose_depth.inputs["True"]) - combine = nodes.new("ShaderNodeCombineXYZ") - combine.location = (350.0, 50.0) - links.new(choose_depth.outputs["Output"], combine.inputs["Z"]) - front = nodes.new("FunctionNodeBooleanMath") - front.operation = "NOT" - front.location = (150.0, 300.0) - links.new(extrude.outputs["Top"], front.inputs[0]) - set_position = nodes.new("GeometryNodeSetPosition") - set_position.location = (550.0, 450.0) - links.new(geometry, set_position.inputs["Geometry"]) - links.new(front.outputs[0], set_position.inputs["Selection"]) - links.new(combine.outputs["Vector"], set_position.inputs["Offset"]) - geometry = set_position.outputs["Geometry"] - - zero_thickness = nodes.new("ShaderNodeMath") - zero_thickness.operation = "LESS_THAN" - zero_thickness.inputs[1].default_value = 1e-6 - zero_thickness.location = (750.0, -150.0) - links.new(cleanup_input.outputs["Thickness"], zero_thickness.inputs[0]) - back_or_side = nodes.new("FunctionNodeBooleanMath") - back_or_side.operation = "OR" - back_or_side.location = (750.0, 150.0) - links.new(extrude.outputs["Top"], back_or_side.inputs[0]) - links.new(extrude.outputs["Side"], back_or_side.inputs[1]) - delete_selection = nodes.new("FunctionNodeBooleanMath") - delete_selection.operation = "AND" - delete_selection.location = (950.0, 50.0) - links.new(zero_thickness.outputs[0], delete_selection.inputs[0]) - links.new(back_or_side.outputs[0], delete_selection.inputs[1]) - delete_geometry = nodes.new("GeometryNodeDeleteGeometry") - delete_geometry.domain = "FACE" - delete_geometry.location = (1100.0, 450.0) - links.new(geometry, delete_geometry.inputs["Geometry"]) - links.new(delete_selection.outputs[0], delete_geometry.inputs["Selection"]) - shade_smooth = nodes.new("GeometryNodeSetShadeSmooth") - shade_smooth.domain = "FACE" - shade_smooth.inputs["Shade Smooth"].default_value = True - shade_smooth.location = (1350.0, 450.0) - links.new(delete_geometry.outputs["Geometry"], shade_smooth.inputs["Geometry"]) - links.new(shade_smooth.outputs["Geometry"], group_output.inputs["Geometry"]) - group["anyimage_mesh_version"] = NODE_GROUP_VERSION - group.use_fake_user = True - return group - - -def _mesh_node_group(shape): - if shape == PLANE: - return load_mesh_plane_node_group() - if shape == DEPTH_PLANE: - return _depth_plane_node_group() - if shape in CUTOUT_SHAPES: - return cutout_nodes.node_group(shape, _expand_smooth) - raise ValueError(f"Unsupported mesh shape: {shape}") - - -def _copy_color_image(source_object): - source = source_object.data - image = source.copy() - if getattr(source, "source", None) == "GENERATED" or getattr( - source, - "is_dirty", - False, - ): - import numpy as np - - pixels = np.empty(len(source.pixels), dtype=np.float32) - source.pixels.foreach_get(pixels) - image.pixels.foreach_set(pixels) - image.update() - image.name = color_data_name(source_object) - return image - - -def _image_rgba(image): - import numpy as np - - width, height = (int(value) for value in image.size) - pixels = np.empty(len(image.pixels), dtype=np.float32) - image.pixels.foreach_get(pixels) - return np.flipud(pixels.reshape((height, width, 4))) - - -def _mask_crop_bounds(alpha): - import numpy as np - - values = np.asarray(alpha) - visible_y, visible_x = np.nonzero(values > 1.0 / 255.0) - if not len(visible_x): - raise ValueError("The Cutout selection contains no visible pixels") - return ( - int(visible_x.min()), - int(visible_y.min()), - int(visible_x.max()) + 1, - int(visible_y.max()) + 1, - ) - - -def _crop_plane_bounds(source_object, crop_bounds, source_size): - source_width, source_height = (float(value) for value in source_size) - left, top, right, bottom = (float(value) for value in crop_bounds) - x_min, x_max, y_min, y_max = plane_bounds(source_object) - width = x_max - x_min - height = y_max - y_min - return ( - x_min + left * width / source_width, - x_min + right * width / source_width, - y_max - bottom * height / source_height, - y_max - top * height / source_height, - ) - - -def _cropped_color_image(source_object, crop_bounds): - import numpy as np - - source = source_object.data - rgba = _image_rgba(source) - left, top, right, bottom = crop_bounds - width = right - left - height = bottom - top - if width <= 0 or height <= 0: - raise ValueError("The Cutout crop dimensions must be positive") - cropped = np.zeros((height, width, 4), dtype=np.float32) - source_height, source_width = rgba.shape[:2] - source_left = max(0, left) - source_top = max(0, top) - source_right = min(source_width, right) - source_bottom = min(source_height, bottom) - if source_right > source_left and source_bottom > source_top: - cropped[ - source_top - top : source_bottom - top, - source_left - left : source_right - left, - ] = rgba[source_top:source_bottom, source_left:source_right] - image = bpy.data.images.new( - color_data_name(source_object), - width=width, - height=height, - alpha=True, - ) - try: - image.colorspace_settings.name = source.colorspace_settings.name - image.alpha_mode = source.alpha_mode - image.pixels.foreach_set(np.flipud(cropped).ravel()) - image.pack() - image.update() - except Exception: - bpy.data.images.remove(image, do_unlink=True) - raise - return image - - -def _cutout_regions(alpha, mesh_detail, threshold=0.5): - contour_resolution = max(512, min(int(mesh_detail) * 8, 1024)) - return alpha_regions( - alpha, - max_resolution=contour_resolution, - threshold=threshold, - ) - - -def _antialias_selection(selection): - import numpy as np - - values = np.asarray(selection, dtype=np.float32) - kernel = np.asarray((0.029, 0.235, 0.472, 0.235, 0.029), dtype=np.float32) - radius = len(kernel) // 2 - horizontal = np.zeros_like(values) - padded = np.pad(values, ((0, 0), (radius, radius)), mode="edge") - for offset, weight in enumerate(kernel): - horizontal += padded[:, offset : offset + values.shape[1]] * weight - result = np.zeros_like(values) - padded = np.pad(horizontal, ((radius, radius), (0, 0)), mode="edge") - for offset, weight in enumerate(kernel): - result += padded[offset : offset + values.shape[0]] * weight - return result - - -def _geometry_cutout_regions( - alpha, - polygon, - mesh_detail, - invert=False, - refine_selection=False, - selection=None, -): - import numpy as np - - values = np.asarray(alpha, dtype=np.float32) - if values.ndim != 2: - raise ValueError("Cutout Alpha must be two-dimensional") - source_height, source_width = values.shape - source_size = (source_width, source_height) - if refine_selection: - regions, _image_size = _cutout_regions(values, mesh_detail) - return regions, source_size - if invert: - geometry_alpha = selection - if geometry_alpha is None: - geometry_alpha = 1.0 - _antialias_selection( - polygon_mask(source_size, polygon, "NONZERO") - ) - regions, _image_size = _cutout_regions(geometry_alpha, mesh_detail) - return regions, source_size - - left, top, right, bottom = polygon_bounds(polygon) - inside_source = ( - left >= 0 - and top >= 0 - and right <= source_width - and bottom <= source_height - ) - if inside_source: - geometry_alpha = values if refine_selection else selection - if geometry_alpha is None: - geometry_alpha = _antialias_selection( - polygon_mask(source_size, polygon, "NONZERO") - ) - regions, _image_size = _cutout_regions(geometry_alpha, mesh_detail) - return regions, source_size - - canvas_size = (right - left, bottom - top) - relative = np.asarray(polygon, dtype=np.float64) - (left, top) - geometry_alpha = _antialias_selection( - polygon_mask(canvas_size, relative, "NONZERO") - ) - regions, _canvas_size = _cutout_regions(geometry_alpha, mesh_detail) - return ( - [ - [ - [[x + left, y + top] for x, y in contour] - for contour in region - ] - for region in regions - ], - source_size, - ) - -def _local_cutout_rgba(source_object, polygon, invert=False): - import numpy as np - - rgba = _image_rgba(source_object.data).copy() - height, width = rgba.shape[:2] - selection = _antialias_selection( - polygon_mask((width, height), polygon, "NONZERO") - ) - if invert: - selection = 1.0 - selection - rgba[:, :, 3] *= selection - rgba[rgba[:, :, 3] <= 0.0, :3] = 0.0 - if not np.any(rgba[:, :, 3] > 0.0): - raise ImageEditWarning("The selection contains no visible image pixels") - return rgba, selection - - -def _rename_color_image(image, source_object): - image.name = color_data_name(source_object) - return image - - -def _new_material( - context, - source_object, - color_image, - normal_image=None, - normal_space="TANGENT", -): - return create_material( - material_data_name(source_object), - color_image, - 0.0, - surface_group_name=IMAGE_MATERIAL_NODE_GROUP_NAME, - normal_image=normal_image, - normal_space=normal_space, - scene=context.scene, - ) - - -def _build_mesh_object( - context, - source_object, - shape, - detail, - material, - field=None, - depth_data=None, - regions=None, - image_size=None, - bounds=None, - keep_original=False, -): - import numpy as np - - bounds = bounds or plane_bounds(source_object) - crop_center = ( - (bounds[0] + bounds[1]) * 0.5, - (bounds[2] + bounds[3]) * 0.5, - ) - world_metric, world_z_scale = _world_plane_metric(source_object.matrix_world) - depth_image = None - depth_reference = None - base_plane_values = None - if shape in {PLANE, DEPTH_PLANE}: - domain = _plane_domain(bounds, detail, world_metric) - vertices, faces, uv, _boundary, _planar = domain - anchored = ( - _anchored_point_field(source_object, field, bounds) - if field - else None - ) - if shape == DEPTH_PLANE: - depth_image = _depth_field_image(source_object, anchored[0]) - else: - preferences = addon_preferences(context) - balloon_algorithm = getattr( - preferences, "balloon_profile_algorithm", "POISSON" - ) - domain = _cutout_domain( - regions, - image_size, - bounds, - _cutout_point_spacing(source_object, detail), - world_metric, - balloon_algorithm, - ) - source_vertices, faces, uv, boundary, planar, balloon_profile = domain - faces = _counter_clockwise_faces(planar, faces) - vertices = _orient_cutout_vertices(source_vertices) - if world_z_scale <= 1e-10: - raise ValueError("The Image Empty has a degenerate world transform") - balloon_profile /= world_z_scale - if shape == DEPTH_SURFACE: - anchored, valid, camera_depth = _anchored_point_field( - source_object, - field, - bounds, - ) - depth_image = _surface_depth_image( - source_object, - anchored, - valid, - camera_depth, - ) - elif shape == DEPTH_BALLOON: - if depth_data is None: - raise ValueError("Depth Balloon data is missing") - depth_image = _depth_balloon_image(source_object, depth_data) - depth_reference = _depth_balloon_reference(source_vertices) - _sampled, local_plane = depth_balloon_fields( - source_vertices, - uv, - depth_data, - depth_reference, - ) - base_plane_values = orient_depth_balloon_plane(local_plane) - - source_name = source_object.name - mesh = bpy.data.meshes.new(source_name) - mesh.from_pydata(np.asarray(vertices).tolist(), [], faces) - for polygon in mesh.polygons: - polygon.use_smooth = True - _assign_uv(mesh, uv) - if shape in CUTOUT_SHAPES: - _assign_float_point_attribute(mesh, BALLOON_ATTRIBUTE, balloon_profile) - mesh.materials.append(material) - mesh.update() - - mesh_object = bpy.data.objects.new(source_name, mesh) - context.collection.objects.link(mesh_object) - mesh_object.matrix_world = ( - _cutout_world_matrix(source_object.matrix_world, crop_center) - if shape in CUTOUT_SHAPES - else source_object.matrix_world.copy() - ) - mesh_object["anyimage_mesh_shape"] = shape - node_group = _mesh_node_group(shape) - modifier = mesh_object.modifiers.new(name=node_group.name, type="NODES") - modifier.node_group = node_group - modifier.show_group_selector = False - if shape in {PLANE, DEPTH_PLANE}: - subdivision = max(0, min(10, int(detail))) - set_modifier_input( - modifier, - modifier_input_identifier(node_group, "Subdivide"), - subdivision, - ) - set_modifier_input( - modifier, - modifier_input_identifier(node_group, "Material"), - material, - ) - if shape in CUTOUT_SHAPES: - set_modifier_input( - modifier, - modifier_input_identifier(node_group, "Cleanup"), - 1.0, - ) - thickness = ( - 1.0 - if shape in {BALLOON, DEPTH_BALLOON} - else 0.1 if shape == DEPTH_PLANE else 0.0 - ) - set_modifier_input( - modifier, - modifier_input_identifier(node_group, "Thickness"), - thickness, - ) - if shape in {DEPTH_PLANE, DEPTH_SURFACE}: - set_modifier_input( - modifier, - modifier_input_identifier( - node_group, - "Depth Scale" if shape == DEPTH_SURFACE else "Depth Amount", - ), - 1.0, - ) - base_plane = None - if shape == DEPTH_BALLOON: - set_modifier_input( - modifier, - modifier_input_identifier(node_group, "Depth Reference"), - depth_reference, - ) - base_plane = create_depth_balloon_base_plane( - context, - mesh_object, - vertices, - base_plane_values, - ) - set_modifier_input( - modifier, - modifier_input_identifier(node_group, "Base Plane"), - base_plane, - ) - if depth_image is not None: - set_modifier_input( - modifier, - modifier_input_identifier(node_group, "Depth Image"), - depth_image, - ) - for selected in context.selected_objects: - selected.select_set(False) - mesh_object.select_set(True) - context.view_layer.objects.active = mesh_object - if not keep_original: - bpy.data.objects.remove(source_object, do_unlink=True) - mesh_object.name = source_name - return mesh_object - - -def _load_normal_image(path, source_object): - image = bpy.data.images.load(str(path), check_existing=False) - image.name = normal_data_name(source_object) - image.colorspace_settings.name = "Non-Color" - image.pack() - return image - - -def _orient_cutout_object_normal(image): - import numpy as np - - width, height = (int(value) for value in image.size) - pixels = np.empty(width * height * 4, dtype=np.float32) - image.pixels.foreach_get(pixels) - rgba = pixels.reshape((height, width, 4)) - source = rgba[:, :, :3] * 2.0 - 1.0 - oriented = np.empty_like(source) - oriented[:, :, 0] = -source[:, :, 2] - oriented[:, :, 1] = -source[:, :, 0] - oriented[:, :, 2] = source[:, :, 1] - rgba[:, :, :3] = np.clip(oriented * 0.5 + 0.5, 0.0, 1.0) - image.pixels.foreach_set(rgba.ravel()) - image.update() - - -def _cutout_point_spacing(source_object, detail): - import math - - x_min, x_max, y_min, y_max = plane_bounds(source_object) - metric, _world_z_scale = _world_plane_metric(source_object.matrix_world) - width = (x_max - x_min) * math.sqrt(max(metric[0][0], 0.0)) - height = (y_max - y_min) * math.sqrt(max(metric[1][1], 0.0)) - return max(width, height) / max(int(detail), 8) - - -def _build_local_cutout( - operator, - context, - source_object, - shape, - selection_data, - invert=False, -): - if video_total_frames(source_object.data) != 1: - raise ValueError("Mesh conversion currently supports still images") - polygon = json.loads(selection_data) if selection_data else None - if not isinstance(polygon, list) or len(polygon) < 3: - raise ValueError("The Cutout selection is invalid") - _rgba, selection = _local_cutout_rgba(source_object, polygon, invert) - source_size = tuple(source_object.data.size) - if invert: - crop_bounds = _mask_crop_bounds(selection) - left, top, right, bottom = crop_bounds - cropped_selection = selection[top:bottom, left:right] - else: - crop_bounds = tuple(int(value) for value in polygon_bounds(polygon)) - left, top, right, bottom = crop_bounds - relative_polygon = [ - (x - left, y - top) - for x, y in polygon - ] - cropped_selection = _antialias_selection( - polygon_mask( - (right - left, bottom - top), - relative_polygon, - "NONZERO", - ) - ) - regions, image_size = _cutout_regions( - cropped_selection, - operator.mesh_detail, - threshold=0.5, - ) - local_bounds = _crop_plane_bounds( - source_object, - crop_bounds, - source_size, - ) - color_image = None - material = None - try: - color_image = _cropped_color_image(source_object, crop_bounds) - material = _new_material(context, source_object, color_image) - _build_mesh_object( - context, - source_object, - shape, - operator.mesh_detail, - material, - regions=regions, - image_size=image_size, - bounds=local_bounds, - keep_original=True, - ) - except Exception: - if material is not None and material.users == 0: - bpy.data.materials.remove(material) - if color_image is not None and color_image.users == 0: - bpy.data.images.remove(color_image) - raise - push_undo(f"Convert to {shape.replace('_', ' ').title()}") - - -def _build_response(context, result, operator): - source_object = bpy.data.objects.get(operator.source_object_name) - if not is_image_empty(source_object): - raise RuntimeError("The source Image Empty no longer exists") - regions = image_size = local_bounds = None - if operator.shape in CUTOUT_SHAPES: - source_size = tuple(source_object.data.size) - crop_bounds = tuple( - int(value) - for value in json.loads( - result.file("bounds").read_text(encoding="utf-8") - ) - ) - local_bounds = _crop_plane_bounds( - source_object, - crop_bounds, - source_size, - ) - selection_image = bpy.data.images.load( - str(result.file("selection")), - check_existing=False, - ) - try: - selection_alpha = _image_rgba(selection_image)[:, :, 3] - regions, image_size = _cutout_regions( - selection_alpha, - operator.mesh_detail, - threshold=0.5, - ) - finally: - bpy.data.images.remove(selection_image) - color_image = create_color_image( - source_object, - result.directory, - assign_to_source=False, - ) - else: - color_image = _rename_color_image( - create_color_image( - source_object, - result.directory, - assign_to_source=False, - ), - source_object, - ) - normal_path = result.directory / "normal.png" - - normal_image = ( - _load_normal_image(normal_path, source_object) - if normal_path.is_file() - else None - ) - if normal_image is not None and operator.shape == DEPTH_SURFACE: - _orient_cutout_object_normal(normal_image) - material = _new_material( - context, - source_object, - color_image, - normal_image, - normal_space=( - "OBJECT" - if operator.shape == DEPTH_SURFACE and normal_image is not None - else "TANGENT" - ), - ) - field = ( - _load_field(result.file("field")) - if operator.shape in {DEPTH_PLANE, DEPTH_SURFACE} - else None - ) - depth_data = ( - load_depth_balloon_data(result.file("depth")) - if operator.shape == DEPTH_BALLOON - else None - ) - mesh_object = _build_mesh_object( - context, - source_object, - operator.shape, - operator.mesh_detail, - material, - field=field, - depth_data=depth_data, - regions=regions, - image_size=image_size, - bounds=local_bounds, - keep_original=operator.shape in CUTOUT_SHAPES, - ) - if normal_image is not None and operator.shape != DEPTH_SURFACE: - bake_tangent_normal_image( - context, - source_object, - mesh_object, - normal_image, - ) - push_undo(f"Convert to {operator.shape.replace('_', ' ').title()}") - - -def _depth_settings(context, shape): - preferences = addon_preferences(context) - model = getattr(preferences, "mesh_depth_model", DEFAULT_MOGE2_MODEL_KEY) - if shape == DEPTH_BALLOON: - model = DEFAULT_MOGE2_MODEL_KEY - process_factor = float(getattr(preferences, "mesh_process_res_factor", 100.0)) - resolution_level = int(getattr(preferences, "mesh_resolution_level", 5)) - return model, process_factor, resolution_level - - -class GenerateMesh(JobOperatorBase, bpy.types.Operator): - bl_idname = "anyimage.generate_mesh" - bl_label = "Generate Image Mesh" - job_type = "generate-mesh" - starting_message = "Preparing image mesh" - - input_path: bpy.props.StringProperty(options=HIDDEN) - analysis_input_path: bpy.props.StringProperty(options=HIDDEN) - source_object_name: bpy.props.StringProperty(options=HIDDEN) - shape: bpy.props.StringProperty(options=HIDDEN) - selection_data: bpy.props.StringProperty(default="", options=HIDDEN) - mesh_detail: bpy.props.IntProperty(default=128, min=4, max=1024, options=HIDDEN) - model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=HIDDEN) - process_res: bpy.props.IntProperty(default=1008, min=1, options=HIDDEN) - resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=HIDDEN) - normal_model: bpy.props.StringProperty( - default=DEFAULT_MOGE2_MODEL_KEY, options=HIDDEN - ) - normal_resolution_level: bpy.props.IntProperty( - default=5, min=0, max=9, options=HIDDEN - ) - normal_analysis: bpy.props.BoolProperty(default=False, options=HIDDEN) - delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN) - delete_analysis_input: bpy.props.BoolProperty(default=False, options=HIDDEN) - refine_selection: bpy.props.BoolProperty(default=False, options=HIDDEN) - invert: bpy.props.BoolProperty(default=False, options=HIDDEN) - - def request(self, context): - require_environment() - if self.shape in DEPTH_SHAPES: - require_model(self.model) - if self.normal_analysis and self.shape in CUTOUT_SHAPES and ( - self.shape not in DEPTH_SHAPES or self.normal_model != self.model - ): - require_model(self.normal_model) - if self.refine_selection: - require_model(BEN2_MODEL_KEY) - polygon = json.loads(self.selection_data) if self.selection_data else None - if self.shape in CUTOUT_SHAPES and (polygon is None or len(polygon) < 3): - raise ValueError("The Cutout selection is invalid") - analysis_value = getattr(self, "analysis_input_path", "") or self.input_path - parameters = { - "input": str(input_path(self.input_path)), - "analysis_input": str(input_path(analysis_value)), - "shape": self.shape, - "polygon": polygon, - "mesh_detail": self.mesh_detail, - "device": production_device().lower(), - "video_frames": 1, - "start_frame": source_start_frame(self.source_object_name), - "refine_selection": self.refine_selection, - "normal_analysis": self.normal_analysis, - "invert": self.invert, - } - if self.normal_analysis and self.shape in CUTOUT_SHAPES: - parameters.update( - normal_model=self.normal_model, - normal_resolution_level=self.normal_resolution_level, - ) - if self.refine_selection: - parameters["refine_model"] = BEN2_MODEL_KEY - if self.shape not in DEPTH_SHAPES: - return parameters - settings = context.scene.anyimage_settings - if self.model.startswith("MOGE2_"): - parameters.update( - moge2_parameters( - settings, - input_path(self.input_path), - self.model, - self.resolution_level, - source_start_frame(self.source_object_name), - ) - ) - else: - parameters.update( - da3_parameters( - settings, - input_path(self.input_path), - self.model, - self.process_res, - source_start_frame(self.source_object_name), - ) - ) - parameters.update(shape=self.shape, polygon=polygon) - return parameters - - def response(self, context, result): - _build_response(context, result, self) - return f"{self.shape.replace('_', ' ').title()} is ready" - - def cleanup(self): - cleanup_temporary_input(self.input_path, self.delete_input) - cleanup_temporary_input( - getattr(self, "analysis_input_path", ""), - getattr(self, "delete_analysis_input", False), - ) - - -def _start_job( - operator, - context, - shape, - selection_data="", - refine_selection=False, - normal_analysis=False, - invert=False, -): - source_object = context.object - if video_total_frames(source_object.data) != 1: - operator.report({"ERROR"}, "Mesh conversion currently supports still images") - return {"CANCELLED"} - setup_result = invoke_ai_setup_if_needed() - if setup_result is not None: - return setup_result - try: - if shape in CUTOUT_SHAPES: - source_path, delete_input = prepare_image_input( - source_object.data, - context.scene, - ) - analysis_path = source_path - delete_analysis_input = False - analysis_image = source_object.data - else: - ( - source_path, - delete_input, - analysis_path, - delete_analysis_input, - ) = prepare_image_analysis_inputs( - source_object.data, context.scene - ) - analysis_image = analysis_source_image(source_object.data) - model, process_factor, resolution_level = _depth_settings(context, shape) - process_res = ( - scaled_process_resolution(analysis_image, process_factor) - if shape in DEPTH_SHAPES and not model.startswith("MOGE2_") - else 1 - ) - except (RuntimeError, ValueError) as error: - operator.report({"ERROR"}, str(error)) - return {"CANCELLED"} - result = bpy.ops.anyimage.generate_mesh( - input_path=str(source_path), - analysis_input_path=str(analysis_path), - source_object_name=source_object.name, - shape=shape, - selection_data=selection_data, - mesh_detail=operator.mesh_detail, - model=model, - process_res=process_res, - resolution_level=resolution_level, - normal_model=DEFAULT_MOGE2_MODEL_KEY, - normal_resolution_level=resolution_level, - normal_analysis=normal_analysis, - delete_input=delete_input, - delete_analysis_input=delete_analysis_input, - refine_selection=refine_selection, - invert=invert, - ) - if "CANCELLED" in result: - if delete_input: - remove_temporary_input(source_path) - if delete_analysis_input: - remove_temporary_input(analysis_path) - return {"CANCELLED"} if "CANCELLED" in result else {"FINISHED"} - - -class ConvertToPlane(bpy.types.Operator): - bl_idname = "anyimage.convert_to_plane" - bl_label = "Convert to Plane" - bl_description = "Convert the complete Image Empty into a rectangular plane" - bl_options = {"REGISTER", "UNDO"} - - mesh_detail: bpy.props.IntProperty( - name="Subdivide", - default=0, - min=0, - max=10, - ) - - @classmethod - def poll(cls, context): - return is_image_empty(context.object) - - def execute(self, context): - source_object = context.object - color_image = _copy_color_image(source_object) - frame_start, frame_offset, frame_duration, use_cyclic = image_user_settings( - source_object - ) - material = create_material( - material_data_name(source_object), - color_image, - 1.0, - frame_start=frame_start, - color_frame_offset=frame_offset, - color_frame_duration=frame_duration, - use_cyclic=use_cyclic, - scene=context.scene, - ) - _build_mesh_object( - context, - source_object, - PLANE, - self.mesh_detail, - material, - ) - return {"FINISHED"} - - -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 front-projected relief" - bl_options = {"REGISTER", "UNDO"} - - mesh_detail: bpy.props.IntProperty( - name="Subdivide", - default=0, - min=0, - max=10, - ) - - @classmethod - def poll(cls, context): - from ..runtime import runtime - - return is_image_empty(context.object) and not runtime.server_busy() - - def invoke(self, context, _event): - return self.execute(context) - - def execute(self, context): - return _start_job(self, context, DEPTH_PLANE) - - -class ConvertCutoutShape(bpy.types.Operator): - bl_idname = "anyimage.convert_cutout_shape" - bl_label = "Convert Cutout Shape" - bl_description = "Convert the saved Cutout selection into the chosen shape" - bl_options = {"UNDO"} - - source_object_name: bpy.props.StringProperty(options=HIDDEN) - selection_data: bpy.props.StringProperty(options=HIDDEN) - shape: bpy.props.StringProperty(options=HIDDEN) - mesh_detail: bpy.props.IntProperty(default=128, min=4, max=1024, options=HIDDEN) - refine_selection: bpy.props.BoolProperty(default=False, options=HIDDEN) - normal_analysis: bpy.props.BoolProperty(default=False, options=HIDDEN) - invert: bpy.props.BoolProperty(default=False, options=HIDDEN) - - def execute(self, context): - source_object = bpy.data.objects.get(self.source_object_name) - if not is_image_empty(source_object): - self.report({"ERROR"}, "The source Image Empty is no longer available") - return {"CANCELLED"} - context.view_layer.objects.active = source_object - source_object.select_set(True) - if ( - self.shape in LOCAL_CUTOUT_SHAPES - and not self.refine_selection - and not self.normal_analysis - ): - try: - _build_local_cutout( - self, - context, - source_object, - self.shape, - self.selection_data, - self.invert, - ) - except Exception as error: - report_image_edit_exception(self, error) - return {"CANCELLED"} - return {"FINISHED"} - return _start_job( - self, - context, - self.shape, - self.selection_data, - self.refine_selection, - self.normal_analysis, - self.invert, - ) diff --git a/src/anyimage/operators/mesh_plane.py b/src/anyimage/operators/mesh_plane.py deleted file mode 100644 index 68f5188..0000000 --- a/src/anyimage/operators/mesh_plane.py +++ /dev/null @@ -1,228 +0,0 @@ -import bpy - -from .image import ( - image_user_settings, - is_image_empty, - material_data_name, - plane_bounds, -) -from .material import ( - create_material, - load_mesh_plane_node_group, - modifier_input_identifier, - set_modifier_input, -) - - -MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane" - - -def is_mesh_plane_object(candidate): - if candidate is None or getattr(candidate, "type", None) != "MESH": - return False - return any( - getattr(getattr(modifier, "node_group", None), "name", None) - == MESH_PLANE_NODE_GROUP_NAME - for modifier in getattr(candidate, "modifiers", ()) - ) - - -def flatten_matrix(matrix): - return tuple( - float(matrix[row][column]) for row in range(4) for column in range(4) - ) - - -def restore_matrix(values): - from mathutils import Matrix - - return Matrix( - tuple( - tuple(values[row * 4 + column] for column in range(4)) - for row in range(4) - ) - ) - - -def create_mesh_plane_mesh(name, bounds): - x_min, x_max, y_min, y_max = bounds - if x_max <= x_min or y_max <= y_min: - raise ValueError("The plane has invalid dimensions") - - mesh = bpy.data.meshes.new(name) - mesh.from_pydata( - ( - (x_min, y_min, 0.0), - (x_max, y_min, 0.0), - (x_max, y_max, 0.0), - (x_min, y_max, 0.0), - ), - (), - ((0, 1, 2, 3),), - ) - mesh.update() - uv_layer = mesh.uv_layers.new(name="UVMap") - for loop, uv in zip( - mesh.loops, - ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)), - ): - uv_layer.data[loop.index].uv = uv - return mesh - - -def add_mesh_plane_processing( - plane_object, - material, - subdivisions=0, - thickness=0.0, -): - node_group = load_mesh_plane_node_group() - modifier = plane_object.modifiers.new( - name=MESH_PLANE_NODE_GROUP_NAME, - type="NODES", - ) - modifier.node_group = node_group - if hasattr(modifier, "show_group_selector"): - modifier.show_group_selector = False - for name, value in ( - ("Subdivide", max(0, int(subdivisions))), - ("Thickness", max(0.0, float(thickness))), - ("Material", material), - ): - identifier = modifier_input_identifier(node_group, name) - set_modifier_input(modifier, identifier, value) - return modifier - - -def create_mesh_plane_object( - context, - name, - bounds, - material, - matrix_world=None, - subdivisions=0, - thickness=0.0, -): - mesh = create_mesh_plane_mesh(name, bounds) - mesh.materials.append(material) - plane = bpy.data.objects.new(name, mesh) - context.collection.objects.link(plane) - if matrix_world is not None: - plane.matrix_world = matrix_world.copy() - add_mesh_plane_processing( - plane, - material, - subdivisions=subdivisions, - thickness=thickness, - ) - return plane - - -def finish_mesh_plane(context, source_object, plane_object, keep_original): - source_name = source_object.name - for selected in context.selected_objects: - selected.select_set(False) - plane_object.select_set(True) - context.view_layer.objects.active = plane_object - if not keep_original: - bpy.data.objects.remove(source_object, do_unlink=True) - plane_object.name = source_name - return plane_object - - -def build_mesh_plane( - context, - source_object, - keep_original, - shadeless=False, - matrix_world=None, -): - frame_start, frame_offset, frame_duration, use_cyclic = image_user_settings( - source_object - ) - material = create_material( - material_data_name(source_object), - source_object.data, - abs(float(source_object.scale.z)), - shadeless=shadeless, - frame_start=frame_start, - color_frame_offset=frame_offset, - color_frame_duration=frame_duration, - use_cyclic=use_cyclic, - scene=context.scene, - ) - plane = create_mesh_plane_object( - context, - source_object.name, - plane_bounds(source_object), - material, - matrix_world=( - source_object.matrix_world if matrix_world is None else matrix_world - ), - ) - return finish_mesh_plane(context, source_object, plane, keep_original) - - -class ConvertToMeshPlane(bpy.types.Operator): - bl_idname = "anyimage.convert_to_mesh_plane" - bl_label = "Convert to Mesh Plane" - bl_description = "Convert an image to an editable mesh plane without AI depth" - bl_options = {"REGISTER", "UNDO"} - - shadeless: bpy.props.BoolProperty( - name="Shadeless", - description="Display the source color without scene lighting", - default=False, - ) - keep_original: bpy.props.BoolProperty( - name="Keep Original", - description="Keep the source Image Empty after conversion", - default=False, - ) - source_object_name: bpy.props.StringProperty(options={"HIDDEN"}) - source_matrix_world: bpy.props.FloatVectorProperty( - size=16, - default=( - 1.0, 0.0, 0.0, 0.0, - 0.0, 1.0, 0.0, 0.0, - 0.0, 0.0, 1.0, 0.0, - 0.0, 0.0, 0.0, 1.0, - ), - options={"HIDDEN"}, - ) - - @classmethod - def poll(cls, context): - return is_image_empty(context.object) or is_mesh_plane_object(context.object) - - def capture_source_state(self, source_object): - self.source_object_name = source_object.name - self.source_matrix_world = flatten_matrix(source_object.matrix_world) - - def invoke(self, context, _event): - self.capture_source_state(context.object) - return self.execute(context) - - def execute(self, context): - try: - source_object = context.object - if not self.source_object_name: - if not is_image_empty(source_object): - raise ValueError("Select an Image Empty to convert") - self.capture_source_state(source_object) - stored_source = bpy.data.objects.get(self.source_object_name) - if is_image_empty(stored_source): - source_object = stored_source - elif not is_image_empty(source_object): - raise ValueError("The source Image Empty is no longer available") - build_mesh_plane( - context, - source_object, - keep_original=self.keep_original, - shadeless=self.shadeless, - matrix_world=restore_matrix(self.source_matrix_world), - ) - except (RuntimeError, ValueError) as error: - self.report({"ERROR"}, str(error)) - return {"CANCELLED"} - return {"FINISHED"} diff --git a/src/anyimage/operators/perspective_tool/__init__.py b/src/anyimage/operators/perspective_tool/__init__.py new file mode 100644 index 0000000..706c328 --- /dev/null +++ b/src/anyimage/operators/perspective_tool/__init__.py @@ -0,0 +1,6 @@ +from .edit_perspective import EditImagePerspective, ImagePerspectiveTool + + +CLASSES = (EditImagePerspective,) + +__all__ = ("CLASSES", "ImagePerspectiveTool") diff --git a/src/anyimage/operators/image_edit/perspective.py b/src/anyimage/operators/perspective_tool/edit_perspective.py similarity index 99% rename from src/anyimage/operators/image_edit/perspective.py rename to src/anyimage/operators/perspective_tool/edit_perspective.py index 11e8080..4c3885a 100644 --- a/src/anyimage/operators/image_edit/perspective.py +++ b/src/anyimage/operators/perspective_tool/edit_perspective.py @@ -3,20 +3,20 @@ import math import bpy -from .interaction import ( +from ..common.image_interaction import ( deserialize_matrix, drawing_in_region, hidden_string_property, screen_path_to_image_pixels, serialize_matrix, ) -from ..image import ( +from ..common.image import ( image_base_name, is_image_empty, plane_bounds, video_total_frames, ) -from .common import ( +from ..common.image_edit import ( active_view3d_tool_id, crop_pixels_to_alpha, draw_dashed_line, diff --git a/src/anyimage/operators/background.py b/src/anyimage/operators/remove_background.py similarity index 91% rename from src/anyimage/operators/background.py rename to src/anyimage/operators/remove_background.py index 23e1bf1..d312530 100644 --- a/src/anyimage/operators/background.py +++ b/src/anyimage/operators/remove_background.py @@ -2,7 +2,7 @@ import bpy from ..properties import BEN2_MODEL_KEY from ..runtime import JobOperatorBase -from .common import ( +from .common.operator import ( HIDDEN, background_input_path, cleanup_temporary_input, @@ -14,7 +14,7 @@ from .common import ( source_start_frame, ) -from .image import ( +from .common.image import ( default_extract_frames, is_image_empty, prepare_image_input, @@ -57,7 +57,7 @@ class RemoveImageBackground(bpy.types.Operator): self.report({"ERROR"}, str(error)) return {"CANCELLED"} - result = bpy.ops.anyimage.process_image_background( + result = bpy.ops.anyimage.run_background_removal( input_path=str(source_path), source_object_name=source_object.name, delete_input=delete_input, @@ -74,7 +74,7 @@ class RemoveImageBackground(bpy.types.Operator): def apply_background_response(result, source_object_name): - from .image import create_color_image, is_image_empty + from .common.image import create_color_image, is_image_empty source_object = bpy.data.objects.get(source_object_name) if not is_image_empty(source_object): @@ -90,9 +90,9 @@ def apply_background_response(result, source_object_name): push_undo("Remove Background") -class ProcessImageBackground(JobOperatorBase, bpy.types.Operator): - bl_idname = "anyimage.process_image_background" - bl_label = "Process Image Background" +class RunBackgroundRemoval(JobOperatorBase, bpy.types.Operator): + bl_idname = "anyimage.run_background_removal" + bl_label = "Run Background Removal" job_type = "remove-background" starting_message = "Starting Image Empty background removal" diff --git a/src/anyimage/operators/surface.py b/src/anyimage/operators/surface.py deleted file mode 100644 index 95c72e2..0000000 --- a/src/anyimage/operators/surface.py +++ /dev/null @@ -1,798 +0,0 @@ -from pathlib import Path - -import bpy - -from ..properties import BEN2_MODEL_KEY, DEFAULT_MOGE2_MODEL_KEY -from ..runtime import JobOperatorBase -from .common import ( - HIDDEN, - cleanup_temporary_input, - da3_parameters, - input_path, - invoke_ai_setup_if_needed, - moge2_parameters, - push_undo, - require_environment, - require_model, - source_start_frame, -) - -from .image import ( - analysis_source_image, - draw_process_resolution, - is_image_empty, - material_data_name, - normal_data_name, - plane_bounds, - prepare_image_analysis_inputs, - process_resolution_scale_property, - remove_temporary_input, - scaled_process_resolution, - video_total_frames, -) -from .material import ( - MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME, - create_material, - set_modifier_input, -) - - -POINT_ATTRIBUTES = { - "o_depth": ("FLOAT", "point_depth"), -} -FACE_ATTRIBUTES = { - "o_confidence": ( - "FLOAT", - "face_confidence", - ), -} -OPTIONAL_FACE_ATTRIBUTES = { - "o_mask": ("BOOLEAN", "face_mask"), - "o_depth_discontinuity": ("FLOAT", "face_depth_discontinuity"), -} -NODE_GROUP_NAME = "O DA3 Surface" -MOGE_NODE_GROUP_NAME = "O MoGe2 Surface" -NODE_ASSET_NAME = "O_AnyImage.blend" - - -def image_surface_model_items(owner, context): - from ..properties import surface_model_enum_items - - return surface_model_enum_items(owner, context) - - -def load_image_mesh_data(npz_path, moge2=False): - import numpy as np - - try: - with np.load(npz_path, allow_pickle=False) as archive: - required = [ - "vertices", - "faces", - "uv", - "intrinsics", - "image_size", - "reference_depth", - "point_depth", - ] - if moge2: - required.extend(("face_mask", "face_depth_discontinuity")) - else: - required.extend( - ("face_confidence", "face_depth_discontinuity") - ) - missing = [key for key in required if key not in archive.files] - if missing: - raise ValueError( - f"Surface data is missing: {', '.join(missing)}" - ) - vertices = np.asarray(archive["vertices"], dtype=np.float32) - faces = np.asarray(archive["faces"], dtype=np.int32) - uv = np.asarray(archive["uv"], dtype=np.float32) - intrinsics = np.asarray( - archive["intrinsics"], - dtype=np.float32, - ) - image_size = tuple( - int(value) for value in archive["image_size"].tolist() - ) - reference_depth = float(archive["reference_depth"]) - attributes = { - key: np.asarray(archive[key]) - for _name, (_data_type, key) in { - **POINT_ATTRIBUTES, - **FACE_ATTRIBUTES, - }.items() - if key in archive.files - } - for _name, (_data_type, key) in OPTIONAL_FACE_ATTRIBUTES.items(): - if key in archive.files: - attributes[key] = np.asarray(archive[key]) - except OSError as error: - raise ValueError(f"Unable to read surface data: {npz_path}") from error - if vertices.ndim != 2 or vertices.shape[1] != 3: - raise ValueError(f"Unsupported vertices shape: {vertices.shape}") - if faces.ndim != 2 or faces.shape[1] != 3: - raise ValueError(f"Unsupported faces shape: {faces.shape}") - if uv.shape != (len(vertices), 2): - raise ValueError("Surface UV and vertex counts do not match") - if intrinsics.shape != (3, 3): - raise ValueError(f"Unsupported intrinsics shape: {intrinsics.shape}") - if len(image_size) != 2 or min(image_size) <= 0: - raise ValueError(f"Unsupported image size: {image_size}") - if not reference_depth > 0.0: - raise ValueError("Surface reference depth must be positive") - for _name, (_data_type, key) in POINT_ATTRIBUTES.items(): - if key in attributes and len(attributes[key]) != len(vertices): - raise ValueError(f"Point attribute has the wrong length: {key}") - for _name, (_data_type, key) in FACE_ATTRIBUTES.items(): - if key in attributes and len(attributes[key]) != len(faces): - raise ValueError(f"Face attribute has the wrong length: {key}") - for _name, (_data_type, key) in OPTIONAL_FACE_ATTRIBUTES.items(): - if key in attributes and len(attributes[key]) != len(faces): - raise ValueError(f"Face attribute has the wrong length: {key}") - return ( - vertices, - faces, - uv, - intrinsics, - image_size, - reference_depth, - attributes, - ) - - -def empty_anchored_vertices( - source_object, - camera_vertices, - intrinsics, - image_size, - reference_depth, -): - import numpy as np - - width, height = image_size - x_min, x_max, y_min, y_max = plane_bounds(source_object) - plane_width = x_max - x_min - plane_height = y_max - y_min - pixel_width = max(width - 1, 1) - pixel_height = max(height - 1, 1) - fx = float(intrinsics[0, 0]) - fy = float(intrinsics[1, 1]) - cx = float(intrinsics[0, 2]) - cy = float(intrinsics[1, 2]) - if fx <= 0.0 or fy <= 0.0: - raise ValueError("Camera intrinsics contain an invalid focal length") - scale_x = plane_width * fx / (pixel_width * reference_depth) - scale_y = plane_height * fy / (pixel_height * reference_depth) - scale_z = float(np.sqrt(scale_x * scale_y)) - camera_x = x_min + plane_width * cx / pixel_width - camera_y = y_max - plane_height * cy / pixel_height - vertices = np.empty_like(camera_vertices, dtype=np.float32) - vertices[:, 0] = camera_x + camera_vertices[:, 0] * scale_x - vertices[:, 1] = camera_y - camera_vertices[:, 1] * scale_y - vertices[:, 2] = ( - reference_depth - camera_vertices[:, 2] - ) * scale_z - camera_location = ( - camera_x, - camera_y, - reference_depth * scale_z, - ) - camera_parameters = { - "scale_x": scale_x, - "scale_y": scale_y, - "scale_z": scale_z, - "location": camera_location, - } - return vertices, camera_parameters - - -def assign_vertex_uv(mesh, vertex_uv): - import numpy as np - - uv_layer = mesh.uv_layers.new(name="UVMap") - loop_vertices = np.empty(len(mesh.loops), dtype=np.int32) - mesh.loops.foreach_get("vertex_index", loop_vertices) - loop_uv = vertex_uv[loop_vertices] - uv_layer.data.foreach_set("uv", loop_uv.reshape(-1)) - - -def assign_mesh_attribute(mesh, name, data_type, domain, values): - import numpy as np - - attribute = mesh.attributes.new( - name=name, - type=data_type, - domain=domain, - ) - array = np.asarray(values) - if data_type == "BOOLEAN": - array = array.astype(bool) - else: - array = array.astype(np.float32) - attribute.data.foreach_set("value", array.reshape(-1)) - - -def assign_anyimage_attributes(mesh, attributes, moge2=False): - for name, (data_type, key) in POINT_ATTRIBUTES.items(): - assign_mesh_attribute( - mesh, - name, - data_type, - "POINT", - attributes[key], - ) - for name, (data_type, key) in FACE_ATTRIBUTES.items(): - if key not in attributes: - continue - assign_mesh_attribute( - mesh, - name, - data_type, - "FACE", - attributes[key], - ) - for name, (data_type, key) in OPTIONAL_FACE_ATTRIBUTES.items(): - if key in attributes: - assign_mesh_attribute( - mesh, - name, - data_type, - "FACE", - attributes[key], - ) - assign_mesh_attribute( - mesh, - "o_depth_discontinuity", - "FLOAT", - "FACE", - attributes["face_depth_discontinuity"], - ) - - -def load_surface_normal_image(path, name): - image = bpy.data.images.load( - str(path), - check_existing=False, - ) - image.name = name - image.colorspace_settings.name = "Non-Color" - image.pack() - return image - - -def node_asset_path(): - return Path(__file__).parent.parent / "assets" / NODE_ASSET_NAME - - -def configure_surface_node_group_interface(node_group): - inputs = [ - item - for item in node_group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT" - ] - iterations = next( - (item for item in inputs if item.name in {"Iterations", "Relax Iterations"}), - None, - ) - if iterations is None: - raise RuntimeError("Geometry Nodes input is missing: Iterations") - iterations.name = "Iterations" - iterations.min_value = 0 - node_group.interface.move(iterations, 2) - - -def load_geometry_node_group(group_name=NODE_GROUP_NAME): - existing = bpy.data.node_groups.get(group_name) - if existing is not None: - configure_surface_node_group_interface(existing) - return existing - asset_path = node_asset_path() - if not asset_path.is_file(): - raise RuntimeError(f"Missing Geometry Nodes asset: {asset_path}") - use_pack = bpy.app.version >= (5, 0, 0) - if not use_pack: - directory = str(asset_path) + "/NodeTree/" - result = bpy.ops.wm.append( - filepath=directory + group_name, - directory=directory, - filename=group_name, - link=False, - do_reuse_local_id=True, - ) - if "FINISHED" not in result: - raise RuntimeError("Unable to append the Geometry Nodes asset") - node_group = bpy.data.node_groups.get(group_name) - if node_group is None: - raise RuntimeError("Appended Geometry Nodes asset is unavailable") - node_group.use_fake_user = True - configure_surface_node_group_interface(node_group) - return node_group - with bpy.data.libraries.load(str(asset_path), link=use_pack) as ( - source, - target, - ): - if group_name not in source.node_groups: - raise RuntimeError( - f"Geometry Nodes asset is missing {group_name}" - ) - target.node_groups = [group_name] - node_group = target.node_groups[0] - if use_pack: - linked_group = node_group - node_group = bpy.data.pack_linked_ids_hierarchy(root_id=linked_group) - if linked_group != node_group and linked_group.users == 0: - bpy.data.node_groups.remove(linked_group) - node_group.use_fake_user = True - configure_surface_node_group_interface(node_group) - return node_group - - -def modifier_input_identifier(node_group, name): - for item in node_group.interface.items_tree: - if item.item_type == "SOCKET" and item.in_out == "INPUT": - if item.name == name: - return item.identifier - raise RuntimeError(f"Geometry Nodes input is missing: {name}") - - -def default_remesh_resolution(vertices, faces): - import numpy as np - - if not len(faces): - return 0.01 - triangles = np.asarray(vertices, dtype=np.float32)[faces] - edge_lengths = np.linalg.norm( - triangles - np.roll(triangles, 1, axis=1), - axis=2, - ) - valid = edge_lengths[np.isfinite(edge_lengths) & (edge_lengths > 0.0)] - if not len(valid): - return 0.01 - # Twice the 90% reduction estimate gives the iterative remesh more room - # to settle into a smooth, stable default topology. - return float(np.median(valid) * np.sqrt(10.0) * 2.0) - - -def material_depth_scale(mesh_object, camera_parameters): - return abs(float(mesh_object.scale.z)) * abs( - float(camera_parameters["scale_z"]) - ) - - -def add_geometry_nodes_processing( - mesh_object, - vertices, - faces, - group_name=NODE_GROUP_NAME, -): - node_group = load_geometry_node_group(group_name) - modifier = mesh_object.modifiers.new(name=node_group.name, type="NODES") - modifier.node_group = node_group - modifier.show_group_selector = False - identifier = modifier_input_identifier(node_group, "Resolution") - set_modifier_input( - modifier, - identifier, - default_remesh_resolution(vertices, faces), - ) - - -def create_matching_camera( - context, - source_object, - mesh_object, - name, - intrinsics, - image_size, - camera_parameters, -): - from mathutils import Matrix - - width, height = image_size - scale_x = camera_parameters["scale_x"] - scale_z = camera_parameters["scale_z"] - effective_fx = float(intrinsics[0, 0]) * scale_z / scale_x - camera_data = bpy.data.cameras.new(f"{name}.camera") - camera_data.sensor_fit = "HORIZONTAL" - camera_data.sensor_width = 36.0 - camera_data.lens = effective_fx * camera_data.sensor_width / max( - width - 1, - 1, - ) - cx = float(intrinsics[0, 2]) - cy = float(intrinsics[1, 2]) - camera_data.shift_x = (cx - (width - 1) * 0.5) / max(width - 1, 1) - camera_data.shift_y = ((height - 1) * 0.5 - cy) / max(width - 1, 1) - camera_object = bpy.data.objects.new(f"{name}.camera", camera_data) - context.collection.objects.link(camera_object) - local_matrix = Matrix.Translation(camera_parameters["location"]) - camera_object.matrix_world = source_object.matrix_world @ local_matrix - camera_world = camera_object.matrix_world.copy() - camera_object.parent = mesh_object - camera_object.matrix_world = camera_world - return camera_object - - -def build_image_mesh( - context, - source_object, - npz_path, - delete_source, - create_camera, - shadeless, - moge2=False, - color_image=None, - normal_path=None, -): - import numpy as np - - ( - camera_vertices, - faces, - uv, - intrinsics, - image_size, - reference_depth, - attributes, - ) = load_image_mesh_data(npz_path, moge2=moge2) - vertices, camera_parameters = empty_anchored_vertices( - source_object, - camera_vertices, - intrinsics, - image_size, - reference_depth, - ) - source_name = source_object.name - mesh = bpy.data.meshes.new(source_name) - mesh.from_pydata(vertices.tolist(), [], faces.tolist()) - assign_vertex_uv(mesh, uv) - assign_anyimage_attributes(mesh, attributes, moge2=moge2) - mesh.update() - for polygon in mesh.polygons: - polygon.use_smooth = True - mesh_object = bpy.data.objects.new(source_name, mesh) - context.collection.objects.link(mesh_object) - mesh_object.matrix_world = source_object.matrix_world.copy() - depth_scale = material_depth_scale(mesh_object, camera_parameters) - if color_image is None: - raise RuntimeError("Surface material image is required") - normal_image = ( - load_surface_normal_image( - normal_path, - normal_data_name(source_object), - ) - if normal_path is not None - else None - ) - material = create_material( - material_data_name(source_object), - color_image, - depth_scale, - shadeless=shadeless, - surface_group_name=( - MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME if moge2 else None - ), - normal_image=normal_image, - normal_space="OBJECT" if moge2 and normal_image is not None else "TANGENT", - scene=context.scene, - ) - mesh.materials.append(material) - mesh_object["anyimage_reference_depth"] = reference_depth - mesh_object["anyimage_source_type"] = "IMAGE_MESH" - add_geometry_nodes_processing( - mesh_object, - vertices, - faces, - MOGE_NODE_GROUP_NAME if moge2 else NODE_GROUP_NAME, - ) - if create_camera: - create_matching_camera( - context, - source_object, - mesh_object, - source_name, - intrinsics, - image_size, - camera_parameters, - ) - for selected in context.selected_objects: - selected.select_set(False) - mesh_object.select_set(True) - context.view_layer.objects.active = mesh_object - if delete_source: - bpy.data.objects.remove(source_object, do_unlink=True) - mesh_object.name = source_name - return mesh_object - - -class ConvertToSurface(bpy.types.Operator): - bl_idname = "anyimage.convert_to_surface" - bl_label = "Convert to Surface" - bl_description = "Convert an Image Empty into a perspective textured surface" - bl_options = {"REGISTER"} - - model: bpy.props.EnumProperty( - name="Depth From", - description="Model used to estimate image depth", - items=image_surface_model_items, - default=0, - ) - process_res_factor: process_resolution_scale_property() - resolution_level: bpy.props.IntProperty( - name="Resolution Level", - description="MoGe-2 inference resolution level; higher is finer and slower", - default=5, - min=0, - max=9, - ) - create_camera: bpy.props.BoolProperty( - name="Create Matching Camera", - description="Create a perspective camera matching the source projection", - default=False, - ) - shadeless: bpy.props.BoolProperty( - name="Shadeless", - description="Display the source color without scene lighting", - default=False, - ) - remove_background: bpy.props.BoolProperty( - name="Remove Background", - description="Remove the background during conversion with BEN2", - default=False, - ) - keep_original: bpy.props.BoolProperty( - name="Keep Original", - description="Keep the source Image Empty after conversion", - default=False, - ) - - @classmethod - def poll(cls, context): - from ..runtime import runtime - - return ( - is_image_empty(context.object) - and video_total_frames(context.object.data) == 1 - and not runtime.server_busy() - ) - - def invoke(self, context, _event): - setup_result = invoke_ai_setup_if_needed() - if setup_result is not None: - return setup_result - return context.window_manager.invoke_props_dialog(self, width=360) - - def draw(self, context): - layout = self.layout - layout.prop(self, "model") - if self.model.startswith("MOGE2_"): - layout.prop(self, "resolution_level") - else: - draw_process_resolution( - layout, - self, - analysis_source_image(context.object.data), - ) - layout.prop(self, "create_camera") - layout.prop(self, "remove_background") - layout.prop(self, "shadeless") - layout.separator(type="LINE") - layout.prop(self, "keep_original") - - def execute(self, context): - source_object = context.object - if video_total_frames(source_object.data) != 1: - self.report({"ERROR"}, "Convert to Surface currently supports still images") - return {"CANCELLED"} - try: - analysis_image = analysis_source_image(source_object.data) - process_res = 1 - if not self.model.startswith("MOGE2_"): - process_res = scaled_process_resolution( - analysis_image, - self.process_res_factor, - ) - ( - source_path, - delete_input, - analysis_path, - delete_analysis_input, - ) = prepare_image_analysis_inputs( - source_object.data, - context.scene, - ) - except (RuntimeError, ValueError) as error: - self.report({"ERROR"}, str(error)) - return {"CANCELLED"} - common = { - "input_path": str(source_path), - "analysis_input_path": str(analysis_path), - "source_object_name": source_object.name, - "model": self.model, - "delete_source": not self.keep_original, - "delete_input": delete_input, - "delete_analysis_input": delete_analysis_input, - "create_camera": self.create_camera, - "shadeless": self.shadeless, - "remove_background": self.remove_background, - } - if self.model.startswith("MOGE2_"): - result = bpy.ops.anyimage.generate_moge2_surface( - **common, - resolution_level=self.resolution_level, - ) - else: - result = bpy.ops.anyimage.generate_da3_surface( - **common, - process_res=process_res, - ) - if "CANCELLED" in result: - if delete_input: - remove_temporary_input(source_path) - if delete_analysis_input: - remove_temporary_input(analysis_path) - return {"CANCELLED"} - return {"FINISHED"} - - - - -def build_surface_response( - context, - result, - *, - source_object_name, - delete_source, - create_camera, - shadeless, - moge2, -): - from .image import create_color_image, is_image_empty - - - source_object = bpy.data.objects.get(source_object_name) - if not is_image_empty(source_object): - raise RuntimeError("The source Image Empty no longer exists") - color_image = create_color_image( - source_object, - result.directory, - assign_to_source=False, - ) - build_image_mesh( - context, - source_object, - result.file("surface"), - delete_source, - create_camera, - shadeless, - moge2, - color_image=color_image, - normal_path=( - result.directory / "normal.png" - if moge2 - else None - ), - ) - push_undo("Convert to Surface") - - -class GenerateDa3Surface(JobOperatorBase, bpy.types.Operator): - bl_idname = "anyimage.generate_da3_surface" - bl_label = "Generate DA3 Surface" - job_type = "generate-surface" - starting_message = "Starting perspective surface generation" - - input_path: bpy.props.StringProperty(options=HIDDEN) - analysis_input_path: bpy.props.StringProperty(options=HIDDEN) - source_object_name: bpy.props.StringProperty(options=HIDDEN) - model: bpy.props.StringProperty(default="BASE", options=HIDDEN) - process_res: bpy.props.IntProperty(default=1024, min=1, options=HIDDEN) - delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN) - delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN) - delete_analysis_input: bpy.props.BoolProperty(default=False, options=HIDDEN) - create_camera: bpy.props.BoolProperty(default=False, options=HIDDEN) - shadeless: bpy.props.BoolProperty(default=False, options=HIDDEN) - remove_background: bpy.props.BoolProperty(default=False, options=HIDDEN) - - def request(self, context): - require_environment() - require_model(self.model) - if self.remove_background: - require_model(BEN2_MODEL_KEY) - settings = context.scene.anyimage_settings - parameters = { - **da3_parameters( - settings, - input_path(self.input_path), - self.model, - self.process_res, - source_start_frame(self.source_object_name), - ), - "remove_background_model": ( - BEN2_MODEL_KEY if self.remove_background else "" - ), - } - analysis_value = getattr(self, "analysis_input_path", "") or self.input_path - parameters["analysis_input"] = str(input_path(analysis_value)) - return parameters - - def response(self, context, result): - build_surface_response( - context, - result, - source_object_name=self.source_object_name, - delete_source=self.delete_source, - create_camera=self.create_camera, - shadeless=self.shadeless, - moge2=False, - ) - return "Perspective surface is ready" - - def cleanup(self): - cleanup_temporary_input(self.input_path, self.delete_input) - cleanup_temporary_input( - getattr(self, "analysis_input_path", ""), - getattr(self, "delete_analysis_input", False), - ) - - -class GenerateMoge2Surface(JobOperatorBase, bpy.types.Operator): - bl_idname = "anyimage.generate_moge2_surface" - bl_label = "Generate MoGe-2 Surface" - job_type = "generate-surface" - starting_message = "Starting MoGe-2 inference" - - input_path: bpy.props.StringProperty(options=HIDDEN) - analysis_input_path: bpy.props.StringProperty(options=HIDDEN) - source_object_name: bpy.props.StringProperty(options=HIDDEN) - model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=HIDDEN) - resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=HIDDEN) - delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN) - delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN) - delete_analysis_input: bpy.props.BoolProperty(default=False, options=HIDDEN) - create_camera: bpy.props.BoolProperty(default=False, options=HIDDEN) - shadeless: bpy.props.BoolProperty(default=False, options=HIDDEN) - remove_background: bpy.props.BoolProperty(default=False, options=HIDDEN) - - def request(self, context): - require_environment() - require_model(self.model) - if self.remove_background: - require_model(BEN2_MODEL_KEY) - settings = context.scene.anyimage_settings - parameters = { - **moge2_parameters( - settings, - input_path(self.input_path), - self.model, - self.resolution_level, - source_start_frame(self.source_object_name), - ), - "remove_background_model": ( - BEN2_MODEL_KEY if self.remove_background else "" - ), - } - analysis_value = getattr(self, "analysis_input_path", "") or self.input_path - parameters["analysis_input"] = str(input_path(analysis_value)) - return parameters - - def response(self, context, result): - build_surface_response( - context, - result, - source_object_name=self.source_object_name, - delete_source=self.delete_source, - create_camera=self.create_camera, - shadeless=self.shadeless, - moge2=True, - ) - return "MoGe-2 surface is ready" - - def cleanup(self): - cleanup_temporary_input(self.input_path, self.delete_input) - cleanup_temporary_input( - getattr(self, "analysis_input_path", ""), - getattr(self, "delete_analysis_input", False), - ) diff --git a/src/anyimage/operators/upscale.py b/src/anyimage/operators/upscale.py index ef1b011..5061629 100644 --- a/src/anyimage/operators/upscale.py +++ b/src/anyimage/operators/upscale.py @@ -1,16 +1,14 @@ import bpy -from ..preferences import ( - DEFAULT_UPSCALE_MAX_OUTPUT_SIZE, - MAX_UPSCALE_OUTPUT_SIZE, -) -from ..properties import ( - configured_upscale_max_output_size, - configured_upscale_model, - upscale_model_label, +from ..preferences import configured_max_ai_input_size +from ..properties import configured_upscale_model, upscale_model_label +from ..server.media.resolution import ( + DEFAULT_MAX_AI_INPUT_SIZE, + MAX_MAX_AI_INPUT_SIZE, + MIN_MAX_AI_INPUT_SIZE, ) from ..runtime import JobOperatorBase -from .common import ( +from .common.operator import ( HIDDEN, cleanup_temporary_input, input_path, @@ -20,7 +18,7 @@ from .common import ( require_model, source_start_frame, ) -from .image import ( +from .common.image import ( create_color_image, default_extract_frames, image_base_name, @@ -42,15 +40,12 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator): default="REALESRGAN_GENERAL_WDN_X4V3", options=HIDDEN, ) - max_output_size: bpy.props.IntProperty( - name="Max Output Size", - description=( - "Maximum output long edge; larger inputs are resized before 4x " - "upscaling to keep memory use bounded" - ), - default=DEFAULT_UPSCALE_MAX_OUTPUT_SIZE, - min=1024, - max=MAX_UPSCALE_OUTPUT_SIZE, + max_input_size: bpy.props.IntProperty( + name="Max AI Input Size", + description="Maximum input long edge used for AI upscale analysis", + default=DEFAULT_MAX_AI_INPUT_SIZE, + min=MIN_MAX_AI_INPUT_SIZE, + max=MAX_MAX_AI_INPUT_SIZE, step=256, subtype="PIXEL", options=HIDDEN, @@ -74,7 +69,7 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator): def execute(self, context): self.model = configured_upscale_model(context) - self.max_output_size = configured_upscale_max_output_size(context) + self.max_input_size = configured_max_ai_input_size(context) source_object = context.object if not is_image_empty(source_object): self.report({"ERROR"}, "Select an Image Empty to upscale") @@ -127,7 +122,7 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator): "device": production_device().lower(), "video_frames": settings.video_frames, "start_frame": source_start_frame(self.source_object_name), - "max_output_size": int(self.max_output_size), + "max_input_size": int(self.max_input_size), } def response(self, _context, result): diff --git a/src/anyimage/preferences.py b/src/anyimage/preferences.py index 674ea20..f4b813f 100644 --- a/src/anyimage/preferences.py +++ b/src/anyimage/preferences.py @@ -2,6 +2,11 @@ from pathlib import Path import bpy +from .server.media.resolution import ( + DEFAULT_MAX_AI_INPUT_SIZE, + MAX_MAX_AI_INPUT_SIZE, + MIN_MAX_AI_INPUT_SIZE, +) from .server.model_catalog import ( DEFAULT_UPSCALE_MODEL_KEY, UPSCALE_MODELS, @@ -11,8 +16,6 @@ DEFAULT_MOGE2_MODEL_KEY = "MOGE2_VITS_NORMAL" ADDON_PACKAGE = __package__ DEFAULT_STORAGE_ROOT = Path.home() / ".anyimage" -DEFAULT_UPSCALE_MAX_OUTPUT_SIZE = 4096 -MAX_UPSCALE_OUTPUT_SIZE = 8192 UPSCALE_MODEL_ITEMS = tuple( (model.key, model.label, model.description, index) for index, model in enumerate(UPSCALE_MODELS.values()) @@ -43,6 +46,14 @@ def configured_storage_root(): return DEFAULT_STORAGE_ROOT +def configured_max_ai_input_size(context=None): + preferences = addon_preferences(context) + value = int( + getattr(preferences, "max_ai_input_size", DEFAULT_MAX_AI_INPUT_SIZE) + ) + return min(max(value, MIN_MAX_AI_INPUT_SIZE), MAX_MAX_AI_INPUT_SIZE) + + def device_items(owner, context): from .properties import device_enum_items @@ -50,9 +61,9 @@ def device_items(owner, context): def mesh_depth_model_items(owner, context): - from .properties import surface_model_enum_items + from .properties import mesh_depth_model_enum_items - return surface_model_enum_items(owner, context) + return mesh_depth_model_enum_items(owner, context) class AnyImagePreferences(bpy.types.AddonPreferences): @@ -82,15 +93,15 @@ class AnyImagePreferences(bpy.types.AddonPreferences): items=UPSCALE_MODEL_ITEMS, default=DEFAULT_UPSCALE_MODEL_KEY, ) - upscale_max_output_size: bpy.props.IntProperty( - name="Maximum Upscale Size", + max_ai_input_size: bpy.props.IntProperty( + name="Maximum AI Input Size", description=( - "Maximum output long edge; larger inputs are resized before 4x " - "upscaling to keep memory use bounded" + "Maximum long edge used by Upscale, Depth and Normal analysis; " + "larger inputs are resized proportionally without changing color textures" ), - default=DEFAULT_UPSCALE_MAX_OUTPUT_SIZE, - min=1024, - max=MAX_UPSCALE_OUTPUT_SIZE, + default=DEFAULT_MAX_AI_INPUT_SIZE, + min=MIN_MAX_AI_INPUT_SIZE, + max=MAX_MAX_AI_INPUT_SIZE, step=256, subtype="PIXEL", ) @@ -135,6 +146,7 @@ class AnyImagePreferences(bpy.types.AddonPreferences): self._draw_section(layout, "Environment") layout.prop(self, "storage_root") layout.prop(self, "device") + layout.prop(self, "max_ai_input_size") layout.label(text=f"Environment: {runtime.environment_directory()}") if not status["environment_ready"]: layout.label(text="Environment is not installed", icon="ERROR") @@ -228,7 +240,6 @@ class AnyImagePreferences(bpy.types.AddonPreferences): models.get(upscale_model), environment_ready, ) - layout.prop(self, "upscale_max_output_size") @staticmethod def _draw_section(layout, label): diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py index aa680df..cfaeea5 100644 --- a/src/anyimage/properties.py +++ b/src/anyimage/properties.py @@ -6,11 +6,7 @@ import sys import bpy -from .preferences import ( - DEFAULT_UPSCALE_MAX_OUTPUT_SIZE, - MAX_UPSCALE_OUTPUT_SIZE, - addon_preferences, -) +from .preferences import addon_preferences from .runtime import runtime from .server import model_catalog as shared_model_catalog @@ -201,7 +197,7 @@ def depth_plane_model_enum_items(_owner, _context): return items -def surface_model_enum_items(_owner, _context): +def mesh_depth_model_enum_items(_owner, _context): models = [ model for model in model_catalog() @@ -223,18 +219,6 @@ def configured_upscale_model(context=None): return shared_model_catalog.DEFAULT_UPSCALE_MODEL_KEY -def configured_upscale_max_output_size(context=None): - preferences = addon_preferences(context) - value = int( - getattr( - preferences, - "upscale_max_output_size", - DEFAULT_UPSCALE_MAX_OUTPUT_SIZE, - ) - ) - return min(max(value, 1024), MAX_UPSCALE_OUTPUT_SIZE) - - def upscale_model_label(model_key): model = shared_model_catalog.UPSCALE_MODELS.get(model_key) return model.label if model is not None else model_key diff --git a/src/anyimage/server/app.py b/src/anyimage/server/app.py index 382c142..84b5296 100644 --- a/src/anyimage/server/app.py +++ b/src/anyimage/server/app.py @@ -2,14 +2,15 @@ from blendjob import JobServer -from .jobs.background import run as _remove_background -from .jobs.debug_ben2 import run as _debug_ben2 -from .jobs.debug_da3 import run as _debug_da3 -from .jobs.debug_moge2 import run as _debug_moge2 -from .jobs.debug_upscale import run as _debug_upscale -from .jobs.frames import run as _extract_frames -from .jobs.image_mask import run as _refine_image_mask -from .jobs.mesh import run as _generate_mesh +from .jobs.remove_background import run as _remove_background +from .jobs.debug_predictions import ( + run_ben2 as _debug_ben2, + run_da3 as _debug_da3, + run_moge2 as _debug_moge2, + run_upscale as _debug_upscale, +) +from .jobs.refine_image_mask import run as _refine_image_mask +from .jobs.prepare_geometry_data import run as _prepare_geometry_data from .jobs.upscale import run as _upscale_image from .model_manager import ModelManager @@ -49,14 +50,9 @@ def upscale_image(context, parameters): return _upscale_image(context, parameters) -@server.job("extract-frames") -def extract_frames(context, parameters): - return _extract_frames(context, parameters) - - -@server.job("generate-mesh") -def generate_mesh(context, parameters): - return _generate_mesh(context, parameters) +@server.job("prepare-geometry-data") +def prepare_geometry_data(context, parameters): + return _prepare_geometry_data(context, parameters) @server.job("debug-da3") diff --git a/src/anyimage/server/geometry/__init__.py b/src/anyimage/server/geometry/__init__.py index c063b52..41ef5a5 100644 --- a/src/anyimage/server/geometry/__init__.py +++ b/src/anyimage/server/geometry/__init__.py @@ -1,35 +1,7 @@ -from .grid_mesh import ( - compact_mesh, - confidence_percentile_map, - confidence_percentile_threshold, - depth_edge_mask, - depth_discontinuity_ratio, - edge_culling_stretch_limit, - foreground_mesh_mask, - mesh_triangle_faces, - remove_background_faces, - triangle_edge_stretch_values, - triangle_edge_stretch_mask, -) from .projection import unproject_camera_depth, unproject_depth_frame -from .surface import GeometryFrame, SurfaceMesh, build_surface __all__ = ( - "GeometryFrame", - "SurfaceMesh", - "build_surface", - "compact_mesh", - "confidence_percentile_map", - "confidence_percentile_threshold", - "depth_edge_mask", - "depth_discontinuity_ratio", - "edge_culling_stretch_limit", - "foreground_mesh_mask", - "mesh_triangle_faces", - "remove_background_faces", - "triangle_edge_stretch_values", - "triangle_edge_stretch_mask", "unproject_camera_depth", "unproject_depth_frame", ) diff --git a/src/anyimage/server/geometry/grid_mesh.py b/src/anyimage/server/geometry/grid_mesh.py deleted file mode 100644 index 305485e..0000000 --- a/src/anyimage/server/geometry/grid_mesh.py +++ /dev/null @@ -1,202 +0,0 @@ -def compact_mesh(vertices, faces): - import numpy as np - - vertices = np.asarray(vertices, dtype=np.float32) - faces = np.asarray(faces, dtype=np.int32) - used_vertices = np.unique(faces) - index_map = np.full(len(vertices), -1, dtype=np.int32) - index_map[used_vertices] = np.arange(len(used_vertices), dtype=np.int32) - vertices = vertices[used_vertices] - faces = index_map[faces] - return vertices, faces - - -def confidence_percentile_map(depth, confidence): - import numpy as np - - valid = np.isfinite(depth) & (depth > 0.0) & np.isfinite(confidence) - result = np.zeros(depth.shape, dtype=np.float32) - if not valid.any(): - return result - sorted_values = np.sort(np.asarray(confidence)[valid]) - ranks = np.searchsorted( - sorted_values, - np.asarray(confidence)[valid], - side="right", - ) - result[valid] = ranks.astype(np.float32) / len(sorted_values) - return result - - -def depth_discontinuity_ratio(depth): - import numpy as np - - padded = np.pad(depth, 1, mode="edge") - windows = np.lib.stride_tricks.sliding_window_view(padded, (3, 3)) - difference = np.nanmax(windows, axis=(-2, -1)) - difference -= np.nanmin(windows, axis=(-2, -1)) - return difference / np.maximum(depth, 1e-6) - - -def triangle_edge_stretch_values(points, triangles): - import numpy as np - - flat_points = np.asarray(points, dtype=np.float32).reshape(-1, 3) - triangle_points = flat_points[triangles] - triangle_depth = triangle_points[..., 2] - safe_depth = np.where( - np.abs(triangle_depth) > 1e-8, - triangle_depth, - np.nan, - ) - triangle_rays = triangle_points / safe_depth[..., None] - next_points = np.roll(triangle_points, -1, axis=1) - next_rays = np.roll(triangle_rays, -1, axis=1) - next_depth = np.roll(triangle_depth, -1, axis=1) - local_depth = (triangle_depth + next_depth) * 0.5 - flat_edges = ( - triangle_rays * local_depth[..., None] - - next_rays * local_depth[..., None] - ) - flat_lengths = np.linalg.norm(flat_edges, axis=-1) - actual_lengths = np.linalg.norm( - triangle_points - next_points, - axis=-1, - ) - stretch = actual_lengths / np.maximum(flat_lengths, 1e-8) - stretch[np.isclose(stretch, 1.0, rtol=1e-5, atol=1e-6)] = 1.0 - return stretch - - -def remove_background_faces(faces, foreground_mask): - """Keep only faces whose vertices are all foreground.""" - foreground_points = foreground_mask.reshape(-1) - return faces[foreground_points[faces].all(axis=1)] - - -def depth_edge_mask(depth, threshold=12.0 / 255.0): - import numpy as np - - values = np.asarray(depth, dtype=np.float32) - edges = np.zeros(values.shape, dtype=bool) - for axis in (0, 1): - first = np.take(values, range(values.shape[axis] - 1), axis=axis) - second = np.take(values, range(1, values.shape[axis]), axis=axis) - valid = ( - np.isfinite(first) - & np.isfinite(second) - & (first > 0.0) - & (second > 0.0) - ) - scale = np.maximum(np.minimum(np.abs(first), np.abs(second)), 1e-6) - discontinuity = valid & (np.abs(first - second) / scale > threshold) - lower = [slice(None), slice(None)] - upper = [slice(None), slice(None)] - lower[axis] = slice(0, -1) - upper[axis] = slice(1, None) - edges[tuple(lower)] |= discontinuity - edges[tuple(upper)] |= discontinuity - return edges - - -def edge_culling_stretch_limit(culling): - factor = min(max(float(culling) / 100.0, 0.0001), 1.0) - return 1.0 / factor - - -def foreground_mesh_mask(foreground_path, depth_shape): - import numpy as np - from PIL import Image, ImageFilter - - height, width = depth_shape - with Image.open(foreground_path) as source: - alpha = source.convert("RGBA").getchannel("A") - alpha = alpha.resize( - (width, height), - resample=Image.Resampling.BILINEAR, - ) - alpha = alpha.filter(ImageFilter.GaussianBlur(radius=0.75)) - return np.asarray(alpha) >= round(255.0 * 0.5) - - -def confidence_percentile_threshold(depth, confidence, percentile): - import numpy as np - - if float(percentile) <= 0.0: - return None - valid = np.isfinite(depth) & (depth > 0.0) & np.isfinite(confidence) - if not valid.any(): - raise ValueError("DA3 returned no valid depth confidence values") - return float(np.percentile(confidence[valid], percentile)) - - -def mesh_triangle_faces( - points, - depth, - confidence, - confidence_threshold, - filter_edges, - max_edge_length, - edge_threshold=12.0 / 255.0, - edge_stretch_limit=None, - vertex_mask=None, -): - import numpy as np - - height, width = depth.shape - indices = np.arange(height * width, dtype=np.int32).reshape(height, width) - quads = np.stack( - ( - indices[:-1, :-1], - indices[1:, :-1], - indices[1:, 1:], - indices[:-1, 1:], - ), - axis=-1, - ).reshape(-1, 4) - valid = ( - np.isfinite(points).all(axis=-1) - & np.isfinite(depth) - & (depth > 0.0) - ) - if confidence_threshold is not None: - valid &= np.isfinite(confidence) & ( - confidence >= confidence_threshold - ) - if filter_edges: - valid &= ~depth_edge_mask(depth, edge_threshold) - if vertex_mask is not None: - if np.shape(vertex_mask) != depth.shape: - raise ValueError("Foreground mask and depth dimensions do not match") - valid &= np.asarray(vertex_mask, dtype=bool) - triangles = np.concatenate( - (quads[:, (0, 1, 2)], quads[:, (0, 2, 3)]), - axis=0, - ) - face_valid = valid.reshape(-1)[triangles].all(axis=1) - if edge_stretch_limit is not None: - face_valid &= triangle_edge_stretch_mask( - points, - triangles, - edge_stretch_limit, - ) - if max_edge_length > 0.0: - flat_points = points.reshape(-1, 3) - triangle_points = flat_points[triangles] - next_points = np.roll(triangle_points, -1, axis=1) - edge_lengths = np.linalg.norm( - triangle_points - next_points, - axis=-1, - ) - face_valid &= (edge_lengths <= max_edge_length).all(axis=1) - triangles = triangles[face_valid] - if not len(triangles): - return np.empty((0, 3), dtype=np.int32) - return triangles - - -def triangle_edge_stretch_mask(points, triangles, stretch_limit): - stretch = triangle_edge_stretch_values(points, triangles) - tolerance = float(stretch_limit) * (1.0 + 1e-5) - return (stretch <= tolerance).all(axis=1) - diff --git a/src/anyimage/server/geometry/normal_texture.py b/src/anyimage/server/geometry/normal_texture.py new file mode 100644 index 0000000..3da7624 --- /dev/null +++ b/src/anyimage/server/geometry/normal_texture.py @@ -0,0 +1,60 @@ +"""Encode camera-space normals as a texture.""" + +from pathlib import Path + + +def resized_selection_mask(selection_mask, shape): + import numpy as np + from PIL import Image + + selection_mask = np.asarray(selection_mask, dtype=bool) + if selection_mask.shape == tuple(shape): + return selection_mask + resized = Image.fromarray(selection_mask.astype(np.uint8) * 255, mode="L") + resized = resized.resize( + (int(shape[1]), int(shape[0])), + Image.Resampling.NEAREST, + ) + return np.asarray(resized, dtype=np.uint8) > 0 + + +def write_camera_normal_texture( + normal, + valid, + origin, + crop_size, + source_size, + output_path, +): + """Encode MoGe camera-axis normals into a full-image RGB texture.""" + import numpy as np + from PIL import Image + + normal = np.asarray(normal, dtype=np.float32) + valid = np.asarray(valid, dtype=bool) + if normal.ndim != 3 or normal.shape[2] != 3: + raise ValueError("Depth Balloon normal data must have three components") + if valid.shape != normal.shape[:2]: + valid = resized_selection_mask(valid, normal.shape[:2]) + encoded = np.clip( + (normal * np.asarray((1.0, -1.0, -1.0), dtype=np.float32) + 1.0) + * 127.5, + 0.0, + 255.0, + ).astype(np.uint8) + encoded[~valid] = (128, 128, 255) + crop_width, crop_height = (int(value) for value in crop_size) + crop = Image.fromarray(encoded, mode="RGB") + if crop.size != (crop_width, crop_height): + crop = crop.resize( + (crop_width, crop_height), + Image.Resampling.BILINEAR, + ) + source_width, source_height = (int(value) for value in source_size) + full = Image.new("RGB", (source_width, source_height), (128, 128, 255)) + full.paste(crop, tuple(int(value) for value in origin)) + output_path = Path(output_path) + full.save(output_path) + return output_path + + diff --git a/src/anyimage/server/geometry/surface.py b/src/anyimage/server/geometry/surface.py deleted file mode 100644 index 999a2a3..0000000 --- a/src/anyimage/server/geometry/surface.py +++ /dev/null @@ -1,133 +0,0 @@ -from dataclasses import dataclass, field -from .grid_mesh import ( - confidence_percentile_map, - depth_discontinuity_ratio, - mesh_triangle_faces, - remove_background_faces, -) - - -@dataclass -class GeometryFrame: - depth: object - points: object - intrinsics: object - valid_mask: object - confidence: object | None = None - model_mask: object | None = None - - -@dataclass -class SurfaceMesh: - vertices: object - faces: object - uv: object - intrinsics: object - image_size: tuple[int, int] - reference_depth: float - point_attributes: dict = field(default_factory=dict) - face_attributes: dict = field(default_factory=dict) - - -def _validate_frame(frame): - import numpy as np - - depth = np.asarray(frame.depth, dtype=np.float32) - points = np.asarray(frame.points, dtype=np.float32) - intrinsics = np.asarray(frame.intrinsics, dtype=np.float32) - valid_mask = np.asarray(frame.valid_mask, dtype=bool) - if depth.ndim != 2: - raise ValueError(f"Unsupported depth shape: {depth.shape}") - if points.shape != (*depth.shape, 3): - raise ValueError("Point grid and depth dimensions do not match") - if intrinsics.shape != (3, 3): - raise ValueError(f"Unsupported intrinsics shape: {intrinsics.shape}") - if valid_mask.shape != depth.shape: - raise ValueError("Valid mask and depth dimensions do not match") - if frame.confidence is not None: - confidence = np.asarray(frame.confidence) - if confidence.shape != depth.shape: - raise ValueError("Confidence and depth dimensions do not match") - if frame.model_mask is not None: - model_mask = np.asarray(frame.model_mask) - if model_mask.shape != depth.shape: - raise ValueError("Model mask and depth dimensions do not match") - return depth, points, intrinsics, valid_mask - - -def _compact_grid(points, faces, depth): - import numpy as np - - height, width = depth.shape - used = np.unique(faces) - index_map = np.full(height * width, -1, dtype=np.int32) - index_map[used] = np.arange(len(used), dtype=np.int32) - u = np.linspace(0.0, 1.0, width, dtype=np.float32) - v = np.linspace(1.0, 0.0, height, dtype=np.float32) - uv = np.stack(np.meshgrid(u, v), axis=-1).reshape(-1, 2)[used] - return ( - points.reshape(-1, 3)[used].astype(np.float32), - index_map[faces], - uv, - used, - ) - - -def build_surface(frame, foreground_mask=None): - import numpy as np - - depth, points, intrinsics, valid_mask = _validate_frame(frame) - faces = mesh_triangle_faces( - points, - depth, - np.ones(depth.shape, dtype=np.float32), - None, - False, - 0.0, - vertex_mask=valid_mask, - ) - if foreground_mask is not None: - foreground_mask = np.asarray(foreground_mask, dtype=bool) - if foreground_mask.shape != depth.shape: - raise ValueError("Foreground mask and depth dimensions do not match") - faces = remove_background_faces(faces, foreground_mask) - if not len(faces): - raise ValueError("Model output does not contain any valid surface faces") - - face_attributes = { - "face_depth_discontinuity": ( - depth_discontinuity_ratio(depth).reshape(-1)[faces].max(axis=1) - ).astype(np.float32), - } - if frame.confidence is not None: - confidence = confidence_percentile_map( - depth, - np.asarray(frame.confidence, dtype=np.float32), - ) - face_attributes["face_confidence"] = ( - confidence.reshape(-1)[faces].min(axis=1).astype(np.float32) - ) - if frame.model_mask is not None: - model_mask = np.asarray(frame.model_mask, dtype=bool) - face_attributes["face_mask"] = model_mask.reshape(-1)[faces].all(axis=1) - - vertices, compact_faces, uv, used = _compact_grid(points, faces, depth) - point_depth = depth.reshape(-1)[used].astype(np.float32) - reference_values = point_depth - if frame.model_mask is not None: - masked = point_depth[ - np.asarray(frame.model_mask, dtype=bool).reshape(-1)[used] - ] - if len(masked): - reference_values = masked - return SurfaceMesh( - vertices=vertices, - faces=compact_faces.astype(np.int32), - uv=uv.astype(np.float32), - intrinsics=intrinsics.astype(np.float32), - image_size=(depth.shape[1], depth.shape[0]), - reference_depth=float(np.median(reference_values)), - point_attributes={"point_depth": point_depth}, - face_attributes=face_attributes, - ) - diff --git a/src/anyimage/server/jobs/common.py b/src/anyimage/server/jobs/common.py deleted file mode 100644 index 6a74f5b..0000000 --- a/src/anyimage/server/jobs/common.py +++ /dev/null @@ -1,37 +0,0 @@ -import shutil -from contextlib import contextmanager - -from ..media import prepare_input - - -def frame_progress_message(message, index, total): - if total > 1: - return f"{message} ({index + 1}/{total})" - return message - - -@contextmanager -def input_frames(context, parameters): - 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): - analysis_input = parameters.get("analysis_input") - if not analysis_input or analysis_input == parameters["input"]: - yield fallback_images - return - analysis_parameters = dict(parameters, input=analysis_input) - with input_frames(context, analysis_parameters) as images: - yield images diff --git a/src/anyimage/server/jobs/cutout_depth.py b/src/anyimage/server/jobs/cutout_depth.py deleted file mode 100644 index afa8d8a..0000000 --- a/src/anyimage/server/jobs/cutout_depth.py +++ /dev/null @@ -1,176 +0,0 @@ -from pathlib import Path - - -PLANE_VARIANCE_FLOOR_RATIO = 0.025 - - -def resized_selection_mask(selection_mask, shape): - import numpy as np - from PIL import Image - - selection_mask = np.asarray(selection_mask, dtype=bool) - if selection_mask.shape == tuple(shape): - return selection_mask - resized = Image.fromarray(selection_mask.astype(np.uint8) * 255, mode="L") - resized = resized.resize( - (int(shape[1]), int(shape[0])), - Image.Resampling.NEAREST, - ) - return np.asarray(resized, dtype=np.uint8) > 0 - - -def _stable_weighted_plane(coordinates, values, weights): - import numpy as np - - total = float(np.sum(weights)) - if total <= 1e-12: - return np.zeros(3, dtype=np.float64) - 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.float64) - - -def normalized_moge_depth(depth, model_mask, selection_mask): - """Normalize MoGe depth while preserving its adjustable fitted base plane.""" - import numpy as np - - depth = np.asarray(depth, dtype=np.float32) - model_mask = np.asarray(model_mask, dtype=bool) - selection_mask = resized_selection_mask(selection_mask, depth.shape) - if model_mask.shape != depth.shape: - raise ValueError("MoGe-2 depth and mask dimensions do not match") - valid = selection_mask & model_mask & np.isfinite(depth) & (depth > 0.0) - if not valid.any(): - raise ValueError("MoGe-2 found no valid depth inside the lasso") - - height, width = depth.shape - y, x = np.indices(depth.shape, dtype=np.float64) - x = x / max(width - 1, 1) * 2.0 - 1.0 - y = y / max(height - 1, 1) * 2.0 - 1.0 - coordinates = np.stack((x[valid], y[valid]), axis=1) - selected = depth[valid].astype(np.float64) - weights = np.ones_like(selected) - coefficients = np.zeros(3, dtype=np.float64) - for _iteration in range(4): - coefficients = _stable_weighted_plane( - coordinates, - selected, - weights, - ) - residual = ( - selected - - coordinates @ coefficients[:2] - - coefficients[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), - ) - - baseline = coefficients[0] * x + coefficients[1] * y + coefficients[2] - reference = float(np.median(baseline[valid])) - residual = baseline[valid] - selected - near_residual = np.maximum(residual, 0.0) - near_total = float(np.sum(near_residual)) - relative_span = ( - float(np.sum(np.square(near_residual))) / near_total - if near_total > 1e-12 - else float(np.sqrt(np.mean(np.square(residual)))) - ) - relative_span = max(relative_span, 1e-6) - depth_signal = np.zeros(depth.shape, dtype=np.float32) - depth_signal[valid] = (reference - selected) / relative_span - baseline_vector = np.asarray( - ( - -coefficients[0] / relative_span, - -coefficients[1] / relative_span, - (reference - coefficients[2]) / relative_span, - ), - dtype=np.float32, - ) - return depth_signal, valid, reference, baseline_vector - - -def write_depth_analysis( - prediction, - selection_mask, - origin, - source_size, - output_path, -): - import numpy as np - - if prediction.depth.shape[0] != 1 or prediction.mask.shape[0] != 1: - raise ValueError("Depth Balloon requires one MoGe-2 frame") - depth_signal, valid, reference, baseline_vector = normalized_moge_depth( - prediction.depth[0], - prediction.mask[0], - selection_mask, - ) - crop_size = (selection_mask.shape[1], selection_mask.shape[0]) - np.savez_compressed( - output_path, - depth_signal=depth_signal, - baseline_vector=baseline_vector, - valid=valid, - reference_depth=np.asarray(reference, dtype=np.float32), - origin=np.asarray(origin, dtype=np.int32), - crop_size=np.asarray(crop_size, dtype=np.int32), - source_size=np.asarray(source_size, dtype=np.int32), - ) - return Path(output_path) - - -def write_camera_normal_texture( - normal, - valid, - origin, - crop_size, - source_size, - output_path, -): - """Encode MoGe camera-axis normals into a full-image RGB texture.""" - import numpy as np - from PIL import Image - - normal = np.asarray(normal, dtype=np.float32) - valid = np.asarray(valid, dtype=bool) - if normal.ndim != 3 or normal.shape[2] != 3: - raise ValueError("Depth Balloon normal data must have three components") - if valid.shape != normal.shape[:2]: - valid = resized_selection_mask(valid, normal.shape[:2]) - encoded = np.clip( - (normal * np.asarray((1.0, -1.0, -1.0), dtype=np.float32) + 1.0) - * 127.5, - 0.0, - 255.0, - ).astype(np.uint8) - encoded[~valid] = (128, 128, 255) - crop_width, crop_height = (int(value) for value in crop_size) - crop = Image.fromarray(encoded, mode="RGB") - if crop.size != (crop_width, crop_height): - crop = crop.resize( - (crop_width, crop_height), - Image.Resampling.BILINEAR, - ) - source_width, source_height = (int(value) for value in source_size) - full = Image.new("RGB", (source_width, source_height), (128, 128, 255)) - full.paste(crop, tuple(int(value) for value in origin)) - output_path = Path(output_path) - full.save(output_path) - return output_path - - diff --git a/src/anyimage/server/jobs/debug_ben2.py b/src/anyimage/server/jobs/debug_ben2.py deleted file mode 100644 index aa65f4f..0000000 --- a/src/anyimage/server/jobs/debug_ben2.py +++ /dev/null @@ -1,26 +0,0 @@ -from ..media import copy_frames -from ..models.ben2 import infer_foreground -from .common import input_frames - - -def run(context, parameters): - context.progress(0.02, "Preparing BEN2 API input") - context.directory.mkdir(parents=True, exist_ok=True) - model_manager = context.resource("model_manager") - model_directory = model_manager.directory(parameters["model"]) - with input_frames(context, parameters) as images: - for index, image in enumerate(images): - context.check_cancelled() - foreground, _load_ms, _inference_ms = infer_foreground( - image, - model_directory, - parameters["device"], - model_cache=model_manager, - cancel_check=context.check_cancelled, - ) - suffix = "" if len(images) == 1 else f".{index + 1:04d}" - foreground.save(context.directory / f"foreground{suffix}.png") - copy_frames(images, context.directory) - context.check_cancelled() - context.progress(1.0, "Saved raw BEN2 API foreground") - return {"directory": "."} diff --git a/src/anyimage/server/jobs/debug_da3.py b/src/anyimage/server/jobs/debug_da3.py deleted file mode 100644 index 67a496e..0000000 --- a/src/anyimage/server/jobs/debug_da3.py +++ /dev/null @@ -1,21 +0,0 @@ -from ..media import copy_frames -from ..models import da3 -from ..outputs.debug_da3 import write_prediction -from .common import input_frames - - -def run(context, parameters): - context.progress(0.02, "Preparing DA3 API input") - context.directory.mkdir(parents=True, exist_ok=True) - with input_frames(context, parameters) as images: - prediction = da3.infer( - context.resource("model_manager"), - parameters, - images, - cancel_check=context.check_cancelled, - ) - write_prediction(prediction, context.directory / "prediction.npz") - copy_frames(images, context.directory) - context.check_cancelled() - context.progress(1.0, "Saved raw DA3 API prediction") - return {"directory": "."} diff --git a/src/anyimage/server/jobs/debug_moge2.py b/src/anyimage/server/jobs/debug_moge2.py deleted file mode 100644 index 6d2d2c7..0000000 --- a/src/anyimage/server/jobs/debug_moge2.py +++ /dev/null @@ -1,26 +0,0 @@ -from ..media import copy_frames -from ..models.moge2 import infer_one -from ..outputs.debug_moge2 import write_prediction -from .common import input_frames - - -def run(context, parameters): - context.progress(0.02, "Preparing MoGe-2 API input") - context.directory.mkdir(parents=True, exist_ok=True) - with input_frames(context, parameters) as images: - for index, image in enumerate(images): - context.check_cancelled() - prediction = infer_one( - context.resource("model_manager"), - parameters, - image, - ) - suffix = "" if len(images) == 1 else f".{index + 1:04d}" - write_prediction( - prediction, - context.directory / f"prediction{suffix}.npz", - ) - copy_frames(images, context.directory) - context.check_cancelled() - context.progress(1.0, "Saved raw MoGe-2 API prediction") - return {"directory": "."} diff --git a/src/anyimage/server/jobs/debug_predictions.py b/src/anyimage/server/jobs/debug_predictions.py new file mode 100644 index 0000000..067ecf4 --- /dev/null +++ b/src/anyimage/server/jobs/debug_predictions.py @@ -0,0 +1,93 @@ +from ..media import copy_frames, input_frames +from ..models import da3 +from ..models.ben2 import infer_foreground +from ..models.moge2 import infer_one as infer_moge2 +from ..models.upscale import infer_one as infer_upscale +from ..outputs.debug_da3 import write_prediction as write_da3_prediction +from ..outputs.debug_moge2 import write_prediction as write_moge2_prediction + + +def run_da3(context, parameters): + context.progress(0.02, "Preparing DA3 API input") + context.directory.mkdir(parents=True, exist_ok=True) + with input_frames(context, parameters) as images: + prediction = da3.infer( + context.resource("model_manager"), + parameters, + images, + cancel_check=context.check_cancelled, + ) + write_da3_prediction(prediction, context.directory / "prediction.npz") + copy_frames(images, context.directory) + context.check_cancelled() + context.progress(1.0, "Saved raw DA3 API prediction") + return {"directory": "."} + + +def run_moge2(context, parameters): + context.progress(0.02, "Preparing MoGe-2 API input") + context.directory.mkdir(parents=True, exist_ok=True) + with input_frames(context, parameters) as images: + for index, image in enumerate(images): + context.check_cancelled() + prediction = infer_moge2( + context.resource("model_manager"), + parameters, + image, + ) + suffix = "" if len(images) == 1 else f".{index + 1:04d}" + write_moge2_prediction( + prediction, + context.directory / f"prediction{suffix}.npz", + ) + copy_frames(images, context.directory) + context.check_cancelled() + context.progress(1.0, "Saved raw MoGe-2 API prediction") + return {"directory": "."} + + +def run_ben2(context, parameters): + context.progress(0.02, "Preparing BEN2 API input") + context.directory.mkdir(parents=True, exist_ok=True) + model_manager = context.resource("model_manager") + model_directory = model_manager.directory(parameters["model"]) + with input_frames(context, parameters) as images: + for index, image in enumerate(images): + context.check_cancelled() + foreground, _load_ms, _inference_ms = infer_foreground( + image, + model_directory, + parameters["device"], + model_cache=model_manager, + cancel_check=context.check_cancelled, + ) + suffix = "" if len(images) == 1 else f".{index + 1:04d}" + foreground.save(context.directory / f"foreground{suffix}.png") + copy_frames(images, context.directory) + context.check_cancelled() + context.progress(1.0, "Saved raw BEN2 API foreground") + return {"directory": "."} + + +def run_upscale(context, parameters): + context.progress(0.02, "Preparing Real-ESRGAN API input") + context.directory.mkdir(parents=True, exist_ok=True) + with input_frames(context, parameters) as images: + for index, image in enumerate(images): + context.check_cancelled() + prediction = infer_upscale( + context.resource("model_manager"), + parameters, + image, + cancel_check=context.check_cancelled, + ) + suffix = "" if len(images) == 1 else f".{index + 1:04d}" + prediction.save( + context.directory / f"prediction{suffix}.png", + format="PNG", + compress_level=1, + ) + copy_frames(images, context.directory) + context.check_cancelled() + context.progress(1.0, "Saved raw Real-ESRGAN API prediction") + return {"directory": "."} diff --git a/src/anyimage/server/jobs/debug_upscale.py b/src/anyimage/server/jobs/debug_upscale.py deleted file mode 100644 index 927debc..0000000 --- a/src/anyimage/server/jobs/debug_upscale.py +++ /dev/null @@ -1,27 +0,0 @@ -from ..media import copy_frames -from ..models.upscale import infer_one -from .common import input_frames - - -def run(context, parameters): - context.progress(0.02, "Preparing Real-ESRGAN API input") - context.directory.mkdir(parents=True, exist_ok=True) - with input_frames(context, parameters) as images: - for index, image in enumerate(images): - context.check_cancelled() - prediction = infer_one( - context.resource("model_manager"), - parameters, - image, - cancel_check=context.check_cancelled, - ) - suffix = "" if len(images) == 1 else f".{index + 1:04d}" - prediction.save( - context.directory / f"prediction{suffix}.png", - format="PNG", - compress_level=1, - ) - copy_frames(images, context.directory) - context.check_cancelled() - context.progress(1.0, "Saved raw Real-ESRGAN API prediction") - return {"directory": "."} diff --git a/src/anyimage/server/jobs/depth.py b/src/anyimage/server/jobs/depth.py deleted file mode 100644 index ac3c770..0000000 --- a/src/anyimage/server/jobs/depth.py +++ /dev/null @@ -1,51 +0,0 @@ -from ..media import copy_color_images -from ..models import da3, geometry_adapter, moge2 -from ..outputs.depth import write_da3_depth, write_moge_depth -from .common import analysis_frames, frame_progress_message, input_frames -from .foreground import write_optional - - -def run(context, parameters): - context.progress(0.02, "Preparing depth input") - context.directory.mkdir(parents=True, exist_ok=True) - model_manager = context.resource("model_manager") - adapter = geometry_adapter(parameters["model"]) - with input_frames(context, parameters) as images: - with analysis_frames(context, parameters, images) as model_images: - if adapter is da3: - prediction = adapter.infer( - model_manager, - parameters, - model_images, - progress_callback=lambda index, total: context.progress( - 0.15 + 0.65 * index / total, - frame_progress_message("Estimating DA3 depth", index, total), - ), - cancel_check=context.check_cancelled, - ) - write_da3_depth(prediction, context.directory / "results.npz") - else: - prediction = adapter.infer( - model_manager, - parameters, - model_images, - include_points=False, - progress_callback=lambda index, total: context.progress( - 0.15 + 0.65 * index / total, - frame_progress_message("Estimating MoGe-2 depth", index, total), - ), - cancel_check=context.check_cancelled, - ) - write_moge_depth(context.directory, prediction) - color_paths = write_optional( - context, - parameters, - images, - progress_start=0.84, - progress_span=0.08, - ) - if not color_paths: - copy_color_images(images, context.directory) - context.check_cancelled() - context.progress(1.0, f"Exported depth for {len(images)} frame(s)") - return {"depth": "results.npz"} diff --git a/src/anyimage/server/jobs/foreground.py b/src/anyimage/server/jobs/foreground.py deleted file mode 100644 index 78a60ad..0000000 --- a/src/anyimage/server/jobs/foreground.py +++ /dev/null @@ -1,54 +0,0 @@ -from ..models.ben2 import infer_foreground, infer_foreground_frames - - -def write_optional( - context, - parameters, - images, - *, - progress_start, - progress_span, -): - model_key = parameters.get("remove_background_model", "") - if not model_key: - return [] - model_manager = context.resource("model_manager") - model_directory = model_manager.directory(model_key) - device = parameters["device"] - if len(images) == 1: - context.progress(progress_start, "Removing image background") - foreground, load_ms, inference_ms = infer_foreground( - images[0], - model_directory, - device, - model_cache=model_manager, - cancel_check=context.check_cancelled, - preserve_input_alpha=True, - ) - path = context.directory / "image.0001.png" - foreground.convert("RGBA").save( - path, - format="PNG", - compress_level=1, - ) - paths = [path] - else: - load_ms, inference_ms = infer_foreground_frames( - images, - model_directory, - device, - context.directory, - context.progress, - progress_start, - progress_span, - model_cache=model_manager, - cancel_check=context.check_cancelled, - output_prefix="image", - preserve_input_alpha=True, - ) - paths = sorted(context.directory.glob("image.*.png")) - context.log( - f"BEN2 inference {inference_ms:.0f} ms; " - f"model load {load_ms:.0f} ms" - ) - return paths diff --git a/src/anyimage/server/jobs/frames.py b/src/anyimage/server/jobs/frames.py deleted file mode 100644 index 3366ad7..0000000 --- a/src/anyimage/server/jobs/frames.py +++ /dev/null @@ -1,21 +0,0 @@ -import shutil - -from ..media import copy_color_images, prepare_input - - -def run(context, parameters): - context.directory.mkdir(parents=True, exist_ok=True) - 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: - copy_color_images(images, context.directory) - finally: - if temporary_directory is not None: - shutil.rmtree(temporary_directory, ignore_errors=True) - context.progress(1.0, f"Extracted {len(images)} frame(s)") - return {"frames": len(images)} diff --git a/src/anyimage/server/jobs/mesh.py b/src/anyimage/server/jobs/mesh.py deleted file mode 100644 index 427cadd..0000000 --- a/src/anyimage/server/jobs/mesh.py +++ /dev/null @@ -1,405 +0,0 @@ -import json - -from ..media import copy_color_images -from ..models import geometry_adapter, moge2 -from .background import remove_image_background -from .cutout_depth import ( - write_camera_normal_texture, - write_depth_analysis, -) -from .common import analysis_frames, input_frames -from .image_mask import ( - apply_selection_mask, - create_selection_canvas, - polygon_selection_mask, -) - - -def _write_field(frame, output_path): - import numpy as np - - depth = np.asarray(frame.depth, dtype=np.float32) - points = np.asarray(frame.points, dtype=np.float32) - intrinsics = np.asarray(frame.intrinsics, dtype=np.float32) - valid = np.asarray(frame.valid_mask, dtype=bool) - if depth.ndim != 2 or points.shape != (*depth.shape, 3): - raise ValueError("The depth model returned an invalid point field") - valid &= np.isfinite(depth) & (depth > 0.0) - valid &= np.isfinite(points).all(axis=-1) - if not valid.any(): - raise ValueError("The depth model returned no valid geometry") - reference_values = depth[valid] - if frame.model_mask is not None: - model_mask = np.asarray(frame.model_mask, dtype=bool) - masked = depth[valid & model_mask] - if len(masked): - reference_values = masked - np.savez_compressed( - output_path, - points=points, - depth=depth, - intrinsics=intrinsics, - valid=valid, - image_size=np.asarray((depth.shape[1], depth.shape[0]), dtype=np.int32), - reference_depth=np.asarray(np.median(reference_values), dtype=np.float32), - ) - - -def _write_masked_color(input_path, polygon, output_path, invert=False): - import numpy as np - from PIL import Image, ImageFilter, ImageOps - - with Image.open(input_path) as opened: - source = opened.convert("RGBA") - selection = Image.fromarray( - polygon_selection_mask(source.size, polygon, "NONZERO").astype(np.uint8) * 255, - mode="L", - ).filter(ImageFilter.GaussianBlur(radius=0.75)) - if invert: - selection = ImageOps.invert(selection) - pixels = np.asarray(source, dtype=np.uint8).copy() - pixels[:, :, 3] = np.asarray(selection, dtype=np.uint8) - pixels[pixels[:, :, 3] == 0, :3] = 0 - Image.fromarray(pixels, mode="RGBA").save( - output_path, - format="PNG", - compress_level=1, - ) - return output_path - - -def _write_unbounded_selection( - input_path, - polygon, - output_path, - invert=False, -): - import numpy as np - from PIL import Image, ImageFilter - - if invert: - _write_masked_color(input_path, polygon, output_path, invert=True) - with Image.open(input_path) as source: - return output_path, (0, 0, source.width, source.height) - canvas, selection_mask, bounds = create_selection_canvas( - input_path, polygon, 'NONZERO' - ) - selection = Image.fromarray( - selection_mask.astype(np.uint8) * 255, - mode='L', - ).filter(ImageFilter.GaussianBlur(radius=0.75)) - pixels = np.asarray(canvas, dtype=np.uint8).copy() - pixels[:, :, 3] = np.asarray(selection, dtype=np.uint8) - pixels[pixels[:, :, 3] == 0, :3] = 0 - Image.fromarray(pixels, mode='RGBA').save( - output_path, - format='PNG', - compress_level=1, - ) - return output_path, bounds - - -def _write_refined_color(context, parameters, input_path, polygon, output_path): - from PIL import Image, ImageChops - - canvas, selection_mask, bounds = create_selection_canvas( - input_path, polygon, "NONZERO" - ) - selection_path = context.directory / "selection-input.png" - canvas.save(selection_path, format="PNG", compress_level=1) - model_manager = context.resource("model_manager") - foreground, _load_ms, _inference_ms = remove_image_background( - selection_path, - model_manager.directory(parameters["refine_model"]), - parameters["device"], - model_manager, - context.check_cancelled, - ) - refined = apply_selection_mask(foreground, selection_mask) - with Image.open(input_path) as opened: - source = opened.convert("RGBA") - if parameters.get("invert"): - mask = Image.new("L", source.size, 0) - mask.paste(refined.getchannel("A"), bounds[:2]) - source.putalpha( - ImageChops.multiply(source.getchannel("A"), ImageChops.invert(mask)) - ) - output = source - else: - output = Image.new("RGBA", source.size, (0, 0, 0, 0)) - output.paste(refined, bounds[:2]) - output.save(output_path, format="PNG", compress_level=1) - return output_path - - -def _write_cutout_images( - input_path, - selection_path, - output_directory, - selection_bounds=None, -): - import numpy as np - from PIL import Image - - with Image.open(input_path) as opened: - source = opened.convert("RGBA") - with Image.open(selection_path) as opened: - selection = opened.convert("RGBA") - if selection_bounds is None and selection.size != source.size: - raise ValueError("The Cutout selection does not match the source image") - if selection_bounds is None: - selection_bounds = (0, 0, source.width, source.height) - selection_pixels = np.asarray(selection, dtype=np.uint8).copy() - alpha = selection_pixels[:, :, 3] - alpha[alpha <= 1] = 0 - selection_pixels[alpha == 0, :3] = 0 - selection = Image.fromarray(selection_pixels, mode="RGBA") - visible_y, visible_x = np.nonzero(alpha >= 128) - if not len(visible_x): - raise ValueError("The Cutout selection contains no visible pixels") - bounds = ( - int(visible_x.min()), - int(visible_y.min()), - int(visible_x.max()) + 1, - int(visible_y.max()) + 1, - ) - content_bounds = bounds - selection_left, selection_top, _selection_right, _selection_bottom = ( - selection_bounds - ) - bounds = ( - selection_left + content_bounds[0], - selection_top + content_bounds[1], - selection_left + content_bounds[2], - selection_top + content_bounds[3], - ) - color_canvas = Image.new('RGBA', selection.size, (0, 0, 0, 0)) - color_canvas.paste(source, (-selection_left, -selection_top)) - color = color_canvas.crop(content_bounds) - color_path = output_directory / "image.0001.png" - color.save(color_path, format="PNG", compress_level=1) - - selection_crop = selection.crop(content_bounds) - selection_path = output_directory / "selection.png" - selection_crop.save(selection_path, format="PNG", compress_level=1) - - analysis_pixels = np.asarray(color, dtype=np.uint8).copy() - mask_alpha = np.asarray(selection_crop, dtype=np.uint8)[:, :, 3] - analysis_pixels[:, :, 3] = np.minimum( - analysis_pixels[:, :, 3], - mask_alpha, - ) - analysis_pixels[mask_alpha < 128] = 0 - analysis_path = output_directory / "analysis.png" - Image.fromarray(analysis_pixels, mode="RGBA").save( - analysis_path, - format="PNG", - compress_level=1, - ) - bounds_path = output_directory / "bounds.json" - bounds_path.write_text( - json.dumps(bounds, separators=(",", ":")), - encoding="utf-8", - ) - return color_path, selection_path, analysis_path, bounds_path - -def _write_camera_normal_from_prediction(prediction, color_path, output_path): - import numpy as np - from PIL import Image - - normal = np.asarray(prediction.normal[0], dtype=np.float32) - with Image.open(color_path) as source: - alpha = source.convert("RGBA").getchannel("A") - source_size = source.size - valid = ( - np.asarray( - alpha.resize((normal.shape[1], normal.shape[0]), Image.Resampling.BILINEAR) - ) - > 127 - ) - return write_camera_normal_texture( - normal, - valid, - (0, 0), - source_size, - source_size, - output_path, - ) - - -def _write_camera_normal(context, parameters, color_path): - normal_parameters = dict(parameters) - normal_parameters.update( - model=parameters["normal_model"], - resolution_level=parameters["normal_resolution_level"], - ) - prediction = moge2.infer( - context.resource("model_manager"), - normal_parameters, - [color_path], - include_points=False, - cancel_check=context.check_cancelled, - ) - return _write_camera_normal_from_prediction( - prediction, - color_path, - context.directory / "normal.png", - ) - - -def _write_depth_balloon( - context, - parameters, - input_path, - include_normal=False, -): - import numpy as np - from PIL import Image - - with Image.open(input_path) as source: - rgba = np.asarray(source.convert("RGBA"), dtype=np.uint8) - source_size = source.size - selection_mask = rgba[:, :, 3] >= 128 - prediction = moge2.infer( - context.resource("model_manager"), - parameters, - [input_path], - include_points=False, - cancel_check=context.check_cancelled, - ) - depth_path = write_depth_analysis( - prediction, - selection_mask, - (0, 0), - source_size, - context.directory / "depth.npz", - ) - normal_path = None - if include_normal: - normal_path = _write_camera_normal_from_prediction( - prediction, - input_path, - context.directory / "normal.png", - ) - return depth_path, normal_path - -def _write_surface_depth( - context, parameters, color_path, include_normal=False -): - model_manager = context.resource("model_manager") - adapter = geometry_adapter(parameters["model"]) - if adapter is moge2: - prediction = adapter.infer( - model_manager, - parameters, - [color_path], - include_points=True, - cancel_check=context.check_cancelled, - ) - else: - prediction = adapter.infer( - model_manager, - parameters, - [color_path], - cancel_check=context.check_cancelled, - ) - context.check_cancelled() - field_path = context.directory / "field.npz" - _write_field(adapter.surface_frame(prediction), field_path) - normal_path = None - if include_normal and adapter is moge2: - normal_path = _write_camera_normal_from_prediction( - prediction, color_path, context.directory / "normal.png" - ) - return field_path, normal_path - - -def run(context, parameters): - context.progress(0.02, "Preparing mesh input") - context.directory.mkdir(parents=True, exist_ok=True) - with input_frames(context, parameters) as images: - if len(images) != 1: - raise ValueError("Empty Image to Mesh currently supports one image") - shape = parameters["shape"] - polygon = parameters.get("polygon") - analysis_path = None - result = {} - if polygon: - selection_bounds = None - full_selection_path = context.directory / "selection-full.png" - if parameters.get("refine_selection"): - _write_refined_color( - context, - parameters, - images[0], - polygon, - full_selection_path, - ) - else: - _selection_path, selection_bounds = _write_unbounded_selection( - images[0], - polygon, - full_selection_path, - parameters.get("invert", False), - ) - color_path, selection_path, analysis_path, bounds_path = ( - _write_cutout_images( - images[0], - full_selection_path, - context.directory, - selection_bounds, - ) - ) - result.update( - selection=selection_path.name, - bounds=bounds_path.name, - ) - else: - copy_color_images(images, context.directory) - color_path = min(context.directory.glob("image.0001.*")) - - normal_analysis = bool(parameters.get("normal_analysis")) - with analysis_frames(context, parameters, images) as model_images: - if len(model_images) != 1: - raise ValueError("Empty Image to Mesh currently supports one image") - model_path = analysis_path or model_images[0] - - if shape in {"CUTOUT", "BALLOON"} and normal_analysis: - context.progress(0.4, "Estimating surface normal") - result["normal"] = _write_camera_normal( - context, parameters, model_path - ).name - elif shape == "DEPTH_BALLOON": - context.progress(0.4, "Estimating Balloon depth") - depth_path, normal_path = _write_depth_balloon( - context, - parameters, - model_path, - include_normal=normal_analysis, - ) - result["depth"] = depth_path.name - if normal_path is not None: - result["normal"] = normal_path.name - elif shape in {"DEPTH_PLANE", "DEPTH_SURFACE"}: - context.progress(0.4, "Estimating image depth") - field_path, normal_path = _write_surface_depth( - context, - parameters, - model_path, - include_normal=normal_analysis, - ) - result["field"] = field_path.name - if ( - normal_analysis - and normal_path is None - and shape == "DEPTH_SURFACE" - ): - normal_path = _write_camera_normal( - context, parameters, model_path - ) - if normal_path is not None: - result["normal"] = normal_path.name - - context.check_cancelled() - context.progress(1.0, "Mesh data is ready") - return result diff --git a/src/anyimage/server/jobs/prepare_geometry_data.py b/src/anyimage/server/jobs/prepare_geometry_data.py new file mode 100644 index 0000000..1026648 --- /dev/null +++ b/src/anyimage/server/jobs/prepare_geometry_data.py @@ -0,0 +1,255 @@ +from ..geometry.normal_texture import write_camera_normal_texture +from ..media import ( + DEFAULT_MAX_AI_INPUT_SIZE, + analysis_frames, + copy_color_images, + input_frames, + limit_image, +) +from ..models import geometry_adapter, moge2 +from ..selection.cutout_images import refine_selection, write_images, write_selection + + +def _limited_model_input(source_path, output_path, max_input_size): + from PIL import Image + + with Image.open(source_path) as opened: + limited = limit_image(opened, max_input_size) + if limited is opened: + return source_path + limited.save(output_path, format="PNG", compress_level=1) + return output_path + + +def _write_field(frame, output_path, calibration_valid=None): + import numpy as np + + depth = np.asarray(frame.depth, dtype=np.float32) + points = np.asarray(frame.points, dtype=np.float32) + intrinsics = np.asarray(frame.intrinsics, dtype=np.float32) + valid = np.asarray(frame.valid_mask, dtype=bool) + if depth.ndim != 2 or points.shape != (*depth.shape, 3): + raise ValueError("The depth model returned an invalid point field") + valid &= np.isfinite(depth) & (depth > 0.0) + valid &= np.isfinite(points).all(axis=-1) + if not valid.any(): + raise ValueError("The depth model returned no valid geometry") + if calibration_valid is None: + calibration_valid = valid.copy() + else: + calibration_valid = np.asarray(calibration_valid, dtype=bool) + if calibration_valid.shape != valid.shape: + raise ValueError("The depth calibration mask dimensions do not match") + calibration_valid &= valid + if not calibration_valid.any(): + raise ValueError("The depth field contains no valid calibration pixels") + reference_values = depth[calibration_valid] + if frame.model_mask is not None: + model_mask = np.asarray(frame.model_mask, dtype=bool) + masked = depth[calibration_valid & model_mask] + if len(masked): + reference_values = masked + np.savez_compressed( + output_path, + points=points, + depth=depth, + intrinsics=intrinsics, + valid=valid, + calibration_valid=calibration_valid, + image_size=np.asarray((depth.shape[1], depth.shape[0]), dtype=np.int32), + reference_depth=np.asarray(np.median(reference_values), dtype=np.float32), + ) + + +def _selection_calibration_mask(frame, selection_path): + import numpy as np + from PIL import Image + + valid = np.asarray(frame.valid_mask, dtype=bool) + with Image.open(selection_path) as opened: + selection = opened.convert("RGBA").getchannel("A") + selection = selection.resize( + (valid.shape[1], valid.shape[0]), + Image.Resampling.NEAREST, + ) + selected = np.asarray(selection, dtype=np.uint8) >= 26 + calibration_valid = valid & selected + if not calibration_valid.any(): + raise ValueError("The depth field contains no valid selected pixels") + return calibration_valid + + +def _write_camera_normal_from_prediction(prediction, color_path, output_path): + import numpy as np + from PIL import Image + + normal = np.asarray(prediction.normal[0], dtype=np.float32) + with Image.open(color_path) as source: + alpha = source.convert("RGBA").getchannel("A") + source_size = source.size + valid = ( + np.asarray( + alpha.resize((normal.shape[1], normal.shape[0]), Image.Resampling.BILINEAR) + ) + > 127 + ) + return write_camera_normal_texture( + normal, + valid, + (0, 0), + source_size, + source_size, + output_path, + ) + + +def _write_camera_normal(context, parameters, model_path, color_path=None): + prediction = moge2.infer( + context.resource("model_manager"), + parameters, + [model_path], + include_points=False, + cancel_check=context.check_cancelled, + ) + return _write_camera_normal_from_prediction( + prediction, + color_path or model_path, + context.directory / "normal.png", + ) + + +def _write_depth_field( + context, + parameters, + model_path, + normal_color_path=None, + include_normal=False, + selection_path=None, +): + model_manager = context.resource("model_manager") + adapter = geometry_adapter(parameters["model"]) + if adapter is moge2: + prediction = adapter.infer( + model_manager, + parameters, + [model_path], + include_points=True, + cancel_check=context.check_cancelled, + ) + else: + prediction = adapter.infer( + model_manager, + parameters, + [model_path], + cancel_check=context.check_cancelled, + ) + context.check_cancelled() + field_path = context.directory / "field.npz" + frame = adapter.geometry_frame(prediction) + calibration_valid = frame.valid_mask + if selection_path is not None: + calibration_valid = _selection_calibration_mask(frame, selection_path) + _write_field(frame, field_path, calibration_valid) + normal_path = None + if include_normal and adapter is moge2: + normal_path = _write_camera_normal_from_prediction( + prediction, + normal_color_path or model_path, + context.directory / "normal.png", + ) + return field_path, normal_path + + +def run(context, parameters): + context.progress(0.02, "Preparing geometry input") + context.directory.mkdir(parents=True, exist_ok=True) + with input_frames(context, parameters) as images: + if len(images) != 1: + raise ValueError("Empty Image to Mesh currently supports one image") + shape = parameters["shape"] + polygon = parameters.get("polygon") + analysis_path = None + selection_path = None + result = {} + if polygon: + selection_bounds = None + full_selection_path = context.directory / "selection-full.png" + if parameters.get("refine_selection"): + refine_selection( + context, + parameters, + images[0], + polygon, + full_selection_path, + ) + else: + _selection_path, selection_bounds = write_selection( + images[0], + polygon, + full_selection_path, + parameters.get("invert", False), + ) + color_path, selection_path, analysis_path, bounds_path = ( + write_images( + images[0], + full_selection_path, + context.directory, + selection_bounds, + ) + ) + result.update( + selection=selection_path.name, + bounds=bounds_path.name, + ) + else: + copy_color_images(images, context.directory) + + normal_analysis = bool(parameters.get("normal_analysis")) + with analysis_frames(context, parameters, images) as model_images: + if len(model_images) != 1: + raise ValueError("Empty Image to Mesh currently supports one image") + model_path = analysis_path or model_images[0] + limited_model_path = model_path + if normal_analysis or shape in { + "DEPTH_PLANE", + "DEPTH_BALLOON", + "DEPTH_SURFACE", + }: + limited_model_path = _limited_model_input( + model_path, + context.directory / "model-input.png", + parameters.get("max_input_size", DEFAULT_MAX_AI_INPUT_SIZE), + ) + if shape in {"CUTOUT", "BALLOON"} and normal_analysis: + context.progress(0.4, "Estimating surface normal") + result["normal"] = _write_camera_normal( + context, parameters, limited_model_path, model_path + ).name + elif shape in {"DEPTH_PLANE", "DEPTH_BALLOON", "DEPTH_SURFACE"}: + context.progress(0.4, "Estimating image depth") + field_path, normal_path = _write_depth_field( + context, + parameters, + limited_model_path, + normal_color_path=model_path, + include_normal=normal_analysis, + selection_path=selection_path, + ) + result["field"] = field_path.name + if ( + normal_analysis + and normal_path is None + and shape == "DEPTH_SURFACE" + ): + normal_path = _write_camera_normal( + context, parameters, limited_model_path, model_path + ) + if normal_path is not None: + result["normal"] = normal_path.name + + if limited_model_path != model_path: + limited_model_path.unlink(missing_ok=True) + + context.check_cancelled() + context.progress(1.0, "Geometry data is ready") + return result diff --git a/src/anyimage/server/jobs/refine_image_mask.py b/src/anyimage/server/jobs/refine_image_mask.py new file mode 100644 index 0000000..98563e9 --- /dev/null +++ b/src/anyimage/server/jobs/refine_image_mask.py @@ -0,0 +1,46 @@ +import json +import time + +from ..models.ben2 import infer_foreground +from ..selection.polygon_mask import ( + apply_selection_mask, + create_selection_canvas, + crop_to_alpha, +) + + +def run(context, parameters): + """Refine a gesture mask with BEN2 and return its source-image bounds.""" + started = time.perf_counter() + context.progress(0.05, "Preparing image selection") + context.check_cancelled() + output_directory = context.directory + output_directory.mkdir(parents=True, exist_ok=True) + canvas, selection_mask, bounds = create_selection_canvas( + parameters["input"], parameters["polygon"] + ) + selection_path = output_directory / "selection-input.png" + canvas.save(selection_path, format="PNG", compress_level=1) + model_manager = context.resource("model_manager") + context.progress(0.2, f"Loading BEN2 for {parameters['device'].upper()}") + foreground, _load_ms, _inference_ms = infer_foreground( + selection_path, + model_manager.directory(parameters["model"]), + parameters["device"], + model_cache=model_manager, + cancel_check=context.check_cancelled, + preserve_input_alpha=True, + ) + context.check_cancelled() + context.progress(0.9, "Applying selection mask") + output, bounds = crop_to_alpha( + apply_selection_mask(foreground, selection_mask), bounds + ) + output_path = output_directory / "image.png" + output.save(output_path, format="PNG", compress_level=1) + bounds_path = output_directory / "bounds.json" + bounds_path.write_text(json.dumps(bounds, separators=(",", ":")), encoding="utf-8") + elapsed_ms = (time.perf_counter() - started) * 1000.0 + message = f"Image selection refined in {elapsed_ms:.0f} ms" + context.progress(1.0, message) + return {"image": output_path.name, "bounds": bounds_path.name} diff --git a/src/anyimage/server/jobs/background.py b/src/anyimage/server/jobs/remove_background.py similarity index 67% rename from src/anyimage/server/jobs/background.py rename to src/anyimage/server/jobs/remove_background.py index 539662c..49c68e9 100644 --- a/src/anyimage/server/jobs/background.py +++ b/src/anyimage/server/jobs/remove_background.py @@ -1,24 +1,9 @@ -import shutil import time -from ..media import prepare_background_input +from ..media import input_frames from ..models.ben2 import infer_foreground, infer_foreground_frames -def remove_image_background( - input_path, model_directory, device, model_manager, cancel_check -): - """Remove one image background with the shared BEN2 configuration.""" - return infer_foreground( - input_path, - model_directory, - device, - model_cache=model_manager, - cancel_check=cancel_check, - preserve_input_alpha=True, - ) - - def run(context, parameters): """Run BEN2 and write transparent foreground images into the Job directory.""" total_started = time.perf_counter() @@ -31,25 +16,17 @@ def run(context, parameters): model_manager = context.resource("model_manager") model_directory = model_manager.directory(parameters["model"]) - video_frames = parameters.get("video_frames", 16) - start_frame = parameters.get("start_frame", 1) - images, temporary_directory = prepare_background_input( - parameters["input"], - video_frames, - output_directory, - start_frame=start_frame, - cancel_check=context.check_cancelled, - ) - try: + with input_frames(context, parameters) as images: context.progress(0.12, f"Loading BEN2 for {device.upper()}") if len(images) == 1: context.progress(0.35, "Removing background") - foreground, load_ms, inference_ms = remove_image_background( + foreground, load_ms, inference_ms = infer_foreground( images[0], model_directory, device, - model_manager, - context.check_cancelled, + model_cache=model_manager, + cancel_check=context.check_cancelled, + preserve_input_alpha=True, ) context.check_cancelled() context.progress(0.92, "Writing transparent PNG") @@ -75,9 +52,6 @@ def run(context, parameters): output_prefix="image", preserve_input_alpha=True, ) - finally: - if temporary_directory is not None: - shutil.rmtree(temporary_directory, ignore_errors=True) context.check_cancelled() total_ms = (time.perf_counter() - total_started) * 1000.0 diff --git a/src/anyimage/server/jobs/surface.py b/src/anyimage/server/jobs/surface.py deleted file mode 100644 index 7035e21..0000000 --- a/src/anyimage/server/jobs/surface.py +++ /dev/null @@ -1,71 +0,0 @@ -from ..geometry import build_surface, foreground_mesh_mask -from ..media import copy_color_images -from ..models import geometry_adapter, moge2 -from ..outputs.surface import write_normal_texture, write_surface_archive -from .common import analysis_frames, frame_progress_message, input_frames -from .foreground import write_optional - -def run(context, parameters): - context.progress(0.02, "Preparing surface input") - context.directory.mkdir(parents=True, exist_ok=True) - model_manager = context.resource("model_manager") - adapter = geometry_adapter(parameters["model"]) - with input_frames(context, parameters) as images: - if len(images) != 1: - raise ValueError("Convert to Surface currently supports one image") - context.progress(0.12, "Estimating surface geometry") - with analysis_frames(context, parameters, images) as model_images: - if len(model_images) != 1: - raise ValueError("Convert to Surface currently supports one image") - if adapter is moge2: - prediction = adapter.infer( - model_manager, - parameters, - model_images, - include_points=True, - progress_callback=lambda index, total: context.progress( - 0.12 + 0.58 * index / total, - frame_progress_message("Estimating MoGe-2 geometry", index, total), - ), - cancel_check=context.check_cancelled, - ) - else: - prediction = adapter.infer( - model_manager, - parameters, - model_images, - progress_callback=lambda index, total: context.progress( - 0.12 + 0.58 * index / total, - frame_progress_message("Estimating DA3 geometry", index, total), - ), - cancel_check=context.check_cancelled, - ) - context.check_cancelled() - frame = adapter.surface_frame(prediction) - color_paths = write_optional( - context, - parameters, - images, - progress_start=0.72, - progress_span=0.08, - ) - foreground_mask = None - if color_paths: - foreground_mask = foreground_mesh_mask( - color_paths[0], - frame.depth.shape, - ) - context.progress(0.82, "Building surface") - mesh = build_surface(frame, foreground_mask=foreground_mask) - context.progress(0.92, "Writing surface") - write_surface_archive(mesh, context.directory / "mesh.npz") - if not color_paths: - copy_color_images(images, context.directory) - if adapter is moge2: - write_normal_texture( - prediction.normal[0], - context.directory / "normal.png", - ) - context.check_cancelled() - context.progress(1.0, "Surface is ready") - return {"surface": "mesh.npz"} diff --git a/src/anyimage/server/jobs/upscale.py b/src/anyimage/server/jobs/upscale.py index 999fbfb..5461838 100644 --- a/src/anyimage/server/jobs/upscale.py +++ b/src/anyimage/server/jobs/upscale.py @@ -1,11 +1,18 @@ +from ..media import DEFAULT_MAX_AI_INPUT_SIZE, input_frames from ..models.upscale import infer_one -from .common import frame_progress_message, input_frames + + +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']}; " - f"maximum output size: {parameters.get('max_output_size', 4096)} px" + f"maximum AI input size: " + 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) diff --git a/src/anyimage/server/media/__init__.py b/src/anyimage/server/media/__init__.py index 6a8aee8..80583cc 100644 --- a/src/anyimage/server/media/__init__.py +++ b/src/anyimage/server/media/__init__.py @@ -2,27 +2,44 @@ from .input import ( IMAGE_SUFFIXES, VIDEO_SUFFIXES, TaskCancelled, + analysis_frames, check_cancelled, collect_images, copy_color_images, copy_frames, extract_video_frames, first_frames, - prepare_background_input, + input_frames, prepare_input, ) +from .resolution import ( + DEFAULT_MAX_AI_INPUT_SIZE, + MAX_MAX_AI_INPUT_SIZE, + MIN_MAX_AI_INPUT_SIZE, + limit_image, + limited_image_size, + normalized_max_input_size, +) + __all__ = ( "IMAGE_SUFFIXES", "VIDEO_SUFFIXES", "TaskCancelled", + "analysis_frames", "check_cancelled", "collect_images", "copy_color_images", "copy_frames", "extract_video_frames", "first_frames", - "prepare_background_input", + "input_frames", "prepare_input", + "DEFAULT_MAX_AI_INPUT_SIZE", + "MAX_MAX_AI_INPUT_SIZE", + "MIN_MAX_AI_INPUT_SIZE", + "limit_image", + "limited_image_size", + "normalized_max_input_size", ) diff --git a/src/anyimage/server/media/input.py b/src/anyimage/server/media/input.py index ea70545..f1de2cc 100644 --- a/src/anyimage/server/media/input.py +++ b/src/anyimage/server/media/input.py @@ -1,5 +1,6 @@ import shutil import tempfile +from contextlib import contextmanager from pathlib import Path @@ -118,29 +119,32 @@ def prepare_input( return images, temporary_directory -def prepare_background_input( - input_path, - video_frames, - temporary_root, - start_frame=1, - cancel_check=None, -): - """Resolve a BEN2 input to frame images; returns (images, temporary_directory).""" - input_path = Path(input_path) - if input_path.is_dir(): - images = collect_images(input_path) - if not images: - raise RuntimeError(f"No image frames found in: {input_path}") - return first_frames(images, video_frames, start_frame), None - if input_path.suffix.lower() in IMAGE_SUFFIXES: - return [input_path], None - return prepare_input( - input_path, - video_frames, - temporary_root, - start_frame=start_frame, - cancel_check=cancel_check, +@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 def copy_color_images(images, output_directory): diff --git a/src/anyimage/server/media/resolution.py b/src/anyimage/server/media/resolution.py new file mode 100644 index 0000000..4953fc8 --- /dev/null +++ b/src/anyimage/server/media/resolution.py @@ -0,0 +1,34 @@ +DEFAULT_MAX_AI_INPUT_SIZE = 2048 +MIN_MAX_AI_INPUT_SIZE = 256 +MAX_MAX_AI_INPUT_SIZE = 8192 + + +def normalized_max_input_size(value): + size = int(value) + if size < 1 or size > MAX_MAX_AI_INPUT_SIZE: + raise ValueError( + f"Maximum AI Input Size must be between 1 and " + f"{MAX_MAX_AI_INPUT_SIZE} pixels" + ) + return size + + +def limited_image_size(image_size, max_input_size=DEFAULT_MAX_AI_INPUT_SIZE): + width, height = (int(value) for value in image_size) + if width < 1 or height < 1: + raise ValueError("Image dimensions must be positive") + maximum = normalized_max_input_size(max_input_size) + longest = max(width, height) + if longest <= maximum: + return width, height + scale = maximum / longest + return max(1, round(width * scale)), max(1, round(height * scale)) + + +def limit_image(image, max_input_size=DEFAULT_MAX_AI_INPUT_SIZE): + from PIL import Image + + size = limited_image_size(image.size, max_input_size) + if size == image.size: + return image + return image.resize(size, Image.Resampling.LANCZOS) diff --git a/src/anyimage/server/models/da3.py b/src/anyimage/server/models/da3.py index 0234e26..4292afa 100644 --- a/src/anyimage/server/models/da3.py +++ b/src/anyimage/server/models/da3.py @@ -1,5 +1,5 @@ from ..geometry import unproject_camera_depth -from ..geometry import GeometryFrame +from .geometry import GeometryFrame def merge_frame_predictions(predictions): @@ -71,14 +71,14 @@ def infer( return merge_frame_predictions(predictions) -def surface_frame(prediction): +def geometry_frame(prediction): import numpy as np depth = np.asarray(prediction.depth, dtype=np.float32) confidence = np.asarray(prediction.conf, dtype=np.float32) intrinsics = np.asarray(prediction.intrinsics, dtype=np.float32) if depth.shape[0] != 1: - raise ValueError("Convert to Surface currently supports one image") + raise ValueError("Image mesh generation currently supports one image") points = unproject_camera_depth(depth[0], intrinsics[0]) valid = ( np.isfinite(depth[0]) diff --git a/src/anyimage/server/models/geometry.py b/src/anyimage/server/models/geometry.py new file mode 100644 index 0000000..19f19a6 --- /dev/null +++ b/src/anyimage/server/models/geometry.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass +class GeometryFrame: + depth: object + points: object + intrinsics: object + valid_mask: object + confidence: object | None = None + model_mask: object | None = None diff --git a/src/anyimage/server/models/moge2.py b/src/anyimage/server/models/moge2.py index 7eb81e2..5317bad 100644 --- a/src/anyimage/server/models/moge2.py +++ b/src/anyimage/server/models/moge2.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from ..geometry import GeometryFrame +from .geometry import GeometryFrame @dataclass @@ -89,11 +89,11 @@ def infer_one(model_manager, parameters, image_path): ) -def surface_frame(prediction): +def geometry_frame(prediction): import numpy as np if prediction.depth.shape[0] != 1 or prediction.points is None: - raise ValueError("Convert to Surface requires one MoGe-2 point grid") + raise ValueError("Image mesh generation requires one MoGe-2 point grid") depth = prediction.depth[0] points = prediction.points[0] intrinsics = prediction.intrinsics[0].copy() @@ -106,7 +106,8 @@ def surface_frame(prediction): intrinsics[1] *= height intrinsics[2] = (0.0, 0.0, 1.0) valid = ( - np.isfinite(depth) + prediction.mask[0] + & np.isfinite(depth) & (depth > 0.0) & np.isfinite(points).all(axis=-1) ) diff --git a/src/anyimage/server/models/onnx_moge2.py b/src/anyimage/server/models/onnx_moge2.py index 121ddc2..4c31a2e 100644 --- a/src/anyimage/server/models/onnx_moge2.py +++ b/src/anyimage/server/models/onnx_moge2.py @@ -3,7 +3,7 @@ from pathlib import Path from .onnx_runtime import create_session as create_runtime_session -from .onnx_runtime import provider_label, select_providers +from .onnx_runtime import select_providers as select_providers ONNX_FILENAME = "model.onnx" diff --git a/src/anyimage/server/models/upscale.py b/src/anyimage/server/models/upscale.py index 41232d8..99882c2 100644 --- a/src/anyimage/server/models/upscale.py +++ b/src/anyimage/server/models/upscale.py @@ -1,32 +1,4 @@ -import math - - -SCALE = 4 -MAX_OUTPUT_SIZE = 8192 -MAX_OUTPUT_PIXELS = 32 * 1024 * 1024 - - -def _limited_input(image, max_output_size): - from PIL import Image - - max_output_size = int(max_output_size) - if max_output_size < 1 or max_output_size > MAX_OUTPUT_SIZE: - raise ValueError( - f"Max Output Size must be between 1 and {MAX_OUTPUT_SIZE} pixels" - ) - width, height = image.size - scale = min( - 1.0, - max_output_size / (max(width, height) * SCALE), - math.sqrt(MAX_OUTPUT_PIXELS / (width * height * SCALE * SCALE)), - ) - if scale >= 1.0: - return image - size = ( - max(1, int(width * scale)), - max(1, int(height * scale)), - ) - return image.resize(size, Image.Resampling.LANCZOS) +from ..media.resolution import DEFAULT_MAX_AI_INPUT_SIZE, limit_image def _source_alpha(image): @@ -53,9 +25,10 @@ def infer_one( ) try: with Image.open(image_path) as opened: - source = opened - if "max_output_size" in parameters: - source = _limited_input(source, parameters["max_output_size"]) + source = limit_image( + opened, + parameters.get("max_input_size", DEFAULT_MAX_AI_INPUT_SIZE), + ) source_alpha = _source_alpha(source) if preserve_alpha else None prediction = infer(model, source, cancel_check=cancel_check) if source_alpha is None: @@ -72,5 +45,5 @@ def infer_one( device = str(parameters.get("device", "selected device")).upper() raise RuntimeError( f"Upscale could not finish on {device}: {error}. " - "Reduce Max Output Size or select CPU, then try again." + "Reduce Maximum AI Input Size or select CPU, then try again." ) from error diff --git a/src/anyimage/server/outputs/__init__.py b/src/anyimage/server/outputs/__init__.py index c13cf98..3994662 100644 --- a/src/anyimage/server/outputs/__init__.py +++ b/src/anyimage/server/outputs/__init__.py @@ -1,10 +1 @@ -from .depth import write_da3_depth, write_moge_depth -from .surface import write_normal_texture, write_surface_archive - - -__all__ = ( - "write_da3_depth", - "write_moge_depth", - "write_normal_texture", - "write_surface_archive", -) +"""Debug output serializers used by Job Server workflows.""" diff --git a/src/anyimage/server/outputs/depth.py b/src/anyimage/server/outputs/depth.py deleted file mode 100644 index 8f7b194..0000000 --- a/src/anyimage/server/outputs/depth.py +++ /dev/null @@ -1,64 +0,0 @@ -from pathlib import Path - -from .surface import write_normal_texture - - -def write_da3_depth(prediction, output_path): - import numpy as np - - values = {"depth": np.round(prediction.depth, 8)} - for name in ("conf", "extrinsics", "intrinsics"): - value = getattr(prediction, name, None) - if value is not None: - values[name] = np.round(value, 2) if name == "conf" else value - output_path = Path(output_path) - output_path.parent.mkdir(parents=True, exist_ok=True) - np.savez_compressed(output_path, **values) - return output_path - - -def write_moge_depth(output_directory, prediction): - import numpy as np - - output_directory = Path(output_directory) - np.savez( - output_directory / "results.npz", - **prediction.archive_values(), - ) - for index, (depth, normal, mask) in enumerate( - zip(prediction.depth, prediction.normal, prediction.mask) - ): - _write_moge_maps( - output_directory, - None if len(prediction.depth) == 1 else index, - depth, - normal, - mask, - ) - - -def _write_moge_maps(output_directory, index, depth, normal, mask): - import numpy as np - from PIL import Image - - suffix = "" if index is None else f".{index + 1:04d}" - valid = mask & np.isfinite(depth) & (depth > 0) - preview = np.zeros(depth.shape, dtype=np.uint8) - if np.any(valid): - low, high = np.percentile(depth[valid], (2.0, 98.0)) - span = max(float(high - low), 1e-6) - preview[valid] = np.clip( - (depth[valid] - low) / span * 255.0, - 0.0, - 255.0, - ).astype(np.uint8) - Image.fromarray(preview, mode="L").save( - output_directory / f"depth{suffix}.png" - ) - write_normal_texture( - normal, - output_directory / f"normal{suffix}.png", - ) - Image.fromarray(mask.astype(np.uint8) * 255, mode="L").save( - output_directory / f"mask{suffix}.png" - ) diff --git a/src/anyimage/server/outputs/surface.py b/src/anyimage/server/outputs/surface.py deleted file mode 100644 index 3924dfb..0000000 --- a/src/anyimage/server/outputs/surface.py +++ /dev/null @@ -1,35 +0,0 @@ -from pathlib import Path - - -def write_surface_archive(mesh, output_path): - import numpy as np - - output_path = Path(output_path) - output_path.parent.mkdir(parents=True, exist_ok=True) - np.savez_compressed( - output_path, - vertices=mesh.vertices, - faces=mesh.faces, - uv=mesh.uv, - intrinsics=mesh.intrinsics, - image_size=np.asarray(mesh.image_size, dtype=np.int32), - reference_depth=np.asarray(mesh.reference_depth, dtype=np.float32), - **mesh.point_attributes, - **mesh.face_attributes, - ) - return output_path - - -def write_normal_texture(normal, output_path): - import numpy as np - from PIL import Image - - normal_rgb = np.clip( - (normal * np.array([1.0, -1.0, -1.0], dtype=np.float32) + 1.0) - * 127.5, - 0.0, - 255.0, - ).astype(np.uint8) - output_path = Path(output_path) - Image.fromarray(normal_rgb, mode="RGB").save(output_path) - return output_path diff --git a/src/anyimage/server/selection/__init__.py b/src/anyimage/server/selection/__init__.py new file mode 100644 index 0000000..bea3f5a --- /dev/null +++ b/src/anyimage/server/selection/__init__.py @@ -0,0 +1,22 @@ +"""Image selection rasterization and Cutout image preparation.""" + +from .cutout_images import refine_selection, write_images, write_selection +from .polygon_mask import ( + apply_selection_mask, + create_selection_canvas, + crop_to_alpha, + polygon_bounds, + polygon_selection_mask, +) + + +__all__ = ( + "apply_selection_mask", + "create_selection_canvas", + "crop_to_alpha", + "polygon_bounds", + "polygon_selection_mask", + "refine_selection", + "write_images", + "write_selection", +) diff --git a/src/anyimage/server/selection/cutout_images.py b/src/anyimage/server/selection/cutout_images.py new file mode 100644 index 0000000..7e1e974 --- /dev/null +++ b/src/anyimage/server/selection/cutout_images.py @@ -0,0 +1,162 @@ +"""Prepare Cutout selection images inside the Job Server.""" + +import json + +from ..models.ben2 import infer_foreground +from .polygon_mask import ( + apply_selection_mask, + create_selection_canvas, + polygon_selection_mask, +) + + +def _write_masked_color(input_path, polygon, output_path, invert=False): + import numpy as np + from PIL import Image, ImageFilter, ImageOps + + with Image.open(input_path) as opened: + source = opened.convert("RGBA") + selection = Image.fromarray( + polygon_selection_mask(source.size, polygon, "NONZERO").astype(np.uint8) * 255, + mode="L", + ).filter(ImageFilter.GaussianBlur(radius=0.75)) + if invert: + selection = ImageOps.invert(selection) + pixels = np.asarray(source, dtype=np.uint8).copy() + source_alpha = pixels[:, :, 3].copy() + pixels[:, :, 3] = np.asarray(selection, dtype=np.uint8) + pixels[source_alpha < 26] = 0 + pixels[pixels[:, :, 3] == 0, :3] = 0 + Image.fromarray(pixels, mode="RGBA").save( + output_path, + format="PNG", + compress_level=1, + ) + return output_path + + +def write_selection(input_path, polygon, output_path, invert=False): + import numpy as np + from PIL import Image, ImageFilter + + if invert: + _write_masked_color(input_path, polygon, output_path, invert=True) + with Image.open(input_path) as source: + return output_path, (0, 0, source.width, source.height) + canvas, selection_mask, bounds = create_selection_canvas( + input_path, polygon, "NONZERO" + ) + selection = Image.fromarray( + selection_mask.astype(np.uint8) * 255, + mode="L", + ).filter(ImageFilter.GaussianBlur(radius=0.75)) + pixels = np.asarray(canvas, dtype=np.uint8).copy() + source_alpha = pixels[:, :, 3].copy() + pixels[:, :, 3] = np.asarray(selection, dtype=np.uint8) + pixels[source_alpha < 26] = 0 + pixels[pixels[:, :, 3] == 0, :3] = 0 + Image.fromarray(pixels, mode="RGBA").save( + output_path, + format="PNG", + compress_level=1, + ) + return output_path, bounds + + +def refine_selection(context, parameters, input_path, polygon, output_path): + from PIL import Image, ImageChops + + canvas, selection_mask, bounds = create_selection_canvas( + input_path, polygon, "NONZERO" + ) + selection_path = context.directory / "selection-input.png" + canvas.save(selection_path, format="PNG", compress_level=1) + model_manager = context.resource("model_manager") + foreground, _load_ms, _inference_ms = infer_foreground( + selection_path, + model_manager.directory(parameters["refine_model"]), + parameters["device"], + model_cache=model_manager, + cancel_check=context.check_cancelled, + preserve_input_alpha=True, + ) + refined = apply_selection_mask(foreground, selection_mask) + with Image.open(input_path) as opened: + source = opened.convert("RGBA") + if parameters.get("invert"): + mask = Image.new("L", source.size, 0) + mask.paste(refined.getchannel("A"), bounds[:2]) + source.putalpha( + ImageChops.multiply(source.getchannel("A"), ImageChops.invert(mask)) + ) + output = source + else: + output = Image.new("RGBA", source.size, (0, 0, 0, 0)) + output.paste(refined, bounds[:2]) + output.save(output_path, format="PNG", compress_level=1) + return output_path + + +def write_images(input_path, selection_path, output_directory, selection_bounds=None): + import numpy as np + from PIL import Image + + with Image.open(input_path) as opened: + source = opened.convert("RGBA") + with Image.open(selection_path) as opened: + selection = opened.convert("RGBA") + if selection_bounds is None and selection.size != source.size: + raise ValueError("The Cutout selection does not match the source image") + if selection_bounds is None: + selection_bounds = (0, 0, source.width, source.height) + color_canvas = Image.new("RGBA", selection.size, (0, 0, 0, 0)) + color_canvas.paste(source, (-selection_bounds[0], -selection_bounds[1])) + source_alpha = np.asarray(color_canvas, dtype=np.uint8)[:, :, 3] + selection_pixels = np.asarray(selection, dtype=np.uint8).copy() + alpha = selection_pixels[:, :, 3] + alpha[alpha <= 1] = 0 + alpha[source_alpha < 26] = 0 + selection_pixels[alpha == 0, :3] = 0 + selection = Image.fromarray(selection_pixels, mode="RGBA") + visible_y, visible_x = np.nonzero(alpha >= 26) + if not len(visible_x): + raise ValueError("The Cutout selection contains no visible pixels") + content_bounds = ( + int(visible_x.min()), + int(visible_y.min()), + int(visible_x.max()) + 1, + int(visible_y.max()) + 1, + ) + selection_left, selection_top, _selection_right, _selection_bottom = ( + selection_bounds + ) + bounds = ( + selection_left + content_bounds[0], + selection_top + content_bounds[1], + selection_left + content_bounds[2], + selection_top + content_bounds[3], + ) + color = color_canvas.crop(content_bounds) + color_path = output_directory / "image.0001.png" + color.save(color_path, format="PNG", compress_level=1) + + selection_crop = selection.crop(content_bounds) + selection_path = output_directory / "selection.png" + selection_crop.save(selection_path, format="PNG", compress_level=1) + + analysis_pixels = np.asarray(color, dtype=np.uint8).copy() + mask_alpha = np.asarray(selection_crop, dtype=np.uint8)[:, :, 3] + analysis_pixels[:, :, 3] = np.minimum(analysis_pixels[:, :, 3], mask_alpha) + analysis_pixels[mask_alpha < 26] = 0 + analysis_path = output_directory / "analysis.png" + Image.fromarray(analysis_pixels, mode="RGBA").save( + analysis_path, + format="PNG", + compress_level=1, + ) + bounds_path = output_directory / "bounds.json" + bounds_path.write_text( + json.dumps(bounds, separators=(",", ":")), + encoding="utf-8", + ) + return color_path, selection_path, analysis_path, bounds_path diff --git a/src/anyimage/server/jobs/image_mask.py b/src/anyimage/server/selection/polygon_mask.py similarity index 77% rename from src/anyimage/server/jobs/image_mask.py rename to src/anyimage/server/selection/polygon_mask.py index 750e619..250457a 100644 --- a/src/anyimage/server/jobs/image_mask.py +++ b/src/anyimage/server/selection/polygon_mask.py @@ -1,8 +1,4 @@ -import json import math -import time - -from .background import remove_image_background def polygon_bounds(polygon): @@ -153,38 +149,3 @@ def crop_to_alpha(image, bounds): top + content_bottom, ) - -def run(context, parameters): - """Refine a gesture mask with BEN2 and return its source-image bounds.""" - started = time.perf_counter() - context.progress(0.05, "Preparing image selection") - context.check_cancelled() - output_directory = context.directory - output_directory.mkdir(parents=True, exist_ok=True) - canvas, selection_mask, bounds = create_selection_canvas( - parameters["input"], parameters["polygon"] - ) - selection_path = output_directory / "selection-input.png" - canvas.save(selection_path, format="PNG", compress_level=1) - model_manager = context.resource("model_manager") - context.progress(0.2, f"Loading BEN2 for {parameters['device'].upper()}") - foreground, _load_ms, _inference_ms = remove_image_background( - selection_path, - model_manager.directory(parameters["model"]), - parameters["device"], - model_manager, - context.check_cancelled, - ) - context.check_cancelled() - context.progress(0.9, "Applying selection mask") - output, bounds = crop_to_alpha( - apply_selection_mask(foreground, selection_mask), bounds - ) - output_path = output_directory / "image.png" - output.save(output_path, format="PNG", compress_level=1) - bounds_path = output_directory / "bounds.json" - bounds_path.write_text(json.dumps(bounds, separators=(",", ":")), encoding="utf-8") - elapsed_ms = (time.perf_counter() - started) * 1000.0 - message = f"Image selection refined in {elapsed_ms:.0f} ms" - context.progress(1.0, message) - return {"image": output_path.name, "bounds": bounds_path.name} diff --git a/tests/test_addon.py b/tests/test_addon.py index 3be3609..6657256 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -83,40 +83,56 @@ class AddonTest(unittest.TestCase): cls.paths_before_import = tuple(sys.path) cls.addon = importlib.import_module("anyimage") cls.job_runtime = importlib.import_module("anyimage.runtime") - cls.model_operators = importlib.import_module("anyimage.operators.models") - cls.image_depth = importlib.import_module("anyimage.operators.material") - cls.image_data = importlib.import_module("anyimage.operators.image") - cls.depth_operator = importlib.import_module( - "anyimage.operators.depth_plane.operator" - ) + cls.model_operators = importlib.import_module("anyimage.operators.ai_setup") + cls.image_depth = importlib.import_module("anyimage.operators.common.material") + cls.image_data = importlib.import_module("anyimage.operators.common.image") cls.debug = importlib.import_module("anyimage.operators.debug") - cls.depth_build = importlib.import_module( - "anyimage.operators.depth_plane.build" + cls.mesh_plane = importlib.import_module("anyimage.operators.convert_to_plane.mesh_plane") + cls.mesh = importlib.import_module("anyimage.operators.convert_to_plane.conversion") + cls.cutout_conversion = importlib.import_module( + "anyimage.operators.cutout_tool.shape_conversion" + ) + cls.cutout_build = importlib.import_module( + "anyimage.operators.cutout_tool.build_shapes" + ) + cls.mesh_shapes = importlib.import_module( + "anyimage.operators.cutout_tool.shape_types" + ) + cls.image_background = importlib.import_module( + "anyimage.operators.remove_background" ) - cls.mesh_plane = importlib.import_module("anyimage.operators.mesh_plane") - cls.mesh = importlib.import_module("anyimage.operators.mesh") - cls.image_background = importlib.import_module("anyimage.operators.background") cls.image_upscale = importlib.import_module("anyimage.operators.upscale") - cls.image_mesh = importlib.import_module("anyimage.operators.surface") cls.image_edit_common = importlib.import_module( - "anyimage.operators.image_edit.common" + "anyimage.operators.common.image_edit" ) - cls.image_mask = importlib.import_module("anyimage.operators.image_edit.mask") - cls.image_box = importlib.import_module("anyimage.operators.image_edit.box") - cls.image_lasso = importlib.import_module("anyimage.operators.image_edit.lasso") - cls.image_line = importlib.import_module("anyimage.operators.image_edit.line") + cls.image_mask = importlib.import_module( + "anyimage.operators.image_tool.edit_mask" + ) + cls.image_mask_data = importlib.import_module( + "anyimage.operators.common.image_mask" + ) + cls.image_gesture = importlib.import_module( + "anyimage.operators.common.image_gesture" + ) + cls.image_box = importlib.import_module("anyimage.operators.image_tool.box") + cls.image_lasso = importlib.import_module( + "anyimage.operators.image_tool.lasso" + ) + cls.image_line = importlib.import_module("anyimage.operators.image_tool.line") cls.image_polyline = importlib.import_module( - "anyimage.operators.image_edit.polyline" + "anyimage.operators.image_tool.polyline" ) cls.image_perspective = importlib.import_module( - "anyimage.operators.image_edit.perspective" + "anyimage.operators.perspective_tool.edit_perspective" ) cls.cutout_geometry = importlib.import_module( - "anyimage.operators.cutout_geometry" + "anyimage.operators.cutout_tool.planar_mesh" + ) + cls.cutout_depth = importlib.import_module( + "anyimage.operators.cutout_tool.depth_calibration" ) - cls.cutout_depth = importlib.import_module("anyimage.operators.cutout_depth") cls.image_interaction = importlib.import_module( - "anyimage.operators.image_edit.interaction" + "anyimage.operators.common.image_interaction" ) cls.paths_after_import = tuple(sys.path) cls.fake_bpy = fake_bpy @@ -138,7 +154,7 @@ class AddonTest(unittest.TestCase): storage_root="", device="CUDA", upscale_model="REALESRGAN_GENERAL_WDN_X4V3", - upscale_max_output_size=4096, + max_ai_input_size=2048, ) self.fake_bpy.context.preferences.addons = { "anyimage": SimpleNamespace(preferences=preferences) @@ -159,16 +175,6 @@ class AddonTest(unittest.TestCase): if hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source"): del self.fake_bpy.types.Image.anyimage_upscale_source - def test_runtime_and_operator_support_modules_have_explicit_boundaries(self): - for name in ( - "client.py", - "environment.py", - "storage.py", - "models.py", - "status_bar.py", - ): - self.assertFalse((SOURCE_ROOT / "anyimage" / name).exists(), name) - def test_mps_requires_m4_or_newer(self): properties = self.addon.properties parse = properties.parse_apple_silicon_generation @@ -219,30 +225,23 @@ class AddonTest(unittest.TestCase): def test_import_does_not_add_addon_directories_to_sys_path(self): self.assertEqual(self.paths_after_import, self.paths_before_import) - def test_job_request_is_not_part_of_blendjob(self): - blendjob = importlib.import_module("blendjob") - self.assertFalse(hasattr(blendjob, "JobRequest")) - - def test_operator_modules_use_plain_properties(self): + def test_operator_properties_use_plain_names(self): operators = self.addon.operators - for name in ( - "AnyImageJobMixin", - "ImageInputJobOperator", - "GeometryImageJobOperator", - "Da3ImageJobOperator", - "Moge2ImageJobOperator", - "BackgroundRequestMixin", - ): - self.assertFalse(hasattr(operators, name), name) for operator in operators.CLASSES: for name in getattr(operator, "__annotations__", {}): self.assertFalse(name.startswith("anyimage_"), (operator, name)) def test_concrete_job_operator_is_the_blender_operator(self): - operator = self.addon.operators.GenerateMesh - self.assertEqual(operator.bl_idname, "anyimage.generate_mesh") - self.assertIn(self.job_runtime.JobOperatorBase, operator.__mro__) - self.assertIn(self.fake_bpy.types.Operator, operator.__mro__) + for operator, identifier in ( + (self.addon.operators.GenerateDepthPlane, "anyimage.generate_depth_plane"), + ( + self.addon.operators.GenerateCutoutShape, + "anyimage.generate_cutout_shape", + ), + ): + self.assertEqual(operator.bl_idname, identifier) + self.assertIn(self.job_runtime.JobOperatorBase, operator.__mro__) + self.assertIn(self.fake_bpy.types.Operator, operator.__mro__) def test_upscale_debug_operator_accepts_each_catalog_model(self): with tempfile.TemporaryDirectory() as directory: @@ -571,7 +570,7 @@ class AddonTest(unittest.TestCase): ) with patch.object(properties, "model_catalog", return_value=catalog): depth_items = properties.depth_plane_model_enum_items(None, None) - surface_items = properties.surface_model_enum_items(None, None) + mesh_depth_items = properties.mesh_depth_model_enum_items(None, None) expected = ( "MOGE2_VITS_NORMAL", @@ -581,7 +580,7 @@ class AddonTest(unittest.TestCase): "BASE", ) self.assertEqual(tuple(item[0] for item in depth_items[:-1]), expected) - self.assertEqual(tuple(item[0] for item in surface_items), expected) + self.assertEqual(tuple(item[0] for item in mesh_depth_items), expected) def test_required_model_operator_requests_both_models(self): operator = self.addon.operators.DownloadRequiredModels() @@ -687,12 +686,28 @@ class AddonTest(unittest.TestCase): self.model_operators.OpenAIEnvironmentSettings, self.addon.CLASSES, ) - panel = importlib.import_module("anyimage.panel") - self.assertFalse(hasattr(panel, "ModelsPanel")) + def test_global_ai_input_size_defaults_and_clamps(self): + preference_module = self.addon.preferences + preferences = self.fake_bpy.context.preferences.addons[ + "anyimage" + ].preferences + + self.assertEqual( + preference_module.configured_max_ai_input_size(), + 2048, + ) + preferences.max_ai_input_size = 10000 + self.assertEqual( + preference_module.configured_max_ai_input_size(), + 8192, + ) + preferences.max_ai_input_size = 1 + self.assertEqual( + preference_module.configured_max_ai_input_size(), + 256, + ) def test_preferences_draws_upscale_model_download(self): - import inspect - preference_module = importlib.import_module("anyimage.preferences") downloads = [] properties = [] @@ -801,12 +816,8 @@ class AddonTest(unittest.TestCase): ) self.assertIn("DA3 models cannot generate normals", labels) self.assertNotIn("Normal Model", labels) - self.assertNotIn( - "separator", - inspect.getsource(preference_module.AnyImagePreferences._draw_panel), - ) self.assertIn("upscale_model", properties) - self.assertIn("upscale_max_output_size", properties) + self.assertIn("max_ai_input_size", properties) self.assertIn("balloon_profile_algorithm", properties) balloon_profile = preference_module.AnyImagePreferences.__annotations__[ "balloon_profile_algorithm" @@ -827,7 +838,7 @@ class AddonTest(unittest.TestCase): operator = self.mesh.ConvertToPlane self.assertIn(operator, self.addon.CLASSES) self.assertIn(self.mesh.ConvertToDepthPlane, self.addon.CLASSES) - self.assertIn(self.mesh.ConvertCutoutShape, self.addon.CLASSES) + self.assertIn(self.cutout_conversion.ConvertCutoutShape, self.addon.CLASSES) self.assertEqual(operator.bl_idname, "anyimage.convert_to_plane") self.assertNotIn("shadeless", operator.__annotations__) self.assertNotIn("keep_original", operator.__annotations__) @@ -875,26 +886,46 @@ class AddonTest(unittest.TestCase): return_value=(1, 0, 24, True), ), patch.object(self.mesh, "create_material", return_value="Material"), - patch.object(self.mesh, "_copy_color_image", return_value="Color"), - patch.object(self.mesh, "_build_mesh_object") as build_mesh, - patch.object( - self.mesh, - "invoke_ai_setup_if_needed", - side_effect=AssertionError("local conversion must not start AI setup"), - ), + patch.object(self.mesh, "copy_color_image", return_value="Color"), + patch.object(self.mesh, "build_mesh_plane") as build_mesh, ): result = self.mesh.ConvertToPlane.execute(operator, context) self.assertEqual(result, {"FINISHED"}) build_mesh.assert_called_once() + def test_depth_plane_request_includes_the_global_ai_input_limit(self): + operator = self.mesh.GenerateDepthPlane() + operator.input_path = __file__ + operator.analysis_input_path = __file__ + operator.source_object_name = "Source" + operator.mesh_detail = 0 + operator.model = self.addon.properties.DEFAULT_MOGE2_MODEL_KEY + operator.resolution_level = 5 + operator.max_input_size = 1536 + context = SimpleNamespace( + scene=SimpleNamespace( + anyimage_settings=SimpleNamespace(video_frames=1) + ) + ) + with ( + patch.object(self.mesh, "require_environment"), + patch.object(self.mesh, "require_model"), + patch.object(self.mesh, "production_device", return_value="CPU"), + patch.object(self.mesh, "source_start_frame", return_value=1), + ): + parameters = operator.request(context) + + self.assertEqual(parameters["max_input_size"], 1536) + self.assertEqual(parameters["shape"], "DEPTH_PLANE") + def test_cutout_and_balloon_without_refine_build_locally(self): source = SimpleNamespace(select_set=lambda _selected: None) context = SimpleNamespace( view_layer=SimpleNamespace(objects=SimpleNamespace(active=None)) ) - for shape in (self.mesh.CUTOUT, self.mesh.BALLOON): + for shape in ("CUTOUT", "BALLOON"): with self.subTest(shape=shape): operator = SimpleNamespace( source_object_name="Source", @@ -908,19 +939,25 @@ class AddonTest(unittest.TestCase): ) with ( patch.object( - self.mesh.bpy.data.objects, + self.cutout_conversion.bpy.data.objects, "get", return_value=source, ), - patch.object(self.mesh, "is_image_empty", return_value=True), - patch.object(self.mesh, "_build_local_cutout") as build_local, patch.object( - self.mesh, - "_start_job", + self.cutout_conversion, "is_image_empty", return_value=True + ), + patch.object( + self.cutout_conversion, "build_local_cutout" + ) as build_local, + patch.object( + self.cutout_conversion, + "start_cutout_job", side_effect=AssertionError("local Cutout must not start a Job"), ), ): - result = self.mesh.ConvertCutoutShape.execute(operator, context) + result = self.cutout_conversion.ConvertCutoutShape.execute( + operator, context + ) self.assertEqual(result, {"FINISHED"}) build_local.assert_called_once_with( @@ -938,7 +975,7 @@ class AddonTest(unittest.TestCase): view_layer=SimpleNamespace(objects=SimpleNamespace(active=None)) ) - for shape in (self.mesh.CUTOUT, self.mesh.BALLOON): + for shape in ("CUTOUT", "BALLOON"): with self.subTest(shape=shape): operator = SimpleNamespace( source_object_name="Source", @@ -952,21 +989,27 @@ class AddonTest(unittest.TestCase): ) with ( patch.object( - self.mesh.bpy.data.objects, + self.cutout_conversion.bpy.data.objects, "get", return_value=source, ), - patch.object(self.mesh, "is_image_empty", return_value=True), patch.object( - self.mesh, - "_build_local_cutout", + self.cutout_conversion, "is_image_empty", return_value=True + ), + patch.object( + self.cutout_conversion, + "build_local_cutout", side_effect=AssertionError("Refine must run through the Job"), ), patch.object( - self.mesh, "_start_job", return_value={"FINISHED"} + self.cutout_conversion, + "start_cutout_job", + return_value={"FINISHED"}, ) as start_job, ): - result = self.mesh.ConvertCutoutShape.execute(operator, context) + result = self.cutout_conversion.ConvertCutoutShape.execute( + operator, context + ) self.assertEqual(result, {"FINISHED"}) start_job.assert_called_once_with( @@ -987,71 +1030,101 @@ class AddonTest(unittest.TestCase): context = SimpleNamespace(object=source, scene=SimpleNamespace()) operator = SimpleNamespace(mesh_detail=64, report=lambda *_args: None) + ops = SimpleNamespace(anyimage=SimpleNamespace()) with ( - patch.object(self.mesh, "video_total_frames", return_value=1), + patch.object(self.fake_bpy, "ops", ops, create=True), patch.object( - self.mesh, "invoke_ai_setup_if_needed", return_value=None + self.cutout_conversion, "video_total_frames", return_value=1 + ), + patch.object( + self.cutout_conversion, + "invoke_ai_setup_if_needed", + return_value=None, ) as setup, patch.object( - self.mesh, + self.cutout_conversion, "prepare_image_input", return_value=(Path("input.png"), False), ), patch.object( - self.mesh, - "_depth_settings", - return_value=("MOGE2_VITL", 100.0, 5), + self.cutout_conversion, + "cutout_moge_settings", + return_value=("MOGE2_VITL", 5), ), patch.object( - self.mesh.bpy.ops.anyimage, - "generate_mesh", + self.cutout_conversion.bpy.ops.anyimage, + "generate_cutout_shape", return_value={"FINISHED"}, create=True, ) as generate_mesh, ): - result = self.mesh._start_job( - operator, context, self.mesh.BALLOON, normal_analysis=True + result = self.cutout_conversion.start_cutout_job( + operator, + context, + "BALLOON", + "[[0,0],[10,0],[0,10]]", + normal_analysis=True, ) self.assertEqual(result, {"FINISHED"}) setup.assert_called_once_with() - self.assertEqual(generate_mesh.call_args.kwargs["shape"], self.mesh.BALLOON) + self.assertEqual(generate_mesh.call_args.kwargs["shape"], "BALLOON") self.assertEqual(generate_mesh.call_args.kwargs["mesh_detail"], 64) self.assertEqual( - generate_mesh.call_args.kwargs["analysis_input_path"], - "input.png", + generate_mesh.call_args.kwargs["model"], + "MOGE2_VITL", ) - self.assertEqual( - generate_mesh.call_args.kwargs["normal_model"], - self.addon.properties.DEFAULT_MOGE2_MODEL_KEY, - ) - self.assertEqual(generate_mesh.call_args.kwargs["normal_resolution_level"], 5) + self.assertEqual(generate_mesh.call_args.kwargs["resolution_level"], 5) + self.assertEqual(generate_mesh.call_args.kwargs["max_input_size"], 2048) + self.assertNotIn("process_res", generate_mesh.call_args.kwargs) + self.assertNotIn("normal_model", generate_mesh.call_args.kwargs) - def test_all_cutout_shapes_request_moge_normal_when_enabled(self): + def test_cutout_moge_settings_falls_back_from_da3(self): + preferences = SimpleNamespace( + mesh_depth_model="DA3_BASE", + mesh_resolution_level=7, + ) + with patch.object( + self.cutout_conversion, + "addon_preferences", + return_value=preferences, + ): + settings = self.cutout_conversion.cutout_moge_settings(None) + + self.assertEqual( + settings, + (self.addon.properties.DEFAULT_MOGE2_MODEL_KEY, 7), + ) + + def test_all_cutout_shapes_request_one_moge_model_when_normal_is_enabled(self): context = SimpleNamespace( scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1)) ) - for shape in self.mesh.CUTOUT_SHAPES: + for shape in self.mesh_shapes.CUTOUT_SHAPES: with self.subTest(shape=shape): - operator = self.mesh.GenerateMesh() + operator = self.cutout_conversion.GenerateCutoutShape() operator.input_path = __file__ operator.source_object_name = "" operator.shape = shape operator.selection_data = "[[0,0],[10,0],[0,10]]" operator.model = self.addon.properties.DEFAULT_MOGE2_MODEL_KEY operator.mesh_detail = 64 - operator.process_res = 1 operator.resolution_level = 5 - operator.normal_model = self.addon.properties.DEFAULT_MOGE2_MODEL_KEY - operator.normal_resolution_level = 5 + operator.max_input_size = 2048 operator.normal_analysis = True operator.refine_selection = False operator.invert = False with ( - patch.object(self.mesh, "require_environment"), - patch.object(self.mesh, "require_model") as require_model, - patch.object(self.mesh, "production_device", return_value="CPU"), - patch.object(self.mesh, "source_start_frame", return_value=1), + patch.object(self.cutout_conversion, "require_environment"), + patch.object( + self.cutout_conversion, "require_model" + ) as require_model, + patch.object( + self.cutout_conversion, "production_device", return_value="CPU" + ), + patch.object( + self.cutout_conversion, "source_start_frame", return_value=1 + ), ): parameters = operator.request(context) @@ -1059,97 +1132,44 @@ class AddonTest(unittest.TestCase): self.addon.properties.DEFAULT_MOGE2_MODEL_KEY ) self.assertEqual( - parameters["normal_model"], + parameters["model"], self.addon.properties.DEFAULT_MOGE2_MODEL_KEY, ) - self.assertEqual(parameters["normal_resolution_level"], 5) + self.assertEqual(parameters["resolution_level"], 5) + self.assertNotIn("normal_model", parameters) + self.assertNotIn("normal_resolution_level", parameters) self.assertEqual(parameters["mesh_detail"], 64) + self.assertEqual(parameters["max_input_size"], 2048) - def test_cutout_job_skips_moge_normal_model_when_disabled(self): - operator = self.mesh.GenerateMesh() + def test_plain_cutout_job_skips_moge_when_normal_is_disabled(self): + operator = self.cutout_conversion.GenerateCutoutShape() operator.input_path = __file__ operator.source_object_name = "" - operator.shape = self.mesh.CUTOUT + operator.shape = "CUTOUT" operator.selection_data = "[[0,0],[10,0],[0,10]]" operator.mesh_detail = 64 + operator.max_input_size = 2048 operator.normal_analysis = False operator.refine_selection = False operator.invert = False with ( - patch.object(self.mesh, "require_environment"), - patch.object(self.mesh, "require_model") as require_model, - patch.object(self.mesh, "production_device", return_value="CPU"), - patch.object(self.mesh, "source_start_frame", return_value=1), + patch.object(self.cutout_conversion, "require_environment"), + patch.object(self.cutout_conversion, "require_model") as require_model, + patch.object( + self.cutout_conversion, "production_device", return_value="CPU" + ), + patch.object( + self.cutout_conversion, "source_start_frame", return_value=1 + ), ): parameters = operator.request(SimpleNamespace(scene=SimpleNamespace())) require_model.assert_not_called() self.assertFalse(parameters["normal_analysis"]) - self.assertNotIn("normal_model", parameters) - self.assertNotIn("normal_resolution_level", parameters) - def test_mesh_plane_redo_keeps_the_original_world_matrix(self): - operator = self.mesh_plane.ConvertToMeshPlane() - operator.shadeless = False - operator.keep_original = True - operator.source_object_name = "" - original_matrix = ( - (1.0, 0.0, 0.0, 3.0), - (0.0, 1.0, 0.0, -2.0), - (0.0, 0.0, 1.0, 7.0), - (0.0, 0.0, 0.0, 1.0), - ) - source = SimpleNamespace( - name="Source Image", - type="EMPTY", - empty_display_type="IMAGE", - data=object(), - matrix_world=original_matrix, - ) - context = SimpleNamespace(object=source) - matrices = [] - - def capture_build(*_args, **kwargs): - matrices.append(kwargs["matrix_world"]) - - with ( - patch.object( - self.mesh_plane.bpy.data.objects, - "get", - return_value=source, - ), - patch.object( - self.mesh_plane, - "restore_matrix", - side_effect=lambda values: tuple(values), - ), - patch.object( - self.mesh_plane, - "build_mesh_plane", - side_effect=capture_build, - ), - ): - self.assertEqual(operator.invoke(context, None), {"FINISHED"}) - source.matrix_world = ( - (1.0, 0.0, 0.0, 0.0), - (0.0, 1.0, 0.0, 0.0), - (0.0, 0.0, 1.0, 0.0), - (0.0, 0.0, 0.0, 1.0), - ) - self.assertEqual(operator.execute(context), {"FINISHED"}) - - expected = self.mesh_plane.flatten_matrix(original_matrix) - self.assertEqual(matrices, [expected, expected]) - - def test_mesh_plane_uses_a_single_quad_and_geometry_nodes(self): - import inspect - - mesh_source = inspect.getsource(self.mesh_plane.create_mesh_plane_mesh) - processing_source = inspect.getsource(self.mesh_plane.add_mesh_plane_processing) - self.assertIn("((0, 1, 2, 3),)", mesh_source) - self.assertIn('("Subdivide", max(0, int(subdivisions)))', processing_source) - self.assertIn('("Thickness", max(0.0, float(thickness)))', processing_source) - self.assertIn('("Material", material)', processing_source) + self.assertEqual(parameters["max_input_size"], 2048) + self.assertNotIn("model", parameters) + self.assertNotIn("resolution_level", parameters) def test_image_menu_keeps_ai_features_clickable_and_offers_models(self): calls = [] @@ -1246,8 +1266,8 @@ class AddonTest(unittest.TestCase): "REALESRGAN_GENERAL_WDN_X4V3", ) self.assertEqual( - operator_type.__annotations__["max_output_size"]["default"], - 4096, + operator_type.__annotations__["max_input_size"]["default"], + 2048, ) self.assertIn(self.job_runtime.JobOperatorBase, operator_type.__mro__) @@ -1256,7 +1276,7 @@ class AddonTest(unittest.TestCase): operator.input_path = "input.png" operator.source_object_name = "Source" operator.model = "SAFMN_REAL_X4" - operator.max_output_size = 6144 + operator.max_input_size = 1024 context = SimpleNamespace( scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=7)) ) @@ -1280,7 +1300,7 @@ class AddonTest(unittest.TestCase): "device": "cuda", "video_frames": 7, "start_frame": 4, - "max_output_size": 6144, + "max_input_size": 1024, }, ) @@ -1309,8 +1329,8 @@ class AddonTest(unittest.TestCase): ), patch.object( self.image_upscale, - "configured_upscale_max_output_size", - return_value=6144, + "configured_max_ai_input_size", + return_value=1024, ), patch.object( self.image_upscale, @@ -1327,7 +1347,7 @@ class AddonTest(unittest.TestCase): self.assertEqual(result, {"RUNNING_MODAL"}) self.assertEqual(operator.model, "SAFMN_REAL_X4") - self.assertEqual(operator.max_output_size, 6144) + self.assertEqual(operator.max_input_size, 1024) def test_upscale_operator_opens_setup_when_required_ai_is_missing(self): expected = {"CANCELLED"} @@ -1654,8 +1674,8 @@ class AddonTest(unittest.TestCase): (1, 1), ) - even_odd = self.image_mask.polygon_mask((12, 9), polygon) - nonzero = self.image_mask.polygon_mask((12, 9), polygon, "NONZERO") + even_odd = self.image_mask_data.polygon_mask((12, 9), polygon) + nonzero = self.image_mask_data.polygon_mask((12, 9), polygon, "NONZERO") self.assertFalse(even_odd[3, 5]) self.assertTrue(nonzero[3, 5]) @@ -1683,8 +1703,10 @@ class AddonTest(unittest.TestCase): top_down = np.full((3, 3, 4), (0.2, 0.4, 0.6, 1.0), dtype=np.float32) source = np.flipud(top_down).ravel() - selection = self.image_mask.polygon_mask((3, 3), ((0, 0), (3, 0), (0, 3))) - pixels, bounds = self.image_mask.edit_image_mask_pixels( + selection = self.image_mask_data.polygon_mask( + (3, 3), ((0, 0), (3, 0), (0, 3)) + ) + pixels, bounds = self.image_mask_data.edit_image_mask_pixels( source, (3, 3), selection, @@ -1702,7 +1724,7 @@ class AddonTest(unittest.TestCase): top_down = np.full((2, 2, 4), (0.2, 0.4, 0.6, 1.0), dtype=np.float32) top_down[0, 0, 3] = 0.0 - pixels, _bounds = self.image_mask.edit_image_mask_pixels( + pixels, _bounds = self.image_mask_data.edit_image_mask_pixels( np.flipud(top_down).ravel(), (2, 2), np.ones((2, 2), dtype=np.float32), @@ -1716,7 +1738,7 @@ class AddonTest(unittest.TestCase): top_down = np.full((2, 2, 4), (0.2, 0.4, 0.6, 1.0), dtype=np.float32) refined = np.asarray(((1.0, 0.25), (0.0, 1.0)), dtype=np.float32) - pixels, bounds = self.image_mask.edit_image_mask_pixels( + pixels, bounds = self.image_mask_data.edit_image_mask_pixels( np.flipud(top_down).ravel(), (2, 2), refined, @@ -1733,7 +1755,7 @@ class AddonTest(unittest.TestCase): import numpy as np with self.assertRaisesRegex(ValueError, "no visible"): - self.image_mask.edit_image_mask_pixels( + self.image_mask_data.edit_image_mask_pixels( np.ones(2 * 2 * 4, dtype=np.float32), (2, 2), np.zeros((2, 2), dtype=np.float32), @@ -1788,7 +1810,7 @@ class AddonTest(unittest.TestCase): top_down = np.zeros((2, 2, 4), dtype=np.float32) top_down[:, :, 3] = ((1.0, 0.5), (0.0, 1.0)) - mask = self.image_mask.refined_mask_from_pixels( + mask = self.image_mask_data.refined_mask_from_pixels( np.flipud(top_down).ravel(), (2, 2), (1, 1, 3, 3), @@ -1927,12 +1949,10 @@ class AddonTest(unittest.TestCase): self.assertEqual(result_image.pixels.values, pixels) def test_image_edit_tools_and_operators_are_registered(self): - import inspect - operator = self.image_mask.EditImageMask self.assertIn(operator, self.addon.CLASSES) self.assertIn(self.image_mask.RefineImageMask, self.addon.CLASSES) - self.assertIn(self.image_mask.ActivateImageEditTool, self.addon.CLASSES) + self.assertIn(self.image_mask.ActivateImageTool, self.addon.CLASSES) tool_cases = ( (self.image_box.ImageBoxTool, "BOX", "ops.sculpt.border_mask"), (self.image_lasso.ImageLassoTool, "LASSO", "ops.sculpt.lasso_mask"), @@ -1951,31 +1971,25 @@ class AddonTest(unittest.TestCase): self.assertNotIn("bl_cursor", tool.__dict__) self.assertIn("draw_settings", self.image_box.ImageBoxTool.__dict__) self.assertIn("draw_settings", self.image_lasso.ImageLassoTool.__dict__) - self.assertIn( - "draw_image_mask_settings", - inspect.getsource(self.image_box.ImageBoxTool.draw_settings), - ) - self.assertIn( - '"image_box_refine_selection"', - inspect.getsource(self.image_box.ImageBoxTool.draw_settings), - ) - self.assertIn( - "draw_image_mask_settings", - inspect.getsource(self.image_lasso.ImageLassoTool.draw_settings), - ) - self.assertIn( - '"image_lasso_refine_selection"', - inspect.getsource(self.image_lasso.ImageLassoTool.draw_settings), - ) - cutout_tool = importlib.import_module("anyimage.operators.cutout").CutoutTool - self.assertIn( - "draw_image_mask_settings", - inspect.getsource(cutout_tool.draw_settings), - ) - self.assertIn( - '"cutout_refine_selection"', - inspect.getsource(cutout_tool.draw_settings), - ) + context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=object())) + layout = SimpleNamespace(prop=lambda *_args: None) + with patch.object(self.image_box, "draw_image_mask_settings") as draw: + self.image_box.ImageBoxTool.draw_settings(context, layout, None) + draw.assert_called_once_with( + context, layout, "image_box_refine_selection" + ) + with patch.object(self.image_lasso, "draw_image_mask_settings") as draw: + self.image_lasso.ImageLassoTool.draw_settings(context, layout, None) + draw.assert_called_once_with( + context, layout, "image_lasso_refine_selection" + ) + cutout_tool = importlib.import_module( + "anyimage.operators.cutout_tool" + ).CutoutTool + cutout_module = sys.modules[cutout_tool.__module__] + with patch.object(cutout_module, "draw_image_mask_settings") as draw: + cutout_tool.draw_settings(context, layout, None) + draw.assert_called_once_with(context, layout, "cutout_refine_selection") self.assertNotIn("draw_settings", self.image_line.ImageLineTool.__dict__) self.assertNotIn( "draw_settings", self.image_polyline.ImagePolylineTool.__dict__ @@ -2000,26 +2014,6 @@ class AddonTest(unittest.TestCase): self.assertNotIn("image_extract_continuous", settings) self.assertNotIn("image_extract_padding", settings) self.assertNotIn("image_extract_remove_background", settings) - source_picker = inspect.getsource( - self.image_edit_common.resolve_image_edit_click - ) - self.assertIn("bpy.ops.view3d.select(", source_picker) - self.assertIn("Click an Image Empty", source_picker) - self.assertIn("_restore_selection(", source_picker) - modal_source = inspect.getsource(operator.modal) - self.assertIn('event.type == "F"', modal_source) - self.assertIn("self.invert = not self.invert", modal_source) - - self.assertIn("append_lasso_point(self._path, point)", modal_source) - lasso_source = inspect.getsource(self.image_mask.append_lasso_point) - self.assertIn( - "math.dist(point, path[-1]) < LASSO_POINT_DISTANCE", - lasso_source, - ) - mask_source = inspect.getsource(self.image_mask.polygon_mask) - self.assertNotIn("np.indices", mask_source) - self.assertIn("range(top, bottom, 128)", mask_source) - def test_refine_selection_is_independent_for_each_area_tool(self): settings = SimpleNamespace( cutout_refine_selection=True, @@ -2052,35 +2046,6 @@ class AddonTest(unittest.TestCase): self.image_edit_common.apply_image_mask_settings(cutout, context) self.assertTrue(cutout.refine_selection) - def test_image_line_click_is_cancelled_before_projection(self): - import inspect - - modal_source = inspect.getsource(self.image_mask.EditImageMask.modal) - projection_source = inspect.getsource( - self.image_mask.image_line_half_plane_polygon - ) - self.assertIn("math.dist(self._path[0], self._cursor) < 2.0", modal_source) - self.assertIn("if len(line) != 2", projection_source) - - def test_image_area_click_is_cancelled_without_a_warning(self): - import inspect - - completion_source = inspect.getsource(self.image_mask.EditImageMask._complete) - - self.assertIn("if len(path) < 3", completion_source) - self.assertIn('return {"CANCELLED"}', completion_source) - self.assertNotIn("Draw a larger image region", completion_source) - - def test_image_polyline_closes_from_start_or_enter(self): - import inspect - - modal_source = inspect.getsource(self.image_mask.EditImageMask._modal_polyline) - draw_source = inspect.getsource(self.image_mask.EditImageMask._draw_selection) - self.assertIn("return self._confirm_polyline(context)", modal_source) - self.assertNotIn("DOUBLE_CLICK", modal_source) - self.assertIn('event.type in {"RET", "NUMPAD_ENTER"}', modal_source) - self.assertIn("_draw_polyline_close_indicator()", draw_source) - def test_image_edit_first_click_selects_another_image_without_editing(self): selected = [] @@ -2389,9 +2354,7 @@ class AddonTest(unittest.TestCase): self.assertEqual(bounds, (8.0, 18.0, 32.0, 42.0)) - def test_image_perspective_tool_uses_four_clicks_and_deform_behavior(self): - import inspect - + def test_image_perspective_tool_registration_and_properties(self): operator = self.image_perspective.EditImagePerspective tool = self.image_perspective.ImagePerspectiveTool settings = self.addon.properties.AnyImageSettings.__annotations__ @@ -2409,44 +2372,52 @@ class AddonTest(unittest.TestCase): self.assertNotIn("refine_selection", operator.__annotations__) self.assertNotIn("continuous", operator.__annotations__) self.assertNotIn("padding", operator.__annotations__) - modal_source = inspect.getsource(operator.modal) - invoke_source = inspect.getsource(operator.invoke) - execute_source = inspect.getsource(operator.execute) - draw_source = inspect.getsource(operator._draw_overlay) - preview_source = inspect.getsource(operator._draw_preview) - finish_source = inspect.getsource(operator._finish) - self.assertIn("if len(self._points) < 4", modal_source) - self.assertIn("return self.execute(context)", modal_source) - self.assertIn('self._phase = "ASPECT"', modal_source) - self.assertIn("create_perspective_preview_image(", modal_source) - self.assertIn("self._update_aspect(context, event)", modal_source) - self.assertIn("context.window.cursor_warp(", modal_source) - self.assertIn("perspective_quad_aspect(polygon)", modal_source) - self.assertIn("active_view3d_tool_id(context)", modal_source) - self.assertIn("drawing_in_region(", modal_source) - self.assertNotIn("context.region_data.is_perspective", invoke_source) - self.assertIn("resolve_image_edit_click(", invoke_source) - self.assertIn("extract_perspective_pixels(", execute_source) - self.assertIn("create_perspective_result(", execute_source) - self.assertIn("draw_polygon_fill(vertices)", draw_source) - self.assertIn("self._draw_preview()", draw_source) - self.assertIn("draw_texture_2d(", preview_source) - self.assertIn("draw_preview_frame(", preview_source) - self.assertIn("draw_aspect_hud(", preview_source) - self.assertIn("event_timer_remove", finish_source) - self.assertIn("self._remove_preview()", finish_source) - self.assertNotIn('"RET"', modal_source) - def test_image_box_builds_a_rectangular_screen_path(self): self.assertEqual( - self.image_mask.box_screen_path((2, 3), (8, 9)), + self.image_gesture.box_screen_path((2, 3), (8, 9)), ((2, 3), (8, 3), (8, 9), (2, 9)), ) self.assertEqual( - self.image_mask.box_screen_path((2, 3), (3, 9)), + self.image_gesture.box_screen_path((2, 3), (3, 9)), (), ) + def test_image_box_completion_serializes_the_pixel_selection(self): + source = SimpleNamespace(data=SimpleNamespace(size=(64, 32))) + finished = [] + operator = SimpleNamespace( + source_object_name="Source", + source_matrix_data="matrix", + gesture="BOX", + selection_data="", + _screen_polygon=lambda: ((1, 1), (8, 1), (8, 6), (1, 6)), + _finish=lambda context: finished.append(context), + execute=lambda _context: {"FINISHED"}, + ) + context = SimpleNamespace(region=object(), region_data=object()) + polygon = ((1, 2), (3, 4), (5, 6)) + + with ( + patch.object( + self.fake_bpy.data.objects, "get", return_value=source + ), + patch.object(self.image_gesture, "is_image_empty", return_value=True), + patch.object(self.image_gesture, "deserialize_matrix"), + patch.object(self.image_gesture, "plane_bounds"), + patch.object( + self.image_gesture, + "screen_path_to_image_pixels", + return_value=polygon, + ), + ): + result = self.image_gesture.ImageMaskGesture._complete( + operator, context + ) + + self.assertEqual(result, {"FINISHED"}) + self.assertEqual(operator.selection_data, "[[1,2],[3,4],[5,6]]") + self.assertEqual(finished, [context]) + def test_image_edit_selection_uses_white_dashed_segments(self): segments = self.image_edit_common.dashed_line_segments( ((0, 0), (10, 0)), dash_length=3.0, gap_length=2.0 @@ -2473,22 +2444,22 @@ class AddonTest(unittest.TestCase): _screen_polygon=lambda: polygon, ) with ( - patch.object(self.image_mask, 'drawing_in_region', return_value=True), + patch.object(self.image_gesture, 'drawing_in_region', return_value=True), patch.object( - self.image_mask, + self.image_gesture, 'dashed_line_segments', return_value=('segments',), ) as build_outline, patch.object( - self.image_mask, + self.image_gesture, 'preview_fill_triangles', return_value=('triangles',), ) as build_fill, - patch.object(self.image_mask, 'draw_polygon_fill') as draw_fill, - patch.object(self.image_mask, 'draw_dashed_line') as draw_outline, + patch.object(self.image_gesture, 'draw_polygon_fill') as draw_fill, + patch.object(self.image_gesture, 'draw_dashed_line') as draw_outline, ): - self.image_mask.ImageMaskGesture._draw_selection(operator) - self.image_mask.ImageMaskGesture._draw_selection(operator) + self.image_gesture.ImageMaskGesture._draw_selection(operator) + self.image_gesture.ImageMaskGesture._draw_selection(operator) build_fill.assert_called_once_with(polygon, 'NONZERO') build_outline.assert_called_once_with((*polygon, polygon[0])) @@ -2504,28 +2475,47 @@ class AddonTest(unittest.TestCase): def test_image_lasso_does_not_add_stationary_or_nearly_collinear_points(self): path = [(0.0, 0.0)] - self.assertFalse(self.image_mask.append_lasso_point(path, (0.5, 0.5))) + self.assertFalse(self.image_gesture.append_lasso_point(path, (0.5, 0.5))) self.assertEqual(path, [(0.0, 0.0)]) - self.assertTrue(self.image_mask.append_lasso_point(path, (3.0, 0.0))) - self.assertTrue(self.image_mask.append_lasso_point(path, (6.0, 0.2))) + self.assertTrue(self.image_gesture.append_lasso_point(path, (3.0, 0.0))) + self.assertTrue(self.image_gesture.append_lasso_point(path, (6.0, 0.2))) self.assertEqual(len(path), 2) self.assertEqual(path[-1], (6.0, 0.2)) - self.assertTrue(self.image_mask.append_lasso_point(path, (6.0, 4.0))) + self.assertTrue(self.image_gesture.append_lasso_point(path, (6.0, 4.0))) self.assertEqual(len(path), 3) def test_image_edit_activation_defaults_to_box(self): - import inspect + calls = [] + previous_ops = getattr(self.fake_bpy, "ops", None) + self.fake_bpy.ops = SimpleNamespace( + wm=SimpleNamespace( + tool_set_by_id=lambda **options: calls.append(options) + or {"FINISHED"} + ) + ) + try: + result = self.image_mask.ActivateImageTool().execute(None) + finally: + if previous_ops is None: + del self.fake_bpy.ops + else: + self.fake_bpy.ops = previous_ops - source = inspect.getsource(self.image_mask.ActivateImageEditTool.execute) - self.assertIn('name="anyimage.image_box"', source) + self.assertEqual(result, {"FINISHED"}) + self.assertEqual( + calls, + [{"name": "anyimage.image_box", "space_type": "VIEW_3D"}], + ) def test_image_line_snaps_and_keeps_the_previewed_half_plane(self): import math - endpoint = self.image_mask.snapped_line_endpoint((0, 0), (10, 1), True) + endpoint = self.image_gesture.snapped_line_endpoint((0, 0), (10, 1), True) self.assertAlmostEqual(math.degrees(math.atan2(endpoint[1], endpoint[0])), 5.0) - preview = self.image_mask.line_half_plane_polygon((0, 5), (10, 5), (10, 10)) - image = self.image_mask.image_line_half_plane_polygon( + preview = self.image_gesture.line_half_plane_polygon( + (0, 5), (10, 5), (10, 10) + ) + image = self.image_gesture.image_line_half_plane_polygon( ((0, 5), (10, 5)), (10, 10) ) self.assertEqual(self.image_edit_common.polygon_bounds(preview), (0, 5, 10, 10)) @@ -2588,27 +2578,12 @@ class AddonTest(unittest.TestCase): self.assertEqual(len(unit_contour), len(scaled_contour)) self.assertEqual(len(unit_interior), len(scaled_interior)) - def test_surface_and_depth_plane_material_names_use_image_data(self): - import inspect - - surface_source = inspect.getsource(self.image_mesh.build_image_mesh) - original_depth_source = inspect.getsource( - self.depth_build.build_image_depth_plane - ) - depth_source = inspect.getsource(self.depth_build.build_depth_plane) - expected = "material_data_name(source_object)" - - self.assertIn(expected, surface_source) - self.assertIn(expected, original_depth_source) - self.assertIn(expected, depth_source) - def test_image_operators_stay_clickable_until_server_is_busy(self): context = SimpleNamespace( object=SimpleNamespace(data=SimpleNamespace()), ) operators = ( - (self.depth_operator, self.depth_operator.ConvertToDepthPlane), - (self.image_mesh, self.image_mesh.ConvertToSurface), + (self.mesh, self.mesh.ConvertToDepthPlane), (self.image_background, self.image_background.RemoveImageBackground), ) @@ -2844,89 +2819,5 @@ class AddonTest(unittest.TestCase): self.assertEqual(calls[0][1]["text"], "Working") self.assertEqual(calls[1], ("operator", "anyimage.cancel_job")) - def test_moge_depth_request_is_written_on_the_operator(self): - operator = self.depth_operator.GenerateMoge2Depth() - operator.input_path = __file__ - operator.source_object_name = "" - operator.model = "MOGE2_VITS_NORMAL" - operator.resolution_level = 6 - operator.remove_background = False - settings = SimpleNamespace(video_frames=12) - context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings)) - - with ( - patch.object(self.depth_operator, "require_environment"), - patch.object(self.depth_operator, "require_model"), - ): - parameters = operator.request(context) - - self.assertEqual(parameters["input"], __file__) - self.assertEqual(parameters["model"], "MOGE2_VITS_NORMAL") - self.assertEqual(parameters["resolution_level"], 6) - self.assertNotIn("job_type", parameters) - - def test_moge_depth_response_calls_an_ordinary_function(self): - operator = self.depth_operator.GenerateMoge2Depth() - operator.source_object_name = "Reference" - operator.remove_background = False - operator.delete_source = True - operator.shadeless = False - result = object() - context = object() - - with patch.object(self.depth_operator, "build_moge2_depth_response") as build: - operator.response(context, result) - - build.assert_called_once_with( - context, - result, - source_object_name="Reference", - remove_background=False, - delete_source=True, - shadeless=False, - ) - - def test_entry_operator_dispatches_plain_properties(self): - operator = self.depth_operator.ConvertToDepthPlane() - operator.model = "MOGE2_VITS_NORMAL" - operator.resolution_level = 7 - operator.keep_original = False - operator.shadeless = True - operator.remove_background = False - source_object = SimpleNamespace( - name="Reference", - data=SimpleNamespace(source="FILE"), - ) - settings = SimpleNamespace(video_frames=1) - context = SimpleNamespace( - object=source_object, - scene=SimpleNamespace(anyimage_settings=settings), - ) - calls = [] - self.fake_bpy.ops = SimpleNamespace( - anyimage=SimpleNamespace( - generate_moge2_depth=lambda **options: ( - calls.append(options) or {"RUNNING_MODAL"} - ) - ) - ) - - with patch.object( - self.depth_operator, - "prepare_image_analysis_inputs", - return_value=( - Path(__file__), - False, - Path(__file__), - False, - ), - ): - self.assertEqual(operator.execute(context), {"FINISHED"}) - - self.assertEqual(calls[0]["resolution_level"], 7) - self.assertEqual(calls[0]["source_object_name"], "Reference") - self.assertFalse(any(key.startswith("anyimage_") for key in calls[0])) - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_clipboard_image.py b/tests/test_clipboard_image.py index e598388..ba48fbe 100644 --- a/tests/test_clipboard_image.py +++ b/tests/test_clipboard_image.py @@ -52,8 +52,11 @@ class ClipboardImageTest(unittest.TestCase): cls.addon = ModuleType("anyimage") cls.addon.__path__ = [str(PROJECT_ROOT / "src" / "anyimage")] sys.modules["anyimage"] = cls.addon + operators = ModuleType("anyimage.operators") + operators.__path__ = [str(PROJECT_ROOT / "src" / "anyimage" / "operators")] + sys.modules["anyimage.operators"] = operators cls.addon.clipboard_image = importlib.import_module( - "anyimage.clipboard_image" + "anyimage.operators.clipboard_image" ) cls.actions = cls.addon.clipboard_image.actions cls.clipboard = cls.addon.clipboard_image.clipboard @@ -318,14 +321,6 @@ class ClipboardImageTest(unittest.TestCase): (2 / 3, 1.0), ) - def test_plane_creation_delegates_to_mesh_plane_geometry_nodes(self): - import inspect - - source = inspect.getsource(self.actions.add_image_plane) - self.assertIn("create_mesh_plane_object", source) - self.assertNotIn("_plane_grid", source) - self.assertNotIn("_block_grid", source) - def test_plane_import_defaults_to_reference_image(self): options = ( self.addon.clipboard_image.PasteClipboardImage diff --git a/tests/test_cutout.py b/tests/test_cutout.py index d3d3f80..90c8aea 100644 --- a/tests/test_cutout.py +++ b/tests/test_cutout.py @@ -8,8 +8,8 @@ import bpy SOURCE_ROOT = Path(__file__).parents[1] / "src" sys.path.insert(0, str(SOURCE_ROOT)) -from anyimage.operators import cutout, mesh -from anyimage.operators.image_edit.mask import ImageMaskGesture +from anyimage.operators.common.image_gesture import ImageMaskGesture +from anyimage.operators.cutout_tool import selection_tool as cutout class Pie: @@ -54,10 +54,10 @@ def test_cutout_pie_uses_four_semantic_shapes_in_cardinal_order(): cutout._open_shape_pie(context, object(), source, ((0, 0), (10, 0), (0, 10))) assert [call[2].shape for call in window_manager.pie.calls] == [ - mesh.CUTOUT, - mesh.BALLOON, - mesh.DEPTH_BALLOON, - mesh.DEPTH_SURFACE, + "CUTOUT", + "BALLOON", + "DEPTH_BALLOON", + "DEPTH_SURFACE", ] assert [call[1]["text"] for call in window_manager.pie.calls] == [ "Surface", diff --git a/tests/test_image_depth.py b/tests/test_image.py similarity index 64% rename from tests/test_image_depth.py rename to tests/test_image.py index 6c5adee..c530156 100644 --- a/tests/test_image_depth.py +++ b/tests/test_image.py @@ -12,7 +12,7 @@ import numpy as np PROJECT_ROOT = Path(__file__).resolve().parents[1] -def load_image_depth(): +def load_image_modules(): fake_bpy = ModuleType("bpy") fake_bpy.props = SimpleNamespace( BoolProperty=lambda **options: options, @@ -27,20 +27,21 @@ def load_image_depth(): module_paths = ( ( - "anyimage.operators.image", - PROJECT_ROOT / "src" / "anyimage" / "operators" / "image.py", + "anyimage.operators.common.image", + PROJECT_ROOT / "src" / "anyimage" / "operators" / "common" / "image.py", ), ( - "anyimage.operators.material", - PROJECT_ROOT / "src" / "anyimage" / "operators" / "material.py", + "anyimage.operators.common.node_asset", + PROJECT_ROOT + / "src" + / "anyimage" + / "operators" + / "common" + / "node_asset.py", ), ( - "anyimage.operators.depth_plane.data", - PROJECT_ROOT / "src" / "anyimage" / "operators" / "depth_plane" / "data.py", - ), - ( - "anyimage.operators.depth_plane.build", - PROJECT_ROOT / "src" / "anyimage" / "operators" / "depth_plane" / "build.py", + "anyimage.operators.common.material", + PROJECT_ROOT / "src" / "anyimage" / "operators" / "common" / "material.py", ), ) packages = { @@ -48,7 +49,7 @@ def load_image_depth(): for name in ( "anyimage", "anyimage.operators", - "anyimage.operators.depth_plane", + "anyimage.operators.common", ) } for package in packages.values(): @@ -73,10 +74,10 @@ def load_image_depth(): setattr(namespace, name, value) return namespace -class ImageDepthTest(unittest.TestCase): +class ImageTest(unittest.TestCase): @classmethod def setUpClass(cls): - cls.image_depth = load_image_depth() + cls.image_depth = load_image_modules() def test_material_asset_path_survives_package_split(self): self.assertEqual( @@ -129,55 +130,6 @@ class ImageDepthTest(unittest.TestCase): self.assertEqual(result, "sRGB") self.assertEqual(image.colorspace_settings.name, "sRGB") - def test_extracts_depth_and_uses_first_view(self): - with tempfile.TemporaryDirectory() as directory: - npz_path = Path(directory) / "results.npz" - np.savez_compressed( - npz_path, - depth=np.array( - ( - ((1.0, 2.0), (3.0, 4.0)), - ((5.0, 6.0), (7.0, 8.0)), - ), - dtype=np.float32, - ), - ) - - depth = self.image_depth.extract_depth_array(npz_path) - - np.testing.assert_array_equal( - depth, - np.array(((1.0, 2.0), (3.0, 4.0)), dtype=np.float32), - ) - - def test_extract_depth_arrays_returns_every_frame(self): - with tempfile.TemporaryDirectory() as directory: - npz_path = Path(directory) / "results.npz" - np.savez_compressed( - npz_path, - depth=np.arange(12, dtype=np.float32).reshape(3, 2, 2), - ) - - depth_frames = self.image_depth.extract_depth_arrays(npz_path) - - self.assertEqual(len(depth_frames), 3) - np.testing.assert_array_equal( - depth_frames[1], - np.array(((4.0, 5.0), (6.0, 7.0)), dtype=np.float32), - ) - - def test_accepts_depths_key_with_single_channel(self): - with tempfile.TemporaryDirectory() as directory: - npz_path = Path(directory) / "results.npz" - np.savez_compressed( - npz_path, - depths=np.ones((1, 2, 3, 1), dtype=np.float32), - ) - - depth = self.image_depth.extract_depth_array(npz_path) - - self.assertEqual(depth.shape, (2, 3)) - def test_is_animated_image_accepts_movie_and_sequence(self): self.assertTrue( self.image_depth.is_animated_image( @@ -448,209 +400,6 @@ class ImageDepthTest(unittest.TestCase): ["frame-1.jpg", "frame-2.jpg", "frame-10.jpg"], ) - def test_accepts_single_depth_map_with_channel_axis(self): - with tempfile.TemporaryDirectory() as directory: - npz_path = Path(directory) / "results.npz" - np.savez_compressed( - npz_path, - depth=np.ones((2, 3, 1), dtype=np.float32), - ) - - depth = self.image_depth.extract_depth_array(npz_path) - - self.assertEqual(depth.shape, (2, 3)) - - def test_raw_da3_depth_is_converted_only_for_displacement(self): - raw_depth = np.array( - ((0.25, 1.0), (1.5, 2.0)), - dtype=np.float32, - ) - - displacement = self.image_depth.depth_displacement_values(raw_depth) - - np.testing.assert_array_equal( - displacement, - np.array(((0.75, 0.0), (-0.5, -1.0)), dtype=np.float32), - ) - np.testing.assert_array_equal( - raw_depth, - np.array(((0.25, 1.0), (1.5, 2.0)), dtype=np.float32), - ) - - def test_extracts_camera_intrinsics(self): - expected = np.array( - ((100.0, 0.0, 50.0), (0.0, 120.0, 40.0), (0.0, 0.0, 1.0)), - dtype=np.float32, - ) - with tempfile.TemporaryDirectory() as directory: - npz_path = Path(directory) / "results.npz" - np.savez_compressed( - npz_path, - intrinsics=expected[None, ...], - ) - - intrinsics = self.image_depth.extract_intrinsics_array( - npz_path - ) - - np.testing.assert_array_equal(intrinsics, expected) - - def test_camera_plane_bounds_use_intrinsics_and_median_depth(self): - source = SimpleNamespace( - data=SimpleNamespace(size=(200, 100)), - empty_image_offset=(-0.5, -0.5), - ) - depth = np.full((100, 200), 2.0, dtype=np.float32) - intrinsics = np.array( - ((100.0, 0.0, 100.0), (0.0, 100.0, 50.0), (0.0, 0.0, 1.0)), - dtype=np.float32, - ) - - bounds, reference_depth = self.image_depth.camera_plane_bounds( - source, - depth, - intrinsics, - -1.0, - ) - - self.assertEqual(bounds, (-2.0, 2.0, -1.0, 1.0)) - self.assertEqual(reference_depth, 2.0) - - def test_camera_plane_bounds_follow_displacement_unit_scale(self): - source = SimpleNamespace( - data=SimpleNamespace(size=(200, 100)), - empty_image_offset=(-0.5, -0.5), - ) - depth = np.full((100, 200), 2.0, dtype=np.float32) - intrinsics = np.array( - ((100.0, 0.0, 100.0), (0.0, 100.0, 50.0), (0.0, 0.0, 1.0)), - dtype=np.float32, - ) - - bounds, _reference_depth = ( - self.image_depth.camera_plane_bounds( - source, - depth, - intrinsics, - -0.5, - ) - ) - - self.assertEqual(bounds, (-1.0, 1.0, -0.5, 0.5)) - - def test_camera_plane_bounds_use_stored_unmasked_reference_depth(self): - source = SimpleNamespace( - data=SimpleNamespace(size=(200, 100)), - empty_image_offset=(-0.5, -0.5), - ) - depth = np.full((100, 200), 9.0, dtype=np.float32) - intrinsics = np.array( - ((100.0, 0.0, 100.0), (0.0, 100.0, 50.0), (0.0, 0.0, 1.0)), - dtype=np.float32, - ) - - bounds, reference_depth = self.image_depth.camera_plane_bounds( - source, - depth, - intrinsics, - -1.0, - reference_depth=2.0, - ) - - self.assertEqual(bounds, (-2.0, 2.0, -1.0, 1.0)) - self.assertEqual(reference_depth, 2.0) - - def test_camera_plane_bounds_preserve_square_source_aspect(self): - source = SimpleNamespace( - data=SimpleNamespace(size=(447, 447)), - empty_image_offset=(-0.5, -0.5), - ) - depth = np.full((140, 140), 0.7366, dtype=np.float32) - intrinsics = np.array( - ( - (460.758, 0.0, 70.0), - (0.0, 393.372, 70.0), - (0.0, 0.0, 1.0), - ), - dtype=np.float32, - ) - - bounds, _reference_depth = ( - self.image_depth.camera_plane_bounds( - source, - depth, - intrinsics, - -1.0, - ) - ) - width = bounds[1] - bounds[0] - height = bounds[3] - bounds[2] - - self.assertAlmostEqual(width / height, 1.0) - - def test_raw_distance_preserves_original_values(self): - depth = np.array( - ((1.25, -2.5), (0.0, np.nan)), - dtype=np.float32, - ) - - mapped = self.image_depth.prepare_depth_values(depth) - - self.assertEqual(float(mapped[0, 0]), 1.25) - self.assertEqual(float(mapped[0, 1]), -2.5) - self.assertEqual(float(mapped[1, 0]), 0.0) - self.assertEqual(float(mapped[1, 1]), 0.0) - - def test_depth_values_are_scaled_before_creating_depth_image(self): - depth = np.array( - ((0.5, -1.0), (0.0, 2.0)), - dtype=np.float32, - ) - - scaled = self.image_depth.scale_depth_values(depth, 4.1) - - np.testing.assert_allclose( - scaled, - ((2.05, -4.1), (0.0, 8.2)), - ) - - def test_depth_plane_uses_fixed_import_settings(self): - self.assertEqual(self.image_depth.DISPLACEMENT_STRENGTH, 1.0) - self.assertEqual(self.image_depth.DISPLACEMENT_MIDLEVEL, 0.0) - self.assertEqual(self.image_depth.BUMP_BASE_DISTANCE, 0.001) - - def test_grid_segments_follow_aspect_ratio_from_subdivision_power(self): - self.assertEqual( - self.image_depth.grid_segments((0.0, 3.0, 0.0, 2.0), 1), - (3, 2), - ) - self.assertEqual( - self.image_depth.grid_segments((0.0, 2.0, 0.0, 3.0), 1), - (2, 3), - ) - self.assertEqual( - self.image_depth.grid_segments((0.0, 3.0, 0.0, 2.0), 3), - (12, 8), - ) - - def test_plane_fit_transform_matches_source_size_and_offset(self): - source = SimpleNamespace( - data=SimpleNamespace(size=(800, 400)), - empty_display_size=2.0, - empty_image_offset=(-0.25, -0.75), - ) - - transform = self.image_depth.plane_fit_transform( - source, - (-0.1, 0.3, -0.1, 0.1), - ) - - for value, expected in zip( - transform, - (5.0, 5.0, 5.0, 0.0, -0.25), - ): - self.assertAlmostEqual(value, expected) - def test_process_resolution_scales_long_edge_and_aligns_to_patch_size(self): image = SimpleNamespace(size=(1080, 1920)) diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 83e31ac..09e73d0 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -11,10 +11,31 @@ from PIL import Image SOURCE_ROOT = Path(__file__).parents[1] / "src" sys.path.insert(0, str(SOURCE_ROOT)) -from anyimage.operators import cutout_depth, cutout_teddy, mesh -from anyimage.operators.cutout_profile import poisson_balloon_profile -from anyimage.operators.normal_map import rasterize_tangent_normal -from anyimage.server.geometry.cutout import alpha_regions, image_alpha_regions +from anyimage.operators.cutout_tool import balloon_profile as cutout_profile +from anyimage.operators.cutout_tool import build_shapes as mesh +from anyimage.operators.cutout_tool import depth_calibration as cutout_depth +from anyimage.operators.cutout_tool import node_assets as cutout_nodes +from anyimage.operators.cutout_tool import shape_types as shapes +from anyimage.operators.cutout_tool.balloon_profile import poisson_balloon_profile +from anyimage.operators.common.normal_map import rasterize_tangent_normal +from anyimage.operators.cutout_tool.mask_regions import alpha_regions, image_alpha_regions +from anyimage.server.models import moge2 as moge2_model + + +def test_cutout_mesh_detail_uses_the_cropped_analysis_scale(): + assert mesh.limited_cutout_mesh_detail(256, (4096, 1024), 2048) == 128 + assert mesh.limited_cutout_mesh_detail(256, (1024, 512), 2048) == 256 + assert mesh.limited_cutout_mesh_detail(8, (8192, 4096), 2048) == 4 + +def test_cutout_node_groups_are_loaded_from_the_asset(): + loaded = object() + with patch.object( + cutout_nodes, "load_asset_node_group", return_value=loaded + ) as load: + for shape in shapes.CUTOUT_NODE_GROUP_NAMES: + assert cutout_nodes.cutout_node_group(shape) is loaded + load.assert_called_with(shapes.CUTOUT_NODE_GROUP_NAME) + assert load.call_count == 4 def test_base_plane_fit_is_stable_for_a_slender_diagonal_domain(): @@ -40,36 +61,58 @@ def test_base_plane_fit_is_stable_for_a_slender_diagonal_domain(): def test_depth_balloon_plane_calibration_matches_cutout_axes(): - vertices = np.asarray( - ( - (-1.0, -1.0, 0.0), - (1.0, -1.0, 0.0), - (1.0, 1.0, 0.0), - (-1.0, 1.0, 0.0), - ), - dtype=np.float32, - ) - uv = np.asarray( - ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)), - dtype=np.float32, - ) - depth_data = { - "depth_signal": np.zeros((2, 2), dtype=np.float32), - "baseline_vector": np.asarray((0.4, -0.2, 0.1), dtype=np.float32), - "valid": np.ones((2, 2), dtype=bool), - "crop_size": (2, 2), - } + vertices = [] + uv = [] + camera_depth = np.empty((3, 3), dtype=np.float32) + for row in range(3): + local_z = 1.0 - row + for column in range(3): + local_y = column - 1.0 + vertices.append((0.0, local_y, local_z)) + uv.append((column * 0.5, 1.0 - row * 0.5)) + camera_depth[row, column] = 2.0 + 0.2 * local_y + 0.1 * local_z - _depth, local_plane = cutout_depth.depth_balloon_fields( + reference, direction = cutout_depth.depth_balloon_projection_parameters( vertices, uv, - depth_data, + camera_depth, + np.ones((3, 3), dtype=bool), + 0.5, + ) + expected_direction = np.asarray((-0.1, 0.05, 1.0), dtype=np.float64) + expected_direction /= np.linalg.norm(expected_direction) + + assert np.isclose( + reference, + np.percentile(camera_depth, 95.0) * 0.5, + atol=1e-6, + ) + assert np.allclose(direction, expected_direction, atol=1e-6) + + +def test_depth_balloon_reference_uses_the_shared_upper_depth_percentile(): + size = 10 + row, column = np.indices((size, size)) + camera_depth = np.full((size, size), 2.0, dtype=np.float32) + camera_depth[(row + column) % 2 == 0] = 2.2 + vertices = [] + uv = [] + for y in range(size): + for x in range(size): + vertices.append((0.0, float(x), float(size - 1 - y))) + uv.append((x / (size - 1), 1.0 - y / (size - 1))) + + reference, direction = cutout_depth.depth_balloon_projection_parameters( + vertices, + uv, + camera_depth, + np.ones((size, size), dtype=bool), 1.0, ) - oriented = cutout_depth.orient_depth_balloon_plane(local_plane) - assert np.allclose(local_plane, (0.2, 0.1, 0.05), atol=1e-6) - assert np.allclose(oriented, (-0.2, 0.1, 0.05), atol=1e-6) + assert reference > float(np.median(camera_depth)) + assert np.isclose(reference, 2.2, atol=1e-6) + assert np.allclose(direction, (0.0, 0.0, 1.0), atol=1e-6) def mesh_component_count(faces): @@ -93,7 +136,7 @@ def mesh_component_count(faces): return count -def test_tangent_normal_pixels_use_the_final_surface_frame(): +def test_tangent_normal_pixels_use_the_final_geometry_frame(): object_normal = np.zeros((3, 3, 3), dtype=np.float32) object_normal[..., 2] = 1.0 triangle_uv = np.asarray((((0.0, 0.0), (1.0, 0.0), (0.0, 1.0)),)) @@ -114,15 +157,6 @@ def test_tangent_normal_pixels_use_the_final_surface_frame(): assert np.allclose(tangent[2, 2], (0.0, 0.0, 1.0)) -def test_plane_source_topology_is_one_quad_at_every_detail_level(): - low = mesh._plane_domain((-2.0, 2.0, -1.0, 1.0), 1) - high = mesh._plane_domain((-2.0, 2.0, -1.0, 1.0), 512) - - assert low[0].shape == high[0].shape == (4, 3) - assert low[1].shape == high[1].shape == (1, 4) - - - def test_depth_surface_discontinuity_map_uses_local_three_by_three_range(): depth = np.ones((5, 5), dtype=np.float32) depth[:, 3:] = 2.0 @@ -130,8 +164,52 @@ def test_depth_surface_discontinuity_map_uses_local_three_by_three_range(): discontinuity = mesh._depth_discontinuity_map(depth, valid) - assert discontinuity[2, 0] < mesh.DEPTH_EDGE_THRESHOLD - assert discontinuity[2, 2] > mesh.DEPTH_EDGE_THRESHOLD + assert discontinuity[2, 0] == 0.0 + assert discontinuity[2, 2] == 1.0 + + +def test_depth_surface_projection_uses_one_scale_for_all_camera_axes(): + points = np.zeros((51, 101, 3), dtype=np.float32) + points[..., 2] = np.linspace(1.0, 3.0, 51 * 101).reshape(51, 101) + field = ( + points, + np.asarray(((100.0, 0.0, 50.0), (0.0, 100.0, 25.0), (0.0, 0.0, 1.0))), + np.ones((51, 101), dtype=bool), + np.ones((51, 101), dtype=bool), + (101, 51), + 2.0, + ) + + uniform_scale = mesh._surface_uniform_scale( + SimpleNamespace(), + field, + bounds=(-2.0, 2.0, -1.0, 1.0), + ) + + assert uniform_scale == 2.0 + reference_depth = cutout_depth.metric_depth_reference( + points[..., 2], + field[3], + uniform_scale, + ) + assert np.isclose(reference_depth, 5.8, atol=1e-6) + + +def test_moge_surface_field_uses_the_model_validity_mask(): + prediction = moge2_model.Prediction( + depth=np.ones((1, 2, 2), dtype=np.float32), + normal=np.zeros((1, 2, 2, 3), dtype=np.float32), + mask=np.asarray((((True, False), (True, True)),)), + intrinsics=np.asarray( + (((1.0, 0.0, 0.5), (0.0, 1.0, 0.5), (0.0, 0.0, 1.0)),), + dtype=np.float32, + ), + points=np.ones((1, 2, 2, 3), dtype=np.float32), + ) + + frame = moge2_model.geometry_frame(prediction) + + assert np.array_equal(frame.valid_mask, prediction.mask[0]) def test_cutout_regions_group_holes_with_their_connected_component(): @@ -215,7 +293,19 @@ def test_blender_cutout_alpha_pipeline_does_not_import_pillow(): assert regions assert np.any((antialiased > 0.0) & (antialiased < 1.0)) -def test_cutout_geometry_ignores_source_alpha_without_refine(): +def test_unrefined_cutout_geometry_uses_strict_tenth_source_alpha_threshold(): + alpha = np.full((40, 60), 0.1, dtype=np.float32) + alpha[10:30, 20:40] = 0.11 + polygon = ((0, 0), (60, 0), (60, 40), (0, 40)) + + regions, size = mesh._geometry_cutout_regions(alpha, polygon, 64) + contour = np.asarray(regions[0][0]) + + assert size == (60, 40) + np.testing.assert_allclose(contour.min(axis=0), (20.0, 10.0)) + np.testing.assert_allclose(contour.max(axis=0), (40.0, 30.0)) + +def test_cutout_geometry_excludes_zero_source_alpha_without_refine(): alpha = np.zeros((80, 120), dtype=np.float32) alpha[10:70, 10:110] = 1.0 alpha[30:50, 45:75] = 0.0 @@ -225,7 +315,7 @@ def test_cutout_geometry_ignores_source_alpha_without_refine(): assert size == (120, 80) assert len(regions) == 1 - assert len(regions[0]) == 1 + assert len(regions[0]) == 2 def test_cutout_geometry_uses_final_alpha_with_refine(): @@ -242,7 +332,7 @@ def test_cutout_geometry_uses_final_alpha_with_refine(): assert len(regions) == 1 assert len(regions[0]) == 2 -def test_cutout_geometry_can_extend_beyond_the_source_image(): +def test_unrefined_cutout_geometry_discards_out_of_frame_selection(): alpha = np.zeros((60, 100), dtype=np.float32) alpha[10:50] = 1.0 polygon = ((-20, 10), (120, 10), (120, 50), (-20, 50)) @@ -253,8 +343,8 @@ def test_cutout_geometry_can_extend_beyond_the_source_image(): [np.asarray(contour) for region in regions for contour in region] ) assert size == (100, 60) - assert points[:, 0].min() <= -19.0 - assert points[:, 0].max() >= 119.0 + assert points[:, 0].min() >= 0.0 + assert points[:, 0].max() <= 100.0 def test_refined_cutout_geometry_discards_out_of_frame_selection(): alpha = np.zeros((60, 100), dtype=np.float32) @@ -297,6 +387,23 @@ def test_cutout_point_spacing_uses_the_complete_source_plane(): assert spacing == 0.02 +def test_cutout_cleanup_area_uses_point_spacing_and_plane_area_scale(): + assert np.isclose( + mesh._cutout_cleanup_area( + 0.25, + ((1.0, 0.0), (0.0, 1.0)), + ), + 0.0625, + ) + assert np.isclose( + mesh._cutout_cleanup_area( + 0.25, + ((4.0, 0.0), (0.0, 9.0)), + ), + 0.0625 / 6.0, + ) + + def test_poisson_balloon_profile_is_zero_on_boundary_and_positive_inside(): points = np.asarray( ((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)), @@ -351,11 +458,11 @@ def test_teddy_balloon_profile_uses_chordal_axis_radius(): nodes = np.asarray(((1, 1), (3, 1)), dtype=float) with patch.object( - cutout_teddy, + cutout_profile, "chordal_axis", return_value=(nodes, [(0, 1)], np.asarray((1.0, 1.0))), ): - profile = cutout_teddy.teddy_balloon_heights( + profile = cutout_profile.teddy_balloon_heights( points, faces, points[:4], diff --git a/tests/test_mesh_blender.py b/tests/test_mesh_blender.py index 75327dc..aadd807 100644 --- a/tests/test_mesh_blender.py +++ b/tests/test_mesh_blender.py @@ -10,8 +10,13 @@ from mathutils import Matrix, Vector SOURCE_ROOT = Path(__file__).parents[1] / "src" sys.path.insert(0, str(SOURCE_ROOT)) -from anyimage.operators import material, mesh -from anyimage.operators.normal_map import bake_tangent_normal_image +from anyimage.operators.common import material, node_graph +from anyimage.operators.cutout_tool import build_shapes as mesh +from anyimage.operators.cutout_tool import node_assets as cutout_nodes +from anyimage.operators.cutout_tool import shape_types as shapes +from anyimage.operators.convert_to_plane import nodes as plane_nodes +from anyimage.operators.convert_to_plane import build as plane_build +from anyimage.operators.common.normal_map import bake_tangent_normal_image def _inputs(group): @@ -30,6 +35,27 @@ def _group_node(group, node_tree): ) +def test_depth_surface_image_packs_metric_points_and_edge_validity(): + bpy.ops.wm.read_factory_settings(use_empty=True) + source_image = bpy.data.images.new("Source.png", width=2, height=2) + source = bpy.data.objects.new("Source", None) + source.data = source_image + points = np.arange(12, dtype=np.float32).reshape((2, 2, 3)) + depth = np.asarray(((1.0, 1.0), (1.0, 2.0)), dtype=np.float32) + valid = np.asarray(((True, False), (True, True))) + + image = mesh._surface_depth_image(source, points, valid, depth) + + packed = np.empty(16, dtype=np.float32) + image.pixels.foreach_get(packed) + packed = np.flipud(packed.reshape((2, 2, 4))) + assert np.array_equal(packed[:, :, :3], points) + assert packed[0, 1, 3] == -1.0 + assert np.all(packed[valid, 3] >= 0.0) + assert image.alpha_mode == "CHANNEL_PACKED" + assert image.name == "Source_depth.exr" + + def test_tangent_bake_changes_pixels_using_evaluated_mesh_tbn(): bpy.ops.wm.read_factory_settings(use_empty=True) source = bpy.data.objects.new("Source", None) @@ -61,36 +87,19 @@ def test_tangent_bake_changes_pixels_using_evaluated_mesh_tbn(): assert not np.allclose(baked[0, 0, :3], pixels[0, 0, :3]) -def test_shape_node_groups_expose_only_shape_parameters(): +def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): bpy.ops.wm.read_factory_settings(use_empty=True) - plane = mesh._mesh_node_group(mesh.PLANE) - depth_plane = mesh._mesh_node_group(mesh.DEPTH_PLANE) - legacy_surface = bpy.data.node_groups.new( - "O Image Cutout", - "GeometryNodeTree", - ) - cutout = mesh._mesh_node_group(mesh.CUTOUT) - balloon = mesh._mesh_node_group(mesh.BALLOON) - depth_balloon = mesh._mesh_node_group(mesh.DEPTH_BALLOON) - depth_surface = mesh._mesh_node_group(mesh.DEPTH_SURFACE) - + plane = plane_nodes.plane_node_group("PLANE") + depth_plane = plane_nodes.plane_node_group("DEPTH_PLANE") + cutout = cutout_nodes.cutout_node_group("CUTOUT") assert plane.name == "O Mesh Plane" - assert cutout.name == "O Image Surface" - assert cutout == legacy_surface + assert cutout.name == shapes.CUTOUT_NODE_GROUP_NAME + assert all( + cutout_nodes.cutout_node_group(shape) == cutout + for shape in shapes.CUTOUT_SHAPES + ) assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness", "Material"] - assert _inputs(cutout) == ["Geometry", "Thickness", "Cleanup"] - assert _inputs(balloon) == ["Geometry", "Thickness", "Cleanup"] - assert _inputs(depth_balloon) == [ - "Geometry", - "Thickness", - "Depth Scale", - "Double Sided", - "Depth Reference", - "Depth Image", - "Cleanup", - "Base Plane", - ] assert _inputs(depth_plane) == [ "Geometry", "Subdivide", @@ -99,59 +108,216 @@ def test_shape_node_groups_expose_only_shape_parameters(): "Depth Image", "Material", ] - assert _inputs(depth_surface) == [ + assert _inputs(cutout) == [ + "Geometry", + "Shape", + "Thickness", + "Depth Scale", + "Double Sided", + "Smooth Iterations", + "Cleanup", + "Smooth", + "Reference Depth", + "Depth Direction", + "Uniform Scale", + "Depth Image", + ] + panels = [ + item for item in cutout.interface.items_tree if item.item_type == "PANEL" + ] + assert [panel.name for panel in panels] == ["Options", "Data"] + assert [item.name for item in panels[0].interface_items] == [ + "Cleanup", + "Smooth", + "Reference Depth", + "Depth Direction", + ] + assert [item.name for item in panels[1].interface_items] == [ + "Uniform Scale", + "Depth Image", + ] + assert all(panel.default_closed for panel in panels) + + shape_socket = next( + item + for item in cutout.interface.items_tree + if item.item_type == "SOCKET" and item.name == "Shape" + ) + assert shape_socket.socket_type == "NodeSocketMenu" + assert not shape_socket.menu_expanded + menu_switch = next( + node for node in cutout.nodes if node.bl_idname == "GeometryNodeMenuSwitch" + ) + assert [item.name for item in menu_switch.enum_items] == list( + shapes.CUTOUT_SHAPE_LABELS.values() + ) + assert any( + link.from_socket.name == "Shape" + and link.to_node == menu_switch + and link.to_socket.name == "Menu" + for link in cutout.links + ) + + child_groups = { + shape: bpy.data.node_groups[name] + for shape, name in shapes.CUTOUT_NODE_GROUP_NAMES.items() + } + assert cutout.is_modifier + assert all(not group.is_modifier for group in child_groups.values()) + child_nodes = { + node.node_tree: node + for node in cutout.nodes + if node.bl_idname == "GeometryNodeGroup" + } + assert set(child_nodes) == set(child_groups.values()) + for shape, group in child_groups.items(): + label = shapes.CUTOUT_SHAPE_LABELS[shape] + assert any( + link.from_node == child_nodes[group] + and link.to_node == menu_switch + and link.to_socket.name == label + for link in cutout.links + ) + + assert _inputs(child_groups["CUTOUT"]) == ["Geometry", "Thickness"] + assert _inputs(child_groups["BALLOON"]) == ["Geometry", "Thickness"] + assert _inputs(child_groups["DEPTH_BALLOON"]) == [ "Geometry", "Thickness", "Depth Scale", - "Iterations", + "Double Sided", + "Reference Depth", + "Depth Direction", + "Uniform Scale", "Depth Image", - "Cleanup", - "Depth Continuity", - "Smooth", ] - assert len({group.as_pointer() for group in ( - cutout, - balloon, - depth_balloon, - depth_surface, - )}) == 4 - expected_options = { - cutout: ["Cleanup"], - balloon: ["Cleanup"], - depth_balloon: ["Cleanup", "Base Plane"], - depth_surface: ["Cleanup", "Depth Continuity", "Smooth"], + assert _inputs(child_groups["DEPTH_SURFACE"]) == [ + "Geometry", + "Thickness", + "Depth Scale", + "Reference Depth", + "Uniform Scale", + "Depth Image", + ] + + dependencies = {} + for link in cutout.links: + if link.from_node.bl_idname != "NodeGroupInput": + continue + if link.to_node.bl_idname != "GeometryNodeGroup": + continue + shape = next( + shape + for shape, group in child_groups.items() + if group == link.to_node.node_tree + ) + dependencies.setdefault(link.from_socket.name, set()).add(shape) + assert dependencies == { + "Thickness": set(shapes.CUTOUT_SHAPES), + "Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, + "Double Sided": {"DEPTH_BALLOON"}, + "Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, + "Depth Direction": {"DEPTH_BALLOON"}, + "Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, + "Depth Image": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, } - for group, expected in expected_options.items(): - panels = [ - item - for item in group.interface.items_tree - if item.item_type == "PANEL" - ] - assert [panel.name for panel in panels] == ["Options"] - assert panels[0].default_closed - assert [item.name for item in panels[0].interface_items] == expected - for group in (cutout, balloon, depth_balloon, depth_surface): + + assert sum( + node.bl_idname == "GeometryNodeInputMeshIsland" for node in cutout.nodes + ) == 1 + assert not any( + node.bl_idname == "GeometryNodeAttributeStatistic" + for node in cutout.nodes + ) + assert all( + not any( + node.bl_idname == "GeometryNodeInputMeshIsland" + for node in group.nodes + ) + for group in child_groups.values() + ) + assert any( + node.bl_idname == "GeometryNodeBlurAttribute" for node in cutout.nodes + ) + assert all( + not any( + node.bl_idname == "GeometryNodeBlurAttribute" for node in group.nodes + ) + for group in child_groups.values() + ) + repeat_counts = { + shape: sum( + node.bl_idname == "GeometryNodeRepeatInput" for node in group.nodes + ) + for shape, group in child_groups.items() + } + assert repeat_counts == { + "CUTOUT": 1, + "BALLOON": 0, + "DEPTH_BALLOON": 0, + "DEPTH_SURFACE": 1, + } + assert sum( + node.bl_idname == "GeometryNodeRepeatInput" for node in cutout.nodes + ) == 1 + + smooth = next( + item + for item in cutout.interface.items_tree + if item.item_type == "SOCKET" and item.name == "Smooth" + ) + iterations = next( + item + for item in cutout.interface.items_tree + if item.item_type == "SOCKET" and item.name == "Smooth Iterations" + ) + cleanup = next( + item + for item in cutout.interface.items_tree + if item.item_type == "SOCKET" and item.name == "Cleanup" + ) + assert cleanup.default_value == 0.0 + assert cleanup.subtype == "NONE" + assert smooth.default_value == 1.0 + assert smooth.subtype == "FACTOR" + assert iterations.default_value == 0 + assert iterations.min_value == 0 + assert getattr(iterations.parent, "name", "") == "" + thickness = next( + item + for item in cutout.interface.items_tree + if item.item_type == "SOCKET" and item.name == "Thickness" + ) + assert thickness.subtype == "NONE" + assert thickness.default_value == 0.0 + + for group in child_groups.values(): extrudes = [ node for node in group.nodes if node.bl_idname == "GeometryNodeExtrudeMesh" ] assert extrudes - assert all( - not node.inputs["Individual"].default_value for node in extrudes - ) + assert all(not node.inputs["Individual"].default_value for node in extrudes) assert all( node.inputs["Offset Scale"].default_value == 1.0 for node in extrudes ) - for group in (depth_plane, cutout, depth_surface): + for group in ( + child_groups["CUTOUT"], + child_groups["DEPTH_SURFACE"], + depth_plane, + ): thickness = next( item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.name == "Thickness" ) assert thickness.subtype == "DISTANCE" - for group in (balloon, depth_balloon): + for group in ( + child_groups["BALLOON"], + child_groups["DEPTH_BALLOON"], + ): thickness = next( item for item in group.interface.items_tree @@ -159,207 +325,12 @@ def test_shape_node_groups_expose_only_shape_parameters(): ) assert thickness.subtype == "NONE" assert thickness.default_value == 1.0 - for group, name in ( - (depth_plane, "Depth Amount"), - (depth_surface, "Depth Scale"), - ): - depth_amount = next( - item - for item in group.interface.items_tree - if item.item_type == "SOCKET" and item.name == name - ) - assert depth_amount.subtype == "NONE" - assert depth_amount.min_value == 0.0 - assert depth_amount.max_value > 1e20 - assert any( - node.bl_idname == "ShaderNodeMath" and node.operation == "MAXIMUM" - for node in depth_plane.nodes - ) - assert any(node.bl_idname == "GeometryNodeMeshGrid" for node in plane.nodes) - assert any( - node.bl_idname == "GeometryNodeGroup" and node.node_tree == plane - for node in depth_plane.nodes - ) - assert any( - node.bl_idname == "GeometryNodeDeleteGeometry" for node in depth_plane.nodes - ) - smooth = next( - item - for item in depth_surface.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Smooth" - ) - assert smooth.default_value == 1.0 - iterations = next( - item - for item in depth_surface.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Iterations" - ) - assert iterations.default_value == 1 - assert iterations.min_value == 0 - continuity = next( - item - for item in depth_surface.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Depth Continuity" - ) - assert continuity.default_value == 0.0 - assert not any(node.bl_idname == "GeometryNodeGroup" for node in depth_surface.nodes) - assert any( - node.bl_idname == "GeometryNodeRepeatInput" for node in depth_surface.nodes - ) - assert any( - node.bl_idname == "GeometryNodeRepeatOutput" for node in depth_surface.nodes - ) - repeat_counts = { - group: sum( - node.bl_idname == "GeometryNodeRepeatInput" for node in group.nodes - ) - for group in (cutout, balloon, depth_balloon, depth_surface) - } - assert repeat_counts == { - cutout: 1, - balloon: 0, - depth_balloon: 0, - depth_surface: 2, - } - assert any( - node.bl_idname == "GeometryNodeBlurAttribute" for node in depth_surface.nodes - ) - assert any( - node.bl_idname == "GeometryNodeInputMeshEdgeNeighbors" - for node in depth_surface.nodes - ) - assert sum( - node.bl_idname == "GeometryNodeInputMeshEdgeNeighbors" - for node in depth_surface.nodes - ) == 3 - assert sum( - node.bl_idname == "GeometryNodeInputMeshVertexNeighbors" - for node in depth_surface.nodes - ) == 2 - boundary_compares = [ - node - for node in depth_surface.nodes - if node.bl_idname == "FunctionNodeCompare" - and node.data_type == "INT" - and node.operation == "EQUAL" - ] - assert len(boundary_compares) == 4 - for compare in boundary_compares: - compare_a = next( - socket for socket in compare.inputs if socket.identifier == "A_INT" - ) - compare_b = next( - socket for socket in compare.inputs if socket.identifier == "B_INT" - ) - assert compare_a.is_linked - assert compare_b.default_value == 1 - fixed_pin_switches = [ - node - for node in depth_surface.nodes - if node.bl_idname == "GeometryNodeSwitch" - and not node.inputs["Switch"].is_linked - ] - assert len(fixed_pin_switches) == 2 - assert all(node.inputs["Switch"].default_value for node in fixed_pin_switches) - assert any( - node.bl_idname == "ShaderNodeMix" - and not node.inputs[0].is_linked - and node.inputs[0].default_value == 1.0 - for node in depth_surface.nodes - ) - depth_reference = next( - item - for item in depth_balloon.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Depth Reference" - ) - assert depth_reference.hide_in_modifier - assert depth_reference.subtype == "DISTANCE" - for group in (depth_balloon, depth_surface): - depth_image = next( - item - for item in group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Depth Image" - ) - assert depth_image.hide_in_modifier - assert any( - node.bl_idname == "GeometryNodeImageTexture" for node in group.nodes - ) - for group in (cutout, balloon, depth_balloon, depth_surface): - assert not any( - node.bl_idname == "GeometryNodeInputShortestEdgePaths" - for node in group.nodes - ) - assert not any( - node.bl_idname == "GeometryNodeInputNamedAttribute" - and node.inputs["Name"].default_value == "o_boundary" - for node in group.nodes - ) - for group in (balloon, depth_balloon): - profile = next( - node - for node in group.nodes - if node.bl_idname == "GeometryNodeInputNamedAttribute" - and node.inputs["Name"].default_value == mesh.BALLOON_ATTRIBUTE - ) - assert profile.data_type == "FLOAT" - assert any( - node.bl_idname == "ShaderNodeMapRange" - and node.interpolation_type == "SMOOTHERSTEP" - for node in group.nodes - ) - for group in (depth_plane, cutout, balloon, depth_balloon, depth_surface): - input_sockets = [ - item - for item in group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT" - ] - assert all(item.structure_type == "SINGLE" for item in input_sockets) - for group_input in ( - node for node in group.nodes if node.bl_idname == "NodeGroupInput" - ): - assert all( - output.is_linked or output.hide - for output in group_input.outputs - if output.name - ) - assert all( - link.from_node.location.x <= link.to_node.location.x - for link in group.links - ) - assert all(not node.label for node in group.nodes) - node_locations = {tuple(node.location) for node in group.nodes} - assert len(node_locations) == len(group.nodes) - if group in (cutout, balloon, depth_balloon, depth_surface): - group_nodes = list(group.nodes) - for index, node in enumerate(group_nodes): - for other in group_nodes[index + 1:]: - assert not ( - abs(node.location.x - other.location.x) < 150.0 - and abs(node.location.y - other.location.y) < 100.0 - ) - assert any( - node.bl_idname == "GeometryNodeSetShadeSmooth" - and node.inputs["Shade Smooth"].default_value - for node in depth_surface.nodes - ) - def test_image_layer_switches_between_tangent_and_object_space_normal(): bpy.ops.wm.read_factory_settings(use_empty=True) - assert material.SURFACE_MATERIAL_NODE_GROUP_NAME == "O Image Layer" - assert ( - material.MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME - == material.BALLOON_MATERIAL_NODE_GROUP_NAME - == material.IMAGE_MATERIAL_NODE_GROUP_NAME - ) - group = material.material_node_group( - surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME - ) + assert material.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer" + group = material.material_node_group() normal = next( item for item in group.interface.items_tree @@ -410,7 +381,6 @@ def test_image_layer_switches_between_tangent_and_object_space_normal(): "Object Normal", color, 1.0, - surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME, normal_image=normal_image, normal_space="OBJECT", ) @@ -421,14 +391,11 @@ def test_image_layer_switches_between_tangent_and_object_space_normal(): "Tangent Normal", color, 1.0, - surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME, normal_image=normal_image, ) layer = _group_node(group, tangent_material.node_tree) assert layer.inputs["Object Space"].default_value is False - reused = material.material_node_group( - surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME - ) + reused = material.material_node_group() assert { node.space for node in reused.nodes @@ -446,20 +413,18 @@ def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes(): bpy.context.collection.objects.link(source) material = bpy.data.materials.new("Source.material") - result = mesh._build_mesh_object( + result = plane_build.build_mesh_plane( bpy.context, source, - mesh.PLANE, 2, material, - keep_original=True, ) - assert result["anyimage_mesh_shape"] == mesh.PLANE + assert result["anyimage_mesh_shape"] == "PLANE" assert len(result.data.vertices) == 4 assert len(result.data.polygons) == 1 assert all(polygon.use_smooth for polygon in result.data.polygons) - assert mesh.BALLOON_ATTRIBUTE not in result.data.attributes + assert shapes.BALLOON_ATTRIBUTE_NAME not in result.data.attributes assert result.modifiers[0].node_group.name == "O Mesh Plane" evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get()) evaluated_mesh = evaluated.to_mesh() @@ -479,10 +444,10 @@ def test_balloon_build_starts_from_the_unified_single_sheet(): bpy.context.collection.objects.link(source) balloon_material = bpy.data.materials.new("Balloon Sheet.material") - result = mesh._build_mesh_object( + result = mesh.build_cutout_object( bpy.context, source, - mesh.BALLOON, + "BALLOON", 8, balloon_material, regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),), @@ -493,14 +458,14 @@ def test_balloon_build_starts_from_the_unified_single_sheet(): assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) assert len(result.data.vertices) > 4 assert len(result.data.polygons) > 1 - assert mesh.BALLOON_ATTRIBUTE in result.data.attributes - profile_attribute = result.data.attributes[mesh.BALLOON_ATTRIBUTE] + assert shapes.BALLOON_ATTRIBUTE_NAME in result.data.attributes + profile_attribute = result.data.attributes[shapes.BALLOON_ATTRIBUTE_NAME] assert profile_attribute.data_type == "FLOAT" assert profile_attribute.domain == "POINT" profile = np.empty(len(result.data.vertices), dtype=np.float32) profile_attribute.data.foreach_get("value", profile) assert profile.min() == 0.0 and profile.max() > 0.0 - assert result.modifiers[0].node_group.name == "O Image Balloon" + assert result.modifiers[0].node_group.name == shapes.CUTOUT_NODE_GROUP_NAME inward = result.matrix_world.to_3x3() @ Vector((1.0, 0.0, 0.0)) assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6) @@ -520,10 +485,10 @@ def test_teddy_preference_writes_the_shared_balloon_attribute(): "addon_preferences", return_value=SimpleNamespace(balloon_profile_algorithm="TEDDY"), ): - result = mesh._build_mesh_object( + result = mesh.build_cutout_object( bpy.context, source, - mesh.CUTOUT, + "CUTOUT", 8, teddy_material, regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),), @@ -531,7 +496,7 @@ def test_teddy_preference_writes_the_shared_balloon_attribute(): keep_original=True, ) - attribute = result.data.attributes[mesh.BALLOON_ATTRIBUTE] + attribute = result.data.attributes[shapes.BALLOON_ATTRIBUTE_NAME] profile = np.empty(len(result.data.vertices), dtype=np.float32) attribute.data.foreach_get("value", profile) assert profile.min() == 0.0 and profile.max() > 0.0 @@ -555,24 +520,14 @@ def test_depth_surface_object_normal_is_remapped_to_cutout_axes(): assert np.allclose(pixels, (0.0, 0.5, 0.5, 1.0), atol=1e-6) -def test_internal_mesh_depth_images_use_standard_data_name(): +def test_internal_depth_plane_image_uses_standard_data_name(): bpy.ops.wm.read_factory_settings(use_empty=True) - cutout_source = bpy.data.objects.new("Cutout Source", None) - cutout_source.data = bpy.data.images.new( - "Cutout Source.png", width=2, height=2, alpha=True - ) - cutout_depth = mesh._cutout_depth_image( - cutout_source, - np.ones((2, 2), dtype=np.float32), - ) - assert cutout_depth.name == "Cutout Source_depth.exr" - plane_source = bpy.data.objects.new("Plane Source", None) plane_source.data = bpy.data.images.new( "Plane Source.png", width=2, height=2, alpha=True ) anchored = np.zeros((2, 2, 3), dtype=np.float32) - plane_depth = mesh._depth_field_image(plane_source, anchored) + plane_depth = plane_build.create_depth_image(plane_source, anchored) assert plane_depth.name == "Plane Source_depth.exr" @@ -602,14 +557,15 @@ def test_depth_surface_protected_smooth_evaluates(): points, intrinsics, np.ones((size, size), dtype=bool), + np.ones((size, size), dtype=bool), (size, size), 1.0, ) - result = mesh._build_mesh_object( + result = mesh.build_cutout_object( bpy.context, source, - mesh.DEPTH_SURFACE, + "DEPTH_SURFACE", 8, surface_material, field=field, @@ -619,8 +575,33 @@ def test_depth_surface_protected_smooth_evaluates(): ) assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) - assert mesh.BALLOON_ATTRIBUTE in result.data.attributes + assert shapes.BALLOON_ATTRIBUTE_NAME in result.data.attributes modifier = result.modifiers[0] + assert modifier.node_group.name == shapes.CUTOUT_NODE_GROUP_NAME + assert modifier[ + mesh.modifier_input_identifier(modifier.node_group, "Shape") + ] == shapes.CUTOUT_SHAPE_INDICES["DEPTH_SURFACE"] + expected_cleanup = mesh._cutout_cleanup_area( + mesh._cutout_point_spacing(source, 8), + mesh._world_plane_metric(source.matrix_world)[0], + ) + assert np.isclose( + modifier[mesh.modifier_input_identifier(modifier.node_group, "Cleanup")], + expected_cleanup, + ) + assert modifier[ + mesh.modifier_input_identifier(modifier.node_group, "Smooth") + ] == 1.0 + assert modifier[ + mesh.modifier_input_identifier( + modifier.node_group, "Smooth Iterations" + ) + ] == 0 + reference_id = mesh.modifier_input_identifier( + modifier.node_group, + "Reference Depth", + ) + assert modifier[reference_id] == 8.0 mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Smooth"), @@ -654,7 +635,9 @@ def test_depth_surface_protected_smooth_evaluates(): mesh.set_modifier_input( modifier, - mesh.modifier_input_identifier(modifier.node_group, "Iterations"), + mesh.modifier_input_identifier( + modifier.node_group, "Smooth Iterations" + ), 4, ) mesh.set_modifier_input( @@ -675,29 +658,31 @@ def test_depth_surface_protected_smooth_evaluates(): mesh.set_modifier_input( modifier, - mesh.modifier_input_identifier(modifier.node_group, "Iterations"), + mesh.modifier_input_identifier( + modifier.node_group, "Smooth Iterations" + ), 0, ) external_group = bpy.data.node_groups.new("External O Smooth", "GeometryNodeTree") external_group.is_modifier = True - mesh._interface_socket( + node_graph.interface_socket( external_group, "Geometry", "OUTPUT", "NodeSocketGeometry" ) - mesh._interface_socket( + node_graph.interface_socket( external_group, "Geometry", "INPUT", "NodeSocketGeometry" ) - external_iterations = mesh._interface_socket( + external_iterations = node_graph.interface_socket( external_group, "Iterations", "INPUT", "NodeSocketInt" ) external_iterations.default_value = 4 external_iterations.min_value = 0 - external_smooth = mesh._interface_socket( + external_smooth = node_graph.interface_socket( external_group, "Smooth", "INPUT", "NodeSocketFloat" ) external_smooth.default_value = 1.0 external_input = external_group.nodes.new("NodeGroupInput") external_output = external_group.nodes.new("NodeGroupOutput") - external_geometry = mesh._expand_smooth( + external_geometry = node_graph.expand_smooth( external_group, external_input.outputs["Geometry"] ) external_group.links.new( @@ -729,6 +714,32 @@ def _evaluated_positions(obj): evaluated.to_mesh_clear() +def _add_cutout_modifier(obj, shape): + group = cutout_nodes.cutout_node_group(shape) + modifier = obj.modifiers.new(group.name, "NODES") + modifier.node_group = group + mesh.set_modifier_input( + modifier, + mesh.modifier_input_identifier(group, "Shape"), + shapes.CUTOUT_SHAPE_INDICES[shape], + ) + mesh.set_modifier_input( + modifier, + mesh.modifier_input_identifier(group, "Cleanup"), + 0.0, + ) + mesh.set_modifier_input( + modifier, + mesh.modifier_input_identifier(group, "Smooth"), + 0.0, + ) + mesh.set_modifier_input( + modifier, + mesh.modifier_input_identifier(group, "Smooth Iterations"), + 1, + ) + return modifier + def test_cutout_thickness_keeps_one_connected_shell(): bpy.ops.wm.read_factory_settings(use_empty=True) vertices = [ @@ -745,8 +756,8 @@ def test_cutout_thickness_keeps_one_connected_shell(): data.from_pydata(vertices, (), faces) obj = bpy.data.objects.new("Connected Cutout", data) bpy.context.collection.objects.link(obj) - modifier = obj.modifiers.new("O Image Surface", "NODES") - modifier.node_group = mesh._mesh_node_group(mesh.CUTOUT) + modifier = _add_cutout_modifier(obj, "CUTOUT") + mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Cleanup"), @@ -828,13 +839,13 @@ def test_balloon_uses_injected_profile_at_default_height(): data.from_pydata(vertices, (), faces) mesh._assign_float_point_attribute( data, - mesh.BALLOON_ATTRIBUTE, + shapes.BALLOON_ATTRIBUTE_NAME, (0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0), ) obj = bpy.data.objects.new("Balloon Profile", data) bpy.context.collection.objects.link(obj) - modifier = obj.modifiers.new("O Image Balloon", "NODES") - modifier.node_group = mesh._mesh_node_group(mesh.BALLOON) + modifier = _add_cutout_modifier(obj, "BALLOON") + mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Cleanup"), @@ -889,41 +900,44 @@ def test_balloon_uses_injected_profile_at_default_height(): assert np.allclose(zero_positions[:, 0], 0.0, atol=1e-6) -def test_cutout_cleanup_removes_only_small_mesh_island(): +def test_cutout_cleanup_uses_absolute_area_with_many_small_islands(): bpy.ops.wm.read_factory_settings(use_empty=True) vertices = [ (0.0, float(y), float(z)) for z in range(3) for y in range(3) ] - vertices.extend( - ((0.0, 5.0, 5.0), (0.0, 5.01, 5.0), (0.0, 5.0, 5.01)) - ) faces = [] for z in range(2): for y in range(2): a = z * 3 + y faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3))) - faces.append((9, 10, 11)) + for island in range(12): + start = len(vertices) + y = 5.0 + island * 0.1 + vertices.extend( + ((0.0, y, 5.0), (0.0, y + 0.01, 5.0), (0.0, y, 5.01)) + ) + faces.append((start, start + 1, start + 2)) data = bpy.data.meshes.new("Cleanup") data.from_pydata(vertices, (), faces) obj = bpy.data.objects.new("Cleanup", data) bpy.context.collection.objects.link(obj) - modifier = obj.modifiers.new("O Image Surface", "NODES") - modifier.node_group = mesh._mesh_node_group(mesh.CUTOUT) + modifier = _add_cutout_modifier(obj, "CUTOUT") + cleanup_id = mesh.modifier_input_identifier(modifier.node_group, "Cleanup") mesh.set_modifier_input(modifier, cleanup_id, 0.0) bpy.context.view_layer.update() - assert len(_evaluated_positions(obj)) == 12 + assert len(_evaluated_positions(obj)) == 45 - mesh.set_modifier_input(modifier, cleanup_id, 1.0) + mesh.set_modifier_input(modifier, cleanup_id, 0.001) obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() assert len(_evaluated_positions(obj)) == 9 -def test_depth_balloon_base_plane_uses_crop_center_and_matching_arrow_direction(): +def test_depth_balloon_uses_metric_plane_inputs_without_controller(): 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) @@ -939,23 +953,34 @@ def test_depth_balloon_base_plane_uses_crop_center_and_matching_arrow_direction( crop_bounds, (100, 100), ) - depth_data = { - "depth_signal": np.ones((61, 41), dtype=np.float32), - "baseline_vector": np.zeros(3, dtype=np.float32), - "valid": np.ones((61, 41), dtype=bool), - "origin": (50, 20), - "reference_depth": 1.0, - "crop_size": (40, 60), - "source_size": (100, 100), - } + y, x = np.indices((61, 41), dtype=np.float32) + normalized_x = x / 40.0 * 2.0 - 1.0 + normalized_y = y / 60.0 * 2.0 - 1.0 + points = np.zeros((61, 41, 3), dtype=np.float32) + points[..., 0] = normalized_x + points[..., 1] = normalized_y + points[..., 2] = 2.0 + normalized_x * 0.15 - normalized_y * 0.1 + points[27:34, 17:24, 2] -= 0.25 + intrinsics = np.asarray( + ((50.0, 0.0, 20.0), (0.0, 50.0, 30.0), (0.0, 0.0, 1.0)), + dtype=np.float32, + ) + field = ( + points, + intrinsics, + np.ones((61, 41), dtype=bool), + np.ones((61, 41), dtype=bool), + (41, 61), + 2.0, + ) - result = mesh._build_mesh_object( + result = mesh.build_cutout_object( bpy.context, source, - mesh.DEPTH_BALLOON, + "DEPTH_BALLOON", 8, balloon_material, - depth_data=depth_data, + field=field, regions=((crop,),), image_size=(40, 60), bounds=local_bounds, @@ -963,22 +988,21 @@ def test_depth_balloon_base_plane_uses_crop_center_and_matching_arrow_direction( ) assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) - assert mesh.BALLOON_ATTRIBUTE in result.data.attributes + assert shapes.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, + } modifier = result.modifiers[0] - base_plane_id = mesh.modifier_input_identifier( - modifier.node_group, - "Base Plane", - ) depth_image_id = mesh.modifier_input_identifier( modifier.node_group, "Depth Image", ) - base_plane = modifier[base_plane_id] assert isinstance(modifier[depth_image_id], bpy.types.Image) expected_center = np.zeros(2, dtype=np.float64) - assert np.allclose(base_plane.location[1:], expected_center, atol=1e-5) crop_center = Vector( ( (local_bounds[0] + local_bounds[1]) * 0.5, @@ -992,18 +1016,35 @@ def test_depth_balloon_base_plane_uses_crop_center_and_matching_arrow_direction( tuple(expected_origin), atol=1e-5, ) - arrow = base_plane.rotation_euler.to_matrix() @ Vector((0.0, 0.0, 1.0)) - assert np.allclose(tuple(arrow), (-1.0, 0.0, 0.0), atol=1e-5) depth_reference_id = mesh.modifier_input_identifier( modifier.node_group, - "Depth Reference", + "Reference Depth", ) - assert modifier[depth_reference_id] > 0.0 + assert modifier[depth_reference_id] == 8.0 + depth_direction_id = mesh.modifier_input_identifier( + modifier.node_group, + "Depth Direction", + ) + direction = np.asarray(modifier[depth_direction_id], dtype=np.float64) + assert np.array_equal(direction, (0.0, 0.0, 1.0)) + uniform_scale_id = mesh.modifier_input_identifier( + modifier.node_group, + "Uniform Scale", + ) + assert modifier[uniform_scale_id] > 0.0 thickness_id = mesh.modifier_input_identifier( modifier.node_group, "Thickness", ) + smooth_id = mesh.modifier_input_identifier( + modifier.node_group, + "Smooth", + ) + assert modifier[smooth_id] == 1.0 + mesh.set_modifier_input(modifier, smooth_id, 0.0) + result.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() positions = _evaluated_positions(result) center_distance = np.linalg.norm(positions[:, 1:] - expected_center, axis=1) nearest_center = center_distance <= center_distance.min() + 1e-6 @@ -1025,7 +1066,11 @@ def test_depth_balloon_base_plane_uses_crop_center_and_matching_arrow_direction( bpy.context.view_layer.update() before_positions = _evaluated_positions(result) - base_plane.location += arrow * 0.2 + mesh.set_modifier_input( + modifier, + depth_reference_id, + modifier[depth_reference_id] + 0.2, + ) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() after_positions = _evaluated_positions(result) @@ -1043,8 +1088,12 @@ def test_depth_balloon_base_plane_uses_crop_center_and_matching_arrow_direction( result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() zero_scale_before = _evaluated_positions(result) - base_plane.location += arrow * 0.15 - base_plane.rotation_euler.rotate_axis("Y", np.radians(12.0)) + mesh.set_modifier_input( + modifier, + depth_reference_id, + modifier[depth_reference_id] + 0.15, + ) + mesh.set_modifier_input(modifier, depth_direction_id, (0.2, 0.1, 1.0)) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() zero_scale_after = _evaluated_positions(result) @@ -1095,8 +1144,8 @@ def test_converted_color_images_are_new_and_use_blender_duplicate_suffix(): source.data = image bpy.context.collection.objects.link(source) - first = mesh._copy_color_image(source) - second = mesh._copy_color_image(source) + first = plane_build.copy_color_image(source) + second = plane_build.copy_color_image(source) assert first is not image assert second is not image @@ -1105,6 +1154,25 @@ def test_converted_color_images_are_new_and_use_blender_duplicate_suffix(): assert second.name == "Poster_color.png.001" +def test_local_cutout_selection_matches_uint8_tenth_alpha_boundary(): + bpy.ops.wm.read_factory_settings(use_empty=True) + image = bpy.data.images.new("Alpha Cutout", width=4, height=4, alpha=True) + top_down = np.ones((4, 4, 4), dtype=np.float32) + top_down[1, 1, 3] = 25.0 / 255.0 + top_down[1, 2, 3] = 26.0 / 255.0 + image.pixels.foreach_set(np.flipud(top_down).ravel()) + source = bpy.data.objects.new("Alpha Cutout", None) + source.empty_display_type = "IMAGE" + source.data = image + + _rgba, selection = mesh._local_cutout_rgba( + source, + ((0, 0), (4, 0), (4, 4), (0, 4)), + ) + + assert selection[1, 1] == 0.0 + assert selection[1, 2] > 0.1 + def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Local Cutout", width=64, height=48, alpha=True) @@ -1120,30 +1188,51 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): source_bounds = mesh.plane_bounds(source) with patch.object(mesh, "push_undo") as undo: - mesh._build_local_cutout( + mesh.build_local_cutout( SimpleNamespace(mesh_detail=16), bpy.context, source, - mesh.CUTOUT, + "CUTOUT", selection, ) result = bpy.context.view_layer.objects.active assert result is not source assert result.type == "MESH" - assert result["anyimage_mesh_shape"] == mesh.CUTOUT + assert result["anyimage_mesh_shape"] == "CUTOUT" + modifier = result.modifiers[0] + assert modifier.node_group.name == shapes.CUTOUT_NODE_GROUP_NAME + assert modifier[ + mesh.modifier_input_identifier(modifier.node_group, "Shape") + ] == shapes.CUTOUT_SHAPE_INDICES["CUTOUT"] + expected_cleanup = mesh._cutout_cleanup_area( + mesh._cutout_point_spacing(source, 16), + mesh._world_plane_metric(source.matrix_world)[0], + ) + assert np.isclose( + modifier[mesh.modifier_input_identifier(modifier.node_group, "Cleanup")], + expected_cleanup, + ) + assert modifier[ + mesh.modifier_input_identifier(modifier.node_group, "Smooth") + ] == 1.0 + assert modifier[ + mesh.modifier_input_identifier( + modifier.node_group, "Smooth Iterations" + ) + ] == 0 assert len(result.data.vertices) > 4 assert len(result.data.polygons) > 1 assert all(polygon.use_smooth for polygon in result.data.polygons) assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) - assert mesh.BALLOON_ATTRIBUTE in result.data.attributes + assert shapes.BALLOON_ATTRIBUTE_NAME in result.data.attributes source_inverse = source.matrix_world.inverted() source_x = [ (source_inverse @ (result.matrix_world @ vertex.co)).x for vertex in result.data.vertices ] - assert min(source_x) < source_bounds[0] - assert max(source_x) > source_bounds[1] + assert min(source_x) >= source_bounds[0] + assert max(source_x) <= source_bounds[1] assert bpy.data.objects.get(source.name) is source assert result.data.materials[0].node_tree is not None color_texture = next( @@ -1152,11 +1241,9 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): if node.bl_idname == "ShaderNodeTexImage" ) assert color_texture.image is not image - assert tuple(color_texture.image.size) == (80, 32) - copied = np.empty(32 * 80 * 4, dtype=np.float32) + assert tuple(color_texture.image.size) == (64, 34) + copied = np.empty(34 * 64 * 4, dtype=np.float32) color_texture.image.pixels.foreach_get(copied) - copied = copied.reshape((32, 80, 4)) - assert np.allclose(copied[:, 8:72], 1.0) - assert np.allclose(copied[:, :8], 0.0) - assert np.allclose(copied[:, 72:], 0.0) + copied = copied.reshape((34, 64, 4)) + assert np.allclose(copied, 1.0) undo.assert_called_once_with("Convert to Cutout") diff --git a/tests/test_pack.py b/tests/test_pack.py index 0d5c011..e81e768 100644 --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -60,53 +60,73 @@ class PackTest(unittest.TestCase): ) self.assertIn("__init__.py", names) - self.assertIn("clipboard_image/__init__.py", names) - self.assertIn("clipboard_image/actions.py", names) - self.assertIn("clipboard_image/clipboard.py", names) + self.assertIn("operators/clipboard_image/__init__.py", names) + self.assertIn("operators/clipboard_image/actions.py", names) + self.assertIn("operators/clipboard_image/clipboard.py", names) self.assertIn("blender_manifest.toml", names) - self.assertIn("operators/depth_plane/operator.py", names) - self.assertIn("operators/mesh_plane.py", names) - self.assertIn("operators/image.py", names) - self.assertIn("operators/cutout_geometry.py", names) - self.assertIn("operators/cutout_nodes.py", names) - self.assertIn("operators/cutout_depth.py", names) - self.assertIn("operators/cutout_profile.py", names) - self.assertIn("operators/cutout_teddy.py", names) - self.assertNotIn("operators/cutout_poisson.py", names) - self.assertNotIn("operators/balloon/operator.py", names) - self.assertIn("operators/image_edit/__init__.py", names) - self.assertIn("operators/image_edit/common.py", names) - self.assertIn("operators/image_edit/mask.py", names) - self.assertIn("operators/image_edit/box.py", names) - self.assertIn("operators/image_edit/lasso.py", names) - self.assertIn("operators/image_edit/line.py", names) - self.assertIn("operators/image_edit/polyline.py", names) - self.assertIn("operators/image_edit/perspective.py", names) - self.assertIn("operators/image_edit/interaction.py", names) - self.assertIn("operators/surface.py", names) + self.assertNotIn("assets/O_AnyImage.blend1", names) + for path in ( + "operators/common/__init__.py", + "operators/common/image.py", + "operators/common/material.py", + "operators/common/node_asset.py", + "operators/common/normal_map.py", + "operators/common/operator.py", + "operators/common/image_edit.py", + "operators/common/image_gesture.py", + "operators/common/image_interaction.py", + "operators/common/image_mask.py", + "operators/common/node_graph.py", + "operators/cutout_tool/__init__.py", + "operators/cutout_tool/balloon_profile.py", + "operators/cutout_tool/build_shapes.py", + "operators/cutout_tool/depth_calibration.py", + "operators/cutout_tool/mask_regions.py", + "operators/cutout_tool/node_assets.py", + "operators/cutout_tool/planar_mesh.py", + "operators/cutout_tool/selection_tool.py", + "operators/cutout_tool/shape_conversion.py", + "operators/cutout_tool/shape_types.py", + "operators/convert_to_plane/__init__.py", + "operators/convert_to_plane/build.py", + "operators/convert_to_plane/conversion.py", + "operators/convert_to_plane/mesh_plane.py", + "operators/convert_to_plane/nodes.py", + "operators/image_tool/__init__.py", + "operators/image_tool/edit_mask.py", + "operators/image_tool/box.py", + "operators/image_tool/lasso.py", + "operators/image_tool/line.py", + "operators/image_tool/polyline.py", + "operators/perspective_tool/__init__.py", + "operators/perspective_tool/edit_perspective.py", + "operators/ai_setup.py", + "operators/remove_background.py", + ): + self.assertIn(path, 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, ) self.assertIn("server/media/input.py", names) self.assertIn("server/geometry/projection.py", names) - self.assertIn("server/geometry/grid_mesh.py", names) - self.assertIn("server/geometry/surface.py", names) - self.assertIn("server/jobs/background.py", names) - self.assertIn("server/jobs/image_mask.py", names) - self.assertIn("server/jobs/cutout_depth.py", names) - self.assertNotIn("server/jobs/balloon.py", names) + self.assertIn("server/models/geometry.py", names) + self.assertIn("server/jobs/remove_background.py", names) + self.assertIn("server/jobs/refine_image_mask.py", names) + self.assertIn("server/selection/cutout_images.py", names) + self.assertIn("server/selection/polygon_mask.py", names) + self.assertIn("server/jobs/prepare_geometry_data.py", names) + self.assertIn("server/geometry/normal_texture.py", names) + self.assertNotIn("server/geometry/cutout.py", names) self.assertIn("server/models/ben2.py", names) self.assertIn("server/models/da3.py", names) self.assertIn("server/models/moge2.py", names) - self.assertIn("server/jobs/depth.py", names) - self.assertIn("server/jobs/surface.py", names) - self.assertIn("server/jobs/debug_da3.py", names) - self.assertIn("server/jobs/debug_moge2.py", names) - self.assertIn("server/jobs/debug_ben2.py", names) - self.assertIn("server/jobs/debug_upscale.py", names) - self.assertNotIn("server/processing.py", names) - self.assertNotIn("server/progress.py", names) + self.assertIn("server/jobs/debug_predictions.py", names) self.assertIn("server/model_catalog.py", names) self.assertIn("server/model_download.py", names) self.assertIn("server/models/onnx_ben2.py", names) @@ -114,8 +134,6 @@ class PackTest(unittest.TestCase): self.assertIn("server/models/onnx_moge2.py", names) self.assertIn("server/models/onnx_upscale.py", names) self.assertIn("server/models/upscale.py", names) - self.assertNotIn("server/da3.py", names) - self.assertNotIn("server/moge2.py", names) self.assertIn("server/__init__.py", names) self.assertIn( "wheels/blendjob-0.1.14-py3-none-any.whl", @@ -124,9 +142,7 @@ class PackTest(unittest.TestCase): self.assertFalse( any(name.startswith("blendjob/") for name in names) ) - self.assertNotIn("install_env.py", names) self.assertNotIn("anyimage/__init__.py", names) - self.assertNotIn("importer.py", names) self.assertFalse( any(name.startswith("sources/") for name in names) ) diff --git a/tests/test_server.py b/tests/test_server.py index 19adc00..4959d35 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -41,8 +41,8 @@ class ServerTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.server_module = load_server_module() - importlib.import_module("server.jobs.cutout_depth") - importlib.import_module("server.jobs.mesh") + importlib.import_module("server.selection.polygon_mask") + importlib.import_module("server.jobs.prepare_geometry_data") from blendjob import JobServer from server.model_manager import ModelManager, ModelSessionCache @@ -82,7 +82,6 @@ class ServerTest(unittest.TestCase): self.assertEqual(result, {"downloaded": ["MOGE2_VITS_NORMAL"]}) def test_models_are_owned_by_the_server_resource(self): - self.assertFalse((PROJECT_ROOT / "src" / "anyimage" / "models.py").exists()) with tempfile.TemporaryDirectory() as directory: manager = self.ModelManager(Path(directory)) snapshot = manager.snapshot() @@ -262,8 +261,7 @@ class ServerTest(unittest.TestCase): "refine-image-mask", "remove-background", "upscale-image", - "extract-frames", - "generate-mesh", + "prepare-geometry-data", "debug-da3", "debug-moge2", "debug-ben2", @@ -387,36 +385,8 @@ class ServerTest(unittest.TestCase): ) self.assertEqual(events, ["clear", "close"]) - def test_server_dispatches_frame_extraction_without_models(self): - fake_models = SimpleNamespace( - snapshot=lambda: {"loaded": {"ben2": "", "da3": ""}}, - clear=lambda: None, - close=lambda: None, - ) - with patch.object(self.app_module, "ModelManager", return_value=fake_models): - server = self.create_server() - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - input_directory = root / "frames" - input_directory.mkdir() - (input_directory / "frame-0001.jpg").touch() - (input_directory / "frame-0002.jpg").touch() - context = server.submit( - "extract-frames", - { - "input": str(input_directory), - "video_frames": 16, - "start_frame": 1, - }, - job_id="job", - ) - status = self.wait_for_job(context) - self.assertTrue((context.directory / "image.0001.jpg").is_file()) - - self.assertEqual(status["state"], "succeeded") - def test_polygon_selection_mask_uses_even_odd_fill_for_self_intersection(self): - image_mask = sys.modules["server.jobs.image_mask"] + image_mask = sys.modules["server.selection.polygon_mask"] polygon = ( (1, 1), (9, 1), @@ -438,7 +408,7 @@ class ServerTest(unittest.TestCase): self.assertEqual(int(mask.sum()), 48) def test_polygon_selection_mask_nonzero_keeps_same_direction_overlap(self): - image_mask = sys.modules["server.jobs.image_mask"] + image_mask = sys.modules["server.selection.polygon_mask"] polygon = ( (1, 1), (7, 1), @@ -467,7 +437,7 @@ class ServerTest(unittest.TestCase): import numpy as np from PIL import Image - image_mask = sys.modules["server.jobs.image_mask"] + image_mask = sys.modules["server.selection.polygon_mask"] source_path = self.storage_root / "source.png" pixels = np.full((2, 2, 4), (20, 40, 60, 255), dtype=np.uint8) Image.fromarray(pixels, mode="RGBA").save(source_path) @@ -487,7 +457,7 @@ class ServerTest(unittest.TestCase): import numpy as np from PIL import Image - image_mask = sys.modules["server.jobs.image_mask"] + image_mask = sys.modules["server.selection.polygon_mask"] pixels = np.zeros((4, 5, 4), dtype=np.uint8) pixels[1:3, 2:4] = (20, 40, 60, 255) result, bounds = image_mask.crop_to_alpha( @@ -501,10 +471,11 @@ class ServerTest(unittest.TestCase): self.assertTrue(np.all(result[:, :, 3] == 255)) def test_mesh_refine_selection_keeps_source_size_and_selection_alpha(self): + cutout_job = importlib.import_module("server.selection.cutout_images") import numpy as np from PIL import Image - mesh_job = sys.modules["server.jobs.mesh"] + mesh_job = sys.modules["server.jobs.prepare_geometry_data"] source_path = self.storage_root / "source.png" pixels = np.full((6, 8, 4), (20, 40, 60, 255), dtype=np.uint8) Image.fromarray(pixels, mode="RGBA").save(source_path) @@ -518,12 +489,16 @@ class ServerTest(unittest.TestCase): check_cancelled=lambda: None, ) - def passthrough(path, *_args): + def passthrough(path, *_args, **_kwargs): return Image.open(path).convert("RGBA"), 0.0, 0.0 output_path = job_directory / "image.0001.png" - with patch.object(mesh_job, "remove_image_background", side_effect=passthrough): - mesh_job._write_refined_color( + with patch.object( + cutout_job, + "infer_foreground", + side_effect=passthrough, + ): + cutout_job.refine_selection( context, {"refine_model": "BEN2_BASE", "device": "cpu"}, source_path, @@ -537,17 +512,20 @@ class ServerTest(unittest.TestCase): self.assertEqual(tuple(result[5, 7]), (0, 0, 0, 0)) def test_mesh_cutout_images_crop_color_and_preserve_source_alpha(self): + cutout_job = importlib.import_module("server.selection.cutout_images") import json import numpy as np from PIL import Image - mesh_job = sys.modules["server.jobs.mesh"] + mesh_job = sys.modules["server.jobs.prepare_geometry_data"] source_path = self.storage_root / "analysis-source.png" selection_path = self.storage_root / "selection-source.png" output_directory = self.storage_root / "cutout-images" output_directory.mkdir() source = np.full((6, 8, 4), (10, 80, 160, 255), dtype=np.uint8) - source[2, 4, 3] = 64 + source[2, 3, 3] = 25 + source[2, 4, 3] = 26 + source[3, 5, 3] = 0 Image.fromarray(source, mode="RGBA").save(source_path) selection = np.zeros((6, 8, 4), dtype=np.uint8) selection[1:5, 2:7] = (200, 20, 40, 255) @@ -555,7 +533,7 @@ class ServerTest(unittest.TestCase): Image.fromarray(selection, mode="RGBA").save(selection_path) color_path, mask_path, analysis_path, bounds_path = ( - mesh_job._write_cutout_images( + cutout_job.write_images( source_path, selection_path, output_directory, @@ -567,18 +545,26 @@ class ServerTest(unittest.TestCase): self.assertEqual(json.loads(bounds_path.read_text()), [2, 1, 7, 5]) self.assertEqual(color.shape, (4, 5, 4)) - self.assertEqual(tuple(color[1, 2]), (10, 80, 160, 64)) + self.assertEqual(tuple(color[1, 1]), (10, 80, 160, 25)) + self.assertEqual(int(mask[1, 1, 3]), 0) + self.assertEqual(tuple(analysis[1, 1]), (0, 0, 0, 0)) + self.assertEqual(tuple(color[1, 2]), (10, 80, 160, 26)) + self.assertGreater(int(mask[1, 2, 3]), 25) self.assertEqual(int(mask[0, 0, 3]), 0) self.assertEqual(tuple(color[0, 0]), (10, 80, 160, 255)) self.assertEqual(tuple(analysis[0, 0]), (0, 0, 0, 0)) - self.assertEqual(tuple(analysis[1, 2]), (10, 80, 160, 64)) + self.assertEqual(tuple(analysis[1, 2]), (10, 80, 160, 26)) + self.assertEqual(tuple(color[2, 3]), (10, 80, 160, 0)) + self.assertEqual(int(mask[2, 3, 3]), 0) + self.assertEqual(tuple(analysis[2, 3]), (0, 0, 0, 0)) - def test_mesh_unrefined_cutout_keeps_selection_outside_the_source(self): + def test_mesh_unrefined_cutout_discards_selection_outside_the_source(self): + cutout_job = importlib.import_module("server.selection.cutout_images") import json import numpy as np from PIL import Image - mesh_job = sys.modules['server.jobs.mesh'] + mesh_job = sys.modules["server.jobs.prepare_geometry_data"] source_path = self.storage_root / 'outside-source.png' full_selection_path = self.storage_root / 'outside-selection-full.png' output_directory = self.storage_root / 'outside-cutout' @@ -587,14 +573,14 @@ class ServerTest(unittest.TestCase): polygon = ((-2, 1), (10, 1), (10, 5), (-2, 5)) _selection_path, selection_bounds = ( - mesh_job._write_unbounded_selection( + cutout_job.write_selection( source_path, polygon, full_selection_path, ) ) color_path, mask_path, analysis_path, bounds_path = ( - mesh_job._write_cutout_images( + cutout_job.write_images( source_path, full_selection_path, output_directory, @@ -606,19 +592,19 @@ class ServerTest(unittest.TestCase): analysis = np.asarray(Image.open(analysis_path).convert('RGBA')) self.assertEqual(selection_bounds, (-2, 1, 10, 5)) - self.assertEqual(json.loads(bounds_path.read_text()), [-2, 1, 10, 5]) - self.assertEqual(color.shape, (4, 12, 4)) - self.assertEqual(tuple(color[1, 0]), (0, 0, 0, 0)) - self.assertEqual(tuple(color[1, 2]), (10, 80, 160, 255)) + self.assertEqual(json.loads(bounds_path.read_text()), [0, 1, 8, 5]) + self.assertEqual(color.shape, (4, 8, 4)) + self.assertEqual(tuple(color[1, 0]), (10, 80, 160, 255)) + self.assertEqual(tuple(color[1, 7]), (10, 80, 160, 255)) self.assertGreater(int(mask[1, 0, 3]), 127) - self.assertEqual(tuple(analysis[1, 0]), (0, 0, 0, 0)) + self.assertEqual(tuple(analysis[1, 0]), (10, 80, 160, 255)) - def test_mesh_job_keeps_unrefined_outside_selection_with_normal(self): + def test_mesh_job_discards_unrefined_outside_selection_with_normal(self): import json import numpy as np from PIL import Image - mesh_job = sys.modules["server.jobs.mesh"] + mesh_job = sys.modules["server.jobs.prepare_geometry_data"] source_path = self.storage_root / "mesh-source.png" pixels = np.full((6, 8, 4), (20, 40, 60, 255), dtype=np.uint8) Image.fromarray(pixels, mode="RGBA").save(source_path) @@ -650,8 +636,8 @@ class ServerTest(unittest.TestCase): "mesh_detail": 64, "invert": False, "device": "cpu", - "normal_model": "MOGE2_VITS_NORMAL", - "normal_resolution_level": 5, + "model": "MOGE2_VITS_NORMAL", + "resolution_level": 5, "normal_analysis": True, }, ) @@ -678,24 +664,74 @@ class ServerTest(unittest.TestCase): ) self.assertEqual( json.loads((job_directory / "bounds.json").read_text()), - [-2, 1, 10, 5], + [0, 1, 8, 5], ) self.assertEqual(tuple(analysis_pixels[2, 2, :3]), (20, 40, 60)) self.assertGreater(int(analysis_pixels[2, 2, 3]), 240) - self.assertEqual(tuple(analysis_pixels[2, 0]), (0, 0, 0, 0)) - self.assertEqual(tuple(color_pixels[2, 2, :3]), (20, 40, 60)) - self.assertGreater(int(color_pixels[2, 2, 3]), 240) - self.assertEqual(tuple(color_pixels[2, 0]), (0, 0, 0, 0)) - self.assertEqual(color_pixels.shape[:2], (4, 12)) - self.assertEqual(analysis_pixels.shape[:2], (4, 12)) - self.assertEqual(normal_pixels.shape[:2], (4, 12)) - self.assertEqual(tuple(normal_pixels[0, 0]), (128, 128, 255)) - self.assertEqual(tuple(normal_pixels[2, 2]), (255, 127, 127)) + self.assertEqual(tuple(analysis_pixels[2, 0, :3]), (20, 40, 60)) + self.assertEqual(tuple(color_pixels[2, 0, :3]), (20, 40, 60)) + self.assertGreater(int(color_pixels[2, 0, 3]), 240) + self.assertEqual(tuple(color_pixels[2, 7, :3]), (20, 40, 60)) + self.assertEqual(color_pixels.shape[:2], (4, 8)) + self.assertEqual(analysis_pixels.shape[:2], (4, 8)) + self.assertEqual(normal_pixels.shape[:2], (4, 8)) + self.assertEqual(tuple(normal_pixels[0, 0]), (255, 127, 127)) + self.assertEqual(tuple(normal_pixels[2, 0]), (255, 127, 127)) + + def test_cutout_limits_the_cropped_model_input_but_keeps_color_resolution(self): + import numpy as np + from PIL import Image + + mesh_job = sys.modules["server.jobs.prepare_geometry_data"] + source_path = self.storage_root / "limited-cutout-source.png" + Image.new("RGBA", (20, 10), (20, 40, 60, 255)).save(source_path) + job_directory = self.storage_root / "limited-cutout-job" + context = SimpleNamespace( + directory=job_directory, + progress=lambda *_args: None, + check_cancelled=lambda: None, + resource=lambda _name: object(), + ) + prediction = SimpleNamespace( + normal=np.zeros((1, 2, 8, 3), dtype=np.float32) + ) + inferred_sizes = [] + + def infer(_manager, _parameters, images, **_kwargs): + with Image.open(images[0]) as model_input: + inferred_sizes.append(model_input.size) + return prediction + + with patch.object(mesh_job.moge2, "infer", side_effect=infer): + result = mesh_job.run( + context, + { + "input": str(source_path), + "shape": "BALLOON", + "polygon": ((2, 3), (18, 3), (18, 7), (2, 7)), + "mesh_detail": 64, + "max_input_size": 8, + "invert": False, + "device": "cpu", + "model": "MOGE2_VITS_NORMAL", + "resolution_level": 5, + "normal_analysis": True, + }, + ) + + with Image.open(job_directory / "image.0001.png") as color: + color_size = color.size + with Image.open(job_directory / result["normal"]) as normal: + normal_size = normal.size + self.assertEqual(inferred_sizes, [(8, 2)]) + self.assertEqual(color_size, (16, 4)) + self.assertEqual(normal_size, (16, 4)) + self.assertFalse((job_directory / "model-input.png").exists()) def test_mesh_job_skips_moge_normal_when_option_is_disabled(self): from PIL import Image - mesh_job = sys.modules["server.jobs.mesh"] + mesh_job = sys.modules["server.jobs.prepare_geometry_data"] source_path = self.storage_root / "mesh-no-normal-source.png" Image.new("RGBA", (8, 6), (20, 40, 60, 255)).save(source_path) job_directory = self.storage_root / "mesh-no-normal-job" @@ -727,57 +763,165 @@ class ServerTest(unittest.TestCase): write_normal.assert_not_called() self.assertFalse((job_directory / "normal.png").exists()) - def test_mesh_depth_balloon_uses_cropped_analysis_alpha(self): - import numpy as np + def test_mesh_depth_balloon_uses_metric_point_field(self): from PIL import Image - mesh_job = sys.modules["server.jobs.mesh"] + mesh_job = sys.modules["server.jobs.prepare_geometry_data"] source_path = self.storage_root / "mesh-depth-balloon-source.png" - pixels = np.full((6, 6, 4), (20, 40, 60, 0), dtype=np.uint8) - pixels[2:5, 2:5, 3] = 255 - Image.fromarray(pixels, mode="RGBA").save(source_path) + Image.new("RGBA", (6, 6), (20, 40, 60, 255)).save(source_path) job_directory = self.storage_root / "mesh-depth-balloon-job" - job_directory.mkdir() context = SimpleNamespace( directory=job_directory, + progress=lambda *_args: None, check_cancelled=lambda: None, - resource=lambda _name: object(), ) - received = [] + field_path = job_directory / "field.npz" - def infer(_manager, _parameters, images, **_kwargs): - received.extend(images) - depth = np.linspace(1.0, 2.0, 36, dtype=np.float32).reshape(6, 6) - normal = np.zeros((1, 6, 6, 3), dtype=np.float32) - normal[..., 0] = 1.0 - return SimpleNamespace( - depth=depth[None], - mask=np.ones((1, 6, 6), dtype=bool), - normal=normal, + with patch.object( + mesh_job, + "_write_depth_field", + return_value=(field_path, None), + ) as write_surface: + result = mesh_job.run( + context, + { + "input": str(source_path), + "shape": "DEPTH_BALLOON", + "polygon": ((1, 1), (5, 1), (5, 5), (1, 5)), + "normal_analysis": False, + "video_frames": 1, + "invert": False, + }, ) - with patch.object(mesh_job.moge2, "infer", side_effect=infer): - depth_path, normal_path = mesh_job._write_depth_balloon( + self.assertEqual( + result, + { + "selection": "selection.png", + "bounds": "bounds.json", + "field": "field.npz", + }, + ) + self.assertTrue(write_surface.called) + self.assertEqual( + write_surface.call_args.kwargs["selection_path"], + job_directory / "selection.png", + ) + + def test_moge_depth_and_normal_share_one_prediction(self): + import numpy as np + + mesh_job = sys.modules["server.jobs.prepare_geometry_data"] + job_directory = self.storage_root / "shared-moge-prediction" + job_directory.mkdir() + color_path = job_directory / "analysis.png" + normal_path = job_directory / "normal.png" + prediction = object() + valid = np.ones((2, 3), dtype=bool) + frame = SimpleNamespace( + depth=np.ones((2, 3), dtype=np.float32), + points=np.ones((2, 3, 3), dtype=np.float32), + intrinsics=np.eye(3, dtype=np.float32), + valid_mask=valid, + model_mask=valid, + ) + context = SimpleNamespace( + directory=job_directory, + resource=lambda _name: object(), + check_cancelled=lambda: None, + ) + parameters = { + "model": "MOGE2_VITS_NORMAL", + "device": "cpu", + "resolution_level": 5, + } + + with ( + patch.object( + mesh_job.moge2, "infer", return_value=prediction + ) as infer, + patch.object( + mesh_job.moge2, "geometry_frame", return_value=frame + ), + patch.object(mesh_job, "_write_field") as write_field, + patch.object( + mesh_job, + "_write_camera_normal_from_prediction", + return_value=normal_path, + ) as write_normal, + ): + field_path, written_normal_path = mesh_job._write_depth_field( context, - {"device": "cpu"}, - source_path, + parameters, + color_path, include_normal=True, ) - self.assertEqual(received, [source_path]) - with np.load(depth_path, allow_pickle=False) as data: - self.assertEqual(tuple(data["origin"]), (0, 0)) - self.assertEqual(tuple(data["crop_size"]), (6, 6)) - self.assertFalse(bool(data["valid"][0, 0])) - self.assertTrue(bool(data["valid"][2, 2])) - normal = np.asarray(Image.open(normal_path).convert("RGB")) - self.assertEqual(tuple(normal[0, 0]), (128, 128, 255)) - self.assertEqual(tuple(normal[2, 2]), (255, 127, 127)) + infer.assert_called_once() + write_field.assert_called_once() + write_normal.assert_called_once_with( + prediction, color_path, normal_path + ) + self.assertEqual(field_path, job_directory / "field.npz") + self.assertEqual(written_normal_path, normal_path) + + def test_depth_calibration_mask_is_separate_from_model_validity(self): + import numpy as np + from PIL import Image + + mesh_job = sys.modules["server.jobs.prepare_geometry_data"] + selection_path = self.storage_root / "depth-selection.png" + selection = np.zeros((2, 3, 4), dtype=np.uint8) + selection[0, 1, 3] = 255 + selection[1, 2, 3] = 255 + Image.fromarray(selection, mode="RGBA").save(selection_path) + frame = SimpleNamespace( + valid_mask=np.asarray( + ((True, True, True), (True, True, False)), + dtype=bool, + ) + ) + + original_valid = frame.valid_mask.copy() + result = mesh_job._selection_calibration_mask(frame, selection_path) + + np.testing.assert_array_equal( + result, + ((False, True, False), (False, False, False)), + ) + np.testing.assert_array_equal(frame.valid_mask, original_valid) + + def test_depth_field_writes_original_and_calibration_masks_separately(self): + import numpy as np + + mesh_job = sys.modules["server.jobs.prepare_geometry_data"] + output_path = self.storage_root / "field.npz" + valid = np.asarray(((True, True), (True, False)), dtype=bool) + calibration = np.asarray(((False, True), (False, False)), dtype=bool) + depth = np.asarray(((2.0, 2.1), (2.2, 0.0)), dtype=np.float32) + points = np.zeros((2, 2, 3), dtype=np.float32) + points[..., 2] = depth + frame = SimpleNamespace( + depth=depth, + points=points, + intrinsics=np.eye(3, dtype=np.float32), + valid_mask=valid, + model_mask=valid, + ) + + mesh_job._write_field(frame, output_path, calibration) + + with np.load(output_path, allow_pickle=False) as archive: + np.testing.assert_array_equal(archive["valid"], valid) + np.testing.assert_array_equal( + archive["calibration_valid"], + calibration, + ) def test_mesh_job_uses_upscale_source_only_for_analysis(self): from PIL import Image - mesh_job = sys.modules["server.jobs.mesh"] + mesh_job = sys.modules["server.jobs.prepare_geometry_data"] display_path = self.storage_root / "upscaled.png" analysis_path = self.storage_root / "original.png" Image.new("RGBA", (12, 8), (200, 20, 40, 255)).save(display_path) @@ -792,7 +936,7 @@ class ServerTest(unittest.TestCase): with patch.object( mesh_job, - "_write_surface_depth", + "_write_depth_field", return_value=(field_path, None), ) as write_surface: result = mesh_job.run( @@ -812,10 +956,11 @@ class ServerTest(unittest.TestCase): self.assertEqual(color.size, (12, 8)) def test_mesh_masked_color_keeps_same_direction_overlap(self): + cutout_job = importlib.import_module("server.selection.cutout_images") import numpy as np from PIL import Image - mesh_job = sys.modules["server.jobs.mesh"] + mesh_job = sys.modules["server.jobs.prepare_geometry_data"] source_path = self.storage_root / "opaque-overlap.png" output_path = self.storage_root / "cutout-overlap.png" Image.new("RGBA", (12, 9), (20, 40, 60, 255)).save(source_path) @@ -833,17 +978,18 @@ class ServerTest(unittest.TestCase): (1, 1), ) - mesh_job._write_masked_color(source_path, polygon, output_path) + cutout_job._write_masked_color(source_path, polygon, output_path) alpha = np.asarray(Image.open(output_path).convert("RGBA"))[:, :, 3] self.assertGreater(int(alpha[3, 2]), 200) self.assertGreater(int(alpha[3, 5]), 200) self.assertGreater(int(alpha[3, 8]), 200) def test_mesh_masked_color_preserves_nonzero_winding_hole(self): + cutout_job = importlib.import_module("server.selection.cutout_images") import numpy as np from PIL import Image - mesh_job = sys.modules["server.jobs.mesh"] + mesh_job = sys.modules["server.jobs.prepare_geometry_data"] source_path = self.storage_root / "opaque-hole.png" output_path = self.storage_root / "cutout-hole.png" Image.new("RGBA", (10, 10), (20, 40, 60, 255)).save(source_path) @@ -861,131 +1007,30 @@ class ServerTest(unittest.TestCase): (1, 1), ) - mesh_job._write_masked_color(source_path, polygon, output_path) + cutout_job._write_masked_color(source_path, polygon, output_path) alpha = np.asarray(Image.open(output_path).convert("RGBA"))[:, :, 3] self.assertGreater(int(alpha[2, 2]), 200) self.assertLess(int(alpha[4, 4]), 32) def test_mesh_masked_color_antialiases_the_cutout_edge(self): + cutout_job = importlib.import_module("server.selection.cutout_images") import numpy as np from PIL import Image - mesh_job = sys.modules["server.jobs.mesh"] + mesh_job = sys.modules["server.jobs.prepare_geometry_data"] source_path = self.storage_root / "opaque.png" output_path = self.storage_root / "cutout.png" Image.new("RGBA", (16, 16), (20, 40, 60, 255)).save(source_path) - mesh_job._write_masked_color( + cutout_job._write_masked_color( source_path, ((1, 1), (14, 1), (1, 14)), output_path, ) alpha = np.asarray(Image.open(output_path).convert("RGBA"))[:, :, 3] - def test_balloon_depth_analysis_normalizes_moge_distance(self): - import numpy as np - - balloon = sys.modules["server.jobs.cutout_depth"] - y, x = np.indices((7, 9), dtype=np.float32) - depth = 2.0 + x * 0.1 + y * 0.2 - depth[3, 4] -= 0.5 - depth[1, 1] += 3.0 - model_mask = np.ones_like(depth, dtype=bool) - selection_mask = np.ones_like(depth, dtype=bool) - selection_mask[0, 0] = False - - depth_signal, valid, reference, baseline_vector = balloon.normalized_moge_depth( - depth, - model_mask, - selection_mask, - ) - normalized_x = x / (depth.shape[1] - 1) * 2.0 - 1.0 - normalized_y = y / (depth.shape[0] - 1) * 2.0 - 1.0 - fitted_plane = ( - baseline_vector[0] * normalized_x - + baseline_vector[1] * normalized_y - + baseline_vector[2] - ) - displacement = depth_signal - fitted_plane - - self.assertFalse(valid[0, 0]) - self.assertEqual(depth_signal[0, 0], 0.0) - self.assertGreater(float(displacement[3, 4]), 1.0) - self.assertLess(float(displacement[3, 4]), 1.01) - self.assertLess(displacement[1, 1], -1.0) - self.assertGreater(reference, 2.0) - self.assertEqual(baseline_vector.shape, (3,)) - self.assertLess(float(displacement.min()), -1.0) - self.assertLess(float(displacement.max()), 1.01) - self.assertLess(float(np.percentile(displacement, 90.0)), 0.01) - - positive = displacement[valid & (displacement > 0.0)] - self.assertAlmostEqual( - float(np.sum(np.square(positive)) / np.sum(positive)), - 1.0, - places=5, - ) - - def test_balloon_depth_plane_is_stable_for_a_slender_diagonal_mask(self): - import numpy as np - - balloon = sys.modules["server.jobs.cutout_depth"] - y, x = np.indices((101, 101), dtype=np.float64) - normalized_x = x / 50.0 - 1.0 - normalized_y = y / 50.0 - 1.0 - distance = y - x - selection = np.abs(distance) <= 1.0 - along = (normalized_x + normalized_y) * 0.5 - depth = 4.0 + 0.4 * along - depth += np.where(distance > 0.0, 0.05, 0.0) - depth -= np.where(distance < 0.0, 0.05, 0.0) - - _signal, valid, _reference, baseline = balloon.normalized_moge_depth( - depth, - np.ones_like(selection), - selection, - ) - fitted = ( - baseline[0] * normalized_x - + baseline[1] * normalized_y - + baseline[2] - ) - - self.assertLessEqual( - float(np.ptp(fitted)), - float(np.ptp(fitted[valid])) * 1.25, - ) - self.assertGreater( - abs(float(np.corrcoef(fitted[valid], along[valid])[0, 1])), - 0.99, - ) - - def test_balloon_relative_depth_ignores_absolute_distance_and_scale(self): - import numpy as np - - balloon = sys.modules["server.jobs.cutout_depth"] - y, x = np.indices((8, 10), dtype=np.float32) - depth = 4.0 + x * 0.04 + y * 0.03 - depth[2:6, 3:7] -= np.asarray( - ( - (0.1, 0.2, 0.3, 0.1), - (0.2, 0.4, 0.8, 0.2), - (0.15, 0.3, 0.5, 0.15), - (0.05, 0.1, 0.2, 0.05), - ), - dtype=np.float32, - ) - mask = np.ones_like(depth, dtype=bool) - - original = balloon.normalized_moge_depth(depth, mask, mask) - farther = balloon.normalized_moge_depth(depth + 100.0, mask, mask) - rescaled = balloon.normalized_moge_depth(depth * 7.0, mask, mask) - - np.testing.assert_allclose(farther[0], original[0], atol=2e-4) - np.testing.assert_allclose(farther[3], original[3], atol=2e-4) - np.testing.assert_allclose(rescaled[0], original[0], atol=2e-4) - np.testing.assert_allclose(rescaled[3], original[3], atol=2e-4) + self.assertTrue(np.any((alpha > 0) & (alpha < 255))) if __name__ == "__main__": unittest.main() diff --git a/tests/test_worker.py b/tests/test_worker.py index d784492..52fbea8 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -1,4 +1,3 @@ -import importlib.util import os import sys import tempfile @@ -116,76 +115,29 @@ class WorkerTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.media = load_server_module("media.input") + cls.resolution = load_server_module("media.resolution") cls.geometry = load_server_module("geometry") - cls.surface = load_server_module("geometry.surface") - cls.background = load_server_module("jobs.background") + cls.background = load_server_module("jobs.remove_background") cls.ben2 = load_server_module("models.ben2") cls.da3_model = load_server_module("models.da3") cls.moge2_model = load_server_module("models.moge2") cls.upscale_model = load_server_module("models.upscale") cls.onnx_upscale = load_server_module("models.onnx_upscale") - cls.surface_outputs = load_server_module("outputs.surface") cls.debug_da3_outputs = load_server_module("outputs.debug_da3") cls.debug_moge2_outputs = load_server_module("outputs.debug_moge2") - cls.common_job = load_server_module("jobs.common") - cls.depth_job = load_server_module("jobs.depth") - cls.surface_job = load_server_module("jobs.surface") - cls.debug_da3_job = load_server_module("jobs.debug_da3") - cls.debug_moge2_job = load_server_module("jobs.debug_moge2") - cls.debug_ben2_job = load_server_module("jobs.debug_ben2") - cls.debug_upscale_job = load_server_module( - "jobs.debug_upscale" - ) + cls.debug_jobs = load_server_module("jobs.debug_predictions") cls.upscale_job = load_server_module("jobs.upscale") - cls.frames_job = load_server_module("jobs.frames") - cls.foreground_job = load_server_module("jobs.foreground") cls.download = load_server_module("model_download") - def test_inference_progress_omits_single_frame_count(self): + def test_upscale_progress_omits_single_frame_count(self): self.assertEqual( - self.common_job.frame_progress_message( - "Estimating MoGe-2 geometry", 0, 1 - ), - "Estimating MoGe-2 geometry", + self.upscale_job.frame_progress_message("Upscaling image", 0, 1), + "Upscaling image", ) self.assertEqual( - self.common_job.frame_progress_message( - "Estimating DA3 geometry", 0, 1 - ), - "Estimating DA3 geometry", + self.upscale_job.frame_progress_message("Upscaling image", 1, 3), + "Upscaling image (2/3)", ) - self.assertEqual( - self.common_job.frame_progress_message( - "Estimating DA3 geometry", 1, 3 - ), - "Estimating DA3 geometry (2/3)", - ) - - self.assertEqual( - self.common_job.frame_progress_message( - "Estimating MoGe-2 depth", 0, 1 - ), - "Estimating MoGe-2 depth", - ) - self.assertEqual( - self.common_job.frame_progress_message( - "Estimating DA3 depth", 0, 1 - ), - "Estimating DA3 depth", - ) - - def test_surface_has_one_model_independent_builder(self): - self.assertTrue(callable(self.surface.build_surface)) - self.assertTrue(callable(self.surface_outputs.write_surface_archive)) - self.assertFalse(hasattr(self.surface_outputs, "write_surface_texture")) - self.assertFalse(hasattr(self.surface, "export_da3_surface")) - self.assertFalse(hasattr(self.surface, "export_moge2_surface")) - - def test_model_adapters_only_normalize_surface_input(self): - self.assertTrue(callable(self.da3_model.surface_frame)) - self.assertTrue(callable(self.moge2_model.surface_frame)) - self.assertFalse(hasattr(self.da3_model, "generate_surface")) - self.assertFalse(hasattr(self.moge2_model, "generate_surface")) def test_collect_images_is_sorted_and_filters_suffixes(self): with tempfile.TemporaryDirectory() as directory: @@ -740,35 +692,6 @@ class WorkerTest(unittest.TestCase): for _image, options in inference_options: self.assertEqual(options, {}) - def test_extract_frames_copies_selected_frames(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - input_directory = root / "frames" - input_directory.mkdir() - for index in range(1, 6): - (input_directory / f"frame-{index:04d}.jpg").touch() - context = FakeJobContext(root / "result", "extract-frames") - self.frames_job.run( - context, - { - "input": str(input_directory), - "video_frames": 3, - "start_frame": 2, - }, - ) - - result_directory = context.directory - self.assertEqual( - sorted( - path.name - for path in result_directory.iterdir() - if path.is_file() - ), - ["image.0001.jpg", "image.0002.jpg", "image.0003.jpg"], - ) - self.assertEqual(context.status["progress"], 1.0) - self.assertIn("3 frame", context.status["message"]) - def test_da3_debug_saves_only_direct_api_prediction(self): prediction = SimpleNamespace( depth=np.full((1, 2, 2), 2.0, dtype=np.float32), @@ -802,7 +725,7 @@ class WorkerTest(unittest.TestCase): }, ): context = FakeJobContext(root / "result", "debug-da3") - self.debug_da3_job.run( + self.debug_jobs.run_da3( context, { "input": str(input_path), @@ -848,11 +771,11 @@ class WorkerTest(unittest.TestCase): (frames / "b.png").touch() context = FakeJobContext(root / "result", "debug-moge2") with patch.object( - self.debug_moge2_job, - "infer_one", + self.debug_jobs, + "infer_moge2", return_value=prediction, ) as infer_one: - self.debug_moge2_job.run( + self.debug_jobs.run_moge2( context, { "input": str(frames), @@ -880,11 +803,11 @@ class WorkerTest(unittest.TestCase): input_path.touch() context = FakeJobContext(root / "result", "debug-ben2") with patch.object( - self.debug_ben2_job, + self.debug_jobs, "infer_foreground", return_value=(Foreground(), 1.0, 2.0), ) as infer_foreground: - self.debug_ben2_job.run( + self.debug_jobs.run_ben2( context, { "input": str(input_path), @@ -910,11 +833,11 @@ class WorkerTest(unittest.TestCase): (frames / "b.png").touch() context = FakeJobContext(root / "result", "debug-upscale") with patch.object( - self.debug_upscale_job, - "infer_one", + self.debug_jobs, + "infer_upscale", return_value=Image.new("RGB", (8, 8), (10, 20, 30)), ) as infer_one: - self.debug_upscale_job.run( + self.debug_jobs.run_upscale( context, { "input": str(frames), @@ -967,7 +890,7 @@ class WorkerTest(unittest.TestCase): ( "INFO", "Upscale model: REALESRGAN_GENERAL_WDN_X4V3; " - "maximum output size: 4096 px", + "maximum AI input size: 2048 px", ), context.logs, ) @@ -1009,24 +932,19 @@ class WorkerTest(unittest.TestCase): int(alpha[:, -1].min()), ) - def test_upscale_input_is_limited_before_four_x_inference(self): + def test_upscale_input_uses_the_global_long_edge_limit(self): from PIL import Image - source = Image.new("RGB", (2000, 1000)) - limited = self.upscale_model._limited_input(source, 4096) + source = Image.new("RGB", (3000, 1500)) + limited = self.resolution.limit_image(source, 2048) - self.assertEqual(limited.size, (1024, 512)) - square = self.upscale_model._limited_input( - Image.new("RGB", (3000, 3000)), - 8192, - ) - self.assertLessEqual( - square.width * square.height * 16, - self.upscale_model.MAX_OUTPUT_PIXELS, + self.assertEqual(limited.size, (2048, 1024)) + self.assertEqual( + self.resolution.limit_image(Image.new("RGB", (800, 600)), 2048).size, + (800, 600), ) with self.assertRaisesRegex(ValueError, "between 1 and 8192"): - self.upscale_model._limited_input(source, 9000) - + self.resolution.limit_image(source, 9000) def test_upscale_resource_error_releases_session_and_is_readable(self): from PIL import Image @@ -1057,495 +975,20 @@ class WorkerTest(unittest.TestCase): ): with self.assertRaisesRegex( RuntimeError, - "Reduce Max Output Size or select CPU", + "Reduce Maximum AI Input Size or select CPU", ): self.upscale_model.infer_one( models, { "model": "REALESRGAN_GENERAL_WDN_X4V3", "device": "directml", - "max_output_size": 4096, + "max_input_size": 2048, }, path, ) self.assertTrue(models.released) - def test_da3_removes_background_after_depth_using_original_input(self): - order = [] - inference_inputs = [] - fake_da3 = ModuleType("server.models.onnx_da3") - - def fake_infer(_session, images, **_options): - order.append("depth") - inference_inputs.append(images) - return SimpleNamespace( - depth=np.full((1, 2, 2), 2.0, dtype=np.float32), - conf=np.ones((1, 2, 2), dtype=np.float32), - extrinsics=np.eye(4, dtype=np.float32)[None, :3], - intrinsics=np.eye(3, dtype=np.float32)[None], - processed_images=np.zeros((1, 2, 2, 3), dtype=np.uint8), - ) - - fake_da3.infer = fake_infer - fake_runtime = ModuleType("server.models.onnx_runtime") - fake_runtime.provider_label = lambda _session: "CPU" - - class FakeForeground: - def convert(self, _mode): - return self - - def save(self, path, **_options): - Path(path).touch() - - def fake_foreground(image, *_args, **_kwargs): - order.append("background") - self.assertEqual(image, input_path) - return FakeForeground(), 1.0, 2.0 - - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - input_path = root / "input.png" - input_path.touch() - with patch.dict( - sys.modules, - { - "server.models.onnx_da3": fake_da3, - "server.models.onnx_runtime": fake_runtime, - }, - ), patch.object( - self.foreground_job, - "infer_foreground", - side_effect=fake_foreground, - ): - context = FakeJobContext( - root / "result", - "generate-depth", - ) - self.depth_job.run( - context, - { - "input": str(input_path), - "model": "DA3", - "remove_background_model": "BEN2", - "video_frames": 1, - "export_format": "mini_npz", - "device": "cpu", - "process_res": 518, - "process_res_method": "upper_bound_resize", - "ref_view_strategy": "first", - "inference_mode": "per_frame", - }, - ) - - self.assertEqual(order, ["depth", "background"]) - self.assertEqual(inference_inputs, [[str(input_path)]]) - - def test_reconstruct_copies_frames_for_multi_frame_input(self): - inference_inputs = [] - fake_da3 = ModuleType("server.models.onnx_da3") - fake_da3.create_session = lambda _model_dir, _device: object() - - def fake_infer(_session, images, **_options): - inference_inputs.append(images) - return SimpleNamespace( - depth=np.full((1, 2, 2), 2.0, dtype=np.float32), - conf=np.ones((1, 2, 2), dtype=np.float32), - extrinsics=np.eye(4, dtype=np.float32)[None, :3], - intrinsics=np.eye(3, dtype=np.float32)[None], - processed_images=np.zeros((1, 2, 2, 3), dtype=np.uint8), - ) - - fake_da3.infer = fake_infer - fake_runtime = ModuleType("server.models.onnx_runtime") - fake_runtime.provider_label = lambda _session: "CPU" - - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - input_directory = root / "frames" - input_directory.mkdir() - (input_directory / "frame-0001.jpg").touch() - (input_directory / "frame-0002.jpg").touch() - with patch.dict( - sys.modules, - { - "server.models.onnx_da3": fake_da3, - "server.models.onnx_runtime": fake_runtime, - }, - ): - context = FakeJobContext( - root / "result", - "generate-depth", - ) - self.depth_job.run( - context, - { - "input": str(input_directory), - "model": "DA3", - "video_frames": 16, - "export_format": "mini_npz", - "device": "cpu", - "process_res": 518, - "process_res_method": "upper_bound_resize", - "ref_view_strategy": "first", - "conf_thresh_percentile": 40.0, - "num_max_points": 1000, - "inference_mode": "per_frame", - }, - ) - - result_directory = context.directory - self.assertTrue( - (result_directory / "image.0001.jpg").is_file() - ) - self.assertTrue( - (result_directory / "image.0002.jpg").is_file() - ) - self.assertEqual(len(inference_inputs), 2) - self.assertTrue(all(len(paths) == 1 for paths in inference_inputs)) - with np.load(result_directory / "results.npz") as result: - self.assertEqual(result["depth"].shape, (2, 2, 2)) - self.assertTrue( - (result_directory / "results.npz").is_file() - ) - self.assertFalse( - (result_directory / "exports").exists() - ) - self.assertEqual(context.status["progress"], 1.0) - - def test_mesh_confidence_uses_percentile(self): - depth = np.ones((1, 2, 2), dtype=np.float32) - confidence = np.array([[[1.0, 2.0], [3.0, 4.0]]]) - - threshold = self.geometry.confidence_percentile_threshold( - depth, - confidence, - 50.0, - ) - - self.assertEqual(threshold, 2.5) - - def test_zero_mesh_confidence_disables_confidence_filter(self): - depth = np.ones((2, 2), dtype=np.float32) - confidence = np.full((2, 2), np.nan, dtype=np.float32) - x, y = np.meshgrid([0.0, 1.0], [0.0, 1.0]) - points = np.stack((x, y, depth), axis=-1) - - threshold = self.geometry.confidence_percentile_threshold( - depth, - confidence, - 0.0, - ) - faces = self.geometry.mesh_triangle_faces( - points, - depth, - confidence, - threshold, - filter_edges=False, - max_edge_length=0.0, - ) - - self.assertIsNone(threshold) - self.assertEqual(len(faces), 2) - - def test_mesh_filters_triangles_independently(self): - depth = np.ones((2, 2), dtype=np.float32) - confidence = np.ones((2, 2), dtype=np.float32) - confidence[1, 0] = 0.0 - x, y = np.meshgrid( - np.arange(2, dtype=np.float32), - np.arange(2, dtype=np.float32), - ) - points = np.stack((x, y, depth), axis=-1) - - faces = self.geometry.mesh_triangle_faces( - points, - depth, - confidence, - confidence_threshold=0.5, - filter_edges=False, - max_edge_length=0.0, - ) - - np.testing.assert_array_equal(faces, [[0, 3, 1]]) - - def test_mesh_background_mask_combines_with_confidence(self): - depth = np.ones((2, 3), dtype=np.float32) - confidence = np.ones((2, 3), dtype=np.float32) - x, y = np.meshgrid( - np.arange(3, dtype=np.float32), - np.arange(2, dtype=np.float32), - ) - points = np.stack((x, y, depth), axis=-1) - foreground = np.ones((2, 3), dtype=bool) - foreground[:, 0] = False - - faces = self.geometry.mesh_triangle_faces( - points, - depth, - confidence, - confidence_threshold=None, - filter_edges=False, - max_edge_length=0.0, - vertex_mask=foreground, - ) - - np.testing.assert_array_equal(faces, [[1, 4, 5], [1, 5, 2]]) - - def test_depth_edge_filter_ignores_smooth_slope(self): - smooth = np.tile( - np.linspace(2.0, 2.04, 5, dtype=np.float32), - (3, 1), - ) - discontinuous = smooth.copy() - discontinuous[:, 3:] += 1.0 - - self.assertFalse(self.geometry.depth_edge_mask(smooth).any()) - self.assertTrue(self.geometry.depth_edge_mask(discontinuous)[:, 2:4].all()) - - def test_higher_edge_culling_allows_less_stretch(self): - limits = [ - self.geometry.edge_culling_stretch_limit(value) - for value in (0.0, 50.0, 100.0) - ] - - self.assertEqual(limits, [10000.0, 2.0, 1.0]) - - def test_stretched_edge_culling_uses_camera_space_edge_ratio(self): - flat_points = np.asarray( - [ - [[0.0, 0.0, 1.0], [1.0, 0.0, 1.0]], - [[0.0, 1.0, 1.0], [1.0, 1.0, 1.0]], - ], - dtype=np.float32, - ) - stretched_points = flat_points.copy() - stretched_points[0, 1] = (2.0, 0.0, 2.0) - triangles = np.asarray([[0, 2, 3], [0, 3, 1]], dtype=np.int32) - - flat_mask = self.geometry.triangle_edge_stretch_mask( - flat_points, - triangles, - 1.0, - ) - loose_mask = self.geometry.triangle_edge_stretch_mask( - stretched_points, - triangles, - 2.0, - ) - strict_mask = self.geometry.triangle_edge_stretch_mask( - stretched_points, - triangles, - 1.0, - ) - - self.assertTrue(flat_mask.all()) - self.assertTrue(loose_mask.all()) - self.assertFalse(strict_mask.all()) - - def test_mesh_faces_apply_confidence_threshold_and_face_direction(self): - points = np.array( - [ - [[0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [2.0, 0.0, 1.0]], - [[0.0, 1.0, 1.0], [1.0, 1.0, 1.0], [2.0, 1.0, 1.0]], - ], - dtype=np.float32, - ) - depth = np.ones((2, 3), dtype=np.float32) - confidence = np.ones((2, 3), dtype=np.float32) - confidence[:, 0] = 0.0 - - faces = self.geometry.mesh_triangle_faces( - points, - depth, - confidence, - confidence_threshold=0.5, - filter_edges=False, - max_edge_length=0.0, - ) - - np.testing.assert_array_equal(faces, [[1, 4, 5], [1, 5, 2]]) - vertices = points.reshape(-1, 3)[faces[0]] - normal = np.cross( - vertices[1] - vertices[0], - vertices[2] - vertices[0], - ) - self.assertLess(normal[2], 0.0) - - def test_mesh_percentile_reduces_face_count(self): - depth = np.ones((3, 3), dtype=np.float32) - confidence = np.arange(1, 10, dtype=np.float32).reshape(3, 3) - x, y = np.meshgrid( - np.arange(3, dtype=np.float32), - np.arange(3, dtype=np.float32), - ) - points = np.stack((x, y, depth), axis=-1) - face_counts = [] - for percentile in (0.0, 50.0, 100.0): - threshold = self.geometry.confidence_percentile_threshold( - depth, - confidence, - percentile, - ) - faces = self.geometry.mesh_triangle_faces( - points, - depth, - confidence, - confidence_threshold=threshold, - filter_edges=False, - max_edge_length=0.0, - ) - face_counts.append(len(faces)) - - self.assertEqual(face_counts, [8, 2, 0]) - - def test_mesh_compaction_removes_unreferenced_vertices(self): - vertices = np.arange(18, dtype=np.float32).reshape(6, 3) - faces = np.asarray([[1, 3, 5]], dtype=np.int32) - - compact_vertices, compact_faces = self.geometry.compact_mesh( - vertices, - faces, - ) - - np.testing.assert_array_equal( - compact_vertices, - vertices[[1, 3, 5]], - ) - np.testing.assert_array_equal(compact_faces, [[0, 1, 2]]) - - def test_surface_background_requires_every_vertex_to_be_foreground(self): - faces = np.asarray( - ((0, 1, 4), (0, 4, 3), (1, 2, 5), (1, 5, 4)), - dtype=np.int32, - ) - foreground = np.asarray( - ((True, True, False), (False, True, False)), - dtype=bool, - ) - - filtered = self.geometry.remove_background_faces(faces, foreground) - - np.testing.assert_array_equal(filtered, faces[[0]]) - - def test_surface_background_mask_uses_half_alpha_threshold(self): - from PIL import Image - - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - for alpha, expected in ( - (0, False), - (127, False), - (128, True), - (255, True), - ): - path = root / f"alpha-{alpha}.png" - Image.new("RGBA", (2, 2), (10, 20, 30, alpha)).save(path) - mask = self.geometry.foreground_mesh_mask(path, (2, 2)) - self.assertEqual(mask.dtype, np.bool_) - self.assertTrue(np.all(mask == expected)) - - def test_image_mesh_data_keeps_camera_geometry_and_uv(self): - depth = np.full((3, 3), 2.0, dtype=np.float32) - confidence = np.ones((3, 3), dtype=np.float32) - intrinsics = np.asarray( - [[2.0, 0.0, 1.0], [0.0, 2.0, 1.0], [0.0, 0.0, 1.0]], - dtype=np.float32, - ) - frame = self.surface.GeometryFrame( - depth=depth, - points=self.geometry.unproject_camera_depth(depth, intrinsics), - intrinsics=intrinsics, - valid_mask=np.ones(depth.shape, dtype=bool), - confidence=confidence, - ) - mesh = self.surface.build_surface(frame) - with tempfile.TemporaryDirectory() as directory: - result_path = self.surface_outputs.write_surface_archive( - mesh, - Path(directory) / "mesh.npz", - ) - with np.load(result_path) as result: - self.assertEqual(result["vertices"].shape, (9, 3)) - self.assertEqual(result["faces"].shape, (8, 3)) - self.assertEqual(result["uv"].shape, (9, 2)) - self.assertEqual(float(result["reference_depth"]), 2.0) - self.assertNotIn("postprocess_method", result.files) - self.assertEqual(result["point_depth"].shape, (9,)) - self.assertEqual( - result["face_confidence"].shape, - (8,), - ) - self.assertEqual( - set(result.files), - { - "vertices", - "faces", - "uv", - "intrinsics", - "image_size", - "reference_depth", - "point_depth", - "face_confidence", - "face_depth_discontinuity", - }, - ) - np.testing.assert_allclose(result["uv"][0], [0.0, 1.0]) - np.testing.assert_allclose(result["uv"][-1], [1.0, 0.0]) - self.assertNotIn("normal_map", result.files) - - def test_moge_surface_uses_the_same_archive_without_normal_data(self): - depth = np.full((3, 3), 2.0, dtype=np.float32) - intrinsics = np.asarray( - [[2.0, 0.0, 1.0], [0.0, 2.0, 1.0], [0.0, 0.0, 1.0]], - dtype=np.float32, - ) - frame = self.surface.GeometryFrame( - depth=depth, - points=self.geometry.unproject_camera_depth(depth, intrinsics), - intrinsics=intrinsics, - valid_mask=np.ones(depth.shape, dtype=bool), - model_mask=np.asarray( - ( - (False, False, False), - (True, True, True), - (True, True, True), - ), - dtype=bool, - ), - ) - mesh = self.surface.build_surface(frame) - with tempfile.TemporaryDirectory() as directory: - path = self.surface_outputs.write_surface_archive( - mesh, - Path(directory) / "mesh.npz", - ) - with np.load(path) as result: - self.assertIn("face_mask", result.files) - self.assertNotIn("face_confidence", result.files) - self.assertNotIn("normal", result.files) - self.assertNotIn("normal_map", result.files) - - def test_moge_surface_normal_is_exported_as_a_texture(self): - from PIL import Image - - normal = np.asarray( - [[[0.0, 0.0, -1.0]]], - dtype=np.float32, - ) - with tempfile.TemporaryDirectory() as directory: - path = self.surface_outputs.write_normal_texture( - normal, - Path(directory) / "normal.png", - ) - with Image.open(path) as image: - self.assertEqual(image.mode, "RGB") - self.assertEqual(image.size, (1, 1)) - red, green, blue = image.getpixel((0, 0)) - self.assertIn(red, {127, 128}) - self.assertIn(green, {127, 128}) - self.assertEqual(blue, 255) - def test_da3_debug_writer_keeps_api_values_without_derived_attributes(self): prediction = SimpleNamespace( depth=np.ones((1, 2, 3), dtype=np.float32), diff --git a/tools/build_geometry_nodes_asset.py b/tools/build_geometry_nodes_asset.py deleted file mode 100644 index 16a49f5..0000000 --- a/tools/build_geometry_nodes_asset.py +++ /dev/null @@ -1,1450 +0,0 @@ -from pathlib import Path - -import bpy - -from mesh_plane_asset import build_mesh_plane_group - - -GROUP_NAME = "O DA3 Surface" -MOGE_SURFACE_GROUP_NAME = "O MoGe2 Surface" -DEPTH_PLANE_GROUP_NAME = "O DA3 Depth Plane" -MOGE_DEPTH_PLANE_GROUP_NAME = "O MoGe2 Depth Plane" -SHADELESS_GROUP_NAME = "O Shadeless" -MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement" -BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth" -DEPTH_SCALE_ATTRIBUTE = "o_depth_scale" -OUTPUT_PATH = ( - Path(__file__).parents[1] - / "src" - / "anyimage" - / "assets" - / "O_AnyImage.blend" -) - - -def input_socket(node, name, fallback): - value = node.inputs.get(name) - return value if value is not None else node.inputs[fallback] - - -def new_socket(interface, name, in_out, socket_type, parent=None): - socket = interface.new_socket( - name=name, - in_out=in_out, - socket_type=socket_type, - parent=parent, - ) - if in_out == "INPUT" and hasattr(socket, "structure_type"): - socket.structure_type = "SINGLE" - return socket - - -def new_compare(nodes, data_type, operation, location, value): - node = nodes.new("FunctionNodeCompare") - node.data_type = data_type - node.operation = operation - node.location = location - input_socket(node, "B", 1).default_value = value - return node - - -def new_group_input(nodes, location, visible_names): - node = nodes.new("NodeGroupInput") - node.location = location - visible = set(visible_names) - for output in node.outputs: - output.hide = output.name not in visible - return node - - -def add_edge_boundary_field(nodes, links, location): - x, y = location - vertex_neighbors = nodes.new("GeometryNodeInputMeshVertexNeighbors") - vertex_neighbors.location = (x, y + 100.0) - loose_vertex = new_compare( - nodes, - "INT", - "EQUAL", - (x + 180.0, y + 100.0), - 1, - ) - links.new( - vertex_neighbors.outputs["Vertex Count"], - input_socket(loose_vertex, "A", 0), - ) - edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") - edge_neighbors.location = (x, y - 100.0) - boundary_edge = new_compare( - nodes, - "INT", - "EQUAL", - (x + 180.0, y - 100.0), - 1, - ) - links.new( - edge_neighbors.outputs["Face Count"], - input_socket(boundary_edge, "A", 0), - ) - edge_to_point = nodes.new("GeometryNodeFieldOnDomain") - edge_to_point.data_type = "BOOLEAN" - edge_to_point.domain = "EDGE" - edge_to_point.location = (x + 370.0, y - 100.0) - links.new(boundary_edge.outputs["Result"], edge_to_point.inputs["Value"]) - combine = nodes.new("FunctionNodeBooleanMath") - combine.operation = "OR" - combine.location = (x + 560.0, y) - links.new(loose_vertex.outputs["Result"], combine.inputs[0]) - links.new(edge_to_point.outputs["Value"], combine.inputs[1]) - return combine.outputs[0] - - -def add_topology_fix_zone(group, geometry, iterations, location): - nodes = group.nodes - links = group.links - x, y = location - repeat_output = nodes.new("GeometryNodeRepeatOutput") - repeat_output.location = (x + 1450.0, y) - repeat_input = nodes.new("GeometryNodeRepeatInput") - repeat_input.pair_with_output(repeat_output) - repeat_input.location = (x, y) - links.new(geometry, repeat_input.inputs["Geometry"]) - links.new(iterations, repeat_input.inputs["Iterations"]) - - edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") - edge_neighbors.location = (x + 100.0, y + 420.0) - non_manifold = new_compare( - nodes, - "INT", - "GREATER_THAN", - (x + 280.0, y + 500.0), - 2, - ) - boundary = new_compare( - nodes, - "INT", - "LESS_THAN", - (x + 280.0, y + 320.0), - 2, - ) - links.new( - edge_neighbors.outputs["Face Count"], - input_socket(non_manifold, "A", 0), - ) - links.new( - edge_neighbors.outputs["Face Count"], - input_socket(boundary, "A", 0), - ) - non_manifold_face = nodes.new("GeometryNodeFieldOnDomain") - non_manifold_face.data_type = "BOOLEAN" - non_manifold_face.domain = "FACE" - non_manifold_face.location = (x + 480.0, y + 500.0) - boundary_face = nodes.new("GeometryNodeFieldOnDomain") - boundary_face.data_type = "BOOLEAN" - boundary_face.domain = "FACE" - boundary_face.location = (x + 480.0, y + 320.0) - links.new(non_manifold.outputs["Result"], non_manifold_face.inputs["Value"]) - links.new(boundary.outputs["Result"], boundary_face.inputs["Value"]) - broken_face = nodes.new("FunctionNodeBooleanMath") - broken_face.operation = "AND" - broken_face.location = (x + 670.0, y + 420.0) - links.new(non_manifold_face.outputs["Value"], broken_face.inputs[0]) - links.new(boundary_face.outputs["Value"], broken_face.inputs[1]) - remove_broken = nodes.new("GeometryNodeSeparateGeometry") - remove_broken.domain = "FACE" - remove_broken.location = (x + 670.0, y) - links.new(repeat_input.outputs["Geometry"], remove_broken.inputs["Geometry"]) - links.new(broken_face.outputs[0], remove_broken.inputs["Selection"]) - - remaining_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") - remaining_neighbors.location = (x + 650.0, y - 420.0) - remaining_boundary = new_compare( - nodes, - "INT", - "LESS_THAN", - (x + 830.0, y - 420.0), - 2, - ) - links.new( - remaining_neighbors.outputs["Face Count"], - input_socket(remaining_boundary, "A", 0), - ) - boundary_face_2 = nodes.new("GeometryNodeFieldOnDomain") - boundary_face_2.data_type = "BOOLEAN" - boundary_face_2.domain = "FACE" - boundary_face_2.location = (x + 1010.0, y - 420.0) - boundary_edge_2 = nodes.new("GeometryNodeFieldOnDomain") - boundary_edge_2.data_type = "BOOLEAN" - boundary_edge_2.domain = "EDGE" - boundary_edge_2.location = (x + 1190.0, y - 420.0) - links.new(remaining_boundary.outputs["Result"], boundary_face_2.inputs["Value"]) - links.new(boundary_face_2.outputs["Value"], boundary_edge_2.inputs["Value"]) - face_groups = nodes.new("GeometryNodeEdgesToFaceGroups") - face_groups.location = (x + 1010.0, y - 220.0) - links.new( - boundary_edge_2.outputs["Value"], - face_groups.inputs["Boundary Edges"], - ) - accumulate = nodes.new("GeometryNodeAccumulateField") - accumulate.data_type = "INT" - accumulate.domain = "FACE" - accumulate.location = (x + 1190.0, y - 160.0) - links.new(remaining_neighbors.outputs["Face Count"], accumulate.inputs["Value"]) - links.new(face_groups.outputs["Face Group ID"], accumulate.inputs["Group ID"]) - single_face = new_compare( - nodes, - "INT", - "LESS_THAN", - (x + 1370.0, y - 160.0), - 2, - ) - links.new(accumulate.outputs["Total"], input_socket(single_face, "A", 0)) - remove_single = nodes.new("GeometryNodeSeparateGeometry") - remove_single.domain = "FACE" - remove_single.location = (x + 1250.0, y + 80.0) - links.new(remove_broken.outputs["Inverted"], remove_single.inputs["Geometry"]) - links.new(single_face.outputs["Result"], remove_single.inputs["Selection"]) - links.new(remove_single.outputs["Inverted"], repeat_output.inputs["Geometry"]) - return repeat_output.outputs["Geometry"] - - -def add_smooth_step(group, geometry, amount, location): - nodes = group.nodes - links = group.links - x, y = location - position = nodes.new("GeometryNodeInputPosition") - position.location = (x + 100.0, y + 500.0) - smooth_all = nodes.new("GeometryNodeBlurAttribute") - smooth_all.data_type = "FLOAT_VECTOR" - smooth_all.location = (x + 300.0, y + 500.0) - smooth_all.inputs["Iterations"].default_value = 1 - smooth_all.inputs["Weight"].default_value = 1.0 - links.new(position.outputs["Position"], smooth_all.inputs["Value"]) - - boundary_field = add_edge_boundary_field( - nodes, - links, - (x + 100.0, y - 350.0), - ) - boundary_on_points = nodes.new("GeometryNodeFieldOnDomain") - boundary_on_points.data_type = "BOOLEAN" - boundary_on_points.domain = "POINT" - boundary_on_points.location = (x + 700.0, y - 350.0) - links.new(boundary_field, boundary_on_points.inputs["Value"]) - boundary_geometry = nodes.new("GeometryNodeSeparateGeometry") - boundary_geometry.domain = "POINT" - boundary_geometry.location = (x + 880.0, y - 220.0) - links.new(geometry, boundary_geometry.inputs["Geometry"]) - links.new(boundary_on_points.outputs["Value"], boundary_geometry.inputs["Selection"]) - - smooth_boundary = nodes.new("GeometryNodeBlurAttribute") - smooth_boundary.data_type = "FLOAT_VECTOR" - smooth_boundary.location = (x + 500.0, y + 300.0) - smooth_boundary.inputs["Iterations"].default_value = 1 - smooth_boundary.inputs["Weight"].default_value = 0.5 - links.new(position.outputs["Position"], smooth_boundary.inputs["Value"]) - nearest_boundary = nodes.new("GeometryNodeSampleNearest") - nearest_boundary.domain = "POINT" - nearest_boundary.location = (x + 1050.0, y - 120.0) - links.new(boundary_geometry.outputs["Selection"], nearest_boundary.inputs["Geometry"]) - sample_boundary = nodes.new("GeometryNodeSampleIndex") - sample_boundary.data_type = "FLOAT_VECTOR" - sample_boundary.domain = "POINT" - sample_boundary.clamp = False - sample_boundary.location = (x + 1220.0, y + 20.0) - links.new(boundary_geometry.outputs["Selection"], sample_boundary.inputs["Geometry"]) - links.new(nearest_boundary.outputs["Index"], sample_boundary.inputs["Index"]) - links.new(smooth_boundary.outputs["Value"], sample_boundary.inputs["Value"]) - - boundary_switch = nodes.new("GeometryNodeSwitch") - boundary_switch.input_type = "VECTOR" - boundary_switch.location = (x + 1050.0, y + 350.0) - links.new(boundary_on_points.outputs["Value"], boundary_switch.inputs["Switch"]) - links.new(smooth_all.outputs["Value"], boundary_switch.inputs["False"]) - links.new(sample_boundary.outputs["Value"], boundary_switch.inputs["True"]) - - normal = nodes.new("GeometryNodeInputNormal") - normal.location = (x + 300.0, y + 100.0) - blur_normal = nodes.new("GeometryNodeBlurAttribute") - blur_normal.data_type = "FLOAT_VECTOR" - blur_normal.location = (x + 500.0, y + 100.0) - blur_normal.inputs["Iterations"].default_value = 10 - links.new(normal.outputs["Normal"], blur_normal.inputs["Value"]) - normal_length = nodes.new("ShaderNodeVectorMath") - normal_length.operation = "LENGTH" - normal_length.location = (x + 700.0, y + 100.0) - links.new(blur_normal.outputs["Value"], normal_length.inputs[0]) - weight = nodes.new("ShaderNodeMath") - weight.operation = "MULTIPLY" - weight.location = (x + 1050.0, y + 150.0) - links.new(normal_length.outputs["Value"], weight.inputs[0]) - links.new(amount, weight.inputs[1]) - - mix = nodes.new("ShaderNodeMix") - mix.data_type = "VECTOR" - mix.location = (x + 1270.0, y + 300.0) - links.new(weight.outputs[0], input_socket(mix, "Factor", 0)) - links.new(position.outputs["Position"], input_socket(mix, "A", 4)) - links.new(boundary_switch.outputs["Output"], input_socket(mix, "B", 5)) - set_position = nodes.new("GeometryNodeSetPosition") - set_position.location = (x + 1450.0, y) - links.new(geometry, set_position.inputs["Geometry"]) - links.new(mix.outputs["Result"], set_position.inputs["Position"]) - return set_position.outputs["Geometry"] - - -def add_remesh_zone( - group, - geometry, - resolution, - iterations, - fix_steps, - smooth_amount, - location, -): - nodes = group.nodes - links = group.links - x, y = location - repeat_output = nodes.new("GeometryNodeRepeatOutput") - repeat_output.location = (x + 3300.0, y) - repeat_input = nodes.new("GeometryNodeRepeatInput") - repeat_input.pair_with_output(repeat_output) - repeat_input.location = (x, y) - links.new(geometry, repeat_input.inputs["Geometry"]) - links.new(iterations, repeat_input.inputs["Iterations"]) - - half_resolution = nodes.new("ShaderNodeMath") - half_resolution.operation = "MULTIPLY" - half_resolution.inputs[1].default_value = 0.5 - half_resolution.location = (x + 100.0, y - 250.0) - links.new(resolution, half_resolution.inputs[0]) - merge = nodes.new("GeometryNodeMergeByDistance") - merge.mode = "CONNECTED" - merge.location = (x + 300.0, y) - links.new(repeat_input.outputs["Geometry"], merge.inputs["Geometry"]) - links.new(half_resolution.outputs[0], merge.inputs["Distance"]) - fixed = add_topology_fix_zone( - group, - merge.outputs["Geometry"], - fix_steps, - (x + 550.0, y), - ) - smoothed = add_smooth_step( - group, - fixed, - smooth_amount, - (x + 2100.0, y), - ) - links.new(smoothed, repeat_output.inputs["Geometry"]) - return repeat_output.outputs["Geometry"] - - -def add_uv_reprojection(group, source, geometry, location): - nodes = group.nodes - links = group.links - x, y = location - position = nodes.new("GeometryNodeInputPosition") - position.location = (x, y - 200.0) - source_uv = nodes.new("GeometryNodeInputNamedAttribute") - source_uv.data_type = "FLOAT_VECTOR" - source_uv.inputs["Name"].default_value = "UVMap" - source_uv.location = (x, y) - sample_uv = nodes.new("GeometryNodeSampleNearestSurface") - sample_uv.data_type = "FLOAT_VECTOR" - sample_uv.location = (x + 430.0, y) - links.new(source, sample_uv.inputs["Mesh"]) - links.new(source_uv.outputs["Attribute"], sample_uv.inputs["Value"]) - links.new(position.outputs["Position"], sample_uv.inputs["Sample Position"]) - store_uv = nodes.new("GeometryNodeStoreNamedAttribute") - store_uv.data_type = "FLOAT2" - store_uv.domain = "CORNER" - store_uv.location = (x + 1050.0, y + 250.0) - store_uv.inputs["Name"].default_value = "UVMap" - links.new(geometry, store_uv.inputs["Geometry"]) - links.new(sample_uv.outputs["Value"], store_uv.inputs["Value"]) - return store_uv.outputs["Geometry"] - - -def build_group( - group_name=GROUP_NAME, - include_confidence=True, - include_discontinuity=True, - include_mask=False, - discontinuity_name="Depth Continuity", - discontinuity_attribute="o_depth_discontinuity", - discontinuity_default=50.0, -): - group = bpy.data.node_groups.new(group_name, "GeometryNodeTree") - group.is_modifier = True - interface = group.interface - geometry_input_socket = new_socket( - interface, - "Geometry", - "INPUT", - "NodeSocketGeometry", - ) - geometry_input_socket.structure_type = "SINGLE" - - remesh_iterations = new_socket( - interface, - "Iterations", - "INPUT", - "NodeSocketInt", - ) - remesh_iterations.default_value = 2 - remesh_iterations.min_value = 0 - remesh_iterations.max_value = 8 - resolution = new_socket( - interface, - "Resolution", - "INPUT", - "NodeSocketFloat", - ) - resolution.default_value = 0.1 - resolution.min_value = 0.000001 - resolution.subtype = "DISTANCE" - smooth_amount = new_socket( - interface, - "Smooth", - "INPUT", - "NodeSocketFloat", - ) - smooth_amount.default_value = 0.5 - smooth_amount.min_value = 0.0 - smooth_amount.max_value = 1.0 - smooth_amount.subtype = "FACTOR" - culling_panel = None - if include_confidence or include_discontinuity or include_mask: - culling_panel = interface.new_panel( - name="Culling", - default_closed=False, - ) - if include_discontinuity: - discontinuity = new_socket( - interface, - discontinuity_name, - "INPUT", - "NodeSocketFloat", - parent=culling_panel, - ) - discontinuity.default_value = discontinuity_default - discontinuity.min_value = 0.0 - discontinuity.max_value = 100.0 - discontinuity.subtype = "PERCENTAGE" - if include_confidence: - confidence = new_socket( - interface, - "Confidence", - "INPUT", - "NodeSocketFloat", - parent=culling_panel, - ) - confidence.default_value = 0.0 - confidence.min_value = 0.0 - confidence.max_value = 100.0 - confidence.subtype = "PERCENTAGE" - if include_mask: - mask_socket = new_socket( - interface, - "Mask", - "INPUT", - "NodeSocketBool", - parent=culling_panel, - ) - mask_socket.default_value = False - - options_panel = interface.new_panel( - name="Options", - default_closed=True, - ) - fix_steps = new_socket( - interface, - "Fix Topology", - "INPUT", - "NodeSocketInt", - parent=options_panel, - ) - fix_steps.default_value = 3 - fix_steps.min_value = 1 - fix_steps.max_value = 8 - new_socket(interface, "Geometry", "OUTPUT", "NodeSocketGeometry") - - nodes = group.nodes - links = group.links - geometry_input = new_group_input(nodes, (-650.0, 50.0), {"Geometry"}) - confidence_input = None - if include_confidence: - confidence_input = new_group_input( - nodes, - (-1800.0, 800.0), - {"Confidence"}, - ) - discontinuity_input = None - if include_discontinuity: - discontinuity_input = new_group_input( - nodes, - (-1800.0, 50.0), - {discontinuity_name}, - ) - remesh_input = new_group_input( - nodes, - (-100.0, -500.0), - {"Resolution", "Iterations", "Smooth"}, - ) - topology_input = new_group_input(nodes, (350.0, -700.0), {"Fix Topology"}) - island_input = new_group_input(nodes, (3400.0, 100.0), {"Resolution"}) - uv_source_input = new_group_input(nodes, (4200.0, -300.0), {"Geometry"}) - group_output = nodes.new("NodeGroupOutput") - group_output.location = (5650.0, 100.0) - - geometry = geometry_input.outputs["Geometry"] - if include_confidence: - confidence_attribute = nodes.new("GeometryNodeInputNamedAttribute") - confidence_attribute.data_type = "FLOAT" - confidence_attribute.inputs["Name"].default_value = "o_confidence" - confidence_attribute.location = (-1800.0, 600.0) - confidence_scale = nodes.new("ShaderNodeMath") - confidence_scale.operation = "MULTIPLY" - confidence_scale.inputs[1].default_value = 0.01 - confidence_scale.location = (-1600.0, 800.0) - confidence_compare = new_compare( - nodes, - "FLOAT", - "LESS_THAN", - (-1550.0, 600.0), - 0.0, - ) - links.new( - confidence_attribute.outputs["Attribute"], - input_socket(confidence_compare, "A", 0), - ) - links.new( - confidence_input.outputs["Confidence"], - confidence_scale.inputs[0], - ) - links.new( - confidence_scale.outputs[0], - input_socket(confidence_compare, "B", 1), - ) - delete_confidence = nodes.new("GeometryNodeDeleteGeometry") - delete_confidence.domain = "FACE" - delete_confidence.location = (-1050.0, 600.0) - links.new(geometry, delete_confidence.inputs["Geometry"]) - links.new( - confidence_compare.outputs["Result"], - delete_confidence.inputs["Selection"], - ) - geometry = delete_confidence.outputs["Geometry"] - - culled_geometry = geometry - if include_discontinuity: - discontinuity_data = nodes.new("GeometryNodeInputNamedAttribute") - discontinuity_data.data_type = "FLOAT" - discontinuity_data.inputs["Name"].default_value = discontinuity_attribute - discontinuity_data.location = (-1800.0, 350.0) - culling_scale = nodes.new("ShaderNodeMath") - culling_scale.operation = "MULTIPLY" - culling_scale.inputs[1].default_value = 0.01 - culling_scale.location = (-1650.0, -50.0) - inverse_culling = nodes.new("ShaderNodeMath") - inverse_culling.operation = "SUBTRACT" - inverse_culling.inputs[0].default_value = 0.5 - inverse_culling.location = (-1450.0, -50.0) - exponent_scale = nodes.new("ShaderNodeMath") - exponent_scale.operation = "MULTIPLY" - exponent_scale.inputs[1].default_value = 10.0 - exponent_scale.location = (-1250.0, -50.0) - threshold_power = nodes.new("ShaderNodeMath") - threshold_power.operation = "POWER" - threshold_power.inputs[0].default_value = 2.0 - threshold_power.location = (-1050.0, -50.0) - threshold_scale = nodes.new("ShaderNodeMath") - threshold_scale.operation = "MULTIPLY" - threshold_scale.inputs[1].default_value = 0.04 - threshold_scale.location = (-850.0, -50.0) - discontinuity_compare = new_compare( - nodes, - "FLOAT", - "GREATER_THAN", - (-650.0, 350.0), - 0.0, - ) - links.new( - discontinuity_input.outputs[discontinuity_name], - culling_scale.inputs[0], - ) - links.new(culling_scale.outputs[0], inverse_culling.inputs[1]) - links.new(inverse_culling.outputs[0], exponent_scale.inputs[0]) - links.new(exponent_scale.outputs[0], threshold_power.inputs[1]) - links.new(threshold_power.outputs[0], threshold_scale.inputs[0]) - links.new( - discontinuity_data.outputs["Attribute"], - input_socket(discontinuity_compare, "A", 0), - ) - links.new( - threshold_scale.outputs[0], - input_socket(discontinuity_compare, "B", 1), - ) - delete_geometry = nodes.new("GeometryNodeDeleteGeometry") - delete_geometry.domain = "FACE" - delete_geometry.location = (-350.0, 100.0) - links.new(geometry, delete_geometry.inputs["Geometry"]) - links.new( - discontinuity_compare.outputs["Result"], - delete_geometry.inputs["Selection"], - ) - culled_geometry = delete_geometry.outputs["Geometry"] - - remeshed = add_remesh_zone( - group, - culled_geometry, - remesh_input.outputs["Resolution"], - remesh_input.outputs["Iterations"], - topology_input.outputs["Fix Topology"], - remesh_input.outputs["Smooth"], - (-300.0, 100.0), - ) - - mesh_island = nodes.new("GeometryNodeInputMeshIsland") - mesh_island.location = (3400.0, 700.0) - face_area = nodes.new("GeometryNodeInputMeshFaceArea") - face_area.location = (3400.0, 550.0) - island_area = nodes.new("GeometryNodeAccumulateField") - island_area.data_type = "FLOAT" - island_area.domain = "FACE" - island_area.location = (3600.0, 630.0) - links.new(face_area.outputs["Area"], island_area.inputs["Value"]) - links.new(mesh_island.outputs["Island Index"], island_area.inputs["Group ID"]) - resolution_squared = nodes.new("ShaderNodeMath") - resolution_squared.operation = "POWER" - resolution_squared.inputs[1].default_value = 2.0 - resolution_squared.location = (3600.0, 250.0) - links.new(island_input.outputs["Resolution"], resolution_squared.inputs[0]) - tiny_island = new_compare( - nodes, - "FLOAT", - "LESS_THAN", - (3800.0, 630.0), - 0.0, - ) - links.new(island_area.outputs["Total"], input_socket(tiny_island, "A", 0)) - links.new(resolution_squared.outputs[0], input_socket(tiny_island, "B", 1)) - remove_tiny = nodes.new("GeometryNodeDeleteGeometry") - remove_tiny.domain = "FACE" - remove_tiny.location = (4000.0, 100.0) - links.new(remeshed, remove_tiny.inputs["Geometry"]) - links.new(tiny_island.outputs["Result"], remove_tiny.inputs["Selection"]) - - reprojected = add_uv_reprojection( - group, - uv_source_input.outputs["Geometry"], - remove_tiny.outputs["Geometry"], - (4100.0, 100.0), - ) - shade_smooth = nodes.new("GeometryNodeSetShadeSmooth") - shade_smooth.domain = "FACE" - shade_smooth.location = (5400.0, 100.0) - shade_smooth.inputs["Shade Smooth"].default_value = True - links.new(reprojected, shade_smooth.inputs["Geometry"]) - links.new(shade_smooth.outputs["Geometry"], group_output.inputs["Geometry"]) - - group.color_tag = "GEOMETRY" - group.description = ( - "Cull DA3 faces, repair merged topology, preserve boundaries, " - "and reproject UVs." - ) - group.use_fake_user = True - return group - - -def configure_moge_surface_group(group): - nodes = group.nodes - links = group.links - controls = new_group_input( - nodes, - (-1800.0, 1050.0), - {"Mask"}, - ) - mask_attribute = nodes.new("GeometryNodeInputNamedAttribute") - mask_attribute.data_type = "BOOLEAN" - mask_attribute.inputs["Name"].default_value = "o_mask" - mask_attribute.location = (-1550.0, 1050.0) - invert_mask = nodes.new("FunctionNodeBooleanMath") - invert_mask.operation = "NOT" - invert_mask.location = (-1350.0, 1050.0) - use_mask = nodes.new("FunctionNodeBooleanMath") - use_mask.operation = "AND" - use_mask.location = (-1150.0, 1050.0) - links.new(mask_attribute.outputs["Attribute"], invert_mask.inputs[0]) - links.new(controls.outputs["Mask"], use_mask.inputs[0]) - links.new(invert_mask.outputs[0], use_mask.inputs[1]) - first_processing = min( - ( - node - for node in nodes - if node.bl_idname == "GeometryNodeDeleteGeometry" - ), - key=lambda node: node.location.x, - ) - geometry_input = first_processing.inputs["Geometry"] - previous_link = geometry_input.links[0] - previous_geometry = previous_link.from_socket - links.remove(previous_link) - delete_mask = nodes.new("GeometryNodeDeleteGeometry") - delete_mask.domain = "FACE" - delete_mask.location = (-750.0, 650.0) - links.new(previous_geometry, delete_mask.inputs["Geometry"]) - links.new(use_mask.outputs[0], delete_mask.inputs["Selection"]) - links.new(delete_mask.outputs["Geometry"], geometry_input) - group.description = ( - "Cull MoGe-2 mask and depth-edge faces, repair topology, preserve boundaries, " - "and reproject UVs." - ) - group.use_fake_user = True - return group - - -def build_sequence_frame(nodes, links, scene_frame, frames_input, y): - relative_frame = nodes.new("ShaderNodeMath") - relative_frame.operation = "SUBTRACT" - relative_frame.location = (300.0, y) - links.new(scene_frame, relative_frame.inputs[0]) - links.new(frames_input.outputs["Frame Start"], relative_frame.inputs[1]) - safe_frames = nodes.new("ShaderNodeMath") - safe_frames.operation = "MAXIMUM" - safe_frames.inputs[1].default_value = 1.0 - safe_frames.location = (300.0, y - 200.0) - links.new(frames_input.outputs["Frames"], safe_frames.inputs[0]) - first_frame = nodes.new("ShaderNodeMath") - first_frame.operation = "ADD" - first_frame.inputs[1].default_value = 1.0 - first_frame.location = (500.0, y) - links.new(relative_frame.outputs[0], first_frame.inputs[0]) - clamp_minimum = nodes.new("ShaderNodeMath") - clamp_minimum.operation = "MAXIMUM" - clamp_minimum.inputs[1].default_value = 1.0 - clamp_minimum.location = (700.0, y) - links.new(first_frame.outputs[0], clamp_minimum.inputs[0]) - clamp_maximum = nodes.new("ShaderNodeMath") - clamp_maximum.operation = "MINIMUM" - clamp_maximum.location = (900.0, y) - links.new(clamp_minimum.outputs[0], clamp_maximum.inputs[0]) - links.new(safe_frames.outputs[0], clamp_maximum.inputs[1]) - cyclic_frame = nodes.new("ShaderNodeMath") - cyclic_frame.operation = "FLOORED_MODULO" - cyclic_frame.location = (700.0, y - 200.0) - links.new(relative_frame.outputs[0], cyclic_frame.inputs[0]) - links.new(safe_frames.outputs[0], cyclic_frame.inputs[1]) - cyclic_first_frame = nodes.new("ShaderNodeMath") - cyclic_first_frame.operation = "ADD" - cyclic_first_frame.inputs[1].default_value = 1.0 - cyclic_first_frame.location = (900.0, y - 200.0) - links.new(cyclic_frame.outputs[0], cyclic_first_frame.inputs[0]) - frame_switch = nodes.new("GeometryNodeSwitch") - frame_switch.input_type = "INT" - frame_switch.location = (1100.0, y) - links.new(frames_input.outputs["Cyclic"], frame_switch.inputs["Switch"]) - links.new(clamp_maximum.outputs[0], frame_switch.inputs["False"]) - links.new(cyclic_first_frame.outputs[0], frame_switch.inputs["True"]) - return frame_switch.outputs["Output"] - - -def build_depth_plane_group(): - group = bpy.data.node_groups.new(DEPTH_PLANE_GROUP_NAME, "GeometryNodeTree") - group.is_modifier = True - interface = group.interface - geometry_input_socket = new_socket( - interface, - "Geometry", - "INPUT", - "NodeSocketGeometry", - ) - geometry_input_socket.structure_type = "SINGLE" - displacement = new_socket( - interface, - "Displacement", - "INPUT", - "NodeSocketMenu", - ) - subdivision = new_socket( - interface, - "Subdivision", - "INPUT", - "NodeSocketInt", - ) - subdivision.default_value = 6 - subdivision.min_value = 0 - subdivision.max_value = 10 - displacement_strength = new_socket( - interface, - "Depth Scale", - "INPUT", - "NodeSocketFloat", - ) - displacement_strength.default_value = 1.0 - displacement_strength.min_value = -10000.0 - displacement_strength.max_value = 10000.0 - displacement_strength.structure_type = "SINGLE" - background_depth = new_socket( - interface, - "Background Depth", - "INPUT", - "NodeSocketFloat", - ) - background_depth.default_value = 1.0 - background_depth.min_value = -10000.0 - background_depth.max_value = 10000.0 - background_depth.subtype = "DISTANCE" - background_depth.structure_type = "SINGLE" - data_panel = interface.new_panel(name="Data", default_closed=True) - depth_image = new_socket( - interface, - "Depth Image", - "INPUT", - "NodeSocketImage", - parent=data_panel, - ) - color_image = new_socket( - interface, - "Color Image", - "INPUT", - "NodeSocketImage", - parent=data_panel, - ) - material = new_socket( - interface, - "Material", - "INPUT", - "NodeSocketMaterial", - parent=data_panel, - ) - frame_data = [] - for name, socket_type, default_value in ( - ("Frame", "NodeSocketInt", 1), - ("Frame Start", "NodeSocketInt", 1), - ("Frames", "NodeSocketInt", 1), - ("Cyclic", "NodeSocketBool", False), - ): - socket = new_socket( - interface, - name, - "INPUT", - socket_type, - parent=data_panel, - ) - socket.default_value = default_value - if name == "Frame": - socket.structure_type = "SINGLE" - if name == "Frames": - socket.min_value = 1 - frame_data.append(socket) - depth_image.hide_in_modifier = True - color_image.hide_in_modifier = True - material.hide_in_modifier = True - for socket in frame_data: - socket.hide_in_modifier = True - new_socket(interface, "Geometry", "OUTPUT", "NodeSocketGeometry") - - nodes = group.nodes - links = group.links - bounds_input = new_group_input(nodes, (-2200.0, 700.0), {"Geometry"}) - grid_input = new_group_input(nodes, (-2200.0, 150.0), {"Subdivision"}) - images_input = new_group_input( - nodes, - (100.0, 1250.0), - {"Depth Image", "Color Image"}, - ) - frames_input = new_group_input( - nodes, - (50.0, 1750.0), - { - "Frame", - "Frame Start", - "Frames", - "Cyclic", - }, - ) - background_input = new_group_input( - nodes, - (350.0, 250.0), - {"Background Depth"}, - ) - background_depth_value = nodes.new("ShaderNodeMath") - background_depth_value.operation = "MULTIPLY" - background_depth_value.inputs[1].default_value = -1.0 - background_depth_value.location = (550.0, 250.0) - links.new( - background_input.outputs["Background Depth"], - background_depth_value.inputs[0], - ) - displacement_input = new_group_input( - nodes, - (950.0, -150.0), - {"Displacement", "Depth Scale"}, - ) - material_input = new_group_input(nodes, (1450.0, 250.0), {"Material"}) - attributes_input = new_group_input( - nodes, - (2150.0, 50.0), - {"Background Depth"}, - ) - group_output = nodes.new("NodeGroupOutput") - group_output.location = (3050.0, 650.0) - - bounds = nodes.new("GeometryNodeBoundBox") - bounds.location = (-1950.0, 700.0) - links.new(bounds_input.outputs["Geometry"], bounds.inputs["Geometry"]) - dimensions = nodes.new("ShaderNodeVectorMath") - dimensions.operation = "SUBTRACT" - dimensions.location = (-1750.0, 700.0) - links.new(bounds.outputs["Max"], dimensions.inputs[0]) - links.new(bounds.outputs["Min"], dimensions.inputs[1]) - separate_dimensions = nodes.new("ShaderNodeSeparateXYZ") - separate_dimensions.location = (-1550.0, 700.0) - links.new(dimensions.outputs["Vector"], separate_dimensions.inputs[0]) - - short_segments = nodes.new("ShaderNodeMath") - short_segments.operation = "POWER" - short_segments.inputs[0].default_value = 2.0 - short_segments.location = (-1950.0, 150.0) - links.new(grid_input.outputs["Subdivision"], short_segments.inputs[1]) - - vertex_counts = [] - for index, (axis, other_axis) in enumerate((("X", "Y"), ("Y", "X"))): - y = 500.0 - index * 300.0 - ratio = nodes.new("ShaderNodeMath") - ratio.operation = "DIVIDE" - ratio.location = (-1350.0, y) - links.new(separate_dimensions.outputs[axis], ratio.inputs[0]) - links.new(separate_dimensions.outputs[other_axis], ratio.inputs[1]) - at_least_one = nodes.new("ShaderNodeMath") - at_least_one.operation = "MAXIMUM" - at_least_one.inputs[1].default_value = 1.0 - at_least_one.location = (-1150.0, y) - links.new(ratio.outputs[0], at_least_one.inputs[0]) - scaled_segments = nodes.new("ShaderNodeMath") - scaled_segments.operation = "MULTIPLY" - scaled_segments.location = (-950.0, y) - links.new(short_segments.outputs[0], scaled_segments.inputs[0]) - links.new(at_least_one.outputs[0], scaled_segments.inputs[1]) - rounded_segments = nodes.new("ShaderNodeMath") - rounded_segments.operation = "ROUND" - rounded_segments.location = (-750.0, y) - links.new(scaled_segments.outputs[0], rounded_segments.inputs[0]) - vertices = nodes.new("ShaderNodeMath") - vertices.operation = "ADD" - vertices.inputs[1].default_value = 1.0 - vertices.location = (-550.0, y) - links.new(rounded_segments.outputs[0], vertices.inputs[0]) - vertex_counts.append(vertices.outputs[0]) - - grid = nodes.new("GeometryNodeMeshGrid") - grid.location = (-300.0, 650.0) - links.new(separate_dimensions.outputs["X"], grid.inputs["Size X"]) - links.new(separate_dimensions.outputs["Y"], grid.inputs["Size Y"]) - links.new(vertex_counts[0], grid.inputs["Vertices X"]) - links.new(vertex_counts[1], grid.inputs["Vertices Y"]) - store_uv = nodes.new("GeometryNodeStoreNamedAttribute") - store_uv.data_type = "FLOAT2" - store_uv.domain = "CORNER" - store_uv.inputs["Name"].default_value = "UVMap" - store_uv.location = (-50.0, 650.0) - links.new(grid.outputs["Mesh"], store_uv.inputs["Geometry"]) - links.new(grid.outputs["UV Map"], store_uv.inputs["Value"]) - - center = nodes.new("ShaderNodeVectorMath") - center.operation = "ADD" - center.location = (-550.0, 950.0) - links.new(bounds.outputs["Min"], center.inputs[0]) - links.new(bounds.outputs["Max"], center.inputs[1]) - half_center = nodes.new("ShaderNodeVectorMath") - half_center.operation = "SCALE" - half_center.inputs[3].default_value = 0.5 - half_center.location = (-300.0, 950.0) - links.new(center.outputs["Vector"], half_center.inputs[0]) - transform_grid = nodes.new("GeometryNodeTransform") - transform_grid.location = (200.0, 650.0) - links.new(store_uv.outputs["Geometry"], transform_grid.inputs["Geometry"]) - links.new(half_center.outputs["Vector"], transform_grid.inputs["Translation"]) - - depth_texture = nodes.new("GeometryNodeImageTexture") - depth_texture.interpolation = "Linear" - depth_texture.extension = "EXTEND" - depth_texture.location = (350.0, 1150.0) - foreground_texture = nodes.new("GeometryNodeImageTexture") - foreground_texture.interpolation = "Closest" - foreground_texture.extension = "EXTEND" - foreground_texture.location = (350.0, 900.0) - links.new(images_input.outputs["Depth Image"], depth_texture.inputs["Image"]) - links.new( - images_input.outputs["Color Image"], - foreground_texture.inputs["Image"], - ) - links.new(grid.outputs["UV Map"], depth_texture.inputs["Vector"]) - links.new(grid.outputs["UV Map"], foreground_texture.inputs["Vector"]) - sequence_frame = build_sequence_frame( - nodes, - links, - frames_input.outputs["Frame"], - frames_input, - 2200.0, - ) - links.new(sequence_frame, depth_texture.inputs["Frame"]) - links.new(sequence_frame, foreground_texture.inputs["Frame"]) - - clamp_foreground = nodes.new("ShaderNodeMath") - clamp_foreground.operation = "MAXIMUM" - clamp_foreground.location = (600.0, 650.0) - links.new(depth_texture.outputs["Color"], clamp_foreground.inputs[0]) - links.new( - background_depth_value.outputs[0], - clamp_foreground.inputs[1], - ) - foreground_mask = new_compare( - nodes, - "FLOAT", - "GREATER_THAN", - (600.0, 900.0), - 0.5, - ) - links.new( - foreground_texture.outputs["Alpha"], - input_socket(foreground_mask, "A", 0), - ) - masked_depth = nodes.new("ShaderNodeMix") - masked_depth.data_type = "FLOAT" - masked_depth.location = (800.0, 650.0) - links.new(foreground_mask.outputs["Result"], masked_depth.inputs[0]) - links.new( - background_depth_value.outputs[0], - masked_depth.inputs[2], - ) - links.new(clamp_foreground.outputs[0], masked_depth.inputs[3]) - displacement_menu = nodes.new("GeometryNodeMenuSwitch") - displacement_menu.data_type = "FLOAT" - displacement_menu.enum_items.clear() - displacement_menu.enum_items.new("Geometry") - displacement_menu.enum_items.new("Material") - displacement_menu.inputs["Geometry"].default_value = 1.0 - displacement_menu.inputs["Material"].default_value = 0.0 - displacement_menu.location = (1150.0, 200.0) - links.new( - displacement_input.outputs["Displacement"], - displacement_menu.inputs["Menu"], - ) - displacement.default_value = "Geometry" - geometry_mode = displacement_menu.outputs[0] - material_mode = nodes.new("ShaderNodeMath") - material_mode.operation = "SUBTRACT" - material_mode.inputs[0].default_value = 1.0 - material_mode.location = (1350.0, 100.0) - links.new(geometry_mode, material_mode.inputs[1]) - - displacement_scale = nodes.new("ShaderNodeMath") - displacement_scale.operation = "MULTIPLY" - displacement_scale.location = (1200.0, 650.0) - links.new(masked_depth.outputs["Result"], displacement_scale.inputs[0]) - geometry_strength = nodes.new("ShaderNodeMath") - geometry_strength.operation = "MULTIPLY" - geometry_strength.location = (1150.0, 450.0) - links.new(geometry_mode, geometry_strength.inputs[0]) - links.new(displacement_input.outputs["Depth Scale"], geometry_strength.inputs[1]) - links.new(geometry_strength.outputs[0], displacement_scale.inputs[1]) - normal = nodes.new("GeometryNodeInputNormal") - normal.location = (1200.0, 950.0) - offset = nodes.new("ShaderNodeVectorMath") - offset.operation = "SCALE" - offset.location = (1400.0, 750.0) - links.new(normal.outputs["Normal"], offset.inputs[0]) - links.new(displacement_scale.outputs[0], offset.inputs[3]) - set_position = nodes.new("GeometryNodeSetPosition") - set_position.location = (1450.0, 650.0) - links.new(transform_grid.outputs["Geometry"], set_position.inputs["Geometry"]) - links.new(offset.outputs["Vector"], set_position.inputs["Offset"]) - - set_material = nodes.new("GeometryNodeSetMaterial") - set_material.location = (1650.0, 650.0) - links.new(set_position.outputs["Geometry"], set_material.inputs["Geometry"]) - links.new(material_input.outputs["Material"], set_material.inputs["Material"]) - shade_smooth = nodes.new("GeometryNodeSetShadeSmooth") - shade_smooth.domain = "FACE" - shade_smooth.inputs["Shade Smooth"].default_value = True - shade_smooth.location = (1850.0, 650.0) - links.new(set_material.outputs["Geometry"], shade_smooth.inputs["Geometry"]) - attribute_values = ( - ( - MATERIAL_DISPLACEMENT_ATTRIBUTE, - "BOOLEAN", - material_mode.outputs[0], - ), - ( - BACKGROUND_DEPTH_ATTRIBUTE, - "FLOAT", - background_depth_value.outputs[0], - ), - ( - DEPTH_SCALE_ATTRIBUTE, - "FLOAT", - displacement_input.outputs["Depth Scale"], - ), - ) - geometry = shade_smooth.outputs["Geometry"] - x = 2100.0 - for name, data_type, value in attribute_values: - store = nodes.new("GeometryNodeStoreNamedAttribute") - store.data_type = data_type - store.domain = "POINT" - store.inputs["Name"].default_value = name - store.location = (x, 650.0) - links.new(geometry, store.inputs["Geometry"]) - links.new(value, store.inputs["Value"]) - geometry = store.outputs["Geometry"] - x += 200.0 - links.new(geometry, group_output.inputs["Geometry"]) - - group.color_tag = "GEOMETRY" - group.description = ( - "Build an aspect-adaptive DA3 depth grid, clamp its foreground, " - "and pass background and displacement controls to the material." - ) - group.use_fake_user = True - return group - - -def build_moge_depth_plane_group(): - group = bpy.data.node_groups.new( - MOGE_DEPTH_PLANE_GROUP_NAME, - "GeometryNodeTree", - ) - group.is_modifier = True - interface = group.interface - new_socket(interface, "Geometry", "INPUT", "NodeSocketGeometry") - displacement = new_socket( - interface, "Displacement", "INPUT", "NodeSocketMenu" - ) - subdivision = new_socket( - interface, "Subdivision", "INPUT", "NodeSocketInt" - ) - subdivision.default_value = 6 - subdivision.min_value = 0 - subdivision.max_value = 10 - depth_scale = new_socket( - interface, "Depth Scale", "INPUT", "NodeSocketFloat" - ) - depth_scale.default_value = 1.0 - depth_scale.structure_type = "SINGLE" - background_depth = new_socket( - interface, "Background Depth", "INPUT", "NodeSocketFloat" - ) - background_depth.default_value = 1.0 - background_depth.min_value = -10000.0 - background_depth.max_value = 10000.0 - background_depth.subtype = "DISTANCE" - background_depth.structure_type = "SINGLE" - data_panel = interface.new_panel(name="Data", default_closed=True) - depth_image = new_socket( - interface, "Depth Image", "INPUT", "NodeSocketImage", data_panel - ) - color_image = new_socket( - interface, "Color Image", "INPUT", "NodeSocketImage", data_panel - ) - material = new_socket( - interface, "Material", "INPUT", "NodeSocketMaterial", data_panel - ) - for socket in (depth_image, color_image, material): - socket.hide_in_modifier = True - frame_sockets = [] - for name, socket_type, default in ( - ("Frame", "NodeSocketInt", 1), - ("Frame Start", "NodeSocketInt", 1), - ("Frames", "NodeSocketInt", 1), - ("Cyclic", "NodeSocketBool", False), - ): - socket = new_socket(interface, name, "INPUT", socket_type, data_panel) - socket.default_value = default - socket.hide_in_modifier = True - frame_sockets.append(socket) - new_socket(interface, "Geometry", "OUTPUT", "NodeSocketGeometry") - - nodes = group.nodes - links = group.links - geometry_input = new_group_input(nodes, (-1600.0, 650.0), {"Geometry"}) - controls = new_group_input( - nodes, - (-1600.0, 150.0), - {"Subdivision", "Displacement", "Depth Scale", "Background Depth"}, - ) - background_depth_value = nodes.new("ShaderNodeMath") - background_depth_value.operation = "MULTIPLY" - background_depth_value.inputs[1].default_value = -1.0 - background_depth_value.location = (-1350.0, -150.0) - links.new( - controls.outputs["Background Depth"], - background_depth_value.inputs[0], - ) - images = new_group_input( - nodes, (-500.0, 1100.0), {"Depth Image", "Color Image"} - ) - frames = new_group_input( - nodes, - (-500.0, 1500.0), - {"Frame", "Frame Start", "Frames", "Cyclic"}, - ) - material_input = new_group_input(nodes, (900.0, 200.0), {"Material"}) - output = nodes.new("NodeGroupOutput") - output.location = (1700.0, 650.0) - - bounds = nodes.new("GeometryNodeBoundBox") - bounds.location = (-1400.0, 650.0) - links.new(geometry_input.outputs["Geometry"], bounds.inputs["Geometry"]) - dimensions = nodes.new("ShaderNodeVectorMath") - dimensions.operation = "SUBTRACT" - dimensions.location = (-1200.0, 650.0) - links.new(bounds.outputs["Max"], dimensions.inputs[0]) - links.new(bounds.outputs["Min"], dimensions.inputs[1]) - separate = nodes.new("ShaderNodeSeparateXYZ") - separate.location = (-1000.0, 650.0) - links.new(dimensions.outputs["Vector"], separate.inputs[0]) - segments = nodes.new("ShaderNodeMath") - segments.operation = "POWER" - segments.inputs[0].default_value = 2.0 - segments.location = (-1200.0, 100.0) - links.new(controls.outputs["Subdivision"], segments.inputs[1]) - vertices = nodes.new("ShaderNodeMath") - vertices.operation = "ADD" - vertices.inputs[1].default_value = 1.0 - vertices.location = (-1000.0, 100.0) - links.new(segments.outputs[0], vertices.inputs[0]) - grid = nodes.new("GeometryNodeMeshGrid") - grid.location = (-750.0, 650.0) - links.new(separate.outputs["X"], grid.inputs["Size X"]) - links.new(separate.outputs["Y"], grid.inputs["Size Y"]) - links.new(vertices.outputs[0], grid.inputs["Vertices X"]) - links.new(vertices.outputs[0], grid.inputs["Vertices Y"]) - store_uv = nodes.new("GeometryNodeStoreNamedAttribute") - store_uv.data_type = "FLOAT2" - store_uv.domain = "CORNER" - store_uv.inputs["Name"].default_value = "UVMap" - store_uv.location = (-500.0, 650.0) - links.new(grid.outputs["Mesh"], store_uv.inputs["Geometry"]) - links.new(grid.outputs["UV Map"], store_uv.inputs["Value"]) - - center = nodes.new("ShaderNodeVectorMath") - center.operation = "ADD" - center.location = (-1000.0, 900.0) - links.new(bounds.outputs["Min"], center.inputs[0]) - links.new(bounds.outputs["Max"], center.inputs[1]) - half = nodes.new("ShaderNodeVectorMath") - half.operation = "SCALE" - half.inputs[3].default_value = 0.5 - half.location = (-750.0, 900.0) - links.new(center.outputs["Vector"], half.inputs[0]) - transform = nodes.new("GeometryNodeTransform") - transform.location = (-250.0, 650.0) - links.new(store_uv.outputs["Geometry"], transform.inputs["Geometry"]) - links.new(half.outputs["Vector"], transform.inputs["Translation"]) - - depth_texture = nodes.new("GeometryNodeImageTexture") - depth_texture.interpolation = "Linear" - depth_texture.extension = "EXTEND" - depth_texture.location = (-250.0, 1100.0) - color_texture = nodes.new("GeometryNodeImageTexture") - color_texture.interpolation = "Closest" - color_texture.extension = "EXTEND" - color_texture.location = (-250.0, 850.0) - links.new(images.outputs["Depth Image"], depth_texture.inputs["Image"]) - links.new(images.outputs["Color Image"], color_texture.inputs["Image"]) - links.new(grid.outputs["UV Map"], depth_texture.inputs["Vector"]) - links.new(grid.outputs["UV Map"], color_texture.inputs["Vector"]) - frame = build_sequence_frame( - nodes, links, frames.outputs["Frame"], frames, 1800.0 - ) - links.new(frame, depth_texture.inputs["Frame"]) - links.new(frame, color_texture.inputs["Frame"]) - - clamp_depth = nodes.new("ShaderNodeMath") - clamp_depth.operation = "MAXIMUM" - clamp_depth.location = (0.0, 750.0) - links.new(depth_texture.outputs["Color"], clamp_depth.inputs[0]) - links.new(background_depth_value.outputs[0], clamp_depth.inputs[1]) - color_mask = new_compare( - nodes, - "FLOAT", - "GREATER_THAN", - (0.0, 1000.0), - 0.5, - ) - links.new( - color_texture.outputs["Alpha"], - input_socket(color_mask, "A", 0), - ) - masked_depth = nodes.new("ShaderNodeMix") - masked_depth.data_type = "FLOAT" - masked_depth.location = (200.0, 850.0) - links.new(color_mask.outputs["Result"], masked_depth.inputs[0]) - links.new(background_depth_value.outputs[0], masked_depth.inputs[2]) - links.new(clamp_depth.outputs[0], masked_depth.inputs[3]) - displacement_menu = nodes.new("GeometryNodeMenuSwitch") - displacement_menu.data_type = "FLOAT" - displacement_menu.enum_items.clear() - displacement_menu.enum_items.new("Geometry") - displacement_menu.enum_items.new("Material") - displacement_menu.inputs["Geometry"].default_value = 1.0 - displacement_menu.inputs["Material"].default_value = 0.0 - displacement_menu.location = (0.0, 300.0) - links.new(controls.outputs["Displacement"], displacement_menu.inputs["Menu"]) - displacement.default_value = "Geometry" - material_mode = nodes.new("ShaderNodeMath") - material_mode.operation = "SUBTRACT" - material_mode.inputs[0].default_value = 1.0 - material_mode.location = (250.0, 250.0) - links.new(displacement_menu.outputs[0], material_mode.inputs[1]) - geometry_strength = nodes.new("ShaderNodeMath") - geometry_strength.operation = "MULTIPLY" - geometry_strength.location = (250.0, 500.0) - links.new(displacement_menu.outputs[0], geometry_strength.inputs[0]) - links.new(controls.outputs["Depth Scale"], geometry_strength.inputs[1]) - scaled_depth = nodes.new("ShaderNodeMath") - scaled_depth.operation = "MULTIPLY" - scaled_depth.location = (250.0, 750.0) - links.new(masked_depth.outputs["Result"], scaled_depth.inputs[0]) - links.new(geometry_strength.outputs[0], scaled_depth.inputs[1]) - normal = nodes.new("GeometryNodeInputNormal") - normal.location = (0.0, 500.0) - offset = nodes.new("ShaderNodeVectorMath") - offset.operation = "SCALE" - offset.location = (250.0, 700.0) - links.new(normal.outputs["Normal"], offset.inputs[0]) - links.new(scaled_depth.outputs[0], offset.inputs[3]) - set_position = nodes.new("GeometryNodeSetPosition") - set_position.location = (500.0, 650.0) - links.new(transform.outputs["Geometry"], set_position.inputs["Geometry"]) - links.new(offset.outputs["Vector"], set_position.inputs["Offset"]) - - set_material = nodes.new("GeometryNodeSetMaterial") - set_material.location = (750.0, 650.0) - links.new(set_position.outputs["Geometry"], set_material.inputs["Geometry"]) - links.new(material_input.outputs["Material"], set_material.inputs["Material"]) - smooth = nodes.new("GeometryNodeSetShadeSmooth") - smooth.domain = "FACE" - smooth.inputs["Shade Smooth"].default_value = True - smooth.location = (1000.0, 650.0) - links.new(set_material.outputs["Geometry"], smooth.inputs["Geometry"]) - geometry = smooth.outputs["Geometry"] - for index, (name, data_type, value) in enumerate(( - (MATERIAL_DISPLACEMENT_ATTRIBUTE, "BOOLEAN", material_mode.outputs[0]), - (BACKGROUND_DEPTH_ATTRIBUTE, "FLOAT", background_depth_value.outputs[0]), - (DEPTH_SCALE_ATTRIBUTE, "FLOAT", controls.outputs["Depth Scale"]), - )): - store = nodes.new("GeometryNodeStoreNamedAttribute") - store.data_type = data_type - store.domain = "POINT" - store.inputs["Name"].default_value = name - store.location = (1200.0 + index * 200.0, 650.0) - links.new(geometry, store.inputs["Geometry"]) - links.new(value, store.inputs["Value"]) - geometry = store.outputs["Geometry"] - links.new(geometry, output.inputs["Geometry"]) - group.color_tag = "GEOMETRY" - group.description = "Build a MoGe-2 depth plane with geometry or material displacement." - group.use_fake_user = True - return group - - -def build_shadeless_group(): - group = bpy.data.node_groups.new(SHADELESS_GROUP_NAME, "ShaderNodeTree") - interface = group.interface - new_socket(interface, "Shader", "OUTPUT", "NodeSocketShader") - new_socket(interface, "Color", "INPUT", "NodeSocketColor") - nodes = group.nodes - links = group.links - group_input = nodes.new("NodeGroupInput") - group_input.location = (-900.0, -350.0) - group_output = nodes.new("NodeGroupOutput") - group_output.location = (850.0, 100.0) - - diffuse = nodes.new("ShaderNodeBsdfDiffuse") - diffuse.location = (250.0, -150.0) - emission = nodes.new("ShaderNodeEmission") - emission.location = (250.0, -400.0) - links.new(group_input.outputs["Color"], diffuse.inputs["Color"]) - links.new(group_input.outputs["Color"], emission.inputs["Color"]) - - light_path = nodes.new("ShaderNodeLightPath") - light_path.location = (-900.0, 150.0) - bounce_count = nodes.new("ShaderNodeMath") - bounce_count.operation = "SUBTRACT" - bounce_count.label = "Bounce Count" - bounce_count.location = (-500.0, -50.0) - links.new(light_path.outputs["Ray Depth"], bounce_count.inputs[0]) - links.new(light_path.outputs["Transmission Depth"], bounce_count.inputs[1]) - refracted = nodes.new("ShaderNodeMath") - refracted.operation = "SUBTRACT" - refracted.label = "Camera or Refracted" - refracted.inputs[0].default_value = 1.0 - refracted.location = (-250.0, -50.0) - links.new(bounce_count.outputs[0], refracted.inputs[1]) - - reflection_limit = nodes.new("ShaderNodeMath") - reflection_limit.operation = "SUBTRACT" - reflection_limit.label = "Limit Reflections" - reflection_limit.inputs[0].default_value = 2.0 - reflection_limit.location = (-650.0, 450.0) - links.new(light_path.outputs["Ray Depth"], reflection_limit.inputs[1]) - camera_reflected = nodes.new("ShaderNodeMath") - camera_reflected.operation = "MULTIPLY" - camera_reflected.label = "Camera Ray to Glossy" - camera_reflected.location = (-400.0, 400.0) - links.new(reflection_limit.outputs[0], camera_reflected.inputs[0]) - links.new(light_path.outputs["Is Glossy Ray"], camera_reflected.inputs[1]) - shadow_or_reflect = nodes.new("ShaderNodeMath") - shadow_or_reflect.operation = "MAXIMUM" - shadow_or_reflect.label = "Shadow or Reflection?" - shadow_or_reflect.location = (-100.0, 350.0) - links.new(camera_reflected.outputs[0], shadow_or_reflect.inputs[0]) - links.new(light_path.outputs["Is Shadow Ray"], shadow_or_reflect.inputs[1]) - shader_factor = nodes.new("ShaderNodeMath") - shader_factor.operation = "MAXIMUM" - shader_factor.label = "Shadow, Reflect or Refract?" - shader_factor.location = (150.0, 300.0) - links.new(shadow_or_reflect.outputs[0], shader_factor.inputs[0]) - links.new(refracted.outputs[0], shader_factor.inputs[1]) - - mix = nodes.new("ShaderNodeMixShader") - mix.location = (550.0, 100.0) - links.new(shader_factor.outputs[0], mix.inputs["Fac"]) - links.new(diffuse.outputs["BSDF"], mix.inputs[1]) - links.new(emission.outputs["Emission"], mix.inputs[2]) - links.new(mix.outputs["Shader"], group_output.inputs["Shader"]) - group.color_tag = "SHADER" - group.description = ( - "Display color without scene lighting while preserving shadows " - "and reflections." - ) - group.use_fake_user = True - return group - - -def main(): - bpy.ops.wm.read_factory_settings(use_empty=True) - bpy.context.preferences.filepaths.save_version = 0 - build_group() - moge_surface_group = build_group( - MOGE_SURFACE_GROUP_NAME, - include_confidence=False, - include_discontinuity=True, - include_mask=True, - discontinuity_name="Depth Continuity", - discontinuity_attribute="o_depth_discontinuity", - discontinuity_default=50.0, - ) - configure_moge_surface_group(moge_surface_group) - build_mesh_plane_group() - build_depth_plane_group() - build_moge_depth_plane_group() - build_shadeless_group() - OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) - bpy.ops.wm.save_as_mainfile(filepath=str(OUTPUT_PATH), check_existing=False) - - -if __name__ == "__main__": - main() diff --git a/tools/node_assets/build.py b/tools/node_assets/build.py new file mode 100644 index 0000000..0603a08 --- /dev/null +++ b/tools/node_assets/build.py @@ -0,0 +1,49 @@ +from pathlib import Path +import sys + +import bpy + + +PROJECT_ROOT = Path(__file__).parents[2] +sys.path.insert(0, str(Path(__file__).parent)) +sys.path.insert(0, str(PROJECT_ROOT / "src")) +sys.path.insert( + 0, + str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), +) + +from build_cutout import ( + CUTOUT_NODE_GROUP_NAMES, + build_cutout_modifier_group, + build_node_group, +) +from build_mesh_plane import build_mesh_plane_group +from build_shadeless import build_shadeless_group +from anyimage.operators.common.node_graph import expand_smooth + + +OUTPUT_PATH = ( + PROJECT_ROOT + / "src" + / "anyimage" + / "assets" + / "O_AnyImage.blend" +) + + +def main(): + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.context.preferences.filepaths.save_version = 0 + build_mesh_plane_group() + build_shadeless_group() + shape_groups = { + shape: build_node_group(shape) + for shape in CUTOUT_NODE_GROUP_NAMES + } + build_cutout_modifier_group(shape_groups, expand_smooth) + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=str(OUTPUT_PATH), check_existing=False) + + +if __name__ == "__main__": + main() diff --git a/src/anyimage/operators/cutout_nodes.py b/tools/node_assets/build_cutout.py similarity index 62% rename from src/anyimage/operators/cutout_nodes.py rename to tools/node_assets/build_cutout.py index 231f327..b141302 100644 --- a/src/anyimage/operators/cutout_nodes.py +++ b/tools/node_assets/build_cutout.py @@ -1,20 +1,15 @@ +"""Build the four Cutout Geometry Nodes groups stored in O_AnyImage.blend.""" + import math import bpy - -CUTOUT = "CUTOUT" -BALLOON = "BALLOON" -DEPTH_BALLOON = "DEPTH_BALLOON" -DEPTH_SURFACE = "DEPTH_SURFACE" -BALLOON_ATTRIBUTE = "o_balloon" -NODE_GROUP_VERSION = 18 -GROUP_NAMES = { - CUTOUT: "O Image Surface", - BALLOON: "O Image Balloon", - DEPTH_BALLOON: "O Image Depth Balloon", - DEPTH_SURFACE: "O Image Depth Surface", -} +from anyimage.operators.cutout_tool.shape_types import ( + BALLOON_ATTRIBUTE_NAME, + CUTOUT_NODE_GROUP_NAME, + CUTOUT_NODE_GROUP_NAMES, + CUTOUT_SHAPE_LABELS, +) def _socket(group, name, in_out, socket_type, parent=None): @@ -106,6 +101,14 @@ def _bool_input(group, name, default=False, parent=None): return socket +def _vector_input(group, name, default, subtype=None, parent=None): + socket = _socket(group, name, "INPUT", "NodeSocketVector", parent) + socket.default_value = default + if subtype is not None: + socket.subtype = subtype + return socket + + def _math(nodes, operation, location): node = nodes.new("ShaderNodeMath") node.operation = operation @@ -128,24 +131,12 @@ def _island_cleanup(group, geometry, cleanup, location): links.new(face_area.outputs["Area"], island_area.inputs["Value"]) links.new(mesh_island.outputs["Island Index"], island_area.inputs["Group ID"]) - statistics = nodes.new("GeometryNodeAttributeStatistic") - statistics.data_type = "FLOAT" - statistics.domain = "FACE" - statistics.location = (x + 220.0, y - 100.0) - links.new(geometry, statistics.inputs["Geometry"]) - links.new(face_area.outputs["Area"], statistics.inputs["Attribute"]) - reference_scale = _math(nodes, "MULTIPLY", (x + 450.0, y - 50.0)) - reference_scale.inputs[1].default_value = 4.0 / math.sqrt(3.0) - links.new(statistics.outputs["Median"], reference_scale.inputs[0]) - threshold = _math(nodes, "MULTIPLY", (x + 650.0, y - 50.0)) - links.new(reference_scale.outputs[0], threshold.inputs[0]) - links.new(cleanup, threshold.inputs[1]) - tiny = _math(nodes, "LESS_THAN", (x + 650.0, y + 200.0)) + tiny = _math(nodes, "LESS_THAN", (x + 450.0, y + 200.0)) links.new(island_area.outputs["Total"], tiny.inputs[0]) - links.new(threshold.outputs[0], tiny.inputs[1]) + links.new(cleanup, tiny.inputs[1]) delete = nodes.new("GeometryNodeDeleteGeometry") delete.domain = "FACE" - delete.location = (x + 900.0, y) + delete.location = (x + 700.0, y) links.new(geometry, delete.inputs["Geometry"]) links.new(tiny.outputs[0], delete.inputs["Selection"]) return delete.outputs["Geometry"] @@ -157,7 +148,7 @@ def _profile_field(group, geometry, location): x, y = location profile = nodes.new("GeometryNodeInputNamedAttribute") profile.data_type = "FLOAT" - profile.inputs["Name"].default_value = BALLOON_ATTRIBUTE + profile.inputs["Name"].default_value = BALLOON_ATTRIBUTE_NAME profile.location = (x, y + 250.0) profile_max = nodes.new("GeometryNodeAttributeStatistic") profile_max.data_type = "FLOAT" @@ -325,6 +316,7 @@ def _set_segmented_layer_x( repeat_output.location = (x + 2380.0, y + 100.0) repeat_input = nodes.new("GeometryNodeRepeatInput") repeat_input.pair_with_output(repeat_output) + repeat_output.repeat_items.new("GEOMETRY", "Geometry") repeat_output.repeat_items.new("GEOMETRY", "Sides") repeat_input.location = (x + 1560.0, y + 100.0) links.new(segment_limit.outputs[0], repeat_input.inputs["Iterations"]) @@ -422,65 +414,63 @@ def _remove_zero_thickness( return choose.outputs["Output"] -def _image_fields(group, depth_image, location): +def _position_field(group, depth_image, location): nodes = group.nodes links = group.links uv = nodes.new("GeometryNodeInputNamedAttribute") uv.data_type = "FLOAT_VECTOR" uv.inputs["Name"].default_value = "UVMap" uv.location = location - texture = nodes.new("GeometryNodeImageTexture") - texture.interpolation = "Linear" - texture.extension = "EXTEND" - texture.location = (location[0] + 220.0, location[1]) - links.new(depth_image, texture.inputs["Image"]) - links.new(uv.outputs["Attribute"], texture.inputs["Vector"]) - separate = nodes.new("ShaderNodeSeparateXYZ") - separate.location = (location[0] + 450.0, location[1]) - links.new(texture.outputs["Color"], separate.inputs["Vector"]) - return separate.outputs["X"], separate.outputs["Y"], texture.outputs["Alpha"] + position_texture = nodes.new("GeometryNodeImageTexture") + position_texture.interpolation = "Linear" + position_texture.extension = "EXTEND" + position_texture.location = (location[0] + 220.0, location[1]) + links.new(depth_image, position_texture.inputs["Image"]) + links.new(uv.outputs["Attribute"], position_texture.inputs["Vector"]) + return position_texture.outputs["Color"] -def _plane_x(group, base_plane, location): +def _plane_depth(group, direction, reference_depth, location): nodes = group.nodes links = group.links - object_info = nodes.new("GeometryNodeObjectInfo") - object_info.transform_space = "RELATIVE" - object_info.location = location - links.new(base_plane, object_info.inputs["Object"]) - plane_normal = nodes.new("FunctionNodeRotateVector") - plane_normal.inputs["Vector"].default_value = (0.0, 0.0, 1.0) - plane_normal.location = (location[0] + 220.0, location[1]) - links.new(object_info.outputs["Rotation"], plane_normal.inputs["Rotation"]) + normal = nodes.new("ShaderNodeVectorMath") + normal.operation = "NORMALIZE" + normal.location = location + links.new(direction, normal.inputs[0]) + normal_components = nodes.new("ShaderNodeSeparateXYZ") + normal_components.location = (location[0] + 220.0, location[1]) + links.new(normal.outputs["Vector"], normal_components.inputs["Vector"]) position = nodes.new("GeometryNodeInputPosition") - position.location = (location[0], location[1] + 250.0) - separate = nodes.new("ShaderNodeSeparateXYZ") - separate.location = (location[0] + 220.0, location[1] + 250.0) - links.new(position.outputs["Position"], separate.inputs["Vector"]) - zero_x = nodes.new("ShaderNodeCombineXYZ") - zero_x.location = (location[0] + 440.0, location[1] + 250.0) - links.new(separate.outputs["Y"], zero_x.inputs["Y"]) - links.new(separate.outputs["Z"], zero_x.inputs["Z"]) - point_dot = nodes.new("ShaderNodeVectorMath") - point_dot.operation = "DOT_PRODUCT" - point_dot.location = (location[0] + 660.0, location[1] + 250.0) - links.new(zero_x.outputs["Vector"], point_dot.inputs[0]) - links.new(plane_normal.outputs["Vector"], point_dot.inputs[1]) - offset_dot = nodes.new("ShaderNodeVectorMath") - offset_dot.operation = "DOT_PRODUCT" - offset_dot.location = (location[0] + 660.0, location[1]) - links.new(object_info.outputs["Location"], offset_dot.inputs[0]) - links.new(plane_normal.outputs["Vector"], offset_dot.inputs[1]) - numerator = _math(nodes, "SUBTRACT", (location[0] + 880.0, location[1] + 150.0)) - links.new(offset_dot.outputs["Value"], numerator.inputs[0]) - links.new(point_dot.outputs["Value"], numerator.inputs[1]) - normal_x = nodes.new("ShaderNodeSeparateXYZ") - normal_x.location = (location[0] + 440.0, location[1] - 100.0) - links.new(plane_normal.outputs["Vector"], normal_x.inputs["Vector"]) - divide = _math(nodes, "DIVIDE", (location[0] + 1100.0, location[1] + 150.0)) - links.new(numerator.outputs[0], divide.inputs[0]) - links.new(normal_x.outputs["X"], divide.inputs[1]) - return divide.outputs[0] + position.location = (location[0], location[1] + 450.0) + position_components = nodes.new("ShaderNodeSeparateXYZ") + position_components.location = (location[0] + 220.0, location[1] + 450.0) + links.new(position.outputs["Position"], position_components.inputs["Vector"]) + direction_x = _math( + nodes, + "MULTIPLY", + (location[0] + 440.0, location[1] + 850.0), + ) + direction_x.inputs[1].default_value = -1.0 + links.new(normal_components.outputs["X"], direction_x.inputs[0]) + y_term = _math(nodes, "MULTIPLY", (location[0] + 440.0, location[1] + 500.0)) + links.new(position_components.outputs["Y"], y_term.inputs[0]) + links.new(direction_x.outputs[0], y_term.inputs[1]) + z_term = _math(nodes, "MULTIPLY", (location[0] + 440.0, location[1] + 250.0)) + links.new(position_components.outputs["Z"], z_term.inputs[0]) + links.new(normal_components.outputs["Y"], z_term.inputs[1]) + point_dot = _math(nodes, "ADD", (location[0] + 660.0, location[1] + 400.0)) + links.new(y_term.outputs[0], point_dot.inputs[0]) + links.new(z_term.outputs[0], point_dot.inputs[1]) + local_x = _math(nodes, "MULTIPLY", (location[0] + 660.0, location[1])) + local_x.inputs[1].default_value = -1.0 + links.new(normal_components.outputs["Z"], local_x.inputs[0]) + divide = _math(nodes, "DIVIDE", (location[0] + 880.0, location[1] + 200.0)) + links.new(point_dot.outputs[0], divide.inputs[0]) + links.new(local_x.outputs[0], divide.inputs[1]) + plane = _math(nodes, "SUBTRACT", (location[0] + 1100.0, location[1] + 200.0)) + links.new(reference_depth, plane.inputs[0]) + links.new(divide.outputs[0], plane.inputs[1]) + return plane.outputs[0] def _shade_output(group, geometry, location): @@ -551,7 +541,8 @@ def _build_depth_balloon( thickness, depth_scale, double_sided, - base_plane, + uniform_scale, + depth_direction, depth_image, depth_reference, ): @@ -560,26 +551,31 @@ def _build_depth_balloon( profile, profile_factor = _profile_field( group, geometry, (-200.0, 250.0) ) - sampled_depth, _auxiliary, _alpha = _image_fields( - group, depth_image, (200.0, -250.0) + camera_position = _position_field(group, depth_image, (200.0, -250.0)) + separate_camera = nodes.new("ShaderNodeSeparateXYZ") + separate_camera.location = (650.0, -250.0) + links.new(camera_position, separate_camera.inputs["Vector"]) + metric_depth = _math(nodes, "MULTIPLY", (900.0, -250.0)) + links.new(separate_camera.outputs["Z"], metric_depth.inputs[0]) + links.new(uniform_scale, metric_depth.inputs[1]) + plane_depth = _plane_depth( + group, + depth_direction, + depth_reference, + (600.0, -850.0), ) - depth_distance = _math(nodes, "MULTIPLY", (850.0, -250.0)) - links.new(sampled_depth, depth_distance.inputs[0]) - links.new(depth_reference, depth_distance.inputs[1]) - calibrated = _math(nodes, "MULTIPLY", (1050.0, -250.0)) - calibrated.inputs[1].default_value = 0.5 - links.new(depth_distance.outputs[0], calibrated.inputs[0]) - depth_base = _math(nodes, "ADD", (1250.0, -200.0)) - links.new(calibrated.outputs[0], depth_base.inputs[0]) - links.new(depth_reference, depth_base.inputs[1]) - plane_x = _plane_x(group, base_plane, (200.0, -650.0)) - corrected_depth = _math(nodes, "SUBTRACT", (1450.0, -150.0)) - links.new(plane_x, corrected_depth.inputs[0]) - links.new(depth_base.outputs[0], corrected_depth.inputs[1]) - scaled_depth = _math(nodes, "MULTIPLY", (1650.0, -150.0)) - links.new(corrected_depth.outputs[0], scaled_depth.inputs[0]) + corrected_depth = _math(nodes, "SUBTRACT", (1750.0, -250.0)) + links.new(plane_depth, corrected_depth.inputs[0]) + links.new(metric_depth.outputs[0], corrected_depth.inputs[1]) + nonnegative_depth = _math(nodes, "MAXIMUM", (1950.0, -250.0)) + nonnegative_depth.inputs[1].default_value = 0.0 + links.new(corrected_depth.outputs[0], nonnegative_depth.inputs[0]) + scaled_depth = _math(nodes, "MULTIPLY", (2150.0, -250.0)) + links.new(nonnegative_depth.outputs[0], scaled_depth.inputs[0]) links.new(depth_scale, scaled_depth.inputs[1]) - + front_depth = _math(nodes, "MULTIPLY", (2350.0, -250.0)) + front_depth.inputs[1].default_value = -1.0 + links.new(scaled_depth.outputs[0], front_depth.inputs[0]) base_front = _math(nodes, "MULTIPLY", (2400.0, 500.0)) links.new(profile, base_front.inputs[0]) links.new(thickness, base_front.inputs[1]) @@ -595,7 +591,7 @@ def _build_depth_balloon( front_mix.location = (2600.0, 50.0) links.new(profile_factor, front_mix.inputs["Factor"]) links.new(negate_front.outputs[0], front_mix.inputs["A"]) - links.new(scaled_depth.outputs[0], front_mix.inputs["B"]) + links.new(front_depth.outputs[0], front_mix.inputs["B"]) mirrored_front = _math(nodes, "MULTIPLY", (2850.0, -150.0)) mirrored_front.inputs[1].default_value = -1.0 links.new(front_mix.outputs["Result"], mirrored_front.inputs[0]) @@ -622,98 +618,89 @@ def _build_depth_balloon( ) -def _depth_continuity_threshold(group, continuity, location): - nodes = group.nodes - links = group.links - percent = _math(nodes, "MULTIPLY", location) - percent.inputs[1].default_value = 0.01 - links.new(continuity, percent.inputs[0]) - inverse = _math(nodes, "SUBTRACT", (location[0] + 200.0, location[1])) - inverse.inputs[0].default_value = 0.5 - links.new(percent.outputs[0], inverse.inputs[1]) - exponent = _math(nodes, "MULTIPLY", (location[0] + 400.0, location[1])) - exponent.inputs[1].default_value = 10.0 - links.new(inverse.outputs[0], exponent.inputs[0]) - power = _math(nodes, "POWER", (location[0] + 600.0, location[1])) - power.inputs[0].default_value = 2.0 - links.new(exponent.outputs[0], power.inputs[1]) - scale = _math(nodes, "MULTIPLY", (location[0] + 800.0, location[1])) - scale.inputs[1].default_value = 0.04 - links.new(power.outputs[0], scale.inputs[0]) - return scale.outputs[0] - - def _build_depth_surface( group, geometry, thickness, depth_scale, - continuity, - cleanup, + uniform_scale, + reference_depth, depth_image, - expand_smooth, ): nodes = group.nodes links = group.links - depth, discontinuity, _alpha = _image_fields( - group, depth_image, (-200.0, -200.0) - ) - threshold = _depth_continuity_threshold(group, continuity, (450.0, -250.0)) - jump = _math(nodes, "GREATER_THAN", (1450.0, -100.0)) - links.new(discontinuity, jump.inputs[0]) - links.new(threshold, jump.inputs[1]) - delete = nodes.new("GeometryNodeDeleteGeometry") - delete.domain = "FACE" - delete.location = (1650.0, 400.0) - links.new(geometry, delete.inputs["Geometry"]) - links.new(jump.outputs[0], delete.inputs["Selection"]) - geometry = _island_cleanup( - group, delete.outputs["Geometry"], cleanup, (1900.0, 0.0) - ) + camera_position = _position_field(group, depth_image, (-200.0, -200.0)) + scaled = nodes.new("ShaderNodeVectorMath") + scaled.operation = "SCALE" + scaled.location = (3100.0, -100.0) + links.new(camera_position, scaled.inputs[0]) + links.new(uniform_scale, scaled.inputs[3]) + separate = nodes.new("ShaderNodeSeparateXYZ") + separate.location = (3300.0, -100.0) + links.new(scaled.outputs["Vector"], separate.inputs["Vector"]) + relative_depth = _math(nodes, "SUBTRACT", (3500.0, -100.0)) + links.new(separate.outputs["Z"], relative_depth.inputs[0]) + links.new(reference_depth, relative_depth.inputs[1]) + front = _math(nodes, "MULTIPLY", (3700.0, -100.0)) + links.new(relative_depth.outputs[0], front.inputs[0]) + links.new(depth_scale, front.inputs[1]) - scaled = _math(nodes, "MULTIPLY", (2900.0, -100.0)) - links.new(depth, scaled.inputs[0]) - links.new(depth_scale, scaled.inputs[1]) - front = _math(nodes, "MULTIPLY", (3100.0, -100.0)) - front.inputs[1].default_value = -1.0 - links.new(scaled.outputs[0], front.inputs[0]) - back = _math(nodes, "ADD", (3300.0, -100.0)) + negative_x = _math(nodes, "MULTIPLY", (3500.0, -350.0)) + negative_x.inputs[1].default_value = -1.0 + links.new(separate.outputs["X"], negative_x.inputs[0]) + negative_y = _math(nodes, "MULTIPLY", (3500.0, -600.0)) + negative_y.inputs[1].default_value = -1.0 + links.new(separate.outputs["Y"], negative_y.inputs[0]) + projected = nodes.new("ShaderNodeCombineXYZ") + projected.location = (3700.0, -450.0) + links.new(negative_x.outputs[0], projected.inputs["Y"]) + links.new(negative_y.outputs[0], projected.inputs["Z"]) + current_position = nodes.new("GeometryNodeInputPosition") + current_position.location = (3500.0, -850.0) + blend = nodes.new("ShaderNodeMix") + blend.data_type = "VECTOR" + blend.clamp_factor = False + blend.location = (3900.0, -450.0) + links.new(depth_scale, blend.inputs[0]) + links.new(current_position.outputs["Position"], blend.inputs[4]) + links.new(projected.outputs["Vector"], blend.inputs[5]) + project_geometry = nodes.new("GeometryNodeSetPosition") + project_geometry.location = (4100.0, 400.0) + links.new(geometry, project_geometry.inputs["Geometry"]) + links.new(blend.outputs["Result"], project_geometry.inputs["Position"]) + + back = _math(nodes, "ADD", (3900.0, -100.0)) links.new(front.outputs[0], back.inputs[0]) links.new(thickness, back.inputs[1]) shaped = _set_segmented_layer_x( group, - geometry, + project_geometry.outputs["Geometry"], front.outputs[0], back.outputs[0], thickness, - (3500.0, 400.0), + (4400.0, 400.0), ) front_only = _set_geometry_x( - group, geometry, front.outputs[0], (3700.0, -200.0) + group, + project_geometry.outputs["Geometry"], + front.outputs[0], + (4600.0, -200.0), ) geometry = _remove_zero_thickness( - group, front_only, shaped, thickness, (7000.0, 400.0) + group, front_only, shaped, thickness, (7900.0, 400.0) ) - return expand_smooth(group, geometry, x_offset=7300.0) + return geometry -def node_group(shape, expand_smooth): - group_name = GROUP_NAMES[shape] - if shape == CUTOUT and bpy.data.node_groups.get(group_name) is None: - legacy = bpy.data.node_groups.get("O Image Cutout") - if legacy is not None: - legacy.name = group_name +def build_node_group(shape): + group_name = CUTOUT_NODE_GROUP_NAMES[shape] group = bpy.data.node_groups.get(group_name) - if group is not None and group.get("anyimage_cutout_version") == NODE_GROUP_VERSION: + if group is not None: return group - if group is None: - group = bpy.data.node_groups.new(group_name, "GeometryNodeTree") - else: - group.nodes.clear() - group.interface.clear() - group.is_modifier = True + group = bpy.data.node_groups.new(group_name, "GeometryNodeTree") + group.is_modifier = False _socket(group, "Geometry", "INPUT", "NodeSocketGeometry") - if shape in {BALLOON, DEPTH_BALLOON}: + if shape in {"BALLOON", "DEPTH_BALLOON"}: thickness = _float_input(group, "Thickness", 1.0) thickness.description = ( "Multiplier for the injected Poisson Balloon profile; zero keeps " @@ -724,69 +711,71 @@ def node_group(shape, expand_smooth): group, "Thickness", 0.0, 0.0, None, subtype="DISTANCE" ) thickness.description = "Final thickness in scene distance" + depth_image = None - if shape == DEPTH_BALLOON: + if shape == "DEPTH_BALLOON": depth_scale = _float_input(group, "Depth Scale", 1.0) - depth_scale.description = "Scale the Base Plane-corrected image depth" + depth_scale.description = "Scale the fitted-plane-corrected metric depth" double_sided = _bool_input(group, "Double Sided", False) double_sided.description = ( "Mirror the complete front surface across the local YZ plane" ) - depth_reference = _float_input( + elif shape == "DEPTH_SURFACE": + depth_scale = _float_input(group, "Depth Scale", 1.0) + + if shape == "DEPTH_BALLOON": + options = group.interface.new_panel(name="Options", default_closed=True) + reference_depth = _float_input( group, - "Depth Reference", - 0.25, + "Reference Depth", + 1.0, 0.0, None, subtype="DISTANCE", + parent=options, + ) + reference_depth.description = ( + "Shared upper-percentile metric depth for outward placement" + ) + depth_direction = _vector_input( + group, + "Depth Direction", + (0.0, 0.0, 1.0), + subtype="DIRECTION", + parent=options, + ) + depth_direction.description = ( + "Normal direction of the fitted metric depth plane" + ) + elif shape == "DEPTH_SURFACE": + options = group.interface.new_panel(name="Options", default_closed=True) + reference_depth = _float_input( + group, + "Reference Depth", + 1.0, + 0.0, + None, + subtype="DISTANCE", + parent=options, + ) + reference_depth.description = ( + "Scaled camera depth placed on the original image sheet" ) - depth_reference.description = "Internal scale for image depth" - depth_reference.hide_in_modifier = True - depth_image = _socket(group, "Depth Image", "INPUT", "NodeSocketImage") - depth_image.description = "Internally generated Depth Balloon data" - depth_image.hide_in_modifier = True - elif shape == DEPTH_SURFACE: - depth_scale = _float_input(group, "Depth Scale", 1.0) - iterations = _socket(group, "Iterations", "INPUT", "NodeSocketInt") - iterations.default_value = 1 - iterations.min_value = 0 - iterations.max_value = 20 - depth_image = _socket(group, "Depth Image", "INPUT", "NodeSocketImage") - depth_image.description = "Internally generated Depth Surface data" - depth_image.hide_in_modifier = True - options = group.interface.new_panel(name="Options", default_closed=True) - cleanup = _float_input(group, "Cleanup", 1.0, 0.0, 100.0, parent=options) - cleanup.description = ( - "Remove disconnected islands smaller than this multiple of the " - "estimated point-spacing area" - ) - if shape == DEPTH_BALLOON: - base_plane = _socket( - group, "Base Plane", "INPUT", "NodeSocketObject", options + if shape in {"DEPTH_BALLOON", "DEPTH_SURFACE"}: + data = group.interface.new_panel(name="Data", default_closed=True) + uniform_scale = _float_input( + group, "Uniform Scale", 1.0, 0.0, parent=data ) - base_plane.description = ( - "Fitted depth plane; its arrow points in the deformation direction" + uniform_scale.description = ( + "Internal uniform conversion from MoGe meters to local distance" ) - elif shape == DEPTH_SURFACE: - continuity = _float_input( - group, - "Depth Continuity", - 0.0, - 0.0, - 100.0, - subtype="PERCENTAGE", - parent=options, - ) - smooth = _float_input( - group, - "Smooth", - 1.0, - 0.0, - 1.0, - subtype="FACTOR", - parent=options, + uniform_scale.hide_in_modifier = True + depth_image = _socket( + group, "Depth Image", "INPUT", "NodeSocketImage", data ) + depth_image.description = f"Internally generated {CUTOUT_SHAPE_LABELS[shape]} data" + depth_image.hide_in_modifier = True _socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") controls = _group_input( @@ -794,42 +783,37 @@ def node_group(shape, expand_smooth): (-1600.0, 500.0), { "Geometry", - "Cleanup", "Thickness", "Double Sided", - "Base Plane", "Depth Image", "Depth Scale", - "Depth Continuity", - "Depth Reference", + "Uniform Scale", + "Reference Depth", + "Depth Direction", }, ) - geometry = _island_cleanup( - group, - controls.outputs["Geometry"], - controls.outputs["Cleanup"], - (-1300.0, 300.0), - ) - if shape == CUTOUT: + geometry = controls.outputs["Geometry"] + if shape == "CUTOUT": geometry = _build_cutout(group, geometry, controls.outputs["Thickness"]) output_location = (4050.0, 300.0) - elif shape == BALLOON: + elif shape == "BALLOON": geometry = _build_balloon( group, geometry, controls.outputs["Thickness"], ) output_location = (4650.0, 650.0) - elif shape == DEPTH_BALLOON: + elif shape == "DEPTH_BALLOON": geometry = _build_depth_balloon( group, geometry, controls.outputs["Thickness"], controls.outputs["Depth Scale"], controls.outputs["Double Sided"], - controls.outputs["Base Plane"], + controls.outputs["Uniform Scale"], + controls.outputs["Depth Direction"], controls.outputs["Depth Image"], - controls.outputs["Depth Reference"], + controls.outputs["Reference Depth"], ) output_location = (5100.0, 600.0) else: @@ -838,26 +822,183 @@ def node_group(shape, expand_smooth): geometry, controls.outputs["Thickness"], controls.outputs["Depth Scale"], - controls.outputs["Depth Continuity"], - controls.outputs["Cleanup"], + controls.outputs["Uniform Scale"], + controls.outputs["Reference Depth"], controls.outputs["Depth Image"], - expand_smooth, ) - output_location = (10700.0, 400.0) + output_location = (8200.0, 400.0) _localize_group_inputs(group, controls) _shade_output(group, geometry, output_location) group.color_tag = "GEOMETRY" group.description = { - CUTOUT: "Clean and symmetrically thicken a Surface sheet.", - BALLOON: "Inflate a Surface sheet from its boundary profile.", - DEPTH_BALLOON: ( - "Mix Balloon profile and hidden image depth around a Base Plane." + "CUTOUT": "Symmetrically thicken a Surface sheet.", + "BALLOON": "Inflate a Surface sheet from its boundary profile.", + "DEPTH_BALLOON": ( + "Mix Balloon profile and metric image depth around a fitted plane." ), - DEPTH_SURFACE: ( - "Displace a Surface sheet from hidden image depth, cull, " - "thicken, and smooth." + "DEPTH_SURFACE": ( + "Project and thicken a Surface sheet from hidden camera points." ), }[shape] - group["anyimage_cutout_version"] = NODE_GROUP_VERSION + group.use_fake_user = True + return group + + +def build_cutout_modifier_group(shape_groups, expand_smooth): + group = bpy.data.node_groups.get(CUTOUT_NODE_GROUP_NAME) + if group is not None: + return group + group = bpy.data.node_groups.new(CUTOUT_NODE_GROUP_NAME, "GeometryNodeTree") + group.is_modifier = True + _socket(group, "Geometry", "INPUT", "NodeSocketGeometry") + _socket(group, "Shape", "INPUT", "NodeSocketMenu") + + thickness = _float_input(group, "Thickness", 0.0, 0.0) + thickness.description = ( + "Scene thickness for Surface shapes or Balloon profile multiplier" + ) + depth_scale = _float_input(group, "Depth Scale", 1.0, 0.0) + depth_scale.description = "Scale metric image depth" + double_sided = _bool_input(group, "Double Sided", False) + double_sided.description = ( + "Mirror the complete Depth Balloon front surface across the local YZ plane" + ) + + iterations = _socket( + group, "Smooth Iterations", "INPUT", "NodeSocketInt" + ) + iterations.default_value = 0 + iterations.min_value = 0 + iterations.max_value = 20 + iterations.description = "Number of smoothing passes" + options = group.interface.new_panel(name="Options", default_closed=True) + cleanup = _float_input( + group, + "Cleanup", + 0.0, + 0.0, + None, + parent=options, + ) + cleanup.description = ( + "Delete mesh islands below this absolute local-area threshold" + ) + smooth = _float_input( + group, + "Smooth", + 1.0, + 0.0, + 1.0, + subtype="FACTOR", + parent=options, + ) + smooth.description = "Blend each smoothing pass into the selected shape" + + reference_depth = _float_input( + group, + "Reference Depth", + 1.0, + 0.0, + None, + subtype="DISTANCE", + parent=options, + ) + reference_depth.description = ( + "Metric reference depth shared by both Depth shapes" + ) + depth_direction = _vector_input( + group, + "Depth Direction", + (0.0, 0.0, 1.0), + subtype="DIRECTION", + parent=options, + ) + depth_direction.description = "Normal direction of the fitted metric depth plane" + + + data = group.interface.new_panel(name="Data", default_closed=True) + uniform_scale = _float_input( + group, "Uniform Scale", 1.0, 0.0, parent=data + ) + uniform_scale.description = ( + "Internal uniform conversion from MoGe meters to local distance" + ) + uniform_scale.hide_in_modifier = True + depth_image = _socket( + group, "Depth Image", "INPUT", "NodeSocketImage", data + ) + depth_image.description = "Internally generated Cutout depth data" + depth_image.hide_in_modifier = True + _socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") + + base_controls = _group_input( + group.nodes, + (-2700.0, 500.0), + {"Geometry", "Cleanup"}, + ) + geometry = _island_cleanup( + group, + base_controls.outputs["Geometry"], + base_controls.outputs["Cleanup"], + (-2350.0, 250.0), + ) + + branch_y = { + "CUTOUT": 1150.0, + "BALLOON": 550.0, + "DEPTH_BALLOON": -50.0, + "DEPTH_SURFACE": -650.0, + } + branch_nodes = {} + for shape, y in branch_y.items(): + node = group.nodes.new("GeometryNodeGroup") + node.node_tree = shape_groups[shape] + node.location = (-850.0, y) + node.width = 220.0 + group.links.new(geometry, node.inputs["Geometry"]) + visible = { + socket.name + for socket in node.inputs + if socket.name and socket.name != "Geometry" + } + controls = _group_input(group.nodes, (-1200.0, y + 120.0), visible) + for socket in node.inputs: + if not socket.name or socket.name == "Geometry": + continue + group.links.new(controls.outputs[socket.name], socket) + branch_nodes[shape] = node + + menu_switch = group.nodes.new("GeometryNodeMenuSwitch") + menu_switch.data_type = "GEOMETRY" + shapes = list(CUTOUT_NODE_GROUP_NAMES) + for item, shape in zip(menu_switch.enum_items, shapes[:2]): + item.name = CUTOUT_SHAPE_LABELS[shape] + for shape in shapes[2:]: + menu_switch.enum_items.new(CUTOUT_SHAPE_LABELS[shape]) + menu_switch.location = (-350.0, 250.0) + + shape_controls = _group_input( + group.nodes, + (-700.0, -1200.0), + {"Shape"}, + ) + group.links.new(shape_controls.outputs["Shape"], menu_switch.inputs["Menu"]) + for shape, node in branch_nodes.items(): + group.links.new( + node.outputs["Geometry"], + menu_switch.inputs[CUTOUT_SHAPE_LABELS[shape]], + ) + + geometry = expand_smooth( + group, + menu_switch.outputs["Output"], + iterations_name="Smooth Iterations", + ) + _shade_output(group, geometry, (3350.0, 550.0)) + group.color_tag = "GEOMETRY" + group.description = ( + "Build Surface, Balloon, Depth Balloon, or Depth Surface from one " + "menu-controlled Cutout modifier." + ) group.use_fake_user = True return group diff --git a/tools/mesh_plane_asset.py b/tools/node_assets/build_mesh_plane.py similarity index 99% rename from tools/mesh_plane_asset.py rename to tools/node_assets/build_mesh_plane.py index db08827..10661f5 100644 --- a/tools/mesh_plane_asset.py +++ b/tools/node_assets/build_mesh_plane.py @@ -1,3 +1,5 @@ +"""Build the Mesh Plane Geometry Nodes group.""" + import bpy diff --git a/tools/node_assets/build_shadeless.py b/tools/node_assets/build_shadeless.py new file mode 100644 index 0000000..d68db3c --- /dev/null +++ b/tools/node_assets/build_shadeless.py @@ -0,0 +1,77 @@ +"""Build the Shadeless shader node group.""" + +import bpy + + +SHADELESS_GROUP_NAME = "O Shadeless" + + +def build_shadeless_group(): + group = bpy.data.node_groups.new(SHADELESS_GROUP_NAME, "ShaderNodeTree") + group.interface.new_socket( + name="Shader", in_out="OUTPUT", socket_type="NodeSocketShader" + ) + group.interface.new_socket( + name="Color", in_out="INPUT", socket_type="NodeSocketColor" + ) + nodes = group.nodes + links = group.links + group_input = nodes.new("NodeGroupInput") + group_input.location = (-900.0, -350.0) + group_output = nodes.new("NodeGroupOutput") + group_output.location = (850.0, 100.0) + + diffuse = nodes.new("ShaderNodeBsdfDiffuse") + diffuse.location = (250.0, -150.0) + emission = nodes.new("ShaderNodeEmission") + emission.location = (250.0, -400.0) + links.new(group_input.outputs["Color"], diffuse.inputs["Color"]) + links.new(group_input.outputs["Color"], emission.inputs["Color"]) + + light_path = nodes.new("ShaderNodeLightPath") + light_path.location = (-900.0, 150.0) + bounce_count = nodes.new("ShaderNodeMath") + bounce_count.operation = "SUBTRACT" + bounce_count.location = (-500.0, -50.0) + links.new(light_path.outputs["Ray Depth"], bounce_count.inputs[0]) + links.new(light_path.outputs["Transmission Depth"], bounce_count.inputs[1]) + refracted = nodes.new("ShaderNodeMath") + refracted.operation = "SUBTRACT" + refracted.inputs[0].default_value = 1.0 + refracted.location = (-250.0, -50.0) + links.new(bounce_count.outputs[0], refracted.inputs[1]) + + reflection_limit = nodes.new("ShaderNodeMath") + reflection_limit.operation = "SUBTRACT" + reflection_limit.inputs[0].default_value = 2.0 + reflection_limit.location = (-650.0, 450.0) + links.new(light_path.outputs["Ray Depth"], reflection_limit.inputs[1]) + camera_reflected = nodes.new("ShaderNodeMath") + camera_reflected.operation = "MULTIPLY" + camera_reflected.location = (-400.0, 400.0) + links.new(reflection_limit.outputs[0], camera_reflected.inputs[0]) + links.new(light_path.outputs["Is Glossy Ray"], camera_reflected.inputs[1]) + shadow_or_reflect = nodes.new("ShaderNodeMath") + shadow_or_reflect.operation = "MAXIMUM" + shadow_or_reflect.location = (-100.0, 350.0) + links.new(camera_reflected.outputs[0], shadow_or_reflect.inputs[0]) + links.new(light_path.outputs["Is Shadow Ray"], shadow_or_reflect.inputs[1]) + shader_factor = nodes.new("ShaderNodeMath") + shader_factor.operation = "MAXIMUM" + shader_factor.location = (150.0, 300.0) + links.new(shadow_or_reflect.outputs[0], shader_factor.inputs[0]) + links.new(refracted.outputs[0], shader_factor.inputs[1]) + + mix = nodes.new("ShaderNodeMixShader") + mix.location = (550.0, 100.0) + links.new(shader_factor.outputs[0], mix.inputs["Fac"]) + links.new(diffuse.outputs["BSDF"], mix.inputs[1]) + links.new(emission.outputs["Emission"], mix.inputs[2]) + links.new(mix.outputs["Shader"], group_output.inputs["Shader"]) + group.color_tag = "SHADER" + group.description = ( + "Display color without scene lighting while preserving shadows " + "and reflections." + ) + group.use_fake_user = True + return group diff --git a/tools/node_assets/validate.py b/tools/node_assets/validate.py new file mode 100644 index 0000000..25def21 --- /dev/null +++ b/tools/node_assets/validate.py @@ -0,0 +1,79 @@ +from pathlib import Path +import sys + +import bpy + + +PROJECT_ROOT = Path(__file__).parents[2] +sys.path.insert(0, str(PROJECT_ROOT / "src")) +sys.path.insert( + 0, + str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), +) + +from anyimage.operators.common.node_asset import ( + load_asset_node_group, + load_mesh_plane_node_group, + load_shadeless_node_group, +) +from anyimage.operators.cutout_tool.shape_types import ( + CUTOUT_NODE_GROUP_NAME, + CUTOUT_NODE_GROUP_NAMES, +) + + +def input_names(group): + return [ + item.name + for item in group.interface.items_tree + if item.item_type == "SOCKET" and item.in_out == "INPUT" + ] + + +def main(): + bpy.ops.wm.read_factory_settings(use_empty=True) + mesh_plane = load_mesh_plane_node_group() + shadeless = load_shadeless_node_group() + cutout = load_asset_node_group(CUTOUT_NODE_GROUP_NAME) + cutout_groups = { + name: bpy.data.node_groups[name] + for name in CUTOUT_NODE_GROUP_NAMES.values() + } + + assert mesh_plane.name == "O Mesh Plane" + assert mesh_plane.bl_idname == "GeometryNodeTree" + assert mesh_plane.is_modifier + assert input_names(mesh_plane) == [ + "Geometry", + "Subdivide", + "Thickness", + "Material", + ] + assert shadeless.name == "O Shadeless" + assert shadeless.bl_idname == "ShaderNodeTree" + assert input_names(shadeless) == ["Color"] + + assert cutout.name == CUTOUT_NODE_GROUP_NAME + assert cutout.bl_idname == "GeometryNodeTree" + assert cutout.is_modifier + assert input_names(cutout)[:3] == ["Geometry", "Shape", "Thickness"] + assert set(cutout_groups) == set(CUTOUT_NODE_GROUP_NAMES.values()) + for group in cutout_groups.values(): + assert group.bl_idname == "GeometryNodeTree" + assert not group.is_modifier + assert input_names(group)[0] == "Geometry" + + public_groups = {mesh_plane, shadeless, cutout} + loaded = {*public_groups, *cutout_groups.values()} + assert all(group.use_fake_user for group in public_groups) + assert all(group.users > 0 for group in cutout_groups.values()) + if bpy.app.version >= (5, 0, 0): + assert all(group.library is not None for group in loaded) + print( + f"Validated Blender {bpy.app.version_string}: " + + ", ".join(sorted(group.name for group in loaded)) + ) + + +if __name__ == "__main__": + main() diff --git a/tools/node_assets/validate_cutout.py b/tools/node_assets/validate_cutout.py new file mode 100644 index 0000000..084a732 --- /dev/null +++ b/tools/node_assets/validate_cutout.py @@ -0,0 +1,430 @@ +from pathlib import Path +import sys + +import bpy + + +PROJECT_ROOT = Path(__file__).parents[2] +sys.path.insert(0, str(PROJECT_ROOT / "src")) +sys.path.insert( + 0, + str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), +) + +from anyimage.operators.cutout_tool import node_assets as cutout_nodes +from anyimage.operators.cutout_tool import shape_types as shapes +from anyimage.operators.common.node_asset import ( + modifier_input_identifier, + set_modifier_input, +) + + +def evaluated_mesh(obj): + evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + result = evaluated.to_mesh() + return evaluated, result + + +def input_names(group): + return [ + item.name + for item in group.interface.items_tree + if item.item_type == "SOCKET" and item.in_out == "INPUT" + ] + + +def grid_object(name): + vertices = [ + (0.0, float(y), float(z)) + for z in range(3) + for y in range(3) + ] + faces = [] + for z in range(2): + for y in range(2): + a = z * 3 + y + faces.extend(((a, a + 4, a + 1), (a, a + 3, a + 4))) + data = bpy.data.meshes.new(name) + data.from_pydata(vertices, (), faces) + uv_layer = data.uv_layers.new(name="UVMap") + for loop in data.loops: + coordinate = data.vertices[loop.vertex_index].co + uv_layer.data[loop.index].uv = (coordinate.y * 0.5, coordinate.z * 0.5) + obj = bpy.data.objects.new(name, data) + bpy.context.scene.collection.objects.link(obj) + return obj + + +def add_modifier(obj, shape, thickness): + group = cutout_nodes.cutout_node_group(shape) + modifier = obj.modifiers.new(group.name, "NODES") + modifier.node_group = group + for name, value in ( + ("Shape", shapes.CUTOUT_SHAPE_INDICES[shape]), + ("Cleanup", 0.0), + ("Smooth", 0.0), + ("Smooth Iterations", 0), + ("Thickness", thickness), + ): + set_modifier_input( + modifier, + modifier_input_identifier(group, name), + value, + ) + return modifier + + +def main(): + bpy.ops.wm.read_factory_settings(use_empty=True) + wrapper = cutout_nodes.cutout_node_group("CUTOUT") + assert all( + cutout_nodes.cutout_node_group(shape) == wrapper + for shape in shapes.CUTOUT_SHAPES + ) + assert wrapper.name == shapes.CUTOUT_NODE_GROUP_NAME + assert wrapper.is_modifier + groups = { + shape: bpy.data.node_groups[name] + for shape, name in shapes.CUTOUT_NODE_GROUP_NAMES.items() + } + assert all(not group.is_modifier for group in groups.values()) + + menu_switch = next( + node for node in wrapper.nodes + if node.bl_idname == "GeometryNodeMenuSwitch" + ) + assert [item.name for item in menu_switch.enum_items] == list( + shapes.CUTOUT_SHAPE_LABELS.values() + ) + assert any( + link.from_socket.name == "Shape" + and link.to_node == menu_switch + and link.to_socket.name == "Menu" + for link in wrapper.links + ) + child_nodes = { + node.node_tree: node + for node in wrapper.nodes + if node.bl_idname == "GeometryNodeGroup" + } + assert set(child_nodes) == set(groups.values()) + for shape, group in groups.items(): + assert any( + link.from_node == child_nodes[group] + and link.to_node == menu_switch + and link.to_socket.name == shapes.CUTOUT_SHAPE_LABELS[shape] + for link in wrapper.links + ) + + assert input_names(wrapper) == [ + "Geometry", + "Shape", + "Thickness", + "Depth Scale", + "Double Sided", + "Smooth Iterations", + "Cleanup", + "Smooth", + "Reference Depth", + "Depth Direction", + "Uniform Scale", + "Depth Image", + ] + expected_inputs = { + "CUTOUT": ["Geometry", "Thickness"], + "BALLOON": ["Geometry", "Thickness"], + "DEPTH_BALLOON": [ + "Geometry", + "Thickness", + "Depth Scale", + "Double Sided", + "Reference Depth", + "Depth Direction", + "Uniform Scale", + "Depth Image", + ], + "DEPTH_SURFACE": [ + "Geometry", + "Thickness", + "Depth Scale", + "Reference Depth", + "Uniform Scale", + "Depth Image", + ], + } + for shape, group in groups.items(): + assert input_names(group) == expected_inputs[shape] + + dependencies = {} + for link in wrapper.links: + if link.from_node.bl_idname != "NodeGroupInput": + continue + if link.to_node.bl_idname != "GeometryNodeGroup": + continue + shape = next( + shape + for shape, group in groups.items() + if group == link.to_node.node_tree + ) + dependencies.setdefault(link.from_socket.name, set()).add(shape) + assert dependencies == { + "Thickness": set(shapes.CUTOUT_SHAPES), + "Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, + "Double Sided": {"DEPTH_BALLOON"}, + "Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, + "Depth Direction": {"DEPTH_BALLOON"}, + "Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, + "Depth Image": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, + } + + repeat_counts = { + shape: sum( + node.bl_idname == "GeometryNodeRepeatInput" + for node in group.nodes + ) + for shape, group in groups.items() + } + assert repeat_counts == { + "CUTOUT": 1, + "BALLOON": 0, + "DEPTH_BALLOON": 0, + "DEPTH_SURFACE": 1, + } + assert sum( + node.bl_idname == "GeometryNodeRepeatInput" + for node in wrapper.nodes + ) == 1 + assert sum( + node.bl_idname == "GeometryNodeInputMeshIsland" + for node in wrapper.nodes + ) == 1 + assert not any( + node.bl_idname == "GeometryNodeAttributeStatistic" + for node in wrapper.nodes + ) + assert all( + not any( + node.bl_idname == "GeometryNodeInputMeshIsland" + for node in group.nodes + ) + for group in groups.values() + ) + assert any( + node.bl_idname == "GeometryNodeBlurAttribute" + for node in wrapper.nodes + ) + smooth = next( + item + for item in wrapper.interface.items_tree + if item.item_type == "SOCKET" and item.name == "Smooth" + ) + iterations = next( + item + for item in wrapper.interface.items_tree + if item.item_type == "SOCKET" and item.name == "Smooth Iterations" + ) + cleanup = next( + item + for item in wrapper.interface.items_tree + if item.item_type == "SOCKET" and item.name == "Cleanup" + ) + assert cleanup.default_value == 0.0 + assert cleanup.subtype == "NONE" + assert smooth.default_value == 1.0 + assert iterations.default_value == 0 + assert iterations.min_value == 0 + assert getattr(iterations.parent, "name", "") == "" + assert all( + not any( + node.bl_idname == "GeometryNodeBlurAttribute" + for node in group.nodes + ) + for group in groups.values() + ) + + for group in (wrapper, *groups.values()): + assert all(not node.label for node in group.nodes) + assert all( + link.from_node.location.x <= link.to_node.location.x + for link in group.links + ) + group_nodes = list(group.nodes) + assert len({tuple(node.location) for node in group_nodes}) == len(group_nodes) + for index, node in enumerate(group_nodes): + for other in group_nodes[index + 1:]: + assert not ( + abs(node.location.x - other.location.x) < 150.0 + and abs(node.location.y - other.location.y) < 100.0 + ) + for group_input in ( + node for node in group_nodes if node.bl_idname == "NodeGroupInput" + ): + assert all( + output.is_linked or output.hide + for output in group_input.outputs + if output.name + ) + for item in group.interface.items_tree: + if item.item_type == "SOCKET" and item.in_out == "INPUT": + assert item.structure_type == "SINGLE" + surface = grid_object("Surface Validation") + add_modifier(surface, "CUTOUT", 2.4) + evaluated, result = evaluated_mesh(surface) + try: + assert len(result.vertices) == 34 + assert len(result.polygons) == 40 + side_faces = [ + polygon for polygon in result.polygons + if abs(polygon.normal.x) < 0.1 + ] + assert len(side_faces) == 24 + finally: + evaluated.to_mesh_clear() + + balloon = grid_object("Balloon Validation") + profile = balloon.data.attributes.new( + shapes.BALLOON_ATTRIBUTE_NAME, "FLOAT", "POINT" + ) + profile.data.foreach_set( + "value", + (0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0), + ) + modifier = add_modifier(balloon, "BALLOON", 1.0) + evaluated, result = evaluated_mesh(balloon) + try: + initial_faces = len(result.polygons) + initial_extent = max(abs(vertex.co.x) for vertex in result.vertices) + finally: + evaluated.to_mesh_clear() + set_modifier_input( + modifier, + modifier_input_identifier(modifier.node_group, "Thickness"), + 2.0, + ) + balloon.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + evaluated, result = evaluated_mesh(balloon) + try: + assert len(result.polygons) == initial_faces + extent = max(abs(vertex.co.x) for vertex in result.vertices) + assert abs(extent - initial_extent * 2.0) < 1e-5 + finally: + evaluated.to_mesh_clear() + + depth_balloon = grid_object("Depth Balloon Metric Validation") + profile = depth_balloon.data.attributes.new( + shapes.BALLOON_ATTRIBUTE_NAME, "FLOAT", "POINT" + ) + profile.data.foreach_set("value", (1.0,) * 9) + modifier = add_modifier(depth_balloon, "DEPTH_BALLOON", 0.0) + point_image = bpy.data.images.new( + "Depth Balloon Points", + width=3, + height=3, + alpha=True, + float_buffer=True, + ) + point_image.colorspace_settings.name = "Non-Color" + point_image.alpha_mode = "CHANNEL_PACKED" + pixels = [] + for z in range(3): + for y in range(3): + if (y, z) == (1, 1): + depth = 1.5 + elif (y, z) == (0, 0): + depth = 2.5 + else: + depth = 2.0 + pixels.extend((0.0, 0.0, depth, 0.0)) + point_image.pixels.foreach_set(pixels) + for name, value in ( + ("Depth Image", point_image), + ("Depth Scale", 1.0), + ("Uniform Scale", 1.0), + ("Reference Depth", 2.0), + ("Depth Direction", (0.0, 0.0, 1.0)), + ): + set_modifier_input( + modifier, + modifier_input_identifier(modifier.node_group, name), + value, + ) + depth_balloon.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + evaluated, result = evaluated_mesh(depth_balloon) + try: + x_values = [vertex.co.x for vertex in result.vertices] + assert abs(min(x_values) + 0.5) < 1e-5 + assert abs(max(x_values)) < 1e-5 + finally: + evaluated.to_mesh_clear() + + pixels[(1 * 3 + 1) * 4 + 3] = -1.0 + point_image.pixels.foreach_set(pixels) + point_image.update() + depth_balloon.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + evaluated, result = evaluated_mesh(depth_balloon) + try: + x_values = [vertex.co.x for vertex in result.vertices] + assert abs(min(x_values) + 0.5) < 1e-5 + assert abs(max(x_values)) < 1e-5 + finally: + evaluated.to_mesh_clear() + + depth_surface = grid_object("Depth Surface Projection Validation") + modifier = add_modifier(depth_surface, "DEPTH_SURFACE", 0.0) + point_image = bpy.data.images.new( + "Depth Surface Points", + width=3, + height=3, + alpha=True, + float_buffer=True, + ) + point_image.colorspace_settings.name = "Non-Color" + point_image.alpha_mode = "CHANNEL_PACKED" + pixels = [] + for z in range(3): + for y in range(3): + pixels.extend((-y * 0.5, -z * 0.5, 2.0 + y * 0.25, 0.0)) + point_image.pixels.foreach_set(pixels) + for name, value in ( + ("Depth Image", point_image), + ("Depth Scale", 1.0), + ("Uniform Scale", 1.0), + ("Reference Depth", 2.0), + ("Smooth Iterations", 0), + ): + set_modifier_input( + modifier, + modifier_input_identifier(modifier.node_group, name), + value, + ) + depth_surface.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + evaluated, result = evaluated_mesh(depth_surface) + try: + coordinates = [tuple(vertex.co) for vertex in result.vertices] + assert all( + all(abs(actual - expected) < 1e-5 for actual, expected in zip(point, target)) + for point, target in zip( + coordinates, + ( + (y * 0.25, y * 0.5, z * 0.5) + for z in range(3) + for y in range(3) + ), + ) + ) + finally: + evaluated.to_mesh_clear() + + print( + f"Validated Blender {bpy.app.version_string}: " + f"{wrapper.name} wrapping " + f"{', '.join(group.name for group in groups.values())}" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/validate_mesh_plane_asset.py b/tools/node_assets/validate_mesh_plane.py similarity index 92% rename from tools/validate_mesh_plane_asset.py rename to tools/node_assets/validate_mesh_plane.py index 5f88c86..41cbd3b 100644 --- a/tools/validate_mesh_plane_asset.py +++ b/tools/node_assets/validate_mesh_plane.py @@ -4,15 +4,19 @@ import sys import bpy -PROJECT_ROOT = Path(__file__).parents[1] +PROJECT_ROOT = Path(__file__).parents[2] sys.path.insert(0, str(PROJECT_ROOT / "src")) +sys.path.insert( + 0, + str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), +) -from anyimage.operators.material import ( +from anyimage.operators.common.node_asset import ( get_modifier_input, modifier_input_identifier, set_modifier_input, ) -from anyimage.operators.mesh_plane import ( +from anyimage.operators.convert_to_plane.mesh_plane import ( add_mesh_plane_processing, create_mesh_plane_mesh, ) diff --git a/tools/validate_cutout_asset.py b/tools/validate_cutout_asset.py deleted file mode 100644 index 2270c2b..0000000 --- a/tools/validate_cutout_asset.py +++ /dev/null @@ -1,165 +0,0 @@ -from pathlib import Path -import sys - -import bpy - - -PROJECT_ROOT = Path(__file__).parents[1] -sys.path.insert(0, str(PROJECT_ROOT / "src")) -sys.path.insert( - 0, - str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), -) - -from anyimage.operators import mesh -from anyimage.operators.material import modifier_input_identifier, set_modifier_input - - -def evaluated_mesh(obj): - evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) - result = evaluated.to_mesh() - return evaluated, result - - -def grid_object(name): - vertices = [ - (0.0, float(y), float(z)) - for z in range(3) - for y in range(3) - ] - faces = [] - for z in range(2): - for y in range(2): - a = z * 3 + y - faces.extend(((a, a + 4, a + 1), (a, a + 3, a + 4))) - data = bpy.data.meshes.new(name) - data.from_pydata(vertices, (), faces) - obj = bpy.data.objects.new(name, data) - bpy.context.scene.collection.objects.link(obj) - return obj - - -def add_modifier(obj, shape, thickness): - group = mesh._mesh_node_group(shape) - modifier = obj.modifiers.new(group.name, "NODES") - modifier.node_group = group - set_modifier_input( - modifier, - modifier_input_identifier(group, "Cleanup"), - 0.0, - ) - set_modifier_input( - modifier, - modifier_input_identifier(group, "Thickness"), - thickness, - ) - return modifier - - -def main(): - bpy.ops.wm.read_factory_settings(use_empty=True) - groups = { - shape: mesh._mesh_node_group(shape) - for shape in ( - mesh.CUTOUT, - mesh.BALLOON, - mesh.DEPTH_BALLOON, - mesh.DEPTH_SURFACE, - ) - } - assert [groups[shape].name for shape in groups] == [ - "O Image Surface", - "O Image Balloon", - "O Image Depth Balloon", - "O Image Depth Surface", - ] - repeat_counts = { - shape: sum( - node.bl_idname == "GeometryNodeRepeatInput" - for node in group.nodes - ) - for shape, group in groups.items() - } - assert repeat_counts == { - mesh.CUTOUT: 1, - mesh.BALLOON: 0, - mesh.DEPTH_BALLOON: 0, - mesh.DEPTH_SURFACE: 2, - } - for group in groups.values(): - assert group.is_modifier - assert all(not node.label for node in group.nodes) - assert all( - link.from_node.location.x <= link.to_node.location.x - for link in group.links - ) - group_nodes = list(group.nodes) - assert len({tuple(node.location) for node in group_nodes}) == len(group_nodes) - for index, node in enumerate(group_nodes): - for other in group_nodes[index + 1:]: - assert not ( - abs(node.location.x - other.location.x) < 150.0 - and abs(node.location.y - other.location.y) < 100.0 - ) - for group_input in ( - node for node in group_nodes if node.bl_idname == "NodeGroupInput" - ): - assert all( - output.is_linked or output.hide - for output in group_input.outputs - if output.name - ) - for item in group.interface.items_tree: - if item.item_type == "SOCKET" and item.in_out == "INPUT": - assert item.structure_type == "SINGLE" - - surface = grid_object("Surface Validation") - add_modifier(surface, mesh.CUTOUT, 2.4) - evaluated, result = evaluated_mesh(surface) - try: - assert len(result.vertices) == 34 - assert len(result.polygons) == 40 - side_faces = [ - polygon for polygon in result.polygons - if abs(polygon.normal.x) < 0.1 - ] - assert len(side_faces) == 24 - finally: - evaluated.to_mesh_clear() - - balloon = grid_object("Balloon Validation") - profile = balloon.data.attributes.new(mesh.BALLOON_ATTRIBUTE, "FLOAT", "POINT") - profile.data.foreach_set( - "value", - (0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0), - ) - modifier = add_modifier(balloon, mesh.BALLOON, 1.0) - evaluated, result = evaluated_mesh(balloon) - try: - initial_faces = len(result.polygons) - initial_extent = max(abs(vertex.co.x) for vertex in result.vertices) - finally: - evaluated.to_mesh_clear() - set_modifier_input( - modifier, - modifier_input_identifier(modifier.node_group, "Thickness"), - 2.0, - ) - balloon.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - evaluated, result = evaluated_mesh(balloon) - try: - assert len(result.polygons) == initial_faces - extent = max(abs(vertex.co.x) for vertex in result.vertices) - assert abs(extent - initial_extent * 2.0) < 1e-5 - finally: - evaluated.to_mesh_clear() - - print( - f"Validated Blender {bpy.app.version_string}: " - f"{', '.join(group.name for group in groups.values())}" - ) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tools/validate_depth_plane_asset.py b/tools/validate_depth_plane_asset.py deleted file mode 100644 index c5ae357..0000000 --- a/tools/validate_depth_plane_asset.py +++ /dev/null @@ -1,410 +0,0 @@ -from pathlib import Path -import sys - -import bpy - - -PROJECT_ROOT = Path(__file__).parents[1] -sys.path.insert(0, str(PROJECT_ROOT / "src")) -sys.path.insert( - 0, - str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), -) - -from anyimage.operators.material import ( - DEPTH_PLANE_MATERIAL_NODE_GROUP_NAME, - SHADELESS_NODE_GROUP_NAME, - SURFACE_MATERIAL_NODE_GROUP_NAME, - create_material, - get_modifier_input, - load_depth_plane_node_group, - material_color_space_name, - modifier_input_driver_path, - modifier_input_identifier, - set_modifier_input, -) -from anyimage.operators.depth_plane.build import add_depth_plane_processing -from anyimage.operators.surface import load_geometry_node_group - - -def main(): - bpy.ops.wm.read_factory_settings(use_empty=True) - group = load_depth_plane_node_group() - geometry_textures = [ - node - for node in group.nodes - if node.bl_idname == "GeometryNodeImageTexture" - ] - assert geometry_textures - assert all(node.extension == "EXTEND" for node in geometry_textures) - background_depth = next( - item - for item in group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Background Depth" - ) - assert background_depth.subtype == "DISTANCE" - assert background_depth.structure_type == "SINGLE" - assert background_depth.default_value == 1.0 - subdivision = next( - item - for item in group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Subdivision" - ) - assert subdivision.default_value == 6 - frame_input = next( - item - for item in group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Frame" - ) - assert frame_input.structure_type == "SINGLE" - geometry_input = next( - item - for item in group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Geometry" - ) - assert geometry_input.structure_type == "SINGLE" - all_inputs = [ - (item.name, getattr(item.parent, "name", None), item.hide_in_modifier) - for item in group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - ] - visible_inputs = [name for name, parent, hidden in all_inputs if not hidden] - assert visible_inputs[:6] == [ - "Geometry", - "Displacement", - "Subdivision", - "Depth Scale", - "Background Depth", - ] - assert not getattr(background_depth.parent, "name", "") - assert not any( - item.name == "Remove Background" - for item in group.interface.items_tree - ) - depth_scale = next( - item - for item in group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Depth Scale" - ) - assert depth_scale.default_value == 1.0 - assert depth_scale.min_value < 0.0 - assert depth_scale.structure_type == "SINGLE" - stores = { - node.inputs["Name"].default_value: node - for node in group.nodes - if node.bl_idname == "GeometryNodeStoreNamedAttribute" - } - attribute_names = ( - "o_material_displacement", - "o_background_depth", - "o_depth_scale", - ) - for name in attribute_names: - assert stores[name].domain == "POINT" - assert not any( - node.bl_idname == "GeometryNodeGeometryToInstance" - for node in group.nodes - ) - - mesh = bpy.data.meshes.new("Depth Plane") - mesh.from_pydata( - [(-1, -1, 0), (1, -1, 0), (-1, 1, 0), (1, 1, 0)], - [], - [(0, 1, 3, 2)], - ) - obj = bpy.data.objects.new("Depth Plane", mesh) - bpy.context.scene.collection.objects.link(obj) - depth = bpy.data.images.new("Depth", width=2, height=2, float_buffer=True) - depth.pixels[:] = [0.25, 0.25, 0.25, 1.0] * 4 - foreground = bpy.data.images.new("Foreground", width=2, height=2) - foreground.pixels[:] = [0.8, 0.2, 0.1, 1.0] * 4 - foreground.alpha_mode = "CHANNEL_PACKED" - material = create_material( - "Depth Plane", - foreground, - 1.0, - displacement_image=depth, - ) - mesh.materials.append(material) - material_group = bpy.data.node_groups[DEPTH_PLANE_MATERIAL_NODE_GROUP_NAME] - interface_inputs = [ - item.name - for item in material_group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - ] - interface_outputs = [ - item.name - for item in material_group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "OUTPUT" - ] - assert interface_inputs == [ - "Color", - "Alpha", - "Depth", - "Bump Scale", - ] - assert interface_outputs == [ - "Color", - "Roughness", - "Alpha", - "Normal", - "Displacement", - ] - depth_input = next( - item - for item in material_group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Depth" - ) - assert depth_input.socket_type == "NodeSocketFloat" - bump_scale = next( - item - for item in material_group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Bump Scale" - ) - assert bump_scale.default_value == 1.0 - assert bump_scale.min_value < 0.0 - material_group_nodes = [ - node - for node in material.node_tree.nodes - if node.bl_idname == "ShaderNodeGroup" - and node.node_tree == material_group - ] - assert len(material_group_nodes) == 1 - assert material_group_nodes[0].name == "O DA3 Depth Plane Layer" - bump = next( - node - for node in material_group.nodes - if node.bl_idname == "ShaderNodeBump" - ) - bump_height_link = next( - link - for link in material_group.links - if link.to_node == bump and link.to_socket == bump.inputs["Height"] - ) - assert bump_height_link.from_node.bl_idname == "NodeGroupInput" - assert bump_height_link.from_socket.name == "Color" - attributes = [ - node - for node in material_group.nodes - if node.bl_idname == "ShaderNodeAttribute" - ] - assert len(attributes) == 3 - assert all(node.attribute_type == "GEOMETRY" for node in attributes) - assert {node.attribute_name for node in attributes} == { - "o_material_displacement", - "o_background_depth", - "o_depth_scale", - } - assert not any( - node.bl_idname == "ShaderNodeAttribute" - for node in material.node_tree.nodes - ) - forbidden_outer_nodes = { - "ShaderNodeAttribute", - "ShaderNodeBump", - "ShaderNodeDisplacement", - "ShaderNodeFloatCurve", - "ShaderNodeMath", - "ShaderNodeMix", - } - assert not any( - node.bl_idname in forbidden_outer_nodes - for node in material.node_tree.nodes - ) - assert all( - node.extension == "EXTEND" - for node in material.node_tree.nodes - if node.bl_idname == "ShaderNodeTexImage" - ) - shadeless_material = create_material( - "Shadeless", - foreground, - 1.0, - shadeless=True, - ) - shadeless_group = bpy.data.node_groups[SHADELESS_NODE_GROUP_NAME] - shadeless_nodes = [ - node - for node in shadeless_material.node_tree.nodes - if node.bl_idname == "ShaderNodeGroup" - and node.node_tree == shadeless_group - ] - assert len(shadeless_nodes) == 1 - assert not any( - node.bl_idname == "ShaderNodeEmission" - for node in shadeless_material.node_tree.nodes - ) - assert [ - item.name - for item in shadeless_group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT" - ] == ["Color"] - assert [ - item.name - for item in shadeless_group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "OUTPUT" - ] == ["Shader"] - assert { - node.label: node.operation - for node in shadeless_group.nodes - if node.bl_idname == "ShaderNodeMath" - } == { - "Bounce Count": "SUBTRACT", - "Camera or Refracted": "SUBTRACT", - "Limit Reflections": "SUBTRACT", - "Camera Ray to Glossy": "MULTIPLY", - "Shadow or Reflection?": "MAXIMUM", - "Shadow, Reflect or Refract?": "MAXIMUM", - } - assert foreground.colorspace_settings.name == material_color_space_name( - bpy.context.scene - ) - surface_material = create_material("Surface", foreground, 1.0) - surface_group = bpy.data.node_groups[SURFACE_MATERIAL_NODE_GROUP_NAME] - surface_group_nodes = [ - node - for node in surface_material.node_tree.nodes - if node.bl_idname == "ShaderNodeGroup" - and node.node_tree == surface_group - ] - assert len(surface_group_nodes) == 1 - assert surface_group_nodes[0].name == "O Image Layer" - surface_inputs = [ - item.name - for item in surface_group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - ] - assert surface_inputs == [ - "Color", - "Alpha", - "Normal", - "Normal Scale", - "Object Space", - "Bump Scale", - ] - normal_input = next( - item - for item in surface_group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Normal" - ) - assert tuple(normal_input.default_value) == (0.5, 0.5, 1.0, 1.0) - normal_scale = next( - item - for item in surface_group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Normal Scale" - ) - assert normal_scale.default_value == 1.0 - surface_outputs = [ - item.name - for item in surface_group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "OUTPUT" - ] - assert surface_outputs == ["Color", "Roughness", "Alpha", "Normal"] - assert not any( - node.bl_idname == "ShaderNodeDisplacement" - for node in surface_group.nodes - ) - refine_group = load_geometry_node_group() - assert not any( - item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Background" - for item in refine_group.interface.items_tree - ) - assert not any( - node.bl_idname in forbidden_outer_nodes - for node in surface_material.node_tree.nodes - ) - assert all( - node.extension == "EXTEND" - for node in surface_material.node_tree.nodes - if node.bl_idname == "ShaderNodeTexImage" - ) - modifier = add_depth_plane_processing( - obj, - depth, - foreground, - material, - animate_frame=True, - ) - set_modifier_input( - modifier, - modifier_input_identifier(group, "Depth Scale"), - -2.0, - ) - for name, expected in ( - ("Frame Start", 1), - ("Frames", 1), - ("Cyclic", False), - ): - identifier = modifier_input_identifier(group, name) - assert get_modifier_input(modifier, identifier) == expected - assert not any( - node.bl_idname == "GeometryNodeInputSceneTime" - for node in group.nodes - ) - frame_identifier = modifier_input_identifier(group, "Frame") - frame_path = modifier_input_driver_path(modifier, frame_identifier) - frame_driver = next( - fcurve - for fcurve in obj.animation_data.drivers - if fcurve.data_path == frame_path - ) - assert frame_driver.driver.type == "SCRIPTED" - assert frame_driver.driver.expression == "frame" - image_textures = [ - node - for node in group.nodes - if node.bl_idname == "GeometryNodeImageTexture" - ] - assert len(image_textures) == 2 - assert all(node.inputs["Frame"].is_linked for node in image_textures) - assert image_textures[0].inputs["Frame"].links[0].from_socket == ( - image_textures[1].inputs["Frame"].links[0].from_socket - ) - floored_modulo = [ - node - for node in group.nodes - if node.bl_idname == "ShaderNodeMath" - and node.operation == "FLOORED_MODULO" - ] - assert len(floored_modulo) == 1 - bpy.context.view_layer.objects.active = obj - obj.select_set(True) - result = bpy.ops.object.modifier_apply(modifier=modifier.name) - assert result == {"FINISHED"} - assert not obj.modifiers - assert obj.type == "MESH" - assert len(obj.data.vertices) > 4 - for name in attribute_names: - assert obj.data.attributes[name].domain == "POINT" - assert all(polygon.use_smooth for polygon in obj.data.polygons) - assert material in obj.data.materials[:] - print("Depth plane asset validation passed") - - -if __name__ == "__main__": - main() diff --git a/tools/validate_geometry_nodes_asset.py b/tools/validate_geometry_nodes_asset.py deleted file mode 100644 index a408448..0000000 --- a/tools/validate_geometry_nodes_asset.py +++ /dev/null @@ -1,245 +0,0 @@ -from pathlib import Path -import sys - -import bpy -import numpy as np - - -PROJECT_ROOT = Path(__file__).parents[1] -sys.path.insert(0, str(PROJECT_ROOT / "src")) -sys.path.insert( - 0, - str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), -) - -from anyimage.operators.material import get_modifier_input, set_modifier_input -from anyimage.operators.surface import create_matching_camera, load_geometry_node_group - - -def set_input(modifier, group, name, value): - item = next( - item - for item in group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == name - ) - set_modifier_input(modifier, item.identifier, value) - - -def add_face_attributes(mesh, count): - confidence = mesh.attributes.new("o_confidence", "FLOAT", "FACE") - confidence.data.foreach_set("value", [1.0] * count) - - -def main(): - bpy.ops.wm.read_factory_settings(use_empty=True) - group = load_geometry_node_group() - assert load_geometry_node_group() == group - assert group.is_modifier - geometry_input = next( - item - for item in group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Geometry" - ) - assert geometry_input.structure_type == "SINGLE" - input_sockets = [ - item for item in group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT" - ] - assert all(item.structure_type == "SINGLE" for item in input_sockets) - assert [item.name for item in input_sockets[:4]] == [ - "Geometry", - "Iterations", - "Resolution", - "Smooth", - ] - iterations = next(item for item in input_sockets if item.name == "Iterations") - assert iterations.default_value == 2 - assert iterations.min_value == 0 - resolution = next(item for item in input_sockets if item.name == "Resolution") - assert abs(resolution.default_value - 0.1) < 1e-6 - assert group.asset_data is None - assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes) - assert not any(node.bl_idname == "GeometryNodeTriangulate" for node in group.nodes) - group_inputs = [ - node for node in group.nodes if node.bl_idname == "NodeGroupInput" - ] - assert len(group_inputs) >= 7 - for node in group_inputs: - visible_outputs = [ - output for output in node.outputs if output.name and not output.hide - ] - assert 1 <= len(visible_outputs) <= 4 - uv_stores = [ - node - for node in group.nodes - if node.bl_idname == "GeometryNodeStoreNamedAttribute" - and node.inputs["Name"].default_value == "UVMap" - ] - assert len(uv_stores) == 1 - assert uv_stores[0].data_type == "FLOAT2" - panels = { - item.name: item - for item in group.interface.items_tree - if item.item_type == "PANEL" - } - assert not panels["Culling"].default_closed - assert panels["Options"].default_closed - assert [item.name for item in panels["Culling"].interface_items] == [ - "Depth Continuity", - "Confidence", - ] - assert [item.name for item in panels["Options"].interface_items] == [ - "Fix Topology", - ] - smooth = next( - item - for item in group.interface.items_tree - if item.item_type == "SOCKET" - and item.in_out == "INPUT" - and item.name == "Smooth" - ) - assert smooth.default_value == 0.5 - for name, default_value in ( - ("Confidence", 0.0), - ("Depth Continuity", 50.0), - ): - item = next( - item - for item in panels["Culling"].interface_items - if item.name == name - ) - assert item.min_value == 0.0 - assert item.max_value == 100.0 - assert item.default_value == default_value - assert item.subtype == "PERCENTAGE" - if bpy.app.version >= (5, 0, 0): - assert group.library is not None - - mesh = bpy.data.meshes.new("Validation Mesh") - mesh.from_pydata( - [(0, 0, 0), (1, 0, 0), (0, 1, 0), (1, 1, 0)], - [], - [(0, 2, 1), (1, 2, 3)], - ) - discontinuity = mesh.attributes.new( - "o_depth_discontinuity", - "FLOAT", - "FACE", - ) - discontinuity.data.foreach_set("value", [0.03] * len(mesh.polygons)) - uv = mesh.uv_layers.new(name="UVMap") - uv.data.foreach_set("uv", [0, 0, 0, 1, 1, 0, 1, 0, 0, 1, 1, 1]) - add_face_attributes(mesh, 2) - assert "o_confidence" in mesh.attributes - assert not { - "o_background", - "o_background_face", - "o_face_confidence", - "o_confidence_percentile", - }.intersection(mesh.attributes.keys()) - - obj = bpy.data.objects.new("Validation Object", mesh) - bpy.context.scene.collection.objects.link(obj) - modifier = obj.modifiers.new(name=group.name, type="NODES") - modifier.node_group = group - modifier.show_group_selector = False - assert not modifier.show_group_selector - set_input(modifier, group, "Resolution", 0.000001) - set_input(modifier, group, "Iterations", 0) - set_input(modifier, group, "Fix Topology", 0) - set_input(modifier, group, "Smooth", 0.0) - evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) - result = evaluated.to_mesh() - try: - assert len(result.polygons) == 2 - assert "UVMap" in result.attributes - assert result.attributes["UVMap"].data_type == "FLOAT2" - finally: - evaluated.to_mesh_clear() - - set_input(modifier, group, "Depth Continuity", 100.0) - discontinuity_item = next( - item - for item in group.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Depth Continuity" - ) - assert get_modifier_input(modifier, discontinuity_item.identifier) == 100.0 - obj.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - culled = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) - culled_result = culled.to_mesh() - try: - assert len(culled_result.polygons) == 0 - assert len(culled_result.vertices) == 0 - finally: - culled.to_mesh_clear() - - size = 10 - dense_vertices = [ - (x * 0.1, y * 0.1, 0.0) - for y in range(size) - for x in range(size) - ] - dense_faces = [] - for y in range(size - 1): - for x in range(size - 1): - a = y * size + x - b = a + 1 - c = a + size - d = c + 1 - dense_faces.extend(((a, c, b), (b, c, d))) - dense_mesh = bpy.data.meshes.new("Dense Validation Mesh") - dense_mesh.from_pydata(dense_vertices, [], dense_faces) - dense_uv = dense_mesh.uv_layers.new(name="UVMap") - dense_uv.data.foreach_set("uv", [0.0] * (len(dense_mesh.loops) * 2)) - add_face_attributes(dense_mesh, len(dense_faces)) - dense_object = bpy.data.objects.new("Dense Validation Object", dense_mesh) - bpy.context.scene.collection.objects.link(dense_object) - dense_modifier = dense_object.modifiers.new(name=group.name, type="NODES") - dense_modifier.node_group = group - set_input(dense_modifier, group, "Resolution", 0.22) - dense_evaluated = dense_object.evaluated_get( - bpy.context.evaluated_depsgraph_get() - ) - dense_result = dense_evaluated.to_mesh() - try: - assert 4 < len(dense_result.polygons) < len(dense_faces) - assert all(polygon.normal.z < -0.01 for polygon in dense_result.polygons) - finally: - dense_evaluated.to_mesh_clear() - - source = bpy.data.objects.new("Validation Source", None) - bpy.context.scene.collection.objects.link(source) - source.location = (2.0, 3.0, 4.0) - bpy.context.view_layer.update() - obj.matrix_world = source.matrix_world.copy() - bpy.context.view_layer.update() - camera = create_matching_camera( - bpy.context, - source, - obj, - "Validation", - np.asarray( - ((100.0, 0.0, 50.0), (0.0, 100.0, 50.0), (0.0, 0.0, 1.0)) - ), - (101, 101), - {"scale_x": 1.0, "scale_z": 1.0, "location": (0.0, 0.0, 2.0)}, - ) - assert camera.parent == obj - camera_location = tuple( - round(value, 5) for value in camera.matrix_world.translation - ) - assert camera_location == ( - 2.0, - 3.0, - 6.0, - ) - print(f"Validated Blender {bpy.app.version_string}: {group.name}") - - -if __name__ == "__main__": - main() diff --git a/tools/validate_moge_depth_plane.py b/tools/validate_moge_depth_plane.py deleted file mode 100644 index bdae5f5..0000000 --- a/tools/validate_moge_depth_plane.py +++ /dev/null @@ -1,249 +0,0 @@ -import sys -import tempfile -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - -import bpy -import numpy as np - - -PROJECT_ROOT = Path(__file__).parents[1] -sys.path.insert(0, str(PROJECT_ROOT / "src")) -sys.path.insert( - 0, - str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), -) - -from anyimage.operators.image import create_color_image -from anyimage.operators.material import ( - MOGE_DEPTH_PLANE_NODE_GROUP_NAME, - MOGE_MATERIAL_NODE_GROUP_NAME, - get_modifier_input, - modifier_input_identifier, -) -from anyimage.operators.depth_plane.build import build_moge2_depth_plane - - -def validate_single_frame_source_types(): - with tempfile.TemporaryDirectory() as temporary: - directory = Path(temporary) - color_path = directory / "color.0001.png" - seed = bpy.data.images.new("Color Seed", width=2, height=2, alpha=True) - seed.pixels[:] = [0.2, 0.4, 0.8, 1.0] * 4 - seed.filepath_raw = str(color_path) - seed.file_format = "PNG" - seed.save() - bpy.data.images.remove(seed, do_unlink=True) - - still_source_image = bpy.data.images.load( - str(color_path), check_existing=False - ) - still_source = bpy.data.objects.new("Still Source", None) - still_source.empty_display_type = "IMAGE" - still_source.data = still_source_image - bpy.context.collection.objects.link(still_source) - still_color = create_color_image( - still_source, directory, assign_to_source=False - ) - assert still_color.source == "FILE" - - animated_source = SimpleNamespace( - data=SimpleNamespace( - source="SEQUENCE", - name="Sequence Source.png", - use_fake_user=False, - ), - image_user=None, - ) - sequence_color = create_color_image( - animated_source, directory, assign_to_source=False - ) - assert sequence_color.source == "SEQUENCE" - - depth = np.ones((1, 2, 2), dtype=np.float32) - mask = np.ones((1, 2, 2), dtype=bool) - intrinsics = np.array( - [[[1.0, 0.0, 0.5], [0.0, 1.0, 0.5], [0.0, 0.0, 1.0]]], - dtype=np.float32, - ) - path = directory / "single-frame-results.npz" - np.savez(path, depth=depth, mask=mask, intrinsics=intrinsics) - with patch.object(depth_plane, "is_animated_image", return_value=True): - plane = build_moge2_depth_plane( - bpy.context, - still_source, - path, - delete_source=False, - color_image=sequence_color, - data_frames_directory=directory, - ) - textures = [ - node.image - for node in plane.data.materials[0].node_tree.nodes - if node.bl_idname == "ShaderNodeTexImage" - ] - assert sequence_color in textures - depth_images = [image for image in textures if image != sequence_color] - assert len(depth_images) == 1 - assert depth_images[0].source == "SEQUENCE" - -def main(): - bpy.ops.wm.read_factory_settings(use_empty=True) - image = bpy.data.images.new("MoGe Test", width=64, height=48, alpha=True) - source = bpy.data.objects.new("MoGe Test", None) - source.empty_display_type = "IMAGE" - source.data = image - bpy.context.collection.objects.link(source) - depth = np.linspace(1.0, 2.0, 48 * 64, dtype=np.float32).reshape(1, 48, 64) - normal = np.zeros((1, 48, 64, 3), dtype=np.float32) - normal[..., 2] = -1.0 - mask = np.ones((1, 48, 64), dtype=bool) - mask[:, :5] = False - intrinsics = np.array( - [[[1.0, 0.0, 0.5], [0.0, 1.0, 0.5], [0.0, 0.0, 1.0]]], - dtype=np.float32, - ) - with tempfile.TemporaryDirectory() as temporary: - path = Path(temporary) / "results.npz" - np.savez( - path, - depth=depth, - normal=normal, - mask=mask, - intrinsics=intrinsics, - ) - material_image = image.copy() - material_image.name = "MoGe Test.color" - plane = build_moge2_depth_plane( - bpy.context, - source, - path, - delete_source=True, - color_image=material_image, - ) - assert plane.modifiers[0].node_group.name == MOGE_DEPTH_PLANE_NODE_GROUP_NAME - assert MOGE_MATERIAL_NODE_GROUP_NAME in bpy.data.node_groups - geometry_group = plane.modifiers[0].node_group - inputs = { - item.name - for item in geometry_group.interface.items_tree - if getattr(item, "in_out", None) == "INPUT" - } - assert "Displacement" in inputs - assert "Background Depth" in inputs - assert "Mask Image" not in inputs - assert "Use Mask" not in inputs - sockets = { - item.name: item - for item in geometry_group.interface.items_tree - if getattr(item, "in_out", None) == "INPUT" - } - assert sockets["Subdivision"].default_value == 6 - assert sockets["Depth Scale"].structure_type == "SINGLE" - assert sockets["Background Depth"].structure_type == "SINGLE" - assert sockets["Background Depth"].subtype == "DISTANCE" - assert sockets["Background Depth"].default_value == 1.0 - assert sockets["Color Image"].hide_in_modifier - color_identifier = modifier_input_identifier(geometry_group, "Color Image") - assert get_modifier_input(plane.modifiers[0], color_identifier) == material_image - material_group = bpy.data.node_groups[MOGE_MATERIAL_NODE_GROUP_NAME] - assert not any(node.bl_idname == "ShaderNodeNormalMap" for node in material_group.nodes) - assert any(node.bl_idname == "ShaderNodeBump" for node in material_group.nodes) - material = plane.data.materials[0] - image_textures = [ - node - for node in material.node_tree.nodes - if node.bl_idname == "ShaderNodeTexImage" - ] - assert image_textures - assert all(node.extension == "EXTEND" for node in image_textures) - static_depth = next( - node.image - for node in image_textures - if node.image.name == "MoGe Test_depth.exr" - ) - assert static_depth.source != "SEQUENCE" - geometry_textures = [ - node - for node in geometry_group.nodes - if node.bl_idname == "GeometryNodeImageTexture" - ] - assert len(geometry_textures) == 2 - assert all(node.extension == "EXTEND" for node in geometry_textures) - color_texture = next( - node - for node in geometry_textures - if any( - link.to_node == node - and link.to_socket == node.inputs["Image"] - and link.from_socket.name == "Color Image" - for link in geometry_group.links - ) - ) - depth_texture = next(node for node in geometry_textures if node != color_texture) - assert color_texture.interpolation == "Closest" - assert depth_texture.interpolation == "Linear" - color_frame_link = next( - link - for link in geometry_group.links - if link.to_node == color_texture - and link.to_socket == color_texture.inputs["Frame"] - ) - depth_frame_link = next( - link - for link in geometry_group.links - if link.to_node == depth_texture - and link.to_socket == depth_texture.inputs["Frame"] - ) - assert color_frame_link.from_socket == depth_frame_link.from_socket - alpha_compare = next( - node - for node in geometry_group.nodes - if node.bl_idname == "FunctionNodeCompare" - and node.operation == "GREATER_THAN" - and any( - link.from_node == color_texture - and link.from_socket == color_texture.outputs["Alpha"] - and link.to_node == node - for link in geometry_group.links - ) - ) - masked_depth = next( - node - for node in geometry_group.nodes - if node.bl_idname == "ShaderNodeMix" - and any( - link.from_node == alpha_compare and link.to_node == node - for link in geometry_group.links - ) - ) - assert masked_depth.data_type == "FLOAT" - masked_sources = { - link.from_node - for link in geometry_group.links - if link.to_node == masked_depth - } - assert alpha_compare in masked_sources - assert any( - node.bl_idname == "ShaderNodeMath" and node.operation == "MAXIMUM" - for node in masked_sources - ) - assert any( - node.bl_idname == "ShaderNodeMath" - and node.operation == "MULTIPLY" - and node.inputs[1].default_value == -1.0 - for node in masked_sources - ) - assert any( - link.from_node == masked_depth - and link.to_node.bl_idname == "ShaderNodeMath" - and link.to_node.operation == "MULTIPLY" - for link in geometry_group.links - ) - validate_single_frame_source_types() - print("MoGe-2 depth plane validation passed") - - -if __name__ == "__main__": - main() diff --git a/tools/validate_moge_surface.py b/tools/validate_moge_surface.py deleted file mode 100644 index 9ec4c56..0000000 --- a/tools/validate_moge_surface.py +++ /dev/null @@ -1,298 +0,0 @@ -import sys -import tempfile -from pathlib import Path - -import bpy -import numpy as np - -PROJECT_ROOT = Path(__file__).parents[1] -sys.path.insert(0, str(PROJECT_ROOT / "src")) -sys.path.insert( - 0, - str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), -) - -from anyimage.operators.material import ( - MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME, - material_color_space_name, -) -from anyimage.operators.surface import MOGE_NODE_GROUP_NAME, build_image_mesh -from anyimage.server.geometry import GeometryFrame, build_surface -from anyimage.server.outputs.surface import write_surface_archive - - -def write_test_normal_texture(normal, output_path): - converted = normal * np.array([1.0, -1.0, -1.0], dtype=np.float32) - rgb = np.clip((converted + 1.0) * 0.5, 0.0, 1.0) - alpha = np.ones((*rgb.shape[:2], 1), dtype=np.float32) - pixels = np.concatenate((rgb, alpha), axis=-1) - image = bpy.data.images.new( - "MoGe Test Normal", - width=rgb.shape[1], - height=rgb.shape[0], - alpha=True, - float_buffer=False, - ) - image.pixels.foreach_set(pixels.reshape(-1)) - image.file_format = "PNG" - image.filepath_raw = str(output_path) - image.save() - return output_path - - -def main(): - bpy.ops.wm.read_factory_settings(use_empty=True) - arguments = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] - if arguments: - validate_real_result(Path(arguments[0]), Path(arguments[1])) - return - height, width = 48, 64 - u, v = np.meshgrid( - np.linspace(-0.5, 0.5, width, dtype=np.float32), - np.linspace(-0.375, 0.375, height, dtype=np.float32), - ) - depth = np.full((1, height, width), 2.0, dtype=np.float32) - points = np.stack((u * 2.0, v * 2.0, depth[0]), axis=-1)[None] - mask = np.ones((1, height, width), dtype=bool) - mask[:, :2] = False - intrinsics = np.array( - [[[1.0, 0.0, 0.5], [0.0, 1.0, 0.5], [0.0, 0.0, 1.0]]], - dtype=np.float32, - ) - image = bpy.data.images.new("MoGe Surface Test", width=width, height=height) - foreground = bpy.data.images.new( - "MoGe Surface Test.color", - width=width, - height=height, - ) - source = bpy.data.objects.new("MoGe Surface Test", None) - source.empty_display_type = "IMAGE" - source.data = image - bpy.context.collection.objects.link(source) - with tempfile.TemporaryDirectory() as temporary: - directory = Path(temporary) - mesh_path = directory / "mesh.npz" - normal = np.broadcast_to( - np.array([0.0, 0.0, -1.0], dtype=np.float32), - (height, width, 3), - ) - pixel_intrinsics = intrinsics[0].copy() - pixel_intrinsics[0] *= width - pixel_intrinsics[1] *= height - pixel_intrinsics[2] = (0.0, 0.0, 1.0) - frame = GeometryFrame( - depth=depth[0], - points=points[0], - intrinsics=pixel_intrinsics, - valid_mask=np.isfinite(depth[0]) & (depth[0] > 0.0), - model_mask=mask[0], - ) - write_surface_archive(build_surface(frame), mesh_path) - normal_path = write_test_normal_texture( - normal, - directory / "normal.png", - ) - surface = build_image_mesh( - bpy.context, - source, - mesh_path, - False, - True, - False, - True, - color_image=foreground, - normal_path=normal_path, - ) - assert len(surface.data.vertices) > 0 - assert all(len(polygon.vertices) == 3 for polygon in surface.data.polygons) - assert surface.modifiers[0].node_group.name == MOGE_NODE_GROUP_NAME - inputs = { - item.name - for item in surface.modifiers[0].node_group.interface.items_tree - if getattr(item, "in_out", None) == "INPUT" - } - assert "Stretch" not in inputs - assert "Depth Continuity" in inputs - assert "Mask" in inputs - assert "Confidence" not in inputs - assert "o_mask" in surface.data.attributes - assert surface.data.attributes["o_mask"].domain == "FACE" - assert not all(item.value for item in surface.data.attributes["o_mask"].data) - attribute_names = set(surface.data.attributes.keys()) - assert attribute_names >= {"o_depth", "o_mask"} - assert "o_depth_discontinuity" in attribute_names - assert surface.data.attributes["o_depth_discontinuity"].data_type == "FLOAT" - assert "o_edge_stretch" not in attribute_names - assert not { - "o_confidence", - "o_confidence_percentile", - "o_confidence", - "o_background", - "o_background_face", - }.intersection(attribute_names) - mask_socket = next( - item - for item in surface.modifiers[0].node_group.interface.items_tree - if getattr(item, "in_out", None) == "INPUT" and item.name == "Mask" - ) - assert mask_socket.default_value is False - assert mask_socket.structure_type == "SINGLE" - assert mask_socket.parent.name == "Culling" - depth_edge_socket = next( - item - for item in surface.modifiers[0].node_group.interface.items_tree - if getattr(item, "in_out", None) == "INPUT" and item.name == "Depth Continuity" - ) - assert depth_edge_socket.default_value == 50.0 - assert depth_edge_socket.subtype == "PERCENTAGE" - assert depth_edge_socket.structure_type == "SINGLE" - culling_panel = next( - item - for item in surface.modifiers[0].node_group.interface.items_tree - if item.item_type == "PANEL" and item.name == "Culling" - ) - assert [item.name for item in culling_panel.interface_items] == [ - "Depth Continuity", - "Mask", - ] - input_sockets = [ - item - for item in surface.modifiers[0].node_group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT" - ] - assert all(item.structure_type == "SINGLE" for item in input_sockets) - resolution_socket = next( - item for item in input_sockets if item.name == "Resolution" - ) - assert abs(resolution_socket.default_value - 0.1) < 1e-6 - smooth_socket = next( - item - for item in surface.modifiers[0].node_group.interface.items_tree - if getattr(item, "in_out", None) == "INPUT" and item.name == "Smooth" - ) - assert smooth_socket.default_value == 0.5 - assert not any( - node.bl_idname == "GeometryNodeInputNamedAttribute" - and node.inputs["Name"].default_value == "o_confidence" - for node in surface.modifiers[0].node_group.nodes - ) - assert MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME in bpy.data.node_groups - material_group = bpy.data.node_groups[MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME] - assert any(node.bl_idname == "ShaderNodeBump" for node in material_group.nodes) - normal_maps = [ - node for node in material_group.nodes if node.bl_idname == "ShaderNodeNormalMap" - ] - assert {node.space for node in normal_maps} == {"TANGENT", "OBJECT"} - normal_strength = next( - item - for item in material_group.interface.items_tree - if getattr(item, "in_out", None) == "INPUT" and item.name == "Normal Scale" - ) - assert normal_strength.default_value == 1.0 - assert normal_strength.min_value == 0.0 - normal_input = next( - item - for item in material_group.interface.items_tree - if getattr(item, "in_out", None) == "INPUT" and item.name == "Normal" - ) - assert tuple(normal_input.default_value) == (0.5, 0.5, 1.0, 1.0) - assert all(node.inputs["Strength"].is_linked for node in normal_maps) - layer = next( - node - for node in surface.data.materials[0].node_tree.nodes - if node.bl_idname == "ShaderNodeGroup" and node.node_tree == material_group - ) - assert layer.inputs["Object Space"].default_value is True - assert not surface.data.has_custom_normals - color_texture = next( - node - for node in surface.data.materials[0].node_tree.nodes - if node.bl_idname == "ShaderNodeTexImage" and node.image == foreground - ) - assert color_texture.image.name == "MoGe Surface Test.color" - assert color_texture.image.colorspace_settings.name == material_color_space_name( - bpy.context.scene - ) - normal_texture = next( - node - for node in surface.data.materials[0].node_tree.nodes - if node.bl_idname == "ShaderNodeTexImage" - and node.image.colorspace_settings.name == "Non-Color" - ) - assert normal_texture.image.name == "MoGe Surface Test.normal" - assert any(child.type == "CAMERA" for child in surface.children) - print("MoGe-2 surface validation passed") - - -def validate_real_result(results_path, image_path): - image = bpy.data.images.load(str(image_path), check_existing=False) - source = bpy.data.objects.new("MoGe Real Surface", None) - source.empty_display_type = "IMAGE" - source.data = image - bpy.context.collection.objects.link(source) - mesh_path = results_path.with_name("mesh.npz") - if not mesh_path.is_file(): - raise ValueError(f"Surface archive is missing: {mesh_path}") - normal_path = results_path.with_name("normal.png") - if not normal_path.is_file(): - raise ValueError(f"Surface normal texture is missing: {normal_path}") - color_image = bpy.data.images.load(str(image_path), check_existing=False) - color_image.name = f"{image_path.stem}.color" - surface = build_image_mesh( - bpy.context, - source, - mesh_path, - False, - True, - False, - True, - color_image=color_image, - normal_path=normal_path, - ) - assert len(surface.data.vertices) > 100 - assert len(surface.data.polygons) > 100 - assert surface.modifiers[0].node_group.name == MOGE_NODE_GROUP_NAME - assert MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME in bpy.data.node_groups - assert not surface.data.has_custom_normals - material_group = bpy.data.node_groups[MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME] - assert any(node.bl_idname == "ShaderNodeBump" for node in material_group.nodes) - normal_maps = [ - node for node in material_group.nodes if node.bl_idname == "ShaderNodeNormalMap" - ] - assert {node.space for node in normal_maps} == {"TANGENT", "OBJECT"} - assert all(node.inputs["Strength"].is_linked for node in normal_maps) - layer = next( - node - for node in surface.data.materials[0].node_tree.nodes - if node.bl_idname == "ShaderNodeGroup" and node.node_tree == material_group - ) - assert layer.inputs["Object Space"].default_value is True - attribute_names = set(surface.data.attributes.keys()) - assert {"o_depth", "o_mask"}.issubset(attribute_names) - assert "o_depth_discontinuity" in attribute_names - assert "o_edge_stretch" not in attribute_names - assert not { - "o_confidence", - "o_confidence_percentile", - "o_confidence", - "o_background", - "o_background_face", - }.intersection(attribute_names) - camera = next(child for child in surface.children if child.type == "CAMERA") - dimensions = surface.dimensions - assert max(dimensions) < 10.0 - assert min(dimensions.x, dimensions.y) > 0.1 - assert 1.0 <= camera.data.lens <= 500.0 - assert abs(camera.data.shift_x) <= 1.0 - assert abs(camera.data.shift_y) <= 1.0 - print( - f"Real MoGe-2 surface validation passed: " - f"{len(surface.data.vertices)} vertices, {len(surface.data.polygons)} faces, " - f"dimensions={tuple(round(value, 4) for value in dimensions)}, " - f"lens={camera.data.lens:.3f}, shift=({camera.data.shift_x:.4f}, " - f"{camera.data.shift_y:.4f})" - ) - - -if __name__ == "__main__": - main() diff --git a/uv.lock b/uv.lock index a7dc22d..59605be 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.11" -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version < '3.12'", -] +requires-python = "==3.11.*" [[package]] name = "annotated-doc" @@ -68,7 +64,7 @@ version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ @@ -89,14 +85,6 @@ wheels = [ { 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" }, - { url = "https://files.pythonhosted.org/packages/7f/8e/c40ac08e63f79387c59f6ecc38f47d4c942b549130eee579ec1a91f6a291/av-17.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:d13250fb4b4522e9a6bec32da082556d5f257110ea223758151375748d9bbe25", size = 23483029, upload-time = "2026-03-14T14:38:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/a9/fb/b4419494bfc249163ec393c613966d66db7e95c76da3345711cd115a79df/av-17.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:dbb56aa3b7ae72451d1bf6e9d37c7d83d39b97af712f73583ff419fbf08fc237", size = 18920446, upload-time = "2026-03-14T14:38:27.905Z" }, - { url = "https://files.pythonhosted.org/packages/30/62/c2306d91602ddad2c56106f21dcb334fd51d5ea2e952f7fa025bb8aa39fc/av-17.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a213ac9e83b7ab12c2e9f277a09cac8e9d85cf0883efdab7a87a60e2e4e48879", size = 37477266, upload-time = "2026-03-14T14:38:30.404Z" }, - { url = "https://files.pythonhosted.org/packages/28/cd/c8510a9607886785c0b3ca019d503e888c3757529be42a7287fe2bfa92d5/av-17.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:e15c88bb0921f9435bcc5a27a0863dba571a80ad5e1389c4fcf2073833bb4a74", size = 39572988, upload-time = "2026-03-14T14:38:32.984Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2d/207d9361e25b5abec9be335bbab4df6b6b838e2214be4b374f4cfb285427/av-17.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:096cfd1e9fc896506726c7c42aaf9b370e78c2f257cde4d6ddb6c889bfcc49ec", size = 38399591, upload-time = "2026-03-14T14:38:35.465Z" }, - { url = "https://files.pythonhosted.org/packages/73/ca/307740c6aa2980966bf11383ffcb04bacc5b13f3d268ab4cfb274ad6f793/av-17.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3649ab3d2c7f58049ded1a36e100c0d8fd529cf258f41dd88678ba824034d8c9", size = 40590681, upload-time = "2026-03-14T14:38:38.269Z" }, - { url = "https://files.pythonhosted.org/packages/35/f2/6fdb26d0651adf409864cb2a0d60da107e467d3d1aabc94b234ead54324a/av-17.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e5002271ab2135b551d980c2db8f3299d452e3b9d3633f24f6bb57fffe91cd10", size = 29216337, upload-time = "2026-03-14T14:38:40.83Z" }, - { url = "https://files.pythonhosted.org/packages/41/0a/0896b829a39b5669a2d811e1a79598de661693685cd62b31f11d0c18e65b/av-17.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dba98603fc4665b4f750de86fbaf6c0cfaece970671a9b529e0e3d1711e8367e", size = 22071058, upload-time = "2026-03-14T14:38:43.663Z" }, ] [[package]] @@ -116,17 +104,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/ec/56/4744bcd0c82184e80 wheels = [ { url = "https://files.pythonhosted.org/packages/e3/fd/9bf53b6a6f6f519ffaac765df2f2a25e5c2fc6d32cfd2b2747099e72c911/backrefs-8.0-py310-none-any.whl", hash = "sha256:4a627b817fd2dce43b79ab48da63613340509381cd8ce0897078a0bce79a2ab8", size = 380377, upload-time = "2026-07-26T19:54:17.457Z" }, { url = "https://files.pythonhosted.org/packages/e1/29/4bd7ae72a2634da00379c2b3bcc5439e7c94620235c6afea8af15229a973/backrefs-8.0-py311-none-any.whl", hash = "sha256:f0c35cf0102ba6b6070c12a492be3c1c1d3f5839529784b9a9565d6d04569a01", size = 392169, upload-time = "2026-07-26T19:54:18.782Z" }, - { url = "https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-none-any.whl", hash = "sha256:87f0fae8c5f207fe9f4b2887efc71d42f4900ac78faa1af08d675ef303692dc5", size = 398084, upload-time = "2026-07-26T19:54:19.954Z" }, - { url = "https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl", hash = "sha256:601ce68ca12385dbda06ce264406b4c4210cf5b79fd0fd627592365c92f29a88", size = 400040, upload-time = "2026-07-26T19:54:21.194Z" }, - { url = "https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl", hash = "sha256:9ec96efa080938be92323e8e730e57718c9c88eb15ad70bbef4e1766df591408", size = 411903, upload-time = "2026-07-26T19:54:23.221Z" }, ] [[package]] name = "blendjob" version = "0.1.14" -source = { registry = "src/anyimage/wheels" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/ad/b968528d83ffb4b97678a6ae98d744186c62e59003b1d3947d6fe517b83b/blendjob-0.1.14.tar.gz", hash = "sha256:a5da8867dbaac7f04373a97211daaf53b626554cf7f15959f977e577cfbc542e", size = 21466, upload-time = "2026-08-23T03:44:09.887Z" } wheels = [ - { path = "blendjob-0.1.14-py3-none-any.whl" }, + { url = "https://files.pythonhosted.org/packages/03/19/c7d81a8377a9146d26d454f6eceab42b358ca1ef775f9e93d979fc254e7f/blendjob-0.1.14-py3-none-any.whl", hash = "sha256:fed773b1e091c4bea4a869eca7cd8028de56b65e92033da75b24d2ab15198b4e", size = 26842, upload-time = "2026-08-23T03:44:09.091Z" }, ] [[package]] @@ -178,112 +164,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, - { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, - { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, - { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, - { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, - { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, - { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, - { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, - { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, - { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, - { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, - { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, - { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, - { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, - { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, - { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, - { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, - { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, - { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, - { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, - { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, - { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, - { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, - { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, - { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, - { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, - { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, - { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, - { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, - { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, - { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, - { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, - { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, - { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, - { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, - { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, - { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, - { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, - { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, - { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, - { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, - { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, - { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, - { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, - { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, - { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, - { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, - { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, - { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, - { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, - { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, - { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, - { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, - { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, - { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, - { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, - { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, - { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, - { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, - { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, - { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, - { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, - { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, - { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, - { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, - { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, - { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, - { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, - { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, - { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, - { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, - { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, - { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, - { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, - { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, - { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, - { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, - { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, @@ -334,18 +214,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/bf/0f280f7960c129957d2ce650e7fd7dec8e706c26d61a428e93927f4c9904/cython-3.2.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cac792b0bde1c86d8f832e513cd061b7e682181cc5a6d843d487e6c8ae9d5fcb", size = 3307656, upload-time = "2026-07-24T06:21:46.649Z" }, { url = "https://files.pythonhosted.org/packages/ac/cd/6d7c35a1065f1a07c9fa5ec784ee562f32f36a8fd05748e5f10694887eb7/cython-3.2.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c843dd85857cd0d0da055489f448d032866120cfb094ce16205a1ff54d06353", size = 3459259, upload-time = "2026-07-24T06:21:48.881Z" }, { url = "https://files.pythonhosted.org/packages/9f/f6/c1ad54ec35fcd5c5a5808d8b5b6319d5ae96acc63f7b5c1f7812f8cc3f71/cython-3.2.9-cp311-cp311-win_amd64.whl", hash = "sha256:efd54fe07f808e7e82f6a04f370457ca02e770c948f0e2f83345ccc7ec8bb829", size = 2789027, upload-time = "2026-07-24T06:21:50.551Z" }, - { url = "https://files.pythonhosted.org/packages/fd/37/c74d842306c8fe381c415b37460d5e3086a820fac72b8ff5cb48513ccfcd/cython-3.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:114b2dee0fa1daa48a59574d848da0ff1b6bdb725a755e9b92fad14962e1ff8d", size = 3009571, upload-time = "2026-07-24T06:21:52.534Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/f7c42b161edd585e3ae556fd62a2c72cd80a6ed527a9907f0c5c6fb060de/cython-3.2.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5cd9c5f138cb052130b40ad3b6976d2180c35348410995812678f4636bd8f94", size = 3183562, upload-time = "2026-07-24T06:21:54.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/1b/c04520ac7f3157aa12a69b632c16261170dac9fab6c48608cc004b8f1b17/cython-3.2.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23e80bc885c599e72072e18d0746df82d394b73100c1e153cda7359e6e59fe09", size = 3354811, upload-time = "2026-07-24T06:21:56.72Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/4ce33235a25b19fcd51dc639f0f403b783a3b7f9b1934eade0d993fbe029/cython-3.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b1fd5a9c03f72a18618668a8e90d569442ed742f910e3ad003dcc9348e9598b", size = 2778077, upload-time = "2026-07-24T06:21:58.7Z" }, - { url = "https://files.pythonhosted.org/packages/83/fa/7ffbaab88558e28411715ad35d913cac7d64ee965344c185325da84c4309/cython-3.2.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44b7fc417933d65bf31bce00337ff318efa3ea59daed21d10fe842d41e657c08", size = 2998816, upload-time = "2026-07-24T06:22:00.822Z" }, - { url = "https://files.pythonhosted.org/packages/04/69/feed68904f389452494d9e0861c24228c0bda656f9fbd32f9e5b525b2ef4/cython-3.2.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41637c644d7224d2d3170ee312077c2173693f0d4a0da1c82a0cffa3680a42dc", size = 3185802, upload-time = "2026-07-24T06:22:02.831Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0d/a7f094343e5c4925efac60e6dbdcf8602042e7f54c83ae14ccbc8033d2d9/cython-3.2.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f815cd3387bd88ca9eeabc357ce4bdf884eef0ab2949db0fbd5e0b69fe5ee422", size = 3360405, upload-time = "2026-07-24T06:22:04.645Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ba/a5c25226e29cb4f3a2c97d2b29de10e37c8a6b67b3c2f2a8b16ba713b218/cython-3.2.9-cp313-cp313-win_amd64.whl", hash = "sha256:ddb43637b7749b88644df4319e1c36f767e7fb71a92bb9942558c8de2f1cc5f4", size = 2774714, upload-time = "2026-07-24T06:22:06.953Z" }, - { url = "https://files.pythonhosted.org/packages/3c/69/e5969fa87c7b8833f62fe790e1617a1391112fa55fe12079c41a4553119c/cython-3.2.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63a42e131112072f912b66734088abc946c6bc42cb7454461a6dfdd2d09d3bae", size = 3013040, upload-time = "2026-07-24T06:22:08.9Z" }, - { url = "https://files.pythonhosted.org/packages/b4/98/8eaceeec00d4f9cc75e3106397c005952e0d710332c4966215b8a110a522/cython-3.2.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f25b402a6f1eed34af2af57603a9aadcf594dacaced1655e925eb317fba3f337", size = 3231531, upload-time = "2026-07-24T06:22:11.028Z" }, - { url = "https://files.pythonhosted.org/packages/94/a4/1e03a0c115afa9de7b06891721d6489d6a05f78934a6897a0f5a409ee2f5/cython-3.2.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d3cba9c4770cf76370fead8d2ae71bc474f3ef67ae4119e455d025726bcdc1c", size = 3372818, upload-time = "2026-07-24T06:22:12.97Z" }, - { url = "https://files.pythonhosted.org/packages/1b/04/b5c4d76723824d84e3761718c51964f7829335ee1c7c16b69f11b111adf0/cython-3.2.9-cp314-cp314-win_amd64.whl", hash = "sha256:56d95c0674c25f281c6ae8f1d17bd425d6c2818bb304ff781831bb5d00d04b0b", size = 2812223, upload-time = "2026-07-24T06:22:14.9Z" }, { url = "https://files.pythonhosted.org/packages/0a/4a/342312c5fe021c8e0c386e1915d138e0902c48ae179b0374ab04773a8831/cython-3.2.9-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:944dc8747f640b3527649c566a5fc75ee0c15e80642ea2fdae4fe6378e1a9d4a", size = 2899729, upload-time = "2026-07-24T06:22:24.877Z" }, { url = "https://files.pythonhosted.org/packages/f0/62/ea919ee426cb4d435ec8155e1ee6bcbb46b20d8f070527191b59769d4e7f/cython-3.2.9-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4b871ad97dd7fb1cbf56f6238c54423febd310afc1d9d9bc70c69c89b7ce57fc", size = 3226650, upload-time = "2026-07-24T06:22:26.947Z" }, { url = "https://files.pythonhosted.org/packages/18/02/057b4f63e2ced8c3cf217c4e9fb544bfe48145f493347c7ca3f51607526c/cython-3.2.9-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9b6ebc6c74b4318eaa4e51e520dc8b95ebc7b262953c3ecb24131104681f14e", size = 2881919, upload-time = "2026-07-24T06:22:29.318Z" }, @@ -411,14 +279,6 @@ version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/62/3c062f593bd92ef4e77a0ef39541e3d82a0a1d3947c8a777a02a13a27828/hf_xet-1.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d", size = 4074584, upload-time = "2026-08-03T22:32:47.364Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1e/c0ad437dd267a8e435bef594acf781bbc3874ff0b6435b4962d03ecf7cc4/hf_xet-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675", size = 3867381, upload-time = "2026-08-03T22:32:49.049Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ee/7c0d7b6ab336167531b1c30af2af003f054af4c749becbd7209ae33a77c3/hf_xet-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b", size = 4453982, upload-time = "2026-08-03T22:32:50.568Z" }, - { url = "https://files.pythonhosted.org/packages/63/06/ad8eab1c9525246650cbaa821caa3cdbaca734ab1a5b8c91bea09cbd8d69/hf_xet-1.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522", size = 4249445, upload-time = "2026-08-03T22:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/d8/26/1eee8aedb0dafc1ab9717dc9ac602cde33361b232dc06803f1f6ed18b58c/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e", size = 4451099, upload-time = "2026-08-03T22:32:54.114Z" }, - { url = "https://files.pythonhosted.org/packages/67/57/0b88af1f194ab6c9c650547d9cc06bfeaab836ae4dcdb331676bfb8be95a/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9", size = 4664712, upload-time = "2026-08-03T22:32:55.547Z" }, - { url = "https://files.pythonhosted.org/packages/53/a0/26b717a9d1840e8abf48dcec64b5ed8fbe472671d38ad28d30e147132b33/hf_xet-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338", size = 4025906, upload-time = "2026-08-03T22:32:57.391Z" }, - { url = "https://files.pythonhosted.org/packages/49/f6/4a9966633c6fef83af997e2cff68ec1963676d412bdfd096df2a93b8e185/hf_xet-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765", size = 3849221, upload-time = "2026-08-03T22:32:59.123Z" }, { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" }, { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" }, @@ -546,61 +406,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -730,14 +535,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567, upload-time = "2024-02-05T23:54:11.696Z" }, { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812, upload-time = "2024-02-05T23:54:26.453Z" }, { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913, upload-time = "2024-02-05T23:54:53.933Z" }, - { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" }, - { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" }, - { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" }, - { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, - { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, - { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, - { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, - { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, ] [[package]] @@ -757,25 +554,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/b6/7a4df417cdd01e8f067a509e123ac8b31af450a719fa7ed81787dd6057ec/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e54ad52e61d2d4618dcff8fa1480ac66b24ee2eab73331322db1049f11ccf330", size = 17222993, upload-time = "2026-03-17T22:04:34.485Z" }, { url = "https://files.pythonhosted.org/packages/dd/59/8febe015f391aa1757fa5ba82c759ea4b6c14ef970132efb5e316665ba61/onnxruntime-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b43b63eb24a2bc8fc77a09be67587a570967a412cccb837b6245ccb546691153", size = 12594863, upload-time = "2026-03-17T22:05:38.749Z" }, { url = "https://files.pythonhosted.org/packages/32/84/4155fcd362e8873eb6ce305acfeeadacd9e0e59415adac474bea3d9281bb/onnxruntime-1.24.4-cp311-cp311-win_arm64.whl", hash = "sha256:e26478356dba25631fb3f20112e345f8e8bf62c499bb497e8a559f7d69cf7e7b", size = 12259895, upload-time = "2026-03-17T22:05:28.812Z" }, - { url = "https://files.pythonhosted.org/packages/d7/38/31db1b232b4ba960065a90c1506ad7a56995cd8482033184e97fadca17cc/onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78", size = 17341875, upload-time = "2026-03-17T22:05:51.669Z" }, - { url = "https://files.pythonhosted.org/packages/aa/60/c4d1c8043eb42f8a9aa9e931c8c293d289c48ff463267130eca97d13357f/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5", size = 15172485, upload-time = "2026-03-17T22:03:32.182Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ab/5b68110e0460d73fad814d5bd11c7b1ddcce5c37b10177eb264d6a36e331/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c", size = 17244912, upload-time = "2026-03-17T22:04:37.251Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f4/6b89e297b93704345f0f3f8c62229bee323ef25682a3f9b4f89a39324950/onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb", size = 12596856, upload-time = "2026-03-17T22:05:41.224Z" }, - { url = "https://files.pythonhosted.org/packages/43/06/8b8ec6e9e6a474fcd5d772453f627ad4549dfe3ab8c0bf70af5afcde551b/onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90", size = 12270275, upload-time = "2026-03-17T22:05:31.132Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f0/8a21ec0a97e40abb7d8da1e8b20fb9e1af509cc6d191f6faa75f73622fb2/onnxruntime-1.24.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0", size = 17341922, upload-time = "2026-03-17T22:03:56.364Z" }, - { url = "https://files.pythonhosted.org/packages/8b/25/d7908de8e08cee9abfa15b8aa82349b79733ae5865162a3609c11598805d/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13", size = 15172290, upload-time = "2026-03-17T22:03:37.124Z" }, - { url = "https://files.pythonhosted.org/packages/7f/72/105ec27a78c5aa0154a7c0cd8c41c19a97799c3b12fc30392928997e3be3/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f", size = 17244738, upload-time = "2026-03-17T22:04:40.625Z" }, - { url = "https://files.pythonhosted.org/packages/05/fb/a592736d968c2f58e12de4d52088dda8e0e724b26ad5c0487263adb45875/onnxruntime-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93", size = 12597435, upload-time = "2026-03-17T22:05:43.826Z" }, - { url = "https://files.pythonhosted.org/packages/ad/04/ae2479e9841b64bd2eb44f8a64756c62593f896514369a11243b1b86ca5c/onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19", size = 12269852, upload-time = "2026-03-17T22:05:33.353Z" }, - { url = "https://files.pythonhosted.org/packages/b4/af/a479a536c4398ffaf49fbbe755f45d5b8726bdb4335ab31b537f3d7149b8/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee", size = 15176861, upload-time = "2026-03-17T22:03:40.143Z" }, - { url = "https://files.pythonhosted.org/packages/be/13/19f5da70c346a76037da2c2851ecbf1266e61d7f0dcdb887c667210d4608/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36", size = 17247454, upload-time = "2026-03-17T22:04:46.643Z" }, - { url = "https://files.pythonhosted.org/packages/89/db/b30dbbd6037847b205ab75d962bc349bf1e46d02a65b30d7047a6893ffd6/onnxruntime-1.24.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4", size = 17343300, upload-time = "2026-03-17T22:03:59.223Z" }, - { url = "https://files.pythonhosted.org/packages/61/88/1746c0e7959961475b84c776d35601a21d445f463c93b1433a409ec3e188/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1", size = 15175936, upload-time = "2026-03-17T22:03:43.671Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ba/4699cde04a52cece66cbebc85bd8335a0d3b9ad485abc9a2e15946a1349d/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177", size = 17246432, upload-time = "2026-03-17T22:04:49.58Z" }, - { url = "https://files.pythonhosted.org/packages/ef/60/4590910841bb28bd3b4b388a9efbedf4e2d2cca99ddf0c863642b4e87814/onnxruntime-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858", size = 12903276, upload-time = "2026-03-17T22:05:46.349Z" }, - { url = "https://files.pythonhosted.org/packages/7f/6f/60e2c0acea1e1ac09b3e794b5a19c166eebf91c0b860b3e6db8e74983fda/onnxruntime-1.24.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d", size = 12594365, upload-time = "2026-03-17T22:05:35.795Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/0c05d10f8f6c40fe0912ebec0d5a33884aaa2af2053507e864dab0883208/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661", size = 15176889, upload-time = "2026-03-17T22:03:48.021Z" }, - { url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" }, ] [[package]] @@ -822,67 +600,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, @@ -1008,44 +725,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] @@ -1205,12 +884,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, @@ -1246,53 +919,4 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, - { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, - { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, - { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, - { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, - { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, - { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, - { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, - { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, - { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, - { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, - { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, - { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, - { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, - { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, - { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, - { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, - { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, - { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, - { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, - { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, - { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, - { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, - { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, - { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, - { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, - { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, - { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, - { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, - { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, - { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, - { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, - { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, - { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, ]