From 7e55af672233fb2d9febcafb56a3d158d4d29cfa Mon Sep 17 00:00:00 2001 From: nan Date: Sun, 6 Sep 2026 21:43:47 +0800 Subject: [PATCH] Add comprehensive tests for edge depth extension, empty image edits, and material view adaptation - Introduced `test_edge_depth.py` to validate the functionality of edge depth extension, ensuring core alpha preservation, proper depth handling, and edge padding behavior. - Created `test_empty_image_edits.py` to verify the behavior of image edits in Blender, focusing on shared edits, preservation of image properties, and handling of failed operations. - Developed `test_material_view_adaptation.py` to test material adaptation preferences, ensuring color space and alpha settings are correctly applied and preserved across various scenarios. --- .../.openspec.yaml | 2 + .../design.md | 61 ++ .../proposal.md | 29 + .../specs/material-view-adaptation/spec.md | 108 +++ .../tasks.md | 32 + .../extend-cutout-edge-depth/.openspec.yaml | 2 + .../extend-cutout-edge-depth/design.md | 69 ++ .../extend-cutout-edge-depth/proposal.md | 25 + .../specs/cutout-edge-depth/spec.md | 65 ++ .../changes/extend-cutout-edge-depth/tasks.md | 28 + .../.openspec.yaml | 2 + .../improve-cutout-adaptive-mesh/design.md | 84 +++ .../experiment-cases.md | 32 + .../experiment.md | 40 ++ .../implementation.md | 64 ++ .../improve-cutout-adaptive-mesh/proposal.md | 30 + .../specs/cutout-adaptive-mesh/spec.md | 98 +++ .../improve-cutout-adaptive-mesh/tasks.md | 26 + .../.openspec.yaml | 2 + .../design.md | 63 ++ .../proposal.md | 26 + .../specs/empty-image-edit-isolation/spec.md | 72 ++ .../isolate-shared-empty-image-edits/tasks.md | 22 + pyproject.toml | 3 +- src/anyimage/blender_manifest.toml | 4 +- src/anyimage/common/image.py | 204 ++++-- src/anyimage/common/material.py | 68 +- src/anyimage/licenses/scikit-image.txt | 29 + .../operators/clipboard_image/actions.py | 8 +- .../operators/cutout_tool/fine_mesh.py | 621 ++++++++++++++++++ .../operators/cutout_tool/geometry.py | 64 +- .../operators/cutout_tool/interaction.py | 8 + src/anyimage/operators/cutout_tool/main.py | 28 +- src/anyimage/operators/cutout_tool/object.py | 4 +- src/anyimage/operators/cutout_tool/polygon.py | 94 +++ .../operators/image_edit_tool/frame.py | 11 +- .../operators/image_edit_tool/mask.py | 7 +- src/anyimage/preferences.py | 17 + src/anyimage/properties.py | 23 + src/anyimage/runtime.py | 1 + src/anyimage/server/geometry/edge_depth.py | 78 +++ .../server/geometry/prediction_artifacts.py | 23 +- src/anyimage/server/jobs/cutout.py | 2 + tests/test_alpha_presentation.py | 4 +- tests/test_blender_addon.py | 41 +- tests/test_clipboard_image.py | 1 + tests/test_cutout_fine_mesh.py | 282 ++++++++ tests/test_cutout_geometry.py | 29 +- tests/test_cutout_interaction.py | 23 + tests/test_cutout_objects.py | 20 +- tests/test_edge_depth.py | 221 +++++++ tests/test_empty_image_edits.py | 254 +++++++ tests/test_extension_packaging.py | 220 ++++++- tests/test_image_data.py | 14 +- tests/test_material_view_adaptation.py | 236 +++++++ tests/test_server_runtime.py | 2 + tools/pack.py | 151 ++++- uv.lock | 30 +- 58 files changed, 3607 insertions(+), 200 deletions(-) create mode 100644 openspec/changes/configure-material-view-adaptation/.openspec.yaml create mode 100644 openspec/changes/configure-material-view-adaptation/design.md create mode 100644 openspec/changes/configure-material-view-adaptation/proposal.md create mode 100644 openspec/changes/configure-material-view-adaptation/specs/material-view-adaptation/spec.md create mode 100644 openspec/changes/configure-material-view-adaptation/tasks.md create mode 100644 openspec/changes/extend-cutout-edge-depth/.openspec.yaml create mode 100644 openspec/changes/extend-cutout-edge-depth/design.md create mode 100644 openspec/changes/extend-cutout-edge-depth/proposal.md create mode 100644 openspec/changes/extend-cutout-edge-depth/specs/cutout-edge-depth/spec.md create mode 100644 openspec/changes/extend-cutout-edge-depth/tasks.md create mode 100644 openspec/changes/improve-cutout-adaptive-mesh/.openspec.yaml create mode 100644 openspec/changes/improve-cutout-adaptive-mesh/design.md create mode 100644 openspec/changes/improve-cutout-adaptive-mesh/experiment-cases.md create mode 100644 openspec/changes/improve-cutout-adaptive-mesh/experiment.md create mode 100644 openspec/changes/improve-cutout-adaptive-mesh/implementation.md create mode 100644 openspec/changes/improve-cutout-adaptive-mesh/proposal.md create mode 100644 openspec/changes/improve-cutout-adaptive-mesh/specs/cutout-adaptive-mesh/spec.md create mode 100644 openspec/changes/improve-cutout-adaptive-mesh/tasks.md create mode 100644 openspec/changes/isolate-shared-empty-image-edits/.openspec.yaml create mode 100644 openspec/changes/isolate-shared-empty-image-edits/design.md create mode 100644 openspec/changes/isolate-shared-empty-image-edits/proposal.md create mode 100644 openspec/changes/isolate-shared-empty-image-edits/specs/empty-image-edit-isolation/spec.md create mode 100644 openspec/changes/isolate-shared-empty-image-edits/tasks.md create mode 100644 src/anyimage/licenses/scikit-image.txt create mode 100644 src/anyimage/operators/cutout_tool/fine_mesh.py create mode 100644 src/anyimage/operators/cutout_tool/polygon.py create mode 100644 src/anyimage/server/geometry/edge_depth.py create mode 100644 tests/test_cutout_fine_mesh.py create mode 100644 tests/test_edge_depth.py create mode 100644 tests/test_empty_image_edits.py create mode 100644 tests/test_material_view_adaptation.py diff --git a/openspec/changes/configure-material-view-adaptation/.openspec.yaml b/openspec/changes/configure-material-view-adaptation/.openspec.yaml new file mode 100644 index 0000000..34f54d2 --- /dev/null +++ b/openspec/changes/configure-material-view-adaptation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-05 diff --git a/openspec/changes/configure-material-view-adaptation/design.md b/openspec/changes/configure-material-view-adaptation/design.md new file mode 100644 index 0000000..cc51ff1 --- /dev/null +++ b/openspec/changes/configure-material-view-adaptation/design.md @@ -0,0 +1,61 @@ +## Context + +`common/material.py` 通过 `scene.view_settings.view_transform` 选择颜色空间,随后调用 `configure_color_alpha()` 固定设置 PREMUL。当前映射包含 ACES 1.3、ACES 2.0、AgX、Filmic 和 Khronos PBR Neutral;Standard 及未匹配视图返回 sRGB。剪贴板 Mesh 材质使用独立构建入口。 + +`common/image.py` 的 Alpha 配置服务于普通编辑结果。此次策略集中作用于材质 Color Image,并复用已有像素读取和恢复机制。 + +## Goals + +- 提供一个全局着色偏好,作为材质颜色贴图适配状态的唯一控制来源。 +- 保证实际 inverse 空间搭配 STRAIGHT,实际 sRGB 搭配 PREMUL。 +- 保留 Color 的业务 RGB、Alpha、透明区域 RGB 和共享源图片。 + +## Decisions + +### 偏好与生效时间 + +在 `AnyImagePreferences` 添加布尔属性 `adapt_material_to_view_transform`,UI 名称为 `Adapt to Scene View Transform`,放入独立 Shading 分组。默认值采用 `False`,让用户主动启用适配;这是本提案的默认选择。读取入口放在 `preferences.py`,通过 `addon_preferences()` 获取;偏好不可用时采用相同默认值。 + +新建材质时读取当前偏好,已创建的材质 Color Image 保留原配置。切换偏好只决定后续创建的材质贴图策略。创建操作、源图片状态和场景 View Transform 都只能参与贴图配置,不能改写适配开关。View Transform 用于开启适配时选择对应颜色空间,回退到 sRGB 后偏好仍保持开启。 + +### 颜色空间与 Alpha 一起决策 + +```mermaid +flowchart TD + A[新建材质 Color] --> B{Preferences 开启视图适配} + B -->|是| C[按当前场景选择颜色空间] + B -->|否| D[设置 sRGB] + C --> E{对应 inverse 空间可用} + E -->|是| F[设置 inverse 空间] + E -->|否| D + F --> G[设置 STRAIGHT] + D --> H[设置 PREMUL] + G --> I[绑定材质颜色贴图] + H --> I +``` + +沿用 `VIEW_TRANSFORM_COLOR_SPACES` 中的准确名称。Standard、未匹配的 View Transform、缺失 scene 以及不支持的 inverse 空间统一使用 sRGB + PREMUL;这属于颜色空间解析结果,适配开关仍由 Preferences 保持。Alpha 模式以实际成功应用的颜色空间为准。如果连 sRGB 也无法设置,配置明确报错并保留该贴图之前的有效配置。 + +### 公共配置边界 + +在 `common/material.py` 集中配置材质 Color 的颜色空间和 Alpha。公共材质与剪贴板 Mesh 材质创建入口调用同一配置函数,在 Blender 主线程处理,颜色空间映射采用创建时当前场景的 View Transform。审查 Plane、Depth Plane、Cutout 调用链,保证加载、复制或 Pack 步骤保持偏好决定的配置。 + +材质专属配置函数负责设置 STRAIGHT 或 PREMUL;普通图片编辑继续使用现有 `configure_color_alpha()`。复用 `common` 中的像素读取函数处理数据保留。 + +颜色空间和 Alpha 都属于 Image 数据块属性。配置作用于本次新建材质专用的 Color Image;当源图还被 Empty、普通图片编辑或已有材质引用时,先复制并仅绑定本次新建材质的 Color 节点。复用 `image_pixels()` 读取业务像素,对 generated、dirty、packed 和文件来源验证两项设置先后变化后的内容保留。 + +### 与已有 Alpha 变更的协调 + +活动变更 `enable-image-alpha-blending-and-premul` 中 `Mesh Color images use PREMUL independently of data textures` 的材质规则由本变更细化为颜色空间配对策略。实施时调整对应材质断言,归档时保证最终主规格采用此规则。普通编辑结果默认 PREMUL、Empty Alpha Blending、Depth 与 Normal 数据解释继续按各自规格执行。 + +## Risks / Trade-offs + +- inverse + STRAIGHT 的边缘优势来自用户观察,需使用实际半透明边缘在 Blender 中复核;属性断言只能验证策略落地。 +- Image 颜色空间或 Alpha 切换可能改变像素读取解释,需覆盖 Pack、保存重载以及重新作为编辑源的行为。 +- 新建材质使用共享源图片时,需要隔离本次 Color 配置,保障已有材质及图片的其他用途。 + +## Validation + +测试偏好默认值、UI、注册与逆序注销;覆盖各视图映射、关闭状态、Standard 和 inverse 不可用的回退,并断言创建及回退前后偏好值保持一致。对 Plane、Depth Plane、Cutout、剪贴板材质验证切换偏好后已有 Color 保持原值、新建 Color 使用当前设置及共享图片隔离。回归验证 Empty、普通编辑结果、Depth、Normal 和非目标材质保持原值。对 byte、float、generated、dirty、packed 材质图片验证业务 RGB/Alpha 保留及保存重载;在实际 Blender 中比较半透明边缘并记录结果。 + +实施完成后运行 `uv run pytest`。本次实施仅修改实现和测试。 diff --git a/openspec/changes/configure-material-view-adaptation/proposal.md b/openspec/changes/configure-material-view-adaptation/proposal.md new file mode 100644 index 0000000..256be55 --- /dev/null +++ b/openspec/changes/configure-material-view-adaptation/proposal.md @@ -0,0 +1,29 @@ +## Why + +用户实测材质颜色贴图在 inverse 类颜色空间下使用 STRAIGHT 边缘更好,在 sRGB 下使用 PREMUL 效果合适。当前材质入口自动适配场景 View Transform 并统一设置 PREMUL,需要提供着色偏好,让用户选择适配方式并配套正确的 Alpha 模式。 + +## What Changes + +- Preferences 新增 Shading 分组和 Adapt to Scene View Transform(适配场景视图变换)开关,默认关闭。 +- 开启时根据当前场景选择对应 inverse 颜色空间,并设置 STRAIGHT。 +- 关闭时使用 sRGB + PREMUL;开启适配但实际回退到 sRGB 时也使用 PREMUL。 +- Preferences 是适配开关的唯一控制来源;新建材质读取当前设置,已创建的材质贴图保留原配置。 +- Plane、Depth Plane、Cutout 和剪贴板 Mesh 材质的 Color Image 统一应用该策略,保留业务像素和源图片独立性。 + +## Capabilities + +### New Capabilities + +- `material-view-adaptation`: 材质视图适配偏好、颜色空间与 Alpha 配对、应用时机和像素保留。 + +### Modified Capabilities + +无。当前主规格目录为空;与活动变更 `enable-image-alpha-blending-and-premul` 的 Mesh Color 默认值重叠部分,在实施和归档时按本变更的配对策略协调。 + +## Impact + +- `src/anyimage/preferences.py`:偏好属性、统一读取入口和 Shading UI。 +- `src/anyimage/common/material.py`:颜色空间选择与材质 Color 配置。 +- `src/anyimage/common/image.py`:复用现有业务像素读取能力;材质专属策略由 `common/material.py` 承担。 +- `src/anyimage/operators/clipboard_image/actions.py`:接入公共材质 Color 配置。 +- Blender 偏好注册、材质配置、Alpha 数据保留和转换入口测试。 diff --git a/openspec/changes/configure-material-view-adaptation/specs/material-view-adaptation/spec.md b/openspec/changes/configure-material-view-adaptation/specs/material-view-adaptation/spec.md new file mode 100644 index 0000000..afb80c8 --- /dev/null +++ b/openspec/changes/configure-material-view-adaptation/specs/material-view-adaptation/spec.md @@ -0,0 +1,108 @@ +## ADDED Requirements + +### Requirement: Preferences expose material view adaptation + +AnyImage SHALL 在 Preferences 的 Shading 分组提供 `Adapt to Scene View Transform` 布尔选项,默认关闭。扩展设置及读取入口 SHALL 位于 `preferences.py`,并通过 `addon_preferences()` 读取。 + +#### Scenario: Preferences use the default + +- **WHEN** 用户尚未设置该选项或读取不到扩展偏好 +- **THEN** 材质颜色贴图 SHALL 使用关闭适配的行为 + +### Requirement: Material color space determines alpha presentation + +开启适配时,AnyImage SHALL 按当前场景 View Transform 选择既有映射中的 inverse 颜色空间,并使用 STRAIGHT。关闭适配时 SHALL 使用 sRGB + PREMUL。实际回退为 sRGB 时 SHALL 使用 PREMUL,并保持 Preferences 中的适配开关原值。 + +#### Scenario: A supported view is adapted + +- **WHEN** 适配开启,当前场景为 AgX,且 `AgX Base sRGB` 可用 +- **THEN** 材质 Color Image SHALL 使用 `AgX Base sRGB` + STRAIGHT + +#### Scenario: Other mapped views are adapted + +- **WHEN** 适配开启,场景使用 ACES 1.3、ACES 2.0、Filmic 或 Khronos PBR Neutral,且对应空间可用 +- **THEN** Color Image SHALL 分别使用 `ACES 1.3 sRGB`、`ACES 2.0 sRGB`、`Filmic sRGB` 或 `Khronos PBR Neutral sRGB` +- **AND** Alpha 模式 SHALL 为 STRAIGHT + +#### Scenario: Adaptation is disabled + +- **WHEN** 适配关闭,创建任意受支持入口的材质 +- **THEN** Color Image SHALL 使用 sRGB + PREMUL + +#### Scenario: The scene resolves to sRGB + +- **WHEN** 适配开启,但场景为 Standard、视图未匹配、场景不可用或 inverse 空间不被支持 +- **THEN** Color Image SHALL 使用 sRGB + PREMUL + +#### Scenario: The fallback color space is unavailable + +- **WHEN** 所需颜色空间及 sRGB 均无法应用 +- **THEN** 配置 SHALL 明确报错并保留贴图之前的有效配置 +- **AND** Preferences 中的适配开关 SHALL 保持原值 + +### Requirement: Material entry points apply the same policy + +AnyImage SHALL 在 Plane、Depth Plane、Cutout 和剪贴板 Mesh 材质创建时统一配置 Color Image。最终模式 SHALL 在后续绑定及交付步骤中保留。Depth 与 Normal SHALL 保留各自的数据解释约定。 + +#### Scenario: A material is created from an edited color result + +- **WHEN** 一个 PREMUL 编辑结果进入开启适配的 inverse 材质 +- **THEN** 最终材质 Color Image SHALL 为 STRAIGHT +- **AND** 材质交付后 SHALL 保留该模式 + +#### Scenario: Shadeless material is created + +- **WHEN** 用户通过支持 Shadeless 的入口创建材质 +- **THEN** Color Image SHALL 应用相同颜色空间与 Alpha 配对策略 + +### Requirement: Configuration preserves business pixels and source ownership + +材质 Color 配置 SHALL 保留原有业务 RGB、Alpha 和完全透明区域 RGB,并在 Pack、保存重载和再次编辑后保持可读取。配置 SHALL 作用于材质专用 Color Image 或独立 Color 副本。 + +#### Scenario: The source image has other users + +- **WHEN** 源 Image 同时被其他对象引用 +- **THEN** 配置 SHALL 作用于独立 Color 结果 +- **AND** 原 Image 的颜色空间、Alpha 模式和业务像素 SHALL 保持原值 + +#### Scenario: A color result is packed and reopened + +- **WHEN** byte、float、generated、dirty 或 packed 来源的 Color 完成配置并保存重载 +- **THEN** 业务 RGB/Alpha SHALL 在既有编码精度内保留 +- **AND** 结果 SHALL 保持所选颜色空间和 Alpha 模式 + +### Requirement: Preferences exclusively control material adaptation + +Preferences SHALL 是适配开关的唯一控制来源。新建材质 SHALL 读取当前偏好;已创建的材质颜色贴图 SHALL 保留原配置。创建操作和颜色空间解析 SHALL 保持偏好值。 + +#### Scenario: The preference changes + +- **WHEN** 用户在 Preferences 开启或关闭适配 +- **THEN** AnyImage 已创建的材质颜色贴图 SHALL 保持原颜色空间、Alpha 模式和像素 +- **AND** 后续新建材质 SHALL 使用新的偏好设置 + +#### Scenario: A new material shares its source with an existing material + +- **WHEN** 切换偏好后,使用已被现有材质引用的源 Image 创建新材质 +- **THEN** 新材质 SHALL 使用独立 Color Image 并应用当前偏好 +- **AND** 已有材质的贴图及其配置 SHALL 保持原值 + +#### Scenario: A material is created or an inverse space is unavailable + +- **WHEN** 创建材质或颜色空间解析发生回退 +- **THEN** Preferences 中的适配开关 SHALL 保持操作前的值 + +### Requirement: Adaptation affects only material color textures + +本选项 SHALL 仅配置 AnyImage 材质的 Color 贴图。Empty、普通图片编辑结果、Depth、Normal 和非目标材质 SHALL 保持原有行为与数据。 + +#### Scenario: A material color image is shared with an Empty + +- **WHEN** 新建材质的源 Color Image 同时被 Empty 引用 +- **THEN** 系统 SHALL 为材质 Color 创建独立副本并重新绑定目标节点 +- **AND** Empty 引用的原 Image 的颜色空间、Alpha 模式与业务像素 SHALL 保持原值 + +#### Scenario: Ordinary image editing runs with adaptation enabled + +- **WHEN** 开启适配后执行 Mask、Frame、Rectify 或图片编辑 Job +- **THEN** 编辑结果 SHALL 沿用各自已有颜色空间与 Alpha 规则 diff --git a/openspec/changes/configure-material-view-adaptation/tasks.md b/openspec/changes/configure-material-view-adaptation/tasks.md new file mode 100644 index 0000000..f2115f1 --- /dev/null +++ b/openspec/changes/configure-material-view-adaptation/tasks.md @@ -0,0 +1,32 @@ +## 1. 着色偏好 + +- [x] 1.1 在 `preferences.py` 增加默认关闭的 `adapt_material_to_view_transform`、统一读取入口和 Shading 分组。 +- [x] 1.2 补充偏好默认值、读取回退、UI 展示测试,并检查 `__init__.py` 注册和逆序注销。 + +## 2. 材质配置 + +- [x] 2.1 在 `common/material.py` 集中颜色空间与 Alpha 配对:inverse + STRAIGHT,sRGB + PREMUL;实现映射、回退和设置失败处理。 +- [x] 2.2 在材质专属配置入口设置 Alpha,复用现有业务像素读取能力,验证颜色空间与 Alpha 连续切换的数据完整性。 +- [x] 2.3 接入公共材质与剪贴板 Mesh 入口,审查 Plane、Depth Plane、Cutout 的独立 Color 所有权及最终交付模式。 +- [x] 2.4 确保新建材质使用独立 Color 图片,隔离共享源对已有材质及其他用途的影响。 + +## 3. 验证 + +- [x] 3.1 覆盖开关两种状态、全部既有视图映射、Standard、场景缺失、inverse 不可用及 sRGB 设置失败。 +- [x] 3.2 覆盖各材质入口及 Shadeless,验证切换偏好后已有材质 Color 保持原值,新建材质读取当前设置,创建和回退均保持偏好值。 +- [ ] 3.3 更新现有材质 PREMUL 断言,验证 byte、float、generated、dirty、packed Color 的 RGB/Alpha、透明 RGB、Pack、保存重载和再次编辑。 +- [x] 3.4 在实际 Blender 中用半透明边缘复核 inverse + STRAIGHT 与 sRGB + PREMUL 的效果,并记录验证结果。 +- [x] 3.5 运行 `uv run pytest`,修复相关回归。 +- [x] 3.6 验证仅材质 Color 受影响:共享源、Empty、Mask/Frame/Rectify、图片编辑 Job、Depth、Normal 和非目标材质保持原有行为与数据。 + +## 4. 规格收敛 + +- [ ] 4.1 归档时协调 `enable-image-alpha-blending-and-premul` 的 Mesh Color 需求,使最终主规格统一采用本变更的颜色空间与 Alpha 配对。 + +## 验证记录 + +2026-09-05:`uv run --no-sync pytest -q --tb=short`:562 passed、2 xfailed、56 subtests passed。`--no-sync` 使用本轮已同步的项目环境。OpenSpec strict 校验通过。 + +Blender 4.5.3 LTS / Cycles CPU、AgX、16 samples:渲染 64×64 橙色圆形贴图的渐变 Alpha 边缘,192×192 输出。sRGB + PREMUL 和 AgX Base sRGB + STRAIGHT 均正常显示连续半透明边缘;后者中心色更接近输入橙色。该检查确认两种策略可用。 + +3.3 部分完成:byte、generated byte、dirty、文件和 packed 图片的业务像素及保存重载通过;原生浮点图片在保存重载后的 RGB 不满足原值断言,两种设置均可复现,已用严格 xfail 保留检查。即时配置和共享源保留通过。此项尚未解决,因此保留未完成状态。4.1 留待归档阶段处理。 diff --git a/openspec/changes/extend-cutout-edge-depth/.openspec.yaml b/openspec/changes/extend-cutout-edge-depth/.openspec.yaml new file mode 100644 index 0000000..34f54d2 --- /dev/null +++ b/openspec/changes/extend-cutout-edge-depth/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-05 diff --git a/openspec/changes/extend-cutout-edge-depth/design.md b/openspec/changes/extend-cutout-edge-depth/design.md new file mode 100644 index 0000000..a02205f --- /dev/null +++ b/openspec/changes/extend-cutout-edge-depth/design.md @@ -0,0 +1,69 @@ +## Context + +Cutout 的矩形 RGBA 经黑底合成后进入 MoGe,Vector Depth 保存相机 XYZ,节点按 UV 线性采样。边缘 Alpha 可用于保留毛发,但其深度可能属于背景。 + +本次实验使用 BEN2、MoGe-2 ViT-S Level 5,固定猫(585×643)和人物(234×222)的同一份预测。Alpha ≥ 0.95、内缩 2 px、最近点外延、σ=0.8 px 局部平滑明显减少拖尾。替换深度的可见像素分别约 4.4% 与 9.6%;可靠内部保持原值。8 px 内缩替换人物约 27% 的可见区域,形状变化明显。 + +实验采用独立投影网格。完整 Blender 的线性采样、平滑、厚度、拉伸剔除及 Normal Map 组合需要在实施阶段验收。实验记录位于 `C:/Users/icrdr/.codex/visualizations/2026/09/05/01a071bc-9eae-7f91-8791-816aa621fd8a/`,其中 `depth_experiment.py` 保存推理入口,`analyze_depth.py` 保存对比算法,`metrics.json` 保存结果;该目录是本次本地证据,自动测试使用可重建的合成数据。 + +## Goals + +保留 Alpha 0.1 的可见轮廓,以可靠内部深度补齐边缘,并保持相机投影与 Cutout 产物单位一致。 + +## Decisions + +### 深度修复入口 + +在 `server/geometry/edge_depth.py` 集中处理普通数组和 GeometryFrame。`generate_moge2_artifacts()` 增加内部显式选项,由 Cutout Job 在生成深度时开启。Depth Surface 与 Depth Balloon 分别输出修复后的 Vector Depth、Z Depth,并使用对应 Metadata。Normal Writer 继续消费原始预测;普通 Depth Plane 和仅生成 Normal 的路径维持现有入口行为。 + +```mermaid +flowchart TD + A[Cutout RGBA] --> B[现有黑底合成与 MoGe 推理] + B --> C[原始 GeometryFrame] + A --> D[对齐到深度图的连续 Alpha] + C --> E[构建可靠内部] + D --> E + E --> F[分量内深度外延与局部平滑] + F --> G[按当前像素射线重建 XYZ] + G --> H[输出 Vector/Z Depth 与 Metadata] + C --> I[现有 Normal Writer] + H --> J[现有 Cutout 修改器] +``` + +### 可见域与可靠域 + +- 连续 Alpha 使用与现有 `_alpha_mask()` 相同的最近邻尺寸对齐;可见域为 Alpha ≥ 0.1。 +- 可靠候选为 Alpha ≥ 0.95、模型 Validity > 0、深度有限且为正。 +- 在深度图输出像素单位下,保留到候选域外欧氏距离大于 2 px 的可靠核心;图片矩形边界按边缘延续处理,避免把贴边主体当作透明边缘内缩。 +- 全不透明源图直接使用原始 Frame。所有参数先固定在服务端实现中。 + +### 外延与回退 + +可见域按 8 连通分量处理,每个分量只使用自身的可靠核心作为供体。没有内缩核心时使用该分量内未内缩的可靠候选;连候选也没有时保留该分量的原始预测。这样细小或半透明分量具有确定的结果。 + +对有供体的分量,以最近可靠像素的标量深度补齐非核心区域。透明背景在可见域外扩 4 px 内接收最近可修复分量的延伸值,覆盖平滑核与线性采样所需的邻域;其他可见分量的值由自身规则决定。局部计算边界须保留滤波所需的 padding。 + +外延结果用 σ=0.8 px、半径 3 px 的 Gaussian 核平滑,仅写回补齐区域;可靠核心逐值保留。分量内平滑使用本分量的外延场,避免滤波读入其他分量深度。未处理区域保留原值。 + +### 投影与产物一致性 + +使用 Frame 的像素单位内参和当前像素中心,由修复后的 Z 重建相机 X、Y。复用服务端现有深度反投影函数时,须显式处理其归一化内参约定。核心位置保留原始 XYZ,防止不必要的浮点变化。 + +修复后的 Frame 保留原模型 Validity;修复不会把模型有效性改写为预测置信度。Metadata 使用修复后的 Depth、原模型 Validity 和现有参考 Alpha Mask 计算,确保参考深度与导出贴图一致。颜色 Alpha、UV 与 Selection Mask 继续由现有路径管理。 + +### 数值依赖 + +复用 SciPy 的距离变换、连通分量和 Gaussian 滤波。实施时选择同时支持开发 Python 3.11、服务端 Python 3.12 及其 NumPy 约束的版本,并同步运行环境声明、开发依赖与锁文件。处理按分量局部范围执行,验证 2048 长边输入的时间和峰值内存。 + +## Risks / Trade-offs + +- 高 Alpha 不等同于正确深度 → 保留 2 px 内缩,并用真实深度场检查残余污染。 +- 最近供体在同一分量内可能跨越前后表面 → 保持小内缩、仅局部平滑,并验收耳朵、衣领和手部交界。 +- 半透明孤立分量没有可靠供体时仍可能后缩 → 保留原始预测,测试回退的有限值和确定性。 +- 固定像素宽度在不同输入尺寸下覆盖比例不同 → 以深度图像素明确单位,验证两张原图及缩放输入;进一步调整参数须有新增证据。 +- 原始 Normal Map 与修复几何在边缘可能不一致 → 分别验收 Normal 开关下的边缘着色,记录残余问题。 +- 边缘跳变减小只证明拉伸改善,并不等同于真实三维深度准确 → 同时核对可见域、正面投影和侧视外观。 + +## Migration Plan + +新任务在模型产物导出阶段应用修复,已有 Blender 对象通过重新生成 Cutout 获得结果。实施完成后运行相关测试和完整 pytest,再对现有节点资产执行两张实图验收。 diff --git a/openspec/changes/extend-cutout-edge-depth/proposal.md b/openspec/changes/extend-cutout-edge-depth/proposal.md new file mode 100644 index 0000000..00efa3b --- /dev/null +++ b/openspec/changes/extend-cutout-edge-depth/proposal.md @@ -0,0 +1,25 @@ +## Why + +Cutout 用 Alpha 0.1 保留毛发时,边缘可能读取背景深度,形成严重后缩和长条拉伸。猫与人物的实图实验已验证:从可靠内部外延深度,可以保留原 Alpha 轮廓并明显减少拖尾。 + +## What Changes + +- 为 Cutout 的 Depth Surface 与 Depth Balloon 增加导出前的边缘深度修复。 +- 以 Alpha ≥ 0.95 且模型深度有效的内部区域为依据,内缩 2 个深度图像素后向边缘外延,并仅对补齐区域轻微平滑。 +- 保留颜色 Alpha、0.1 裁切阈值和可靠内部深度;按当前像素的相机射线重建 XYZ。 +- 为细小分量、无可靠深度和透明区采样增加确定的处理规则,并补充自动测试与 Blender 实图验收。 + +## Capabilities + +### New Capabilities + +- `cutout-edge-depth`: Cutout 的可靠深度区域、边缘外延、投影保持和产物一致性。 + +### Modified Capabilities + +## Impact + +- `server/geometry/`:增加深度修复模块,接入推理产物编排。 +- `server/jobs/cutout.py`:为 Cutout 深度产物启用修复。 +- `runtime.py`、`pyproject.toml`、`uv.lock`:加入 SciPy,复用距离变换与局部滤波,并检查环境及打包测试。 +- 服务端几何与任务测试,以及现有 Blender Cutout 修改器的实图验证。 diff --git a/openspec/changes/extend-cutout-edge-depth/specs/cutout-edge-depth/spec.md b/openspec/changes/extend-cutout-edge-depth/specs/cutout-edge-depth/spec.md new file mode 100644 index 0000000..8c7e7ff --- /dev/null +++ b/openspec/changes/extend-cutout-edge-depth/specs/cutout-edge-depth/spec.md @@ -0,0 +1,65 @@ +## ADDED Requirements + +### Requirement: Reliable interior supplies edge depth + +系统 SHALL 为包含透明内容的 Cutout 深度产物,从 Alpha ≥ 0.95、Validity > 0 且深度有限为正的候选域中,选择欧氏内缩 2 个深度图像素后的可靠核心。可见域 SHALL 继续使用 Alpha ≥ 0.1,核心深度 SHALL 逐值保留。 + +#### Scenario: Hair samples contain background depth +- **WHEN** 半透明毛发仍属于可见域,但其预测深度接近背景,所属分量存在可靠核心 +- **THEN** 系统以该分量最近可靠核心的深度补齐毛发区域,并保留原颜色 Alpha 和裁切轮廓 + +#### Scenario: Subject reaches the image rectangle boundary +- **WHEN** 不透明主体延伸到图片矩形边界 +- **THEN** 系统按边缘延续计算内缩,不将图片外部自动视为透明背景 + +### Requirement: Extension has deterministic component fallbacks + +系统 SHALL 按可见域的 8 连通分量选择供体。缺少内缩核心时 SHALL 使用本分量未内缩的可靠候选;缺少可靠候选时 SHALL 保留该分量原始预测。 + +#### Scenario: Narrow component has no eroded core +- **WHEN** 细小分量的可靠候选在 2 px 内缩后消失 +- **THEN** 系统使用该分量未内缩候选完成外延 + +#### Scenario: Translucent component has no reliable candidates +- **WHEN** 独立分量完全由低 Alpha 或模型无效像素组成 +- **THEN** 系统保留该分量预测,不从另一可见分量复制深度 + +### Requirement: Filtered sampling uses extended depth + +系统 SHALL 向可修复分量相邻透明区外扩 4 px 的范围延伸深度,并以 σ=0.8 px、半径 3 px 的 Gaussian 核仅平滑补齐区域。核心及其他未处理区域 SHALL 保留原值。 + +#### Scenario: Texture sampling straddles the silhouette +- **WHEN** 线性采样邻域跨越可见边缘和邻近透明背景 +- **THEN** 透明侧提供外延深度,采样不会重新混入该邻域原来的背景深度 + +#### Scenario: Adjacent components have different depths +- **WHEN** 两个可见分量分别位于不同深度 +- **THEN** 各分量的供体与平滑输入保持分量隔离 + +### Requirement: Corrected geometry preserves camera projection + +系统 SHALL 根据修复后的 Z 和当前像素中心的相机射线重建 XYZ,使修复前后的相机投影保持一致,并保留核心原始 XYZ。 + +#### Scenario: Edge pixel inherits an interior depth +- **WHEN** 边缘像素采用另一像素的可靠 Z +- **THEN** 重建 XYZ 仍投影到边缘像素自身位置,而不是供体位置;数值测试的归一化投影误差 SHALL 小于 1e-6 + +### Requirement: Cutout artifacts share corrected depth + +系统 SHALL 为 Depth Surface 与 Depth Balloon 的 Vector/Z Depth 和 Metadata 使用同一修复后深度。模型 Validity SHALL 保留其原始语义,Normal Writer SHALL 继续使用原始预测。 + +#### Scenario: Cutout exports vector depth and metadata +- **WHEN** Cutout 请求深度产物 +- **THEN** Vector Depth 的 Z 与 Metadata 参考深度来自同一修复后深度场,所有输出坐标有限 + +#### Scenario: Existing artifact paths do not require correction +- **WHEN** 输入完全不透明,或请求来自普通 Depth Plane,或 Cutout 只生成 Normal +- **THEN** 系统使用对应的原始产物路径与预测行为 + +### Requirement: Edge correction is verified in the full Cutout pipeline + +验收 SHALL 使用猫与人物原图依次执行 Remove Background 和 Cutout,固定同一 Alpha 与模型预测比较修复前后结果,并检查 Depth Surface、Depth Balloon 及 Normal 开关组合。 + +#### Scenario: Side-view regression comparison +- **WHEN** 两张实图完成完整 Blender 修改器求值 +- **THEN** 主要边缘长条后缩明显减少,原 Alpha 可见域保持一致,可靠核心深度逐值不变,并记录残余台阶、着色或剔除问题 diff --git a/openspec/changes/extend-cutout-edge-depth/tasks.md b/openspec/changes/extend-cutout-edge-depth/tasks.md new file mode 100644 index 0000000..b060c66 --- /dev/null +++ b/openspec/changes/extend-cutout-edge-depth/tasks.md @@ -0,0 +1,28 @@ +## 1. 数值依赖与深度修复 + +- [x] 1.1 确定兼容开发和服务端 Python、NumPy 的 SciPy 版本,同步 `runtime.py`、`pyproject.toml` 和 `uv.lock`,检查 Manifest 与环境、打包测试约束。 +- [x] 1.2 在 `server/geometry/edge_depth.py` 实现连续 Alpha 对齐、可靠候选、2 px 欧氏内缩及图片边缘延续,补充阈值和贴边主体测试。 +- [x] 1.3 实现按可见域 8 连通分量选取最近供体,以及无内缩核心、无候选的回退规则,测试细小分量与不同深度的相邻分量。 +- [x] 1.4 实现透明区 4 px 外扩和 σ=0.8 px、半径 3 px 的局部平滑,测试核心逐值不变、邻域采样与跨分量隔离。 + +## 2. 产物接入 + +- [x] 2.1 按 Frame 内参和当前像素射线重建修复区域 XYZ;复用现有服务端反投影能力,测试非方形图片、像素中心、有限值及小于 1e-6 的归一化投影误差。 +- [x] 2.2 为 `generate_moge2_artifacts()` 增加内部修复选项,由 Cutout Job 在深度请求中启用;Vector/Z Depth 与 Metadata 共用修复后 Frame,保留模型 Validity 和原始 Normal Writer 输入。 +- [x] 2.3 补充任务回归测试,覆盖 Depth Surface、Depth Balloon、完全不透明图片、普通 Depth Plane 和仅 Normal 请求的产物行为。 + +## 3. 验证 + +- [x] 3.1 运行数值、服务端任务、运行环境和打包相关测试,再运行 `uv run pytest`。 +- [x] 3.2 用可重建的 2048 长边输入检查处理时间、峰值内存和分量数量对开销的影响,确认局部范围处理可用。 +- [x] 3.3 使用两张用户原图重新执行 BEN2 与 MoGe,固定同一 Alpha 和预测,对比修复前后边缘跳变、可见域及正面投影;补充缩放输入检查,记录结果。 +- [x] 3.4 使用现有 Blender 节点资产验收两张图的 Depth Surface、Depth Balloon 和 Normal 开关组合,检查线性采样、平滑、厚度与拉伸剔除后的侧视拖尾及残余台阶、着色问题。 + +## 验证记录 + +- `uv run pytest -q`:524 passed,56 subtests passed。 +- SciPy 1.15.3 支持 Python ≥ 3.10、NumPy ≥ 1.23.5 且 < 2.5;已在开发和服务端环境执行。 +- 2048×2048、1/64/1024 个分量:0.445/0.399/0.394 秒;独立进程峰值工作集约 453/343/367 MiB(处理前约 229 MiB)。 +- 猫与人物原图及半尺寸图完成重新推理,正面投影误差均小于 3×10⁻⁸,可靠核心逐值保持。猫原图边缘跳变 P95 从约 14.86% 降至 0.10%,人物从约 2.98% 降至 0.35%(以各自参考深度归一化)。 +- Blender 4.5.3 现有资产完成 16 组渲染,并对平滑 2 次、厚度 0.02、Stretch Limit 0.05 组合额外求值;所有网格坐标有限。Depth Surface 主要拖尾消除,猫最大网格边长从 2.266 降至 0.107,人物从 1.311 降至 0.662。Depth Balloon 轮廓整体稳定,人物手部原有拉伸仍可见;原始 Normal 的边缘着色仍有局部差异。 +- 本地验证脚本、预测、指标及渲染位于 `C:/Users/icrdr/.codex/visualizations/2026/09/05/01a071bc-9eae-7f91-8791-816aa621fd8a/verification/`;脚本 `verify_edge_depth.py`、`verify_blender_depth.py` 位于其父目录。 diff --git a/openspec/changes/improve-cutout-adaptive-mesh/.openspec.yaml b/openspec/changes/improve-cutout-adaptive-mesh/.openspec.yaml new file mode 100644 index 0000000..1a62d62 --- /dev/null +++ b/openspec/changes/improve-cutout-adaptive-mesh/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-06 diff --git a/openspec/changes/improve-cutout-adaptive-mesh/design.md b/openspec/changes/improve-cutout-adaptive-mesh/design.md new file mode 100644 index 0000000..ce15a96 --- /dev/null +++ b/openspec/changes/improve-cutout-adaptive-mesh/design.md @@ -0,0 +1,84 @@ +## Context + +当前平面网格由 Alpha 轮廓、均匀内部点和 Blender CDT 构成,Balloon 消费内部高度。Mesh Detail 的源像素间距以及世界坐标度量已有约定。本提案基于四张已抠图样本的独立实验,测量与验证边界见 [实验基准](experiment.md);实验脚本使用开发 Python,正式 Blender 环境需要独立验证。 + +## Goals / Non-Goals + +以较少顶点表达低密度轮廓与细长结构,提高高密度的实际轮廓贴合度,维持内部连通和尺寸过渡,并降低首次生成耗时。 + +## Decisions + +### 0. Fine Outline 默认开启,两种算法独立选择 + +Scene 的 `cutout_fine_outline` 与 Operator 的 `fine_outline` 共用默认 True 的属性定义,界面位于 Mesh Detail 之后。菜单捕获设置,本地创建与 AI 返回均使用该快照。关闭时使用原始轮廓平滑与均匀采样,开启时使用以下精细流程。算法选择适用于全部 Shape。 + +密集蕨叶允许用户选择原算法获得更强概括和更短等待;精细模式仍完整验证约束,失败明确报错。两种模式分别测试,记录复杂输入的实际开销。 + +### 1. 轮廓精度与网格间距共同受 Mesh Detail 控制 + +```mermaid +flowchart TD + A[源 Alpha 与 Selection] --> B[按当前 Alpha 阈值构建有效区域] + B --> C[清理极小独立区域] + C --> D[提取外轮廓和孔洞] + D --> E[按像素弧长采样与轮廓概括] + E --> F[按细节档位降低简化误差] + F --> G[提取并连接稀疏内部路径] + G --> H[细分轮廓和路径并均匀化自由点] + H --> I[约束三角化及实际边验证] + I --> J[既有高度求解与 Shape 创建] +``` + +Low / Medium / High / Ultra 的内部目标间距为 32 / 16 / 8 / 4 源像素;实验轮廓 RDP 容差分别为 2.5 / 1.25 / 0.625 / 0.5 像素。Medium 以上先按约 1 像素弧长重采样,再以 0.8 像素尺度轻微平滑像素阶梯。容差针对平滑后的轮廓,最终验收针对源 Alpha,二者分别记录。 + +高密度从源轮廓重新近似,再限制最长边。轮廓有效性检查覆盖穿越相交、孔洞包含关系和面积;平滑或简化破坏拓扑时,逐次减半处理强度,最后使用源轮廓。像素轮廓原有的顶点接触允许保留,内部约束路径仍须严格远离边界。细桥与内部支撑在最终网格中验证。 + +像素误差、面积阈值和内部间距在源像素坐标中定义,再通过既有变换转换为世界坐标。触及现有网格分辨率上限时,采用受限的有效间距推导精度档位,防止轮廓无限加密绕过开销限制。 + +### 2. 极小碎片按独立区域面积处理 + +实验默认过滤四邻域面积小于 16 源像素的独立区域,保留最大前景区域。与主体相连的细腿按所在区域整体保留;孔洞属于轮廓拓扑。清理仅作用于几何有效区域,材质 Alpha 与用户图片沿用原数据。全透明输入走现有无有效 Selection 的提示。 + +### 3. 连续内部路径作为三角化约束 + +从边界约束三角网格的内部边中点和三角形中心构造有效路径图。局部宽度参与选择需要支撑的细部,结构尺度由图像范围决定。选定分支通过一次多源 Dijkstra 与连接候选的最小生成森林补连,再概括为稀疏折线;过短末梢剪枝须保留必要的端部支撑。 + +线段有效性使用端点内外判定、边界相交及线段最小间距,复用预计算边界数组。对共线接触、孔洞和近零距离使用一致容差。补连搜索采用整个细部有效子图,取消局部最大路径长度限制;实验以到边界距离不超过结构尺度的 0.75 倍限定补连区域,让宽阔中央区域由普通点支撑。该宽度边界仍须通过变宽关节回归,不能被当作所有图像上的连通保证。 + +简化后每段都必须在轮廓内部。CDT 后通过输入约束映射查找实际网格边,通过原始顶点映射验证每条约束两端间的实际边连通和内部顶点身份,避免 float32 输出坐标使大像素坐标下的短边长度误差触发误报。所有保留细长分支需覆盖端部与关节,不能只验证已经成功选中的少数线段。Alpha 本身断开的区域保持各自独立。 + +轮廓精度改变时,内部路径坐标可以调整;分支覆盖和连通性是跨档位契约,不要求四档顶点和边完全相同。 + +### 4. 自由点均匀化按收敛停止 + +沿用三角格点作为普通内部点种子;固定边界和结构路径,释放其余点。只采样目标积分网格需要的位置,限制到分量包围盒;通过最近邻归属和分组求和计算质心,最多 12 轮。至少 4 轮后,位移 RMS 小于目标间距的 1% 可停止。通过邻面尺寸比和内部最近邻距离离散度验证停止条件。 + +轮廓与结构线按目标间距细分。最终网格中,端点均在边界的内部边共享一个新增中点,受影响三角形以内部中心点细分;全边界三角形也补入中心点。这消除内部的零高度截断,保持相邻面的公共边一致。极细部沿稀疏路径保留内部支撑。 + +### 5. 优化计算并限定复用边界 + +按半径分组建立中轴圆的空间索引,查询时从大半径向小半径筛选,保持宽度估计结果一致。轮廓射线和距离数组在一次分量处理内复用。布点与结构准备分为普通数组接口,复用结果须与重新计算一致。 + +每次创建依据当前输入和精度重新准备结构;分量内复用距离、射线和宽度索引,创建结束释放。当前交互一次创建一个 Shape,因此直接使用操作局部数据。 + +首次生成、已准备结构的重建、Blender 对象更新分别计时。实验复用计时不包含输入检查和结构准备,因此不能当作首次生成或跨档位 UI 延迟。 + +### 6. 正式运行时依赖作为接入门槛 + +客户端使用 SciPy 的标记、距离变换与 KDTree,NumPy 的轮廓简化及 Blender CDT。距离脊圆用于估计局部宽度,连续内部路径仍由约束图构建。轮廓简化保留 scikit-image 0.25.2 的独立 NumPy 实现及 BSD 许可证。 + +客户端声明 SciPy 1.15.3;NumPy 使用 Blender 自带版本,由打包器按 Python 目标版本解析兼容范围并排除 NumPy wheel。锁文件和本地 Manifest 同步。此前六图四档实验的轮廓边界一致,保留内部支撑和连通性检查。 + +## Risks / Trade-offs + +- 细桥经平滑或简化消失 → 以源有效域检查连通关系,降低处理强度;将单像素桥、弯折和孔洞列入测试。 +- 路径连接增加宽区域内部线 → 验收普通点均匀性、总点数和邻面尺寸,保留稀疏折线概括。 +- 小独立区域具有内容语义 → 将清理限定为极小面积,保留最大区域,保持材质原 Alpha;记录几何过滤数量。 +- 高密度轮廓复杂度增加 → 批处理与空间索引,按分量限制工作集;测量 2K / 4K 输入及网格上限。 +- 四张实图不足以保证所有细部 → 合成图覆盖弯折、窄桥、多个孔洞、独立岛和边界接触;失败不得静默丢弃约束。 +- [十图扩展实验](experiment-cases.md) 中蕨叶 Low / Ultra 达到约 6 / 23.6 秒,羽毛高密度仍有支撑间断 → 把密集多级分枝和局部零高度边列为实施验收反例,定位候选图数量与边界查询开销。 +- 开发 Python 测时不能代表 Blender 全流程 → 正式接入前分别完成数值阶段和对象/节点阶段验收。 + +## Migration Plan + +依次完成运行时可行性、独立几何回归、双模式接入和 Blender 验收。精细轮廓作为默认模式,原算法作为用户主动选择的概括模式。用户现有 Shape、像素间距和世界变换语义沿用原约定。 diff --git a/openspec/changes/improve-cutout-adaptive-mesh/experiment-cases.md b/openspec/changes/improve-cutout-adaptive-mesh/experiment-cases.md new file mode 100644 index 0000000..d144ce5 --- /dev/null +++ b/openspec/changes/improve-cutout-adaptive-mesh/experiment-cases.md @@ -0,0 +1,32 @@ +# 十张新增实图 + +2026-09-06,固定上一轮独立 `final.py`,从 Wikimedia Commons 选取十张公开图片。下载图缩至长边不超过 768 像素且不放大,再以本地 BEN2 抠图;已有透明图先合成白底。Alpha 阈值 0.9、独立岛面积阈值 16,四档间距 32/16/8/4。未按单张图片调参。 + +每档预热一次,测三次数值构建取中位数,计时边界沿用 experiment.md。BEN2 本轮实际使用 CPUExecutionProvider,约每图 3.5–4.1 秒,未纳入网格耗时。 + +| 图片 | 尺寸 | Low 秒 | Medium 秒 | High 秒 | Ultra 秒 | +|---|---|---:|---:|---:|---:| +| 螳螂 | 768×565 | 0.092 | 0.116 | 0.231 | 0.429 | +| 蚁后 | 475×768 | 0.045 | 0.050 | 0.067 | 0.088 | +| 蚁蛉 | 479×768 | 0.112 | 0.135 | 0.224 | 0.354 | +| 捕鸟蛛 | 768×548 | 0.082 | 0.099 | 0.167 | 0.383 | +| 凤蝶 | 768×412 | 0.069 | 0.098 | 0.201 | 0.540 | +| 枫叶 | 768×512 | 0.058 | 0.074 | 0.115 | 0.284 | +| 羽毛 | 576×768 | 0.287 | 0.466 | 0.771 | 1.264 | +| 剪刀 | 400×290 | 0.058 | 0.072 | 0.117 | 0.229 | +| 蕨叶 | 768×768 | 6.026 | 10.211 | 18.854 | 23.600 | +| 骑行者 | 768×507 | 0.089 | 0.103 | 0.149 | 0.272 | + +## 新发现 + +- 蕨叶仅约 97,003 个前景像素,与先前蜘蛛相近,但 Low 已有 8,012 点和 3,459 条内部约束。清理将独立区域从 661 减至 58,移除 1,475 像素;保留下来的细枝和孔洞仍形成显著开销。Ultra 有 24,007 点,约 23.6 秒。密集分枝必须作为性能反例,不能沿用四图结论推断其运行时间。 +- 羽毛 Low / Ultra 分别有 24 / 42 条端点均在边界的内部边,存在局部零高度夹扁风险。额外内部顶点图分量从 1 增至 25。蕨叶的额外内部图分量从 Low 的 3 增至 Ultra 的 172。 +- 剪刀、捕鸟蛛、蚁后和骑行者在四档内部图诊断中没有额外内部分量,但此指标不等于逐腿端部覆盖证明。没有为新图人工指定每条细腿的完整检查点。 +- BEN2 与高 Alpha 阈值造成的分割缺失、图片中主体本身很小、网格自身产生的支撑间断必须分别展示。原图、BEN2、网格和高度图均保存用于对照。 +- 四十组数值构建均完成并保存。蕨叶在全部数值结果保存后触及单例总流程 240 秒超时,随后从 NPZ 补绘图表;超时不属于表中的单次数值构建耗时。 + +## 验收影响 + +提案尚不能视为已经解决普遍的细分枝连通和性能问题。正式实施需增加蕨叶型多级分枝、羽毛型细丝的合成回归,检查最终内部顶点图与局部零高度边,并在保留细部支撑的前提下处理候选路径数量和几何查询开销。 + +逐图来源、作者、许可、全部指标及对比图保存在本机 `cutout-experiment/case-study/report.md` 与 `sources.json`。完整路径前缀为 `C:/Users/icrdr/.codex/visualizations/2026/09/05/01a07226-3125-7c63-b655-8206da43c177/`。 diff --git a/openspec/changes/improve-cutout-adaptive-mesh/experiment.md b/openspec/changes/improve-cutout-adaptive-mesh/experiment.md new file mode 100644 index 0000000..8a5ea23 --- /dev/null +++ b/openspec/changes/improve-cutout-adaptive-mesh/experiment.md @@ -0,0 +1,40 @@ +# 实验验收基准 + +2026-09-06,本机开发 Python 3.11、NumPy 1.x、Blender bpy 4.5.3,使用已完成 BEN2 抠图的四张样图。每项预热一次,交替测三次取中位数。输入尺寸:头骨 620×444、蜘蛛 476×702、甲虫 465×347、卡通 128×123。 + +## 首次数值构建 + +单位为毫秒,包含过滤、轮廓、结构准备、布点、约束三角化和既有 Poisson 求解;不含 removebg、进程导入、图片读写、指标计算、绘图、Blender 对象创建与节点求值。下表作为同机正式接入的 1.25 倍耗时验收参照,不作为其他硬件或大图的绝对时限。 + +| 图片 | Low | Medium | High | Ultra | +|---|---:|---:|---:|---:| +| 头骨 | 115 | 183 | 314 | 858 | +| 蜘蛛 | 233 | 304 | 647 | 1292 | +| 甲虫 | 60 | 73 | 121 | 275 | +| 卡通 | 19 | 23 | 32 | 51 | + +上一轮蜘蛛 Low / High / Ultra 为约 785 / 965 / 1454 ms;上一轮高密度仍沿用低密度边缘。当前轮廓更精细,因此这是结果质量发生变化的端到端对比。最初正式算法的蜘蛛 Low 约 190 ms、Ultra 约 303 ms,当前仍有额外计算成本。 + +## 轮廓贴合 + +从源轮廓采样到实际网格边界的 P95 距离,单位为源像素。误差测量使用过滤后的原始 Alpha 轮廓,包含高密度平滑带来的偏差。 + +| 图片 | Low | Medium | High | Ultra | +|---|---:|---:|---:|---:| +| 头骨 | 1.612 | 1.024 | 0.650 | 0.590 | +| 蜘蛛 | 1.530 | 0.986 | 0.680 | 0.615 | +| 甲虫 | 1.566 | 0.968 | 0.687 | 0.609 | +| 卡通 | 1.666 | 0.868 | 0.652 | 0.605 | + +High 的正向最大采样距离约 0.98 像素,Ultra 约 0.88 像素以内;反向采样最大距离分别约 0.73 / 0.63 像素以内。采样距离是实验指标,不是连续 Hausdorff 误差的严格上界。 + +## 已验证与正式验收边界 + +- 蜘蛛截图右外侧腿的六个检查位置在间距 4、8、16、28、32、36 像素,以及 Low / High 平移 1 像素时处于同一内部约束图分量;检查点最近距离最高约 10.55 像素。 +- 保留约束覆盖最终实际网格边,端点为内部点且 Balloon 高度为正;不同精度的路径位置与点数允许变化。 +- 含孔洞矩形的内部线段、穿孔、越界、共线接触、零长度内部点和边界点检查通过;随机内外判定与现有函数一致。 +- 半径分组空间索引在四图随机查询上与未索引宽度估计一致;准备结构复用与完整重算的点、面和高度一致。 +- 仍有少量未被选为结构路径的全边界三角形,不能据此声称所有末梢均已获得支撑。正式任务要求补充端部覆盖检查。 +- 当前原型未实现完整的轮廓拓扑重试、操作级缓存身份、Blender 客户端依赖接入和 2K/4K 资源验收,这些在 tasks.md 中保持未完成。 + +独立脚本及测量数据位于本机会话目录:`C:/Users/icrdr/.codex/visualizations/2026/09/05/01a07226-3125-7c63-b655-8206da43c177/cutout-experiment/final/`;原型入口为其上级的 `final.py`。正式自动测试需使用仓库内可复现的合成样本。 diff --git a/openspec/changes/improve-cutout-adaptive-mesh/implementation.md b/openspec/changes/improve-cutout-adaptive-mesh/implementation.md new file mode 100644 index 0000000..6e93cec --- /dev/null +++ b/openspec/changes/improve-cutout-adaptive-mesh/implementation.md @@ -0,0 +1,64 @@ +# 实施验收 + +2026-09-06,Windows、Python 3.11、bpy 4.5.3。Fine Outline 默认开启;关闭调用原有轮廓平滑和均匀布点流程。 + +## 数值构建 + +计时包含已加载 Alpha 的过滤、轮廓、路径、布点、三角化及 Poisson 高度;不含抠图、依赖首次导入、对象创建。四张原样本预热后取三次中位数;复杂蕨叶每档一次,供开销判断。两种模式均使用 32 / 16 / 8 / 4 像素目标间距。 + +| 样本 | 间距 px | 原算法 ms | Fine Outline ms | 原算法点数 | Fine Outline 点数 | +|---|---:|---:|---:|---:|---:| +| skull | 32 | 32.7 | 118.4 | 411 | 486 | +| skull | 16 | 43.2 | 164.4 | 984 | 1112 | +| skull | 8 | 68.7 | 313.5 | 3233 | 3472 | +| skull | 4 | 285.3 | 862.0 | 11983 | 12239 | +| spider | 32 | 195.8 | 235.7 | 1752 | 953 | +| spider | 16 | 211.2 | 310.5 | 2198 | 1736 | +| spider | 8 | 243.3 | 661.7 | 3549 | 3879 | +| spider | 4 | 336.4 | 1303.4 | 9272 | 10076 | +| beetle | 32 | 22.8 | 62.8 | 184 | 268 | +| beetle | 16 | 23.9 | 83.2 | 322 | 459 | +| beetle | 8 | 33.9 | 130.0 | 804 | 1001 | +| beetle | 4 | 56.8 | 274.8 | 2519 | 2797 | +| cartoon | 32 | 3.0 | 20.0 | 16 | 47 | +| cartoon | 16 | 3.1 | 25.2 | 52 | 96 | +| cartoon | 8 | 4.9 | 34.0 | 188 | 243 | +| cartoon | 4 | 11.2 | 61.0 | 702 | 755 | +| fern | 32 | 796.9 | 15637.6 | 4217 | 13946 | +| fern | 16 | 814.1 | 10733.5 | 4861 | 11961 | +| fern | 8 | 2226.1 | 20422.6 | 10546 | 18226 | +| fern | 4 | 2431.2 | 25987.0 | 17748 | 26353 | + +二十组精细网格均无两端处于边界的内部边,所有内部顶点的 Poisson 高度为正。复杂蕨叶 Low 的拓扑重试提高了局部精度,点数可能高于 Medium;Mesh Detail 表示目标间距,实际数量还受轮廓拓扑约束影响。密集分枝使用精细模式仍有明显等待,关闭可获得原算法的概括效果。 + +## 大图与内存 + +合成前景包含 36 个孔洞及多个独立碎片,网格分辨率上限设置为 128。峰值为独立 Python 进程的 PeakWorkingSetSize,包含 bpy 与数值依赖,不等同于单个数组占用。 + +| 分辨率 | 受限间距 px | 耗时 ms | 顶点 | 峰值 MiB | +|---|---:|---:|---:|---:| +| 2048² | 16.0 | 876.9 | 4802 | 470.3 | +| 4096² | 32.0 | 3827.1 | 4820 | 1182.2 | + +网格点数保持在相近规模;源像素中轴计算的时间与内存仍随图像面积增长。两组均通过零高度内部边与正内部高度检查。 + +## 依赖与打包 + +客户端新增 NumPy >=1.26,<2.0、SciPy 1.15.3、scikit-image 0.25.2;uv.lock 已同步。使用项目打包器的 Python 3.11 与平台参数实际下载全部数值 wheel,三平台均成功。 + +| 平台 | 数值 wheel 数 | 压缩体积 MiB | +|---|---:|---:| +| linux | 9 | 77.8 | +| macos | 9 | 61.8 | +| windows | 9 | 76.2 | + +数值 wheel 体积包含传递依赖,不含原有 BlendJob。实际平台 Manifest 由打包器使用解析出的全部 wheel 名称生成,已增加对应测试;源 Manifest 的平台集合保持一致。本次仅做依赖与测试校验。 + +## 自动验证 + +- Cutout 几何、交互、对象、Blender 注册、Manifest 与打包:270 passed、29 subtests passed,退出码 0。 +- 其余全部测试:318 passed、2 xfailed、27 subtests passed,退出码 0。 +- 单进程全量测试的断言结果为 588 passed、2 xfailed、56 subtests passed,但解释器退出阶段出现 Windows 原生访问异常(0xC0000005);原因尚未确定。分组复跑覆盖全部用例且均正常退出。 +- OpenSpec strict validate 通过。 + +新增回归覆盖弯腿、变宽关节、1 像素平移、相邻密度、单像素桥、孔洞、密集分枝、极小岛、阈值与输入不变性、源轮廓双向误差、索引与穷举一致性、均匀化误差、CDT 拆分约束、菜单模式快照,以及关闭模式与原流程逐数组一致。既有对象测试覆盖四种 Shape、TEDDY/Poisson 高度消费和非均匀世界变换。 diff --git a/openspec/changes/improve-cutout-adaptive-mesh/proposal.md b/openspec/changes/improve-cutout-adaptive-mesh/proposal.md new file mode 100644 index 0000000..5b316eb --- /dev/null +++ b/openspec/changes/improve-cutout-adaptive-mesh/proposal.md @@ -0,0 +1,30 @@ +## Why + +Cutout 在低密度下轮廓偏离原图,细腿缺少连续内部顶点时 Balloon 会形成扁平断点。实验还发现,高密度只细分简化直边无法提高贴合度;轮廓精度、内部连通性和计算开销需要一起约束。 + +## What Changes + +- 新增默认开启的 Fine Outline(精细轮廓)。开启使用自适应网格;关闭保留原轮廓平滑、概括和均匀布点算法,适合蕨叶等密集细部。 +- 设置保存在 Scene,打开 Shape 菜单时捕获到 Operator,覆盖本地及 AI 返回后的创建流程。 +- Mesh Detail 同时控制轮廓近似误差和内部目标间距;Low 保留概括,High、Ultra 从源轮廓恢复更精细的形状。 +- 为保留的细长结构构建稀疏、连续的内部约束路径,并在最终网格和 Balloon 高度中验证路径完整性。 +- 固定轮廓与结构路径,均匀化其余内部点;通过局部细分处理边缘与内部的尺寸过渡。 +- 在网格生成前过滤极小独立区域,保留最大区域和与主体相连的细腿。 +- 批量复用边界几何检查,合并路径搜索,限制均匀化采样量并按收敛停止;每次创建依据输入重新准备结构。 +- 增加轮廓误差、跨密度连通性、拓扑质量和首次生成耗时的回归验收。 + +## Capabilities + +### New Capabilities + +- `cutout-adaptive-mesh`: Cutout 的轮廓精度、稀疏内部支撑、约束网格、碎片清理和性能验收。 + +### Modified Capabilities + +## Impact + +- `src/anyimage/operators/cutout_tool/geometry.py` 及 Cutout 几何模块:根据 Fine Outline 选择平面网格算法,复用现有高度求解能力。 +- `src/anyimage/operators/cutout_tool/main.py`、`object.py`:核对各 Shape 的几何输入、像素坐标和高度消费边界。 +- `tests/test_cutout_geometry.py` 及独立性能实验:补充真实问题的合成回归样本和四档密度检查。 +- 客户端数值依赖与打包检查:实验使用 NumPy、SciPy、scikit-image 和 Blender CDT;正式实现需验证 Blender Python 中的可用性与平台 wheel 支持。 +- 沿用 Low / Medium / High / Ultra 的 32 / 16 / 8 / 4 源像素目标间距和现有网格分辨率上限。 diff --git a/openspec/changes/improve-cutout-adaptive-mesh/specs/cutout-adaptive-mesh/spec.md b/openspec/changes/improve-cutout-adaptive-mesh/specs/cutout-adaptive-mesh/spec.md new file mode 100644 index 0000000..46c9feb --- /dev/null +++ b/openspec/changes/improve-cutout-adaptive-mesh/specs/cutout-adaptive-mesh/spec.md @@ -0,0 +1,98 @@ +## ADDED Requirements + +### Requirement: Fine Outline selects the mesh algorithm +系统 SHALL 提供默认开启的 Fine Outline 设置。开启 SHALL 使用精细轮廓和连续内部支撑算法;关闭 SHALL 使用原始轮廓平滑、概括与均匀布点算法。以下精细网格要求适用于开启状态。 + +#### Scenario: User chooses abstraction for a fern +- **WHEN** 用户关闭 Fine Outline 并创建任一 Cutout Shape +- **THEN** 系统使用原算法,Mesh Detail 和 Alpha Threshold 继续有效 + +#### Scenario: New scene uses fine outlines +- **WHEN** Scene 或 Operator 使用默认属性 +- **THEN** Fine Outline 为开启状态,界面在 Mesh Detail 旁提供切换 + +#### Scenario: Settings change during an AI job +- **WHEN** 用户打开 Shape 菜单后修改 Scene 的 Fine Outline +- **THEN** 已发起操作的本地或 AI 返回创建使用菜单捕获的模式 + + +### Requirement: Detail controls actual contour accuracy +系统 SHALL 根据有效 Mesh Detail 同时确定内部目标间距和源轮廓近似精度,保留 Low 的形状概括;High 与 Ultra SHALL 从源轮廓恢复更精细的边缘,而非仅拆分已有直边。系统 SHALL 分别记录相对源轮廓的双向采样误差和网格数量。 + +#### Scenario: Curved outline is refined at higher detail +- **WHEN** 未触及网格上限的弧线样本分别使用 Low、High、Ultra 构建网格 +- **THEN** High 与 Ultra 的实际边界包含恢复曲率的新位置,双向 P95 误差低于 Low,Ultra 在规定数值容差内不劣于 High + +#### Scenario: Source-pixel spacing and mesh limit remain effective +- **WHEN** 用户选择四档 Mesh Detail 或输入触及现有网格上限 +- **THEN** 内部目标间距沿用 32、16、8、4 源像素及上限调整规则,轮廓精度采用相应有效档位 + +### Requirement: Cleanup preserves connected thin structures +系统 SHALL 按独立前景区域面积过滤极小碎片,默认阈值为 16 源像素,保留最大区域。系统 SHALL 保留主体连接的细长结构、有效孔洞及原材质 Alpha。 + +#### Scenario: Tiny islands accompany an insect +- **WHEN** 昆虫主体、与主体相连的单像素细腿和多个小于阈值的独立岛同时存在 +- **THEN** 系统移除极小独立岛,保留主体和细腿,原图片像素保持一致 + +#### Scenario: Entire subject is smaller than the cleanup threshold +- **WHEN** 唯一有效区域小于默认面积阈值 +- **THEN** 最大区域被保留并获得有效网格和内部支撑 + +#### Scenario: Empty selection contains no foreground +- **WHEN** 当前阈值下没有有效前景 +- **THEN** 系统返回现有无有效 Selection 的错误结果,不创建空对象 + +### Requirement: Thin branches retain continuous interior support +系统 SHALL 为过滤后可表示的细长分支提供稀疏连续的内部网格路径,覆盖端部和关节。路径 SHALL 位于有效区域内部,分支连通性 SHALL 在密度变化后维持;已断开的 Alpha 区域独立处理。 + +#### Scenario: Leg changes width at a joint +- **WHEN** 细腿经较宽关节继续延伸,且内部目标间距大于腿宽 +- **THEN** 腿内保持单列内部支撑与连续实际网格边,关节不会因宽度筛选丢失连接 + +#### Scenario: Density and translation change +- **WHEN** 同一弯腿样本在 Low、Medium、High、Ultra 和平移 1 像素后生成 +- **THEN** 各次端部与关节检查位置均由同一内部路径连通,保留路径端点的 Balloon 高度为正 + +#### Scenario: Foreground components are separated by transparency +- **WHEN** 两个细结构之间存在透明间隙 +- **THEN** 系统分别构建各自内部支撑,不跨透明间隙建立连接 + +### Requirement: Constraint validation uses the final mesh +系统 SHALL 在最终三角化结果中验证所需内部约束的实际边覆盖、连通性和内部顶点身份,SHALL 拒绝跨边界、穿孔洞、边界接触和丢失约束的结果。失败 SHALL 触发有效修复或明确错误。 + +#### Scenario: Simplified path would cross a hole +- **WHEN** 路径概括的候选线段穿过孔洞或碰到其边界 +- **THEN** 系统使用有效内部路径重建该段,不能将候选线段直接送入最终网格 + +#### Scenario: Triangulation splits an input constraint +- **WHEN** CDT 将一条内部约束拆分为多个实际网格边 +- **THEN** 系统验证这些边完整覆盖输入约束且首尾相接,而非要求输入边原样保留 + +#### Scenario: Short constraints at large pixel coordinates +- **WHEN** 高分辨率图片的短内部约束经 CDT 发生坐标舍入 +- **THEN** 系统通过原始顶点和边映射验证实际约束链的端点连通,有效约束不因长度舍入误报;真实断线、被过滤的约束边和丢失端点仍报错 + +### Requirement: Ordinary interior sampling remains uniform +系统 SHALL 固定轮廓与稀疏内部路径、均匀化其余内部点,并以局部细分控制边界和内部的尺寸过渡。均匀化 SHALL 有确定的迭代上限和收敛停止条件。 + +#### Scenario: Broad cartoon region accompanies narrow appendages +- **WHEN** 卡通图像包含宽阔中央区域与细耳朵或细腿 +- **THEN** 中央普通点保持接近目标间距,细部使用稀疏内部支撑;通过内部最近邻离散度和邻面尺寸比验收,不能只增加中央点数掩盖不均匀 + +### Requirement: Each creation uses its current inputs +系统 SHALL 在每次创建时根据当前像素、Selection、阈值和有效间距准备结构,计算期间复用分量内的几何查询数组。 + +#### Scenario: Detail or image pixels change +- **WHEN** 用户改变 Mesh Detail 或 Alpha 后再次创建 +- **THEN** 系统从新输入重建轮廓与内部结构 + +### Requirement: Performance is measured with quality checks enabled +系统 SHALL 在四档密度下分别测量两种模式的数值构建与 Blender 创建,保留完整约束检查。基准 SHALL 使用相同已加载 Alpha、固定环境、预热和多次重复,并同时记录点数、轮廓误差、连通性与内存规模。 + +#### Scenario: Optimized implementation is accepted +- **WHEN** 正式实现进行性能验收 +- **THEN** 四张基准图的几何质量通过,记录与原算法的耗时比较及复杂蕨叶的实际开销;首次生成计时包含过滤、轮廓、结构、布点、三角化和高度求解,精细模式不能通过静默切换原算法满足计时要求 + +#### Scenario: Large input reaches the resource limit +- **WHEN** 2K 或 4K 输入包含大量孔洞和独立区域 +- **THEN** 系统验证网格上限、按分量的有界批次与峰值内存,不能使用小图或缓存计时替代该测量 diff --git a/openspec/changes/improve-cutout-adaptive-mesh/tasks.md b/openspec/changes/improve-cutout-adaptive-mesh/tasks.md new file mode 100644 index 0000000..b560616 --- /dev/null +++ b/openspec/changes/improve-cutout-adaptive-mesh/tasks.md @@ -0,0 +1,26 @@ +## 1. 规格与双模式入口 + +- [x] 1.1 修改 proposal、design 和 spec,明确 Fine Outline 默认开启、关闭使用原算法及复杂输入的性能取舍。 +- [x] 1.2 增加共享 Bool 属性、工具设置与菜单快照,覆盖本地和 AI 返回创建。 +- [x] 1.3 接入两种几何算法,复用 UV、世界度量、高度求解及全部 Shape。 + +## 2. 精细网格 + +- [x] 2.1 移植独立实验的轮廓精度、极小独立岛清理及拓扑有效性重试,尊重当前 Alpha 阈值。 +- [x] 2.2 接入宽度索引、连续稀疏路径及 CDT 实际约束验证。 +- [x] 2.3 接入有收敛上限的内部均匀化,并修复最终网格的零高度内部边与无支撑三角形。 +- [x] 2.4 增加弯腿、变宽关节、孔洞、细桥、密集分枝、独立岛与高密度轮廓误差回归。 + +## 3. 运行时与验收 + +- [x] 3.1 声明客户端依赖、同步锁文件,验证三平台 wheel 可用性及体积,检查 Manifest 和打包测试。 +- [x] 3.2 测试默认值、关闭模式、菜单快照、本地与异步入口、世界变换及 Blender 类型注册。 +- [x] 3.3 在四图四档和复杂蕨叶上比较正式实现与原算法,记录数值耗时、顶点和支撑检查。 +- [x] 3.4 测量 2K/4K 合成输入的耗时、峰值内存与网格上限。 +- [x] 3.5 运行 Cutout、交互、对象、依赖打包相关测试及完整测试,验证现有 Shape 的节点消费。 + +## 4. SciPy 依赖精简与大坐标约束校验 + +- [x] 4.1 接入距离脊宽度采样和 NumPy 轮廓简化,保留许可证,移除 scikit-image 并同步锁文件和本地 wheels。 +- [x] 4.2 用实际约束边的端点连通验证替代长度误差判断,覆盖大坐标短边与真实断线回归。 +- [x] 4.3 验证典型图片、高分辨率输入、打包和 Cutout 相关回归。 diff --git a/openspec/changes/isolate-shared-empty-image-edits/.openspec.yaml b/openspec/changes/isolate-shared-empty-image-edits/.openspec.yaml new file mode 100644 index 0000000..1a62d62 --- /dev/null +++ b/openspec/changes/isolate-shared-empty-image-edits/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-06 diff --git a/openspec/changes/isolate-shared-empty-image-edits/design.md b/openspec/changes/isolate-shared-empty-image-edits/design.md new file mode 100644 index 0000000..0aab8d1 --- /dev/null +++ b/openspec/changes/isolate-shared-empty-image-edits/design.md @@ -0,0 +1,63 @@ +## Context + +`create_image_edit_result()` 和 Job 编辑结果加载入口先准备结果 Image,`replace_empty_image()` 再替换物体引用。当前仅在旧 Image 没有真实用户且没有 Fake User 时删除旧图并恢复源名。Frame 在替换活动物体之后删除合并源物体,随后再次清理图片。 + +## Goals + +- 以其他 Empty Image 的真实引用决定结果是否独立。 +- 共享编辑保护其他 Empty 的数据、显示和动画状态。 +- 单独使用时保留源 Image 身份和完整名称。 +- 统一结果提交、失败恢复与临时数据清理。 + +## Decisions + +### 1. 按文件中的物体引用判断共享 + +在 `common/image.py` 复用 `is_image_empty()`,遍历 `bpy.data.objects`,检查当前物体以外是否存在 `data` 等于源 Image 的图片 Empty。比较 Blender 数据块身份;找到首个匹配即可结束。隐藏、未在当前 View Layer 显示、其他场景中的对象均参与判断。同一物体链接到多个集合仍是一个对象。 + +扫描在主线程最终提交时执行,使异步 Job 运行期间新增的共享物体也得到保护。Frame 提供本次成功后将删除的源物体集合,判断时排除这些物体。 + +`Image.users` 仅继续承担无引用数据清理等生命周期判断。Fake User 是保存设置,材质引用是图片消费者,两者均不代表另一个 Empty Image。 + +### 2. 结果先准备,按共享关系提交 + +```mermaid +flowchart TD + A[准备完整编辑结果] --> B[查找操作后仍保留的其他图片 Empty] + B --> C{是否共用源 Image} + C -->|是| D[当前 Empty 绑定独立结果] + C -->|否| E[将结果内容提交到源 Image] + D --> F[保留其他 Empty 与源图状态] + E --> G[保留源图身份与名称并释放临时结果] +``` + +共享分支沿用结果 Image 的创建、打包与 Blender 唯一名称规则,仅改变当前物体绑定。单独使用分支将准备好的结果内容写入源 Image,保留源名称和 Fake User 设置,统一入口返回最终实际使用的 Image。临时结果不作为额外的最终图片保留。 + +提交需覆盖像素尺寸、颜色空间、Alpha、Packed 内容、文件路径及图片类型;静态像素结果与动画 Job 结果按各自的数据表示处理。继续复用现有像素和动画辅助函数。调用方使用返回的最终 Image,避免保留已释放的临时结果引用。 + +AnyImage 材质贴图使用独立的 `_color.png` 等数据。用户手动引用源 Image 时,其引用关系保持原样;单独使用分支原位更新产生的内容变化按普通数据共享处理,不额外遍历或迁移材质引用。 + +### 3. Frame 使用提交后的存活物体集合 + +Frame 以活动物体为编辑目标,其他本次合并源物体按既有语义删除。只有合并之外仍保留的 Empty 共用活动源图时才分离。提交成功之后删除合并源物体,再清理其确实没有用户的旧图;活动结果名称由统一提交入口负责。 + +### 4. 提交具有失败恢复边界 + +完整结果准备好后才修改源数据。共享分支保留现有物体绑定、显示位置和动画设置恢复;原位分支还需保留可恢复的源内容、尺寸、编码及打包状态,直到提交成功。恢复数据应复用现有图片读取与加载能力,在成功和失败时均释放临时资源。取消编辑时不进入提交。 + +## Risks / Trade-offs + +- [原位写入需要同时维护像素、Packed 数据与动画来源] → 通过真实 Blender 数据测试覆盖静态图片、尺寸变化、序列结果及保存重开,验证结果内容实际持久化。 +- [中途失败留下部分写入] → 对内容写入和物体显示配置分别注入失败,验证源图及物体状态恢复。 +- [Frame 的待删除物体导致错误分离] → 测试所有共享者均被合并、合并外仍有共享者两种情况。 +- [已有未归档图片命名规格包含总用户数语义] → 本 change 明确 Empty 共享判定;归档整合时以本规则统一相关要求。 + +## Migration Plan + +1. 增加真实 Blender 引用关系与结果提交回归用例。 +2. 修改公共提交入口,并核对所有本地编辑与 Job 调用方的返回值和清理职责。 +3. 整合 Frame 存活物体判断,运行相关测试和完整测试。 + +## Open Questions + +无产品规则待确认;原位提交对静态与动画 Image 的具体写入步骤通过实现阶段的真实 Blender 回归验证。 diff --git a/openspec/changes/isolate-shared-empty-image-edits/proposal.md b/openspec/changes/isolate-shared-empty-image-edits/proposal.md new file mode 100644 index 0000000..4da9bcb --- /dev/null +++ b/openspec/changes/isolate-shared-empty-image-edits/proposal.md @@ -0,0 +1,26 @@ +## Why + +图片编辑目前通过旧 Image 的总引用数决定是否保留旧数据,无法直接表达用户关心的其他 Empty Image 是否共用原图。需要以真实物体引用关系决定图片分离,使编辑当前 Empty 时其他共用原图的 Empty 保持原样。 + +## What Changes + +- 在整个文件中查找引用同一 Image 的其他 Empty Image,以此决定编辑结果是否需要独立数据块。 +- 存在其他 Empty Image 时,仅将当前物体切换到结果 Image;不存在时更新当前 Image,保留身份与完整名称。 +- 隐藏物体和其他场景中的 Empty Image 同样参与判断;Frame 按合并后仍保留的物体判断共享。 +- 统一本地像素编辑与 Job 结果提交,保留成功、取消和失败时的图片及物体状态约定。 + +## Capabilities + +### New Capabilities + +- `empty-image-edit-isolation`: 定义以 Empty Image 引用关系为依据的编辑分离、单独使用时的更新、Frame 合并及失败恢复规则。 + +### Modified Capabilities + +无。当前 `openspec/specs/` 为空;本能力承接已有 change 中的图片编辑命名与替换约定。 + +## Impact + +- `src/anyimage/common/image.py`:共享判断与统一结果提交入口。 +- `src/anyimage/operators/image_edit_tool/` 及调用图片替换入口的 Job 响应:结果提交与 Frame 合并。 +- `tests/test_image_data.py`、`tests/test_alpha_presentation.py`、`tests/test_blender_addon.py` 及相关图片编辑测试。 diff --git a/openspec/changes/isolate-shared-empty-image-edits/specs/empty-image-edit-isolation/spec.md b/openspec/changes/isolate-shared-empty-image-edits/specs/empty-image-edit-isolation/spec.md new file mode 100644 index 0000000..a64ab9d --- /dev/null +++ b/openspec/changes/isolate-shared-empty-image-edits/specs/empty-image-edit-isolation/spec.md @@ -0,0 +1,72 @@ +## ADDED Requirements + +### Requirement: Sharing is determined by other image Empty objects +系统 SHALL 在编辑结果提交时,以整个文件中是否存在其他保留的 Empty Image 引用同一源 Image 判断共享,覆盖隐藏物体及其他场景中的物体。判断 SHALL 使用物体与数据块身份,且 SHALL NOT 使用总引用数、Fake User 或界面计数显示作为分离依据。 + +#### Scenario: A hidden object shares the source +- **WHEN** 当前物体与一个隐藏的 Empty Image 引用同一 Image,且界面没有显示引用数量 +- **THEN** 系统识别为共享图片 + +#### Scenario: An object in another scene shares the source +- **WHEN** 另一场景中的独立 Empty Image 引用当前源 Image +- **THEN** 系统识别为共享图片 + +#### Scenario: One object is linked to multiple collections +- **WHEN** 仅当前 Empty 引用源 Image,且该物体链接到多个集合 +- **THEN** 系统按单独使用处理 + +#### Scenario: Non-Empty references exist +- **WHEN** 仅当前 Empty Image 引用源图,源图另有 Fake User 或用户手动建立的材质引用 +- **THEN** 系统按单独使用处理 + +#### Scenario: Sharing changes during a job +- **WHEN** Job 提交后、结果应用前,另一个 Empty Image 开始引用源图 +- **THEN** 最终提交按共享图片处理 + +### Requirement: Shared edits update only the current Empty +存在其他保留的 Empty Image 共用源图时,系统 SHALL 仅将当前物体绑定到独立编辑结果,并保持其他 Empty 的 Image 绑定、原图内容和显示状态。结果 SHALL 请求源 Image 的完整名称,由 Blender 处理唯一性后缀。 + +#### Scenario: Two Empty objects share one image +- **WHEN** A 与 B 共用同一 Image,用户编辑 A +- **THEN** A 使用编辑后的独立 Image,B 仍引用原 Image,原图像素、Packed 内容、名称及 B 的显示和动画设置保持原样 + +### Requirement: A sole image Empty retains its Image identity +没有其他保留的 Empty Image 共用源图时,系统 SHALL 将编辑结果提交到源 Image,保留数据块身份、完整名称与 Fake User 设置,且成功后 SHALL 释放本次临时结果数据。用户手动建立的其他引用 SHALL 保持原绑定。 + +#### Scenario: A sole Empty is edited +- **WHEN** 唯一使用源图的 Empty 完成图片编辑 +- **THEN** 其 Image 身份和完整名称保持不变,内容及尺寸更新为结果,保存重开后内容正确,且不保留额外结果 Image + +#### Scenario: A user manually references the source in a material +- **WHEN** 唯一 Empty 的源图另被用户手动用于材质 +- **THEN** 编辑按单独使用提交,材质引用仍绑定同一 Image + +#### Scenario: An animated job result is committed +- **WHEN** 单独使用的 Empty 收到动画图片编辑结果 +- **THEN** 源 Image 身份和名称保持不变,帧来源及打包结果更新正确,物体动画播放设置按现有工具语义配置 + +### Requirement: Frame considers surviving Empty objects +Frame SHALL 按成功合并后仍保留的 Empty Image 判断活动源图是否共享,并在结果提交成功后执行合并源物体删除。 + +#### Scenario: All other sharing objects are merged +- **WHEN** A 与 B 共用源图,Frame 将 B 合并到活动物体 A,且没有其他 Empty 使用源图 +- **THEN** A 的源 Image 原位更新,B 按合并规则删除 + +#### Scenario: A sharing object survives outside the merge +- **WHEN** Frame 将 B 合并到 A,合并之外的 C 仍引用 A 的源图 +- **THEN** A 使用独立结果,C 及其源 Image 保持原样 + +### Requirement: Failed or cancelled edits preserve source state +统一提交入口 SHALL 覆盖本地编辑与 Job 图片编辑。取消或提交失败时,系统 SHALL 保留或恢复源 Image 内容与身份、物体绑定、显示及动画设置,并释放本次临时资源。 + +#### Scenario: Sole-image commit fails +- **WHEN** 原位提交在写入内容或配置显示期间失败 +- **THEN** 源图内容、尺寸、Packed 数据及物体状态恢复到提交前状态,临时结果被清理 + +#### Scenario: Shared-image commit fails +- **WHEN** 共用图片的当前 Empty 提交失败 +- **THEN** 当前物体恢复原绑定,其他 Empty 及原图保持原样,临时结果被清理 + +#### Scenario: An edit is cancelled +- **WHEN** 用户预览编辑后取消 +- **THEN** 原图和所有 Empty 的绑定及显示状态保持原样 diff --git a/openspec/changes/isolate-shared-empty-image-edits/tasks.md b/openspec/changes/isolate-shared-empty-image-edits/tasks.md new file mode 100644 index 0000000..91a032a --- /dev/null +++ b/openspec/changes/isolate-shared-empty-image-edits/tasks.md @@ -0,0 +1,22 @@ +## 1. 固定共享判断与身份约定 + +- [x] 1.1 增加真实 Blender 数据测试,覆盖两个 Empty 共享、隐藏共享者、跨场景共享者及同一物体链接多个集合,按数据块身份验证判断结果。 +- [x] 1.2 增加单独使用回归,覆盖普通图片、Fake User、手动材质引用,验证结果内容、源 Image 身份、完整名称及临时 Image 数量。 +- [x] 1.3 在 `common/image.py` 复用 `is_image_empty()` 实现其他存活 Empty 的引用查找,支持 Frame 排除待删除对象,并验证提交时重新判断共享。 + +## 2. 统一图片编辑提交 + +- [x] 2.1 调整公共结果提交入口,共享分支只更新当前物体绑定,验证其他 Empty 与原图像素、打包及动画状态保持不变。 +- [x] 2.2 实现单独使用时的静态结果原位提交及临时结果释放,覆盖尺寸变化、Alpha、颜色空间、Packed 内容、名称和 Fake User 保留。 +- [x] 2.3 实现动画 Job 结果原位提交,验证帧来源、打包内容、播放设置及保存重开;复用现有图片与动画辅助函数。 +- [x] 2.4 为内容写入和显示配置注入失败,完善源图内容及物体状态恢复,验证失败与取消不残留临时数据。 + +## 3. 整合工具与 Frame + +- [x] 3.1 检查全部 `replace_empty_image()` 调用方,包括 Mask、Rectify、Frame、Remove Background 和 Upscale,使用最终返回 Image 并统一临时结果清理职责。 +- [x] 3.2 Frame 按合并后存活对象判断共享,移除重复命名处理,覆盖共享者全部参与合并及外部共享者保留两种情况。 + +## 4. 验证 + +- [x] 4.1 运行相关图片数据、Alpha、图片编辑及 Operator 回归,验证连续编辑、保存重开、共享保护和异常恢复。 +- [x] 4.2 运行 `uv run pytest`,确认全部测试通过。 diff --git a/pyproject.toml b/pyproject.toml index e12dac8..a5e5b45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Fast image-guided structure building tools for Blender" readme = "README.md" requires-python = ">=3.11,<3.12" license = "GPL-3.0-or-later" -dependencies = ["blendjob==0.1.16"] +dependencies = ["blendjob==0.1.16", "numpy>=1.26,<2.0", "scipy==1.15.3"] [dependency-groups] dev = [ @@ -16,7 +16,6 @@ dev = [ "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", diff --git a/src/anyimage/blender_manifest.toml b/src/anyimage/blender_manifest.toml index 88b6659..b44cf12 100644 --- a/src/anyimage/blender_manifest.toml +++ b/src/anyimage/blender_manifest.toml @@ -8,9 +8,9 @@ maintainer = "OmooLab" type = "add-on" blender_version_min = "4.5.0" -platforms = ["windows-x64", "macos-arm64", "linux-x64"] +platforms = ["windows-x64"] +wheels = ["./wheels/blendjob-0.1.16-py3-none-any.whl", "./wheels/scipy-1.15.3-cp311-cp311-win_amd64.whl", "./wheels/scipy-1.15.3-cp313-cp313-win_amd64.whl"] license = ["SPDX:GPL-3.0-or-later"] -wheels = ["./wheels/blendjob-0.1.16-py3-none-any.whl"] [permissions] network = "Access the local Job Server and download environment dependencies and model weights" diff --git a/src/anyimage/common/image.py b/src/anyimage/common/image.py index 10e250c..ccc8eb2 100644 --- a/src/anyimage/common/image.py +++ b/src/anyimage/common/image.py @@ -340,7 +340,7 @@ def image_path(image): filepath_from_user = getattr(image, "filepath_from_user", None) filepath = ( filepath_from_user() - if callable(filepath_from_user) + if callable(filepath_from_user) and getattr(image, "source", None) != "SEQUENCE" else getattr(image, "filepath", "") ) if not filepath: @@ -558,7 +558,7 @@ def configure_image_user( def pack_sequence_if_possible(image, expected_frames): """Pack a frame sequence when Blender supports it; otherwise keep external files.""" - if expected_frames <= 1: + if expected_frames <= 1 or not image.has_data: return False try: image.pack() @@ -655,71 +655,149 @@ def create_image_edit_result(source_image, pixels, output_size): return image -def replace_empty_image(source_object, result_image, *, placement_bounds=None): +def has_other_image_empty(source_object, *, removed_objects=()): + """Check surviving image Empty objects for the same Image datablock.""" + return any( + candidate != source_object + and candidate not in removed_objects + and is_image_empty(candidate) + and candidate.data == source_object.data + for candidate in bpy.data.objects + ) + + +def image_content_state(image): + """Capture encoded content and unsaved pixels for an Image transaction.""" + import numpy as np + + pixels = None + if image.is_dirty: + pixels = np.empty(len(image.pixels), dtype=np.float32) + image.pixels.foreach_get(pixels) + return { + "source": image.source, + "filepath": image.filepath_raw, + "colorspace": image.colorspace_settings.name, + "alpha_mode": image.alpha_mode, + "file_format": image.file_format, + "packed": bytes(image.packed_file.data) if image.packed_file else None, + "pixels": pixels, + "size": tuple(image.size), + "generated": { + name: getattr(image, name) + for name in ("generated_width", "generated_height", "generated_type", + "use_generated_float") + }, + "generated_color": tuple(image.generated_color), + } + + +def restore_image_content(image, state): + """Apply captured image content while retaining the destination ID.""" + if image.packed_file: + image.source = "FILE" + image.unpack(method="REMOVE") + image.filepath_raw = state["filepath"] + image.source = state["source"] + if state["source"] == "GENERATED": + for name, value in state["generated"].items(): + setattr(image, name, value) + image.generated_color = state["generated_color"] + image.colorspace_settings.name = state["colorspace"] + image.alpha_mode = state["alpha_mode"] + image.file_format = state["file_format"] + if state["packed"] is not None: + data = state["packed"] + image.source = "FILE" + image.pack(data=data, data_len=len(data)) + image.source = state["source"] + image.reload() + if state["pixels"] is not None: + if tuple(image.size) != state["size"]: + image.scale(*state["size"]) + image.pixels.foreach_set(state["pixels"]) + image.update() + + +def replace_empty_image(source_object, result_image, *, placement_bounds=None, + removed_objects=()): """Replace one Empty image, retaining shared source images.""" - source_image = source_object.data - source_name = source_image.name - original_fake_user = source_image.use_fake_user - original_blending = getattr(source_object, "use_empty_image_alpha", False) - image_user = getattr(source_object, "image_user", None) - original_timing = image_user_settings(source_object) if image_user is not None else None - frame_count = ( - video_total_frames(result_image) - if is_animated_image(result_image) - else None - ) - alignment = ( - empty_display_alignment( - source_object, - tuple(source_image.size), - tuple(result_image.size), - placement_bounds, - ) - if placement_bounds is not None - else None - ) - if frame_count is not None: - ( - original_frame_start, - _original_frame_offset, - _original_frame_duration, - original_use_cyclic, - ) = image_user_settings(source_object) - - source_image.use_fake_user = True - original_alignment = ( - (source_object.empty_display_size, tuple(source_object.empty_image_offset)) - if alignment is not None else None - ) try: - source_object.data = result_image - source_object.use_empty_image_alpha = True - if frame_count is not None: - configure_image_user( - image_user, - frame_count, - frame_start=original_frame_start, - use_cyclic=original_use_cyclic, + source_image = source_object.data + original_fake_user = source_image.use_fake_user + original_blending = getattr(source_object, "use_empty_image_alpha", False) + image_user = getattr(source_object, "image_user", None) + original_timing = image_user_settings(source_object) if image_user is not None else None + original_auto_refresh = getattr(image_user, "use_auto_refresh", False) + frame_count = ( + video_total_frames(result_image) + if is_animated_image(result_image) + else None + ) + alignment = ( + empty_display_alignment( + source_object, + tuple(source_image.size), + tuple(result_image.size), + placement_bounds, ) - if alignment is not None: - source_object.empty_display_size = alignment[0] - source_object.empty_image_offset = alignment[1] - except Exception: - source_object.data = source_image - source_object.use_empty_image_alpha = original_blending - if original_alignment is not None: - source_object.empty_display_size, source_object.empty_image_offset = original_alignment - if original_timing is not None: - (image_user.frame_start, image_user.frame_offset, - image_user.frame_duration, image_user.use_cyclic) = original_timing - source_image.use_fake_user = original_fake_user - raise + if placement_bounds is not None + else None + ) + if frame_count is not None: + ( + original_frame_start, + _original_frame_offset, + _original_frame_duration, + original_use_cyclic, + ) = image_user_settings(source_object) - source_image.use_fake_user = original_fake_user - if not original_fake_user and int(source_image.users) == 0: - bpy.data.images.remove(source_image, do_unlink=True) - result_image.name = source_name - return result_image + source_image.use_fake_user = True + original_alignment = ( + (source_object.empty_display_size, tuple(source_object.empty_image_offset)) + if alignment is not None else None + ) + original_content = None + try: + shared = has_other_image_empty(source_object, removed_objects=removed_objects) + if shared: + final_image = result_image + else: + result_content = image_content_state(result_image) + original_content = image_content_state(source_image) + restore_image_content(source_image, result_content) + final_image = source_image + source_object.data = final_image + source_object.use_empty_image_alpha = True + if frame_count is not None: + configure_image_user( + image_user, + frame_count, + frame_start=original_frame_start, + use_cyclic=original_use_cyclic, + ) + if alignment is not None: + source_object.empty_display_size = alignment[0] + source_object.empty_image_offset = alignment[1] + except Exception: + if original_content is not None: + restore_image_content(source_image, original_content) + source_object.data = source_image + source_object.use_empty_image_alpha = original_blending + if original_alignment is not None: + source_object.empty_display_size, source_object.empty_image_offset = original_alignment + if original_timing is not None: + (image_user.frame_start, image_user.frame_offset, + image_user.frame_duration, image_user.use_cyclic) = original_timing + image_user.use_auto_refresh = original_auto_refresh + source_image.use_fake_user = original_fake_user + raise + + source_image.use_fake_user = original_fake_user + return final_image + finally: + if result_image.users == 0: + bpy.data.images.remove(result_image) def _load_packed_result_image( diff --git a/src/anyimage/common/material.py b/src/anyimage/common/material.py index da5f3e2..9306641 100644 --- a/src/anyimage/common/material.py +++ b/src/anyimage/common/material.py @@ -2,8 +2,8 @@ import bpy +from ..preferences import configured_material_view_adaptation from .image import ( - configure_color_alpha, configure_image_user, image_base_name, image_pixels, @@ -36,31 +36,51 @@ def material_color_space_name(scene): return "sRGB" -def set_material_color_space(image, scene=None): +def configure_material_color_image(image, scene=None): + """Configure an owned material Color image using the current shading preference.""" if scene is None: scene = getattr(bpy.context, "scene", None) - settings = image.colorspace_settings - preferred = material_color_space_name(scene) - original = settings.name - pixels = None - if preferred != original and ( - getattr(image, "source", None) == "GENERATED" + preferred = ( + material_color_space_name(scene) + if configured_material_view_adaptation() + else "sRGB" + ) + pixels = ( + image_pixels(image) + if getattr(image, "source", None) == "GENERATED" or getattr(image, "is_dirty", False) - ): - pixels = image_pixels(image) - for color_space in dict.fromkeys((preferred, "sRGB", original)): - if not color_space: - continue - try: - settings.name = color_space - except (TypeError, ValueError): - continue - if settings.name == color_space: + else None + ) + shared = image.users > int(image.use_fake_user) + result = image.copy() if shared else image + settings = result.colorspace_settings + original_space = settings.name + original_alpha = result.alpha_mode + try: + for color_space in dict.fromkeys((preferred, "sRGB")): + try: + settings.name = color_space + except (TypeError, ValueError): + continue + if settings.name == color_space: + break + else: + raise RuntimeError("Unable to configure the material color space") + result.alpha_mode = "PREMUL" if settings.name == "sRGB" else "STRAIGHT" + if pixels is not None: + result.pixels.foreach_set(pixels) + result.update() + except Exception: + if shared: + bpy.data.images.remove(result) + else: + settings.name = original_space + result.alpha_mode = original_alpha if pixels is not None: - image.pixels.foreach_set(pixels) - image.update() - return color_space - return settings.name + result.pixels.foreach_set(pixels) + result.update() + raise + return result def material_node_group(depth_plane=False): @@ -92,6 +112,7 @@ def create_image_material( ior=None, scene=None, ): + material_image = configure_material_color_image(color_image, scene) material = bpy.data.materials.new(image_base_name(source_object.data)) material.use_nodes = True if hasattr(material, "surface_render_method"): @@ -105,9 +126,6 @@ def create_image_material( uv_map.uv_map = "UVMap" uv_map.location = (-1250.0, 350.0) - material_image = color_image - set_material_color_space(material_image, scene) - configure_color_alpha(material_image) color_texture.image = material_image color_texture.interpolation = "Linear" color_texture.extension = "EXTEND" diff --git a/src/anyimage/licenses/scikit-image.txt b/src/anyimage/licenses/scikit-image.txt new file mode 100644 index 0000000..4e0e32b --- /dev/null +++ b/src/anyimage/licenses/scikit-image.txt @@ -0,0 +1,29 @@ +approximate_polygon from scikit-image 0.25.2, skimage/measure/_polygon.py +Copyright: 2009-2022 the scikit-image team + +License: BSD-3-Clause + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the University nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. +. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE HOLDERS OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/src/anyimage/operators/clipboard_image/actions.py b/src/anyimage/operators/clipboard_image/actions.py index a4e1371..fe6e2e3 100644 --- a/src/anyimage/operators/clipboard_image/actions.py +++ b/src/anyimage/operators/clipboard_image/actions.py @@ -4,6 +4,7 @@ import tempfile import bpy +from ...common.material import configure_material_color_image CLIPBOARD_HASH_PROPERTY = "anyimage_clipboard_sha256" CLIPBOARD_TEXTURE_PROPERTY = "anyimage_clipboard_texture" @@ -149,7 +150,7 @@ def add_image_plane( from ..convert_to_plane.mesh_plane import create_mesh_plane_object half_width, half_height = _plane_half_dimensions(width, height) - material = _create_image_material(image, shadeless) + material = _create_image_material(image, shadeless, scene=context.scene) plane = create_mesh_plane_object( context, image.name, @@ -197,7 +198,8 @@ def _plane_half_dimensions(width, height): return width / height, 1.0 -def _create_image_material(image, shadeless=False): +def _create_image_material(image, shadeless=False, *, scene=None): + material_image = configure_material_color_image(image, scene) material = bpy.data.materials.new(name=f"{image.name} Material") material.use_nodes = True if hasattr(material, "surface_render_method"): @@ -206,7 +208,7 @@ def _create_image_material(image, shadeless=False): nodes = material.node_tree.nodes nodes.clear() image_node = nodes.new("ShaderNodeTexImage") - image_node.image = image + image_node.image = material_image image_node.location = (-620.0, 80.0) output_node = nodes.new("ShaderNodeOutputMaterial") diff --git a/src/anyimage/operators/cutout_tool/fine_mesh.py b/src/anyimage/operators/cutout_tool/fine_mesh.py new file mode 100644 index 0000000..91924b6 --- /dev/null +++ b/src/anyimage/operators/cutout_tool/fine_mesh.py @@ -0,0 +1,621 @@ +"""Fine Cutout contours, sparse constrained support and interior sampling.""" + +import heapq + +import numpy as np +from mathutils import Vector +from mathutils.geometry import delaunay_2d_cdt +from scipy import ndimage +from scipy.spatial import cKDTree +from .polygon import approximate_polygon + +from . import geometry as g + + +def valid_contours(contours): + """Reject crossing rings and holes moved outside the outer ring.""" + domain = ContourDomain(contours) + count = len(domain.starts) + following = np.concatenate( + [ + np.roll(np.arange(len(contour)) + sum(map(len, contours[:index])), -1) + for index, contour in enumerate(contours) + ] + ) + for first in range(0, count, 32): + ids = np.arange(first, min(first + 32, count))[:, None] + other = np.arange(count)[None, :] + a = domain.starts[first : first + 32, None] + v = domain.delta[first : first + 32, None] + w = domain.delta[None] + d = domain.starts[None] - a + determinant = v[..., 0] * w[..., 1] - v[..., 1] * w[..., 0] + divisor = np.where(np.abs(determinant) > 1e-12, determinant, 1.0) + t = (d[..., 0] * w[..., 1] - d[..., 1] * w[..., 0]) / divisor + u = (d[..., 0] * v[..., 1] - d[..., 1] * v[..., 0]) / divisor + adjacent = (other == following[ids]) | (ids == following[other]) + if np.any( + (other > ids) + & ~adjacent + & (np.abs(determinant) > 1e-12) + & (t > 1e-9) + & (t < 1 - 1e-9) + & (u > 1e-9) + & (u < 1 - 1e-9) + ): + return False + return all( + g.points_inside_contours(hole[:1], contours[:1])[0] for hole in contours[1:] + ) + + +def support_flat_regions(points, faces): + """Split interior boundary chords so inflated faces remain connected.""" + boundary = g.boundary_vertices_from_faces(len(points), faces) + edges = np.sort(np.concatenate((faces[:, :2], faces[:, 1:], faces[:, ::2])), axis=1) + unique, counts = np.unique(edges, axis=0, return_counts=True) + chords = unique[(counts == 2) & boundary[unique].all(axis=1)] + midpoints = {tuple(edge): len(points) + index for index, edge in enumerate(chords)} + vertices = points.tolist() + points[chords].mean(axis=1).tolist() + triangles = [] + for face in faces: + ring = [] + for index, start in enumerate(face): + end = face[(index + 1) % 3] + ring.append(int(start)) + midpoint = midpoints.get(tuple(sorted((int(start), int(end))))) + if midpoint is not None: + ring.append(midpoint) + if len(ring) > 3 or boundary[face].all(): + center = len(vertices) + vertices.append(points[face].mean(axis=0).tolist()) + triangles.extend( + (center, start, ring[(index + 1) % len(ring)]) + for index, start in enumerate(ring) + ) + else: + triangles.append(tuple(face)) + return np.asarray(vertices), np.asarray(triangles, dtype=np.int32) + + +def build_fine_mesh(alpha, spacing, threshold): + """Return pixel-space triangles and component ranges for height solving.""" + alpha = np.asarray(alpha, dtype=float) + if alpha.ndim != 2 or not alpha.size or not np.isfinite(alpha).all(): + raise ValueError("The Cutout Alpha must be a finite two-dimensional array") + if not np.isfinite(spacing) or spacing <= 0: + raise ValueError("Cutout point spacing must be greater than zero") + filtered, _statistics = filter_alpha(alpha, threshold) + mask = (filtered > 0) & (filtered >= threshold) + if not mask.any(): + raise ValueError("The Cutout selection contains no visible image pixels") + reference = g.extract_selection_contours( + filtered, max_resolution=max(mask.shape), threshold=threshold + ) + width_sizes = WidthSamples(mask) + points, faces, components = [], [], [] + offset = 0 + for component in reference.components: + contours = contours_for_component(component, spacing) + spine, path_edges = interior_paths( + contours, min(32, max(mask.shape) / 6), width_sizes + ) + contours, spine, path_edges = subdivide_constraints( + contours, spine, path_edges, spacing + ) + free = relax_points(contours, spine, mask.shape, spacing) + vertices, triangles, constraints = triangulate( + contours, spine, path_edges, free, spacing + ) + if not len(triangles): + raise ValueError( + "Fine Outline could not triangulate a foreground component" + ) + vertices, triangles = support_flat_regions(vertices, triangles) + boundary = g.boundary_vertices_from_faces(len(vertices), triangles) + if boundary[constraints].any(): + raise ValueError( + "Fine Outline placed an interior constraint on the boundary" + ) + points.append(vertices) + faces.append(triangles + offset) + components.append((offset, offset + len(vertices), triangles, contours[0])) + offset += len(vertices) + points, faces = np.concatenate(points), np.concatenate(faces) + return points, faces, g.boundary_vertices_from_faces(len(points), faces), components + + +def clean_mesh(points, faces): + faces = np.asarray(faces, dtype=np.int32).reshape(-1, 3) + triangles = points[faces] + area = np.abs( + np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0]) + ) + faces = faces[area > 1e-8] + used = np.unique(faces) + remap = np.full(len(points), -1, dtype=np.int32) + remap[used] = np.arange(len(used)) + return points[used], remap[faces] + + +def filter_alpha(alpha, threshold, min_area=16): + mask = (alpha > 0) & (alpha >= threshold) + labels, count = ndimage.label(mask) + areas = np.bincount(labels.ravel(), minlength=count + 1) + keep = areas >= min_area + keep[0] = False + if count: + keep[1 + np.argmax(areas[1:])] = True + removed = mask & ~keep[labels] + result = alpha.copy() + result[removed] = 0 + return ( + result, + { + "components_before": count, + "components_after": int(keep.sum()), + "removed_pixels": int(removed.sum()), + "foreground_pixels": int(mask.sum()), + }, + ) + + +def width_ridge_samples(image): + """Supply ridge disks for width estimation, not a connected skeleton.""" + distance = ndimage.distance_transform_edt(image) + ridge = np.zeros(image.shape, dtype=bool) + dominated = np.zeros(image.shape, dtype=bool) + for dy, dx in ((0, 1), (1, 0), (1, 1), (1, -1)): + before = np.roll(distance, (dy, dx), axis=(0, 1)) + after = np.roll(distance, (-dy, -dx), axis=(0, 1)) + ridge |= ( + (distance >= before) + & (distance >= after) + & ((distance > before) | (distance > after)) + ) + dominated |= np.maximum(before, after) >= distance + np.hypot(dx, dy) - 1e-9 + ridge &= image & ~dominated + return ridge, distance + + +class WidthSamples: + """Estimate local width from indexed distance-ridge disks.""" + + def __init__(self, mask): + ridge, distance = width_ridge_samples(np.pad(mask, 1)) + ridge = ridge[1:-1, 1:-1] + self.distance = np.maximum(distance[1:-1, 1:-1] - 0.5, 0.3) + yy, xx = np.nonzero(ridge) + self.centers = np.column_stack((xx + 0.5, yy + 0.5)) + self.radius = self.distance[yy, xx] + self.reach_squared = (self.radius + 0.75) ** 2 + buckets = np.floor(np.log2(self.radius)).astype(int) + self.groups = [] + for bucket in np.unique(buckets)[::-1]: + ids = np.flatnonzero(buckets == bucket) + self.groups.append( + (ids, cKDTree(self.centers[ids]), float(self.radius[ids].max() + 0.75)) + ) + + def __call__(self, points): + radius = np.full(len(points), 0.5) + pending = np.arange(len(points)) + for ids, tree, reach in self.groups: + if not len(pending): + break + neighbors = tree.query_ball_point(points[pending], reach) + solved = np.zeros(len(pending), dtype=bool) + for index, local in enumerate(neighbors): + if not len(local): + continue + candidates = ids[np.asarray(local)] + delta = self.centers[candidates] - points[pending[index]] + covered = ( + np.sum(delta * delta, axis=1) <= self.reach_squared[candidates] + ) + if covered.any(): + radius[pending[index]] = max( + 0.5, float(self.radius[candidates[covered]].max()) + ) + solved[index] = True + pending = pending[~solved] + distance = ndimage.map_coordinates( + self.distance, + [points[:, 1] - 0.5, points[:, 0] - 0.5], + order=1, + mode="nearest", + ) + return np.maximum( + 0.7, np.minimum(32, np.minimum(16, radius * 0.9) + 0.4 * distance) + ) + + +class ContourDomain: + """Reuse boundary segments for exact internal segment validation.""" + + def __init__(self, contours): + self.contours = contours + self.starts = np.concatenate(contours) + self.ends = np.concatenate([np.roll(c, -1, axis=0) for c in contours]) + self.delta = self.ends - self.starts + self.lengths = np.maximum(np.sum(self.delta**2, axis=1), 1e-20) + + def clearance(self, points): + result = np.empty(len(points)) + for first in range(0, len(points), 64): + d = points[first : first + 64, None, :] - self.starts[None, :, :] + t = np.clip(np.sum(d * self.delta, axis=2) / self.lengths, 0, 1) + result[first : first + 64] = np.sqrt( + np.min(np.sum((d - t[:, :, None] * self.delta) ** 2, axis=2), axis=1) + ) + return result + + def contains(self, points): + result = np.empty(len(points), dtype=bool) + a = self.starts + b = self.ends + dy = b[:, 1] - a[:, 1] + divisor = np.where(dy != 0, dy, 1.0) + for first in range(0, len(points), 64): + p = points[first : first + 64] + y = p[:, 1, None] + x = p[:, 0, None] + crosses = (a[:, 1] > y) != (b[:, 1] > y) + hit = a[:, 0] + (y - a[:, 1]) * (b[:, 0] - a[:, 0]) / divisor + result[first : first + 64] = ( + np.count_nonzero(crosses & (x < hit), axis=1) % 2 == 1 + ) + return result + + def valid_segments(self, starts, ends): + starts = np.asarray(starts) + ends = np.asarray(ends) + valid = self.contains(np.concatenate((starts, ends))).reshape(2, -1).all(axis=0) + valid &= np.minimum(self.clearance(starts), self.clearance(ends)) > 1e-05 + for first in range(0, len(starts), 32): + a = starts[first : first + 32, None, :] + v = (ends - starts)[first : first + 32, None, :] + d = self.starts[None, :, :] - a + w = self.delta[None, :, :] + det = v[:, :, 0] * w[:, :, 1] - v[:, :, 1] * w[:, :, 0] + nonparallel = np.abs(det) > 1e-12 + divisor = np.where(nonparallel, det, 1.0) + t = (d[:, :, 0] * w[:, :, 1] - d[:, :, 1] * w[:, :, 0]) / divisor + u = (d[:, :, 0] * v[:, :, 1] - d[:, :, 1] * v[:, :, 0]) / divisor + crossing = (nonparallel & (t >= 0) & (t <= 1) & (u >= 0) & (u <= 1)).any( + axis=1 + ) + factors = np.clip( + np.sum(d * v, axis=2) / np.maximum(np.sum(v * v, axis=2), 1e-20), 0, 1 + ) + distance = np.min( + np.sum((d - factors[:, :, None] * v) ** 2, axis=2), axis=1 + ) + valid[first : first + 32] &= ~crossing & (distance > 1e-10) + return valid + + +def contours_for_component(component, spacing, strength=1.0): + result = [] + tolerance = ( + 2.5 if spacing > 16 else 1.25 if spacing > 8 else 0.625 if spacing > 4 else 0.5 + ) + for contour in component: + original = np.asarray(contour, dtype=float) + if spacing <= 16 and strength > 0: + original = ndimage.gaussian_filter1d( + g.resample_closed_contour(original, 1.0), + 0.8 * strength, + axis=0, + mode="wrap", + ) + for error in (tolerance * strength, tolerance * strength * 0.5, 0): + simplified = approximate_polygon(np.vstack((original, original[0])), error)[ + :-1 + ] + if len(simplified) >= 3 and abs(g.contour_area(simplified)) > 0.05: + break + points = [] + for a, b in zip(simplified, np.roll(simplified, -1, axis=0)): + count = max(1, int(np.ceil(np.linalg.norm(b - a) / 32))) + points.extend(a + np.arange(count)[:, None] / count * (b - a)) + result.append(np.asarray(points)) + if strength > 0 and not valid_contours(result): + return contours_for_component( + component, spacing, strength * 0.5 if strength > 0.125 else 0 + ) + return result + + +def interior_paths(contours, spacing, width_sizes): + p, f, _ = g._triangulate_contour_component(contours, np.empty((0, 2)), spacing) + p, f = clean_mesh(p, f) + users = {} + for ti, face in enumerate(f): + for i in range(3): + edge = tuple(sorted((int(face[i]), int(face[(i + 1) % 3])))) + users.setdefault(edge, []).append(ti) + nodes = [] + connections = [] + midpoints = {} + for edge, owners in users.items(): + if len(owners) == 2: + midpoints[edge] = len(nodes) + nodes.append(p[list(edge)].mean(axis=0)) + for face in f: + mids = [ + midpoints[e] + for i in range(3) + if (e := tuple(sorted((int(face[i]), int(face[(i + 1) % 3]))))) in midpoints + ] + if len(mids) == 2: + connections.append(tuple(mids)) + else: + center = len(nodes) + nodes.append(p[face].mean(axis=0)) + connections.extend(((m, center) for m in mids)) + if not nodes: + return (np.empty((0, 2)), []) + nodes = np.asarray(nodes) + domain = ContourDomain(contours) + radius = domain.clearance(nodes) + local_sizes = width_sizes(nodes) + connections = [(a, b) for a, b in connections if min(radius[a], radius[b]) > 0.0001] + if connections: + pairs = np.asarray(connections) + valid = domain.valid_segments(nodes[pairs[:, 0]], nodes[pairs[:, 1]]) + connections = [edge for edge, ok in zip(connections, valid) if ok] + full_graph = {} + for a, b in connections: + length = float(np.linalg.norm(nodes[a] - nodes[b])) + full_graph.setdefault(a, []).append((b, length)) + full_graph.setdefault(b, []).append((a, length)) + selected = { + tuple(sorted((a, b))) + for a, b in connections + if min(radius[a], radius[b]) < spacing * 0.4 + and min(local_sizes[a], local_sizes[b]) < min(16, spacing * 0.6) + } + if selected: + parents = {i: i for edge in selected for i in edge} + + def root(i): + while parents[i] != i: + parents[i] = parents[parents[i]] + i = parents[i] + return i + + for a, b in selected: + parents[root(a)] = root(b) + owners = {i: root(i) for i in parents} + distances = {i: 0.0 for i in parents} + previous = {} + queue = [(0.0, i) for i in parents] + heapq.heapify(queue) + while queue: + distance, node = heapq.heappop(queue) + if distance != distances[node]: + continue + for neighbor, length in full_graph.get(node, []): + if max(radius[node], radius[neighbor]) > spacing * 0.75: + continue + value = distance + length + if value < distances.get(neighbor, np.inf): + distances[neighbor] = value + owners[neighbor] = owners[node] + previous[neighbor] = node + heapq.heappush(queue, (value, neighbor)) + candidates = [] + for a, b in connections: + if ( + a in owners + and b in owners + and (owners[a] != owners[b]) + and (max(radius[a], radius[b]) <= spacing * 0.75) + ): + candidates.append( + ( + distances[a] + + np.linalg.norm(nodes[a] - nodes[b]) + + distances[b], + a, + b, + ) + ) + for _, a, b in sorted(candidates): + ra, rb = (root(owners[a]), root(owners[b])) + if ra == rb: + continue + parents[ra] = rb + selected.add(tuple(sorted((a, b)))) + for cursor in (a, b): + while cursor in previous: + other = previous[cursor] + selected.add(tuple(sorted((cursor, other)))) + cursor = other + connections = sorted(selected) + graph = {} + for a, b in connections: + graph.setdefault(a, set()).add(b) + graph.setdefault(b, set()).add(a) + used = set() + paths = [] + starts = [i for i in graph if len(graph[i]) != 2] + list(graph) + for start in starts: + for neighbor in graph[start]: + edge = tuple(sorted((start, neighbor))) + if edge in used: + continue + used.add(edge) + ids = [start, neighbor] + while len(graph[ids[-1]]) == 2: + nxt = next((x for x in graph[ids[-1]] if x != ids[-2])) + edge = tuple(sorted((ids[-1], nxt))) + if edge in used: + break + used.add(edge) + ids.append(nxt) + path = nodes[ids] + length = np.linalg.norm(np.diff(path, axis=0), axis=1).sum() + if length < 5 and (len(graph[ids[0]]) == 1 or len(graph[ids[-1]]) == 1): + continue + simplified = path + for tol in (2.0, 1.0, 0.25, 0): + candidate = approximate_polygon(path, tol) + if domain.valid_segments(candidate[:-1], candidate[1:]).all(): + simplified = candidate + break + expanded = [] + for a, b in zip(simplified, simplified[1:]): + n = max(1, int(np.ceil(np.linalg.norm(b - a) / 24))) + expanded.extend(a + np.arange(n)[:, None] / n * (b - a)) + expanded.append(simplified[-1]) + paths.append(np.asarray(expanded)) + points = [] + edges = [] + lookup = {} + for path in paths: + ids = [] + for p in path: + key = tuple(np.round(p, 7)) + if key not in lookup: + lookup[key] = len(points) + points.append(p) + ids.append(lookup[key]) + edges.extend(zip(ids, ids[1:])) + return (np.asarray(points).reshape(-1, 2), edges) + + +def relax_points(contours, spine, shape, spacing): + free = g.uniform_interior_points_for_contours(contours, spacing) + if len(free) and len(spine): + free = free[cKDTree(spine).query(free)[0] > spacing * 0.55] + step = max(1, int(spacing / 4)) + phase = step // 2 + points = np.concatenate(contours) + minimum = points.min(axis=0) + maximum = points.max(axis=0) + xx = np.arange(phase, shape[1], step) + 0.5 + yy = np.arange(phase, shape[0], step) + 0.5 + xx = xx[(xx >= minimum[0]) & (xx <= maximum[0])] + yy = yy[(yy >= minimum[1]) & (yy <= maximum[1])] + xx, yy = np.meshgrid(xx, yy) + samples = np.column_stack((xx.ravel(), yy.ravel())) + samples = samples[g.points_inside_contours(samples, contours)] + if not len(samples): + return free + fixed = np.concatenate((*contours, spine)) + for iteration in range(12): + if not len(free): + break + sites = np.concatenate((fixed, free)) + labels = cKDTree(sites).query(samples)[1] - len(fixed) + next_free = free.copy() + valid = labels >= 0 + owners = labels[valid] + owned = samples[valid] + counts = np.bincount(owners, minlength=len(free)) + used = counts > 0 + for axis in range(2): + sums = np.bincount(owners, weights=owned[:, axis], minlength=len(free)) + next_free[used, axis] = sums[used] / counts[used] + proposed = free * 0.3 + next_free * 0.7 + usable = g.points_inside_contours(proposed, contours) + movement = np.linalg.norm(proposed[usable] - free[usable], axis=1) + free[usable] = proposed[usable] + if iteration >= 3 and ( + not len(movement) or np.sqrt(np.mean(movement**2)) < spacing * 0.01 + ): + break + return free + + +def subdivide_constraints(contours, spine, path_edges, spacing): + dense = [] + for c in contours: + points = [] + for a, b in zip(c, np.roll(c, -1, axis=0)): + count = max(1, int(np.ceil(np.linalg.norm(b - a) / spacing))) + points.extend(a + np.arange(count)[:, None] / count * (b - a)) + dense.append(np.asarray(points)) + points = spine.tolist() + edges = [] + for a, b in path_edges: + count = max(1, int(np.ceil(np.linalg.norm(spine[b] - spine[a]) / spacing))) + ids = [a] + for i in range(1, count): + ids.append(len(points)) + points.append((spine[a] + i / count * (spine[b] - spine[a])).tolist()) + ids.append(b) + edges.extend(zip(ids, ids[1:])) + return (dense, np.asarray(points).reshape(-1, 2), edges) + + +def triangulate(contours, spine, path_edges, free, spacing): + boundary_count = sum(map(len, contours)) + p = np.concatenate((*contours, spine, free)) + edges = [] + offset = 0 + for c in contours: + edges.extend(((offset + i, offset + (i + 1) % len(c)) for i in range(len(c)))) + offset += len(c) + constraint_start = len(edges) + edges.extend(((boundary_count + a, boundary_count + b) for a, b in path_edges)) + result = delaunay_2d_cdt([Vector(v) for v in p], edges, [], 0, 1e-07, True) + points = np.asarray([tuple(v) for v in result[0]]) + faces = np.asarray(result[2], dtype=np.int32) + faces = faces[g.points_inside_contours(points[faces].mean(axis=1), contours)] + area = np.abs( + np.cross( + points[faces[:, 1]] - points[faces[:, 0]], + points[faces[:, 2]] - points[faces[:, 0]], + ) + ) + faces = faces[area > 1e-08] + used = np.unique(faces) + remap = np.full(len(points), -1, dtype=np.int32) + remap[used] = np.arange(len(used)) + face_edges = { + tuple(sorted((int(face[i]), int(face[(i + 1) % 3])))) + for face in faces + for i in range(3) + } + path_result = [] + constraint_graphs = [{} for _ in path_edges] + for edge, originals in zip(result[1], result[4]): + if tuple(sorted(edge)) not in face_edges: + continue + for original in originals: + if constraint_start <= original < len(edges): + graph = constraint_graphs[original - constraint_start] + a, b = edge + graph.setdefault(a, set()).add(b) + graph.setdefault(b, set()).add(a) + path_result.append(tuple(remap[list(edge)])) + # CDT coordinates are float32. Validate endpoint connectivity via provenance, + # so short constraints at large pixel coordinates survive rounding. + original_vertices = { + original: vertex + for vertex, originals in enumerate(result[3]) + for original in originals + } + for (a, b), graph in zip(edges[constraint_start:], constraint_graphs): + start = original_vertices.get(a) + end = original_vertices.get(b) + seen = set() + pending = [start] if start in graph else [] + while pending: + vertex = pending.pop() + if vertex in seen: + continue + seen.add(vertex) + pending.extend(graph[vertex] - seen) + if end not in seen: + raise ValueError( + "Fine Outline lost an interior constraint during triangulation" + ) + return ( + points[used], + remap[faces], + np.asarray(path_result, dtype=np.int32).reshape(-1, 2), + ) diff --git a/src/anyimage/operators/cutout_tool/geometry.py b/src/anyimage/operators/cutout_tool/geometry.py index c18f89b..298f0f2 100644 --- a/src/anyimage/operators/cutout_tool/geometry.py +++ b/src/anyimage/operators/cutout_tool/geometry.py @@ -216,7 +216,7 @@ def extract_selection_contours(selection_values, max_resolution=512, threshold=0 sampled_size = tuple(max(1, round(value * scale)) for value in source_size) if sampled_size != source_size: values = _resize_values(values, sampled_size) - mask = values >= 255.0 * threshold + mask = (values > 0.0) & (values >= 255.0 * threshold) components = _group_selection_contours(mask) scale_x = source_size[0] / sampled_size[0] scale_y = source_size[1] / sampled_size[1] @@ -1226,7 +1226,7 @@ def depth_balloon_projection_parameters( return reference, tuple(float(value) for value in direction) -CUTOUT_ALPHA_THRESHOLD = 0.1 +CUTOUT_ALPHA_THRESHOLD = 0.9 CUTOUT_CLEANUP_UI_SCALE = 100.0 @@ -1244,18 +1244,38 @@ def cutout_domain( spacing, world_metric, balloon_algorithm="POISSON", + *, + fine_mesh=None, ): import numpy as np x_min, x_max, y_min, y_max = bounds - planar, planar_faces, boundary, components = triangulate_selection_contours( - selection_contours, - bounds, - spacing=spacing, - world_metric=world_metric, - return_components=True, - ) + if fine_mesh is None: + planar, planar_faces, boundary, components = triangulate_selection_contours( + selection_contours, + bounds, + spacing=spacing, + world_metric=world_metric, + return_components=True, + ) + else: + pixels, planar_faces, boundary, pixel_components, image_size = fine_mesh + image_width, image_height = image_size + transform = metric_transform(world_metric) + + def pixel_to_planar(points): + local = np.asarray(points, dtype=np.float64).copy() + local[:, 0] = x_min + local[:, 0] * (x_max - x_min) / image_width + local[:, 1] = y_max - local[:, 1] * (y_max - y_min) / image_height + return local @ transform + + planar = pixel_to_planar(pixels) + planar_faces = planar_faces.tolist() + components = [ + (start, end, faces.tolist(), pixel_to_planar(contour)) + for start, end, faces, contour in pixel_components + ] profile = balloon_profile( balloon_algorithm, planar, planar_faces, boundary, components ) @@ -1381,7 +1401,7 @@ def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_T source_width - 1, ) resized = values[y[:, None], x[None, :]] - valid = resized > float(threshold) + valid = (resized > 0.0) & (resized >= float(threshold)) transparent_fraction = float(np.count_nonzero(~valid) / valid.size) return valid, transparent_fraction @@ -1392,6 +1412,9 @@ def build_base_shape( point_spacing, content_values, content_bounds, + *, + alpha_threshold=CUTOUT_ALPHA_THRESHOLD, + fine_outline=False, ): import numpy as np @@ -1400,11 +1423,21 @@ def build_base_shape( content_bounds, tuple(source_object.data.size), ) - selection_contours = extract_cutout_contours( - content_values, - point_spacing, - threshold=CUTOUT_ALPHA_THRESHOLD, - ) + fine_mesh = None + selection_contours = None + if fine_outline: + from .fine_mesh import build_fine_mesh + + fine_mesh = ( + *build_fine_mesh(content_values, point_spacing, alpha_threshold), + (content_values.shape[1], content_values.shape[0]), + ) + else: + selection_contours = extract_cutout_contours( + content_values, + point_spacing, + threshold=alpha_threshold, + ) world_metric, world_z_scale = world_plane_metric(source_object.matrix_world) if world_z_scale <= 1e-10: raise ValueError("The Image Empty has a degenerate world transform") @@ -1417,6 +1450,7 @@ def build_base_shape( point_spacing, world_metric, balloon_algorithm, + fine_mesh=fine_mesh, ) source_vertices, faces, uv, _boundary, planar, balloon = domain return BaseShape( diff --git a/src/anyimage/operators/cutout_tool/interaction.py b/src/anyimage/operators/cutout_tool/interaction.py index 4c16e1c..c570701 100644 --- a/src/anyimage/operators/cutout_tool/interaction.py +++ b/src/anyimage/operators/cutout_tool/interaction.py @@ -32,6 +32,8 @@ def _pie_button( generate_normal, point_spacing, inward_axis, + alpha_threshold, + fine_outline, ): operator = pie.operator( "anyimage.cutout_selection_to_shape", @@ -44,6 +46,8 @@ def _pie_button( operator.generate_normal = generate_normal operator.point_spacing = point_spacing operator.inward_axis = inward_axis + operator.alpha_threshold = alpha_threshold + operator.fine_outline = fine_outline def open_shape_pie( @@ -61,6 +65,8 @@ def open_shape_pie( getattr(settings, "cutout_generate_normal", False) ) inward_axis = str(getattr(settings, "cutout_inward_axis", "NEGATIVE_Z")) + alpha_threshold = settings.cutout_alpha_threshold + fine_outline = settings.cutout_fine_outline def draw(menu, _context): pie = menu.layout.menu_pie() @@ -76,6 +82,8 @@ def open_shape_pie( generate_normal, point_spacing, inward_axis, + alpha_threshold, + fine_outline, ) context.window_manager.popup_menu_pie( diff --git a/src/anyimage/operators/cutout_tool/main.py b/src/anyimage/operators/cutout_tool/main.py index 09fd20d..cf8e73a 100644 --- a/src/anyimage/operators/cutout_tool/main.py +++ b/src/anyimage/operators/cutout_tool/main.py @@ -9,7 +9,11 @@ from ...preferences import ( configured_max_ai_input_size, configured_max_cutout_mesh_resolution, ) -from ...properties import DEFAULT_MOGE2_MODEL_KEY +from ...properties import ( + DEFAULT_MOGE2_MODEL_KEY, + cutout_alpha_threshold_property, + cutout_fine_outline_property, +) from ...runtime import JobOperatorBase from ...server.media.resolution import ( DEFAULT_MAX_AI_INPUT_SIZE, @@ -92,6 +96,8 @@ def create_cutout_shape( content_values, content_bounds, *, + alpha_threshold=CUTOUT_ALPHA_THRESHOLD, + fine_outline=False, depth_image=None, depth_metadata=None, normal_image=None, @@ -106,6 +112,8 @@ def create_cutout_shape( point_spacing, content_values, content_bounds, + alpha_threshold=alpha_threshold, + fine_outline=fine_outline, ) result = create_shape_object( context, @@ -115,6 +123,7 @@ def create_cutout_shape( content_values, content_bounds, base_shape, + alpha_threshold=alpha_threshold, depth_image=depth_image, depth_metadata=depth_metadata, normal_image=normal_image, @@ -132,7 +141,9 @@ def create_cutout_shape( return result -def _cutout_content_values(source_alpha, selection_values=None): +def _cutout_content_values( + source_alpha, selection_values=None, *, alpha_threshold=CUTOUT_ALPHA_THRESHOLD +): """Combine local image Alpha with optional pure Selection geometry.""" import numpy as np @@ -146,7 +157,7 @@ def _cutout_content_values(source_alpha, selection_values=None): if alpha.shape != selection.shape: raise ValueError("The Cutout source Alpha does not match the Selection") values = alpha * selection - if not np.any(values > CUTOUT_ALPHA_THRESHOLD): + if not np.any((values > 0.0) & (values >= alpha_threshold)): raise ImageEditWarning( "The Cutout selection contains no visible image pixels" ) @@ -212,6 +223,8 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): options=_HIDDEN, ) generate_normal: bpy.props.BoolProperty(default=False, options=_HIDDEN) + alpha_threshold: cutout_alpha_threshold_property() + fine_outline: cutout_fine_outline_property() inward_axis: bpy.props.StringProperty( default=CutoutInwardAxis.NEGATIVE_Z.value, options=_HIDDEN, @@ -254,6 +267,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): content_values = _cutout_content_values( source_rgba[top:bottom, left:right, 3], selection_mask.values, + alpha_threshold=self.alpha_threshold, ) color_image = create_cropped_color_image( source_object, @@ -268,6 +282,8 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): content_values, selection_mask.bounds, inward_axis=self.inward_axis, + alpha_threshold=self.alpha_threshold, + fine_outline=self.fine_outline, color_image=color_image, ) return {"FINISHED"} @@ -314,6 +330,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): "depth_mode": depth_mode, "normal_mode": normal_mode.value, "max_input_size": int(self.max_ai_input_size), + "alpha_threshold": float(self.alpha_threshold), } if depth_mode != "NONE" or normal_mode != NormalMode.NONE: start_frame = source_start_frame(self.source_object_name) @@ -356,6 +373,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): content_values = _cutout_content_values( source_alpha, selection_mask.values, + alpha_threshold=self.alpha_threshold, ) content_bounds = color_bounds normal_mode = normal_mode_for_shape(self.shape, self.generate_normal) @@ -389,6 +407,8 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): normal_image=normal_image, normal_mode=normal_mode, inward_axis=inward_axis, + alpha_threshold=self.alpha_threshold, + fine_outline=self.fine_outline, color_image=color_image, ) except Exception: @@ -490,4 +510,6 @@ class CutoutTool(bpy.types.WorkSpaceTool): status = draw_ai_setup(layout) draw_ai_property(layout, settings, "cutout_generate_normal", status) layout.prop(settings, "cutout_mesh_detail") + layout.prop(settings, "cutout_fine_outline") + layout.prop(settings, "cutout_alpha_threshold") layout.prop(settings, "cutout_inward_axis") diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py index 1224b61..98c0dfa 100644 --- a/src/anyimage/operators/cutout_tool/object.py +++ b/src/anyimage/operators/cutout_tool/object.py @@ -17,6 +17,7 @@ from ...common.object import ( ) from ...common.node import load_node_group from .geometry import ( + CUTOUT_ALPHA_THRESHOLD, DEFAULT_DEPTH_BASELINE, DEFAULT_DEPTH_DIRECTION, crop_plane_bounds, @@ -161,6 +162,7 @@ def create_shape_object( content_bounds, base_shape, *, + alpha_threshold=CUTOUT_ALPHA_THRESHOLD, depth_image=None, depth_metadata=None, normal_image=None, @@ -213,7 +215,7 @@ def create_shape_object( raise ValueError("Depth Shape requires a Depth texture and metadata") camera_depth, valid = camera_depth_values(depth_image) calibration_valid, transparent_fraction = selection_calibration( - content_values, valid.shape + content_values, valid.shape, threshold=alpha_threshold ) calibration_valid &= valid uniform_scale = depth_uniform_scale( diff --git a/src/anyimage/operators/cutout_tool/polygon.py b/src/anyimage/operators/cutout_tool/polygon.py new file mode 100644 index 0000000..489d9fd --- /dev/null +++ b/src/anyimage/operators/cutout_tool/polygon.py @@ -0,0 +1,94 @@ +"""Polygonal chain simplification using NumPy.""" + +import numpy as np + + +# approximate_polygon: scikit-image 0.25.2, copyright 2009-2022 the scikit-image team. +# BSD-3-Clause; see ../../licenses/scikit-image.txt. +def approximate_polygon(coords, tolerance): + """Approximate a polygonal chain with the specified tolerance. + + It is based on the Douglas-Peucker algorithm. + + Note that the approximated polygon is always within the convex hull of the + original polygon. + + Parameters + ---------- + coords : (K, 2) array + Coordinate array. + tolerance : float + Maximum distance from original points of polygon to approximated + polygonal chain. If tolerance is 0, the original coordinate array + is returned. + + Returns + ------- + coords : (L, 2) array + Approximated polygonal chain where L <= K. + + References + ---------- + .. [1] https://en.wikipedia.org/wiki/Ramer-Douglas-Peucker_algorithm + """ + if tolerance <= 0: + return coords + + chain = np.zeros(coords.shape[0], "bool") + # pre-allocate distance array for all points + dists = np.zeros(coords.shape[0]) + chain[0] = True + chain[-1] = True + pos_stack = [(0, chain.shape[0] - 1)] + end_of_chain = False + + while not end_of_chain: + start, end = pos_stack.pop() + # determine properties of current line segment + r0, c0 = coords[start, :] + r1, c1 = coords[end, :] + dr = r1 - r0 + dc = c1 - c0 + segment_angle = -np.arctan2(dr, dc) + segment_dist = c0 * np.sin(segment_angle) + r0 * np.cos(segment_angle) + + # select points in-between line segment + segment_coords = coords[start + 1 : end, :] + segment_dists = dists[start + 1 : end] + + # check whether to take perpendicular or euclidean distance with + # inner product of vectors + + # vectors from points -> start and end + dr0 = segment_coords[:, 0] - r0 + dc0 = segment_coords[:, 1] - c0 + dr1 = segment_coords[:, 0] - r1 + dc1 = segment_coords[:, 1] - c1 + # vectors points -> start and end projected on start -> end vector + projected_lengths0 = dr0 * dr + dc0 * dc + projected_lengths1 = -dr1 * dr - dc1 * dc + perp = np.logical_and(projected_lengths0 > 0, projected_lengths1 > 0) + eucl = np.logical_not(perp) + segment_dists[perp] = np.abs( + segment_coords[perp, 0] * np.cos(segment_angle) + + segment_coords[perp, 1] * np.sin(segment_angle) + - segment_dist + ) + segment_dists[eucl] = np.minimum( + # distance to start point + np.sqrt(dc0[eucl] ** 2 + dr0[eucl] ** 2), + # distance to end point + np.sqrt(dc1[eucl] ** 2 + dr1[eucl] ** 2), + ) + + if np.any(segment_dists > tolerance): + # select point with maximum distance to line + new_end = start + np.argmax(segment_dists) + 1 + pos_stack.append((new_end, end)) + pos_stack.append((start, new_end)) + chain[new_end] = True + + if len(pos_stack) == 0: + end_of_chain = True + + return coords[chain, :] diff --git a/src/anyimage/operators/image_edit_tool/frame.py b/src/anyimage/operators/image_edit_tool/frame.py index bb6959a..0aae4b8 100644 --- a/src/anyimage/operators/image_edit_tool/frame.py +++ b/src/anyimage/operators/image_edit_tool/frame.py @@ -498,8 +498,6 @@ def apply_frame(context, source_objects, frame_quad): output_size, ) result_image = create_image_edit_result(active.data, pixels, output_size) - active_source_image = active.data - active_source_name = active_source_image.name old_matrix = active.matrix_world.copy() old_display_size = active.empty_display_size old_offset = tuple(active.empty_image_offset) @@ -511,13 +509,14 @@ def apply_frame(context, source_objects, frame_quad): active.matrix_world = matrix active.empty_display_size = display_size active.empty_image_offset = (-0.5, -0.5) - replace_empty_image(active, result_image) + replace_empty_image( + active, result_image, + removed_objects=tuple(source for source in source_objects if source != active), + ) except Exception: active.matrix_world = old_matrix active.empty_display_size = old_display_size active.empty_image_offset = old_offset - if result_image.users == 0: - bpy.data.images.remove(result_image, do_unlink=True) raise for source in source_objects: if source is not active: @@ -526,8 +525,6 @@ def apply_frame(context, source_objects, frame_quad): try: if image.users == 0 and not image.use_fake_user: bpy.data.images.remove(image, do_unlink=True) - if image is active_source_image: - result_image.name = active_source_name except (ReferenceError, RuntimeError): pass return active diff --git a/src/anyimage/operators/image_edit_tool/mask.py b/src/anyimage/operators/image_edit_tool/mask.py index 7a1f786..f88bbab 100644 --- a/src/anyimage/operators/image_edit_tool/mask.py +++ b/src/anyimage/operators/image_edit_tool/mask.py @@ -145,12 +145,7 @@ class EditImageAlpha(ImageGesture, bpy.types.Operator): pixels, tuple(source_image.size), ) - try: - replace_empty_image(source_object, result_image) - except Exception: - if result_image.users == 0: - bpy.data.images.remove(result_image, do_unlink=True) - raise + replace_empty_image(source_object, result_image) except (RuntimeError, TypeError, ValueError) as error: report_image_edit_exception(self, error) return {"CANCELLED"} diff --git a/src/anyimage/preferences.py b/src/anyimage/preferences.py index 5199941..8ebd1a2 100644 --- a/src/anyimage/preferences.py +++ b/src/anyimage/preferences.py @@ -61,6 +61,12 @@ def configured_storage_root(): return DEFAULT_STORAGE_ROOT +def configured_material_view_adaptation(context=None): + return bool( + getattr(addon_preferences(context), "adapt_material_to_view_transform", True) + ) + + def configured_max_cutout_mesh_resolution(context=None): preferences = addon_preferences(context) value = int( @@ -109,6 +115,14 @@ def geometry_model_items(owner, context): class AnyImagePreferences(bpy.types.AddonPreferences): bl_idname = ADDON_PACKAGE + adapt_material_to_view_transform: bpy.props.BoolProperty( + name="Adapt to Scene View Transform", + description=( + "Use the scene view transform's inverse color space with Straight alpha " + "for new material color textures; otherwise use sRGB with Premultiplied alpha" + ), + default=True, + ) storage_root: bpy.props.StringProperty( name="Storage Root", description=( @@ -243,6 +257,9 @@ class AnyImagePreferences(bpy.types.AddonPreferences): "AI Upscale Image", ) + shading = self._draw_box(layout, "Shading") + shading.prop(self, "adapt_material_to_view_transform") + tools = self._draw_box(layout, "Tools") tools.label(text="Frame Tool") tools.prop(self, "max_frame_resolution") diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py index 68493d5..db290b0 100644 --- a/src/anyimage/properties.py +++ b/src/anyimage/properties.py @@ -258,6 +258,27 @@ def cutout_generate_normal_property(): ) +def cutout_alpha_threshold_property(): + return bpy.props.FloatProperty( + name="Alpha Threshold", + description="Minimum Alpha kept in the Cutout contour", + default=0.9, + min=0.0, + max=1.0, + soft_min=0.1, + soft_max=0.9, + subtype="FACTOR", + ) + + +def cutout_fine_outline_property(): + return bpy.props.BoolProperty( + name="Fine Outline", + description="Fit detailed outlines and support thin parts; disable for simpler, faster meshes", + default=False, + ) + + def cutout_mesh_detail_property(): return bpy.props.EnumProperty( name="Mesh Detail", @@ -273,7 +294,9 @@ def cutout_mesh_detail_property(): class AnyImageSettings(bpy.types.PropertyGroup): + cutout_alpha_threshold: cutout_alpha_threshold_property() cutout_mesh_detail: cutout_mesh_detail_property() + cutout_fine_outline: cutout_fine_outline_property() cutout_generate_normal: cutout_generate_normal_property() cutout_inward_axis: cutout_inward_axis_property() mask_gesture: mask_gesture_property() diff --git a/src/anyimage/runtime.py b/src/anyimage/runtime.py index 040b1b4..facaf3a 100644 --- a/src/anyimage/runtime.py +++ b/src/anyimage/runtime.py @@ -9,6 +9,7 @@ ENVIRONMENT = { "python": "3.12", "packages": [ "numpy==2.4.2", + "scipy==1.15.3", "pillow==12.1.1", "av==17.0.0", "trimesh==4.11.2", diff --git a/src/anyimage/server/geometry/edge_depth.py b/src/anyimage/server/geometry/edge_depth.py new file mode 100644 index 0000000..fb61d30 --- /dev/null +++ b/src/anyimage/server/geometry/edge_depth.py @@ -0,0 +1,78 @@ +"""Extend reliable Cutout depth along each visible component's silhouette.""" + +from dataclasses import replace + + +def extend_edge_depth(frame, alpha, alpha_threshold=0.9): + """Return edge-corrected geometry while preserving reliable camera points.""" + import numpy as np + from scipy import ndimage + + from ..models.onnx_moge2 import depth_to_points + + depth = frame.depth + alpha = np.asarray(alpha, dtype=np.float32) + if alpha.shape != depth.shape: + raise ValueError("Edge Alpha must match the depth dimensions") + if np.all(alpha == 1.0): + return frame + + visible = (alpha > 0.0) & (alpha >= alpha_threshold) + candidates = ( + visible + & (alpha >= 0.95) + & (frame.validity > 0.0) + & np.isfinite(depth) + & (depth > 0.0) + ) + if not candidates.any(): + return frame + interior_distance = ndimage.distance_transform_edt( + np.pad(candidates, 3, mode="edge") + )[3:-3, 3:-3] + core = candidates & (interior_distance > 2.0) + del interior_distance + labels, _ = ndimage.label(visible, structure=np.ones((3, 3), dtype=bool)) + corrected = depth.copy() + background_distance = np.full(depth.shape, np.inf, dtype=np.float32) + + for component_id, bounds in enumerate(ndimage.find_objects(labels), start=1): + region = tuple( + slice(max(0, bound.start - 7), min(size, bound.stop + 7)) + for bound, size in zip(bounds, depth.shape) + ) + component = labels[region] == component_id + donors = core[region] & component + if not donors.any(): + donors = candidates[region] & component + if not donors.any(): + continue + + indices = ndimage.distance_transform_edt( + ~donors, return_distances=False, return_indices=True + ) + extended = depth[region][tuple(indices)] + smoothed = ndimage.gaussian_filter(extended, sigma=0.8, radius=3) + distance = ndimage.distance_transform_edt(~component) + background = ( + ~visible[region] + & (distance <= 4.0) + & (distance < background_distance[region]) + ) + target = (component & ~donors) | background + corrected[region][target] = smoothed[target] + background_distance[region][background] = distance[background] + + changed = corrected != depth + if not changed.any(): + return frame + points = frame.points + if points is not None: + height, width = depth.shape + intrinsics = frame.intrinsics.copy() + intrinsics[0] /= width + intrinsics[1] /= height + projected = depth_to_points(corrected, intrinsics) + points = points.copy() + points[changed] = projected[changed] + return replace(frame, depth=corrected, points=points) diff --git a/src/anyimage/server/geometry/prediction_artifacts.py b/src/anyimage/server/geometry/prediction_artifacts.py index 59ed418..f33b71b 100644 --- a/src/anyimage/server/geometry/prediction_artifacts.py +++ b/src/anyimage/server/geometry/prediction_artifacts.py @@ -8,6 +8,7 @@ from .depth_texture import ( write_z_depth_texture, ) from .normal_texture import write_camera_normal_texture +from .edge_depth import extend_edge_depth def _limited_model_input(source_path, output_path, max_input_size): @@ -21,7 +22,7 @@ def _limited_model_input(source_path, output_path, max_input_size): return output_path -def _alpha_mask(image_path, shape): +def _image_alpha(image_path, shape): import numpy as np from PIL import Image @@ -31,13 +32,13 @@ def _alpha_mask(image_path, shape): (int(shape[1]), int(shape[0])), Image.Resampling.NEAREST, ) - return np.asarray(alpha, dtype=np.uint8) >= 26 + return np.asarray(alpha, dtype=np.float32) / 255.0 def _write_normal(frame, color_path, output_path, normal_mode): from PIL import Image - valid = _alpha_mask(color_path, frame.depth.shape) + valid = _image_alpha(color_path, frame.depth.shape) >= 0.1 with Image.open(color_path) as source: output_size = source.size return write_camera_normal_texture( @@ -58,6 +59,8 @@ def generate_moge2_artifacts( *, depth_mode="NONE", normal_mode="NONE", + correct_edge_depth=False, + alpha_threshold=0.9, ): """Run MoGe-2 once and write only the requested file artifacts.""" depth_mode = str(depth_mode).upper() @@ -85,21 +88,27 @@ def generate_moge2_artifacts( result = {} if depth_mode != "NONE": context.progress(0.45, "Generating depth texture") + alpha = _image_alpha(input_path, frame.depth.shape) + depth_frame = ( + extend_edge_depth(frame, alpha, alpha_threshold) + if correct_edge_depth else frame + ) if depth_mode == "VECTOR": depth_path = write_vector_depth_texture( - frame, + depth_frame, context.directory / "vector-depth.exr", ) result["vector_depth"] = depth_path else: depth_path = write_z_depth_texture( - frame, + depth_frame, context.directory / "z-depth.exr", ) result["z_depth"] = depth_path result["depth_metadata"] = write_depth_metadata( - frame, - _alpha_mask(input_path, frame.depth.shape), + depth_frame, + (alpha > 0.0) & (alpha >= alpha_threshold) + if correct_edge_depth else alpha >= 0.1, context.directory / "depth.json", ) diff --git a/src/anyimage/server/jobs/cutout.py b/src/anyimage/server/jobs/cutout.py index 523be25..cc7275d 100644 --- a/src/anyimage/server/jobs/cutout.py +++ b/src/anyimage/server/jobs/cutout.py @@ -19,6 +19,8 @@ def run(context, parameters): input_path, depth_mode=depth_mode, normal_mode=normal_mode, + correct_edge_depth=depth_mode.upper() != "NONE", + alpha_threshold=float(parameters.get("alpha_threshold", 0.9)), ) result.update({key: path.name for key, path in artifacts.items()}) context.check_cancelled() diff --git a/tests/test_alpha_presentation.py b/tests/test_alpha_presentation.py index 649f579..ee4a694 100644 --- a/tests/test_alpha_presentation.py +++ b/tests/test_alpha_presentation.py @@ -55,13 +55,13 @@ def test_failed_empty_replacement_restores_blending_and_timing(): original = SimpleNamespace(name="Original", use_fake_user=False, users=1) timing = SimpleNamespace(frame_start=3, frame_offset=2, frame_duration=7, use_cyclic=True) source = SimpleNamespace(data=original, use_empty_image_alpha=False, image_user=timing) - result = SimpleNamespace(source="SEQUENCE") + result = SimpleNamespace(source="SEQUENCE", users=1) def fail_configuration(*args, **kwargs): timing.frame_start = 99 raise RuntimeError("Failed configuration") - with patch.object(images, "video_total_frames", return_value=3), patch.object( + with patch.object(images, "has_other_image_empty", return_value=True), patch.object(images, "video_total_frames", return_value=3), patch.object( images, "configure_image_user", side_effect=fail_configuration ): with pytest.raises(RuntimeError, match="Failed configuration"): diff --git a/tests/test_blender_addon.py b/tests/test_blender_addon.py index 963b8e9..947a3c2 100644 --- a/tests/test_blender_addon.py +++ b/tests/test_blender_addon.py @@ -435,6 +435,7 @@ class BlenderAddonTest(unittest.TestCase): runtime = self.anyimage.runtime self.assertEqual(runtime.environment["python"], "3.12") self.assertIn("numpy==2.4.2", runtime.environment["packages"]) + self.assertIn("scipy==1.15.3", runtime.environment["packages"]) self.assertFalse( any( "blendjob" in package.lower() @@ -896,7 +897,15 @@ class BlenderAddonTest(unittest.TestCase): "Cutout Tool", ], ) - self.assertEqual(len(boxes), 3) + self.assertEqual(len(boxes), 4) + self.assertIn("Shading", labels) + self.assertIn("adapt_material_to_view_transform", properties) + adaptation = preference_module.AnyImagePreferences.__annotations__[ + "adapt_material_to_view_transform" + ] + self.assertTrue(adaptation["default"]) + self.assertNotIn("update", adaptation) + self.assertIn(preference_module.AnyImagePreferences, self.anyimage.CLASSES) self.assertNotIn("Normal Model", labels) self.assertIn("background_model", properties) self.assertIn("upscale_model", properties) @@ -1006,7 +1015,7 @@ class BlenderAddonTest(unittest.TestCase): self.assertEqual( labels, - ["Server", "Tools", "Frame Tool", "Depth & Normal", "Cutout Tool"], + ["Server", "Shading", "Tools", "Frame Tool", "Depth & Normal", "Cutout Tool"], ) self.assertEqual( operators, @@ -1252,6 +1261,8 @@ class BlenderAddonTest(unittest.TestCase): ), shape=shape, point_spacing=32, + alpha_threshold=0.25, + fine_outline=False, generate_normal=False, report=lambda *_args: None, _needs_generation=lambda: False, @@ -1312,6 +1323,8 @@ class BlenderAddonTest(unittest.TestCase): selection_mask.values, selection_mask.bounds, inward_axis="NEGATIVE_Z", + alpha_threshold=0.25, + fine_outline=False, color_image="Color", ) @@ -1331,6 +1344,8 @@ class BlenderAddonTest(unittest.TestCase): (1, 1, 7, 3), ) operator = self.cutout_main.CutoutSelectionToShape() + operator.alpha_threshold = 0.25 + operator.fine_outline = False operator.source_object_name = "Source" operator.selection_path_json = json.dumps( {"points": [[1, 1], [7, 1], [7, 3], [1, 3]]} @@ -1455,6 +1470,8 @@ class BlenderAddonTest(unittest.TestCase): def test_cutout_request_describes_ai_capabilities(self): operator = self.cutout_main.CutoutSelectionToShape() + operator.alpha_threshold = 0.25 + operator.fine_outline = False operator.input_path = __file__ operator.source_object_name = "Source" operator.shape = "DEPTH_SURFACE" @@ -1485,6 +1502,7 @@ class BlenderAddonTest(unittest.TestCase): [operator.model], ) self.assertEqual(parameters["depth_mode"], "VECTOR") + self.assertEqual(parameters["alpha_threshold"], 0.25) self.assertEqual(parameters["normal_mode"], "OBJECT") self.assertNotIn("polygon", parameters) self.assertNotIn("shape", parameters) @@ -2051,6 +2069,8 @@ class BlenderAddonTest(unittest.TestCase): [ "cutout_generate_normal", "cutout_mesh_detail", + "cutout_fine_outline", + "cutout_alpha_threshold", "cutout_inward_axis", ], ) @@ -2084,6 +2104,14 @@ class BlenderAddonTest(unittest.TestCase): }, ) self.assertEqual(settings["cutout_mesh_detail"]["default"], "LOW") + self.assertFalse(settings["cutout_fine_outline"]["default"]) + self.assertFalse(cutout_module.CutoutSelectionToShape.__annotations__["fine_outline"]["default"]) + self.assertEqual(settings["cutout_alpha_threshold"]["default"], 0.9) + self.assertEqual(settings["cutout_alpha_threshold"]["min"], 0.0) + self.assertEqual(settings["cutout_alpha_threshold"]["max"], 1.0) + self.assertEqual(settings["cutout_alpha_threshold"]["soft_min"], 0.1) + self.assertEqual(settings["cutout_alpha_threshold"]["soft_max"], 0.9) + self.assertEqual(settings["cutout_alpha_threshold"]["subtype"], "FACTOR") self.assertEqual( [item[:2] for item in settings["cutout_mesh_detail"]["items"]], [ @@ -2150,6 +2178,8 @@ class BlenderAddonTest(unittest.TestCase): setup, ("prop", "cutout_generate_normal", False), ("prop", "cutout_mesh_detail", True), + ("prop", "cutout_fine_outline", True), + ("prop", "cutout_alpha_threshold", True), ("prop", "cutout_inward_axis", True), ], ) @@ -3567,7 +3597,8 @@ class BlenderAddonTest(unittest.TestCase): result_image = Mock(users=1) remove_object = Mock() - def replace(source, result): + def replace(source, result, *, removed_objects): + self.assertEqual(removed_objects, (other,)) source.data = result with ( @@ -3963,6 +3994,8 @@ class BlenderAddonTest(unittest.TestCase): (10, 20, 14, 23), ) operator = self.cutout_main.CutoutSelectionToShape() + operator.alpha_threshold = 0.25 + operator.fine_outline = False operator.source_object_name = "Source" operator.selection_bounds_json = "[10,20,14,23]" operator.input_path = __file__ @@ -4002,6 +4035,8 @@ class BlenderAddonTest(unittest.TestCase): np.testing.assert_array_equal(content_values, pure_values) self.assertEqual(create_shape.call_args.args[5], selection_mask.bounds) self.assertIs(create_shape.call_args.kwargs["color_image"], color_image) + self.assertEqual(create_shape.call_args.kwargs["alpha_threshold"], 0.25) + self.assertIs(create_shape.call_args.kwargs["fine_outline"], False) def test_image_selection_uses_white_dashed_segments(self): segments = self.image_interaction.dashed_line_segments( diff --git a/tests/test_clipboard_image.py b/tests/test_clipboard_image.py index 093f6b9..5fde92b 100644 --- a/tests/test_clipboard_image.py +++ b/tests/test_clipboard_image.py @@ -30,6 +30,7 @@ class ClipboardImageTest(unittest.TestCase): StringProperty=lambda **options: options, ) fake_bpy.types = SimpleNamespace( + AddonPreferences=object, GizmoGroup=object, Image=object, Menu=object, diff --git a/tests/test_cutout_fine_mesh.py b/tests/test_cutout_fine_mesh.py new file mode 100644 index 0000000..97f7933 --- /dev/null +++ b/tests/test_cutout_fine_mesh.py @@ -0,0 +1,282 @@ +import sys +from pathlib import Path + +import numpy as np +import pytest +from scipy.sparse import coo_matrix +from scipy.sparse.csgraph import connected_components +from scipy.spatial import cKDTree + +sys.path.insert(0, str(Path(__file__).parents[1] / "src")) +from anyimage.operators.cutout_tool import geometry as g +from anyimage.operators.cutout_tool import fine_mesh as fine + + +def branch_alpha(offset=0): + yy, xx = np.mgrid[:192, :192] + xx = xx - offset + yy = yy - offset + alpha = (xx - 46) ** 2 + (yy - 48) ** 2 < 30**2 + path = np.asarray(((48, 48), (91, 70), (130, 110), (149, 167))) + for a, b in zip(path[:-1], path[1:]): + d = np.stack((xx - a[0], yy - a[1]), axis=-1) + v = b - a + t = np.clip(d @ v / (v @ v), 0, 1) + alpha |= np.sum((d - t[..., None] * v) ** 2, axis=-1) < 2.5**2 + alpha |= (xx - 130) ** 2 + (yy - 110) ** 2 < 7**2 + return alpha.astype(float) + + +def assert_supported(points, faces, boundary, components): + triangles = points[faces] + area = np.abs( + np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0]) + ) + assert np.isfinite(points).all() and np.all(area > 1e-8) + assert not boundary[faces].all(axis=1).any() + edges = np.sort(np.concatenate((faces[:, :2], faces[:, 1:], faces[:, ::2])), axis=1) + edges, counts = np.unique(edges, axis=0, return_counts=True) + assert counts.max() <= 2 + assert not boundary[edges[counts == 2]].all(axis=1).any() + interior = np.flatnonzero(~boundary) + links = edges[~boundary[edges].any(axis=1)] + graph = coo_matrix( + (np.ones(len(links)), (links[:, 0], links[:, 1])), + shape=(len(points), len(points)), + ).tocsr() + count, _labels = connected_components(graph[interior][:, interior], directed=False) + assert count == len(components) + profile = g.poisson_balloon_profile(points, faces, boundary) + assert np.isfinite(profile).all() + assert np.all(profile[interior] > 0) + + +@pytest.mark.parametrize("spacing", (4, 8, 16, 28, 32, 36)) +@pytest.mark.parametrize("offset", (0, 1)) +def test_fine_bent_leg_and_joint_have_connected_positive_support(spacing, offset): + points, faces, boundary, components = fine.build_fine_mesh( + branch_alpha(offset), spacing, 0.9 + ) + assert_supported(points, faces, boundary, components) + interior = points[~boundary] + for checkpoint in ((90, 70), (130, 110), (146, 159)): + assert cKDTree(interior).query(np.asarray(checkpoint) + offset)[0] < 12 + + +@pytest.mark.parametrize("spacing", (4, 8, 16, 32)) +def test_fine_comb_hole_and_one_pixel_bridge(spacing): + alpha = np.zeros((160, 180)) + alpha[15:145, 15:42] = 1 + alpha[45:115, 125:165] = 1 + alpha[75, 40:126] = 1 + alpha[65:90, 140:150] = 0 + for row in range(20, 140, 12): + alpha[row : row + 2, 35:110] = 1 + result = fine.build_fine_mesh(alpha, spacing, 0.9) + assert_supported(*result) + points, faces, _boundary, components = result + assert len(components) == 1 + assert not np.any( + g.points_inside_contours( + points[faces].mean(axis=1), + [np.asarray(((140, 65), (150, 65), (150, 90), (140, 90)))], + ) + ) + + +def test_fine_cleanup_uses_threshold_and_preserves_input_and_largest_island(): + alpha = np.zeros((40, 60)) + alpha[5:25, 5:25] = 0.5 + alpha[12, 20:45] = 0.5 + alpha[32:34, 50:52] = 1 + original = alpha.copy() + filtered, stats = fine.filter_alpha(alpha, 0.3) + assert stats["components_after"] == 1 + assert filtered[12, 44] == 0.5 and filtered[32, 50] == 0 + np.testing.assert_array_equal(alpha, original) + assert_supported(*fine.build_fine_mesh(alpha, 32, 0.3)) + assert_supported(*fine.build_fine_mesh(alpha, 32, 0.9)) + with pytest.raises(ValueError, match="no visible"): + fine.build_fine_mesh(np.zeros((10, 10)), 32, 0.9) + + +def test_higher_detail_recovers_curve_instead_of_splitting_low_edges(): + yy, xx = np.mgrid[:240, :240] + alpha = ((xx - 120) ** 2 + (yy - 120) ** 2 < 92**2).astype(float) + reference = g.extract_selection_contours(alpha, max_resolution=240, threshold=0.9) + source = g.resample_closed_contour(np.asarray(reference.components[0][0]), 0.5) + errors = [] + for spacing in (32, 8, 4): + contours = fine.contours_for_component(reference.components[0], spacing) + sampled = g.resample_closed_contour(contours[0], 0.5) + forward = fine.ContourDomain(contours).clearance(source) + backward = fine.ContourDomain( + [np.asarray(reference.components[0][0])] + ).clearance(sampled) + errors.append(max(np.percentile(forward, 95), np.percentile(backward, 95))) + assert errors[1] < errors[0] + assert errors[2] <= errors[1] + 0.1 + + +def test_segment_validation_rejects_holes_tangency_and_outside(): + domain = fine.ContourDomain( + [ + np.asarray(((0, 0), (20, 0), (20, 20), (0, 20))), + np.asarray(((8, 8), (12, 8), (12, 12), (8, 12))), + ] + ) + starts = np.asarray(((2, 2), (2, 10), (2, 8), (-1, 2), (2, 2))) + ends = np.asarray(((18, 2), (18, 10), (18, 8), (2, 2), (2, 2))) + assert domain.valid_segments(starts, ends).tolist() == [ + True, + False, + False, + False, + True, + ] + + +def test_closed_mode_uses_original_geometry_without_fine_import(monkeypatch): + from types import SimpleNamespace + from mathutils import Matrix + from anyimage.common.image import image_empty_bounds + + source = SimpleNamespace( + data=SimpleNamespace(size=(80, 60)), + empty_display_size=2.0, + empty_image_offset=(-0.5, -0.5), + matrix_world=Matrix.Identity(4), + ) + monkeypatch.setattr( + g, + "addon_preferences", + lambda _context: SimpleNamespace(balloon_profile_algorithm="POISSON"), + ) + monkeypatch.setattr( + fine, + "build_fine_mesh", + lambda *_args: pytest.fail("Fine algorithm called while disabled"), + ) + alpha = np.ones((60, 80)) + actual = g.build_base_shape( + None, source, 16, alpha, (0, 0, 80, 60), fine_outline=False + ) + contours = g.extract_cutout_contours(alpha, 16, threshold=0.9) + expected = g.cutout_domain( + contours, + image_empty_bounds(source), + g.cutout_sampling_spacing(source, 16), + ((1.0, 0.0), (0.0, 1.0)), + ) + np.testing.assert_array_equal(actual.vertices, expected[0]) + np.testing.assert_array_equal(actual.uv, expected[2]) + np.testing.assert_array_equal(actual.balloon, expected[-1]) + + +def test_width_index_matches_exhaustive_covering_disks(): + from scipy import ndimage + + width = fine.WidthSamples(branch_alpha().astype(bool)) + points = np.asarray(((48, 48), (85, 70), (130, 110), (147, 160), (2, 2))) + radii = [] + for point in points: + covered = np.sum((width.centers - point) ** 2, axis=1) <= width.reach_squared + radii.append(max(0.5, width.radius[covered].max() if covered.any() else 0.5)) + distances = ndimage.map_coordinates( + width.distance, + [points[:, 1] - 0.5, points[:, 0] - 0.5], + order=1, + mode="nearest", + ) + expected = np.maximum( + 0.7, np.minimum(32, np.minimum(16, np.asarray(radii) * 0.9) + 0.4 * distances) + ) + np.testing.assert_allclose(width(points), expected) + + +def test_relaxation_reduces_sampling_error_without_adding_points(): + contours = [np.asarray(((4.0, 4.0), (194.0, 4.0), (194.0, 104.0), (4.0, 104.0)))] + spine = np.empty((0, 2)) + original = g.uniform_interior_points_for_contours(contours, 16) + relaxed = fine.relax_points(contours, spine, (110, 200), 16) + yy, xx = np.mgrid[6:104:4, 6:194:4] + samples = np.column_stack((xx.ravel() + 0.5, yy.ravel() + 0.5)) + + def energy(free): + return np.mean( + cKDTree(np.concatenate((*contours, free))).query(samples)[0] ** 2 + ) + + assert len(relaxed) == len(original) + assert energy(relaxed) < energy(original) + assert fine.ContourDomain(contours).contains(relaxed).all() + + +def test_cdt_preserves_constraint_split_by_an_interior_vertex(): + contours = [np.asarray(((0.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0)))] + spine = np.asarray(((2.0, 5.0), (18.0, 5.0))) + points, faces, constraints = fine.triangulate( + contours, spine, [(0, 1)], np.asarray(((10.0, 5.0),)), 8 + ) + assert len(constraints) == 2 + assert np.isclose( + np.linalg.norm( + points[constraints[:, 1]] - points[constraints[:, 0]], axis=1 + ).sum(), + 16, + ) + assert not g.boundary_vertices_from_faces(len(points), faces)[constraints].any() + + +def test_contour_validation_accepts_pixel_contacts_and_rejects_crossings(): + crossing = np.asarray(((0, 0), (4, 4), (0, 4), (4, 0))) + assert not fine.valid_contours([crossing]) + touching = np.asarray( + ((0, 0), (2, 0), (2, 2), (4, 2), (4, 4), (2, 4), (2, 2), (0, 2)) + ) + assert fine.valid_contours([touching]) + + +@pytest.mark.parametrize("offset", [0, 2048, 4096, 8192, 16384]) +@pytest.mark.parametrize("spacing", [4, 0.5]) +def test_cdt_preserves_short_constraints_at_large_pixel_coordinates(offset, spacing): + contour = np.asarray(((0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0))) + offset + spine = np.asarray(((5.1234567, 5.654321), (6.456789, 6.234567))) + offset + free = g.uniform_interior_points_for_contours([contour], spacing) + points, faces, constraints = fine.triangulate( + [contour], spine, [(0, 1)], free, spacing + ) + assert len(constraints) >= 1 + assert not g.boundary_vertices_from_faces(len(points), faces)[constraints].any() + + +@pytest.mark.parametrize( + "damage", ["missing_edge", "missing_faces", "missing_endpoint"] +) +def test_cdt_rejects_broken_constraint_coverage(monkeypatch, damage): + real_cdt = fine.delaunay_2d_cdt + + def damaged_cdt(*args): + result = list(real_cdt(*args)) + result[2] = list(result[2]) + result[3] = [list(ids) for ids in result[3]] + result[4] = [list(ids) for ids in result[4]] + constraint = len(args[1]) - 1 + edge_id = next(i for i, ids in enumerate(result[4]) if constraint in ids) + if damage == "missing_edge": + result[4][edge_id].remove(constraint) + elif damage == "missing_faces": + a, b = result[1][edge_id] + result[2] = [face for face in result[2] if not (a in face and b in face)] + else: + endpoint = args[1][-1][1] + for originals in result[3]: + if endpoint in originals: + originals.remove(endpoint) + return result + + monkeypatch.setattr(fine, "delaunay_2d_cdt", damaged_cdt) + contour = np.asarray(((0.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0))) + spine = np.asarray(((2.0, 5.0), (18.0, 5.0))) + with pytest.raises(ValueError, match="lost an interior constraint"): + fine.triangulate([contour], spine, [(0, 1)], np.asarray(((10.0, 5.0),)), 8) diff --git a/tests/test_cutout_geometry.py b/tests/test_cutout_geometry.py index 790ae26..3aa82cc 100644 --- a/tests/test_cutout_geometry.py +++ b/tests/test_cutout_geometry.py @@ -331,15 +331,14 @@ def test_cutout_crop_bounds_map_to_the_source_plane(): np.testing.assert_allclose(bounds, (-0.5, 0.5, -0.25, 0.25)) -def test_depth_surface_cutout_uses_half_alpha_boundary(): +def test_depth_surface_cutout_uses_default_alpha_boundary(): alpha = np.zeros((12, 16), dtype=np.float32) - alpha[2:10, 2:14] = 0.49 - alpha[4:8, 5:11] = 0.51 + alpha[2:10, 2:14] = 0.89 + alpha[4:8, 5:11] = 0.91 selection_contours = cutout_geometry.extract_cutout_contours( alpha, 64, - threshold=0.5, ) contour = np.asarray(selection_contours.components[0][0]) @@ -348,6 +347,28 @@ def test_depth_surface_cutout_uses_half_alpha_boundary(): np.testing.assert_allclose(contour.max(axis=0), (11.0, 8.0)) +def test_cutout_threshold_controls_contour_and_calibration(): + alpha = np.zeros((12, 16), dtype=np.float32) + alpha[2:10, 2:14] = 0.2 + alpha[4:8, 5:11] = 1.0 + for threshold, lower, upper in [ + (0.0, (2, 2), (14, 10)), + (0.1, (2, 2), (14, 10)), + (0.5, (5, 4), (11, 8)), + (1.0, (5, 4), (11, 8)), + ]: + contours = cutout_geometry.extract_cutout_contours( + alpha, 64, threshold=threshold + ) + contour = np.asarray(contours.components[0][0]) + np.testing.assert_allclose(contour.min(axis=0), lower) + np.testing.assert_allclose(contour.max(axis=0), upper) + valid, _ = cutout_geometry.selection_calibration( + alpha, alpha.shape, threshold=threshold + ) + np.testing.assert_array_equal(valid, (alpha > 0) & (alpha >= threshold)) + + def test_blender_cutout_alpha_pipeline_does_not_import_pillow(): original_import = builtins.__import__ diff --git a/tests/test_cutout_interaction.py b/tests/test_cutout_interaction.py index 68c20a5..a90aaf6 100644 --- a/tests/test_cutout_interaction.py +++ b/tests/test_cutout_interaction.py @@ -37,11 +37,15 @@ def cutout_context( mesh_detail="LOW", generate_normal=False, inward_axis="NEGATIVE_Z", + alpha_threshold=0.9, + fine_outline=True, ): settings = SimpleNamespace( cutout_mesh_detail=mesh_detail, cutout_generate_normal=generate_normal, cutout_inward_axis=inward_axis, + cutout_alpha_threshold=alpha_threshold, + cutout_fine_outline=fine_outline, ) return SimpleNamespace( window_manager=window_manager, @@ -49,6 +53,21 @@ def cutout_context( ) +def test_cutout_menu_captures_fine_outline_before_later_redraw(monkeypatch): + window_manager = WindowManager() + context = cutout_context(window_manager) + monkeypatch.setattr(interaction, "ai_ready", lambda: False) + + def delayed_popup(_event, draw, **_options): + context.scene.anyimage_settings.cutout_fine_outline = False + draw(SimpleNamespace(layout=SimpleNamespace(menu_pie=lambda: window_manager.pie)), None) + + window_manager.popup_menu_pie = delayed_popup + interaction.open_shape_pie(context, object(), cutout_source(), + SelectionPath(points=((0, 0), (10, 0), (0, 10)))) + assert all(call[2].fine_outline is True for call in window_manager.pie.calls) + + def cutout_source(name="Source", size=(2048, 1024)): return SimpleNamespace(name=name, data=SimpleNamespace(size=size)) @@ -90,6 +109,8 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch): mesh_detail="MEDIUM", generate_normal=True, inward_axis="POSITIVE_X", + alpha_threshold=0.1, + fine_outline=False, ) source = cutout_source(size=(4096, 2048)) monkeypatch.setattr(interaction, "ai_ready", lambda: True) @@ -119,6 +140,8 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch): "DEPTH_SURFACE", ] assert all(call[2].generate_normal for call in window_manager.pie.calls) + assert all(call[2].alpha_threshold == 0.1 for call in window_manager.pie.calls) + assert all(call[2].fine_outline is False for call in window_manager.pie.calls) assert len(serialized) == 1 assert set(json.loads(serialized[0])) == {"points"} assert all( diff --git a/tests/test_cutout_objects.py b/tests/test_cutout_objects.py index 7fca6cf..174331d 100644 --- a/tests/test_cutout_objects.py +++ b/tests/test_cutout_objects.py @@ -144,7 +144,7 @@ def test_image_edit_result_uses_complete_shared_source_name(): np.zeros(4 * 4 * 4, dtype=np.float32), (4, 4), ) - image_data.replace_empty_image(source, result_image) + result_image = image_data.replace_empty_image(source, result_image) assert shared.data == source_image assert source_image.alpha_mode == "STRAIGHT" @@ -168,7 +168,7 @@ def test_image_edit_result_recovers_unshared_source_name(): np.zeros(4 * 4 * 4, dtype=np.float32), (4, 4), ) - image_data.replace_empty_image(source, result_image) + result_image = image_data.replace_empty_image(source, result_image) assert source.data == result_image assert result_image.name == "Poster.png" @@ -208,7 +208,7 @@ def test_mask_frame_mask_add_preserves_packed_hidden_rgb(): (descriptor,), ((0, 4), (4, 4), (4, 0), (0, 0)), (4, 4) ) result = image_data.create_image_edit_result(source.data, framed, (4, 4)) - image_data.replace_empty_image(source, result) + result = image_data.replace_empty_image(source, result) assert result.alpha_mode == "PREMUL" assert source.use_empty_image_alpha assert result.packed_file is not None @@ -261,7 +261,7 @@ def test_frame_restored_alpha_keeps_foreground_blended_into_white(): sources, ((0, 4 / 3), (6, 4 / 3), (6, 2 / 3), (0, 2 / 3)), (6, 2) ) result = image_data.create_image_edit_result(front["object"].data, pixels, (6, 2)) - image_data.replace_empty_image(front["object"], result) + result = image_data.replace_empty_image(front["object"], result) encoded = np.asarray(Image.open(BytesIO(result.packed_file.data)).convert("RGBA")) / 255 np.testing.assert_allclose(encoded[..., :3], expected, atol=1 / 255) np.testing.assert_allclose(encoded[..., 3:4], source_alpha, atol=1 / 255) @@ -437,7 +437,11 @@ def test_cutout_axes_produce_the_same_world_geometry(shape): expected = np.asarray(sorted(map(tuple, world_positions(positive_x)))) actual = np.asarray(sorted(map(tuple, world_positions(negative_z)))) - assert np.allclose(actual, expected, atol=2e-6) + from scipy.optimize import linear_sum_assignment + + distances = np.linalg.norm(actual[:, None] - expected[None], axis=2) + rows, columns = linear_sum_assignment(distances) + assert distances[rows, columns].max() < 2e-6 assert np.allclose(negative_z.matrix_world, source.matrix_world) @@ -2100,7 +2104,9 @@ def test_cutout_content_merge_keeps_the_geometric_selection_unchanged(): original = selection.copy() alpha = np.asarray(((1.0, 0.5), (0.0, 0.25)), dtype=np.float32) - content = cutout_main._cutout_content_values(alpha, selection) + content = cutout_main._cutout_content_values( + alpha, selection, alpha_threshold=0.5 + ) np.testing.assert_array_equal(selection, original) np.testing.assert_allclose(content, alpha * original) @@ -2140,6 +2146,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): content_values = cutout_main._cutout_content_values( rgba[top:bottom, left:right, 3], selection_mask.values, + alpha_threshold=0.5, ) color_image = cutout_object.create_cropped_color_image( source, @@ -2153,6 +2160,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): 16, content_values, selection_mask.bounds, + alpha_threshold=0.5, color_image=color_image, ) diff --git a/tests/test_edge_depth.py b/tests/test_edge_depth.py new file mode 100644 index 0000000..e4aff88 --- /dev/null +++ b/tests/test_edge_depth.py @@ -0,0 +1,221 @@ +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import numpy as np +import pytest +from PIL import Image + +sys.path.insert(0, str(Path(__file__).parents[1] / "src" / "anyimage")) + +from server.geometry import prediction_artifacts as artifacts +from server.geometry.edge_depth import extend_edge_depth +from server.models.geometry import GeometryFrame +from server.models.onnx_moge2 import depth_to_points + + +def make_frame(depth): + depth = np.asarray(depth, dtype=np.float32) + height, width = depth.shape + normalized = np.array([[1.2, 0, 0.4], [0, 1.4, 0.6], [0, 0, 1]], np.float32) + intrinsics = normalized.copy() + intrinsics[0] *= width + intrinsics[1] *= height + return GeometryFrame( + depth=depth, + validity=np.ones_like(depth), + intrinsics=intrinsics, + points=depth_to_points(depth, normalized), + normal=np.zeros((*depth.shape, 3), np.float32), + ) + + +def silhouette(): + alpha = np.zeros((25, 35), np.float32) + alpha[4:21, 4:22] = 1 + alpha[4:21, 22:25] = 0.2 + depth = np.full(alpha.shape, 20, np.float32) + depth[alpha == 1] = 2 + return make_frame(depth), alpha + + +def test_hair_extension_preserves_core_alpha_and_projection(): + frame, alpha = silhouette() + original_alpha = alpha.copy() + result = extend_edge_depth(frame, alpha) + assert np.all(result.depth[alpha >= 0.1] == 2) + np.testing.assert_array_equal(result.points[9:16, 9:16], frame.points[9:16, 9:16]) + np.testing.assert_array_equal(alpha, original_alpha) + np.testing.assert_array_equal(frame.depth[alpha == 0.2], 20) + np.testing.assert_allclose( + result.points[..., :2] / result.points[..., 2:], + frame.points[..., :2] / frame.points[..., 2:], + atol=1e-6, + ) + assert result.validity is frame.validity + assert result.normal is frame.normal + assert np.isfinite(result.points).all() + + +def test_sampling_padding_is_bounded(): + frame, alpha = silhouette() + result = extend_edge_depth(frame, alpha, alpha_threshold=0.1) + assert np.all(result.depth[12, 24:29] == 2) + assert result.depth[12, 29] == 20 + assert (result.depth[12, 24] + result.depth[12, 25]) / 2 == 2 + + +def test_edge_padding_tracks_selected_threshold(): + frame, alpha = silhouette() + loose = extend_edge_depth(frame, alpha, alpha_threshold=0.1) + strict = extend_edge_depth(frame, alpha) + assert loose.depth[12, 28] == 2 + assert strict.depth[12, 28] == 20 + + +def test_strict_threshold_excludes_lower_alpha_donors(): + alpha = np.zeros((10, 10), np.float32) + alpha[3:7, 3:7] = 0.96 + alpha[5, 5] = 1 + depth = np.full(alpha.shape, 10, np.float32) + depth[5, 5] = 2 + result = extend_edge_depth(make_frame(depth), alpha, alpha_threshold=1) + assert result.depth[5, 6] == 2 + + +def test_image_rectangle_does_not_erode_subject(): + alpha = np.ones((15, 20), np.float32) + alpha[:, 15:] = 0 + depth = np.full(alpha.shape, 2, np.float32) + depth[0, 0] = 3 + frame = make_frame(depth) + result = extend_edge_depth(frame, alpha) + assert result.depth[0, 0] == 3 + np.testing.assert_array_equal(result.points[0, 0], frame.points[0, 0]) + + +def test_small_components_fall_back_without_borrowing_other_depths(): + alpha = np.zeros((15, 30), np.float32) + alpha[5, 5:7] = [0.95, 0.1] + alpha[5, 10:12] = [1, 0.2] + alpha[5, 14] = 0.94 + depth = np.full(alpha.shape, 30, np.float32) + depth[5, 5] = 2 + depth[5, 10] = 8 + result = extend_edge_depth(make_frame(depth), alpha) + assert result.depth[5, 6] == 2 + assert result.depth[5, 11] == 8 + assert result.depth[5, 14] == 30 + assert result.depth[5, 7] == 2 + assert result.depth[5, 9] == 8 + + +def test_diagonal_pixels_share_a_component(): + alpha = np.zeros((10, 10), np.float32) + alpha[4, 4] = 1 + alpha[5, 5] = 0.2 + depth = np.full(alpha.shape, 10, np.float32) + depth[4, 4] = 2 + result = extend_edge_depth(make_frame(depth), alpha) + assert result.depth[5, 5] == 2 + + +def test_invalid_donors_are_excluded(): + frame, alpha = silhouette() + frame.depth[10, 12] = 100 + frame.validity[10, 12] = 0 + result = extend_edge_depth(frame, alpha) + assert result.depth[10, 12] == 2 + assert result.validity[10, 12] == 0 + + +def test_local_smoothing_reduces_donor_step_and_preserves_core(): + frame, alpha = silhouette() + frame.depth[12:, alpha.shape[1] // 4:22] = 4 + result = extend_edge_depth(frame, alpha) + assert 2 < result.depth[11, 23] < 4 + assert 2 < result.depth[12, 23] < 4 + np.testing.assert_array_equal(result.depth[8:17, 8:17], frame.depth[8:17, 8:17]) + + +@pytest.mark.parametrize("alpha_value", [0, 0.3, 1]) +def test_images_without_repair_return_original_frame(alpha_value): + frame = make_frame(np.ones((5, 8))) + assert extend_edge_depth(frame, np.full((5, 8), alpha_value)) is frame + + +def test_alpha_resize_retains_threshold_values(tmp_path): + rgba = np.zeros((1, 4, 4), np.uint8) + rgba[0, :, 3] = [25, 26, 242, 243] + path = tmp_path / "alpha.png" + Image.fromarray(rgba).save(path) + alpha = artifacts._image_alpha(path, (2, 8)) + np.testing.assert_array_equal( + alpha >= 0.1, [[False] * 2 + [True] * 6] * 2 + ) + np.testing.assert_array_equal( + alpha >= 0.95, [[False] * 6 + [True] * 2] * 2 + ) + + +@pytest.mark.parametrize( + "depth_mode,normal_mode,correct", + [ + ("VECTOR", "OBJECT", True), + ("VECTOR", "TANGENT", True), + ("Z", "TANGENT", True), + ("Z", "TANGENT", False), + ("NONE", "OBJECT", True), + ], +) +def test_artifacts_use_corrected_depth_and_original_normals( + tmp_path, depth_mode, normal_mode, correct +): + frame, alpha = silhouette() + if depth_mode == "Z": + frame.points = None + rgba = np.full((*alpha.shape, 4), 255, np.uint8) + rgba[..., 3] = np.round(alpha * 255).astype(np.uint8) + path = tmp_path / "source.png" + Image.fromarray(rgba).save(path) + context = SimpleNamespace( + directory=tmp_path, + resource=lambda _: None, + progress=lambda *_: None, + check_cancelled=lambda: None, + ) + with ( + patch.object(artifacts.moge2, "infer", return_value=[frame]), + patch.object( + artifacts, "write_vector_depth_texture", + return_value=tmp_path / "vector.exr", + ) as vector, + patch.object( + artifacts, "write_z_depth_texture", return_value=tmp_path / "z.exr" + ) as z, + patch.object( + artifacts, "write_depth_metadata", return_value=tmp_path / "depth.json" + ) as metadata, + patch.object( + artifacts, "_write_normal", return_value=tmp_path / "normal.png" + ) as normal, + patch.object(artifacts, "extend_edge_depth", wraps=extend_edge_depth) as repair, + ): + artifacts.generate_moge2_artifacts( + context, {}, path, depth_mode=depth_mode, normal_mode=normal_mode, + correct_edge_depth=correct, + ) + assert normal.call_args.args[0] is frame + assert repair.call_count == int(correct and depth_mode != "NONE") + if depth_mode != "NONE": + written_frame = (vector if depth_mode == "VECTOR" else z).call_args.args[0] + assert metadata.call_args.args[0] is written_frame + if correct: + assert written_frame.depth[12, 24] == 2 + else: + assert written_frame is frame + else: + metadata.assert_not_called() + vector.assert_not_called() + z.assert_not_called() diff --git a/tests/test_empty_image_edits.py b/tests/test_empty_image_edits.py new file mode 100644 index 0000000..fc45826 --- /dev/null +++ b/tests/test_empty_image_edits.py @@ -0,0 +1,254 @@ +import sys +from pathlib import Path +from unittest.mock import patch + +import bpy +import numpy as np +import pytest +from PIL import Image + +sys.path.insert(0, str(Path(__file__).parents[1] / "src")) + +from anyimage.common import image as images + + +@pytest.fixture +def source(): + bpy.ops.wm.read_factory_settings(use_empty=True) + image = bpy.data.images.new("Source.png", width=2, height=2, alpha=True) + image.pixels.foreach_set(np.tile([0.2, 0.4, 0.6, 0.25], 4).astype(np.float32)) + image.pack() + obj = create_empty("Source", image) + yield obj + bpy.ops.wm.read_factory_settings(use_empty=True) + + +def create_empty(name, image): + obj = bpy.data.objects.new(name, None) + obj.empty_display_type = "IMAGE" + obj.data = image + bpy.context.collection.objects.link(obj) + return obj + + +def edit_result(source): + return images.create_image_edit_result( + source.data, np.tile([0.8, 0.3, 0.1, 0.0], 12).astype(np.float32), (4, 3), + ) + + +@pytest.mark.parametrize("sharing", ["visible", "hidden", "other_scene"]) +def test_shared_edit_preserves_other_empty(source, sharing): + original = source.data + packed = bytes(original.packed_file.data) + rgba = images.image_rgba(original) + result = edit_result(source) + # Create the second user after preparing the result, as during an async Job. + other = create_empty("Other", original) + other.hide_viewport = sharing == "hidden" + if sharing == "other_scene": + bpy.context.collection.objects.unlink(other) + scene = bpy.data.scenes.new("Other scene") + scene.collection.objects.link(other) + other.empty_display_size = 7 + other.image_user.frame_start = 9 + assert images.has_other_image_empty(source) + assert images.replace_empty_image(source, result) == result + assert source.data == result + assert result.name == "Source.png.001" + assert other.data == original + assert other.empty_display_size == 7 + assert other.image_user.frame_start == 9 + assert original.name == "Source.png" + assert bytes(original.packed_file.data) == packed + np.testing.assert_array_equal(images.image_rgba(original), rgba) + + +@pytest.mark.parametrize("reference", ["none", "fake_user", "material", "collections"]) +def test_sole_edit_preserves_id_and_releases_result(source, reference, tmp_path): + original = source.data + pointer = original.as_pointer() + original.use_fake_user = reference == "fake_user" + if reference == "material": + material = bpy.data.materials.new("Manual reference") + material.use_nodes = True + node = material.node_tree.nodes.new("ShaderNodeTexImage") + node.image = original + if reference == "collections": + collection = bpy.data.collections.new("Another collection") + bpy.context.scene.collection.children.link(collection) + collection.objects.link(source) + count = len(bpy.data.images) + result = edit_result(source) + expected = images.image_rgba(result) + assert not images.has_other_image_empty(source) + final = images.replace_empty_image(source, result) + assert final.as_pointer() == pointer == source.data.as_pointer() + assert final.name == "Source.png" + assert final.use_fake_user == (reference == "fake_user") + assert len(bpy.data.images) == count + if reference == "material": + assert node.image == final + np.testing.assert_allclose(images.image_rgba(final), expected, atol=1 / 255) + path = str(tmp_path / "edited.blend") + bpy.ops.wm.save_as_mainfile(filepath=path) + bpy.ops.wm.open_mainfile(filepath=path) + reopened = bpy.data.objects["Source"].data + assert reopened.name == "Source.png" + np.testing.assert_allclose(images.image_rgba(reopened), expected, atol=1 / 255) + + +@pytest.mark.parametrize("outside", [False, True]) +def test_frame_sharing_uses_surviving_objects(source, outside): + original = source.data + merged = create_empty("Merged", original) + if outside: + other = create_empty("Outside", original) + assert images.has_other_image_empty(source, removed_objects=(merged,)) == outside + result = edit_result(source) + final = images.replace_empty_image(source, result, removed_objects=(merged,)) + bpy.data.objects.remove(merged, do_unlink=True) + assert (final == original) == (not outside) + if outside: + assert other.data == original + assert tuple(original.size) == (2, 2) + + +@pytest.mark.parametrize("shared", [False, True]) +def test_failed_display_configuration_restores_content(source, tmp_path, shared): + original = source.data + if shared: + create_empty("Other", original) + # Preserve unsaved pixels as well as the previous packed representation. + original.pixels.foreach_set(np.tile([0.1, 0.9, 0.2, 0.5], 4).astype(np.float32)) + packed = bytes(original.packed_file.data) + pixels = images.image_pixels(original) + alpha = original.alpha_mode + source.image_user.frame_start = 5 + source.image_user.use_auto_refresh = False + for index in (1, 2): + Image.new("RGBA", (4, 3), (100, 150, 200, 255)).save(tmp_path / f"image.{index:04d}.png") + result = bpy.data.images.load(str(tmp_path / "image.0001.png")) + result.source = "SEQUENCE" + count = len(bpy.data.images) - 1 + + def fail_configuration(user, *args, **kwargs): + user.frame_start = 99 + user.use_auto_refresh = True + raise RuntimeError("Display failed") + + with patch.object(images, "configure_image_user", side_effect=fail_configuration): + with pytest.raises(RuntimeError, match="Display failed"): + images.replace_empty_image(source, result) + assert source.data == original + assert tuple(original.size) == (2, 2) + assert original.alpha_mode == alpha + assert original.is_dirty + assert bytes(original.packed_file.data) == packed + np.testing.assert_array_equal(images.image_pixels(original), pixels) + assert source.image_user.frame_start == 5 + assert not source.image_user.use_auto_refresh + assert not source.use_empty_image_alpha + assert not original.use_fake_user + assert len(bpy.data.images) == count + + +def test_failed_content_write_restores_source(source): + original = source.data + packed = bytes(original.packed_file.data) + result = edit_result(source) + restore = images.restore_image_content + calls = 0 + + def fail_once(image, state): + nonlocal calls + calls += 1 + restore(image, state) + if calls == 1: + raise RuntimeError("Write failed") + + with patch.object(images, "restore_image_content", side_effect=fail_once): + with pytest.raises(RuntimeError, match="Write failed"): + images.replace_empty_image(source, result) + assert source.data == original + assert tuple(original.size) == (2, 2) + assert bytes(original.packed_file.data) == packed + assert len(bpy.data.images) == 1 + + +def test_animated_result_preserves_id_and_reopens(source, tmp_path): + original = source.data + source.image_user.frame_start = 4 + source.image_user.use_cyclic = True + for index in (1, 2): + Image.new("RGBA", (4, 3), (index * 80, 100, 50, 255)).save(tmp_path / f"image.{index:04d}.png") + original.source = "SEQUENCE" + result = images.load_image_edit_result(source, tmp_path) + expected_path = result.filepath_raw + expected_packed = bytes(result.packed_file.data) if result.packed_file else None + final = images.replace_empty_image(source, result) + assert final == original + assert final.source == "SEQUENCE" + assert final.filepath_raw == expected_path + assert (bytes(final.packed_file.data) if final.packed_file else None) == expected_packed + assert len(images.collect_sequence_frames(final)) == 2 + assert source.image_user.frame_start == 4 + assert source.image_user.frame_duration == 2 + assert source.image_user.use_cyclic + assert source.image_user.use_auto_refresh + path = str(tmp_path / "animation.blend") + bpy.ops.wm.save_as_mainfile(filepath=path) + bpy.ops.wm.open_mainfile(filepath=path) + reopened = bpy.data.objects["Source"].data + assert reopened.name == "Source.png" + assert reopened.source == "SEQUENCE" + frames = images.collect_sequence_frames(reopened) + assert len(frames) == 2 + for index, frame in enumerate(frames, 1): + loaded = bpy.data.images.load(str(frame), check_existing=False) + try: + assert tuple(loaded.size) == (4, 3) + np.testing.assert_allclose( + images.image_pixels(loaded)[:4], + np.array([index * 80, 100, 50, 255]) / 255, + atol=1 / 255, + ) + finally: + bpy.data.images.remove(loaded) + + +@pytest.mark.parametrize("floating", [False, True]) +def test_failed_generated_commit_restores_unsaved_pixels(source, floating): + original = bpy.data.images.new("Generated", width=3, height=2, alpha=True, float_buffer=floating) + source.data = original + original.pixels.foreach_set(np.tile([0.12, 0.24, 0.48, 0.5], 6).astype(np.float32)) + expected = images.image_pixels(original) + result = edit_result(source) + restore = images.restore_image_content + calls = 0 + + def fail_once(image, state): + nonlocal calls + calls += 1 + restore(image, state) + if calls == 1: + raise RuntimeError("Write failed") + + with patch.object(images, "restore_image_content", side_effect=fail_once): + with pytest.raises(RuntimeError, match="Write failed"): + images.replace_empty_image(source, result) + assert original.source == "GENERATED" + assert original.is_float == floating + assert original.packed_file is None + assert tuple(original.size) == (3, 2) + np.testing.assert_array_equal(images.image_pixels(original), expected) + + +def test_failed_preparation_releases_result(source): + result = edit_result(source) + count = len(bpy.data.images) - 1 + with patch.object(images, "empty_display_alignment", side_effect=ValueError("Invalid bounds")): + with pytest.raises(ValueError, match="Invalid bounds"): + images.replace_empty_image(source, result, placement_bounds=(0, 0, 1, 1)) + assert len(bpy.data.images) == count + assert tuple(source.data.size) == (2, 2) diff --git a/tests/test_extension_packaging.py b/tests/test_extension_packaging.py index 9f1ed16..46a1251 100644 --- a/tests/test_extension_packaging.py +++ b/tests/test_extension_packaging.py @@ -71,6 +71,7 @@ class ExtensionPackagingTest(unittest.TestCase): "operators/cutout_tool/main.py", "operators/cutout_tool/interaction.py", "operators/cutout_tool/geometry.py", + "operators/cutout_tool/fine_mesh.py", "operators/cutout_tool/object.py", "operators/cutout_tool/shape.py", "operators/convert_to_plane/__init__.py", @@ -104,6 +105,7 @@ class ExtensionPackagingTest(unittest.TestCase): self.assertIn("server/jobs/depth_plane.py", names) self.assertIn("server/geometry/depth_texture.py", names) self.assertIn("server/geometry/prediction_artifacts.py", names) + self.assertIn("server/geometry/edge_depth.py", names) self.assertIn("server/geometry/normal_texture.py", names) self.assertNotIn( "server/geometry/generate_geometry_and_normal.py", names @@ -125,6 +127,8 @@ class ExtensionPackagingTest(unittest.TestCase): self.assertIn("server/models/onnx_upscale.py", names) self.assertIn("server/models/upscale.py", names) self.assertIn("server/__init__.py", names) + self.assertIn("operators/cutout_tool/polygon.py", names) + self.assertIn("licenses/scikit-image.txt", names) self.assertIn( "wheels/blendjob-0.1.16-py3-none-any.whl", names, @@ -141,30 +145,105 @@ class ExtensionPackagingTest(unittest.TestCase): def test_reads_extension_dependencies_from_pyproject(self): pack = load_pack() - self.assertEqual(pack.project_dependencies(), ["blendjob==0.1.16"]) + self.assertEqual( + pack.project_dependencies(), + [ + "blendjob==0.1.16", + "numpy>=1.26,<2.0", + "scipy==1.15.3", + ], + ) - def test_downloads_declared_wheels_for_target_platform(self): + def test_manifest_includes_all_resolved_numerical_wheels(self): + pack = load_pack() + wheels = [ + Path(name) + for name in ( + "blendjob-0.1.16-py3-none-any.whl", + "numpy-1.26.4-cp311-cp311-win_amd64.whl", + "scipy-1.15.3-cp311-cp311-win_amd64.whl", + ) + ] + manifest = tomllib.loads(pack.manifest_for_platform("windows-x64", wheels)) + self.assertEqual( + set(manifest["wheels"]), + { + f"./wheels/{wheel.name}" + for wheel in wheels + if not wheel.name.startswith("numpy-") + }, + ) + + def test_downloads_both_python_versions_for_each_platform(self): + pack = load_pack() + for platform, config in pack.PLATFORM_CONFIGS.items(): + with self.subTest(platform=platform), tempfile.TemporaryDirectory() as directory: + destination = Path(directory) + + def fake_run(command, check): + self.assertTrue(check) + target = Path(command[command.index("--dest") + 1]) + tag = "cp" + target.name.replace(".", "") + if target.name == "3.13": + constraints = Path(command[command.index("--constraint") + 1]) + self.assertEqual( + constraints.read_text(), "blendjob==0.1.16" + ) + else: + self.assertNotIn("--constraint", command) + for name in ( + "blendjob-0.1.16-py3-none-any.whl", + f"scipy-1.15.3-{tag}-{tag}-{config.pip_platform}.whl", + f"numpy-2.1.0-{tag}-{tag}-{config.pip_platform}.whl", + ): + (target / name).write_bytes(b"wheel") + + with patch.object(pack.subprocess, "run", side_effect=fake_run) as run: + wheels = pack.download_wheels(platform, destination) + + self.assertEqual(run.call_count, 2) + for call, version, numpy_requirement in zip( + run.call_args_list, + ("3.11", "3.13"), + ("numpy>=1.26,<2.0", "numpy>=2.1,<3.0"), + ): + command = call.args[0] + self.assertIn("blendjob==0.1.16", command) + self.assertIn(str(pack.SOURCE_DIR / "wheels"), command) + self.assertIn(f"--python-version={version}", command) + self.assertIn("--implementation=cp", command) + self.assertIn(f"--platform={config.pip_platform}", command) + self.assertEqual( + [value for value in command if value.startswith("numpy")], + [numpy_requirement], + ) + self.assertEqual(len(wheels), 3) + self.assertEqual( + [wheel.name for wheel in wheels], sorted({wheel.name for wheel in wheels}) + ) + self.assertFalse(any(wheel.name.startswith("numpy-") for wheel in wheels)) + with patch.object(pack, "DIST_DIR", destination / "dist"): + output = pack._build_extension(platform, wheels) + with ZipFile(output) as archive: + manifest = tomllib.loads(archive.read("blender_manifest.toml").decode()) + expected = {f"wheels/{wheel.name}" for wheel in wheels} + self.assertEqual({name[2:] for name in manifest["wheels"]}, expected) + self.assertEqual( + {name for name in archive.namelist() if name.startswith("wheels/")}, + expected, + ) + + def test_empty_second_python_download_is_rejected(self): pack = load_pack() with tempfile.TemporaryDirectory() as directory: - destination = Path(directory) - def fake_run(command, check): - self.assertTrue(check) - (destination / "blendjob-0.1.16-py3-none-any.whl").write_bytes(b"wheel") + target = Path(command[command.index("--dest") + 1]) + if target.name == "3.11": + (target / "blendjob-0.1.16-py3-none-any.whl").write_bytes(b"wheel") - with patch.object(pack.subprocess, "run", side_effect=fake_run) as run: - wheels = pack.download_wheels("linux-x64", destination) - - command = run.call_args.args[0] - self.assertIn("blendjob==0.1.16", command) - self.assertIn("--find-links", command) - self.assertIn(str(pack.SOURCE_DIR / "wheels"), command) - self.assertIn("--python-version=3.11", command) - self.assertIn("--platform=manylinux2014_x86_64", command) - self.assertEqual( - [wheel.name for wheel in wheels], - ["blendjob-0.1.16-py3-none-any.whl"], - ) + with patch.object(pack.subprocess, "run", side_effect=fake_run): + with self.assertRaisesRegex(RuntimeError, "Python 3.13"): + pack.download_wheels("windows-x64", Path(directory)) def test_rejects_unknown_platform(self): pack = load_pack() @@ -176,12 +255,113 @@ class ExtensionPackagingTest(unittest.TestCase): manifest = tomllib.loads( (pack.SOURCE_DIR / "blender_manifest.toml").read_text(encoding="utf-8") ) - self.assertEqual(set(pack.PLATFORMS), set(manifest["platforms"])) + self.assertTrue(set(manifest["platforms"]) <= set(pack.PLATFORMS)) self.assertEqual( set(pack.PLATFORMS), {"windows-x64", "macos-arm64", "linux-x64"}, ) + def test_prepares_local_wheels_and_removes_stale_versions(self): + pack = load_pack() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + source.mkdir() + manifest_path = source / "blender_manifest.toml" + manifest_path.write_text( + (pack.SOURCE_DIR / "blender_manifest.toml").read_text(encoding="utf-8"), + encoding="utf-8", + ) + destination = source / "wheels" + destination.mkdir() + (destination / "numpy-1.26.4-cp311-cp311-win_amd64.whl").write_bytes( + b"numpy" + ) + (destination / "scipy-1.14.0-cp311-cp311-win_amd64.whl").write_bytes(b"old") + (destination / "notes.txt").write_text("keep", encoding="utf-8") + wheel = root / "scipy-1.15.3-cp311-cp311-win_amd64.whl" + wheel.write_bytes(b"new") + with ( + patch.object(pack, "SOURCE_DIR", source), + patch.object(pack, "download_wheels", return_value=[wheel]), + ): + result = pack.prepare_local_dependencies("windows-x64") + self.assertEqual(result, [destination / wheel.name]) + self.assertEqual(result[0].read_bytes(), b"new") + self.assertTrue((destination / "notes.txt").exists()) + manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8")) + self.assertEqual(manifest["wheels"], [f"./wheels/{wheel.name}"]) + self.assertEqual(manifest["platforms"], ["windows-x64"]) + self.assertIn("permissions", manifest) + + def test_failed_download_keeps_local_dependencies_and_manifest(self): + pack = load_pack() + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) + manifest = source / "blender_manifest.toml" + manifest.write_bytes(b"original manifest") + wheels = source / "wheels" + wheels.mkdir() + old = wheels / "old.whl" + old.write_bytes(b"original wheel") + def fake_run(command, check): + target = Path(command[command.index("--dest") + 1]) + if target.name == "3.13": + raise RuntimeError("download failed") + (target / "blendjob-0.1.16-py3-none-any.whl").write_bytes(b"wheel") + + with ( + patch.object(pack, "SOURCE_DIR", source), + patch.object(pack.subprocess, "run", side_effect=fake_run), + ): + with self.assertRaisesRegex(RuntimeError, "download failed"): + pack.prepare_local_dependencies("windows-x64") + self.assertEqual(manifest.read_bytes(), b"original manifest") + self.assertEqual(old.read_bytes(), b"original wheel") + + def test_archive_excludes_blender_numpy(self): + pack = load_pack() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + wheel = root / "numpy-1.26.4-cp311-cp311-win_amd64.whl" + wheel.write_bytes(b"numpy") + with patch.object(pack, "DIST_DIR", root / "dist"): + output = pack._build_extension("windows-x64", [wheel]) + with ZipFile(output) as archive: + self.assertFalse(any("numpy-" in name for name in archive.namelist())) + self.assertEqual( + tomllib.loads(archive.read("blender_manifest.toml").decode())[ + "wheels" + ], + [], + ) + + def test_prepare_command_defaults_to_host_and_does_not_build(self): + pack = load_pack() + with ( + patch.object(pack, "current_platform", return_value="windows-x64"), + patch.object( + pack, "prepare_local_dependencies", return_value=[] + ) as prepare, + patch.object(pack, "build_all") as build, + ): + pack.main(["--prepare"]) + prepare.assert_called_once_with("windows-x64") + build.assert_not_called() + + def test_detects_supported_hosts(self): + pack = load_pack() + for system, machine, expected in ( + ("Windows", "AMD64", "windows-x64"), + ("Darwin", "arm64", "macos-arm64"), + ("Linux", "x86_64", "linux-x64"), + ): + with ( + patch.object(pack.host_platform, "system", return_value=system), + patch.object(pack.host_platform, "machine", return_value=machine), + ): + self.assertEqual(pack.current_platform(), expected) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_image_data.py b/tests/test_image_data.py index 60803e6..a7f96c2 100644 --- a/tests/test_image_data.py +++ b/tests/test_image_data.py @@ -112,7 +112,9 @@ def load_image_modules(): package.__path__ = [] loaded = [] - with patch.dict(sys.modules, {"bpy": fake_bpy, **packages}): + preferences = ModuleType("anyimage.preferences") + preferences.configured_material_view_adaptation = lambda: True + with patch.dict(sys.modules, {"bpy": fake_bpy, "anyimage.preferences": preferences, **packages}): for module_name, module_path in module_paths: specification = importlib.util.spec_from_file_location( module_name, @@ -221,14 +223,18 @@ class ImageDataTest(unittest.TestCase): raise TypeError("unsupported color space") self._name = value - image = SimpleNamespace(colorspace_settings=ColorSpaceSettings()) + image = SimpleNamespace( + colorspace_settings=ColorSpaceSettings(), + users=0, use_fake_user=False, alpha_mode="STRAIGHT", + ) scene = SimpleNamespace( view_settings=SimpleNamespace(view_transform="ACES 2.0") ) - result = self.image_depth.set_material_color_space(image, scene) + result = self.image_depth.configure_material_color_image(image, scene) - self.assertEqual(result, "sRGB") + self.assertIs(result, image) + self.assertEqual(image.alpha_mode, "PREMUL") self.assertEqual(image.colorspace_settings.name, "sRGB") def test_is_animated_image_accepts_movie_and_sequence(self): diff --git a/tests/test_material_view_adaptation.py b/tests/test_material_view_adaptation.py new file mode 100644 index 0000000..1331397 --- /dev/null +++ b/tests/test_material_view_adaptation.py @@ -0,0 +1,236 @@ +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import bpy +import numpy as np +import pytest +from PIL import Image + +sys.path.insert(0, str(Path(__file__).parents[1] / "src")) + +from anyimage import preferences +from anyimage.common import image as images +from anyimage.common import material +from anyimage.operators.clipboard_image import actions as clipboard +from anyimage.operators.convert_to_plane.object import create_color_image +from anyimage.operators.cutout_tool.object import create_cropped_color_image + + +@pytest.fixture +def color_data(): + original_images = set(bpy.data.images) + original_materials = set(bpy.data.materials) + original_objects = set(bpy.data.objects) + yield + for obj in set(bpy.data.objects) - original_objects: + bpy.data.objects.remove(obj, do_unlink=True) + for item in set(bpy.data.materials) - original_materials: + bpy.data.materials.remove(item, do_unlink=True) + for item in set(bpy.data.images) - original_images: + bpy.data.images.remove(item, do_unlink=True) + + +def view_scene(view): + return SimpleNamespace(view_settings=SimpleNamespace(view_transform=view)) + + +def color_texture(result): + return next(node.image for node in result.node_tree.nodes if node.type == "TEX_IMAGE") + + +def test_adaptation_preference_defaults_and_reads_current_value(): + with patch.object(preferences, "addon_preferences", return_value=None): + assert preferences.configured_material_view_adaptation() + with patch.object(preferences, "addon_preferences", return_value=SimpleNamespace()): + assert preferences.configured_material_view_adaptation() + settings = SimpleNamespace(adapt_material_to_view_transform=False) + with patch.object(preferences, "addon_preferences", return_value=settings): + assert not preferences.configured_material_view_adaptation() + settings.adapt_material_to_view_transform = True + assert preferences.configured_material_view_adaptation() + + +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("view,space", [*material.VIEW_TRANSFORM_COLOR_SPACES, ("Standard", "sRGB"), ("Unknown", "sRGB")]) +def test_color_space_and_alpha_follow_preference(color_data, enabled, view, space): + image = bpy.data.images.new("Material policy", width=2, height=1, alpha=True) + expected = np.array([0.2, 0.4, 0.8, 0.25, 0.6, 0.3, 0.1, 0], dtype=np.float32) + image.pixels.foreach_set(expected) + settings = SimpleNamespace(adapt_material_to_view_transform=enabled) + # The installed bpy may not include newer view transforms such as ACES 2.0. + try: + image.colorspace_settings.name = space + except TypeError: + space = "sRGB" + image.colorspace_settings.name = "sRGB" + image.pixels.foreach_set(expected) + with patch.object(preferences, "addon_preferences", return_value=settings): + result = material.configure_material_color_image(image, view_scene(view)) + selected = space if enabled else "sRGB" + assert result.colorspace_settings.name == selected + assert result.alpha_mode == ("PREMUL" if selected == "sRGB" else "STRAIGHT") + assert settings.adapt_material_to_view_transform == enabled + np.testing.assert_allclose(images.image_pixels(result), expected, atol=1 / 255) + + +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("kind", [ + "byte", "packed", "file", "dirty", + pytest.param("float", marks=pytest.mark.xfail( + strict=True, + reason="Native float RGB is reinterpreted by Blender after packing and reloading with a display color space", + )), +]) +def test_material_pixels_survive_pack_and_library_reload(color_data, tmp_path, enabled, kind): + expected = np.array([[[51, 102, 204, 64], [153, 76, 25, 0]]], dtype=np.uint8) + if kind in {"file", "dirty"}: + path = tmp_path / "source.png" + Image.fromarray(expected).save(path) + source = bpy.data.images.load(str(path), check_existing=False) + if kind == "dirty": + source.pixels.foreach_set((expected.astype(np.float32) / 255).ravel()) + else: + source = bpy.data.images.new("Material pixels", width=2, height=1, alpha=True, float_buffer=kind == "float") + source.pixels.foreach_set((expected.astype(np.float32) / 255).ravel()) + if kind == "packed": + source.pack() + source.reload() + images.configure_color_alpha(source) + before = images.image_pixels(source) + owner = bpy.data.objects.new("Color source owner", None) + owner.empty_display_type = "IMAGE" + owner.data = source + with patch.object(material, "configured_material_view_adaptation", return_value=enabled): + result = material.configure_material_color_image(source, view_scene("AgX")) + assert result != source + assert source.alpha_mode == "PREMUL" + np.testing.assert_allclose(images.image_pixels(source), before, atol=1 / 255) + np.testing.assert_allclose(images.image_pixels(result), before, atol=1 / 255) + if result.packed_file is None or result.is_dirty: + result.pack() + selected = (result.colorspace_settings.name, result.alpha_mode) + path = tmp_path / "material.blend" + bpy.data.libraries.write(str(path), {result}) + with bpy.data.libraries.load(str(path)) as (available, loaded): + loaded.images = available.images + restored = loaded.images[0] + assert (restored.colorspace_settings.name, restored.alpha_mode) == selected + np.testing.assert_allclose(images.image_pixels(restored), before, atol=1 / 255) + edited = images.create_image_edit_result(restored, images.image_pixels(restored), (2, 1)) + assert edited.alpha_mode == "PREMUL" + np.testing.assert_allclose(images.image_pixels(edited), before, atol=1 / 255) + + +@pytest.mark.parametrize("shadeless", [False, True]) +@pytest.mark.parametrize("entry", ["plane", "cutout", "depth", "clipboard"]) +def test_new_material_uses_current_preference_and_preserves_existing(color_data, entry, shadeless): + source = bpy.data.images.new("Shared source", width=2, height=2, alpha=True) + source.pixels.foreach_set(np.tile(np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32), 4)) + owner = bpy.data.objects.new("Shared Empty", None) + owner.empty_display_type = "IMAGE" + owner.data = source + before = images.image_pixels(source) + scene = view_scene("AgX") + settings = SimpleNamespace(adapt_material_to_view_transform=True) + data_textures = {} + if entry == "depth": + for key in ("displacement_image", "normal_image"): + image = bpy.data.images.new(key, width=2, height=2, float_buffer=True) + image.colorspace_settings.name = "Non-Color" + data_textures[key] = image + data_states = { + key: (image.colorspace_settings.name, image.alpha_mode, images.image_pixels(image)) + for key, image in data_textures.items() + } + with patch.object(preferences, "addon_preferences", return_value=settings): + if entry == "clipboard": + first = clipboard._create_image_material(source, shadeless, scene=scene) + else: + if entry == "plane": + color = create_color_image(owner) + elif entry == "cutout": + color = create_cropped_color_image(owner, (0, 0, 2, 2), images.image_rgba(source)) + else: + color = source + first = material.create_image_material(owner, color, 0, shadeless=shadeless, scene=scene, **data_textures) + original_color = color_texture(first) + assert original_color.colorspace_settings.name == "AgX Base sRGB" + assert original_color.alpha_mode == "STRAIGHT" + settings.adapt_material_to_view_transform = False + assert original_color.alpha_mode == "STRAIGHT" + if entry == "clipboard": + second = clipboard._create_image_material(original_color, shadeless, scene=scene) + else: + second = material.create_image_material(owner, original_color, 0, shadeless=shadeless, scene=scene) + new_color = color_texture(second) + assert new_color != original_color + assert new_color.colorspace_settings.name == "sRGB" + assert new_color.alpha_mode == "PREMUL" + assert original_color.colorspace_settings.name == "AgX Base sRGB" + assert original_color.alpha_mode == "STRAIGHT" + assert not settings.adapt_material_to_view_transform + assert owner.data == source + assert source.colorspace_settings.name == "sRGB" + assert source.alpha_mode == "STRAIGHT" + for image in (source, original_color, new_color): + np.testing.assert_allclose(images.image_pixels(image), before, atol=1 / 255) + for key, image in data_textures.items(): + space, alpha, pixels = data_states[key] + assert image.colorspace_settings.name == space + assert image.alpha_mode == alpha + np.testing.assert_array_equal(images.image_pixels(image), pixels) + + +def test_edit_results_keep_their_policy_with_adaptation_enabled(color_data, tmp_path): + source = bpy.data.images.new("Editing source", width=1, height=1, alpha=True) + rgba = np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32) + Image.fromarray(np.array([[[51, 102, 204, 64]]], dtype=np.uint8)).save(tmp_path / "image.0001.png") + with patch.object(preferences, "addon_preferences", return_value=SimpleNamespace(adapt_material_to_view_transform=True)): + edited = images.create_image_edit_result(source, rgba, (1, 1)) + loaded = images.load_image_edit_result(SimpleNamespace(data=source), tmp_path) + for result in (edited, loaded): + assert result.colorspace_settings.name == "sRGB" + assert result.alpha_mode == "PREMUL" + np.testing.assert_allclose(images.image_pixels(result), rgba, atol=1 / 255) + assert source.colorspace_settings.name == "sRGB" + assert source.alpha_mode == "STRAIGHT" + + +@pytest.mark.parametrize("supported", [{"sRGB", "Non-Color"}, {"Non-Color"}]) +def test_unavailable_color_space_falls_back_or_preserves_original(supported): + class ColorSpace: + value = "Non-Color" + + @property + def name(self): + return self.value + + @name.setter + def name(self, value): + if value not in supported: + raise TypeError("Unsupported color space") + self.value = value + + image = SimpleNamespace(colorspace_settings=ColorSpace(), users=0, use_fake_user=False, alpha_mode="STRAIGHT") + with patch.object(material, "configured_material_view_adaptation", return_value=True): + if "sRGB" in supported: + result = material.configure_material_color_image(image, view_scene("AgX")) + assert result.colorspace_settings.name == "sRGB" + assert result.alpha_mode == "PREMUL" + else: + with pytest.raises(RuntimeError, match="material color space"): + material.configure_material_color_image(image, view_scene("AgX")) + assert image.colorspace_settings.name == "Non-Color" + assert image.alpha_mode == "STRAIGHT" + + +def test_missing_scene_uses_srgb(color_data): + image = bpy.data.images.new("Missing scene", width=1, height=1) + with patch.object(material, "bpy", SimpleNamespace(context=SimpleNamespace(scene=None))), patch.object( + material, "configured_material_view_adaptation", return_value=True + ): + result = material.configure_material_color_image(image) + assert result.colorspace_settings.name == "sRGB" + assert result.alpha_mode == "PREMUL" diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index 4213c05..7382826 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -747,4 +747,6 @@ class ServerRuntimeTest(unittest.TestCase): input_path, depth_mode="VECTOR", normal_mode="OBJECT", + correct_edge_depth=True, + alpha_threshold=0.9, ) diff --git a/tools/pack.py b/tools/pack.py index c4ebbc8..a7aacd8 100644 --- a/tools/pack.py +++ b/tools/pack.py @@ -1,3 +1,7 @@ +import argparse +import platform as host_platform +import re +import shutil import subprocess import sys import tempfile @@ -10,7 +14,12 @@ from zipfile import ZIP_DEFLATED, ZipFile PROJECT_ROOT = Path(__file__).resolve().parents[1] SOURCE_DIR = PROJECT_ROOT / "src" / "anyimage" DIST_DIR = PROJECT_ROOT / "dist" -PYTHON_VERSION = "3.11" +PYTHON_TARGETS = { + "3.11": "numpy>=1.26,<2.0", + "3.13": "numpy>=2.1,<3.0", +} +# Resolve NumPy for dependency compatibility, but use Blender's runtime copy. +BLENDER_PROVIDED_PACKAGES = frozenset({"numpy"}) @dataclass(frozen=True) @@ -40,29 +49,102 @@ def download_wheels(platform, destination): if not dependencies: return [] config = PLATFORM_CONFIGS[platform] - command = [ - sys.executable, - "-m", - "pip", - "download", - *dependencies, - "--dest", - str(destination), - "--find-links", - str(SOURCE_DIR / "wheels"), - "--only-binary=:all:", - f"--python-version={PYTHON_VERSION}", - f"--platform={config.pip_platform}", + # Blender supplies NumPy; resolve against each runtime's compatible range. + dependencies = [ + dependency + for dependency in dependencies + if re.split(r"[\s\[<>=!~;@]", dependency, maxsplit=1)[0] + .lower().replace("_", "-") not in BLENDER_PROVIDED_PACKAGES ] - subprocess.run(command, check=True) - wheels = sorted(destination.glob("*.whl")) - if not wheels: - raise RuntimeError(f"No wheels downloaded for {platform}") - return wheels + wheels_by_name = {} + shared_constraints = destination / "shared-constraints.txt" + for python_version, numpy_requirement in PYTHON_TARGETS.items(): + target_directory = destination / python_version + target_directory.mkdir() + command = [ + sys.executable, + "-m", + "pip", + "download", + *dependencies, + numpy_requirement, + "--dest", + str(target_directory), + "--find-links", + str(SOURCE_DIR / "wheels"), + "--only-binary=:all:", + f"--python-version={python_version}", + "--implementation=cp", + f"--platform={config.pip_platform}", + ] + if shared_constraints.exists(): + command.extend(["--constraint", str(shared_constraints)]) + subprocess.run(command, check=True) + wheels = extension_wheels(sorted(target_directory.glob("*.whl"))) + if not wheels: + raise RuntimeError( + f"No wheels downloaded for {platform}, Python {python_version}" + ) + if not shared_constraints.exists(): + shared_constraints.write_text( + "\n".join( + "==".join(wheel.name.split("-")[:2]) + for wheel in wheels + if wheel.name.endswith("-none-any.whl") + ), + encoding="utf-8", + ) + for wheel in wheels: + wheels_by_name[wheel.name] = wheel + return [wheels_by_name[name] for name in sorted(wheels_by_name)] + + +def extension_wheels(wheels): + """Exclude distributions supplied by the supported Blender runtime.""" + return [ + wheel + for wheel in wheels + if wheel.name.split("-", 1)[0].lower().replace("_", "-") + not in BLENDER_PROVIDED_PACKAGES + ] + + +def current_platform(): + target = { + ("Windows", "amd64"): "windows-x64", + ("Darwin", "arm64"): "macos-arm64", + ("Linux", "x86_64"): "linux-x64", + }.get((host_platform.system(), host_platform.machine().lower())) + if target is None: + raise ValueError("Unsupported host platform; specify --platform explicitly") + return target + + +def prepare_local_dependencies(platform): + """Synchronize local wheels and Manifest after a successful download.""" + if platform not in PLATFORMS: + raise ValueError(f"Unsupported platform: {platform}") + with tempfile.TemporaryDirectory() as directory: + wheels = download_wheels(platform, Path(directory)) + manifest = manifest_for_platform(platform, wheels) + destination = SOURCE_DIR / "wheels" + destination.mkdir(exist_ok=True) + for wheel in wheels: + shutil.copy2(wheel, destination / wheel.name) + selected = {wheel.name for wheel in wheels} + for wheel in destination.glob("*.whl"): + if wheel.name not in selected: + wheel.unlink() + manifest_path = SOURCE_DIR / "blender_manifest.toml" + temporary_manifest = manifest_path.with_suffix(".toml.tmp") + temporary_manifest.write_text(manifest, encoding="utf-8") + temporary_manifest.replace(manifest_path) + return sorted(destination.glob("*.whl")) def manifest_for_platform(platform, wheels): """Return manifest text for one platform and its downloaded wheels.""" + wheels = extension_wheels(wheels) text = (SOURCE_DIR / "blender_manifest.toml").read_text(encoding="utf-8") lines = text.splitlines() table_start = next( @@ -85,14 +167,13 @@ def manifest_for_platform(platform, wheels): len(head), ) head.insert(insert_at, f'platforms = ["{platform}"]') - wheel_names = ", ".join( - f'"./wheels/{wheel.name}"' for wheel in wheels - ) + wheel_names = ", ".join(f'"./wheels/{wheel.name}"' for wheel in wheels) head.insert(insert_at + 1, f"wheels = [{wheel_names}]") return "\n".join(head + tail) + "\n" def _build_extension(platform, wheels): + wheels = extension_wheels(wheels) manifest_text = manifest_for_platform(platform, wheels) manifest = tomllib.loads(manifest_text) output_path = DIST_DIR / ( @@ -134,8 +215,30 @@ def build_all(): return [build_extension(platform) for platform in PLATFORMS] -def main(): - extension_paths = build_all() +def main(argv=None): + parser = argparse.ArgumentParser( + description="Prepare local dependencies or pack Blender extensions" + ) + parser.add_argument( + "--prepare", + action="store_true", + help="Update source wheels and Manifest for local testing", + ) + parser.add_argument( + "--platform", + choices=PLATFORMS, + help="Target platform; preparation defaults to this computer", + ) + args = parser.parse_args(argv) + if args.prepare: + platform = args.platform or current_platform() + wheels = prepare_local_dependencies(platform) + print( + f"Prepared {len(wheels)} wheels for {platform} in {SOURCE_DIR / 'wheels'}" + ) + print(f"Updated {SOURCE_DIR / 'blender_manifest.toml'}") + return + extension_paths = [build_extension(args.platform)] if args.platform else 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)") diff --git a/uv.lock b/uv.lock index 5c39b8a..d815b5b 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,8 @@ version = "0.13.0" source = { editable = "." } dependencies = [ { name = "blendjob" }, + { name = "numpy" }, + { name = "scipy" }, ] [package.dev-dependencies] @@ -28,7 +30,6 @@ dev = [ { name = "mkdocs" }, { name = "mkdocs-click" }, { name = "mkdocs-material" }, - { name = "numpy" }, { name = "onnx" }, { name = "onnxruntime" }, { name = "pillow" }, @@ -39,7 +40,11 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "blendjob", specifier = "==0.1.16" }] +requires-dist = [ + { name = "blendjob", specifier = "==0.1.16" }, + { name = "numpy", specifier = ">=1.26,<2.0" }, + { name = "scipy", specifier = "==1.15.3" }, +] [package.metadata.requires-dev] dev = [ @@ -50,7 +55,6 @@ dev = [ { 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" }, @@ -1048,6 +1052,26 @@ 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 = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, +] + [[package]] name = "setuptools" version = "84.0.0"