From e7aeeb21d44d110518a77903f12a8bd1d3d0ad4c Mon Sep 17 00:00:00 2001 From: nan Date: Mon, 31 Aug 2026 15:15:33 +0800 Subject: [PATCH] Add tools and tests for AnyImage model management and documentation - Implement sync_models.py for preparing and uploading ONNX artifacts. - Create build_node.py for building and validating Blender scripts. - Add docs.py for building and previewing documentation with Mike or MkDocs. - Introduce model_exports.py for exporting PyTorch models to ONNX format. - Develop pack.py for packaging the project with dependencies for different platforms. - Add test_sync_models.py to validate sync_models functionality. - Add test_tool_commands.py to ensure repository tools have correct command scripts. - Initialize tools package with __init__.py. --- .github/workflows/build.yml | 2 +- .gitignore | 4 +- AGENTS.md | 127 +++--- README.md | 79 +--- docs/assets/logo.png | 3 + docs/getting-started/installation.md | 7 +- docs/getting-started/quick-start.md | 11 +- docs/guides/animation.md | 33 -- docs/guides/clipboard-image.md | 43 -- docs/guides/cutout.md | 61 --- docs/guides/depth-plane.md | 36 -- docs/guides/faq.md | 54 --- docs/guides/image-tools.md | 82 ---- docs/guides/mesh-plane.md | 27 -- docs/guides/models-and-storage.md | 66 --- docs/guides/remove-background.md | 23 -- docs/guides/upscale.md | 27 -- docs/guides/workflows.md | 41 -- docs/index.md | 42 -- docs/internals/architecture.md | 25 +- docs/internals/common.md | 31 +- docs/internals/development.md | 33 +- docs/internals/index.md | 15 +- docs/internals/node-assets.md | 88 ++-- docs/internals/operators/ai-setup.md | 4 +- docs/internals/operators/clipboard-image.md | 6 +- docs/internals/operators/convert-to-plane.md | 2 +- docs/internals/operators/cutout-tool.md | 26 +- docs/internals/operators/debug.md | 8 +- docs/internals/operators/image-tool.md | 10 +- docs/internals/operators/index.md | 4 +- docs/internals/operators/perspective-tool.md | 10 +- docs/internals/operators/remove-background.md | 8 +- docs/internals/operators/upscale.md | 10 +- docs/internals/outputs.md | 10 +- docs/internals/runtime.md | 12 +- docs/reference/features.md | 55 --- docs/stylesheets/extra.css | 53 +-- mkdocs.yml | 22 +- pyproject.toml | 26 +- src/anyimage/assets/O_AnyImage.blend | 2 +- src/anyimage/common/__init__.py | 1 + src/anyimage/{operators => }/common/ai_job.py | 6 +- .../{operators => }/common/depth_data.py | 0 .../{operators => }/common/image_data.py | 0 .../common/image_interaction.py | 2 +- .../{operators => }/common/image_region.py | 0 src/anyimage/common/node.py | 47 +++ .../mesh_result.py => common/object.py} | 51 +-- src/anyimage/menu.py | 2 +- src/anyimage/operators/ai_setup.py | 2 +- src/anyimage/operators/common/__init__.py | 1 - .../operators/convert_to_plane/conversion.py | 6 +- .../operators/convert_to_plane/mesh_plane.py | 6 +- .../operators/convert_to_plane/nodes.py | 23 -- .../operators/convert_to_plane/object.py | 13 +- .../operators/cutout_tool/geometry.py | 2 +- .../operators/cutout_tool/interaction.py | 13 +- src/anyimage/operators/cutout_tool/main.py | 10 +- src/anyimage/operators/cutout_tool/object.py | 10 +- src/anyimage/operators/debug.py | 2 +- src/anyimage/operators/image_tool/box.py | 2 +- .../operators/image_tool/edit_mask.py | 8 +- src/anyimage/operators/image_tool/lasso.py | 2 +- .../perspective_tool/edit_perspective.py | 8 +- src/anyimage/operators/remove_background.py | 6 +- src/anyimage/operators/upscale.py | 4 +- sync_models.py | 173 -------- .../{test_addon.py => test_blender_addon.py} | 390 +++++++++--------- .../{test_mesh.py => test_cutout_geometry.py} | 74 ++-- ...t_cutout.py => test_cutout_interaction.py} | 36 +- ...mesh_blender.py => test_cutout_objects.py} | 208 +++++----- tests/test_docs.py | 42 ++ ...manifest.py => test_extension_manifest.py} | 2 +- ...st_pack.py => test_extension_packaging.py} | 23 +- tests/{test_image.py => test_image_data.py} | 22 +- ...t_ben2_onnx.py => test_model_ben2_onnx.py} | 0 .../{test_models.py => test_model_catalog.py} | 2 +- ...est_da3_onnx.py => test_model_da3_onnx.py} | 0 ...oad_progress.py => test_model_download.py} | 2 +- ..._moge_onnx.py => test_model_moge2_onnx.py} | 2 +- ...ale_onnx.py => test_model_upscale_onnx.py} | 0 tests/test_node_asset_launcher.py | 72 ++++ tests/test_node_assets.py | 26 +- tests/{test_worker.py => test_server_jobs.py} | 2 +- ...{test_server.py => test_server_runtime.py} | 2 +- tests/test_sync_models.py | 99 +++++ tests/test_tool_commands.py | 26 ++ tools/__init__.py | 1 + tools/build_node.py | 91 ++++ tools/docs.py | 48 +++ tools/model_exports.py | 204 +++++++++ tools/node_assets/build_material_layers.py | 2 +- tools/node_assets/validate.py | 21 +- tools/node_assets/validate_cutout.py | 2 +- tools/node_assets/validate_mesh_plane.py | 2 +- pack.py => tools/pack.py | 8 +- tools/sync_models.py | 244 +++++++++++ uv.lock | 348 +++++++++++++++- 99 files changed, 1953 insertions(+), 1676 deletions(-) create mode 100644 docs/assets/logo.png delete mode 100644 docs/guides/animation.md delete mode 100644 docs/guides/clipboard-image.md delete mode 100644 docs/guides/cutout.md delete mode 100644 docs/guides/depth-plane.md delete mode 100644 docs/guides/faq.md delete mode 100644 docs/guides/image-tools.md delete mode 100644 docs/guides/mesh-plane.md delete mode 100644 docs/guides/models-and-storage.md delete mode 100644 docs/guides/remove-background.md delete mode 100644 docs/guides/upscale.md delete mode 100644 docs/guides/workflows.md delete mode 100644 docs/reference/features.md create mode 100644 src/anyimage/common/__init__.py rename src/anyimage/{operators => }/common/ai_job.py (93%) rename src/anyimage/{operators => }/common/depth_data.py (100%) rename src/anyimage/{operators => }/common/image_data.py (100%) rename src/anyimage/{operators => }/common/image_interaction.py (99%) rename src/anyimage/{operators => }/common/image_region.py (100%) create mode 100644 src/anyimage/common/node.py rename src/anyimage/{operators/common/mesh_result.py => common/object.py} (86%) delete mode 100644 src/anyimage/operators/common/__init__.py delete mode 100644 src/anyimage/operators/convert_to_plane/nodes.py delete mode 100644 sync_models.py rename tests/{test_addon.py => test_blender_addon.py} (86%) rename tests/{test_mesh.py => test_cutout_geometry.py} (86%) rename tests/{test_cutout.py => test_cutout_interaction.py} (76%) rename tests/{test_mesh_blender.py => test_cutout_objects.py} (85%) create mode 100644 tests/test_docs.py rename tests/{test_manifest.py => test_extension_manifest.py} (94%) rename tests/{test_pack.py => test_extension_packaging.py} (93%) rename tests/{test_image.py => test_image_data.py} (97%) rename tests/{test_ben2_onnx.py => test_model_ben2_onnx.py} (100%) rename tests/{test_models.py => test_model_catalog.py} (99%) rename tests/{test_da3_onnx.py => test_model_da3_onnx.py} (100%) rename tests/{test_download_progress.py => test_model_download.py} (99%) rename tests/{test_moge_onnx.py => test_model_moge2_onnx.py} (99%) rename tests/{test_upscale_onnx.py => test_model_upscale_onnx.py} (100%) create mode 100644 tests/test_node_asset_launcher.py rename tests/{test_worker.py => test_server_jobs.py} (99%) rename tests/{test_server.py => test_server_runtime.py} (99%) create mode 100644 tests/test_sync_models.py create mode 100644 tests/test_tool_commands.py create mode 100644 tools/__init__.py create mode 100644 tools/build_node.py create mode 100644 tools/docs.py create mode 100644 tools/model_exports.py rename pack.py => tools/pack.py (98%) create mode 100644 tools/sync_models.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3a33699..8034f81 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: run: uv run --frozen pytest - name: 构建扩展 - run: uv run --frozen python pack.py + run: uv run --frozen pack - name: 发布扩展包 uses: https://gitea.com/actions/gitea-release-action@v1.3.6 diff --git a/.gitignore b/.gitignore index b4c7ea4..9fe7029 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ __pycache__/ *.py[cod] .venv/ +.env .uv-cache/ dist/ build/ @@ -8,4 +9,5 @@ build/ .pytest_cache/ /models/ -wheels/ \ No newline at end of file +/.model-cache/ +wheels/ diff --git a/AGENTS.md b/AGENTS.md index 5682e06..cac3017 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,103 +1,90 @@ -# AnyImage 开发约定 +# AnyImage 项目规范 ## 总则 -- 说明性内容(提交说明、文档、注释、Mermaid 等)以中文为主,专有名词和约定俗成的表达可使用英文 -- 动手前先读 `docs/index.md`;程序边界、调用流程和模块职责见 `docs/internals/index.md` 与 `docs/internals/architecture.md` -- 修改代码、依赖或执行方式后,同步更新 `README.md` 与对应主题文档,保持文档、测试和实现一致 -- 优先选择依赖少、直接、易验证的实现;保留工作区中与当前任务无关的已有改动 +**不管是代码还是文档,遵循「少就是多」的原则,以好理解为首要要求** -## 内部实现文档 +- 说明性内容以中文为主,专有名词和约定俗成的表达可使用英文 +- 修改代码、依赖或执行方式后, + - 不保留旧路径、转发函数、兼容层或胶水代码 + - 仅过测试,不改同步改文档,不构建文档、产物 +- 优先选择依赖少、直接、易验证的实现 +- 复用来自 `common` 的函数,避免重复造轮子 +- 确保文件名/文件夹名和内容边界一致 -- [内部实现总览](docs/internals/index.md):内部实现文档的统一入口 +## 查询文档 + +下列是重要文档 + +- [总览](docs/internals/index.md):内部实现的总揽 - [程序架构](docs/internals/architecture.md):进程边界、职责与调用主链 -- [Operator 实现索引](docs/internals/operators/index.md):源码功能与实现页的对应关系 - -修改完整工作流时同步更新对应实现页。`operators/common/` 不单独建立页面,共享边界写入程序架构。 - -## 模块与目录边界 - -- 目录结构应能直接表达业务模块和职责;优先按用户可理解的功能或稳定能力组织,不按模糊的数据类型、临时步骤或界面摆放机械分组 -- 一个模块只承担一个可描述的职责。功能出现多个相对独立的交互、算法或构建阶段时再建立子目录,并让同一抽象层的实现并列 -- 公共层来自已经存在的复用,不来自对未来的猜测。没有真实共享消费者的代码留在所属模块;抽取后只暴露消费者需要的最小接口 -- 模块边界优先服从运行进程、依赖方向、数据所有权和生命周期。只在一侧处理的数据不为形式上的“共享”跨越边界;确因加载机制产生的例外应在架构文档中说明 -- 编排层只组合步骤和定义公开协议,原子能力放在所属领域模块。编排模块之间不互相导入实现,共享逻辑应下沉 -- 公开任务与界面入口不要求一一对应;边界由可复用计算、输入输出协议和失败语义决定。名称必须描述实际动作与产物,不暗示未承担的职责 -- 没有生产消费者的入口、兼容层和辅助协议应删除,不为测试便利单独保留 -- 移动或拆分模块时同步更新注册、导入、打包、测试和文档,不留下旧路径转发层 +- [Operator 索引](docs/internals/operators/index.md):Operator 实现功能和对应源码 +- [节点资产](docs/internals/node-assets.md):节点组的构建与验证(节点组不在运行时新建) ## 常见命令 -优先在项目根目录使用 Git Bash;Windows 路径在 Bash 命令中使用正斜杠。 - ```bash # 安装或同步开发依赖 uv sync +# 本地 BlendJob wheel 内容变化后刷新锁文件 +uv lock --refresh + # 运行全部测试 uv run pytest -# 运行插件注册、Manifest 与打包相关的快速测试 -uv run pytest tests/test_addon.py tests/test_manifest.py tests/test_pack.py -v - # 构建 Windows、macOS、Linux 扩展包到 dist/ -uv run python pack.py +uv run pack -# 本地 BlendJob wheel 内容变化后刷新锁文件 -uv lock --refresh +# 构建并验证 Blender 节点资产 +uv run build_node + +# 准备、校验并同步 ONNX 模型到 R2 +uv run sync_models ``` -资产脚本必须由 Blender 执行;具体入口与验证范围见 [节点资产](docs/internals/node-assets.md),不要用普通 Python 解释器运行。 - -`tools/` 中的脚本按维护对象分组,并统一使用能够区分“构建”和“验证”等动作的命名。每个脚本都应有明确入口、文档或测试消费者;没有实际用途的脚本应删除。 - ## 命名规范 -- 变量、函数、Docstring 使用英文;同一概念在代码和文档中使用统一表达 -- 函数的动作前缀应表达产物边界:`generate_*` 表示 AI 生成,`build_*` 只在内存中构建值或几何,`create_*` 表示创建 Blender Image、Mesh、Material、Object 等真实数据 +- 变量、函数、Docstring 必须使用英文;同一概念在代码和文档中使用统一表达 +- 函数的动作前缀应表达产物边界:`generate_*` 表示 AI 生成。`build_*` 表示代码层构建。`create_*` 表示 Blender 内创建 - 名称应说明业务职责,并与用户概念、公开协议和界面术语保持一致;不要使用脱离上下文后无法判断用途的宽泛名称 -- 文件和目录名应表达稳定的业务分类和模块职责,不直接沿用函数名,也不按临时执行步骤命名;集合层使用复数,单项分类使用单数,同一层级保持一致 -- 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 Class 使用动宾结构,如 `ConvertToDepthPlane`、`RemoveImageBackground` +- Menu、Panel、AddonPreferences、PropertyGroup 分别使用实际类型后缀,如 `AnyImageImageMenu`、`ServerPanel`、`AnyImagePreferences`、`AnyImageSettings` +- `bl_idname` 使用 `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/operators/`,模型下载、ONNX 推理和输出放在 `src/anyimage/server/` -- 扩展级设置及其读取入口统一放在 `preferences.py`;使用 `addon_preferences()` 读取,不把 Blender Add-on Preferences 的定位、默认值或路径解析放进专门管理 BlendJob 的 `runtime.py` +- `README.md` 面向用户,不在其中堆叠内部细节。实现原理放入 `docs/internals` 对应主题 +- 文档只正面描述当前主题的职责与实现,不记录无关模块、未采用路径、历史差异、边界辩解、“不做什么”的排除性信息。 +- 实现原理用流程图替代文字,Mermaid 默认使用从上到下 + +## Blender 开发规范 + +- 扩展级设置及其读取入口统一放在 `preferences.py`;使用 `addon_preferences()` 读取 - Job 参数使用可序列化的普通字典;Blender 数据只能在主线程的 Operator 响应阶段读写 - 新增或修改 Blender 类型时同步检查 `src/anyimage/__init__.py` 的注册与逆序注销,并补充对应测试 - 依赖版本、wheel 或 Manifest 改变时同步检查 `pyproject.toml`、`uv.lock`、`blender_manifest.toml` 和打包测试 - +- `server/` 是 blendjob 后端代码,无法引用来自它上级文件夹的内容。共享函数需单独实现。 ## Blender 节点组规范 -资产脚本必须由 Blender 执行;具体入口与验证范围见 [节点资产](docs/internals/node-assets.md),不要用普通 Python 解释器运行。 - -`tools/` 中的脚本按维护对象分组,并统一使用能够区分“构建”和“验证”等动作的命名。每个脚本都应有明确入口、文档或测试消费者;没有实际用途的脚本应删除。 - -- 生产运行时使用的可复用节点组必须构建并存储在 `src/anyimage/assets/O_AnyImage.blend`,由统一资产加载入口按名称加载;不得在 `src/anyimage/` 中通过 `bpy.data.node_groups.new()` 动态创建。节点组新增、修改或删除时同步更新资产构建、验证、测试和节点资产文档 - -- 不修改节点的 `name`,也不使用 `label` 覆盖节点标题;保留 Blender 为节点类型生成的默认名称和外观,避免读图时无法判断节点原型。用途应通过节点类型、参数、空间布局和必要的少量注释表达 - -- 节点图优先依靠清晰的空间层次表达流程,不用五颜六色的 Frame 做分类;仅在重复区、模拟区等确有包裹语义时少量使用 Frame,并保持配色克制 -- 避免跨越大段节点图的连线;来自 Group Input 的参数可按使用区域放置多个 Group Input,并隐藏未连接的 sockets -- 主处理链尽量保持为一根从左到右连续可见的几何主线,辅助字段和控制分支放在主线上下两侧,就近汇入消费者 -- 避免交错连线、节点重叠和不必要的回折;新增或重建节点组时检查节点坐标唯一性和主要连线方向 -- Repeat、Simulation、For Each Geometry Element 等具有输入/输出“头尾”的成对结构,应先确定完整包裹范围,再放置内部节点,保证头尾顺序、配对关系和区域边界一眼可辨 -- 作为修改器使用的 Geometry Nodes,除确实需要动态字段或其他结构的接口外,输入 socket 的 structure_type 应设为 SINGLE -- 为接口 socket 设置符合业务含义的 `subtype`;例如表示 Blender 场景实际长度的浮点输入使用 `DISTANCE`,明确限制为 `0–1` 的比例使用 `FACTOR`,以 `0–100` 表示的百分比使用 `PERCENTAGE`,角度使用对应 subtype。无上限倍率等不符合范围语义的数值保留普通 Float,不为显示形式强套 subtype -- 调整节点接口或布局后,应补充或更新 Blender 验证,检查 socket 顺序与结构、节点不重叠、Group Input 未使用输出已隐藏,以及几何主线可正常求值 - -## 文档与 Markdown 规范 - -- `README.md` 面向用户;实现原理放入 `docs/` 对应主题,不在 README 堆叠内部细节 -- 不使用 `---` 分隔线;一级标题只在文档开头使用一次 -- Mermaid 节点 ID 使用英文字符,节点显示文本可以使用中文 -- 文档描述当前已实现行为;限制、默认值、命令和文件名必须能从代码或测试中验证 +- 节点组统一存入 `assets/O_AnyImage.blend`,运行时按名称加载(`load_node_group(group_name)`) +- 节点组通过[节点资产](docs/internals/node-assets.md)说明构建,禁止运行时调用 `bpy.data.node_groups.new()` 新建 +- 节点组变更须同步构建脚本、验证、测试和节点资产文档 +- 保留节点默认 `name` 和标题,不用 `label` 覆盖 +- 主链从左到右连续排列;辅助分支就近汇入,避免长线、交错、重叠和回折 +- Group Input 可按区域重复放置,并隐藏未连接 sockets +- Frame 仅用于确有包裹语义的区域 +- Repeat、Simulation、For Each Geometry Element 等成对节点,确保位置正确,没有遮挡其他节点 +- 修改器 Geometry Nodes 的输入 socket 默认使用 `SINGLE` +- socket `subtype` 按业务语义设置:长度用 `DISTANCE`,`0–1` 比例用 `FACTOR`,`0–100` 用 `PERCENTAGE`;无对应范围语义时保留默认 +- 接口或布局变更须验证 socket 顺序与结构、节点不重叠、未使用输出隐藏及几何求值 diff --git a/README.md b/README.md index e81502d..7caf19b 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,12 @@ # AnyImage -AnyImage 是一款 Blender Extension,用于把图片带入可编辑的 2.5D 工作流:粘贴和清理图片、创建普通 Plane 或 Depth Plane、从套索生成 Cutout Shape,并通过独立 AI Environment 去除背景或进行 4× 高清放大。 +把图片里的空间感带进 Blender。 -完整说明从[文档首页](docs/index.md)开始;首次使用请阅读[安装与首次配置](docs/getting-started/installation.md)和[五分钟上手](docs/getting-started/quick-start.md)。 +AnyImage 是一款 Blender 扩展,可以粘贴和编辑图片、创建普通 Plane 或 Depth Plane、从局部选区生成 Cutout Shape,并在独立 AI Environment 中完成去背景与高清放大。 -## 主要功能 - -- 在 3D View、Node Editor 与绘画模式中用 `Ctrl+V`(macOS 为 `Cmd+V`)粘贴并 Pack 剪贴板图片 -- 将 Image Empty 转换为带 `O Mesh Plane` Geometry Nodes 的普通 Plane -- 使用 MoGe-2 将静态图转换为可调 Depth Plane -- 用 Cutout 套索在图片可见 Alpha 内生成 Surface、Balloon、Depth Balloon 或 Depth Surface,并通过统一的 `O Image Cutout` 修改器切换 Shape、按创建分辨率清理碎岛、根据 Region Alpha 校准浮雕基准和调整表面 -- 用 Image Box、Lasso、Line、Polyline 和 Perspective 清理或矫正静态图片 -- 用 BEN2 为单图、视频或序列帧去除背景 -- 用三档 ONNX 模型将单图、视频或序列帧放大 4×,并独立重采样 Alpha - -AI 推理运行在 Blender 之外的独立 Python 3.12 Environment 中。Blender 进程只负责 UI、任务提交和结果导入,不加载 ONNX Runtime 或模型权重。 - -**Maximum AI Input Size** 默认设为 2048 px,只限制 Upscale 可执行图片长边 以及 Depth、Normal Map、Fit to Foreground 的 AI 分析输入,不降低颜色贴图分辨率。Cutout 网格密度由默认 `1024` 的独立 **Maximum Cutout Mesh Resolution** 限制,因此本地与 AI Shape 使用同一套明确的网格上限。 - -## 安装 - -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。 - -普通 Plane、基础 Image 编辑,以及未启用 **Fit to Foreground** / **Generate Normal Map** 的 Cutout Surface 与 Balloon 无需 AI Environment。Cutout 的 Depth 与 Normal Map 统一使用 MoGe-2;两者同时启用时共享同一次 Prediction,不会重复运行模型。Cutout 的 **Generate Normal Map** 是启用开关;开启后,Depth Surface 自动使用 Object Space,其余 Shape 自动使用 Tangent Space。 - -Balloon 与 Depth Balloon 会用 `o_balloon` 驱动 `O Image Layer` 已有的 **Normal Scale**,使 Normal Map 强度在轮廓处回落。 - -## 快速使用 - -选中 Image Empty 后,右键 **AnyImage** 菜单提供: - -- **Convert to Plane**:同步创建普通图片网格; -- **Convert to Depth Plane**:使用 Preferences 中选择的深度模型处理静态图; -- **Remove Background**:生成透明图片或序列; -- **Upscale**:使用默认高清模型把当前图片编辑为 4× 结果。 - -3D Viewport 工具栏提供 Image Mask、Perspective 与 Cutout。Remove Background、Upscale、Image Mask 和 Perspective 都只更新当前 Empty;共享 Image 会保留原数据,并为当前 Empty 创建独立结果。工作流对比和当前输入范围见[选择工作流](docs/guides/workflows.md)。 - -## 源码结构 - -```text -src/anyimage/ -├── operators/ # Blender 交互、任务提交与结果导入 -├── server/ # 无 Blender 依赖的 Job、媒体、Region、几何与模型能力 -├── assets/ # Geometry Nodes 与 Shader 资产 -├── runtime.py # BlendJob Runtime 配置 -└── __init__.py # Blender 注册入口 -``` - -生产功能使用的可复用节点组统一构建到 `assets/O_AnyImage.blend`,运行时只按名称加载,不动态创建节点组。 - -程序边界、功能文件结构和调用流程见[内部实现总览](docs/internals/index.md);开发新功能前先查看[Common 公共函数](docs/internals/common.md),复用已经存在的 Image、Depth 产物、Material 和对象替换入口。文档按开始使用、功能指南、参考和内部实现分层,不在 README 重复内部算法细节。 - -## 开发 - -仓库开发环境使用 CPython 3.11: - -```bash -uv sync -uv run pytest -uv run python pack.py -``` - -节点资产脚本必须由 Blender 执行,具体命令和验证范围见[节点资产](docs/internals/node-assets.md)。完整开发、打包与 CI 说明见[开发与发布](docs/internals/development.md)。 - -## 许可证 - -AnyImage Extension 代码采用 GPL-3.0-or-later。模型权重使用各自上游许可证。 +- 按 **Ctrl+V**(macOS 为 **Cmd+V**)把剪贴板图片粘贴到当前编辑器 +- 用 **Plane** 创建可调厚度和细分的图片网格 +- 用 **Depth Plane** 把静态图片转换为可调节的立体面片 +- 用 **Cutout** 从套索选区生成 Surface、Balloon 与两种 Depth Shape +- 用 **Image Tools / Perspective** 直接清理或矫正 Image Empty +- 用 **Remove Background / Upscale** 处理单图、视频和序列帧 \ No newline at end of file diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 0000000..8f5aa2d --- /dev/null +++ b/docs/assets/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ca3028a094d5f2108c41de863dd81140c43f11b99d43b3a2681ed839f708a30 +size 24997 diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index f6d7e62..1673f88 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -25,10 +25,10 @@ AnyImage 的扩展偏好设置按功能分组。Environment 区包含: Environment 检测后显示 Diagnostics。Depth & Normal、Remove Background、Upscale 与 Cutout 是默认展开的可折叠功能区,各自包含对应设置和模型状态。其中: - **Default Upscale Model**:Upscale 使用的模型,默认是 General WDN x4v3。 -- **Maximum AI Input Size**:Upscale、Depth、Normal Map 与 Fit to Foreground 的 AI 输入长边上限,默认 2048 px;只缩放分析输入,不降低颜色贴图分辨率。 +- **Maximum AI Input Size**:Upscale、Depth、Normal Map 与 Fit to Foreground 的 AI 输入长边上限,默认 2048 px;颜色贴图保留原始分辨率。 - **Maximum Cutout Mesh Resolution**:所有 Cutout Shape 的网格采样长边上限,默认 1024,独立于 AI 输入尺寸。 -Depth & Normal 只列出三档 MoGe-2,Depth Plane 与 Cutout Depth / Normal Map 使用同一个显式选择,不存在生产回退。可选模型的 **Ready** 状态或 **Download** 按钮位于选择项下方;Cutout 区提供独立网格上限与 Balloon Profile。 +Depth & Normal 提供三档 MoGe-2,Depth Plane 与 Cutout Depth / Normal Map 共用当前选择。可选模型的 **Ready** 状态或 **Download** 按钮位于选择项下方;Cutout 区提供独立网格上限与 Balloon Profile。 如果准备把 Storage Root 放到其他磁盘,建议在首次安装 Environment 之前修改。更换目录后,旧目录中的环境和模型不会自动移动。 @@ -51,11 +51,10 @@ Depth & Normal 只列出三档 MoGe-2,Depth Plane 与 Cutout Depth / Normal Ma 完成后可以用以下方式确认: -- **Job Server** 不再显示 “Environment is not installed”。 - AnyImage 扩展偏好设置中的 MoGe-2 ViT-S Normal、BEN2 Base 和 Real-ESRGAN General WDN x4v3 显示 **Ready**。 - 选中 Image Empty 后,右键菜单底部显示 **AI Environment Settings…**。 -**Convert to Depth Plane**、**Remove Background** 和 **Upscale** 在 AI 未就绪时仍可点击,点击后会根据状态转入 **Set Up AI Environment…** 或 **Download Required Models…**。普通 Plane、Image 编辑,以及未启用 **Fit to Foreground** / **Generate Normal Map** 的 Cutout Surface 与 Balloon 不要求安装 AI Environment。 +点击 **Convert to Depth Plane**、**Remove Background** 或 **Upscale** 时,AnyImage 会根据状态进入 **Set Up AI Environment…** 或 **Download Required Models…**。普通 Plane、Image 编辑和基础 Cutout Surface / Balloon 直接在 Blender 中执行。 ## 下一步 diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 041145d..a3fb43a 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -10,11 +10,11 @@ 打开 **Edit > Preferences > Extensions > AnyImage**。第一次使用建议保持默认: -- **Mesh Depth Model**:MoGe-2 ViT-S Normal +- **Depth & Normal Model**:MoGe-2 ViT-S Normal - **MoGe Resolution Level**:5 - **Device**:使用自动列出的首选设备 -这些是扩展级默认值,转换时不会再弹出参数对话框。 +转换直接使用这些扩展级默认值。 ## 3. 开始转换 @@ -31,10 +31,3 @@ - **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/guides/animation.md b/docs/guides/animation.md deleted file mode 100644 index 906b974..0000000 --- a/docs/guides/animation.md +++ /dev/null @@ -1,33 +0,0 @@ -# 视频与序列帧 - -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/guides/clipboard-image.md b/docs/guides/clipboard-image.md deleted file mode 100644 index 8550625..0000000 --- a/docs/guides/clipboard-image.md +++ /dev/null @@ -1,43 +0,0 @@ -# 粘贴剪贴板图片 - -复制图片后,直接在 Blender 当前编辑器按 `CTRL + V`;macOS 使用 `CMD + V`。AnyImage 会根据上下文决定如何使用图片,并将它 Pack 到当前 `.blend`。 - -## 支持的上下文 - -| 上下文 | 结果 | -| --- | --- | -| 3D View / Object Mode | 创建 Reference Image 或带材质的 Plane | -| Sculpt | 设置为笔刷 Texture | -| Vertex Paint | 设置为笔刷 Texture | -| Texture Paint | 设置为笔刷 Texture | -| Shader Editor | 创建 Image Texture 节点 | -| Compositor | 创建 Image 节点 | -| Geometry Nodes | 创建 Image Texture 节点 | - -## 在 3D View 中 - -图片会放在鼠标所在的屏幕位置,并使用当前 3D Cursor 作为视图深度参考。默认导入为 `Reference Image`。 - -如果在操作面板中切换为 `Plane`,会创建基础四边形和 `O Mesh Plane` Geometry Nodes 修改器。操作面板只保留: - -- **Import As**:在 Reference Image 与 Plane 之间切换 -- **Shadeless**:让图片不受场景灯光影响 - -`Thickness` 与 `Subdivide` 直接在 Geometry Nodes 修改器中调整,不作为粘贴参数。 - -## 在 Node Editor 中 - -节点创建在鼠标位置。不同节点编辑器会选择对应的图片节点类型,不需要先保存临时图片再手动浏览文件。 - -## 与 Blender 原生粘贴共存 - -如果刚刚使用 `CTRL + C` 复制了 Blender 内部数据,随后按 `CTRL + V` 时会优先让 Blender 处理原生粘贴。只有剪贴板中存在新的图片数据时,图片粘贴功能才接管操作。 - -## 平台说明 - -- Windows:直接支持 -- macOS:使用 `CMD + V` -- Linux Wayland:需要 `wl-clipboard` -- Linux X11:需要 `xclip` - -剪贴板中没有可用图片时,操作会放行,不阻断 Blender 的其他粘贴行为。 diff --git a/docs/guides/cutout.md b/docs/guides/cutout.md deleted file mode 100644 index 7b9ff47..0000000 --- a/docs/guides/cutout.md +++ /dev/null @@ -1,61 +0,0 @@ -# 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 进行比较。 - -无论是否启用 Fit to Foreground,套索超出图片的部分都不会生成网格,源图中 `Alpha <= 0.1` 的像素也始终从 Region 中排除。双击使用完整画面时同样只为 `Alpha > 0.1` 的 Region 生成网格。 - -## 工具设置 - -选项按执行意图排列: - -- **Fit to Foreground**:用 BEN2 把几何套索贴合到其中的前景主体,默认关闭。 -- **Mesh Sampling Spacing**:相邻网格采样点大约对应多少源图像素;数值越小,三角网格越密。 -- **Generate Normal Map**:是否使用 MoGe-2 生成法线贴图,默认关闭;空间由 Shape 自动决定。 - -Surface 与 Balloon 在 **Generate Normal Map**、**Fit to Foreground** 都关闭时完全在 Blender 中生成,不需要 AI Environment。开启任一 AI 选项,或选择 Depth Shape 时,会提交后台任务。 - -Mesh Sampling Spacing 先按完整源图换算期望密度,再由 Cutout 区的 **Maximum Cutout Mesh Resolution** 限制裁切区域的最长采样边。这个网格上限对 Surface、Balloon 与 Depth Shape 完全相同。**Maximum AI Input Size** 只限制 Fit to Foreground、Depth 和 Normal Map 的模型分析图;颜色贴图始终保留原裁切分辨率。建议先用默认 `32` 观察形状,轮廓需要更细时再逐步减小。 - -## 调整修改器 - -四个 Shape 都使用同一个 `O Image Cutout` Geometry Nodes 修改器。创建时 Shape 菜单已经设为饼菜单所选类型;切换 Shape 后,Blender 只显示该类型实际使用的输入。常用参数包括: - -- **Thickness**:Surface / Depth Surface 使用场景距离;Balloon / Depth Balloon 使用鼓起高度倍率。 -- **Cleanup**:碎岛面积阈值;创建时根据 Mesh Sampling Spacing 和图片平面尺寸自动写入。为便于在修改器中调节,显示值放大了 `100` 倍,节点内部会还原为实际面积,因此这次显示缩放不会改变默认清理效果。 -- **Smooth Iterations**:位于外层,控制平滑次数,默认 `0`;**Smooth** 位于 Options 中,控制每次平滑的混合强度。四种 Shape 都可使用。 -- **Depth Scale**:Depth Shape 的深度强度;`0` 回到基础平面或 Balloon。 - -两个 Depth Shape 共用 **Base Plane Depth**;Depth Balloon 还提供 **Depth Direction**。生成前会检查最终 Region 输入的 Alpha:`Alpha < 0.1` 的像素达到 `5%` 时,根据有效 Region 自适应 Base Plane Depth,并为 Depth Balloon 拟合 Direction;不足 `5%` 时使用稳定的固定基准。这个判断不依赖双击全图或 Fit to Foreground。插件自动写入的 Depth Image 与 Uniform Scale 位于折叠的 Data 面板,一般无需手动修改。 - -Balloon 的轮廓高度算法由扩展 Preferences 中的 **Balloon Profile** 控制:Poisson 更平滑,Teddy 更强调轮廓骨架。修改默认值只影响之后创建的 Shape。 - -## Fit to Foreground 与 Normal Map - -Fit to Foreground 只在“套索与源图可见 Alpha 的交集”内收紧最终 Region,不把 AI 生成的颜色写回原图。材质使用按最终 Region 包围矩形裁切的当前 Image Empty 源图,保留源颜色和原 Alpha,不把 RegionMask 写入 Alpha。 - -Normal Map 关闭时使用几何法线。开启后,Depth Surface 自动使用 Object Space,把模型法线编码到 Cutout 对象局部轴;Surface、Balloon 与 Depth Balloon 自动使用 Tangent Space,作为相对图片平面的浮雕细节。用户不直接选择 Normal Space,两种路径都不根据 Geometry Nodes 的成品网格再次烘焙。Normal Map 保持模型分析分辨率,Color Image 仍保留原始裁切分辨率;Depth 与 Normal Map 同时需要时共享同一次 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 deleted file mode 100644 index 7cee899..0000000 --- a/docs/guides/depth-plane.md +++ /dev/null @@ -1,36 +0,0 @@ -# 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`。 - -## 调整结果 - -生成对象使用扩展资产中的 `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/guides/faq.md b/docs/guides/faq.md deleted file mode 100644 index 179fe07..0000000 --- a/docs/guides/faq.md +++ /dev/null @@ -1,54 +0,0 @@ -# 常见问题 - -## 找不到 AnyImage 右键菜单 - -AnyImage 菜单只会在选中 Image Empty 时出现。确认对象类型是 Empty,并且显示方式为 Image。普通 Mesh、图片编辑器中的图片或未选中的 Image Empty 都不会显示入口。 - -## 转换入口是灰色的 - -通常有以下原因: - -- 另一个 AnyImage 任务正在运行。 -- 当前对象不是 Image Empty。 - -Environment 或默认模型缺失不会让四个 AI 功能变灰;点击功能会根据状态打开 **Set Up AI Environment…** 或 **Download Required Models…**。Balloon 的 **Contour**、**Depth** 与 Depth Model 会在 AI 未就绪时单独禁用,同一位置提供安装按钮。 - -## Environment 安装失败 - -- 海外源访问不稳定时重新打开 **Set Up AI Environment…**,勾选 **Use Mirror**。 -- 检查 Blender 的 **Online Access** 是否开启。 -- 确认 Storage Root 至少有约 1 GB 可用空间且可写。 -- 查看 Storage Root 下的 `install.log`。 - -安装模型失败不会删除已经成功创建的 Environment。可以稍后使用 **Download Required Models…** 补齐默认模型,或在 AnyImage 扩展偏好设置中逐项重试。 - -## 模型下载很慢或失败 - -模型会尝试 Hugging Face 和 OmooLab 备用来源。检查网络、代理和磁盘空间后重新点击 Download。模型直链连接或长时间无数据时会自动尝试下一来源。 - -## 第一次转换较慢 - -第一次任务需要启动 Job Server 并把模型载入内存。Server 运行且模型仍在缓存中时,后续同类任务通常会更快。 - -## Upscale 很卡或提示内存不足 - -三个高清模型都使用分块推理,但完整输出数组、PNG 编码和 Blender 图片载入仍取决于输入与 4× 结果尺寸。Environment 区的 **Maximum AI Input Size** 默认限制输入长边为 `2048 px`;可以降低该值,或改用 CPU。DirectML 或其他 provider 无法分配资源时,任务也会提示降低这个全局值。 -## Depth Plane 太平或深度方向反了 - -在 Geometry Nodes 修改器中调整 **Depth Scale**。增大绝对值会加强深度,使用负值可以反转前后关系。 - -## Cutout Depth Surface 边缘破碎或细节太多 - -先增大 **Mesh Sampling Spacing** 降低基础网格密度,再调整 **Cleanup** 清理小块,并用 **Smooth** 稳定表面。必要时降低模型分辨率。 - -## 动画面片不播放 - -检查源 Image Empty 的 **Image Frames** 是否大于 `1`,并确认场景时间正在变化。生成后的动画还需要对应 Job 文件夹存在;文件被移动或删除时应重新定位序列。 - -## 可以删除 jobs 文件夹吗 - -静态图的部分结果会 Pack 到 `.blend`,但视频与序列帧会直接引用 Job 文件。只有确认没有工程继续引用对应目录时才应删除。 - -## 如何完全撤销一次转换 - -转换完成后直接按 `Ctrl+Z`。AnyImage 会把网格、材质、图片和修改器的创建合并为一次 Blender Undo 操作。 diff --git a/docs/guides/image-tools.md b/docs/guides/image-tools.md deleted file mode 100644 index 222f97b..0000000 --- a/docs/guides/image-tools.md +++ /dev/null @@ -1,82 +0,0 @@ -# Image 编辑工具 - -Image Box、Lasso、Line、Polyline 与 Perspective 直接编辑静态 Image Empty,并把结果 Pack 到当前 `.blend`。前四个工具组成 Mask 工具组;Perspective 是紧随其后的独立工具。 - -## 选择工具 - -| 工具 | 用途 | -| --- | --- | -| **Image Box** | 用矩形保留或清理图片区域 | -| **Image Lasso** | 手绘自由选区 | -| **Image Line** | 用一条直线清理一侧 | -| **Image Polyline** | 逐点构建精确多边形 | -| **Image Perspective** | 将任意顺序输入的四个角矫正为矩形 | - -在 3D Viewport 左侧工具栏长按 Image Mask 图标,可以在 Box、Lasso、Line 与 Polyline 之间切换。**Fit to Foreground** 只显示在 Box / Lasso;Line / Polyline 保持几何直线边界。Perspective 使用独立图标。 - -
-**截图占位:Image 工具组与 Tool Settings** - -显示包含四个工具的 Image Mask 组、独立 Perspective,以及 Box / Lasso 的 Fit to Foreground。 - -目标文件:`images/image-tools/tool-group.png` -
- -## 选择图片 - -直接在当前 Active 图片上开始绘制。如果第一次点击的是另一张 Image Empty,这次点击只会选中它;下一次操作才开始编辑。点击空白处或非图片对象时,继续编辑当前 Active 图片,不会移动对象。 - -没有 Active Image Empty 时,工具会提示先选择图片。切换到其他工具会立即取消正在进行的绘制。 - -## 清理选区 - -### Box 与 Lasso - -- Box:按住左键拖出矩形,松开后执行。 -- Lasso:按住左键绘制轮廓,松开后执行。 - -### Line - -从起点拖到终点,松开后保留直线左侧的内容。按住 `Ctrl` 以 5° 为单位吸附方向;需要另一侧时按 `F` 翻转。 - -### Polyline - -逐点单击构建多边形。鼠标接近起点时会显示闭合圆环;单击圆环或按 `Enter` 完成,`Backspace` 删除最近一点。 - -绘制过程中按 `F` 切换反选。反选是一个持续到本次操作完成的状态,不需要按住: - -- 关闭:保留选区内; -- 开启:保留选区外。 - -Cutout、Box 与 Lasso 分别保存各自的 **Fit to Foreground** 设置;在一个工具中切换不会改变另外两个工具。启用后,BEN2 会先把几何 Region 收紧到其中的前景,再应用反选。例如框住一个苹果并开启反选,最终会清理苹果本身、保留其余内容。Line / Polyline 不提供 Fit to Foreground,以保持直线边界和明确的裁切方向。 - -结果会直接更新当前 Empty 的图片,并把画布紧裁切到最终可见 Alpha。只有一个 Image Empty 使用该图片时,工具更新原 Image;两个或更多 Image Empty 共用它时,工具为当前 Empty 创建独立结果,其他 Empty 保持不变。对象 Transform 不变,显示尺寸与 Image Offset 会更新,使剩余像素保持在原图片平面上的位置。透明区只修改 Alpha,保留源 RGB 供纹理过滤使用。未形成有效区域的单击会静默结束;有效选区没有可见像素时只显示警告,不修改图片。 - -
-**截图占位:Polyline 与反选** - -显示半透明选区面、虚线边界,以及状态栏中的 `F toggles Invert`。 - -目标文件:`images/image-tools/polyline-invert.png` -
- -## 四点透视 - -1. 任意顺序单击四个角点。 -2. 第四点后,在 Viewport 中央检查矫正预览。 -3. 左右移动鼠标调整宽高比;按住 `Ctrl` 可吸附到 `1:1`、`4:3`、`3:4`、`16:9`、`9:16`。 -4. 左键确认。 - -`Backspace` 返回上一步,右键或 `Esc` 取消。四点会自动排序,点击顺序不会改变输出旋转。 - -Perspective 只执行图片 Homography 与紧裁切,不提供 Fit to Foreground,也不尝试从当前 View 推导真实相机。结果保持源 Empty 的 Transform,并按原四边形覆盖范围近似更新显示位置与尺寸。选区完全位于图片外或没有可见像素时不会执行。 - -
-**截图占位:Perspective 比例确认** - -显示中央图片预览、蓝色边框、当前比例与底部操作提示。 - -目标文件:`images/image-tools/perspective-aspect.png` -
- -实现结构与像素规则见 [Image Tool 实现原理](../internals/operators/image-tool.md)。 diff --git a/docs/guides/mesh-plane.md b/docs/guides/mesh-plane.md deleted file mode 100644 index c3f3e80..0000000 --- a/docs/guides/mesh-plane.md +++ /dev/null @@ -1,27 +0,0 @@ -# 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/guides/models-and-storage.md b/docs/guides/models-and-storage.md deleted file mode 100644 index e0b7500..0000000 --- a/docs/guides/models-and-storage.md +++ /dev/null @@ -1,66 +0,0 @@ -# 模型、设备与存储 - -AnyImage 把运行环境、模型和任务结果放在 Blender 之外,多个 Blender 版本可以共享同一套数据。 - -## 功能与模型设置 - -打开 **Edit > Preferences > Extensions > AnyImage**,可以查看和下载模型。右键菜单中的 **AI Environment Settings…** 会直接打开这里。Environment 检测后紧接 Diagnostics;其余设置分为默认展开、可折叠的 Depth & Normal、Remove Background、Upscale 和 Cutout 功能区,模型不会再集中显示在单独列表中。 - -- **Ready** 表示模型文件已经就绪。 -- **Download** 用于按需下载模型。 -- Depth & Normal 与 Upscale 的下载按钮位于当前模型选择项下方;切换选择后,可直接下载对应模型。 -- Depth & Normal 只列出三档 MoGe-2;Depth Plane 与 Cutout 共用这个显式选择。 -- Remove Background 使用的 BEN2 固定模型显示在对应功能区;Cutout 区只包含 Balloon Profile。 -- 默认安装会准备 MoGe-2 ViT-S Normal、BEN2 Base 和 Real-ESRGAN General WDN x4v3。 -- 更大的 MoGe-2,以及可选的 SAFMN Real x4、Real-ESRGAN x4plus 需要时再下载即可。 -- 可公开取得的 ONNX 制品使用经过大小与 SHA-256 校验的原址,并以 OmooLab R2 为镜像;不会把源码仓库名称推测成 Hugging Face 下载地址。 -- Real-ESRGAN General WDN x4v3 与 SAFMN Real x4 的上游只发布 PyTorch 权重,当前 ONNX 均从官方权重导出并仅从 R2 下载。 - -较大的模型不一定适合所有图片。建议先用默认模型完成构图,再针对细节与性能需求比较其他模型。 - -## AI 输入与 Cutout 网格尺寸 - -Environment 区的 **Maximum AI Input Size** 默认 `2048 px`,可在 `256–8192 px` 之间调整。它是 Upscale 的可执行源图长边上限,也是 Depth、Normal Map 与 Fit to Foreground 模型分析输入的最大长边;颜色贴图仍使用原始分辨率。Cutout 区的 **Maximum Cutout Mesh Resolution** 默认 `1024`,独立限制所有 Shape 的网格采样密度,不会因为某个 Shape 是否使用 AI 而改变含义。 -## Device - -扩展偏好设置中的 **Device** 控制 Depth Plane、Cutout 的 AI 增强、Remove Background 和 Upscale 等正式任务。 - -- Windows 通常优先使用 DirectML。 -- Apple Silicon macOS 通常优先使用 CoreML。 -- Provider 不可用时会回退到 CPU。 - -Debug 面板中的 **Debug Device** 只影响 Debug 任务,不会覆盖正式任务的 Device。 - -## Session 缓存 - -连续使用同一模型和 Device 时,Server 会复用已经加载的 ONNX Session。缓存分为 BEN2、几何模型和 Upscale 三个族,每个族只保留最近使用的一套;同族切换模型或 Device 时释放旧 Session。如果高清推理遇到 provider 资源错误,也会立即卸载该 Session。 - -## Storage Root - -默认 Storage Root 是 `~/.anyimage`,主要包含: - -```text -~/.anyimage/ -├── .venv/ # 独立 Python Environment -├── models/ # 模型文件 -├── jobs/ # 每次任务的结果与 job.log -├── install.log # Environment 安装日志 -└── server.log # 带 Job ID 的任务主线与 Job Server 日志 -``` - -更改 Storage Root 相当于切换到另一套环境、模型和任务数据。AnyImage 不会自动移动旧目录。 - -## Job 文件夹 - -每个任务会在 `jobs/` 下创建一个独立目录。单张图生成的关键贴图通常会 Pack 到 `.blend`;视频和序列帧则继续引用 Job 目录中的文件。 - -可以清理已经确认不再使用的任务目录,但清理前应检查是否有 `.blend` 仍引用其中的序列帧。 - -## 日志 - -遇到安装或 Server 问题时: - -- 在扩展偏好设置中点击 **Open Server Log** 查看带时间、Job ID 与 Job Type 的运行主线。 -- 具体 Job 的 Handler 细节和完整 traceback 查看 `jobs//job.log`。 -- Environment 安装问题查看 Storage Root 下的 `install.log`。 -- 修改 Storage Root 后,确认你检查的是当前目录中的日志。 diff --git a/docs/guides/remove-background.md b/docs/guides/remove-background.md deleted file mode 100644 index f1d4c5e..0000000 --- a/docs/guides/remove-background.md +++ /dev/null @@ -1,23 +0,0 @@ -# 去除背景 - -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 deleted file mode 100644 index eb75cb0..0000000 --- a/docs/guides/upscale.md +++ /dev/null @@ -1,27 +0,0 @@ -# 高清放大 - -Upscale 用 ONNX 模型把 Image Empty 的 RGB 放大 4 倍,不创建网格。它支持单图、Movie 和 Image Sequence,并以 Lanczos 独立放大源 Alpha 后重新合并。 - -## 使用 - -1. 在 AnyImage 扩展偏好设置中选择 **Default Upscale Model**。 -2. 按需要设置 Environment 区的 **Maximum AI Input Size**;默认 `2048 px`,最高 `8192 px`。 -3. 选中 Image Empty,在右键 **AnyImage** 菜单中点击 **Upscale**。 - -菜单项只显示本次操作预计生成的 4× 尺寸。当前图片长边必须小于全局上限;达到上限后 Upscale 会置灰,因此默认设置下单次结果长边小于 `8192 px`。完成后,当前 Empty 会直接使用放大结果;原 Image 没有其他引用时沿用原名并移除旧数据,存在其他引用时则为当前 Empty 保留独立结果。 - -## 模型选择 - -- **Real-ESRGAN General WDN x4v3**:默认模型,速度快,细节处理较保守。 -- **SAFMN Real x4**:速度与细节较均衡。 -- **Real-ESRGAN x4plus**:生成的细节更明显。 - -后两项可在扩展偏好设置中按需下载。 - -AnyImage 不追踪高清结果的原图引用,Upscale 始终处理当前 Image。只要当前长边仍小于全局上限,就可以再次放大;达到上限后不能继续执行。 - -把高清结果继续转换为 Depth Plane 时,颜色材质和分析都以当前 Image 为来源,分析输入仍遵守全局上限;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 deleted file mode 100644 index 6c7bfcc..0000000 --- a/docs/guides/workflows.md +++ /dev/null @@ -1,41 +0,0 @@ -# 选择工作流 - -先确定需要的是图片数据、普通平面、整图深度,还是局部网格。 - -| 工作流 | 输入 | 结果 | AI | -| --- | --- | --- | --- | -| Clipboard Image | 剪贴板静态图 | Reference、Plane、节点或笔刷纹理 | 不需要 | -| Convert to Plane | 单图、Movie、Sequence | 带材质的普通网格 | 不需要 | -| Convert to Depth Plane | 静态图 | 带深度位移的整图面片 | MoGe-2 | -| Cutout | 静态图局部选区 | 四种局部 Shape | 按 Shape 与选项决定 | -| Remove Background | 单图、Movie、Sequence | 透明图片或序列 | BEN2 | -| Upscale | 单图、Movie、Sequence | 4× 图片或序列 | Upscale 模型 | -| Image Mask / Perspective | 静态图 | 原位更新的裁切或矫正图 | Fit to Foreground 时使用 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 内完成,Fit to Foreground、Generate Normal Map 与 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/index.md b/docs/index.md index 61aea4b..432c443 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,45 +10,3 @@ AnyImage 是一款 Blender 扩展,可以粘贴和编辑图片、创建普通 P - 用 **Cutout** 从套索选区生成 Surface、Balloon 与两种 Depth Shape - 用 **Image Tools / Perspective** 直接清理或矫正 Image Empty - 用 **Remove Background / Upscale** 处理单图、视频和序列帧 - -!!! info "运行环境与 Blender 分离" - AI 计算运行在独立 Environment 中,不会把 ONNX Runtime 等依赖安装进 Blender Python。Blender 只负责界面、任务提交和结果导入。 - -## 从这里开始 - -
- -- **安装 AnyImage** - - 下载与你的系统匹配的扩展包,完成 Environment 与默认模型配置。 - - [阅读安装指南](getting-started/installation.md) - -- **五分钟上手** - - 从导入一张静态图片开始,创建第一个 Depth Plane。 - - [开始第一个作品](getting-started/quick-start.md) - -- **选择工作流** - - 对比 Plane、Depth Plane、Cutout、去背景、高清放大和图片编辑。 - - [查看工作流](guides/workflows.md) - -- **了解实现** - - 从项目主干、进程边界和功能模块了解内部实现。 - - [阅读内部实现总览](internals/index.md) - -
- -## 使用要求 - -- Blender 4.5 或更高版本 -- Windows x64、macOS Apple Silicon 或 Linux x64 -- 安装 Environment 与模型时需要网络连接 -- AI 功能需要对应模型;普通 Plane、基础 Image 编辑与不带 AI 选项的 Cutout Surface / Balloon 可离线使用 - -遇到安装、模型或任务问题时,查看[常见问题](guides/faq.md)。 diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index 101370c..28aef60 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -1,6 +1,6 @@ # 程序架构 -AnyImage 由 Blender 交互层、BlendJob Runtime 和独立 Job Server 三部分组成。这个分层让 Blender Python 不需要加载 ONNX Runtime、Pillow 或模型权重,同时保证所有 Blender 数据仍在主线程创建和修改。 +AnyImage 由 Blender 交互层、BlendJob Runtime 和独立 Job Server 三部分组成。AI 依赖运行在 Server Environment 中,Blender 数据在主线程创建和修改。 ## 对应文件 @@ -12,17 +12,18 @@ src/anyimage/ ├── preferences.py # Storage Root、Device 与功能默认值 ├── properties.py # Scene 状态、模型状态与动态枚举 ├── runtime.py # Environment 和 JobRuntime +├── common/ # Blender 侧共享原子能力 ├── operators/ # Blender 侧业务入口 └── server/ # 独立计算端 ``` -## 职责边界 +## 职责 -| 层次 | 负责 | 不负责 | -| --- | --- | --- | -| Blender 交互层 | UI、手势、RNA 状态、输入准备、结果导入、Undo | 在 Blender 进程加载 AI 依赖 | -| BlendJob Runtime | Environment、Server 生命周期、队列、进度、取消、Job 目录 | AnyImage 的图像与几何业务 | -| Job Server | 媒体解码、ONNX 推理、数组派生、文件输出 | 创建 Blender 对象或访问 `bpy` | +| 层次 | 职责 | +| --- | --- | +| Blender 交互层 | UI、手势、RNA 状态、输入准备、结果导入和 Undo | +| BlendJob Runtime | Environment、Server 生命周期、队列、进度、取消和 Job 目录 | +| Job Server | 媒体解码、ONNX 推理、数组派生和文件输出 | Operator 向 Server 发送普通字典。Server 返回文件名和 Job 目录;Operator 的 `response()` 再读取这些文件,并在 Blender 主线程中创建 Image、Mesh、Material 和 Geometry Nodes Modifier。 @@ -58,12 +59,8 @@ sequenceDiagram Op->>Op: 导入数据并提交 Undo ``` -这条边界也决定了模块依赖方向:`server/` 不导入 `bpy` 或 `operators/`;Blender 侧可以读取 Server 产物,但不会复用 Server 中的 Blender 无关算法来创建 RNA 数据。 +`server/` 保持纯 Python 实现;Operator 在 Blender 主线程读取产物并创建 RNA 数据。 -## 共享能力 { #shared-capabilities } +## 共享能力 -`operators/common/` 只保存多个生产功能真实复用的稳定原子能力;完整工作流、功能参数和专用算法留在所属模块。公共层不保留旧路径转发函数,也不为了测试或资产工具建立入口。 - -全部可由功能模块调用的函数见[Common 公共函数](common.md)。该索引只列 `common/` 外部的生产调用,不重复列出公共模块内部辅助函数;创建新功能前先核对该页,避免重新实现 Image 引用分流、像素方向、Job 输入生命周期、数据块命名、节点资产加载和对象激活逻辑。 - -新增公共函数前应确认至少有两个真实消费者,或它是两个公共流程共同依赖的原子能力。只有单一功能使用的逻辑应留在所属模块;删除或移动能力时同步清理注册、测试、工具脚本和文档,不保留兼容胶水。 +`common/` 提供 Blender 侧复用的 Image 数据、Region、Depth、Object 和 AI Job 原子能力。公开函数见[Common 公共函数](common.md)。 diff --git a/docs/internals/common.md b/docs/internals/common.md index f2f391f..f5902e6 100644 --- a/docs/internals/common.md +++ b/docs/internals/common.md @@ -1,12 +1,10 @@ # Common 公共函数 -`src/anyimage/operators/common/` 集中已经被多个功能复用的 Blender 侧原子能力,目的是让开发新功能时能够先查找并复用现有实现,避免重复造轮子。 - -本页只列功能模块直接调用的公共函数,不列 `common/` 内部辅助函数、测试或资产工具专用函数,也不列公共类型。 +`src/anyimage/common/` 提供多个功能复用的 Blender 侧原子能力。 ## Image 数据块 -`image_data.py` 处理 Blender `Image` 数据块及其在 Image Empty 上的使用方式,包括像素读取、动画帧、Job 输入、结果 Image 创建和 Empty 引用替换。它不处理 Viewport 手势或 Region 算法。 +`image_data.py` 处理 Blender `Image` 数据块及其在 Image Empty 上的使用方式,包括像素读取、动画帧、Job 输入、结果 Image 创建和 Empty 引用替换。 | 函数 | 职责 | | --- | --- | @@ -30,16 +28,14 @@ | `image_data.replace_empty_image()` | 执行 Image Empty 结果替换、共享引用分流、旧数据清理、帧设置继承和可选画布对齐。 | | `image_data.image_empty_bounds()` | 计算 Image Empty 在对象局部坐标中的实际显示边界。 | -`load_*_result_image()` 和 `create_pixel_result_image()` 只创建结果 Image;`replace_empty_image()` 负责把结果提交给当前 Empty。两者分别表达“创建产物”和“替换引用”。 - ## Image Region 与视图交互 这组公共能力使用固定术语: - Region 是用户在图片空间圈定的业务范围;`RegionPath` 保存路径点、反选状态和填充规则。 - `RegionMask` 是 Region 的栅格化结果,只包含局部像素权重 `values` 和源图 `bounds`。 -- Mask 只表示二维像素权重或 Alpha 运算,不作为手势、协议或业务范围的别名。 -- Selection 只用于 Blender 对象选择;图片业务范围统一称为 Region,细化动作称为 Fit to Foreground。 +- Mask 表示二维像素权重或 Alpha 运算。 +- Selection 表示 Blender 对象选择;图片业务范围称为 Region,细化动作称为 Fit to Foreground。 `image_interaction.py` 处理 3D Viewport 中的点击、坐标投影、`ImageRegionGesture`、工具设置和预览绘制;`image_region.py` 负责图片空间 Region 的值、栅格化和 Alpha 裁切。前者负责用户如何圈定 Region,后者负责 Region 如何变成 Mask。 @@ -71,7 +67,7 @@ ## Depth 产物 -`depth_data.py` 负责 Blender 侧共享的 Depth 文件加载和相机尺度换算;Server 已经写好 EXR,Blender 不再从点场创建第二张 Depth Image。 +`depth_data.py` 负责 Blender 侧共享的 Depth 文件加载和相机尺度换算,直接加载 Server 写出的 EXR。 | 函数 | 职责 | | --- | --- | @@ -80,23 +76,22 @@ | `depth_data.camera_depth_values()` | 从已加载 Depth Image 读取用于本地标定的 Z 与有效区。 | | `depth_data.depth_uniform_scale()` | 根据相机内参、参考深度和 Image Empty 范围计算统一尺度。 | -## Mesh 结果构建 +## Object 构建 -`mesh_result.py` 集中 Convert to Plane 与 Cutout 共同使用的结果构建能力:创建材质、加载节点资产、设置修改器输入,并完成结果对象替换。 +`object.py` 集中 Convert to Plane 与 Cutout 共用的材质、Modifier 和结果 Object 构建能力;`node.py` 负责加载节点资产。 | 函数 | 职责 | | --- | --- | -| `mesh_result.create_image_material()` | 从 Image Empty 创建包含颜色、位移、Normal Map、Alpha 和动画设置的完整材质。 | -| `mesh_result.load_asset_node_group()` | 从随扩展分发的 Blend 资产加载指定 Node Group。 | -| `mesh_result.modifier_input_identifier()` | 按接口名称解析 Geometry Nodes Modifier 输入 Identifier。 | -| `mesh_result.set_modifier_input()` | 跨受支持 Blender RNA 形式设置 Modifier 输入。 | -| `mesh_result.finalize_object_result()` | 激活结果对象,并按需替换或保留源 Empty。 | +| `node.load_node_group()` | 从随扩展分发的 Blend 资产加载指定 Node Group。 | +| `object.create_image_material()` | 从 Image Empty 创建包含颜色、位移、Normal Map、Alpha 和动画设置的完整材质。 | +| `object.modifier_input_identifier()` | 按接口名称解析 Geometry Nodes Modifier 输入 Identifier。 | +| `object.set_modifier_input()` | 跨受支持 Blender RNA 形式设置 Modifier 输入。 | +| `object.finalize_object_result()` | 激活结果对象,并按需替换或保留源 Empty。 | -节点资产构建和验证专用函数位于 `tools/node_assets/`,不属于运行时公共能力。 ## AI 任务准备 -`ai_job.py` 处理多个 AI 功能共同使用的任务准备,包括输入路径校验、Environment 与模型检查、Device 选择和模型请求参数组装。DA3 参数只供 Debug Operator 使用;正式几何流程只组装 MoGe-2 参数。它不包含业务 Operator、结果导入或 Undo 操作。 +`ai_job.py` 处理多个 AI 功能共同使用的任务准备,包括输入路径校验、Environment 与模型检查、Device 选择和模型请求参数组装。 | 函数 | 职责 | | --- | --- | diff --git a/docs/internals/development.md b/docs/internals/development.md index f677388..f1c636c 100644 --- a/docs/internals/development.md +++ b/docs/internals/development.md @@ -1,6 +1,6 @@ # 开发与发布 -AnyImage 的仓库环境用于测试 Blender 侧代码、Server 纯 Python 模块、Manifest 和打包逻辑。AI Runtime 使用另一套 Python 3.12 Environment,不应与仓库开发环境混为一谈。 +AnyImage 的仓库环境用于测试 Blender 侧代码、Server 纯 Python 模块、Manifest 和打包逻辑。AI Runtime 使用独立的 Python 3.12 Environment。 ## 对应文件 @@ -9,11 +9,14 @@ AnyImage 的仓库环境用于测试 Blender 侧代码、Server 纯 Python 模 ├── pyproject.toml ├── uv.lock ├── .python-version -├── pack.py -├── sync_models.py +├── tools/ +│ ├── pack.py +│ ├── model_exports.py +│ ├── sync_models.py +│ ├── build_node.py +│ └── node_assets/ ├── src/anyimage/blender_manifest.toml ├── tests/ -├── tools/node_assets/ └── .github/workflows/ ├── test.yml ├── build.yml @@ -40,10 +43,10 @@ uv run pytest tests/test_addon.py tests/test_manifest.py tests/test_pack.py -v ## 扩展打包 ```bash -uv run python pack.py +uv run pack ``` -`pack.py` 为 `windows-x64`、`macos-arm64` 和 `linux-x64` 分别解析依赖 wheel,生成平台专属 Manifest,再把 `src/anyimage/` 与 wheel 写入 `dist/`: +`tools/pack.py` 为 `windows-x64`、`macos-arm64` 和 `linux-x64` 分别解析依赖 wheel,生成平台专属 Manifest,再把 `src/anyimage/` 与 wheel 写入 `dist/`: ```text dist/ @@ -54,21 +57,29 @@ dist/ 本地 `src/anyimage/wheels/` 是 wheel 解析的优先来源,用于携带尚未发布的 BlendJob 构建。依赖、wheel 或 Manifest 变化后,需要同时检查 `pyproject.toml`、`uv.lock`、`blender_manifest.toml` 和打包测试;本地 wheel 内容变化后运行 `uv lock --refresh`。 +## 模型发布 + +```bash +uv run sync_models +``` + +`tools/sync_models.py` 从公开原址下载已有 ONNX;Real-ESRGAN General WDN x4v3 与 SAFMN Real x4 则从官方 PyTorch 权重调用 `tools/model_exports.py` 构建。所有制品通过固定大小、SHA-256、ONNX Checker 和 PyTorch/ONNX Runtime 数值对比后,逐文件上传到 R2。添加 `--prepare-only` 可仅准备和验证制品。 + ## 文档 ```bash -uv run --only-group docs mkdocs serve -uv run --only-group docs mkdocs build --strict +uv run mkdocs serve +uv run mkdocs build --strict ``` -站点导航由 `mkdocs.yml` 显式维护,因此移动页面时必须同时更新导航和相对链接。Mike 仍可用于本地版本化发布;CI 则直接构建严格模式站点并部署到 Cloudflare Worker。 +站点导航由 `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"] + Tag["push v* tag"] --> Release["测试 → tools/pack.py → Release ZIP"] Docs["main 上文档变更"] --> Site["mkdocs --strict → Cloudflare"] ``` @@ -76,6 +87,6 @@ flowchart TD ## 许可证 -AnyImage Extension 代码采用 GPL-3.0-or-later,完整文本见仓库 `LICENSE`。模型权重保持各自上游许可证;模型目录声明下载来源、文件校验和许可信息,技术文档不复制可能变化的制品清单。 +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 index 20b592f..256938e 100644 --- a/docs/internals/index.md +++ b/docs/internals/index.md @@ -1,6 +1,6 @@ # 内部实现总览 -AnyImage 的实现围绕一条清晰边界展开:Blender 进程负责交互与数据创建,独立 Job Server 负责模型推理和文件处理。两侧通过 BlendJob 传递可序列化参数与 Job 目录中的产物,不跨进程传递 Blender RNA 数据。 +Blender 进程负责交互与数据创建,独立 Job Server 负责模型推理和文件处理。两侧通过 BlendJob 传递可序列化参数与 Job 目录中的产物。 ```mermaid flowchart TD @@ -16,8 +16,6 @@ flowchart TD ## 项目主干 -总览只展示稳定边界;各功能包的具体文件在对应实现页中展开。 - ```text . ├── src/anyimage/ @@ -26,24 +24,21 @@ flowchart TD │ ├── preferences.py # 扩展级配置 │ ├── properties.py # Scene 状态与模型枚举 │ ├── runtime.py # BlendJob Runtime 配置 +│ ├── common/ # Blender 侧共享原子能力 │ ├── operators/ # 交互、任务提交与结果导入 │ ├── server/ # 无 Blender 依赖的计算端 │ ├── assets/ # 随扩展分发的节点资产 │ └── wheels/ # 随扩展分发的 BlendJob wheel -├── tools/ # Blender 资产构建与验证 -├── tests/ # Python 自动化测试 -├── pack.py # 多平台扩展打包 -└── sync_models.py # ONNX 模型同步 +├── tools/ # 打包、模型同步与 Blender 节点资产 +└── tests/ # Python 自动化测试 ``` ## 文档分层 - [程序架构](architecture.md):注册、配置、线程和模块边界。 -- [Common 公共函数](common.md):仅列生产功能从 `operators/common/` 外部调用的函数。 +- [Common 公共函数](common.md):仅列生产功能从 `common/` 外部调用的函数。 - [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 的构建、加载与验证。 - -内部实现文档描述模块之间的职责和主要数据流。[Common 公共函数](common.md)按真实外部调用列出开发新功能时应优先复用的稳定入口;函数级分支、参数校验与异常处理仍以源码和测试为准。 diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md index d7fe960..723c179 100644 --- a/docs/internals/node-assets.md +++ b/docs/internals/node-assets.md @@ -1,60 +1,52 @@ # 节点资产 -AnyImage 将生产功能使用的可复用 Geometry Nodes 和 Shader 节点组统一构建到 `O_AnyImage.blend`,运行时只按名称加载,不动态创建节点组。 +生产节点组由脚本构建到 `src/anyimage/assets/O_AnyImage.blend`;运行时只加载,不创建节点组。 -## 对应文件 +## 构建 -```text -src/anyimage/ -├── assets/O_AnyImage.blend -├── operators/common/mesh_result.py # 运行时资产加载与 Modifier 输入 -└── operators/convert_to_plane/nodes.py # Plane 节点资产名称与加载入口 - -tools/node_assets/ -├── build.py # 统一构建入口 -├── node_graph.py # Cutout 资产构建专用节点图函数 -├── build_mesh_plane.py -├── build_depth_plane.py -├── build_material_layers.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/mesh_result.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 +uv run build_node ``` -`build.py` 从空 Factory 文件开始,依次构建 `O Mesh Plane`、`O Image Depth Plane`、`O Image Layer`、`O Image Depth Layer`、`O Shadeless`、四个 Cutout 内部节点组与公开修改器节点组 `O Image Cutout`,再保存唯一资产文件。`O Image Cutout` 用 Menu Switch 包装四个内部组;运行时 Loader 只加载这个稳定入口,Blender 4.5 根据 Shape 菜单依赖只显示当前分支实际使用的输入。 +脚本依次构建资产、运行三项 Blender 验证和完整测试,任一步失败即退出。 -## 验证范围 +脚本按 `--blender`、环境变量 `BLENDER_BIN`、项目 `.env`、`PATH` 的顺序查找 Blender。无法自动找到时,在项目根目录的 `.env` 中配置: -基础验证检查完整发布资产清单、节点树类型、Modifier 标记、接口顺序和 Fake User。专项验证继续检查: +```dotenv +BLENDER_BIN="/absolute/path/to/blender" +``` -- Mesh Plane 的接口、细分、Thickness 和几何求值; -- `O Image Cutout` 的公开接口、Shape 菜单和四个内部组的依赖关系; -- 节点不重叠、主要连线保持从左向右; -- 局部 Group Input 未使用输出隐藏; -- Cleanup 只在四条分支之前执行,Smooth 只在菜单选择之后执行; -- Repeat Zone 数量和四种 Shape 的关键几何求值稳定。 +`.env` 已被 Git 忽略。临时指定路径或跳过 Python 测试: -节点资产接口变化时,应运行全部四个 Blender 入口和完整 Python 测试。只修改 Python、Markdown 或打包逻辑时无需重建 `.blend`。Blender 自动生成的 `.blend1` 等备份不属于发布资产。 +```bash +uv run build_node --blender "/path/to/blender" +uv run build_node --skip-tests +``` + +## 资产清单 + +| 节点组 | 构建模块 | +| --- | --- | +| `O Mesh Plane` | `build_mesh_plane.py` | +| `O Image Depth Plane` | `build_depth_plane.py` | +| `O Image Layer`、`O Image Depth Layer` | `build_material_layers.py` | +| `O Shadeless` | `build_shadeless.py` | +| `O Image Cutout` | `build_cutout.py` | +| `O Image Surface`、`O Image Balloon` | `build_cutout.py` | +| `O Image Depth Balloon`、`O Image Depth Surface` | `build_cutout.py` | + +构建从空 Factory 文件生成全部节点组,关闭 `.blend1` 备份,并覆盖唯一发布资产。 + +## 修改节点组 + +1. 修改 `tools/node_assets/build_*.py`; +2. 更新对应验证; +3. 运行一键构建脚本; +4. 搜索并清理旧名称、接口和路径; +5. 检查 `.blend`、代码、测试和文档差异。 + +接口、节点、连线、默认值或布局变化时必须重建。只修改运行时 Python、Markdown 或打包逻辑时无需重建。 + +`O_AnyImage.blend1` 等备份不应提交或打包。 diff --git a/docs/internals/operators/ai-setup.md b/docs/internals/operators/ai-setup.md index 06b6d5c..1e736cb 100644 --- a/docs/internals/operators/ai-setup.md +++ b/docs/internals/operators/ai-setup.md @@ -23,11 +23,11 @@ flowchart TD 入口位于 `src/anyimage/operators/ai_setup.py`。`SetupAIEnvironment` 通过 `properties.py` 中的 `ai_status()` 读取环境和模型状态,并根据结果结束、安装环境或调用 `DownloadRequiredModels`。对话框同时显示预计下载量和缺失模型名称。 -`runtime.py` 提供当前 Environment 与 Server 状态;Server 正忙时不会启动新的安装流程。`OpenAIEnvironmentSettings` 也在 `ai_setup.py` 中,它只负责打开 AnyImage 的扩展首选项。 +`runtime.py` 提供当前 Environment 与 Server 状态;Server 正忙时不会启动新的安装流程。`OpenAIEnvironmentSettings` 打开 AnyImage 的扩展首选项。 ### 安装独立环境 -环境尚未就绪时,`SetupAIEnvironment` 调用 Runtime 注册的安装 Operator。普通源与中国镜像使用同一条安装流程,`use_mirror` 只决定 uv、Python 和 Python 包的下载源,不改变模型来源。 +环境尚未就绪时,`SetupAIEnvironment` 调用 Runtime 注册的安装 Operator。`use_mirror` 决定 uv、Python 和 Python 包的下载源,模型使用模型目录中声明的来源。 安装完成后继续进入模型下载,不在 Blender Python 中加载 ONNX Runtime 或模型依赖。 diff --git a/docs/internals/operators/clipboard-image.md b/docs/internals/operators/clipboard-image.md index 14ffb6b..54ff708 100644 --- a/docs/internals/operators/clipboard-image.md +++ b/docs/internals/operators/clipboard-image.md @@ -16,7 +16,7 @@ flowchart TD Target --> Paint["Brush Texture"] ``` -整条流程运行在 Blender 进程中,不使用 Job Server。快捷键会先尊重 Blender 自己的 Copy/Paste;只有系统剪贴板出现新的图片内容时,AnyImage 才接管粘贴。 +快捷键优先处理 Blender 自己的 Copy/Paste;系统剪贴板出现新的图片内容时,AnyImage 接管粘贴。 ## 节点说明 @@ -28,7 +28,7 @@ flowchart TD ### 读取系统图片 -`clipboard.py` 负责平台差异。Windows 读取 DIB 或 DIBV5 并补成 BMP;macOS 和 Linux 调用系统提供的图片导出命令。无论来源平台,输出都收敛为图片字节和扩展名,后续步骤不再判断操作系统。 +`clipboard.py` 负责平台差异。Windows 读取 DIB 或 DIBV5 并补成 BMP;macOS 和 Linux 调用系统提供的图片导出命令。各平台输出统一为图片字节和扩展名,再交给后续步骤。 ### 创建或复用 Packed Image @@ -38,4 +38,4 @@ flowchart TD 落点仍在 `actions.py` 中完成:3D View 创建 Reference 或 Plane,Node Editor 创建与当前节点树匹配的 Image 节点,绘制模式把 Image 配置为当前画笔的 Texture。鼠标位置和视图位置也在这里换算。 -Plane 路径调用 `convert_to_plane/mesh_plane.py` 创建基础网格,并使用 `convert_to_plane/nodes.py` 提供的 `O Mesh Plane` 节点组。`clipboard_image/__init__.py` 只负责入口和快捷键,具体 Blender 数据始终由 `actions.py` 创建。 +Plane 路径调用 `convert_to_plane/mesh_plane.py` 创建基础网格,并从节点资产加载 `O Mesh Plane`。`actions.py` 创建 Blender 数据。 diff --git a/docs/internals/operators/convert-to-plane.md b/docs/internals/operators/convert-to-plane.md index 1e0cf7f..b63cc74 100644 --- a/docs/internals/operators/convert-to-plane.md +++ b/docs/internals/operators/convert-to-plane.md @@ -15,7 +15,7 @@ flowchart TD Import --> Depth["创建 Depth Plane Object"] ``` -两条路径共用 Image Empty 的显示范围、颜色材质和基础 Plane。Depth Plane 多出一次 Server Job,并由节点根据相机元数据把 Z Depth 转换为局部位移。正式流程只使用 MoGe-2;DA3 只保留 Debug 入口。 +两条路径共用 Image Empty 的显示范围、颜色材质和基础 Plane。Depth Plane 通过一次 MoGe-2 Server Job 生成 Z Depth,再由节点根据相机元数据转换为局部位移。 ## 节点说明 diff --git a/docs/internals/operators/cutout-tool.md b/docs/internals/operators/cutout-tool.md index a51dcd6..2b67f0b 100644 --- a/docs/internals/operators/cutout-tool.md +++ b/docs/internals/operators/cutout-tool.md @@ -21,11 +21,7 @@ flowchart TD Ready --> Build --> Create ``` -Surface 与 Balloon 可以完全在 Blender 中完成;启用 Fit to Foreground、生成 Depth Shape 或 Normal Map 时,中间会经过一次 Server Job。 - -两条路径的区别只在中间是否需要 Server 产物。回到 Blender 后,它们都使用最终的 `RegionMask` 构建同一种 `BaseShape`,再由同一入口创建对象。Server 不创建 Blender Mesh,也不访问 `bpy`。 - -下文中的 Blender 侧文件相对 `src/anyimage/operators/cutout_tool/`,Server 侧文件相对 `src/anyimage/server/`。 +Surface 与 Balloon 直接使用 `RegionMask` 构建 `BaseShape`;Fit to Foreground、Depth Shape 和 Normal Map 先通过 Server Job 生成所需产物。 ## 节点说明 @@ -33,13 +29,13 @@ Surface 与 Balloon 可以完全在 Blender 中完成;启用 Fit to Foreground Cutout 工具由 `__init__.py` 注册,交互从 `main.py` 中的 `SelectCutoutRegion` 开始。它接收 Lasso 或双击整图手势,把图片空间路径保存为 `RegionPath`,然后调用 `interaction.py` 打开 Shape 饼菜单。 -`interaction.py` 在这里完成 Region 有效性检查、读取三个工具选项,并根据 **Mesh Sampling Spacing** 换算期望网格分辨率;它不定义 Operator。菜单中的 Surface、Balloon、Depth Balloon 和 Depth Surface,以及它们对应的节点分支索引,统一来自 `shape.py`。 +`interaction.py` 完成 Region 有效性检查、读取三个工具选项,并根据 **Mesh Sampling Spacing** 换算期望网格分辨率。菜单中的 Surface、Balloon、Depth Balloon 和 Depth Surface,以及它们对应的节点分支索引,统一来自 `shape.py`。 ### 栅格化 Region -用户选定 Shape 后,`main.py` 中的 `CutoutRegionToShape` 接过主线。它使用 `operators/common/image_region.py` 把 `RegionPath` 栅格化为 `RegionMask`:`values` 保存局部像素权重,`bounds` 保存它在源图中的位置。套索外和源图透明区域不会进入最终 Region。 +用户选定 Shape 后,`main.py` 中的 `CutoutRegionToShape` 接过主线。它使用 `common/image_region.py` 把 `RegionPath` 栅格化为 `RegionMask`:`values` 保存局部像素权重,`bounds` 保存它在源图中的位置。套索外和源图透明区域不会进入最终 Region。 -此时也会应用 **Maximum Cutout Mesh Resolution**。它限制裁切区域的最长采样边,对四种 Shape 一视同仁,与是否使用 AI 无关。 +此时也会应用 **Maximum Cutout Mesh Resolution**,统一限制四种 Shape 裁切区域的最长采样边。 ### 需要后台生成数据? @@ -49,23 +45,23 @@ Cutout 工具由 `__init__.py` 注册,交互从 `main.py` 中的 `SelectCutout - 选择 Depth Balloon 或 Depth Surface; - 开启 **Generate Normal Map**。 -没有开启两个 AI 选项的 Surface 和 Balloon 会直接沿用本地 `RegionMask`。 +Surface 和 Balloon 的本地路径直接沿用 `RegionMask`。 ### 执行 Fit to Foreground,生成 Depth / Normal 产物 -`main.py` 把请求提交给 `server/jobs/cutout.py`。这个 Job 只生成本次 Shape 实际需要的产物: +`main.py` 把请求提交给 `server/jobs/cutout.py`,生成本次 Shape 需要的产物: - **Fit to Foreground** 调用 `server/region/refine_region.py`,写出收紧后的 `region.png` 和 `region_bounds.json`; - 需要 Vector Depth 时写出相机空间 XYZ 组成的 `vector-depth.exr`,需要 Z Depth 时写出 `z-depth.exr`,两者都配套 `depth.json`; - **Generate Normal Map** 根据既定空间写出 `tangent-normal.png` 或 `object-normal.png`。 -Depth 与 Normal Map 同时需要时,共享同一次 MoGe-2 Prediction。Prediction 只存在于 Server 内存,Job 目录只保留 Blender 可直接加载的图片和 JSON。Depth Surface 的 Normal Map 编码为 Cutout 对象空间,其正面是对象局部 `-X`;其余 Shape 使用 Tangent Space。Normal Space 由 `main.normal_mode_for_shape()` 根据 Shape 决定,不是单独的用户选项。 +Depth 与 Normal Map 共享同一次 MoGe-2 Prediction。Depth Surface 的 Normal Map 编码为 Cutout 对象空间,其正面是对象局部 `-X`;其余 Shape 使用 Tangent Space。`main.normal_mode_for_shape()` 根据 Shape 确定 Normal Space。 -**Maximum AI Input Size** 只限制 BEN2 和 MoGe-2 看到的分析图长边。它不改变网格密度,也不降低最终颜色图分辨率。DA3 不进入这条正式路径,只保留 Debug 功能。 +**Maximum AI Input Size** 约束 BEN2 和 MoGe-2 的分析图长边;网格密度使用 **Maximum Cutout Mesh Resolution**,最终颜色图使用源图裁切分辨率。 ### 构建 BaseShape -Job 完成后,`main.py` 在 Blender 主线程读取可选的 RegionMask,直接加载并 Pack Depth 与 Normal 图片;本地路径则直接使用已有数据。两者最终都进入 `main.create_cutout_shape()`,再调用 `geometry.py` 中的 `build_base_shape()`: +Job 完成后,`main.py` 在 Blender 主线程读取可选的 RegionMask,加载并 Pack Depth 与 Normal 图片。`main.create_cutout_shape()` 调用 `geometry.py` 中的 `build_base_shape()`: 1. 从最终 `RegionMask` 提取外轮廓和孔洞; 2. 按 Mesh Sampling Spacing 生成轮廓与内部采样点; @@ -77,7 +73,7 @@ Job 完成后,`main.py` 在 Blender 主线程读取可选的 RegionMask,直 ### 创建 Blender 对象 -`main.create_cutout_shape()` 把 `BaseShape` 和可选产物交给 `object.py` 的 `create_shape_object()`。这里创建裁切后的 Color Image、Material、Mesh Object,并挂上统一的 `O Image Cutout` Modifier。材质通过 `operators/common/mesh_result.py` 创建,Cutout 使用 `1.2` 的 IOR。Color Image 是按最终 Region 包围矩形裁切的源图 RGBA;RegionMask 不写入颜色图 Alpha,源图原有 Alpha 会原样保留。 +`main.create_cutout_shape()` 把 `BaseShape` 和可选产物交给 `object.py` 的 `create_shape_object()`。这里创建裁切后的 Color Image、Material、Mesh Object,并挂上统一的 `O Image Cutout` Modifier。材质通过 `common/object.py` 创建,Cutout 使用 `1.2` 的 IOR。Color Image 按最终 Region 包围矩形裁切源图并保留 RGBA。 | Shape | Modifier 如何形成结果 | 必需后台产物 | | --- | --- | --- | @@ -88,6 +84,6 @@ Job 完成后,`main.py` 在 Blender 主线程读取可选的 RegionMask,直 Depth Shape 的 Base Plane Depth,以及 Depth Balloon 的 Direction,都由 `object.py` 调用 `geometry.py` 的标定函数,根据最终 Region Alpha 计算。透明像素达到 `5%` 时使用有效 Region 自适应标定,否则使用稳定的固定基准。 -Normal Map 是材质细节,不改变上述四种 Shape 的几何分类。关闭时材质使用几何法线;开启时,`object.py` 把生成的图片和已经确定的 Normal Space 交给材质节点。最后 `main.py` 提交 Undo,整条 Cutout 流程结束。 +Normal Map 关闭时材质使用几何法线;开启时,`object.py` 把生成的图片和已经确定的 Normal Space 交给材质节点。最后 `main.py` 提交 Undo,整条 Cutout 流程结束。 Balloon 与 Depth Balloon 的材质会创建 Geometry Attribute 节点,把 `o_balloon` 的 Float 输出直接连接到 `O Image Layer` 已有的 **Normal Scale**;Surface 与 Depth Surface 不建立这条连接。 diff --git a/docs/internals/operators/debug.md b/docs/internals/operators/debug.md index 9cf8421..355c0c5 100644 --- a/docs/internals/operators/debug.md +++ b/docs/internals/operators/debug.md @@ -1,6 +1,6 @@ # Debug Operator -Debug Operator 从面板中的模型参数发起一次原始推理,把结果文件留在 Job 目录中供开发者检查,不创建用户场景对象。 +Debug Operator 从面板中的模型参数发起一次原始推理,把结果文件写入 Job 目录并打开目录供开发者检查。 ## 执行过程 @@ -13,7 +13,7 @@ flowchart TD Output --> Directory["打开 Job 目录"] ``` -DA3、MoGe-2、BEN2 和 Upscale 都走这条调试路径。它验证模型、设备、请求参数和输出格式,但不代表正式功能的对象创建流程。 +DA3、MoGe-2、BEN2 和 Upscale 都走这条调试路径,用于验证模型、设备、请求参数和输出格式。 ## 节点说明 @@ -25,8 +25,8 @@ DA3、MoGe-2、BEN2 和 Upscale 都走这条调试路径。它验证模型、设 `debug.py` 分别提交 `debug-da3`、`debug-moge2`、`debug-ben2` 和 `debug-upscale` Job。Server 侧由 `server/jobs/debug_predictions.py` 接收请求,再调用相应模型适配器。 -DA3 和 MoGe-2 的可视化与数组输出由 `server/outputs/debug_da3.py`、`server/outputs/debug_moge2.py` 写出;BEN2 与 Upscale 保留模型返回的图片结果。这里允许 DA3 继续存在,但它不会被 Plane、Depth Plane 或 Cutout 的正式 Operator 调用。 +DA3 和 MoGe-2 的可视化与数组输出由 `server/outputs/debug_da3.py`、`server/outputs/debug_moge2.py` 写出;BEN2 与 Upscale 保存模型返回的图片结果。 ### 查看结果 -任务完成后,`debug.py` 的 `_open_job_directory()` 调用 Blender 的路径打开操作,直接显示该 Job 的目录。响应阶段不导入 Image,不创建 Mesh、Material 或 Modifier,也不修改当前场景。 +任务完成后,`debug.py` 的 `_open_job_directory()` 调用 Blender 的路径打开操作,直接显示该 Job 的目录。 diff --git a/docs/internals/operators/image-tool.md b/docs/internals/operators/image-tool.md index 363c7af..dadcd33 100644 --- a/docs/internals/operators/image-tool.md +++ b/docs/internals/operators/image-tool.md @@ -24,13 +24,13 @@ flowchart TD `operators/image_tool/__init__.py` 汇总注册类型;`box.py`、`lasso.py`、`line.py` 和 `polyline.py` 分别定义四个 WorkspaceTool。它们都把操作交给 `edit_mask.py` 中的 `EditImageMask`。 -屏幕坐标投射、点击选择、轨迹预览和状态切换来自 `operators/common/image_interaction.py`。Lasso 按移动距离采点并合并近似共线点;Line 扩展为有向半平面;Polyline 在确认时闭合路径。 +屏幕坐标投射、点击选择、轨迹预览和状态切换来自 `common/image_interaction.py`。Lasso 按移动距离采点并合并近似共线点;Line 扩展为有向半平面;Polyline 在确认时闭合路径。 ### RegionPath → RegionMask -`EditImageMask` 通过 `operators/common/image_region.py` 把手势整理为 `RegionPath`,再栅格化为 `RegionMask`。凹多边形使用三角剖分,自交路径使用 `EVEN_ODD` 填充。预览缓存只在轨迹变化时更新。 +`EditImageMask` 通过 `common/image_region.py` 把手势整理为 `RegionPath`,再栅格化为 `RegionMask`。凹多边形使用三角剖分,自交路径使用 `EVEN_ODD` 填充。预览缓存只在轨迹变化时更新。 -Box 和 Lasso 分别读取自己的 **Fit to Foreground** 设置。Line 与 Polyline 强制关闭该选项,因此不会改变直线边界。 +Box 和 Lasso 分别读取自己的 **Fit to Foreground** 设置。Line 与 Polyline 直接使用几何边界。 ### Fit to Foreground @@ -40,6 +40,6 @@ Box 和 Lasso 分别读取自己的 **Fit to Foreground** 设置。Line 与 Poly ### 源 Alpha × RegionMask -`edit_mask.py` 保留源 RGB,只把最终 `RegionMask` 乘入源 Alpha,再按非零 Alpha 紧裁切。像素读取、结果 Image 创建和共享引用处理使用 `operators/common/image_data.py`。 +`edit_mask.py` 保留源 RGB,只把最终 `RegionMask` 乘入源 Alpha,再按非零 Alpha 紧裁切。像素读取、结果 Image 创建和共享引用处理使用 `common/image_data.py`。 -公共 Image Empty 替换入口会同步更新 `empty_display_size` 和 `empty_image_offset`,因此裁切后的内容仍对齐原图片平面,对象 `matrix_world` 不变。Perspective 使用相同的像素写回能力,但属于独立工具和状态。 +公共 Image Empty 替换入口同步更新 `empty_display_size` 和 `empty_image_offset`,使裁切后的内容对齐原图片平面并保持对象 `matrix_world`。 diff --git a/docs/internals/operators/index.md b/docs/internals/operators/index.md index 8b8d9d0..2a971f6 100644 --- a/docs/internals/operators/index.md +++ b/docs/internals/operators/index.md @@ -1,6 +1,6 @@ # Operator 实现索引 -`src/anyimage/operators/` 负责 Blender 侧交互、Job 请求组装和结果导入。内部实现按完整功能包或顶层业务模块拆页;`common/` 的边界见[程序架构](../architecture.md),实际公开入口见[Common 公共函数](../common.md)。 +`src/anyimage/operators/` 负责 Blender 侧交互、Job 请求组装和结果导入。 | Operator 路径 | 实现页 | 主要职责 | | --- | --- | --- | @@ -15,4 +15,4 @@ | `operators/remove_background.py` | [Remove Background](remove-background.md) | BEN2 去背与结果替换 | | `operators/upscale.py` | [Upscale](upscale.md) | 当前 Image 的 4× 高清与输入长边检查 | -`operators/__init__.py` 只聚合注册类,不承载独立业务流程。具体类的注册和逆序注销由扩展入口统一管理,整体边界见[程序架构](../architecture.md)。 +`operators/__init__.py` 聚合注册类,扩展入口按顺序注册并逆序注销。 diff --git a/docs/internals/operators/perspective-tool.md b/docs/internals/operators/perspective-tool.md index f4212d8..1d88a1b 100644 --- a/docs/internals/operators/perspective-tool.md +++ b/docs/internals/operators/perspective-tool.md @@ -15,7 +15,7 @@ flowchart TD Crop --> Write["Pack 并写回 Empty"] ``` -预览只在第四点确认后生成一次;之后拖动鼠标只改变目标宽高比。用户确认时才执行全分辨率 Warp。整个流程在 Blender 进程内完成,不使用 Job Server。 +第四点确认后生成预览,拖动鼠标调整目标宽高比,用户确认后执行全分辨率 Warp。整个流程在 Blender 进程内完成。 ## 节点说明 @@ -23,18 +23,18 @@ flowchart TD `operators/perspective_tool/__init__.py` 导出注册类型,交互和计算集中在 `edit_perspective.py`。`ImagePerspectiveTool` 启动 `EditImagePerspective`,后者接受任意顺序的四个图片角点,再根据源图 X/Y 方向整理成不交错的凸 Quad。 -图片坐标投射和首次点击选择复用 `operators/common/image_interaction.py`。Perspective 始终把四点称为 Quad,不使用 Image Tool 的 Region 或 Mask 状态。 +图片坐标投射和首次点击选择复用 `common/image_interaction.py`。四个角点在该流程中统一表示为 Quad。 ### 预览并调整比例 -第四点确定后,`edit_perspective.py` 创建一次 512 × 512 预览纹理。鼠标移动只更新目标宽高比和 HUD,可吸附到常用比例,不会重复重采样源图。 +第四点确定后,`edit_perspective.py` 创建 512 × 512 预览纹理。鼠标移动更新目标宽高比和 HUD,并可吸附到常用比例。 输出尺寸根据 Quad 边长和目标比例计算,最长边不超过 8192 px,总像素不超过 16,777,216。 ### 执行 Homography -确认后,`edit_perspective.py` 计算 Homography,并按 256 行分块处理全分辨率像素。插值使用预乘 Alpha,避免透明边缘混入黑色。Perspective 只做形变,不提供 Fit to Foreground 或反选。 +确认后,`edit_perspective.py` 计算 Homography,并按 256 行分块处理全分辨率像素。插值使用预乘 Alpha,避免透明边缘混入黑色。 ### 裁切并写回 -Warp 结果按可见 Alpha 紧裁切,再通过 `operators/common/image_data.py` 创建 Packed Image 并替换当前 Empty。结果宽高比可能不同于原 Quad 的轴对齐范围,因此写回时保持中心和覆盖尺度近似一致,而不是复用原图片像素范围。 +Warp 结果按可见 Alpha 紧裁切,再通过 `common/image_data.py` 创建 Packed Image 并替换当前 Empty。写回时保持中心和覆盖尺度近似一致。 diff --git a/docs/internals/operators/remove-background.md b/docs/internals/operators/remove-background.md index d402e05..85f33a0 100644 --- a/docs/internals/operators/remove-background.md +++ b/docs/internals/operators/remove-background.md @@ -14,13 +14,13 @@ flowchart TD Import --> Replace["替换 Empty.data"] ``` -Blender 负责媒体输入准备和结果导入,BEN2 推理留在 Server。Packed 图片和内存中修改过的单图会先导出临时文件;可直接读取的文件图片、视频和序列沿用原路径。 +Packed 图片和内存中修改过的单图会先导出临时文件;文件图片、视频和序列沿用原路径。 ## 节点说明 ### 准备媒体输入 -入口是 `operators/remove_background.py` 中的 `RemoveImageBackground`。它检查当前对象、AI Environment 和帧数,再调用 `operators/common/image_data.py` 的 `prepare_image_input()`。多帧输入会先写入 Scene 中的抽帧设置。 +入口是 `operators/remove_background.py` 中的 `RemoveImageBackground`。它检查当前对象、AI Environment 和帧数,再调用 `common/image_data.py` 的 `prepare_image_input()`。多帧输入会先写入 Scene 中的抽帧设置。 准备完成后,内部 Operator `RunBackgroundRemoval` 保存输入路径、源对象名和临时文件清理标记。 @@ -32,6 +32,6 @@ BEN2 推理前先用源 Alpha 屏蔽透明区域,模型输出的前景 Alpha ### 导入并替换结果 -Job 返回后,`remove_background.py` 的 `apply_background_response()` 调用 `operators/common/image_data.py` 创建单图或序列 Image,并恢复源 Image User 的帧起点、偏移和循环设置。 +Job 返回后,`remove_background.py` 的 `apply_background_response()` 调用 `common/image_data.py` 创建单图或序列 Image,并恢复源 Image User 的帧起点、偏移和循环设置。 -同一公共入口随后替换 `Empty.data`。旧 Image 没有其他用户且未启用 Fake User 时会被清理,结果继承原名;否则保留旧数据块。最后提交一个 Remove Background Undo 步骤,并清理本次创建的临时输入。 +`replace_empty_image()` 随后替换 `Empty.data`,提交一个 Remove Background Undo 步骤,并清理本次创建的临时输入。 diff --git a/docs/internals/operators/upscale.md b/docs/internals/operators/upscale.md index 47fe277..a2b687c 100644 --- a/docs/internals/operators/upscale.md +++ b/docs/internals/operators/upscale.md @@ -25,20 +25,20 @@ flowchart TD 入口位于 `operators/upscale.py`。`UpscaleImage.poll()` 读取 `preferences.py` 中的 **Maximum AI Input Size**;当前 Image 长边必须严格小于该值。菜单显示的预计尺寸来自同一文件的 `upscale_output_size()`。 -执行时,`upscale.py` 冻结当前模型和输入上限,并通过 `operators/common/image_data.py` 准备单图、视频或序列输入。Packed Image 与内存修改结果会导出临时文件,多帧输入保留帧起点和抽帧设置。 +执行时,`upscale.py` 冻结当前模型和输入上限,并通过 `common/image_data.py` 准备单图、视频或序列输入。Packed Image 与内存修改结果会导出临时文件,多帧输入保留帧起点和抽帧设置。 ### 分块放大 RGB `UpscaleImage` 提交 `upscale-image` Job。`server/jobs/upscale.py` 展开媒体帧并逐帧调用模型;`server/models/upscale.py` 负责输入约束、RGB/Alpha 分离和结果合并,`server/models/onnx_upscale.py` 以 256 × 256 分块执行 4× ONNX 推理并带入相邻上下文。 -`server/media/resolution.py` 统一解释输入长边上限。正式 Upscale 在 Blender 侧直接禁用超限图片,因此不会进入预缩放路径,输出长边小于上限的四倍;模型层仍保留同一限制作为任务边界。 +`server/media/resolution.py` 统一解释输入长边上限。Blender 侧要求源图长边小于该值,结果长边小于该值的四倍。 ### 合并 Alpha -Alpha 不进入模型,由 `server/models/upscale.py` 使用 Lanczos 放大到目标尺寸,再与 4× RGB 合并为 RGBA。Provider 资源不足时,Server 释放当前 Upscale Session,并返回降低输入上限或改用 CPU 的提示。 +`server/models/upscale.py` 使用 Lanczos 将 Alpha 放大到目标尺寸,再与 4× RGB 合并为 RGBA。Provider 资源不足时,Server 释放当前 Upscale Session,并返回降低输入上限或改用 CPU 的提示。 ### 导入并替换结果 -响应阶段回到 `operators/upscale.py`,通过 `operators/common/image_data.py` 读取 `upscale.*` 结果序列并替换当前 Empty。源 Image 没有其他用户且未启用 Fake User 时会被清理并由结果继承名称;存在其他用户时只替换当前 Empty 的引用。 +响应阶段回到 `operators/upscale.py`,通过 `common/image_data.py` 读取 `upscale.*` 结果序列并替换当前 Empty。 -结果不添加 Upscale 专属数据块后缀,也不保留来源引用。后续再次 Upscale 或转换 Depth Plane 时,读取的都是当前结果 Image。 +后续 Upscale 和 Depth Plane 都以当前结果 Image 为输入。 diff --git a/docs/internals/outputs.md b/docs/internals/outputs.md index 8305e96..fecd3ab 100644 --- a/docs/internals/outputs.md +++ b/docs/internals/outputs.md @@ -1,6 +1,6 @@ # Job 产物 -Job 目录是 Server 与 Blender Operator 的交换边界。Server 写入普通图片、JSON 或 Debug NPZ;Blender 只读取 Job 返回值明确声明的文件,再创建最终 Image、Mesh、Material 与 Object。 +Server 在 Job 目录写入图片、JSON 或 Debug NPZ;Blender 根据 Job 返回值读取文件并创建 Image、Mesh、Material 与 Object。 ## 正式 Job @@ -12,7 +12,7 @@ Job 目录是 Server 与 Blender Operator 的交换边界。Server 写入普通 | `generate-depth-plane-geometry` | `image.0001.*`、`z-depth.exr`、`depth.json` | 创建 Depth Plane | | `generate-cutout-artifacts` | 可选 Region、Depth 与 Normal 文件 | 创建 Cutout Shape 与材质 | -两个 Depth Job 都不输出 Blender 网格。Server 保存可由 Blender 直接加载的模型空间 EXR;轮廓提取、三角化、Depth 标定以及 Blender 数据创建仍在主线程完成。 +Depth Job 保存可由 Blender 直接加载的模型空间 EXR;Blender 主线程负责轮廓提取、三角化、Depth 标定和数据创建。 ## Depth 与 Normal 文件 @@ -26,7 +26,7 @@ MoGe-2 Prediction 只存在于 Server 内存。`geometry/prediction_artifacts.py | `tangent-normal.png` | Tangent Space Normal Map | | `object-normal.png` | Object Space Normal Map | -Blender 通过 `operators/common/depth_data.py` 直接加载 EXR,设为 Non-Color 并 Pack;不会从 NumPy 点场再创建一张 Depth Image。Job 文件名不改变 Blender 数据块命名:Depth 与 Normal 仍分别使用 `depth_data_name()` 和 `normal_data_name()`。Blender 读取 Z 与 Alpha 只用于最终 Region 的本地标定,Geometry Nodes 直接采样已加载的 EXR。 +Blender 通过 `common/depth_data.py` 加载 EXR,设为 Non-Color 并 Pack。Depth 与 Normal 数据块分别由 `depth_data_name()` 和 `normal_data_name()` 命名。Blender 读取 Z 与 Alpha 进行最终 Region 的本地标定,Geometry Nodes 采样已加载的 EXR。 ## Cutout Region 产物 @@ -37,7 +37,7 @@ Blender 先把 `RegionPath` 栅格化为 `RegionMask`,再把局部 RGBA 作为 - 需要 Z Depth 时请求 `z-depth.exr` 与 `depth.json`; - Normal Map 按空间请求 `tangent-normal.png` 或 `object-normal.png`。 -未启用 Fit to Foreground,且不需要 Depth 或 Normal Map 时,Surface 与 Balloon 不提交 Job。最终颜色图仍由 Blender 从源 Image 裁切,并把 Alpha 乘以最终 `RegionMask`。 +Surface 与 Balloon 的本地路径直接使用 `RegionMask` 构建 Shape。最终颜色图由 Blender 按 Region 范围裁切源 Image,并保留源 RGBA。 ## Debug 产物 @@ -48,4 +48,4 @@ Blender 先把 `RegionPath` 栅格化为 `RegionMask`,再把局部 RGBA 作为 | `debug-ben2` | `foreground*.png` | | `debug-upscale` | `prediction*.png` | -Debug Job 只用于直接检查模型适配层,不参与正式对象创建流程。 +Debug Job 保存模型适配层的直接输出。 diff --git a/docs/internals/runtime.md b/docs/internals/runtime.md index 1af9a3a..e6bad33 100644 --- a/docs/internals/runtime.md +++ b/docs/internals/runtime.md @@ -1,6 +1,6 @@ # Job Runtime 与 Server -AnyImage 使用 BlendJob 管理独立 Python Environment、Server 进程与单 Worker 队列。Blender 侧只准备输入、提交普通字典参数并导入结果;`server/` 不依赖 `bpy`。 +AnyImage 使用 BlendJob 管理独立 Python Environment、Server 进程与单 Worker 队列。Blender 侧准备输入、提交普通字典参数并导入结果。 ## 目录职责 @@ -20,7 +20,7 @@ src/anyimage/ └── outputs/ # Debug Prediction 序列化 ``` -公开 Job 不互相导入实现。`jobs/` 只编排,稳定的原子能力放在对应领域目录中。 +`jobs/` 编排任务,领域目录提供媒体、Region、Geometry、模型和输出能力。 ## 生命周期 @@ -52,15 +52,15 @@ Job 在阶段边界调用 `context.check_cancelled()`,并把稳定产物写入 ## 尺寸约束 -`server/media/resolution.py` 提供 **Maximum AI Input Size**,默认 `2048 px`。它限制 Upscale 的可提交源图长边,以及 Depth、Normal Map、Fit to Foreground 的模型分析图长边;不会降低材质颜色图分辨率。 +`server/media/resolution.py` 提供 **Maximum AI Input Size**,默认 `2048 px`。它限制 Upscale 的可提交源图长边,以及 Depth、Normal Map、Fit to Foreground 的模型分析图长边;材质颜色图使用原始分辨率。 -Cutout 另有 Blender 侧的 **Maximum Cutout Mesh Resolution**。它限制所有 Cutout Shape 的网格采样密度,包括完全不使用 AI 的 Surface 与 Balloon。两个设置没有隐式绑定。 +Blender 侧的 **Maximum Cutout Mesh Resolution** 统一限制所有 Cutout Shape 的网格采样密度。 `generate-depth-plane-geometry` 与 `generate-cutout-artifacts` 只接受一帧,因此 Depth Plane 与 Cutout 是静态图工作流。Remove Background 与 Upscale 继续通过媒体入口处理单图、视频和序列帧。 -## 模型边界 +## 模型 -正式 Depth Plane、Cutout Depth 与 Normal Map 都只使用 MoGe-2。BEN2 用于 Remove Background 与 Fit to Foreground,Upscale 使用所选高清模型。DA3 只保留 `debug-da3` 及其 Debug 参数、模型下载与适配代码,不进入任何生产 Operator、Job 或自动回退。 +Depth Plane、Cutout Depth 与 Normal Map 使用 MoGe-2,Remove Background 与 Fit to Foreground 使用 BEN2,Upscale 使用所选高清模型。 `ModelManager` 按模型族管理下载目录与 ONNX Session,同一族只保留最近使用的模型和 Device,避免同类模型重复占用资源。 diff --git a/docs/reference/features.md b/docs/reference/features.md deleted file mode 100644 index 40ba950..0000000 --- a/docs/reference/features.md +++ /dev/null @@ -1,55 +0,0 @@ -# 功能参考 - -本页集中列出 AnyImage 当前实现的主要入口。操作建议见[工作流选择](../guides/workflows.md),内部设计见[内部实现总览](../internals/index.md)。 - -## Blender 入口 - -| 入口 | 位置 | 输入 | -| --- | --- | --- | -| Paste Clipboard Image | 3D View / oode 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、未启用 Fit to Foreground 的 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 在分支后执行;Depth Shape 根据 Region 透明比例选择固定或自适应 Base Plane Depth,Generate Normal Map 是启用开关:Depth Surface 自动使用 Object Space,其余 Shape 自动使用 Tangent Space;Fit to Foreground 按需启用 BEN2。 - -### 图片结果 - -Image Mask 和 Perspective 直接更新当前 Empty 的图片并 Pack。Remove Background 输出透明颜色图或序列。Upscale 通过三档 ONNX 模型把当前 Image 输出为 4× RGBA;当前长边达到全局输入上限时不可执行。 - -## 模型 - -- MoGe-2 ViT-S / ViT-B / ViT-L Normal -- 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/stylesheets/extra.css b/docs/stylesheets/extra.css index 0606ec9..76c0982 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1,27 +1,28 @@ +@import url("https://fontsapi.zeoseven.com/292/main/result.css"); +@import url("https://fontsapi.zeoseven.com/293/main/result.css"); + +/* [data-md-color-scheme="slate"] { + --md-code-hl-string-color: #8b8b8b; + --md-code-hl-keyword-color: #ffe8a3; + --md-code-hl-number-color: #ffe8a3; + --md-typeset-a-color: #ffe8a3; +} + +[data-md-color-scheme="default"] { + --md-code-hl-string-color: #444444; + --md-code-hl-keyword-color: #bd9100; + --md-code-hl-number-color: #bd9100; +} + +[data-md-color-scheme=slate][data-md-color-primary=black] { + --md-typeset-a-color: #ffe8a3; +} + +[data-md-color-scheme=default][data-md-color-primary=black] { + --md-typeset-a-color: #bd9100; +} */ + :root { - --md-text-font: "Open Sans", "Noto Sans SC", "Microsoft YaHei", sans-serif; - --md-code-font: "Cascadia Code", "SFMono-Regular", Consolas, monospace; -} - -.md-typeset h1 { - font-weight: 700; - letter-spacing: -0.02em; -} - -.md-typeset .grid.cards > ul > li { - border-radius: 0.5rem; -} - -.md-typeset .screenshot-placeholder { - margin: 1rem 0; - padding: 2.5rem 1.25rem; - border: 2px dashed var(--md-default-fg-color--lighter); - border-radius: 0.5rem; - color: var(--md-default-fg-color--light); - text-align: center; - background: var(--md-code-bg-color); -} - -.md-typeset .screenshot-placeholder p { - margin: 0.35rem 0; -} + --md-text-font: "LXGW WenKai"; + --md-code-font: "LXGW WenKai Mono"; +} \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index f4207f6..33bec50 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,20 +12,6 @@ nav: - 开始使用: - 安装与首次配置: getting-started/installation.md - 五分钟上手: getting-started/quick-start.md - - 粘贴剪贴板图片: guides/clipboard-image.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 @@ -102,8 +88,6 @@ plugins: - search markdown_extensions: - - attr_list - - md_in_html - mkdocs-click - admonition - pymdownx.highlight: @@ -113,7 +97,9 @@ markdown_extensions: - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.details - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid - pymdownx.tabbed: alternate_style: true - diff --git a/pyproject.toml b/pyproject.toml index 8eb1540..e94deee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,23 +7,33 @@ requires-python = ">=3.11,<3.12" license = "GPL-3.0-or-later" dependencies = ["blendjob==0.1.14"] +[project.scripts] +pack = "tools.pack:main" +build_node = "tools.build_node:main" +sync_models = "tools.sync_models:main" +docs = "tools.docs:main" + +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + [dependency-groups] dev = [ "av==17.0.0", "bpy==4.5.3", "huggingface-hub==1.4.1", + "mike>=2.1.3,<3", + "mkdocs>=1.6.1,<2", + "mkdocs-click==0.9.0", + "mkdocs-material>=9.6.16,<10", "numpy>=1.26,<2.0", + "onnx==1.19.0", "onnxruntime==1.24.4", "pillow==12.1.1", "pip>=25,<27", "pytest", + "torch==2.13.0", "trimesh==4.11.2", ] -docs = [ - "mike>=2.1.3,<3", - "mkdocs>=1.6.1,<2", - "mkdocs-material>=9.6.16,<10", -] - -[tool.uv] -package = false +[tool.setuptools] +packages = ["tools"] diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 1d57865..5735c14 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:9de415a7fb052b4c998e2263e41a5cd5e421200bed8413bce4b957405ac1c07b +oid sha256:0c4ff876a695bbc1af419a6007b47cced337c611fae0c5c519618623280182af size 1638353 diff --git a/src/anyimage/common/__init__.py b/src/anyimage/common/__init__.py new file mode 100644 index 0000000..e5e590b --- /dev/null +++ b/src/anyimage/common/__init__.py @@ -0,0 +1 @@ +"""Shared Blender-side image, node, object, interaction, and AI Job helpers.""" diff --git a/src/anyimage/operators/common/ai_job.py b/src/anyimage/common/ai_job.py similarity index 93% rename from src/anyimage/operators/common/ai_job.py rename to src/anyimage/common/ai_job.py index 1e4406f..795da08 100644 --- a/src/anyimage/operators/common/ai_job.py +++ b/src/anyimage/common/ai_job.py @@ -2,9 +2,9 @@ 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 ..preferences import addon_preferences +from ..properties import ai_ready, model_ready, model_record +from ..runtime import runtime BEN2_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"} BEN2_VIDEO_SUFFIXES = { diff --git a/src/anyimage/operators/common/depth_data.py b/src/anyimage/common/depth_data.py similarity index 100% rename from src/anyimage/operators/common/depth_data.py rename to src/anyimage/common/depth_data.py diff --git a/src/anyimage/operators/common/image_data.py b/src/anyimage/common/image_data.py similarity index 100% rename from src/anyimage/operators/common/image_data.py rename to src/anyimage/common/image_data.py diff --git a/src/anyimage/operators/common/image_interaction.py b/src/anyimage/common/image_interaction.py similarity index 99% rename from src/anyimage/operators/common/image_interaction.py rename to src/anyimage/common/image_interaction.py index 3c56b6a..8cd573b 100644 --- a/src/anyimage/operators/common/image_interaction.py +++ b/src/anyimage/common/image_interaction.py @@ -3,7 +3,7 @@ import math import bpy -from ...properties import ai_ready, ai_setup_label, ai_status +from ..properties import ai_ready, ai_setup_label, ai_status from .image_data import ( image_empty_bounds, is_image_empty, diff --git a/src/anyimage/operators/common/image_region.py b/src/anyimage/common/image_region.py similarity index 100% rename from src/anyimage/operators/common/image_region.py rename to src/anyimage/common/image_region.py diff --git a/src/anyimage/common/node.py b/src/anyimage/common/node.py new file mode 100644 index 0000000..158d2b6 --- /dev/null +++ b/src/anyimage/common/node.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import bpy + + +NODE_ASSET_NAME = "O_AnyImage.blend" + + +def node_asset_path(): + return Path(__file__).resolve().parents[1] / "assets" / NODE_ASSET_NAME + + +def load_node_group(group_name): + existing = bpy.data.node_groups.get(group_name) + if existing is not None: + 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 diff --git a/src/anyimage/operators/common/mesh_result.py b/src/anyimage/common/object.py similarity index 86% rename from src/anyimage/operators/common/mesh_result.py rename to src/anyimage/common/object.py index cb83f86..b3cb7f7 100644 --- a/src/anyimage/operators/common/mesh_result.py +++ b/src/anyimage/common/object.py @@ -1,5 +1,3 @@ -from pathlib import Path - import bpy from .image_data import ( @@ -8,50 +6,7 @@ from .image_data import ( image_pixels, is_animated_image, ) - - -NODE_ASSET_NAME = "O_AnyImage.blend" - - -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: - 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 +from .node import load_node_group def modifier_input_identifier(node_group, name): @@ -139,7 +94,7 @@ def material_node_group(depth_plane=False): if depth_plane else IMAGE_MATERIAL_NODE_GROUP_NAME ) - return load_asset_node_group(group_name) + return load_node_group(group_name) def create_image_material( @@ -224,7 +179,7 @@ def create_image_material( links.new(normal_texture.outputs["Color"], material_inputs.inputs["Normal"]) if shadeless: shader = nodes.new("ShaderNodeGroup") - shader.node_tree = load_asset_node_group(SHADELESS_NODE_GROUP_NAME) + shader.node_tree = load_node_group(SHADELESS_NODE_GROUP_NAME) shader.name = SHADELESS_NODE_GROUP_NAME transparent = nodes.new("ShaderNodeBsdfTransparent") mix_shader = nodes.new("ShaderNodeMixShader") diff --git a/src/anyimage/menu.py b/src/anyimage/menu.py index c87a29c..07282f1 100644 --- a/src/anyimage/menu.py +++ b/src/anyimage/menu.py @@ -1,6 +1,6 @@ import bpy -from .operators.common.image_data import is_image_empty +from .common.image_data import is_image_empty from .operators.upscale import upscale_output_size diff --git a/src/anyimage/operators/ai_setup.py b/src/anyimage/operators/ai_setup.py index 98eb8a4..65b3997 100644 --- a/src/anyimage/operators/ai_setup.py +++ b/src/anyimage/operators/ai_setup.py @@ -8,7 +8,7 @@ from ..properties import ( model_download_size, model_record, ) -from .common.ai_job import require_environment +from ..common.ai_job import require_environment def format_download_size(size): diff --git a/src/anyimage/operators/common/__init__.py b/src/anyimage/operators/common/__init__.py deleted file mode 100644 index 7c8a6d5..0000000 --- a/src/anyimage/operators/common/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Shared Blender-side image data, interaction, mesh result, and AI Job helpers.""" diff --git a/src/anyimage/operators/convert_to_plane/conversion.py b/src/anyimage/operators/convert_to_plane/conversion.py index 1a2ee27..90e64ad 100644 --- a/src/anyimage/operators/convert_to_plane/conversion.py +++ b/src/anyimage/operators/convert_to_plane/conversion.py @@ -8,7 +8,7 @@ from ...server.media.resolution import ( MAX_MAX_AI_INPUT_SIZE, MIN_MAX_AI_INPUT_SIZE, ) -from ..common.ai_job import ( +from ...common.ai_job import ( invoke_ai_setup_if_needed, moge2_parameters, production_device, @@ -16,7 +16,7 @@ from ..common.ai_job import ( require_input_path, require_model, ) -from ..common.image_data import ( +from ...common.image_data import ( cleanup_image_input, image_user_settings, is_image_empty, @@ -24,7 +24,7 @@ from ..common.image_data import ( source_start_frame, video_total_frames, ) -from ..common.mesh_result import create_image_material +from ...common.object import create_image_material from .object import ( create_color_image, create_depth_plane_from_result, diff --git a/src/anyimage/operators/convert_to_plane/mesh_plane.py b/src/anyimage/operators/convert_to_plane/mesh_plane.py index 7ca4d49..cdfdfba 100644 --- a/src/anyimage/operators/convert_to_plane/mesh_plane.py +++ b/src/anyimage/operators/convert_to_plane/mesh_plane.py @@ -2,8 +2,8 @@ import bpy -from ..common.mesh_result import modifier_input_identifier, set_modifier_input -from .nodes import mesh_plane_node_group +from ...common.object import modifier_input_identifier, set_modifier_input +from ...common.node import load_node_group MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane" @@ -41,7 +41,7 @@ def add_mesh_plane_processing( subdivisions=0, thickness=0.0, ): - node_group = mesh_plane_node_group() + node_group = load_node_group(MESH_PLANE_NODE_GROUP_NAME) modifier = plane_object.modifiers.new( name=MESH_PLANE_NODE_GROUP_NAME, type="NODES", diff --git a/src/anyimage/operators/convert_to_plane/nodes.py b/src/anyimage/operators/convert_to_plane/nodes.py deleted file mode 100644 index a5aa217..0000000 --- a/src/anyimage/operators/convert_to_plane/nodes.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Asset-backed Geometry Nodes for Plane conversion.""" - -from ..common.mesh_result import load_asset_node_group - - -MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane" -DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane" - - -def mesh_plane_node_group(): - return load_asset_node_group(MESH_PLANE_NODE_GROUP_NAME) - - -def depth_plane_node_group(): - return load_asset_node_group(DEPTH_PLANE_NODE_GROUP_NAME) - - -def plane_node_group(shape): - if shape == "PLANE": - return 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/convert_to_plane/object.py b/src/anyimage/operators/convert_to_plane/object.py index 922d5d5..d4456dc 100644 --- a/src/anyimage/operators/convert_to_plane/object.py +++ b/src/anyimage/operators/convert_to_plane/object.py @@ -2,26 +2,29 @@ import bpy -from ..common.image_data import ( +from ...common.image_data import ( color_data_name, image_empty_bounds, image_pixels, load_color_result_image, require_image_empty, ) -from ..common.depth_data import ( +from ...common.depth_data import ( depth_uniform_scale, load_depth_metadata, load_depth_result_image, ) -from ..common.mesh_result import ( +from ...common.object import ( create_image_material, finalize_object_result, modifier_input_identifier, set_modifier_input, ) +from ...common.node import load_node_group from .mesh_plane import create_mesh_plane_mesh, create_mesh_plane_object -from .nodes import depth_plane_node_group + + +DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane" def create_color_image(source_object): @@ -71,7 +74,7 @@ def create_depth_plane_object( plane.matrix_world = source_object.matrix_world.copy() plane["anyimage_mesh_shape"] = "DEPTH_PLANE" - node_group = depth_plane_node_group() + node_group = load_node_group(DEPTH_PLANE_NODE_GROUP_NAME) modifier = plane.modifiers.new(name=node_group.name, type="NODES") modifier.node_group = node_group modifier.show_group_selector = False diff --git a/src/anyimage/operators/cutout_tool/geometry.py b/src/anyimage/operators/cutout_tool/geometry.py index 477f361..5353cdc 100644 --- a/src/anyimage/operators/cutout_tool/geometry.py +++ b/src/anyimage/operators/cutout_tool/geometry.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from ...preferences import addon_preferences from ...server.media.resolution import limited_image_size -from ..common.image_data import image_empty_bounds +from ...common.image_data import image_empty_bounds @dataclass(frozen=True) diff --git a/src/anyimage/operators/cutout_tool/interaction.py b/src/anyimage/operators/cutout_tool/interaction.py index f4feceb..0870730 100644 --- a/src/anyimage/operators/cutout_tool/interaction.py +++ b/src/anyimage/operators/cutout_tool/interaction.py @@ -4,9 +4,10 @@ from ...preferences import ( MAX_CUTOUT_MESH_RESOLUTION, MIN_CUTOUT_MESH_RESOLUTION, ) -from ..common.image_data import image_rgba -from ..common.image_region import rasterize_region_path +from ...common.image_data import image_rgba +from ...common.image_region import rasterize_region_path from .geometry import CUTOUT_ALPHA_THRESHOLD +from .shape import CUTOUT_SHAPE_LABELS def validate_cutout_region( @@ -76,7 +77,7 @@ def open_shape_pie( _pie_button( pie, "SURFACE", - "Surface", + CUTOUT_SHAPE_LABELS["SURFACE"], "MESH_GRID", source_name, region_path_json, @@ -87,7 +88,7 @@ def open_shape_pie( _pie_button( pie, "BALLOON", - "Balloon", + CUTOUT_SHAPE_LABELS["BALLOON"], "MESH_UVSPHERE", source_name, region_path_json, @@ -98,7 +99,7 @@ def open_shape_pie( _pie_button( pie, "DEPTH_BALLOON", - "Depth Balloon", + CUTOUT_SHAPE_LABELS["DEPTH_BALLOON"], "META_BALL", source_name, region_path_json, @@ -109,7 +110,7 @@ def open_shape_pie( _pie_button( pie, "DEPTH_SURFACE", - "Depth Surface", + CUTOUT_SHAPE_LABELS["DEPTH_SURFACE"], "SURFACE_NCURVE", source_name, region_path_json, diff --git a/src/anyimage/operators/cutout_tool/main.py b/src/anyimage/operators/cutout_tool/main.py index 8ee305f..9e4cccd 100644 --- a/src/anyimage/operators/cutout_tool/main.py +++ b/src/anyimage/operators/cutout_tool/main.py @@ -22,7 +22,7 @@ from ...server.media.resolution import ( MAX_MAX_AI_INPUT_SIZE, MIN_MAX_AI_INPUT_SIZE, ) -from ..common.ai_job import ( +from ...common.ai_job import ( invoke_ai_setup_if_needed, moge2_parameters, production_device, @@ -30,7 +30,7 @@ from ..common.ai_job import ( require_input_path, require_model, ) -from ..common.image_data import ( +from ...common.image_data import ( cleanup_image_input, image_rgba, load_normal_result_image, @@ -39,8 +39,8 @@ from ..common.image_data import ( source_start_frame, video_total_frames, ) -from ..common.depth_data import load_depth_metadata, load_depth_result_image -from ..common.image_interaction import ( +from ...common.depth_data import load_depth_metadata, load_depth_result_image +from ...common.image_interaction import ( ImageRegionGesture, apply_region_settings, draw_region_settings, @@ -49,7 +49,7 @@ from ..common.image_interaction import ( resolve_image_edit_click, serialize_matrix, ) -from ..common.image_region import ( +from ...common.image_region import ( ImageEditWarning, RegionMask, RegionPath, diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py index a47da09..caa5a6c 100644 --- a/src/anyimage/operators/cutout_tool/object.py +++ b/src/anyimage/operators/cutout_tool/object.py @@ -4,19 +4,19 @@ from enum import StrEnum import bpy -from ..common.image_data import ( +from ...common.image_data import ( color_data_name, image_rgba, ) -from ..common.depth_data import camera_depth_values, depth_uniform_scale -from ..common.mesh_result import ( +from ...common.depth_data import camera_depth_values, depth_uniform_scale +from ...common.object import ( IMAGE_MATERIAL_NODE_GROUP_NAME, create_image_material, finalize_object_result, - load_asset_node_group, modifier_input_identifier, set_modifier_input, ) +from ...common.node import load_node_group from .geometry import ( DEFAULT_DEPTH_BASELINE, DEFAULT_DEPTH_DIRECTION, @@ -123,7 +123,7 @@ def _cropped_color_image(source_object, region_mask, source_rgba): def load_cutout_node_group(): - return load_asset_node_group(CUTOUT_NODE_GROUP_NAME) + return load_node_group(CUTOUT_NODE_GROUP_NAME) def _connect_balloon_normal_scale(material): diff --git a/src/anyimage/operators/debug.py b/src/anyimage/operators/debug.py index 9a0c836..180716d 100644 --- a/src/anyimage/operators/debug.py +++ b/src/anyimage/operators/debug.py @@ -4,7 +4,7 @@ import bpy from ..runtime import JobOperatorBase from ..properties import BEN2_MODEL_KEY, REALESRGAN_GENERAL_WDN_MODEL_KEY -from .common.ai_job import ( +from ..common.ai_job import ( require_background_input_path, da3_parameters, require_input_path, diff --git a/src/anyimage/operators/image_tool/box.py b/src/anyimage/operators/image_tool/box.py index 507d67a..ae17176 100644 --- a/src/anyimage/operators/image_tool/box.py +++ b/src/anyimage/operators/image_tool/box.py @@ -1,6 +1,6 @@ import bpy -from ..common.image_interaction import draw_region_settings +from ...common.image_interaction import draw_region_settings from .edit_mask import EditImageMask diff --git a/src/anyimage/operators/image_tool/edit_mask.py b/src/anyimage/operators/image_tool/edit_mask.py index 1b51fa3..d97de5b 100644 --- a/src/anyimage/operators/image_tool/edit_mask.py +++ b/src/anyimage/operators/image_tool/edit_mask.py @@ -4,12 +4,12 @@ import bpy from ...properties import BEN2_MODEL_KEY, refine_region_property from ...runtime import JobOperatorBase -from ..common.ai_job import ( +from ...common.ai_job import ( production_device, require_environment, require_model, ) -from ..common.image_data import ( +from ...common.image_data import ( cleanup_image_input, create_pixel_result_image, image_pixels, @@ -19,13 +19,13 @@ from ..common.image_data import ( require_image_empty, video_total_frames, ) -from ..common.image_interaction import ( +from ...common.image_interaction import ( ImageRegionGesture, gesture_property, image_edit_poll, report_image_edit_exception, ) -from ..common.image_region import ( +from ...common.image_region import ( ImageEditWarning, RegionPath, crop_pixels_to_alpha, diff --git a/src/anyimage/operators/image_tool/lasso.py b/src/anyimage/operators/image_tool/lasso.py index a60df36..59bd1ce 100644 --- a/src/anyimage/operators/image_tool/lasso.py +++ b/src/anyimage/operators/image_tool/lasso.py @@ -1,6 +1,6 @@ import bpy -from ..common.image_interaction import draw_region_settings +from ...common.image_interaction import draw_region_settings from .edit_mask import EditImageMask diff --git a/src/anyimage/operators/perspective_tool/edit_perspective.py b/src/anyimage/operators/perspective_tool/edit_perspective.py index f2e6432..048f0c2 100644 --- a/src/anyimage/operators/perspective_tool/edit_perspective.py +++ b/src/anyimage/operators/perspective_tool/edit_perspective.py @@ -3,14 +3,14 @@ import math import bpy -from ..common.image_interaction import ( +from ...common.image_interaction import ( deserialize_matrix, drawing_in_region, screen_path_to_image_pixels, serialize_matrix, ) -from ..common.image_region import crop_pixels_to_alpha -from ..common.image_data import ( +from ...common.image_region import crop_pixels_to_alpha +from ...common.image_data import ( create_pixel_result_image, image_base_name, image_pixels, @@ -19,7 +19,7 @@ from ..common.image_data import ( require_image_empty, video_total_frames, ) -from ..common.image_interaction import ( +from ...common.image_interaction import ( active_view3d_tool_id, draw_dashed_line, draw_polygon_fill, diff --git a/src/anyimage/operators/remove_background.py b/src/anyimage/operators/remove_background.py index ba5c0dd..adae7a9 100644 --- a/src/anyimage/operators/remove_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.ai_job import ( +from ..common.ai_job import ( require_background_input_path, invoke_ai_setup_if_needed, production_device, @@ -10,7 +10,7 @@ from .common.ai_job import ( require_model, ) -from .common.image_data import ( +from ..common.image_data import ( cleanup_image_input, default_extract_frames, is_image_empty, @@ -70,7 +70,7 @@ class RemoveImageBackground(bpy.types.Operator): def apply_background_response(result, source_object_name): - from .common.image_data import ( + from ..common.image_data import ( load_color_result_image, replace_empty_image, require_image_empty, diff --git a/src/anyimage/operators/upscale.py b/src/anyimage/operators/upscale.py index 9893bd6..20220d8 100644 --- a/src/anyimage/operators/upscale.py +++ b/src/anyimage/operators/upscale.py @@ -8,14 +8,14 @@ from ..server.media.resolution import ( MIN_MAX_AI_INPUT_SIZE, ) from ..runtime import JobOperatorBase -from .common.ai_job import ( +from ..common.ai_job import ( require_input_path, invoke_ai_setup_if_needed, production_device, require_environment, require_model, ) -from .common.image_data import ( +from ..common.image_data import ( cleanup_image_input, default_extract_frames, is_image_empty, diff --git a/sync_models.py b/sync_models.py deleted file mode 100644 index 4d9bba1..0000000 --- a/sync_models.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Prepare AnyImage ONNX models and sync them to OmooLab R2.""" - -import argparse -import hashlib -import shutil -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path -from urllib.request import Request, urlopen - - -PROJECT_ROOT = Path(__file__).resolve().parent -MODELS_DIR = PROJECT_ROOT / "models" -SOURCE_ROOT = PROJECT_ROOT / "src" / "anyimage" -sys.path.insert(0, str(SOURCE_ROOT)) -from server.model_catalog import DOWNLOADABLE_MODELS - -R2_ENDPOINT = "https://models.omoolab.xyz" -CHUNK_SIZE = 1024 * 1024 - - -@dataclass(frozen=True) -class ModelFile: - folder: str - size: int - sha256: str - sources: tuple[str, ...] - filename: str = "model.onnx" - - @property - def destination(self): - return MODELS_DIR / self.folder / self.filename - - -def hf_url(repository, filename): - return f"https://huggingface.co/{repository}/resolve/main/{filename}" - - -def r2_url(folder, filename="model.onnx"): - return f"{R2_ENDPOINT}/{folder}/{filename}" - - -def catalog_model_files(): - files = [] - for model in DOWNLOADABLE_MODELS.values(): - for filename, size, sha256 in model.r2_files: - relative_path = Path(filename) - folder = str(Path(model.r2_directory) / relative_path.parent) - sources = [] - if model.huggingface_repository: - sources.append( - hf_url(model.huggingface_repository, filename) - ) - sources.append(r2_url(model.r2_directory, filename)) - files.append( - ModelFile( - folder, - size, - sha256, - tuple(sources), - relative_path.name, - ) - ) - return tuple(files) - - -MODEL_FILES = catalog_model_files() - - -def file_sha256(path): - digest = hashlib.sha256() - with Path(path).open("rb") as stream: - while chunk := stream.read(CHUNK_SIZE): - digest.update(chunk) - return digest.hexdigest() - - -def verify_existing(model): - path = model.destination - if not path.is_file(): - return False - if path.stat().st_size != model.size: - raise RuntimeError(f"Existing model has an unexpected size: {path}") - if file_sha256(path) != model.sha256: - raise RuntimeError(f"Existing model has an unexpected SHA-256: {path}") - print(f"Skip existing: {path.relative_to(PROJECT_ROOT)}") - return True - - -def download_once(model, url): - destination = model.destination - destination.parent.mkdir(parents=True, exist_ok=True) - temporary = destination.with_suffix(".onnx.part") - temporary.unlink(missing_ok=True) - request = Request(url, headers={"User-Agent": "AnyImage model sync"}) - print(f"Download: {url}") - downloaded = 0 - digest = hashlib.sha256() - try: - with urlopen(request, timeout=60) as response, temporary.open("wb") as output: - while chunk := response.read(CHUNK_SIZE): - output.write(chunk) - digest.update(chunk) - downloaded += len(chunk) - print( - f"\r {downloaded / 1024**2:.1f} / " - f"{model.size / 1024**2:.1f} MiB", - end="", - flush=True, - ) - print() - if downloaded != model.size: - raise RuntimeError(f"size mismatch: {downloaded} != {model.size}") - if digest.hexdigest() != model.sha256: - raise RuntimeError("SHA-256 mismatch") - temporary.replace(destination) - except Exception: - temporary.unlink(missing_ok=True) - raise - - -def prepare_model(model): - if verify_existing(model): - return - failures = [] - for url in model.sources: - try: - download_once(model, url) - return - except Exception as error: - failures.append(f"{url}: {error}") - print(f"Source failed: {error}", file=sys.stderr) - raise RuntimeError("All sources failed:\n" + "\n".join(failures)) - - -def sync_models(): - executable = shutil.which("rclone") - if executable is None: - raise RuntimeError("rclone is not available on PATH") - subprocess.run( - [ - executable, - "sync", - "models", - "omoolab-r2:ai-models", - "--progress", - ], - cwd=PROJECT_ROOT, - check=True, - ) - - -def parse_args(): - parser = argparse.ArgumentParser() - parser.add_argument( - "--download-only", - action="store_true", - help="Prepare and verify models without running rclone", - ) - return parser.parse_args() - - -def main(): - args = parse_args() - for model in MODEL_FILES: - prepare_model(model) - if not args.download_only: - sync_models() - - -if __name__ == "__main__": - main() diff --git a/tests/test_addon.py b/tests/test_blender_addon.py similarity index 86% rename from tests/test_addon.py rename to tests/test_blender_addon.py index b0a95c4..0c36426 100644 --- a/tests/test_addon.py +++ b/tests/test_blender_addon.py @@ -12,7 +12,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] SOURCE_ROOT = PROJECT_ROOT / "src" -class AddonTest(unittest.TestCase): +class BlenderAddonTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.remove_modules() @@ -81,45 +81,29 @@ class AddonTest(unittest.TestCase): sys.modules["bpy"] = fake_bpy sys.path.insert(0, str(SOURCE_ROOT)) 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.ai_setup") - cls.image_depth = importlib.import_module( - "anyimage.operators.common.mesh_result" - ) - cls.image_data = importlib.import_module("anyimage.operators.common.image_data") - cls.object_data = importlib.import_module( - "anyimage.operators.common.mesh_result" - ) - cls.debug = importlib.import_module("anyimage.operators.debug") - cls.mesh_plane = importlib.import_module( - "anyimage.operators.convert_to_plane.mesh_plane" - ) - cls.mesh = importlib.import_module( + cls.anyimage = importlib.import_module("anyimage") + cls.runtime = importlib.import_module("anyimage.runtime") + cls.ai_setup = importlib.import_module("anyimage.operators.ai_setup") + cls.image_data = importlib.import_module("anyimage.common.image_data") + cls.debug_operators = importlib.import_module("anyimage.operators.debug") + cls.convert_to_plane = importlib.import_module( "anyimage.operators.convert_to_plane.conversion" ) - cls.cutout_conversion = importlib.import_module( + cls.cutout_main = importlib.import_module( "anyimage.operators.cutout_tool.main" ) - cls.cutout_build = importlib.import_module( - "anyimage.operators.cutout_tool.main" - ) - cls.mesh_shapes = importlib.import_module("anyimage.operators.cutout_tool.main") - cls.image_background = importlib.import_module( + cls.remove_background = importlib.import_module( "anyimage.operators.remove_background" ) - cls.image_upscale = importlib.import_module("anyimage.operators.upscale") - cls.image_edit_common = importlib.import_module( - "anyimage.operators.common.image_interaction" + cls.upscale = importlib.import_module("anyimage.operators.upscale") + cls.image_interaction = importlib.import_module( + "anyimage.common.image_interaction" ) - cls.image_mask = importlib.import_module( + cls.edit_mask = importlib.import_module( "anyimage.operators.image_tool.edit_mask" ) cls.image_region = importlib.import_module( - "anyimage.operators.common.image_region" - ) - cls.image_gesture = importlib.import_module( - "anyimage.operators.common.image_interaction" + "anyimage.common.image_region" ) cls.image_box = importlib.import_module("anyimage.operators.image_tool.box") cls.image_lasso = importlib.import_module("anyimage.operators.image_tool.lasso") @@ -133,12 +117,6 @@ class AddonTest(unittest.TestCase): cls.cutout_geometry = importlib.import_module( "anyimage.operators.cutout_tool.geometry" ) - cls.cutout_depth = importlib.import_module( - "anyimage.operators.cutout_tool.geometry" - ) - cls.image_interaction = importlib.import_module( - "anyimage.operators.common.image_interaction" - ) cls.paths_after_import = tuple(sys.path) cls.fake_bpy = fake_bpy @@ -166,7 +144,7 @@ class AddonTest(unittest.TestCase): } def tearDown(self): - self.addon.runtime.close_active() + self.anyimage.runtime.close_active() self.registered.clear() self.registered_tools.clear() self.status_bar_draws.clear() @@ -181,7 +159,7 @@ class AddonTest(unittest.TestCase): del self.fake_bpy.types.Image.anyimage_upscale_source def test_mps_requires_m4_or_newer(self): - properties = self.addon.properties + properties = self.anyimage.properties parse = properties.parse_apple_silicon_generation self.assertEqual(parse("Apple M2"), 2) self.assertEqual(parse("Apple M4 Pro"), 4) @@ -191,11 +169,11 @@ class AddonTest(unittest.TestCase): self.assertTrue(properties.supports_mps(4)) def test_unsupported_mac_hides_manifest_mps_device(self): - properties = self.addon.properties + properties = self.anyimage.properties manifest = {"devices": ["mps", "cpu"]} with ( patch.object( - self.addon.runtime, + self.anyimage.runtime, "environment_manifest", return_value=manifest, ), @@ -204,7 +182,7 @@ class AddonTest(unittest.TestCase): self.assertEqual(properties.supported_devices(), ["cpu"]) with ( patch.object( - self.addon.runtime, + self.anyimage.runtime, "environment_manifest", return_value=manifest, ), @@ -213,10 +191,10 @@ class AddonTest(unittest.TestCase): self.assertEqual(properties.supported_devices(), ["mps", "cpu"]) def test_windows_keeps_default_directml_device(self): - properties = self.addon.properties + properties = self.anyimage.properties with ( patch.object( - self.addon.runtime, + self.anyimage.runtime, "environment_manifest", return_value={}, ), @@ -231,21 +209,21 @@ class AddonTest(unittest.TestCase): self.assertEqual(self.paths_after_import, self.paths_before_import) def test_operator_properties_use_plain_names(self): - operators = self.addon.operators + operators = self.anyimage.operators 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): for operator, identifier in ( - (self.addon.operators.GenerateDepthPlane, "anyimage.generate_depth_plane"), + (self.anyimage.operators.GenerateDepthPlane, "anyimage.generate_depth_plane"), ( - self.addon.operators.CutoutRegionToShape, + self.anyimage.operators.CutoutRegionToShape, "anyimage.cutout_region_to_shape", ), ): self.assertEqual(operator.bl_idname, identifier) - self.assertIn(self.job_runtime.JobOperatorBase, operator.__mro__) + self.assertIn(self.runtime.JobOperatorBase, operator.__mro__) self.assertIn(self.fake_bpy.types.Operator, operator.__mro__) def test_upscale_debug_operator_accepts_each_catalog_model(self): @@ -258,24 +236,24 @@ class AddonTest(unittest.TestCase): video_frames=3, ) context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings)) - for model_key in self.addon.properties.shared_model_catalog.UPSCALE_MODELS: - operator = self.debug.DebugUpscale() + for model_key in self.anyimage.properties.shared_model_catalog.UPSCALE_MODELS: + operator = self.debug_operators.DebugUpscale() operator.model = model_key with ( self.subTest(model=model_key), - patch.object(self.debug, "require_environment"), - patch.object(self.debug, "require_model") as require_model, + patch.object(self.debug_operators, "require_environment"), + patch.object(self.debug_operators, "require_model") as require_model, ): request = operator.request(context) require_model.assert_called_once_with(model_key) self.assertEqual(request["model"], model_key) self.assertEqual(request["input"], str(image_path)) - self.assertIn(self.debug.DebugUpscale, self.addon.CLASSES) + self.assertIn(self.debug_operators.DebugUpscale, self.anyimage.CLASSES) panel = importlib.import_module("anyimage.panel") - self.assertIn(panel.UpscaleDebugPanel, self.addon.CLASSES) + self.assertIn(panel.UpscaleDebugPanel, self.anyimage.CLASSES) def test_default_request_sends_every_operator_property(self): - class Example(self.job_runtime.JobOperatorBase): + class Example(self.runtime.JobOperatorBase): value: object() enabled: object() @@ -288,7 +266,7 @@ class AddonTest(unittest.TestCase): ) def test_overridden_request_purely_replaces_default_properties(self): - class Example(self.job_runtime.JobOperatorBase): + class Example(self.runtime.JobOperatorBase): value: object() def request(self, context): @@ -317,7 +295,7 @@ class AddonTest(unittest.TestCase): def mark_job_complete(self, _job_id): pass - class Example(self.job_runtime.JobOperatorBase): + class Example(self.runtime.JobOperatorBase): job_type = "example" value: object() @@ -346,7 +324,7 @@ class AddonTest(unittest.TestCase): self.assertEqual(calls, [("example", {"value": 9})]) def test_runtime_owns_environment_and_storage_paths(self): - runtime = self.addon.runtime + runtime = self.anyimage.runtime with tempfile.TemporaryDirectory() as directory: root = Path(directory) with patch.object(runtime, "storage_root_factory", root): @@ -359,7 +337,7 @@ class AddonTest(unittest.TestCase): self.assertEqual(runtime.server_log_path(), root / "server.log") def test_runtime_checks_its_environment_dictionary_hash(self): - runtime = self.addon.runtime + runtime = self.anyimage.runtime with tempfile.TemporaryDirectory() as directory: root = Path(directory) python = ( @@ -377,7 +355,7 @@ class AddonTest(unittest.TestCase): self.assertTrue(runtime.environment_ready()) def test_runtime_builds_the_standard_installer_command(self): - runtime = self.addon.runtime + runtime = self.anyimage.runtime with tempfile.TemporaryDirectory() as directory: root = Path(directory) with patch.object(runtime, "storage_root_factory", root): @@ -392,7 +370,7 @@ class AddonTest(unittest.TestCase): self.assertNotIn("--stages", command) def test_runtime_exposes_declared_environment_and_post_install(self): - runtime = self.addon.runtime + runtime = self.anyimage.runtime self.assertEqual(runtime.environment["python"], "3.12") self.assertIn("numpy==2.4.2", runtime.environment["packages"]) self.assertFalse( @@ -408,7 +386,7 @@ class AddonTest(unittest.TestCase): fake_runtime = SimpleNamespace( request=lambda job_type, parameters: calls.append((job_type, parameters)) ) - self.job_runtime.post_install(fake_runtime) + self.runtime.post_install(fake_runtime) self.assertEqual( calls, [ @@ -436,7 +414,7 @@ class AddonTest(unittest.TestCase): "Real-ESRGAN General WDN x4v3: network unavailable" ), ): - self.job_runtime.post_install(SimpleNamespace(request=request)) + self.runtime.post_install(SimpleNamespace(request=request)) self.assertEqual( calls, @@ -449,7 +427,7 @@ class AddonTest(unittest.TestCase): def test_environment_controller_runs_arbitrary_post_install_callable(self): runtime_module = importlib.import_module("blendjob.runtime") - runtime = self.addon.runtime + runtime = self.anyimage.runtime calls = [] started = threading.Event() release = threading.Event() @@ -485,7 +463,7 @@ class AddonTest(unittest.TestCase): def test_environment_controller_reports_post_install_failure(self): runtime_module = importlib.import_module("blendjob.runtime") - runtime = self.addon.runtime + runtime = self.anyimage.runtime def post_install(_runtime): raise RuntimeError("BEN2 download failed") @@ -514,7 +492,7 @@ class AddonTest(unittest.TestCase): def test_post_install_progress_resets_without_stage_fraction(self): runtime_module = importlib.import_module("blendjob.runtime") - controller = runtime_module.EnvironmentController(self.addon.runtime) + controller = runtime_module.EnvironmentController(self.anyimage.runtime) controller._update_post_progress( {"job_id": "first", "job_type": "download-model", "progress": 1.0} @@ -586,8 +564,8 @@ class AddonTest(unittest.TestCase): 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() - with patch.object(self.model_operators, "require_environment"): + operator = self.anyimage.operators.DownloadRequiredModels() + with patch.object(self.ai_setup, "require_environment"): parameters = operator.request(None) self.assertEqual( @@ -602,9 +580,9 @@ class AddonTest(unittest.TestCase): ) def test_ai_status_checks_environment_and_required_model_files(self): - properties = self.addon.properties + properties = self.anyimage.properties with ( - patch.object(self.addon.runtime, "environment_ready", return_value=True), + patch.object(self.anyimage.runtime, "environment_ready", return_value=True), patch.object( properties, "model_catalog", @@ -625,7 +603,7 @@ class AddonTest(unittest.TestCase): self.assertEqual(properties.ai_setup_label(status), "Download Required Models…") with ( - patch.object(self.addon.runtime, "environment_ready", return_value=False), + patch.object(self.anyimage.runtime, "environment_ready", return_value=False), patch.object( properties, "model_catalog", @@ -656,11 +634,11 @@ class AddonTest(unittest.TestCase): ), ) ) - operator = self.model_operators.SetupAIEnvironment() + operator = self.ai_setup.SetupAIEnvironment() with patch.object(self.fake_bpy, "ops", ops, create=True): operator.use_mirror = True with patch.object( - self.model_operators, + self.ai_setup, "ai_status", return_value={ "environment_ready": False, @@ -671,7 +649,7 @@ class AddonTest(unittest.TestCase): self.assertEqual(operator.execute(None), {"FINISHED"}) with patch.object( - self.model_operators, + self.ai_setup, "ai_status", return_value={ "environment_ready": True, @@ -684,14 +662,14 @@ class AddonTest(unittest.TestCase): self.assertEqual(calls, ["mirror", "models"]) def test_ai_environment_ui_types_are_registered(self): - self.assertIn(self.model_operators.SetupAIEnvironment, self.addon.CLASSES) + self.assertIn(self.ai_setup.SetupAIEnvironment, self.anyimage.CLASSES) self.assertIn( - self.model_operators.OpenAIEnvironmentSettings, - self.addon.CLASSES, + self.ai_setup.OpenAIEnvironmentSettings, + self.anyimage.CLASSES, ) def test_global_ai_input_size_defaults_and_clamps(self): - preference_module = self.addon.preferences + preference_module = self.anyimage.preferences preferences = self.fake_bpy.context.preferences.addons["anyimage"].preferences self.assertEqual( @@ -710,7 +688,7 @@ class AddonTest(unittest.TestCase): ) def test_cutout_mesh_resolution_defaults_and_clamps_independently(self): - preference_module = self.addon.preferences + preference_module = self.anyimage.preferences preferences = self.fake_bpy.context.preferences.addons["anyimage"].preferences self.assertEqual( @@ -782,7 +760,7 @@ class AddonTest(unittest.TestCase): ) with ( patch.object( - self.addon.properties, + self.anyimage.properties, "ai_status", return_value={ "environment_ready": True, @@ -791,11 +769,11 @@ class AddonTest(unittest.TestCase): }, ), patch.object( - self.addon.properties, + self.anyimage.properties, "model_catalog", return_value=catalog, ), - patch.object(self.addon.runtime, "server_busy", return_value=False), + patch.object(self.anyimage.runtime, "server_busy", return_value=False), ): preferences.draw(None) @@ -856,10 +834,10 @@ class AddonTest(unittest.TestCase): ) def test_semantic_mesh_operators_are_registered_without_dialog_options(self): - operator = self.mesh.ConvertToPlane - self.assertIn(operator, self.addon.CLASSES) - self.assertIn(self.mesh.ConvertToDepthPlane, self.addon.CLASSES) - self.assertIn(self.cutout_conversion.CutoutRegionToShape, self.addon.CLASSES) + operator = self.convert_to_plane.ConvertToPlane + self.assertIn(operator, self.anyimage.CLASSES) + self.assertIn(self.convert_to_plane.ConvertToDepthPlane, self.anyimage.CLASSES) + self.assertIn(self.cutout_main.CutoutRegionToShape, self.anyimage.CLASSES) self.assertEqual(operator.bl_idname, "anyimage.convert_to_plane") self.assertNotIn("shadeless", operator.__annotations__) self.assertNotIn("keep_original", operator.__annotations__) @@ -900,38 +878,38 @@ class AddonTest(unittest.TestCase): context = SimpleNamespace(object=source, scene=object()) with ( - patch.object(self.mesh, "video_total_frames", return_value=24), + patch.object(self.convert_to_plane, "video_total_frames", return_value=24), patch.object( - self.mesh, + self.convert_to_plane, "image_user_settings", return_value=(1, 0, 24, True), ), - patch.object(self.mesh, "create_image_material", return_value="Material"), - patch.object(self.mesh, "create_color_image", return_value="Color"), - patch.object(self.mesh, "create_plane_object") as build_mesh, + patch.object(self.convert_to_plane, "create_image_material", return_value="Material"), + patch.object(self.convert_to_plane, "create_color_image", return_value="Color"), + patch.object(self.convert_to_plane, "create_plane_object") as build_mesh, ): - result = self.mesh.ConvertToPlane.execute(operator, context) + result = self.convert_to_plane.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 = self.convert_to_plane.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.model = self.anyimage.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), + patch.object(self.convert_to_plane, "require_environment"), + patch.object(self.convert_to_plane, "require_model"), + patch.object(self.convert_to_plane, "production_device", return_value="CPU"), + patch.object(self.convert_to_plane, "source_start_frame", return_value=1), ): parameters = operator.request(context) @@ -948,7 +926,7 @@ class AddonTest(unittest.TestCase): context = SimpleNamespace( view_layer=SimpleNamespace(objects=SimpleNamespace(active=None)) ) - region_mask = self.cutout_conversion.RegionMask( + region_mask = self.cutout_main.RegionMask( np.ones((8, 8), dtype=np.float32), (1, 1, 9, 9) ) source_rgba = np.ones((10, 10, 4), dtype=np.float32) @@ -968,36 +946,36 @@ class AddonTest(unittest.TestCase): ) with ( patch.object( - self.cutout_conversion, + self.cutout_main, "require_image_empty", return_value=source, ), patch.object( - self.cutout_conversion, "video_total_frames", return_value=1 + self.cutout_main, "video_total_frames", return_value=1 ), patch.object( - self.cutout_conversion, "image_rgba", return_value=source_rgba + self.cutout_main, "image_rgba", return_value=source_rgba ), patch.object( - self.cutout_conversion, + self.cutout_main, "rasterize_region_path", return_value=region_mask, ), patch.object( - self.cutout_conversion, + self.cutout_main, "configured_max_ai_input_size", return_value=2048, ), patch.object( - self.cutout_conversion, + self.cutout_main, "configured_max_cutout_mesh_resolution", return_value=1024, ), patch.object( - self.cutout_conversion, "create_cutout_shape" + self.cutout_main, "create_cutout_shape" ) as convert, ): - result = self.cutout_conversion.CutoutRegionToShape.execute( + result = self.cutout_main.CutoutRegionToShape.execute( operator, context ) @@ -1012,11 +990,11 @@ class AddonTest(unittest.TestCase): ) def test_cutout_request_describes_ai_capabilities(self): - operator = self.cutout_conversion.CutoutRegionToShape() + operator = self.cutout_main.CutoutRegionToShape() operator.input_path = __file__ operator.source_object_name = "Source" operator.shape = "DEPTH_SURFACE" - operator.model = self.addon.properties.DEFAULT_MOGE2_MODEL_KEY + operator.model = self.anyimage.properties.DEFAULT_MOGE2_MODEL_KEY operator.resolution_level = 5 operator.max_ai_input_size = 2048 operator.generate_normal = True @@ -1025,14 +1003,14 @@ class AddonTest(unittest.TestCase): scene=SimpleNamespace(anyimage_settings=SimpleNamespace()) ) with ( - patch.object(self.cutout_conversion, "require_environment"), - patch.object(self.cutout_conversion, "require_model") as require_model, + patch.object(self.cutout_main, "require_environment"), + patch.object(self.cutout_main, "require_model") as require_model, patch.object( - self.cutout_conversion, "production_device", return_value="CPU" + self.cutout_main, "production_device", return_value="CPU" ), - patch.object(self.cutout_conversion, "source_start_frame", return_value=1), + patch.object(self.cutout_main, "source_start_frame", return_value=1), patch.object( - self.cutout_conversion, + self.cutout_main, "moge2_parameters", return_value={"model": operator.model, "resolution_level": 5}, ), @@ -1041,7 +1019,7 @@ class AddonTest(unittest.TestCase): self.assertEqual( [call.args[0] for call in require_model.call_args_list], - [self.addon.properties.BEN2_MODEL_KEY, operator.model], + [self.anyimage.properties.BEN2_MODEL_KEY, operator.model], ) self.assertTrue(parameters["refine_region"]) self.assertEqual(parameters["depth_mode"], "VECTOR") @@ -1056,11 +1034,11 @@ class AddonTest(unittest.TestCase): geometry_resolution_level=7, ) with patch.object( - self.cutout_conversion, + self.cutout_main, "addon_preferences", return_value=preferences, ): - settings = self.cutout_conversion.cutout_geometry_settings(None) + settings = self.cutout_main.cutout_geometry_settings(None) self.assertEqual( settings, @@ -1083,7 +1061,7 @@ class AddonTest(unittest.TestCase): def separator(self): calls.append(("separator", self.enabled)) - menu = self.addon.AnyImageImageMenu() + menu = self.anyimage.AnyImageImageMenu() menu.layout = Layout() with patch( "anyimage.properties.ai_status", @@ -1139,7 +1117,7 @@ class AddonTest(unittest.TestCase): def separator(self): calls.append(("separator", self.enabled)) - menu = self.addon.AnyImageImageMenu() + menu = self.anyimage.AnyImageImageMenu() menu.layout = Layout() with patch( "anyimage.properties.ai_status", @@ -1155,8 +1133,8 @@ class AddonTest(unittest.TestCase): self.assertNotIn(("anyimage.setup_ai_environment", True), calls) def test_upscale_operator_defaults_to_wdn_and_sends_selected_model(self): - operator_type = self.image_upscale.UpscaleImage - self.assertIn(operator_type, self.addon.CLASSES) + operator_type = self.upscale.UpscaleImage + self.assertIn(operator_type, self.anyimage.CLASSES) self.assertEqual( operator_type.__annotations__["model"]["default"], "REALESRGAN_GENERAL_WDN_X4V3", @@ -1166,7 +1144,7 @@ class AddonTest(unittest.TestCase): 2048, ) - self.assertIn(self.job_runtime.JobOperatorBase, operator_type.__mro__) + self.assertIn(self.runtime.JobOperatorBase, operator_type.__mro__) self.assertEqual(operator_type.bl_options, {"UNDO"}) operator = operator_type() operator.input_path = "input.png" @@ -1177,13 +1155,13 @@ class AddonTest(unittest.TestCase): scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=7)) ) with ( - patch.object(self.image_upscale, "require_environment"), - patch.object(self.image_upscale, "require_model") as require_model, + patch.object(self.upscale, "require_environment"), + patch.object(self.upscale, "require_model") as require_model, patch.object( - self.image_upscale, "require_input_path", return_value=Path("input.png") + self.upscale, "require_input_path", return_value=Path("input.png") ), - patch.object(self.image_upscale, "production_device", return_value="CUDA"), - patch.object(self.image_upscale, "source_start_frame", return_value=4), + patch.object(self.upscale, "production_device", return_value="CUDA"), + patch.object(self.upscale, "source_start_frame", return_value=4), ): request = operator.request(context) @@ -1216,25 +1194,25 @@ class AddonTest(unittest.TestCase): object=source_object, scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1)), ) - operator = self.image_upscale.UpscaleImage() + operator = self.upscale.UpscaleImage() with ( patch.object( - self.image_upscale, + self.upscale, "configured_upscale_model", return_value="SAFMN_REAL_X4", ), patch.object( - self.image_upscale, + self.upscale, "configured_max_ai_input_size", return_value=1024, ), patch.object( - self.image_upscale, + self.upscale, "prepare_image_input", return_value=(Path("input.png"), False), ), patch.object( - self.job_runtime.JobOperatorBase, + self.runtime.JobOperatorBase, "execute", return_value={"RUNNING_MODAL"}, ), @@ -1248,22 +1226,22 @@ class AddonTest(unittest.TestCase): def test_upscale_operator_opens_setup_when_required_ai_is_missing(self): expected = {"CANCELLED"} with patch.object( - self.image_upscale, + self.upscale, "invoke_ai_setup_if_needed", return_value=expected, ) as setup: - result = self.image_upscale.UpscaleImage().invoke(None, None) + result = self.upscale.UpscaleImage().invoke(None, None) setup.assert_called_once_with() self.assertIs(result, expected) def test_upscale_operator_runs_immediately_when_ai_is_ready(self): - operator = self.image_upscale.UpscaleImage() + operator = self.upscale.UpscaleImage() context = object() expected = {"FINISHED"} with ( patch.object( - self.image_upscale, + self.upscale, "invoke_ai_setup_if_needed", return_value=None, ), @@ -1372,10 +1350,10 @@ class AddonTest(unittest.TestCase): self.assertTrue(nonzero[3, 5]) def preview_area(fill_rule): - triangles = self.image_edit_common.scanline_fill_triangles(path, fill_rule) + triangles = self.image_interaction.scanline_fill_triangles(path, fill_rule) return sum( abs( - self.image_edit_common._triangle_cross( + self.image_interaction._triangle_cross( triangles[index], triangles[index + 1], triangles[index + 2], @@ -1394,13 +1372,13 @@ class AddonTest(unittest.TestCase): report=lambda level, message: reports.append((level, message)) ) - self.image_edit_common.report_image_edit_exception( + self.image_interaction.report_image_edit_exception( operator, - self.image_edit_common.ImageEditWarning( + self.image_interaction.ImageEditWarning( "The edit contains no visible image pixels" ), ) - self.image_edit_common.report_image_edit_exception( + self.image_interaction.report_image_edit_exception( operator, ValueError("Unexpected invalid data"), ) @@ -1417,10 +1395,10 @@ class AddonTest(unittest.TestCase): ) def test_image_edit_tools_and_operators_are_registered(self): - operator = self.image_mask.EditImageMask - self.assertIn(operator, self.addon.CLASSES) - self.assertIn(self.image_mask.RefineImageRegion, self.addon.CLASSES) - self.assertIn(self.image_mask.ActivateImageTool, self.addon.CLASSES) + operator = self.edit_mask.EditImageMask + self.assertIn(operator, self.anyimage.CLASSES) + self.assertIn(self.edit_mask.RefineImageRegion, self.anyimage.CLASSES) + self.assertIn(self.edit_mask.ActivateImageTool, self.anyimage.CLASSES) tool_cases = ( (self.image_box.ImageBoxTool, "BOX", "ops.sculpt.border_mask"), (self.image_lasso.ImageLassoTool, "LASSO", "ops.sculpt.lasso_mask"), @@ -1432,7 +1410,7 @@ class AddonTest(unittest.TestCase): ), ) for tool, gesture, icon in tool_cases: - self.assertNotIn(tool, self.addon.CLASSES) + self.assertNotIn(tool, self.anyimage.CLASSES) self.assertEqual(tool.bl_operator, operator.bl_idname) self.assertEqual(tool.bl_icon, icon) self.assertEqual(tool.bl_keymap[0][2]["properties"], [("gesture", gesture)]) @@ -1478,7 +1456,7 @@ class AddonTest(unittest.TestCase): self.assertIn("invert", operator.__annotations__) self.assertNotIn("continuous", operator.__annotations__) self.assertNotIn("padding", operator.__annotations__) - settings = self.addon.properties.AnyImageSettings.__annotations__ + settings = self.anyimage.properties.AnyImageSettings.__annotations__ for name in ( "cutout_refine_region", "image_box_refine_region", @@ -1510,13 +1488,13 @@ class AddonTest(unittest.TestCase): image_lasso_refine_region=True, ) context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings)) - with patch.object(self.image_edit_common, "ai_ready", return_value=True): + with patch.object(self.image_interaction, "ai_ready", return_value=True): for gesture in ("LINE", "POLYLINE"): operator = SimpleNamespace( gesture=gesture, refine_region=True, ) - self.image_edit_common.apply_region_settings(operator, context) + self.image_interaction.apply_region_settings(operator, context) self.assertFalse(operator.refine_region) box = SimpleNamespace( @@ -1524,7 +1502,7 @@ class AddonTest(unittest.TestCase): refine_region=True, refine_region_settings={"BOX": "image_box_refine_region"}, ) - self.image_edit_common.apply_region_settings(box, context) + self.image_interaction.apply_region_settings(box, context) self.assertFalse(box.refine_region) lasso = SimpleNamespace( @@ -1532,7 +1510,7 @@ class AddonTest(unittest.TestCase): refine_region=False, refine_region_settings={"LASSO": "image_lasso_refine_region"}, ) - self.image_edit_common.apply_region_settings(lasso, context) + self.image_interaction.apply_region_settings(lasso, context) self.assertTrue(lasso.refine_region) cutout = SimpleNamespace( @@ -1541,7 +1519,7 @@ class AddonTest(unittest.TestCase): refine_region=False, refine_region_settings={"LASSO": "cutout_refine_region"}, ) - self.image_edit_common.apply_region_settings(cutout, context) + self.image_interaction.apply_region_settings(cutout, context) self.assertTrue(cutout.refine_region) def test_image_edit_first_click_selects_another_image_without_editing(self): @@ -1579,7 +1557,7 @@ class AddonTest(unittest.TestCase): clicked.name: clicked, } try: - source, should_edit = self.image_edit_common.resolve_image_edit_click( + source, should_edit = self.image_interaction.resolve_image_edit_click( context, event, lambda *_args: None ) finally: @@ -1624,7 +1602,7 @@ class AddonTest(unittest.TestCase): ) self.fake_bpy.data.objects = {active.name: active, clicked.name: clicked} try: - source, should_edit = self.image_edit_common.resolve_image_edit_click( + source, should_edit = self.image_interaction.resolve_image_edit_click( context, event, lambda *_args: None ) finally: @@ -1714,10 +1692,10 @@ class AddonTest(unittest.TestCase): (1, 4), (0, 4), ) - triangles = self.image_edit_common.triangulated_polygon_vertices(polygon) + triangles = self.image_interaction.triangulated_polygon_vertices(polygon) area = sum( abs( - self.image_edit_common._triangle_cross( + self.image_interaction._triangle_cross( triangles[index], triangles[index + 1], triangles[index + 2], @@ -1739,7 +1717,7 @@ class AddonTest(unittest.TestCase): ) ) with patch.dict(sys.modules, {"mathutils": mathutils}): - triangles = self.image_edit_common.triangulated_polygon_vertices( + triangles = self.image_interaction.triangulated_polygon_vertices( ((0, 0), (2, 0), (0, 2)) ) @@ -1755,7 +1733,7 @@ class AddonTest(unittest.TestCase): tessellate_polygon=lambda _loops: (_ for _ in ()).throw(TypeError()) ) with patch.dict(sys.modules, {"mathutils": mathutils}): - triangles = self.image_edit_common.triangulated_polygon_vertices( + triangles = self.image_interaction.triangulated_polygon_vertices( ((0, 0), (2, 0), (0, 2)) ) @@ -1763,10 +1741,10 @@ class AddonTest(unittest.TestCase): def test_image_mask_preview_fills_a_self_intersecting_lasso(self): polygon = ((0, 0), (4, 4), (0, 4), (4, 0)) - triangles = self.image_edit_common.preview_fill_triangles(polygon, "NONZERO") + triangles = self.image_interaction.preview_fill_triangles(polygon, "NONZERO") area = sum( abs( - self.image_edit_common._triangle_cross( + self.image_interaction._triangle_cross( triangles[index], triangles[index + 1], triangles[index + 2], @@ -1781,12 +1759,12 @@ class AddonTest(unittest.TestCase): def test_image_mask_preview_detects_only_actual_self_intersections(self): self.assertTrue( - self.image_edit_common.polygon_self_intersects( + self.image_interaction.polygon_self_intersects( ((0, 0), (4, 4), (0, 4), (4, 0)) ) ) self.assertFalse( - self.image_edit_common.polygon_self_intersects( + self.image_interaction.polygon_self_intersects( ((0, 0), (4, 0), (2, 2), (4, 4), (0, 4)) ) ) @@ -1853,10 +1831,10 @@ class AddonTest(unittest.TestCase): 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__ + settings = self.anyimage.properties.AnyImageSettings.__annotations__ - self.assertIn(operator, self.addon.CLASSES) - self.assertNotIn(tool, self.addon.CLASSES) + self.assertIn(operator, self.anyimage.CLASSES) + self.assertNotIn(tool, self.anyimage.CLASSES) self.assertEqual(tool.bl_operator, operator.bl_idname) self.assertEqual(tool.bl_icon, "ops.sculpt.line_project") self.assertNotIn("bl_cursor", tool.__dict__) @@ -1871,11 +1849,11 @@ class AddonTest(unittest.TestCase): def test_image_box_builds_a_rectangular_screen_path(self): self.assertEqual( - self.image_gesture.box_screen_path((2, 3), (8, 9)), + self.image_interaction.box_screen_path((2, 3), (8, 9)), ((2, 3), (8, 3), (8, 9), (2, 9)), ) self.assertEqual( - self.image_gesture.box_screen_path((2, 3), (3, 9)), + self.image_interaction.box_screen_path((2, 3), (3, 9)), (), ) @@ -1898,17 +1876,17 @@ class AddonTest(unittest.TestCase): with ( patch.object( - self.image_gesture, "require_image_empty", return_value=source + self.image_interaction, "require_image_empty", return_value=source ), - patch.object(self.image_gesture, "deserialize_matrix"), - patch.object(self.image_gesture, "image_empty_bounds"), + patch.object(self.image_interaction, "deserialize_matrix"), + patch.object(self.image_interaction, "image_empty_bounds"), patch.object( - self.image_gesture, + self.image_interaction, "screen_path_to_image_pixels", return_value=polygon, ), ): - result = self.image_gesture.ImageRegionGesture._complete(operator, context) + result = self.image_interaction.ImageRegionGesture._complete(operator, context) self.assertEqual(result, {"FINISHED"}) self.assertEqual( @@ -1918,7 +1896,7 @@ class AddonTest(unittest.TestCase): self.assertEqual(finished, [context]) def test_image_region_uses_white_dashed_segments(self): - segments = self.image_edit_common.dashed_line_segments( + segments = self.image_interaction.dashed_line_segments( ((0, 0), (10, 0)), dash_length=3.0, gap_length=2.0 ) @@ -1943,22 +1921,22 @@ class AddonTest(unittest.TestCase): _screen_path=lambda: polygon, ) with ( - patch.object(self.image_gesture, "drawing_in_region", return_value=True), + patch.object(self.image_interaction, "drawing_in_region", return_value=True), patch.object( - self.image_gesture, + self.image_interaction, "dashed_line_segments", return_value=("segments",), ) as build_outline, patch.object( - self.image_gesture, + self.image_interaction, "preview_fill_triangles", return_value=("triangles",), ) as build_fill, - patch.object(self.image_gesture, "draw_polygon_fill") as draw_fill, - patch.object(self.image_gesture, "draw_dashed_line") as draw_outline, + patch.object(self.image_interaction, "draw_polygon_fill") as draw_fill, + patch.object(self.image_interaction, "draw_dashed_line") as draw_outline, ): - self.image_gesture.ImageRegionGesture._draw_region(operator) - self.image_gesture.ImageRegionGesture._draw_region(operator) + self.image_interaction.ImageRegionGesture._draw_region(operator) + self.image_interaction.ImageRegionGesture._draw_region(operator) build_fill.assert_called_once_with(polygon, "NONZERO") build_outline.assert_called_once_with((*polygon, polygon[0])) @@ -1974,13 +1952,13 @@ 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_gesture.append_lasso_point(path, (0.5, 0.5))) + self.assertFalse(self.image_interaction.append_lasso_point(path, (0.5, 0.5))) self.assertEqual(path, [(0.0, 0.0)]) - 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.assertTrue(self.image_interaction.append_lasso_point(path, (3.0, 0.0))) + self.assertTrue(self.image_interaction.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_gesture.append_lasso_point(path, (6.0, 4.0))) + self.assertTrue(self.image_interaction.append_lasso_point(path, (6.0, 4.0))) self.assertEqual(len(path), 3) def test_image_edit_activation_defaults_to_box(self): @@ -1992,7 +1970,7 @@ class AddonTest(unittest.TestCase): ) ) try: - result = self.image_mask.ActivateImageTool().execute(None) + result = self.edit_mask.ActivateImageTool().execute(None) finally: if previous_ops is None: del self.fake_bpy.ops @@ -2008,10 +1986,10 @@ class AddonTest(unittest.TestCase): def test_image_line_snaps_and_keeps_the_previewed_half_plane(self): import math - endpoint = self.image_gesture.snapped_line_endpoint((0, 0), (10, 1), True) + endpoint = self.image_interaction.snapped_line_endpoint((0, 0), (10, 1), True) self.assertAlmostEqual(math.degrees(math.atan2(endpoint[1], endpoint[0])), 5.0) - preview = self.image_gesture.line_half_plane_polygon((0, 5), (10, 5), (10, 10)) - image = self.image_gesture.image_line_half_plane_polygon( + preview = self.image_interaction.line_half_plane_polygon((0, 5), (10, 5), (10, 10)) + image = self.image_interaction.image_line_half_plane_polygon( ((0, 5), (10, 5)), (10, 10) ) self.assertEqual( @@ -2028,15 +2006,15 @@ class AddonTest(unittest.TestCase): object=SimpleNamespace(data=SimpleNamespace()), ) operators = ( - (self.mesh, self.mesh.ConvertToDepthPlane), - (self.image_background, self.image_background.RemoveImageBackground), + (self.convert_to_plane, self.convert_to_plane.ConvertToDepthPlane), + (self.remove_background, self.remove_background.RemoveImageBackground), ) for busy, expected in ((False, True), (True, False)): with ( self.subTest(busy=busy), patch.object( - self.addon.runtime, + self.anyimage.runtime, "server_busy", return_value=busy, ), @@ -2073,12 +2051,12 @@ class AddonTest(unittest.TestCase): with ( patch.object( - self.addon.runtime, + self.anyimage.runtime, "storage_root", return_value=root, ), patch.object( - self.addon.runtime, + self.anyimage.runtime, "resource", side_effect=AssertionError("UI must not start the Server"), ), @@ -2089,11 +2067,11 @@ class AddonTest(unittest.TestCase): result = object() responses = [] with patch.object( - self.addon.runtime.server, + self.anyimage.runtime.server, "request", return_value=result, ) as request: - returned = self.addon.runtime.request( + returned = self.anyimage.runtime.request( "example", {"value": 3}, response=responses.append, @@ -2141,27 +2119,27 @@ class AddonTest(unittest.TestCase): self.assertEqual(responses, [result]) def test_runtime_keeps_server_as_an_entrypoint_string(self): - path, attribute = self.addon.runtime.server_entrypoint.rsplit(":", 1) + path, attribute = self.anyimage.runtime.server_entrypoint.rsplit(":", 1) self.assertEqual(Path(path).name, "__init__.py") self.assertEqual(Path(path).parent.name, "server") self.assertEqual(attribute, "server") def test_runtime_registers_system_operators_and_status_bar(self): self.assertIn( - self.addon.clipboard_image.PasteClipboardImage, - self.addon.CLASSES, + self.anyimage.clipboard_image.PasteClipboardImage, + self.anyimage.CLASSES, ) self.assertIn( - self.addon.clipboard_image.TrackNativeCopy, - self.addon.CLASSES, + self.anyimage.clipboard_image.TrackNativeCopy, + self.anyimage.CLASSES, ) existing_context_draw = object() existing_outliner_draw = object() self.context_menu_draws.append(existing_context_draw) self.outliner_menu_draws.append(existing_outliner_draw) - self.addon.register() - system_classes = self.addon.runtime.operator_classes() + self.anyimage.register() + system_classes = self.anyimage.runtime.operator_classes() self.assertTrue(set(system_classes) <= set(self.registered)) self.assertEqual( @@ -2202,7 +2180,7 @@ class AddonTest(unittest.TestCase): }, ), ( - self.addon.CutoutTool, + self.anyimage.CutoutTool, { "after": { self.image_perspective.ImagePerspectiveTool.bl_idname @@ -2216,15 +2194,15 @@ class AddonTest(unittest.TestCase): self.assertEqual(self.status_bar_draws[0]._owner, "anyimage") self.assertIs( self.context_menu_draws[0], - self.addon.draw_image_context_menu, + self.anyimage.draw_image_context_menu, ) self.assertIs( self.outliner_menu_draws[0], - self.addon.draw_image_context_menu, + self.anyimage.draw_image_context_menu, ) self.assertFalse(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source")) - self.addon.unregister() + self.anyimage.unregister() self.assertEqual(self.registered, []) self.assertEqual(self.registered_tools, []) self.assertEqual(self.status_bar_draws, []) @@ -2253,7 +2231,7 @@ class AddonTest(unittest.TestCase): def operator(self, identifier, **_options): calls.append(("operator", identifier)) - runtime = self.addon.runtime + runtime = self.anyimage.runtime runtime.active_job = object() runtime.progress = 0.4 runtime.message = "Working" diff --git a/tests/test_mesh.py b/tests/test_cutout_geometry.py similarity index 86% rename from tests/test_mesh.py rename to tests/test_cutout_geometry.py index 5e214f8..3682e6d 100644 --- a/tests/test_mesh.py +++ b/tests/test_cutout_geometry.py @@ -10,20 +10,20 @@ import numpy as np SOURCE_ROOT = Path(__file__).parents[1] / "src" sys.path.insert(0, str(SOURCE_ROOT)) -from anyimage.operators.common.image_region import ( +from anyimage.common.image_region import ( RegionMask, RegionPath, rasterize_region_path, ) -from anyimage.operators.common.depth_data import ( +from anyimage.common.depth_data import ( depth_uniform_scale, load_depth_metadata, ) -from anyimage.operators.cutout_tool import geometry as cutout_depth -from anyimage.operators.cutout_tool import geometry as cutout_profile -from anyimage.operators.cutout_tool import geometry as mesh -from anyimage.operators.cutout_tool import object as cutout_nodes -from anyimage.operators.cutout_tool import shape as shapes +from anyimage.operators.cutout_tool import geometry as cutout_geometry + + +from anyimage.operators.cutout_tool import object as cutout_object +from anyimage.operators.cutout_tool import shape as cutout_shape from anyimage.operators.cutout_tool.geometry import ( RegionContours, extract_region_contours, @@ -33,9 +33,9 @@ from anyimage.server.models import moge2 as moge2_model def test_cutout_mesh_detail_uses_the_cropped_analysis_scale(): - assert mesh.limit_cutout_mesh_resolution(256, (4096, 1024), 2048) == 128 - assert mesh.limit_cutout_mesh_resolution(256, (1024, 512), 2048) == 256 - assert mesh.limit_cutout_mesh_resolution(8, (8192, 4096), 2048) == 4 + assert cutout_geometry.limit_cutout_mesh_resolution(256, (4096, 1024), 2048) == 128 + assert cutout_geometry.limit_cutout_mesh_resolution(256, (1024, 512), 2048) == 256 + assert cutout_geometry.limit_cutout_mesh_resolution(8, (8192, 4096), 2048) == 4 def test_depth_metadata_loader_uses_one_schema_for_plane_and_cutout(tmp_path): @@ -61,11 +61,11 @@ def test_depth_metadata_loader_uses_one_schema_for_plane_and_cutout(tmp_path): def test_cutout_node_groups_are_loaded_from_the_asset(): loaded = object() with patch.object( - cutout_nodes, "load_asset_node_group", return_value=loaded + cutout_object, "load_node_group", return_value=loaded ) as load: - for shape in shapes.CUTOUT_NODE_GROUP_NAMES: - assert cutout_nodes.load_cutout_node_group() is loaded - load.assert_called_with(shapes.CUTOUT_NODE_GROUP_NAME) + for shape in cutout_shape.CUTOUT_NODE_GROUP_NAMES: + assert cutout_object.load_cutout_node_group() is loaded + load.assert_called_with(cutout_shape.CUTOUT_NODE_GROUP_NAME) assert load.call_count == 4 @@ -80,7 +80,7 @@ def test_base_plane_fit_is_stable_for_a_slender_diagonal_domain(): ) values = 0.4 * along + np.sign(across) * 0.05 - plane = cutout_depth._stable_plane(coordinates, values) + plane = cutout_geometry._stable_plane(coordinates, values) selected_plane = coordinates @ plane[:2] + plane[2] corners = np.asarray(((-1.0, -1.0), (-1.0, 1.0), (1.0, -1.0), (1.0, 1.0))) corner_plane = corners @ plane[:2] + plane[2] @@ -101,7 +101,7 @@ def test_depth_balloon_plane_calibration_matches_cutout_axes(): uv.append((column * 0.5, 1.0 - row * 0.5)) camera_depth[row, column] = 2.0 + 0.2 * local_y + 0.1 * local_z - reference, direction = cutout_depth.depth_balloon_projection_parameters( + reference, direction = cutout_geometry.depth_balloon_projection_parameters( vertices, uv, camera_depth, @@ -131,7 +131,7 @@ def test_depth_balloon_reference_uses_the_shared_upper_depth_percentile(): 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( + reference, direction = cutout_geometry.depth_balloon_projection_parameters( vertices, uv, camera_depth, @@ -185,7 +185,7 @@ def test_depth_surface_projection_uses_one_scale_for_all_camera_axes(): ) assert uniform_scale == 2.0 - reference_depth = cutout_depth.metric_depth_reference( + reference_depth = cutout_geometry.metric_depth_reference( points[..., 2], np.ones((51, 101), dtype=bool), uniform_scale, @@ -271,7 +271,7 @@ def test_cutout_crop_bounds_map_to_the_source_plane(): empty_image_offset=(-0.5, -0.5), ) - bounds = mesh.crop_plane_bounds( + bounds = cutout_geometry.crop_plane_bounds( source, (50, 25, 150, 75), (200, 100), @@ -285,7 +285,7 @@ def test_depth_surface_cutout_uses_half_alpha_boundary(): alpha[2:10, 2:14] = 0.49 alpha[4:8, 5:11] = 0.51 - region_contours = mesh.extract_cutout_contours( + region_contours = cutout_geometry.extract_cutout_contours( RegionMask(alpha, (0, 0, 16, 12)), 64, threshold=0.5, @@ -328,9 +328,9 @@ def test_blender_cutout_contour_resolution_tracks_mesh_detail(): image_size=(1, 1), ) region_mask = RegionMask(np.ones((2, 2), dtype=np.float32), (0, 0, 2, 2)) - with patch.object(mesh, "extract_region_contours", return_value=expected) as trace: - assert mesh.extract_cutout_contours(region_mask, 32) == expected - assert mesh.extract_cutout_contours(region_mask, 256) == expected + with patch.object(cutout_geometry, "extract_region_contours", return_value=expected) as trace: + assert cutout_geometry.extract_cutout_contours(region_mask, 32) == expected + assert cutout_geometry.extract_cutout_contours(region_mask, 256) == expected assert trace.call_args_list[0].kwargs["max_resolution"] == 512 assert trace.call_args_list[1].kwargs["max_resolution"] == 1024 @@ -339,28 +339,28 @@ def test_blender_cutout_contour_resolution_tracks_mesh_detail(): def test_cutout_point_spacing_uses_the_complete_source_plane(): source = SimpleNamespace(matrix_world=object()) with ( - patch.object(mesh, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)), + patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)), patch.object( - mesh, + cutout_geometry, "world_plane_metric", return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0), ), ): - spacing = mesh.cutout_point_spacing(source, 200) + spacing = cutout_geometry.cutout_point_spacing(source, 200) assert spacing == 0.02 def test_cutout_cleanup_value_scales_the_ui_without_changing_node_area(): assert np.isclose( - mesh.cutout_cleanup_value( + cutout_geometry.cutout_cleanup_value( 0.25, ((1.0, 0.0), (0.0, 1.0)), ), 6.25, ) assert np.isclose( - mesh.cutout_cleanup_value( + cutout_geometry.cutout_cleanup_value( 0.25, ((4.0, 0.0), (0.0, 9.0)), ), @@ -369,8 +369,8 @@ def test_cutout_cleanup_value_scales_the_ui_without_changing_node_area(): def test_cutout_depth_calibration_uses_five_percent_transparency_threshold(): - assert not cutout_depth.use_adaptive_depth_calibration(0.0499) - assert cutout_depth.use_adaptive_depth_calibration(0.05) + assert not cutout_geometry.use_adaptive_depth_calibration(0.0499) + assert cutout_geometry.use_adaptive_depth_calibration(0.05) def test_poisson_balloon_profile_is_zero_on_boundary_and_positive_inside(): @@ -429,11 +429,11 @@ def test_teddy_balloon_profile_uses_chordal_axis_radius(): nodes = np.asarray(((1, 1), (3, 1)), dtype=float) with patch.object( - cutout_profile, + cutout_geometry, "chordal_axis", return_value=(nodes, [(0, 1)], np.asarray((1.0, 1.0))), ): - profile = cutout_profile.teddy_balloon_heights( + profile = cutout_geometry.teddy_balloon_heights( points, faces, points[:4], @@ -448,7 +448,7 @@ def test_teddy_balloon_profile_uses_chordal_axis_radius(): def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_region(): regions = ((((2046, 2046), (2050, 2046), (2050, 2050), (2046, 2050)),),) - domain = mesh.cutout_domain( + domain = cutout_geometry.cutout_domain( RegionContours(regions, (4096, 4096)), (-1.0, 1.0, -1.0, 1.0), 0.5, @@ -462,7 +462,7 @@ def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_region(): def test_cutout_domain_builds_a_full_image_at_minimum_detail(): regions = ((((0, 0), (4096, 0), (4096, 4096), (0, 4096)),),) - domain = mesh.cutout_domain( + domain = cutout_geometry.cutout_domain( RegionContours(regions, (4096, 4096)), (-1.0, 1.0, -1.0, 1.0), 0.5, @@ -476,7 +476,7 @@ def test_cutout_domain_builds_a_full_image_at_minimum_detail(): def test_cutout_domain_centers_vertices_on_crop_bounds(): regions = ((((0, 0), (100, 0), (100, 50), (0, 50)),),) - vertices, _faces, uv, _boundary, _planar, _profile = mesh.cutout_domain( + vertices, _faces, uv, _boundary, _planar, _profile = cutout_geometry.cutout_domain( RegionContours(regions, (100, 50)), (2.0, 6.0, -3.0, 1.0), 0.5, @@ -497,7 +497,7 @@ def test_cutout_domain_keeps_overlapping_regions_disconnected(): ), (((0, 20), (40, 20), (40, 50), (0, 50)),), ) - domain = mesh.cutout_domain( + domain = cutout_geometry.cutout_domain( RegionContours(regions, (120, 80)), (0.0, 1.0, 0.0, 1.0), 0.025, @@ -529,7 +529,7 @@ def test_region_calibration_uses_the_final_region_mask(): (0, 0, 2, 2), ) - valid, transparent_fraction = mesh.region_calibration(region_mask, (2, 2)) + valid, transparent_fraction = cutout_geometry.region_calibration(region_mask, (2, 2)) assert np.array_equal(valid, ((True, True), (True, False))) assert transparent_fraction == 0.25 diff --git a/tests/test_cutout.py b/tests/test_cutout_interaction.py similarity index 76% rename from tests/test_cutout.py rename to tests/test_cutout_interaction.py index c179c0a..3b007d9 100644 --- a/tests/test_cutout.py +++ b/tests/test_cutout_interaction.py @@ -7,10 +7,10 @@ import bpy SOURCE_ROOT = Path(__file__).parents[1] / "src" sys.path.insert(0, str(SOURCE_ROOT)) -from anyimage.operators.common.image_interaction import ImageRegionGesture -from anyimage.operators.common.image_region import RegionPath +from anyimage.common.image_interaction import ImageRegionGesture +from anyimage.common.image_region import RegionPath from anyimage.operators.cutout_tool import interaction -from anyimage.operators.cutout_tool import main as cutout +from anyimage.operators.cutout_tool import main as cutout_main class Pie: @@ -106,11 +106,11 @@ def test_cutout_pie_passes_image_lasso_options_to_every_shape(): def test_cutout_normal_space_is_derived_from_shape(): - assert cutout.normal_mode_for_shape("DEPTH_SURFACE", False) == "NONE" - assert cutout.normal_mode_for_shape("SURFACE", True) == "TANGENT" - assert cutout.normal_mode_for_shape("BALLOON", True) == "TANGENT" - assert cutout.normal_mode_for_shape("DEPTH_BALLOON", True) == "TANGENT" - assert cutout.normal_mode_for_shape("DEPTH_SURFACE", True) == "OBJECT" + assert cutout_main.normal_mode_for_shape("DEPTH_SURFACE", False) == "NONE" + assert cutout_main.normal_mode_for_shape("SURFACE", True) == "TANGENT" + assert cutout_main.normal_mode_for_shape("BALLOON", True) == "TANGENT" + assert cutout_main.normal_mode_for_shape("DEPTH_BALLOON", True) == "TANGENT" + assert cutout_main.normal_mode_for_shape("DEPTH_SURFACE", True) == "OBJECT" def test_cutout_mesh_detail_tracks_the_source_long_edge_and_clamps(): @@ -130,30 +130,30 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa context = cutout_context(window_manager) monkeypatch.setattr( - cutout, "resolve_image_edit_click", lambda *_args: (source, True) + cutout_main, "resolve_image_edit_click", lambda *_args: (source, True) ) - monkeypatch.setattr(cutout, "video_total_frames", lambda _image: 1) - monkeypatch.setattr(cutout, "apply_region_settings", lambda *_args: None) - monkeypatch.setattr(cutout, "validate_cutout_region", lambda *_args: None) + monkeypatch.setattr(cutout_main, "video_total_frames", lambda _image: 1) + monkeypatch.setattr(cutout_main, "apply_region_settings", lambda *_args: None) + monkeypatch.setattr(cutout_main, "validate_cutout_region", lambda *_args: None) operator = SimpleNamespace( full_image=True, refine_region=False, report=lambda *_args: None, ) - operator.execute = MethodType(cutout.SelectCutoutRegion.execute, operator) - result = cutout.SelectCutoutRegion.invoke( + operator.execute = MethodType(cutout_main.SelectCutoutRegion.execute, operator) + result = cutout_main.SelectCutoutRegion.invoke( operator, context, SimpleNamespace(value="DOUBLE_CLICK") ) - assert issubclass(cutout.SelectCutoutRegion, ImageRegionGesture) + assert issubclass(cutout_main.SelectCutoutRegion, ImageRegionGesture) assert result == {"FINISHED"} region_path = RegionPath.from_json(window_manager.pie.calls[0][2].region_path_json) assert region_path.points == ((0, 0), (320, 0), (320, 180), (0, 180)) assert window_manager.pie.calls[0][2].mesh_resolution == 10 - assert not hasattr(cutout, "pending_selection") + assert not hasattr(cutout_main, "pending_selection") def test_cutout_tool_prioritizes_double_click_before_lasso_press(): - assert cutout.CutoutTool.bl_keymap[0][1]["value"] == "DOUBLE_CLICK" - assert cutout.CutoutTool.bl_keymap[1][1]["value"] == "PRESS" + assert cutout_main.CutoutTool.bl_keymap[0][1]["value"] == "DOUBLE_CLICK" + assert cutout_main.CutoutTool.bl_keymap[1][1]["value"] == "PRESS" diff --git a/tests/test_mesh_blender.py b/tests/test_cutout_objects.py similarity index 85% rename from tests/test_mesh_blender.py rename to tests/test_cutout_objects.py index 64e7c4c..dcaea4c 100644 --- a/tests/test_mesh_blender.py +++ b/tests/test_cutout_objects.py @@ -10,20 +10,20 @@ from mathutils import Vector SOURCE_ROOT = Path(__file__).parents[1] / "src" sys.path.insert(0, str(SOURCE_ROOT)) -from anyimage.operators.common import image_data as common_image -from anyimage.operators.common import depth_data as common_depth -from anyimage.operators.common import mesh_result as material -from anyimage.operators.common.image_region import ( +from anyimage.common import image_data +from anyimage.common import depth_data +from anyimage.common import node as node_asset +from anyimage.common import object as object_data +from anyimage.common.image_region import ( RegionMask, RegionPath, rasterize_region_path, ) -from anyimage.operators.convert_to_plane import nodes as plane_nodes from anyimage.operators.convert_to_plane import object as plane_object from anyimage.operators.cutout_tool import geometry as cutout_geometry from anyimage.operators.cutout_tool import main as cutout_main from anyimage.operators.cutout_tool import object as cutout_object -from anyimage.operators.cutout_tool import shape as shapes +from anyimage.operators.cutout_tool import shape as cutout_shape from anyimage.server.geometry.depth_texture import write_vector_depth_texture @@ -117,7 +117,7 @@ def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path write_vector_depth_texture(points, valid, path) metadata = _depth_metadata(np.eye(3), (2, 2), 1.0) - image = common_depth.load_depth_result_image(path, source, metadata) + image = depth_data.load_depth_result_image(path, source, metadata) packed = np.empty(16, dtype=np.float32) image.pixels.foreach_get(packed) @@ -131,14 +131,14 @@ def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): bpy.ops.wm.read_factory_settings(use_empty=True) - plane = plane_nodes.plane_node_group("PLANE") - depth_plane = plane_nodes.plane_node_group("DEPTH_PLANE") + plane = node_asset.load_node_group("O Mesh Plane") + depth_plane = node_asset.load_node_group("O Image Depth Plane") cutout = cutout_object.load_cutout_node_group() assert plane.name == "O Mesh Plane" - assert cutout.name == shapes.CUTOUT_NODE_GROUP_NAME + assert cutout.name == cutout_shape.CUTOUT_NODE_GROUP_NAME assert all( cutout_object.load_cutout_node_group() == cutout - for shape in shapes.CUTOUT_SHAPES + for shape in cutout_shape.CUTOUT_SHAPES ) assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness", "Material"] assert _inputs(depth_plane) == [ @@ -191,7 +191,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): ) labels = { shape: shape.replace("_", " ").title() - for shape in shapes.CUTOUT_NODE_GROUP_NAMES + for shape in cutout_shape.CUTOUT_NODE_GROUP_NAMES } assert [item.name for item in menu_switch.enum_items] == list(labels.values()) assert any( @@ -203,7 +203,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): child_groups = { shape: bpy.data.node_groups[name] - for shape, name in shapes.CUTOUT_NODE_GROUP_NAMES.items() + for shape, name in cutout_shape.CUTOUT_NODE_GROUP_NAMES.items() } assert cutout.is_modifier assert all(not group.is_modifier for group in child_groups.values()) @@ -256,7 +256,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): ) dependencies.setdefault(link.from_socket.name, set()).add(shape) assert dependencies == { - "Thickness": set(shapes.CUTOUT_SHAPES), + "Thickness": set(cutout_shape.CUTOUT_SHAPES), "Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Double Sided": {"DEPTH_BALLOON"}, "Base Plane Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, @@ -370,8 +370,8 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): def test_image_layer_switches_between_tangent_and_object_space_normal(): bpy.ops.wm.read_factory_settings(use_empty=True) - assert material.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer" - group = material.material_node_group() + assert object_data.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer" + group = object_data.material_node_group() normal = next( item for item in group.interface.items_tree @@ -416,7 +416,7 @@ def test_image_layer_switches_between_tangent_and_object_space_normal(): color = bpy.data.images.new("Color", width=2, height=2) normal_image = bpy.data.images.new("Normal", width=2, height=2) - object_material = material.create_image_material( + object_material = object_data.create_image_material( SimpleNamespace(data=SimpleNamespace(name="Object Normal")), color, 1.0, @@ -426,7 +426,7 @@ def test_image_layer_switches_between_tangent_and_object_space_normal(): layer = _group_node(group, object_material.node_tree) assert layer.inputs["Object Space"].default_value is True - tangent_material = material.create_image_material( + tangent_material = object_data.create_image_material( SimpleNamespace(data=SimpleNamespace(name="Tangent Normal")), color, 1.0, @@ -434,7 +434,7 @@ def test_image_layer_switches_between_tangent_and_object_space_normal(): ) layer = _group_node(group, tangent_material.node_tree) assert layer.inputs["Object Space"].default_value is False - reused = material.material_node_group() + reused = object_data.material_node_group() assert { node.space for node in reused.nodes if node.bl_idname == "ShaderNodeNormalMap" } == {"TANGENT", "OBJECT"} @@ -461,7 +461,7 @@ def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes(): assert len(result.data.vertices) == 4 assert len(result.data.polygons) == 1 assert all(polygon.use_smooth for polygon in result.data.polygons) - assert shapes.BALLOON_ATTRIBUTE_NAME not in result.data.attributes + assert cutout_shape.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() @@ -488,8 +488,8 @@ 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 shapes.BALLOON_ATTRIBUTE_NAME in result.data.attributes - profile_attribute = result.data.attributes[shapes.BALLOON_ATTRIBUTE_NAME] + assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes + profile_attribute = result.data.attributes[cutout_shape.BALLOON_ATTRIBUTE_NAME] assert profile_attribute.data_type == "FLOAT" assert profile_attribute.domain == "POINT" profile = np.empty(len(result.data.vertices), dtype=np.float32) @@ -502,8 +502,8 @@ def test_balloon_build_starts_from_the_unified_single_sheet(): if node.bl_idname == "ShaderNodeAttribute" ) assert normal_scale_attribute.attribute_type == "GEOMETRY" - assert normal_scale_attribute.attribute_name == shapes.BALLOON_ATTRIBUTE_NAME - image_layer = _group_node(material.material_node_group(), material_tree) + assert normal_scale_attribute.attribute_name == cutout_shape.BALLOON_ATTRIBUTE_NAME + image_layer = _group_node(object_data.material_node_group(), material_tree) normal_scale_link = next( link for link in material_tree.links @@ -512,7 +512,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet(): ) assert normal_scale_link.from_node == normal_scale_attribute assert normal_scale_link.from_socket == normal_scale_attribute.outputs["Fac"] - assert result.modifiers[0].node_group.name == shapes.CUTOUT_NODE_GROUP_NAME + assert result.modifiers[0].node_group.name == cutout_shape.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) @@ -536,7 +536,7 @@ def test_teddy_preference_writes_the_shared_balloon_attribute(): 8, ) - attribute = result.data.attributes[shapes.BALLOON_ATTRIBUTE_NAME] + attribute = result.data.attributes[cutout_shape.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 @@ -572,7 +572,7 @@ def test_cutout_material_uses_flat_tangent_normal(): color = bpy.data.images.new("Color", width=2, height=2) normal = bpy.data.images.new("Normal", width=1, height=1) - result = material.create_image_material( + result = object_data.create_image_material( source, color, 0.0, @@ -580,7 +580,7 @@ def test_cutout_material_uses_flat_tangent_normal(): scene=bpy.context.scene, ) - layer = _group_node(material.material_node_group(), result.node_tree) + layer = _group_node(object_data.material_node_group(), result.node_tree) assert layer.inputs["Object Space"].default_value is False @@ -616,39 +616,39 @@ def test_depth_surface_protected_smooth_evaluates(): ) assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) - assert shapes.BALLOON_ATTRIBUTE_NAME in result.data.attributes + assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes modifier = result.modifiers[0] - assert modifier.node_group.name == shapes.CUTOUT_NODE_GROUP_NAME + assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME assert ( - modifier[material.modifier_input_identifier(modifier.node_group, "Shape")] - == shapes.CUTOUT_SHAPE_INDICES["DEPTH_SURFACE"] + modifier[object_data.modifier_input_identifier(modifier.node_group, "Shape")] + == cutout_shape.CUTOUT_SHAPE_INDICES["DEPTH_SURFACE"] ) expected_cleanup = cutout_geometry.cutout_cleanup_value( cutout_geometry.cutout_point_spacing(source, 8), cutout_geometry.world_plane_metric(source.matrix_world)[0], ) assert np.isclose( - modifier[material.modifier_input_identifier(modifier.node_group, "Cleanup")], + modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup")], expected_cleanup, ) assert ( - modifier[material.modifier_input_identifier(modifier.node_group, "Smooth")] + modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth")] == 1.0 ) assert ( modifier[ - material.modifier_input_identifier(modifier.node_group, "Smooth Iterations") + object_data.modifier_input_identifier(modifier.node_group, "Smooth Iterations") ] == 0 ) - reference_id = material.modifier_input_identifier( + reference_id = object_data.modifier_input_identifier( modifier.node_group, "Base Plane Depth", ) assert modifier[reference_id] == 8.0 - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(modifier.node_group, "Smooth"), + object_data.modifier_input_identifier(modifier.node_group, "Smooth"), 0.0, ) evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get()) @@ -660,9 +660,9 @@ def test_depth_surface_protected_smooth_evaluates(): finally: evaluated.to_mesh_clear() - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(modifier.node_group, "Thickness"), + object_data.modifier_input_identifier(modifier.node_group, "Thickness"), 1.0, ) result.update_tag(refresh={"DATA"}) @@ -677,14 +677,14 @@ def test_depth_surface_protected_smooth_evaluates(): finally: evaluated.to_mesh_clear() - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(modifier.node_group, "Smooth Iterations"), + object_data.modifier_input_identifier(modifier.node_group, "Smooth Iterations"), 4, ) - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(modifier.node_group, "Smooth"), + object_data.modifier_input_identifier(modifier.node_group, "Smooth"), 1.0, ) result.update_tag(refresh={"DATA"}) @@ -716,24 +716,24 @@ def _add_cutout_modifier(obj, shape): group = cutout_object.load_cutout_node_group() modifier = obj.modifiers.new(group.name, "NODES") modifier.node_group = group - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(group, "Shape"), - shapes.CUTOUT_SHAPE_INDICES[shape], + object_data.modifier_input_identifier(group, "Shape"), + cutout_shape.CUTOUT_SHAPE_INDICES[shape], ) - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(group, "Cleanup"), + object_data.modifier_input_identifier(group, "Cleanup"), 0.0, ) - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(group, "Smooth"), + object_data.modifier_input_identifier(group, "Smooth"), 0.0, ) - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(group, "Smooth Iterations"), + object_data.modifier_input_identifier(group, "Smooth Iterations"), 1, ) return modifier @@ -753,14 +753,14 @@ def test_cutout_thickness_keeps_one_connected_shell(): bpy.context.collection.objects.link(obj) modifier = _add_cutout_modifier(obj, "SURFACE") - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(modifier.node_group, "Cleanup"), + object_data.modifier_input_identifier(modifier.node_group, "Cleanup"), 0.0, ) - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(modifier.node_group, "Thickness"), + object_data.modifier_input_identifier(modifier.node_group, "Thickness"), 1.0, ) @@ -789,9 +789,9 @@ def test_cutout_thickness_keeps_one_connected_shell(): finally: evaluated.to_mesh_clear() - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(modifier.node_group, "Thickness"), + object_data.modifier_input_identifier(modifier.node_group, "Thickness"), 2.4, ) obj.update_tag(refresh={"DATA"}) @@ -828,21 +828,21 @@ def test_balloon_uses_injected_profile_at_default_height(): data.from_pydata(vertices, (), faces) cutout_object._assign_float_point_attribute( data, - shapes.BALLOON_ATTRIBUTE_NAME, + cutout_shape.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 = _add_cutout_modifier(obj, "BALLOON") - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(modifier.node_group, "Cleanup"), + object_data.modifier_input_identifier(modifier.node_group, "Cleanup"), 0.0, ) - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(modifier.node_group, "Thickness"), + object_data.modifier_input_identifier(modifier.node_group, "Thickness"), 1.0, ) @@ -861,9 +861,9 @@ def test_balloon_uses_injected_profile_at_default_height(): baseline_face_count = len(result.polygons) finally: evaluated.to_mesh_clear() - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(modifier.node_group, "Thickness"), + object_data.modifier_input_identifier(modifier.node_group, "Thickness"), 2.0, ) obj.update_tag(refresh={"DATA"}) @@ -877,9 +877,9 @@ def test_balloon_uses_injected_profile_at_default_height(): assert np.isclose(x.max(), 2.0, atol=1e-6) finally: evaluated.to_mesh_clear() - material.set_modifier_input( + object_data.set_modifier_input( modifier, - material.modifier_input_identifier(modifier.node_group, "Thickness"), + object_data.modifier_input_identifier(modifier.node_group, "Thickness"), 0.0, ) obj.update_tag(refresh={"DATA"}) @@ -908,13 +908,13 @@ def test_cutout_cleanup_uses_absolute_area_with_many_small_islands(): bpy.context.collection.objects.link(obj) modifier = _add_cutout_modifier(obj, "SURFACE") - cleanup_id = material.modifier_input_identifier(modifier.node_group, "Cleanup") + cleanup_id = object_data.modifier_input_identifier(modifier.node_group, "Cleanup") - material.set_modifier_input(modifier, cleanup_id, 0.0) + object_data.set_modifier_input(modifier, cleanup_id, 0.0) bpy.context.view_layer.update() assert len(_evaluated_positions(obj)) == 45 - material.set_modifier_input(modifier, cleanup_id, 0.1) + object_data.set_modifier_input(modifier, cleanup_id, 0.1) obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() assert len(_evaluated_positions(obj)) == 9 @@ -964,7 +964,7 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller(): ) assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) - assert shapes.BALLOON_ATTRIBUTE_NAME in result.data.attributes + assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes assert "o_balloon_depth" not in result.data.attributes assert "o_depth_offset" not in result.data.attributes assert not result.children @@ -973,7 +973,7 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller(): result.name, } modifier = result.modifiers[0] - depth_image_id = material.modifier_input_identifier( + depth_image_id = object_data.modifier_input_identifier( modifier.node_group, "Depth Image", ) @@ -993,39 +993,39 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller(): atol=1e-5, ) - depth_reference_id = material.modifier_input_identifier( + depth_reference_id = object_data.modifier_input_identifier( modifier.node_group, "Base Plane Depth", ) assert not np.isclose(modifier[depth_reference_id], 8.0) - depth_direction_id = material.modifier_input_identifier( + depth_direction_id = object_data.modifier_input_identifier( modifier.node_group, "Depth Direction", ) direction = np.asarray(modifier[depth_direction_id], dtype=np.float64) assert not np.allclose(direction, (0.0, 0.0, 1.0)) - uniform_scale_id = material.modifier_input_identifier( + uniform_scale_id = object_data.modifier_input_identifier( modifier.node_group, "Uniform Scale", ) assert modifier[uniform_scale_id] > 0.0 - thickness_id = material.modifier_input_identifier( + thickness_id = object_data.modifier_input_identifier( modifier.node_group, "Thickness", ) - smooth_id = material.modifier_input_identifier( + smooth_id = object_data.modifier_input_identifier( modifier.node_group, "Smooth", ) assert modifier[smooth_id] == 1.0 - material.set_modifier_input(modifier, smooth_id, 0.0) + object_data.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 depth_at_thickness_one = positions[nearest_center, 0].min() - material.set_modifier_input(modifier, thickness_id, 2.0) + object_data.set_modifier_input(modifier, thickness_id, 2.0) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() positions = _evaluated_positions(result) @@ -1037,12 +1037,12 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller(): depth_at_thickness_two, atol=1e-5, ) - material.set_modifier_input(modifier, thickness_id, 1.0) + object_data.set_modifier_input(modifier, thickness_id, 1.0) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() before_positions = _evaluated_positions(result) - material.set_modifier_input( + object_data.set_modifier_input( modifier, depth_reference_id, modifier[depth_reference_id] + 0.2, @@ -1056,31 +1056,31 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller(): atol=1e-5, ) - depth_scale_id = material.modifier_input_identifier( + depth_scale_id = object_data.modifier_input_identifier( modifier.node_group, "Depth Scale", ) - material.set_modifier_input(modifier, depth_scale_id, 0.0) + object_data.set_modifier_input(modifier, depth_scale_id, 0.0) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() zero_scale_before = _evaluated_positions(result) - material.set_modifier_input( + object_data.set_modifier_input( modifier, depth_reference_id, modifier[depth_reference_id] + 0.15, ) - material.set_modifier_input(modifier, depth_direction_id, (0.2, 0.1, 1.0)) + object_data.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) assert np.allclose(zero_scale_after, zero_scale_before, atol=1e-5) - material.set_modifier_input(modifier, depth_scale_id, 1.0) + object_data.set_modifier_input(modifier, depth_scale_id, 1.0) - double_sided_id = material.modifier_input_identifier( + double_sided_id = object_data.modifier_input_identifier( modifier.node_group, "Double Sided", ) - material.set_modifier_input(modifier, double_sided_id, True) + object_data.set_modifier_input(modifier, double_sided_id, True) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() mirrored = _evaluated_positions(result) @@ -1106,7 +1106,7 @@ def test_loaded_normal_image_uses_normal_texture_name(tmp_path): Image.new("RGB", (1, 1), (128, 128, 255)).save(path) source = SimpleNamespace(data=SimpleNamespace(name="Poster.png")) - image = common_image.load_normal_result_image(path, source) + image = image_data.load_normal_result_image(path, source) assert image.name == "Poster_normal.png" assert image.colorspace_settings.name == "Non-Color" @@ -1166,14 +1166,14 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): points=((-8, 8), (72, 8), (72, 40), (-8, 40)), fill_rule="NONZERO", ) - source_bounds = common_image.image_empty_bounds(source) + source_bounds = image_data.image_empty_bounds(source) with patch.object( - common_image, + image_data, "image_rgba", - wraps=common_image.image_rgba, + wraps=image_data.image_rgba, ) as read_rgba: - rgba = common_image.image_rgba(image) + rgba = image_data.image_rgba(image) region_mask = rasterize_region_path( tuple(image.size), region_path, @@ -1197,26 +1197,26 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): assert result.type == "MESH" assert result["anyimage_mesh_shape"] == "SURFACE" modifier = result.modifiers[0] - assert modifier.node_group.name == shapes.CUTOUT_NODE_GROUP_NAME + assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME assert ( - modifier[material.modifier_input_identifier(modifier.node_group, "Shape")] - == shapes.CUTOUT_SHAPE_INDICES["SURFACE"] + modifier[object_data.modifier_input_identifier(modifier.node_group, "Shape")] + == cutout_shape.CUTOUT_SHAPE_INDICES["SURFACE"] ) expected_cleanup = cutout_geometry.cutout_cleanup_value( cutout_geometry.cutout_point_spacing(source, 16), cutout_geometry.world_plane_metric(source.matrix_world)[0], ) assert np.isclose( - modifier[material.modifier_input_identifier(modifier.node_group, "Cleanup")], + modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup")], expected_cleanup, ) assert ( - modifier[material.modifier_input_identifier(modifier.node_group, "Smooth")] + modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth")] == 1.0 ) assert ( modifier[ - material.modifier_input_identifier(modifier.node_group, "Smooth Iterations") + object_data.modifier_input_identifier(modifier.node_group, "Smooth Iterations") ] == 0 ) @@ -1224,7 +1224,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): 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 shapes.BALLOON_ATTRIBUTE_NAME in result.data.attributes + assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes source_inverse = source.matrix_world.inverted() source_x = [ (source_inverse @ (result.matrix_world @ vertex.co)).x @@ -1253,11 +1253,11 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): def test_balloon_normal_scale_uses_existing_image_layer_input(): bpy.ops.wm.read_factory_settings(use_empty=True) - assert shapes.BALLOON_CUTOUT_SHAPES == frozenset( + assert cutout_shape.BALLOON_CUTOUT_SHAPES == frozenset( {"BALLOON", "DEPTH_BALLOON"} ) group = bpy.data.node_groups.new( - material.IMAGE_MATERIAL_NODE_GROUP_NAME, + object_data.IMAGE_MATERIAL_NODE_GROUP_NAME, "ShaderNodeTree", ) group.interface.new_socket( @@ -1280,7 +1280,7 @@ def test_balloon_normal_scale_uses_existing_image_layer_input(): if node != image_layer ) assert attribute.bl_idname == "ShaderNodeAttribute" - assert attribute.attribute_name == shapes.BALLOON_ATTRIBUTE_NAME + assert attribute.attribute_name == cutout_shape.BALLOON_ATTRIBUTE_NAME link = next(iter(image_material.node_tree.links)) assert link.from_socket == attribute.outputs["Fac"] assert link.to_socket == image_layer.inputs["Normal Scale"] diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 0000000..2934b1f --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,42 @@ +import importlib.util +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "tools" / "docs.py" +SPEC = importlib.util.spec_from_file_location("docs_script", SCRIPT_PATH) +docs = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(docs) + + +def test_documentation_command_deploys_project_version(monkeypatch): + monkeypatch.setattr(docs, "project_version", lambda: "1.2.3") + + assert docs.documentation_command() == [ + "mike", + "deploy", + "--update-aliases", + "1.2.3", + "latest", + ] + + +def test_documentation_command_previews_current_source(): + assert docs.documentation_command(dev=True) == ["mkdocs", "serve"] + + +def test_main_runs_from_project_root(monkeypatch): + calls = [] + monkeypatch.setattr( + docs.subprocess, + "run", + lambda *args, **kwargs: calls.append((args, kwargs)), + ) + + docs.main(["-dev"]) + + assert calls == [ + ( + (["mkdocs", "serve"],), + {"cwd": docs.PROJECT_ROOT, "check": True}, + ) + ] diff --git a/tests/test_manifest.py b/tests/test_extension_manifest.py similarity index 94% rename from tests/test_manifest.py rename to tests/test_extension_manifest.py index c0fa44f..4d91adf 100644 --- a/tests/test_manifest.py +++ b/tests/test_extension_manifest.py @@ -7,7 +7,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] MANIFEST_PATH = PROJECT_ROOT / "src" / "anyimage" / "blender_manifest.toml" -class ManifestTest(unittest.TestCase): +class ExtensionManifestTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.manifest = tomllib.loads(MANIFEST_PATH.read_text(encoding="utf-8")) diff --git a/tests/test_pack.py b/tests/test_extension_packaging.py similarity index 93% rename from tests/test_pack.py rename to tests/test_extension_packaging.py index ee84136..09f8e8c 100644 --- a/tests/test_pack.py +++ b/tests/test_extension_packaging.py @@ -12,14 +12,14 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] def load_pack(): specification = importlib.util.spec_from_file_location( "anyimage_pack", - PROJECT_ROOT / "pack.py", + PROJECT_ROOT / "tools" / "pack.py", ) module = importlib.util.module_from_spec(specification) specification.loader.exec_module(module) return module -class PackTest(unittest.TestCase): +class ExtensionPackagingTest(unittest.TestCase): def test_builds_platform_specific_extension_zips(self): pack = load_pack() with tempfile.TemporaryDirectory() as directory: @@ -58,12 +58,13 @@ class PackTest(unittest.TestCase): self.assertIn("blender_manifest.toml", names) self.assertNotIn("assets/O_AnyImage.blend1", names) for path in ( - "operators/common/__init__.py", - "operators/common/ai_job.py", - "operators/common/image_data.py", - "operators/common/image_interaction.py", - "operators/common/image_region.py", - "operators/common/mesh_result.py", + "common/__init__.py", + "common/ai_job.py", + "common/image_data.py", + "common/image_interaction.py", + "common/image_region.py", + "common/node.py", + "common/object.py", "operators/cutout_tool/__init__.py", "operators/cutout_tool/main.py", "operators/cutout_tool/interaction.py", @@ -74,7 +75,6 @@ class PackTest(unittest.TestCase): "operators/convert_to_plane/object.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", @@ -87,6 +87,7 @@ class PackTest(unittest.TestCase): "operators/remove_background.py", ): self.assertIn(path, names) + self.assertNotIn("operators/convert_to_plane/nodes.py", names) self.assertNotIn("operators/cutout/__init__.py", names) self.assertNotIn("operators/image_mesh/__init__.py", names) self.assertNotIn("operators/mesh/__init__.py", names) @@ -97,7 +98,7 @@ class PackTest(unittest.TestCase): names, ) self.assertIn("server/media/input.py", names) - self.assertIn("operators/common/depth_data.py", names) + self.assertIn("common/depth_data.py", names) self.assertIn("server/geometry/projection.py", names) self.assertIn("server/models/geometry.py", names) self.assertIn("server/jobs/remove_background.py", names) @@ -111,7 +112,7 @@ class PackTest(unittest.TestCase): self.assertNotIn( "server/geometry/generate_geometry_and_normal.py", names ) - self.assertNotIn("operators/common/image_selection.py", names) + self.assertNotIn("common/image_selection.py", names) self.assertNotIn("server/selection/cutout_images.py", names) self.assertNotIn("server/selection/polygon_mask.py", names) self.assertNotIn("server/jobs/prepare_geometry_data.py", names) diff --git a/tests/test_image.py b/tests/test_image_data.py similarity index 97% rename from tests/test_image.py rename to tests/test_image_data.py index 627a7b7..5353cf5 100644 --- a/tests/test_image.py +++ b/tests/test_image_data.py @@ -24,22 +24,28 @@ def load_image_modules(): module_paths = ( ( - "anyimage.operators.common.image_data", + "anyimage.common.image_data", PROJECT_ROOT / "src" / "anyimage" - / "operators" / "common" / "image_data.py", ), ( - "anyimage.operators.common.mesh_result", + "anyimage.common.node", PROJECT_ROOT / "src" / "anyimage" - / "operators" / "common" - / "mesh_result.py", + / "node.py", + ), + ( + "anyimage.common.object", + PROJECT_ROOT + / "src" + / "anyimage" + / "common" + / "object.py", ), ) packages = { @@ -47,7 +53,7 @@ def load_image_modules(): for name in ( "anyimage", "anyimage.operators", - "anyimage.operators.common", + "anyimage.common", "anyimage.operators.convert_to_plane", ) } @@ -74,12 +80,12 @@ def load_image_modules(): return namespace -class ImageTest(unittest.TestCase): +class ImageDataTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.image_depth = load_image_modules() - def test_material_asset_path_survives_package_split(self): + def test_node_asset_path_survives_package_split(self): self.assertEqual( self.image_depth.node_asset_path(), PROJECT_ROOT / "src" / "anyimage" / "assets" / "O_AnyImage.blend", diff --git a/tests/test_ben2_onnx.py b/tests/test_model_ben2_onnx.py similarity index 100% rename from tests/test_ben2_onnx.py rename to tests/test_model_ben2_onnx.py diff --git a/tests/test_models.py b/tests/test_model_catalog.py similarity index 99% rename from tests/test_models.py rename to tests/test_model_catalog.py index 3d47280..9a4d6eb 100644 --- a/tests/test_models.py +++ b/tests/test_model_catalog.py @@ -16,7 +16,7 @@ def load_module(name, relative_path): return module -class ModelTest(unittest.TestCase): +class ModelCatalogTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.models = load_module( diff --git a/tests/test_da3_onnx.py b/tests/test_model_da3_onnx.py similarity index 100% rename from tests/test_da3_onnx.py rename to tests/test_model_da3_onnx.py diff --git a/tests/test_download_progress.py b/tests/test_model_download.py similarity index 99% rename from tests/test_download_progress.py rename to tests/test_model_download.py index 2d71931..aa598a6 100644 --- a/tests/test_download_progress.py +++ b/tests/test_model_download.py @@ -35,7 +35,7 @@ class FakeTqdm: return True -class DownloadProgressTest(unittest.TestCase): +class ModelDownloadTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.download = load_download_module() diff --git a/tests/test_moge_onnx.py b/tests/test_model_moge2_onnx.py similarity index 99% rename from tests/test_moge_onnx.py rename to tests/test_model_moge2_onnx.py index 5771697..6189f4c 100644 --- a/tests/test_moge_onnx.py +++ b/tests/test_model_moge2_onnx.py @@ -60,7 +60,7 @@ class FakeSession: return [FakeInput(name) for name in self.outputs] -class MogeOnnxTest(unittest.TestCase): +class Moge2OnnxTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.moge = load_module() diff --git a/tests/test_upscale_onnx.py b/tests/test_model_upscale_onnx.py similarity index 100% rename from tests/test_upscale_onnx.py rename to tests/test_model_upscale_onnx.py diff --git a/tests/test_node_asset_launcher.py b/tests/test_node_asset_launcher.py new file mode 100644 index 0000000..bb63a30 --- /dev/null +++ b/tests/test_node_asset_launcher.py @@ -0,0 +1,72 @@ +import importlib.util +from pathlib import Path +from unittest.mock import patch + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = PROJECT_ROOT / "tools" / "build_node.py" + + +def load_launcher(): + specification = importlib.util.spec_from_file_location( + "node_asset_launcher", + SCRIPT_PATH, + ) + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +def test_blender_path_prefers_explicit_path(tmp_path): + launcher = load_launcher() + executable = tmp_path / "blender" + executable.touch() + + with patch.dict("os.environ", {"BLENDER_BIN": "ignored"}): + assert launcher.blender_path(executable) == executable + + +def test_blender_path_reads_environment(tmp_path): + launcher = load_launcher() + executable = tmp_path / "blender" + executable.touch() + + with patch.dict("os.environ", {"BLENDER_BIN": str(executable)}): + assert launcher.blender_path() == executable + + +def test_blender_path_reads_project_dotenv(tmp_path): + launcher = load_launcher() + executable = tmp_path / "Blender 4.5" / "blender.exe" + executable.parent.mkdir() + executable.touch() + env_path = tmp_path / ".env" + env_path.write_text( + f'BLENDER_BIN="{executable}"\n', + encoding="utf-8", + ) + + with patch.dict("os.environ", {}, clear=True), patch.object( + launcher, + "PROJECT_ROOT", + tmp_path, + ): + assert launcher.blender_path() == executable + + +def test_blender_path_reports_missing_configuration(tmp_path): + launcher = load_launcher() + + with patch.dict("os.environ", {}, clear=True), patch.object( + launcher.shutil, + "which", + return_value=None, + ), patch.object( + launcher, + "PROJECT_ROOT", + tmp_path, + ): + with pytest.raises(RuntimeError, match="Blender not found"): + launcher.blender_path() diff --git a/tests/test_node_assets.py b/tests/test_node_assets.py index d4e7327..7aa0dc6 100644 --- a/tests/test_node_assets.py +++ b/tests/test_node_assets.py @@ -6,7 +6,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] SOURCE_ROOT = PROJECT_ROOT / "src" / "anyimage" -class NodeAssetTest(unittest.TestCase): +class NodeAssetsTest(unittest.TestCase): def test_production_code_does_not_create_node_groups(self): offenders = [] for path in SOURCE_ROOT.rglob("*.py"): @@ -15,16 +15,26 @@ class NodeAssetTest(unittest.TestCase): self.assertEqual(offenders, []) def test_runtime_node_group_entries_load_assets(self): - material_source = ( - SOURCE_ROOT / "operators" / "common" / "mesh_result.py" + object_source = ( + SOURCE_ROOT / "common" / "object.py" ).read_text(encoding="utf-8") - plane_source = ( - SOURCE_ROOT / "operators" / "convert_to_plane" / "nodes.py" + mesh_plane_source = ( + SOURCE_ROOT / "operators" / "convert_to_plane" / "mesh_plane.py" ).read_text(encoding="utf-8") - self.assertIn("return load_asset_node_group(group_name)", material_source) + depth_plane_source = ( + SOURCE_ROOT / "operators" / "convert_to_plane" / "object.py" + ).read_text(encoding="utf-8") + self.assertIn("return load_node_group(group_name)", object_source) self.assertIn( - "return load_asset_node_group(DEPTH_PLANE_NODE_GROUP_NAME)", - plane_source, + "load_node_group(MESH_PLANE_NODE_GROUP_NAME)", + mesh_plane_source, + ) + self.assertIn( + "load_node_group(DEPTH_PLANE_NODE_GROUP_NAME)", + depth_plane_source, + ) + self.assertFalse( + (SOURCE_ROOT / "operators" / "convert_to_plane" / "nodes.py").exists() ) diff --git a/tests/test_worker.py b/tests/test_server_jobs.py similarity index 99% rename from tests/test_worker.py rename to tests/test_server_jobs.py index 3e00f4b..dda1e94 100644 --- a/tests/test_worker.py +++ b/tests/test_server_jobs.py @@ -111,7 +111,7 @@ class FakeJobContext: return None -class WorkerTest(unittest.TestCase): +class ServerJobsTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.media = load_server_module("media.input") diff --git a/tests/test_server.py b/tests/test_server_runtime.py similarity index 99% rename from tests/test_server.py rename to tests/test_server_runtime.py index 2206844..105e4b4 100644 --- a/tests/test_server.py +++ b/tests/test_server_runtime.py @@ -38,7 +38,7 @@ class FakeCuda: return None -class ServerTest(unittest.TestCase): +class ServerRuntimeTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.server_module = load_server_module() diff --git a/tests/test_sync_models.py b/tests/test_sync_models.py new file mode 100644 index 0000000..4663f82 --- /dev/null +++ b/tests/test_sync_models.py @@ -0,0 +1,99 @@ +import importlib.util +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def load_sync_models(): + path = PROJECT_ROOT / "tools" / "sync_models.py" + spec = importlib.util.spec_from_file_location("sync_models", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class SyncModelsTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.sync = load_sync_models() + + def test_r2_is_only_an_upload_destination(self): + for model in self.sync.MODEL_FILES: + self.assertNotIn("models.omoolab.xyz", model.source_url) + if model.build: + self.assertFalse(model.source_url) + + def test_local_exports_use_official_pytorch_weights(self): + builds = self.sync.MODEL_BUILDS + self.assertEqual( + set(builds), + {"REALESRGAN_GENERAL_WDN_X4V3", "SAFMN_REAL_X4"}, + ) + for build in builds.values(): + self.assertTrue(build.source_url.startswith("https://github.com/")) + self.assertTrue(build.source_filename.endswith(".pth")) + self.assertEqual(len(build.source_sha256), 64) + + def test_prepare_model_builds_missing_local_export(self): + model = next(model for model in self.sync.MODEL_FILES if model.build) + with ( + patch.object(self.sync, "verify_file", return_value=False), + patch.object(self.sync, "build_model") as build, + patch.object(self.sync, "download_file") as download, + ): + self.sync.prepare_model(model) + build.assert_called_once_with(model) + download.assert_not_called() + + def test_prepare_model_downloads_upstream_onnx(self): + model = next( + model + for model in self.sync.MODEL_FILES + if model.source_url and not model.build + ) + with ( + patch.object(self.sync, "verify_file", return_value=False), + patch.object(self.sync, "download_file") as download, + ): + self.sync.prepare_model(model) + download.assert_called_once_with( + model.source_url, + model.destination, + model.size, + model.sha256, + ) + + def test_upload_copies_declared_files_without_remote_deletion(self): + model = self.sync.ModelFile( + key="TEST", + folder="folder", + filename="model.onnx", + size=1, + sha256="hash", + ) + with ( + patch.object(self.sync.shutil, "which", return_value="rclone"), + patch.object(self.sync.subprocess, "run") as run, + ): + self.sync.upload_models((model,)) + command = run.call_args.args[0] + self.assertEqual(command[1], "copyto") + self.assertNotIn("sync", command) + self.assertEqual(command[-2], model.remote) + + def test_verify_file_checks_size_and_sha256(self): + path = PROJECT_ROOT / "tools" / "sync_models.py" + size = path.stat().st_size + digest = self.sync.file_sha256(path) + self.assertTrue(self.sync.verify_file(path, size, digest)) + with self.assertRaisesRegex(RuntimeError, "unexpected size"): + self.sync.verify_file(path, size - 1, digest) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tool_commands.py b/tests/test_tool_commands.py new file mode 100644 index 0000000..f7eb62d --- /dev/null +++ b/tests/test_tool_commands.py @@ -0,0 +1,26 @@ +import tomllib +import unittest +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +class ToolCommandsTests(unittest.TestCase): + def test_repository_tools_have_uv_commands(self): + project = tomllib.loads( + (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8") + )["project"] + self.assertEqual( + project["scripts"], + { + "pack": "tools.pack:main", + "build_node": "tools.build_node:main", + "sync_models": "tools.sync_models:main", + "docs": "tools.docs:main", + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..298b6d2 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +"""AnyImage repository maintenance commands.""" diff --git a/tools/build_node.py b/tools/build_node.py new file mode 100644 index 0000000..19f2a34 --- /dev/null +++ b/tools/build_node.py @@ -0,0 +1,91 @@ +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +BLENDER_SCRIPTS = ( + "build.py", + "validate.py", + "validate_mesh_plane.py", + "validate_cutout.py", +) + + +def project_env_value(name, path=None): + env_path = path or PROJECT_ROOT / ".env" + if not env_path.is_file(): + return None + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line.removeprefix("export ").lstrip() + key, separator, value = line.partition("=") + if separator and key.strip() == name: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + return value or None + return None + + +def blender_path(explicit=None): + candidate = ( + explicit + or os.environ.get("BLENDER_BIN") + or project_env_value("BLENDER_BIN") + or shutil.which("blender") + ) + if not candidate: + raise RuntimeError( + "Blender not found: pass --blender, set BLENDER_BIN, add it to .env, " + "or add blender to PATH" + ) + path = Path(candidate).expanduser() + if not path.is_file(): + raise RuntimeError(f"Blender executable does not exist: {path}") + return path + + +def run(command): + print("+", subprocess.list2cmdline([str(part) for part in command]), flush=True) + subprocess.run(command, cwd=PROJECT_ROOT, check=True) + + +def main(): + parser = argparse.ArgumentParser( + description="Build and validate O_AnyImage.blend.", + ) + parser.add_argument( + "--blender", + help="Blender executable; overrides BLENDER_BIN, .env, and PATH.", + ) + parser.add_argument( + "--skip-tests", + action="store_true", + help="Skip the Python test suite.", + ) + options = parser.parse_args() + executable = blender_path(options.blender) + + for script_name in BLENDER_SCRIPTS: + run( + ( + executable, + "--factory-startup", + "--background", + "--python", + PROJECT_ROOT / "tools" / "node_assets" / script_name, + ) + ) + if not options.skip_tests: + run((sys.executable, "-m", "pytest")) + + +if __name__ == "__main__": + main() diff --git a/tools/docs.py b/tools/docs.py new file mode 100644 index 0000000..73a9bec --- /dev/null +++ b/tools/docs.py @@ -0,0 +1,48 @@ +import argparse +import subprocess +import tomllib +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def project_version(): + """Return the documentation version declared by the project.""" + project = tomllib.loads( + (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8") + )["project"] + return project["version"] + + +def documentation_command(dev=False): + """Return the Mike or MkDocs command for the requested mode.""" + if dev: + return ["mkdocs", "serve"] + return [ + "mike", + "deploy", + "--update-aliases", + project_version(), + "latest", + ] + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Build or preview documentation.") + parser.add_argument( + "-dev", + "--dev", + action="store_true", + help="preview the current documentation with live reload", + ) + args = parser.parse_args(argv) + subprocess.run( + documentation_command(args.dev), + cwd=PROJECT_ROOT, + check=True, + ) + + +if __name__ == "__main__": + main() diff --git a/tools/model_exports.py b/tools/model_exports.py new file mode 100644 index 0000000..b3fc3aa --- /dev/null +++ b/tools/model_exports.py @@ -0,0 +1,204 @@ +"""Export official PyTorch super-resolution models to ONNX.""" + +import numpy as np + + +OPSET_VERSION = 17 + + +def _require_build_dependencies(): + try: + import onnx + import onnxruntime + import torch + except ImportError as error: + raise RuntimeError( + "Model build dependencies are missing. Run with: " + "uv sync" + ) from error + return onnx, onnxruntime, torch + + +def _load_weights(torch, checkpoint_path, key): + checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=True) + return checkpoint[key] + + +def _validate_export(onnx, onnxruntime, torch, model, sample, destination): + onnx.checker.check_model(onnx.load(destination)) + expected = model(sample).detach().numpy() + session = onnxruntime.InferenceSession( + str(destination), + providers=["CPUExecutionProvider"], + ) + actual = session.run(None, {"input": sample.numpy()})[0] + np.testing.assert_allclose(actual, expected, rtol=1e-4, atol=1e-5) + + +def _export(torch, model, sample, destination, dynamic_axes=None): + model.eval() + torch.onnx.export( + model, + sample, + destination, + export_params=True, + opset_version=OPSET_VERSION, + do_constant_folding=True, + input_names=["input"], + output_names=["output"], + dynamic_axes=dynamic_axes, + dynamo=False, + ) + + +def _restore_realesrgan_scope_names(onnx, destination): + model = onnx.load(destination) + for node in model.graph.node: + node.name = node.name.replace("/body.", "/body/body.") + for values in (node.input, node.output): + for index, value in enumerate(values): + values[index] = value.replace("/body.", "/body/body.") + onnx.save(model, destination) + + +def export_realesrgan_wdn_x4(checkpoint_path, destination): + onnx, onnxruntime, torch = _require_build_dependencies() + nn = torch.nn + + class SRVGGNetCompact(nn.Module): + def __init__(self): + super().__init__() + self.upscale = 4 + self.body = nn.ModuleList([nn.Conv2d(3, 64, 3, 1, 1), nn.PReLU(64)]) + for _ in range(32): + self.body.extend([nn.Conv2d(64, 64, 3, 1, 1), nn.PReLU(64)]) + self.body.append(nn.Conv2d(64, 3 * self.upscale**2, 3, 1, 1)) + self.upsampler = nn.PixelShuffle(self.upscale) + + def forward(self, value): + result = value + for layer in self.body: + result = layer(result) + result = self.upsampler(result) + return result + torch.nn.functional.interpolate( + value, + scale_factor=self.upscale, + mode="nearest", + ) + + model = SRVGGNetCompact() + model.load_state_dict(_load_weights(torch, checkpoint_path, "params"), strict=True) + sample = torch.rand(1, 3, 16, 16) + dynamic_axes = { + "input": {2: "height", 3: "width"}, + "output": {2: "height4", 3: "width4"}, + } + _export(torch, model, sample, destination, dynamic_axes) + _restore_realesrgan_scope_names(onnx, destination) + _validate_export(onnx, onnxruntime, torch, model, sample, destination) + + +def export_safmn_real_x4(checkpoint_path, destination): + onnx, onnxruntime, torch = _require_build_dependencies() + nn = torch.nn + functional = torch.nn.functional + + class LayerNorm(nn.Module): + def __init__(self, channels): + super().__init__() + self.weight = nn.Parameter(torch.ones(channels)) + self.bias = nn.Parameter(torch.zeros(channels)) + self.eps = 1e-6 + + def forward(self, value): + mean = value.mean(1, keepdim=True) + variance = (value - mean).pow(2).mean(1, keepdim=True) + value = (value - mean) / torch.sqrt(variance + self.eps) + return self.weight[:, None, None] * value + self.bias[:, None, None] + + class SAFM(nn.Module): + def __init__(self, channels): + super().__init__() + chunk_channels = channels // 4 + self.mfr = nn.ModuleList( + nn.Conv2d(chunk_channels, chunk_channels, 3, 1, 1, groups=chunk_channels) + for _ in range(4) + ) + self.aggr = nn.Conv2d(channels, channels, 1) + self.act = nn.GELU() + + def forward(self, value): + height, width = value.shape[-2:] + outputs = [] + for level, chunk in enumerate(value.chunk(4, dim=1)): + if level: + size = (height // 2**level, width // 2**level) + chunk = functional.adaptive_max_pool2d(chunk, size) + chunk = self.mfr[level](chunk) + if level: + chunk = functional.interpolate( + chunk, + size=(height, width), + mode="nearest", + ) + outputs.append(chunk) + return self.act(self.aggr(torch.cat(outputs, dim=1))) * value + + class CCM(nn.Module): + def __init__(self, channels): + super().__init__() + self.ccm = nn.Sequential( + nn.Conv2d(channels, channels * 2, 3, 1, 1), + nn.GELU(), + nn.Conv2d(channels * 2, channels, 1), + ) + + def forward(self, value): + return self.ccm(value) + + class AttBlock(nn.Module): + def __init__(self, channels): + super().__init__() + self.norm1 = LayerNorm(channels) + self.norm2 = LayerNorm(channels) + self.safm = SAFM(channels) + self.ccm = CCM(channels) + + def forward(self, value): + value = self.safm(self.norm1(value)) + value + return self.ccm(self.norm2(value)) + value + + class SAFMN(nn.Module): + def __init__(self): + super().__init__() + self.to_feat = nn.Conv2d(3, 128, 3, 1, 1) + self.feats = nn.Sequential(*(AttBlock(128) for _ in range(16))) + self.to_img = nn.Sequential( + nn.Conv2d(128, 3 * 4**2, 3, 1, 1), + nn.PixelShuffle(4), + ) + + def forward(self, value): + value = self.to_feat(value) + return self.to_img(self.feats(value) + value) + + model = SAFMN() + model.load_state_dict( + _load_weights(torch, checkpoint_path, "params_ema"), + strict=True, + ) + sample = torch.rand(1, 3, 256, 256) + _export(torch, model, sample, destination) + _validate_export(onnx, onnxruntime, torch, model, sample, destination) + + +def export_model(exporter, checkpoint_path, destination): + exporters = { + "realesrgan_wdn_x4": export_realesrgan_wdn_x4, + "safmn_real_x4": export_safmn_real_x4, + } + try: + export = exporters[exporter] + except KeyError as error: + raise ValueError(f"Unknown model exporter: {exporter}") from error + export(checkpoint_path, destination) diff --git a/tools/node_assets/build_material_layers.py b/tools/node_assets/build_material_layers.py index b265695..dbd15ed 100644 --- a/tools/node_assets/build_material_layers.py +++ b/tools/node_assets/build_material_layers.py @@ -2,7 +2,7 @@ import bpy -from anyimage.operators.common.mesh_result import ( +from anyimage.common.object import ( DEPTH_MATERIAL_NODE_GROUP_NAME, IMAGE_MATERIAL_NODE_GROUP_NAME, ) diff --git a/tools/node_assets/validate.py b/tools/node_assets/validate.py index fa16b92..f8f01c1 100644 --- a/tools/node_assets/validate.py +++ b/tools/node_assets/validate.py @@ -10,19 +10,20 @@ sys.path.insert( str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), ) -from anyimage.operators.common.mesh_result import ( +from anyimage.common.object import ( DEPTH_MATERIAL_NODE_GROUP_NAME, IMAGE_MATERIAL_NODE_GROUP_NAME, - load_asset_node_group, - node_asset_path, ) -from anyimage.operators.convert_to_plane.nodes import DEPTH_PLANE_NODE_GROUP_NAME +from anyimage.common.node import load_node_group, node_asset_path from anyimage.operators.cutout_tool.shape import ( CUTOUT_NODE_GROUP_NAME, CUTOUT_NODE_GROUP_NAMES, ) +DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane" + + def input_names(group): return [ item.name @@ -33,12 +34,12 @@ def input_names(group): def main(): bpy.ops.wm.read_factory_settings(use_empty=True) - mesh_plane = load_asset_node_group("O Mesh Plane") - depth_plane = load_asset_node_group(DEPTH_PLANE_NODE_GROUP_NAME) - image_layer = load_asset_node_group(IMAGE_MATERIAL_NODE_GROUP_NAME) - depth_layer = load_asset_node_group(DEPTH_MATERIAL_NODE_GROUP_NAME) - shadeless = load_asset_node_group("O Shadeless") - cutout = load_asset_node_group(CUTOUT_NODE_GROUP_NAME) + mesh_plane = load_node_group("O Mesh Plane") + depth_plane = load_node_group(DEPTH_PLANE_NODE_GROUP_NAME) + image_layer = load_node_group(IMAGE_MATERIAL_NODE_GROUP_NAME) + depth_layer = load_node_group(DEPTH_MATERIAL_NODE_GROUP_NAME) + shadeless = load_node_group("O Shadeless") + cutout = load_node_group(CUTOUT_NODE_GROUP_NAME) cutout_groups = { name: bpy.data.node_groups[name] for name in CUTOUT_NODE_GROUP_NAMES.values() } diff --git a/tools/node_assets/validate_cutout.py b/tools/node_assets/validate_cutout.py index 4285f9e..babf52e 100644 --- a/tools/node_assets/validate_cutout.py +++ b/tools/node_assets/validate_cutout.py @@ -10,7 +10,7 @@ sys.path.insert( str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), ) -from anyimage.operators.common.mesh_result import ( +from anyimage.common.object import ( modifier_input_identifier, set_modifier_input, ) diff --git a/tools/node_assets/validate_mesh_plane.py b/tools/node_assets/validate_mesh_plane.py index 5e15c97..72e3014 100644 --- a/tools/node_assets/validate_mesh_plane.py +++ b/tools/node_assets/validate_mesh_plane.py @@ -11,7 +11,7 @@ sys.path.insert( str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), ) -from anyimage.operators.common.mesh_result import ( +from anyimage.common.object import ( modifier_input_identifier, set_modifier_input, ) diff --git a/pack.py b/tools/pack.py similarity index 98% rename from pack.py rename to tools/pack.py index 680cf83..c4ebbc8 100644 --- a/pack.py +++ b/tools/pack.py @@ -7,7 +7,7 @@ from pathlib import Path from zipfile import ZIP_DEFLATED, ZipFile -PROJECT_ROOT = Path(__file__).resolve().parent +PROJECT_ROOT = Path(__file__).resolve().parents[1] SOURCE_DIR = PROJECT_ROOT / "src" / "anyimage" DIST_DIR = PROJECT_ROOT / "dist" PYTHON_VERSION = "3.11" @@ -134,8 +134,12 @@ def build_all(): return [build_extension(platform) for platform in PLATFORMS] -if __name__ == "__main__": +def main(): extension_paths = build_all() for extension_path in extension_paths: size_mib = extension_path.stat().st_size / (1024 * 1024) print(f"Built {extension_path} ({size_mib:.2f} MiB)") + + +if __name__ == "__main__": + main() diff --git a/tools/sync_models.py b/tools/sync_models.py new file mode 100644 index 0000000..8fbf7ef --- /dev/null +++ b/tools/sync_models.py @@ -0,0 +1,244 @@ +"""Prepare AnyImage ONNX artifacts and upload them to OmooLab R2.""" + +import argparse +import hashlib +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from urllib.request import Request, urlopen + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +MODELS_DIR = PROJECT_ROOT / "models" +MODEL_CACHE_DIR = PROJECT_ROOT / ".model-cache" +SOURCE_ROOT = PROJECT_ROOT / "src" / "anyimage" +sys.path.insert(0, str(SOURCE_ROOT)) +from server.model_catalog import DOWNLOADABLE_MODELS + +R2_REMOTE = "omoolab-r2:ai-models" +CHUNK_SIZE = 1024 * 1024 + + +@dataclass(frozen=True) +class BuildSpec: + source_url: str + source_filename: str + source_size: int + source_sha256: str + exporter: str + + +@dataclass(frozen=True) +class ModelFile: + key: str + folder: str + filename: str + size: int + sha256: str + source_url: str = "" + build: BuildSpec | None = None + + @property + def destination(self): + return MODELS_DIR / self.folder / self.filename + + @property + def remote(self): + return f"{R2_REMOTE}/{self.folder}/{self.filename}" + + +MODEL_BUILDS = { + "REALESRGAN_GENERAL_WDN_X4V3": BuildSpec( + source_url=( + "https://github.com/xinntao/Real-ESRGAN/releases/download/" + "v0.2.5.0/realesr-general-wdn-x4v3.pth" + ), + source_filename="realesr-general-wdn-x4v3.pth", + source_size=4885111, + source_sha256=( + "1641f8c4464b9f097c9fdda5589273713f67cf59f3d909e0bd688f0cee269dca" + ), + exporter="realesrgan_wdn_x4", + ), + "SAFMN_REAL_X4": BuildSpec( + source_url=( + "https://github.com/sunny2109/SAFMN/releases/download/" + "v0.1.0/SAFMN_L_Real_LSDIR_x4-v2.pth" + ), + source_filename="SAFMN_L_Real_LSDIR_x4-v2.pth", + source_size=45002097, + source_sha256=( + "f1ac0ee3ee143fbbc49aff6584cb7b48b71ba8d1961dfb8ad076b98ca4799ae2" + ), + exporter="safmn_real_x4", + ), +} + + +def hf_url(repository, filename): + return f"https://huggingface.co/{repository}/resolve/main/{filename}" + + +def catalog_model_files(): + files = [] + for key, model in DOWNLOADABLE_MODELS.items(): + for filename, size, sha256 in model.r2_files: + source_url = "" + if model.huggingface_repository: + source_url = hf_url(model.huggingface_repository, filename) + files.append( + ModelFile( + key=key, + folder=model.r2_directory, + filename=filename, + size=size, + sha256=sha256, + source_url=source_url, + build=MODEL_BUILDS.get(key), + ) + ) + return tuple(files) + + +MODEL_FILES = catalog_model_files() + + +def file_sha256(path): + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + while chunk := stream.read(CHUNK_SIZE): + digest.update(chunk) + return digest.hexdigest() + + +def verify_file(path, size, sha256): + path = Path(path) + if not path.is_file(): + return False + if path.stat().st_size != size: + raise RuntimeError(f"File has an unexpected size: {path}") + if file_sha256(path) != sha256: + raise RuntimeError(f"File has an unexpected SHA-256: {path}") + return True + + +def download_file(url, destination, size, sha256): + destination = Path(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name(f"{destination.name}.part") + temporary.unlink(missing_ok=True) + request = Request(url, headers={"User-Agent": "AnyImage model build"}) + print(f"Download: {url}") + downloaded = 0 + digest = hashlib.sha256() + try: + with urlopen(request, timeout=60) as response, temporary.open("wb") as output: + while chunk := response.read(CHUNK_SIZE): + output.write(chunk) + digest.update(chunk) + downloaded += len(chunk) + print( + f"\r {downloaded / 1024**2:.1f} / {size / 1024**2:.1f} MiB", + end="", + flush=True, + ) + print() + if downloaded != size: + raise RuntimeError(f"size mismatch: {downloaded} != {size}") + if digest.hexdigest() != sha256: + raise RuntimeError("SHA-256 mismatch") + temporary.replace(destination) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def prepare_source(build): + source = MODEL_CACHE_DIR / build.source_filename + if verify_file(source, build.source_size, build.source_sha256): + return source + download_file( + build.source_url, + source, + build.source_size, + build.source_sha256, + ) + return source + + +def build_model(model): + from tools.model_exports import export_model + + source = prepare_source(model.build) + destination = model.destination + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name(f"{destination.name}.part") + temporary.unlink(missing_ok=True) + print(f"Build: {destination.relative_to(PROJECT_ROOT)}") + try: + export_model(model.build.exporter, source, temporary) + verify_file(temporary, model.size, model.sha256) + temporary.replace(destination) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def prepare_model(model): + if verify_file(model.destination, model.size, model.sha256): + print(f"Skip existing: {model.destination.relative_to(PROJECT_ROOT)}") + return + if model.build: + build_model(model) + return + if not model.source_url: + raise RuntimeError(f"No upstream source or builder for {model.key}") + download_file( + model.source_url, + model.destination, + model.size, + model.sha256, + ) + + +def upload_models(models=MODEL_FILES): + executable = shutil.which("rclone") + if executable is None: + raise RuntimeError("rclone is not available on PATH") + for model in models: + print(f"Upload: {model.remote}") + subprocess.run( + [ + executable, + "copyto", + str(model.destination), + model.remote, + "--progress", + ], + cwd=PROJECT_ROOT, + check=True, + ) + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--prepare-only", + action="store_true", + help="Prepare and verify models without uploading them", + ) + return parser.parse_args() + + +def main(): + args = parse_args() + for model in MODEL_FILES: + prepare_model(model) + if not args.prepare_only: + upload_models() + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 59605be..a100c78 100644 --- a/uv.lock +++ b/uv.lock @@ -14,7 +14,7 @@ wheels = [ [[package]] name = "anyimage" version = "0.12.15" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "blendjob" }, ] @@ -24,18 +24,19 @@ dev = [ { name = "av" }, { name = "bpy" }, { name = "huggingface-hub" }, + { name = "mike" }, + { name = "mkdocs" }, + { name = "mkdocs-click" }, + { name = "mkdocs-material" }, { name = "numpy" }, + { name = "onnx" }, { name = "onnxruntime" }, { name = "pillow" }, { name = "pip" }, { name = "pytest" }, + { name = "torch" }, { name = "trimesh" }, ] -docs = [ - { name = "mike" }, - { name = "mkdocs" }, - { name = "mkdocs-material" }, -] [package.metadata] requires-dist = [{ name = "blendjob", specifier = "==0.1.14" }] @@ -45,18 +46,19 @@ dev = [ { name = "av", specifier = "==17.0.0" }, { name = "bpy", specifier = "==4.5.3" }, { name = "huggingface-hub", specifier = "==1.4.1" }, + { name = "mike", specifier = ">=2.1.3,<3" }, + { name = "mkdocs", specifier = ">=1.6.1,<2" }, + { name = "mkdocs-click", specifier = "==0.9.0" }, + { name = "mkdocs-material", specifier = ">=9.6.16,<10" }, { name = "numpy", specifier = ">=1.26,<2.0" }, + { name = "onnx", specifier = "==1.19.0" }, { name = "onnxruntime", specifier = "==1.24.4" }, { name = "pillow", specifier = "==12.1.1" }, { name = "pip", specifier = ">=25,<27" }, { name = "pytest" }, + { name = "torch", specifier = "==2.13.0" }, { name = "trimesh", specifier = "==4.11.2" }, ] -docs = [ - { name = "mike", specifier = ">=2.1.3,<3" }, - { name = "mkdocs", specifier = ">=1.6.1,<2" }, - { name = "mkdocs-material", specifier = ">=9.6.16,<10" }, -] [[package]] name = "anyio" @@ -204,6 +206,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cuda-bindings" +version = "13.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/95/7a/c5e3c34a409b148f5c0f5a4ea374158f95d488862c1dffedf9aa5c639df9/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708", size = 6674166, upload-time = "2026-05-29T23:11:45.478Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/b1/ef21259ec74fe0b265ed201379de1d0ef7c14178313ee03705952f1b7093/cuda_pathfinder-1.8.0-py3-none-any.whl", hash = "sha256:c44e574dc997fae2814721d1ae97d0fd6db76db82decbe9b753bf75de53f515e", size = 62539, upload-time = "2026-08-27T21:33:03.229Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.3.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f", size = 2512, upload-time = "2026-04-14T00:50:08.173Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +curand = [ + { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cusolver = [ + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] + [[package]] name = "cython" version = "3.2.9" @@ -467,6 +538,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, ] +[[package]] +name = "mkdocs-click" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/c7/8c25f3a3b379def41e6d0bb5c4beeab7aa8a394b17e749f498504102cfa5/mkdocs_click-0.9.0.tar.gz", hash = "sha256:6050917628d4740517541422b607404d044117bc31b770c4f9e9e1939a50c908", size = 18720, upload-time = "2025-04-07T16:59:36.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/fc/9124ab36e2341e78d8d9c669511bd70f52ea0de8105760c31fabec1f9396/mkdocs_click-0.9.0-py3-none-any.whl", hash = "sha256:5208e828f4f68f63c847c1ef7be48edee9964090390afc8f5b3d4cbe5ea9bbed", size = 15104, upload-time = "2025-04-07T16:59:34.807Z" }, +] + [[package]] name = "mkdocs-get-deps" version = "0.2.2" @@ -512,6 +596,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] +[[package]] +name = "ml-dtypes" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/5e/712092cfe7e5eb667b8ad9ca7c54442f21ed7ca8979745f1000e24cf8737/ml_dtypes-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6c7ecb74c4bd71db68a6bea1edf8da8c34f3d9fe218f038814fd1d310ac76c90", size = 679734, upload-time = "2025-11-17T22:31:39.223Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cf/912146dfd4b5c0eea956836c01dcd2fce6c9c844b2691f5152aca196ce4f/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc11d7e8c44a65115d05e2ab9989d1e045125d7be8e05a071a48bc76eb6d6040", size = 5056165, upload-time = "2025-11-17T22:31:41.071Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/19189ea605017473660e43762dc853d2797984b3c7bf30ce656099add30c/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b9a53598f21e453ea2fbda8aa783c20faff8e1eeb0d7ab899309a0053f1483", size = 5034975, upload-time = "2025-11-17T22:31:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/b4/24/70bd59276883fdd91600ca20040b41efd4902a923283c4d6edcb1de128d2/ml_dtypes-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:7c23c54a00ae43edf48d44066a7ec31e05fdc2eee0be2b8b50dd1903a1db94bb", size = 210742, upload-time = "2025-11-17T22:31:44.068Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c9/64230ef14e40aa3f1cb254ef623bf812735e6bec7772848d19131111ac0d/ml_dtypes-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:557a31a390b7e9439056644cb80ed0735a6e3e3bb09d67fd5687e4b04238d1de", size = 160709, upload-time = "2025-11-17T22:31:46.557Z" }, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -521,6 +621,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + [[package]] name = "numpy" version = "1.26.4" @@ -537,6 +646,178 @@ wheels = [ { 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" }, ] +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.20.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.29.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, + { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.3.33" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/ee/580ca6f29dcab0221db8706badca1bbbb084f1975c4d4e83329c3a7e31f0/nvidia_nvjitlink-13.3.33-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:26a6de7fb4c8fdaa7703d3dad720d6d427ddfea5c48a528fd97c11733ad830e5", size = 40742423, upload-time = "2026-05-26T16:54:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/69/30/45414e35ff2eee7db3da037e5707037ccf9d2b5218ffbdb055ea4d5aa98a/nvidia_nvjitlink-13.3.33-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ce48b37dfeb3cb1eae4cf85adacb47d7a6539ea2272870c9a3628ce275c2037e", size = 39168635, upload-time = "2026-05-26T16:54:13.906Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "onnx" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ml-dtypes" }, + { name = "numpy" }, + { name = "protobuf" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/bf/b0a63ee9f3759dcd177b28c6f2cb22f2aecc6d9b3efecaabc298883caa5f/onnx-1.19.0.tar.gz", hash = "sha256:aa3f70b60f54a29015e41639298ace06adf1dd6b023b9b30f1bca91bb0db9473", size = 11949859, upload-time = "2025-08-27T02:34:27.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/5c/b959b17608cfb6ccf6359b39fe56a5b0b7d965b3d6e6a3c0add90812c36e/onnx-1.19.0-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:206f00c47b85b5c7af79671e3307147407991a17994c26974565aadc9e96e4e4", size = 18312580, upload-time = "2025-08-27T02:33:03.081Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ee/ac052bbbc832abe0debb784c2c57f9582444fb5f51d63c2967fd04432444/onnx-1.19.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4d7bee94abaac28988b50da675ae99ef8dd3ce16210d591fbd0b214a5930beb3", size = 18029165, upload-time = "2025-08-27T02:33:05.771Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c9/8687ba0948d46fd61b04e3952af9237883bbf8f16d716e7ed27e688d73b8/onnx-1.19.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7730b96b68c0c354bbc7857961bb4909b9aaa171360a8e3708d0a4c749aaadeb", size = 18202125, upload-time = "2025-08-27T02:33:09.325Z" }, + { url = "https://files.pythonhosted.org/packages/e2/16/6249c013e81bd689f46f96c7236d7677f1af5dd9ef22746716b48f10e506/onnx-1.19.0-cp311-cp311-win32.whl", hash = "sha256:7cb7a3ad8059d1a0dfdc5e0a98f71837d82002e441f112825403b137227c2c97", size = 16332738, upload-time = "2025-08-27T02:33:12.448Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/34a1e2166e418c6a78e5c82e66f409d9da9317832f11c647f7d4e23846a6/onnx-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:d75452a9be868bd30c3ef6aa5991df89bbfe53d0d90b2325c5e730fbd91fff85", size = 16452303, upload-time = "2025-08-27T02:33:15.176Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/639664626e5ba8027860c4d2a639ee02b37e9c322215c921e9222513c3aa/onnx-1.19.0-cp311-cp311-win_arm64.whl", hash = "sha256:23c7959370d7b3236f821e609b0af7763cff7672a758e6c1fc877bac099e786b", size = 16425340, upload-time = "2025-08-27T02:33:17.78Z" }, +] + [[package]] name = "onnxruntime" version = "1.24.4" @@ -767,6 +1048,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -797,6 +1087,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] +[[package]] +name = "torch" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/fe/cba54dc58523434919b66f13a667e36e436deddd77ca519e96553617d4ec/torch-2.13.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8", size = 111187938, upload-time = "2026-07-08T16:05:17.065Z" }, + { url = "https://files.pythonhosted.org/packages/c2/59/1e3160e18e12aa3038390efab3ce02b36a9d4d6a527ecdd8520dca2e68d8/torch-2.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:092790c696a760c729fd5722835f50b9d81fd7c8f141571f3f3cf4081a8f664c", size = 427199369, upload-time = "2026-07-08T16:04:51.054Z" }, + { url = "https://files.pythonhosted.org/packages/01/79/1f2d34ad7034ee1c7ffc1cf8bf0f8213af2a81df6ecdb3997ecec107c09d/torch-2.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:60fcdcb2f3876e21146cb4524ef06397d727ca9ad5f020818547e25075fe3cb7", size = 526574961, upload-time = "2026-07-08T16:04:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/0f2ce40f58aefbdb3392f9acce3c8171940943ae2d661f70558bfa73befb/torch-2.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:a0d8b11f16a48d60e2015d8213aa0390744cbebb98e58b62b3514dddc656e330", size = 122015870, upload-time = "2026-07-08T16:05:27.59Z" }, +] + [[package]] name = "tqdm" version = "4.70.0" @@ -821,6 +1138,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/b9/da09903ea53b677a58ba770112de6fe8b2acb8b4cd9bffae4ff6cfe7c072/trimesh-4.11.2-py3-none-any.whl", hash = "sha256:25e3ab2620f9eca5c9376168c67aabdd32205dad1c4eea09cd45cd4a3edf775a", size = 740328, upload-time = "2026-02-10T16:00:25.246Z" }, ] +[[package]] +name = "triton" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/f9/19d842d06a08559534fa1eaab6ca551b1bcf40f06620bddec1babaa2772d/triton-3.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a0e1cd4c4a76370ed74a8432a53cea28716827d19e40ffc732233e35ceb3f6", size = 184664887, upload-time = "2026-06-17T20:03:42.913Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5e/fce69606f7f240297f163e25539906732b199530d486ce67ae319877e821/triton-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6744957e9fd610a29680ec2346057d0c86948ed3812468670719f391e94b44a5", size = 197701306, upload-time = "2026-06-17T19:53:13.673Z" }, +] + [[package]] name = "typer" version = "0.27.1"