Enhance image editing tests and functionality
- Added parameterized tests for image edit replacement to ensure alpha mode preservation. - Refactored existing tests to utilize new image editing methods and improve clarity. - Introduced new tests for image target functionality, ensuring proper handling of shared textures and context. - Updated various tests to validate alpha mode handling across different scenarios. - Improved undo/redo functionality in image operators to maintain state consistency. - Added support for texture node actions, enhancing integration with Blender's node system. - Ensured that image properties are preserved during operations and that invalid contexts are handled gracefully.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-09
|
||||
55
openspec/changes/add-texture-node-image-actions/design.md
Normal file
55
openspec/changes/add-texture-node-image-actions/design.md
Normal file
@@ -0,0 +1,55 @@
|
||||
## Context
|
||||
|
||||
现有菜单挂载于对象右键菜单,两个 AI 操作通过 Empty 取得 Image 和播放设置。`common/image.py` 已提供输入准备、结果打包、Image 内容快照和恢复;现有共享判定专用于 Empty。动机见 proposal.md。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
本设计围绕材质 Shader Editor 的 Image Texture 节点完成图片输入、异步目标定位和原位提交;Empty 的现有行为作为回归基线。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 菜单按编辑器和当前节点确定目标
|
||||
|
||||
在 `NODE_MT_context_menu` 注册回调,以材质 Shader Editor、当前编辑树、活动 Image Texture 节点及有效 Image 为显示条件。支持进入材质节点组和固定材质后的编辑上下文,节点编辑器中的无效目标直接判为不可用。新增 `AnyImageTextureNodeMenu`,包含两个图片操作及现有 AI 设置入口;Upscale 标签使用目标图片尺寸。注册与逆序注销对称。
|
||||
|
||||
### 在 common 中集中处理图片编辑目标
|
||||
|
||||
新增职责明确的 `common/image_target.py`,统一取得源 Image、ImageUser、目标校验与结果提交分派。复用两个现有 Operator 和服务端 Job;将结果加载中对 `source_object.data` 的依赖改为显式 Image 参数,并同步修改真实调用点。Empty 特有的显示对齐和共享规则仍由现有提交函数负责,节点提交复用 Image 内容事务函数。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[节点右键选择操作] --> B[解析并捕获节点和源 Image]
|
||||
B --> C[复用输入准备与 AI Job]
|
||||
C --> D[主线程验证原目标仍有效]
|
||||
D --> E[加载并打包处理结果]
|
||||
E --> F{源图有其他实际使用者}
|
||||
F -->|有| G[当前节点绑定独立结果]
|
||||
F -->|无| H[保留 Image 身份并更新内容]
|
||||
G --> I[完成撤销记录和临时资源清理]
|
||||
H --> I
|
||||
```
|
||||
|
||||
### 捕获提交时的节点身份
|
||||
|
||||
在主线程捕获编辑树、节点与源 Image 的运行期身份,以及节点 ImageUser 播放参数。响应在主线程使用捕获目标,校验节点仍属于原树且仍绑定源图;名称只用于提示,防止删除后同名重建误命中。运行期引用仅保存在本地任务状态,Job 请求使用路径、帧参数等普通字典数据。切换活动节点不改变目标;目标删除、源绑定变化或变为只读时报告错误并清理结果。
|
||||
|
||||
### 原位替换保留节点结构并隔离共享图片
|
||||
|
||||
节点独占 Image 时复用内容快照与恢复,保留 Image 身份、名称和 Fake User;存在其他实际 Image 使用者时,仅当前节点绑定新结果,保留原图。共享判定覆盖其他节点树、Empty 和其他数据使用者,忽略 Fake User、编辑器显示和本次临时持有;在响应提交时计算。该规则仅用于节点目标。
|
||||
|
||||
保持节点位置、插值、投影、扩展方式和连线。结果继承源色彩空间并使用现有 alpha 配置;去背景输出透明图片,材质的 Alpha 连线和渲染设置由用户已有材质决定。动画输入沿用现有处理帧数约定,起始帧和播放设置从节点 ImageUser 读取,结果序列以同样时间位置播放。
|
||||
|
||||
### 失败恢复和撤销采用完整提交边界
|
||||
|
||||
节点提交覆盖 Image 内容、绑定和 ImageUser 设置的快照、恢复与清理。沿用两个操作现有撤销入口,验证异步成功仅产生一个有效图片编辑撤销步骤,重做恢复结果且无需再次推理。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [节点组被多个材质实例使用] → 编辑共享节点树内的节点会作用于所有该组实例,遵循 Blender 节点树共享语义;图片隔离以节点身份为边界。
|
||||
- [共享判定遗漏其他 Image 使用者] → 结合实际数据引用校验,测试跨材质、跨节点组、Empty 及 Fake User 情况。
|
||||
- [异步完成时上下文已变化] → 使用捕获的运行期身份并在提交前验证;覆盖改名、删除和重新绑定图片。
|
||||
- [Image 内容原位修改的撤销与打包行为] → 使用真实 Blender 测试验证撤销重做、保存重开、动画和失败恢复。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
先集中目标解析与图片结果加载,再接入菜单和节点提交,最后完成注册、回归及 Blender 验证。无需数据迁移;回退代码后,已有处理结果继续作为普通 Image 使用。
|
||||
24
openspec/changes/add-texture-node-image-actions/proposal.md
Normal file
24
openspec/changes/add-texture-node-image-actions/proposal.md
Normal file
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
材质的 Image Texture 节点目前无法直接使用 AnyImage 去背景和放大。用户需要在节点右键菜单中完成处理,并像 Empty Image 一样在当前位置使用结果。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在材质 Shader Editor 的有效 Image Texture 节点右键菜单增加 AnyImage 菜单,提供 Remove Background 和 Upscale。
|
||||
- 复用现有 AI 环境准备、模型配置、输入限制、任务进度和图片处理流程。
|
||||
- 处理成功后原位更新目标节点图片,保留节点、连线及设置;单独使用的图片保留 Image 身份,共享图片仅替换当前节点的绑定。
|
||||
- 为异步目标校验、失败恢复、撤销重做和菜单注册增加覆盖。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `texture-node-image-actions`: 材质 Image Texture 节点的 AI 图片操作入口及原位提交语义。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。
|
||||
|
||||
## Impact
|
||||
|
||||
涉及 `src/anyimage/menu.py`、扩展注册入口、Remove Background / Upscale Operator、`common` 图片输入与提交逻辑及对应测试。服务端沿用现有 Job 协议,依赖和节点资产保持现状。
|
||||
@@ -0,0 +1,85 @@
|
||||
## Purpose
|
||||
|
||||
为材质 Shader Editor 中的 Image Texture 节点提供直接去背景与放大的操作入口,让用户在现有节点位置使用处理结果,并明确共享图片、异步完成和撤销恢复的可见行为。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Material image texture context actions
|
||||
|
||||
系统 SHALL 在材质 Shader Editor 中为具有有效图片的活动 Image Texture 节点提供 AnyImage 右键菜单,包含 Remove Background 和 Upscale;一次操作 SHALL 处理当前节点。
|
||||
|
||||
#### Scenario: An image texture is active
|
||||
- **WHEN** 用户在材质编辑上下文中对有图片的活动 Image Texture 节点打开右键菜单
|
||||
- **THEN** 菜单提供两项操作,Upscale 显示预期 2× 输出尺寸
|
||||
|
||||
#### Scenario: The target is unavailable
|
||||
- **WHEN** 当前上下文为 World、Compositor、Geometry Nodes,或活动节点不是 Image Texture、没有图片、不可编辑
|
||||
- **THEN** 系统不提供可执行的节点图片操作,且不使用活动 Empty 作为替代目标
|
||||
|
||||
#### Scenario: A material or node group is being edited
|
||||
- **WHEN** 用户固定材质或进入其节点组并对有效 Image Texture 发起操作
|
||||
- **THEN** 系统处理该编辑树内的目标节点图片
|
||||
|
||||
### Requirement: Existing AI processing settings apply
|
||||
|
||||
系统 SHALL 沿用 Empty Image 操作的 AI 环境准备、模型选择、任务状态和输入限制;Upscale SHALL 使用当前模型输出 2× 结果。
|
||||
|
||||
#### Scenario: Environment preparation is needed
|
||||
- **WHEN** 用户点击操作且 AI 环境或模型尚未准备好
|
||||
- **THEN** 系统进入现有准备流程
|
||||
|
||||
#### Scenario: Upscale is unavailable
|
||||
- **WHEN** 服务忙碌或图片长边达到当前最大 AI 输入尺寸
|
||||
- **THEN** Upscale 不可执行;服务忙碌时 Remove Background 也不可执行
|
||||
|
||||
#### Scenario: An animated texture is processed
|
||||
- **WHEN** 目标图片为现有流程支持的视频或序列
|
||||
- **THEN** 系统依据节点播放设置和现有处理帧数约定取得输入,结果序列保留对应播放时间位置
|
||||
|
||||
### Requirement: Results replace the image at the target node
|
||||
|
||||
系统 SHALL 在成功后保留目标节点、位置、连线和纹理设置,并在该节点使用结果图片。源图独占时 SHALL 保留 Image 身份、名称与 Fake User;源图有其他实际使用者时 SHALL 仅替换当前节点图片绑定并保持其他使用者和原图不变。结果 SHALL 继承源色彩空间、正确保存透明信息并支持保存重开。
|
||||
|
||||
#### Scenario: A sole image is processed
|
||||
- **WHEN** 目标节点独占源图并完成去背景或放大
|
||||
- **THEN** 原 Image 内容及尺寸更新为结果,身份和名称保留,临时结果被释放
|
||||
|
||||
#### Scenario: Other users share the image
|
||||
- **WHEN** 另一纹理节点或 Empty 等数据使用者仍引用源图
|
||||
- **THEN** 当前节点使用独立处理结果,其他使用者继续使用未改变的原图
|
||||
|
||||
#### Scenario: Only a fake user is present
|
||||
- **WHEN** 源图除当前节点外只有 Fake User 或编辑器显示引用
|
||||
- **THEN** 系统保留源 Image 身份并更新内容
|
||||
|
||||
#### Scenario: Background removal succeeds
|
||||
- **WHEN** 用户对节点执行 Remove Background 成功
|
||||
- **THEN** 图片带有处理后的透明信息,材质原有连线和渲染设置保留
|
||||
|
||||
### Requirement: Asynchronous completion keeps the original target
|
||||
|
||||
系统 SHALL 将结果提交到发起任务时的节点,并在提交前验证原节点和图片绑定仍有效。
|
||||
|
||||
#### Scenario: Active selection changes
|
||||
- **WHEN** 处理期间用户切换编辑器、材质或活动节点
|
||||
- **THEN** 结果仍提交到原节点
|
||||
|
||||
#### Scenario: The target is renamed
|
||||
- **WHEN** 原节点或所属材质在任务期间改名且绑定仍有效
|
||||
- **THEN** 结果提交到同一原节点
|
||||
|
||||
#### Scenario: The original target is invalid
|
||||
- **WHEN** 原节点被删除、同名重建、变为只读或改绑图片
|
||||
- **THEN** 系统报告目标失效,保留现有节点和图片状态,并清理本次结果
|
||||
|
||||
### Requirement: Edits support failure recovery and undo
|
||||
|
||||
系统 SHALL 在失败或取消时保持或恢复原图片内容、节点绑定和播放设置,并清理临时资源。成功操作 SHALL 支持一次撤销还原及重做恢复。
|
||||
|
||||
#### Scenario: Commit fails
|
||||
- **WHEN** 写入图片内容或绑定、配置播放设置期间发生错误
|
||||
- **THEN** 节点绑定与原图完整恢复,其他使用者保持不变
|
||||
|
||||
#### Scenario: The user undoes and redoes an edit
|
||||
- **WHEN** 用户撤销一次成功操作,然后重做
|
||||
- **THEN** 撤销恢复原图、尺寸、绑定和播放设置,重做恢复处理结果且无需再次推理
|
||||
20
openspec/changes/add-texture-node-image-actions/tasks.md
Normal file
20
openspec/changes/add-texture-node-image-actions/tasks.md
Normal file
@@ -0,0 +1,20 @@
|
||||
## 1. 图片编辑目标
|
||||
|
||||
- [x] 1.1 在 common 集中解析 Empty 与材质纹理节点目标,捕获源 Image、编辑树身份和播放参数;测试材质固定、节点组编辑、无效上下文和节点改名。
|
||||
- [x] 1.2 将图片结果加载改为显式 Image 输入并更新真实调用点;通过现有 Empty 图片处理测试,检查旧调用签名无残留。
|
||||
|
||||
## 2. 原位提交与任务接入
|
||||
|
||||
- [x] 2.1 实现节点图片事务提交,复用内容快照、恢复与打包;测试独占 Image 身份、Fake User、共享节点/Empty 隔离、连线设置保留及失败回滚。
|
||||
- [x] 2.2 接入现有 Remove Background 和 Upscale 的目标解析、输入准备和响应;测试模型配置、2× 尺寸、输入限制、动画帧参数及任务清理。
|
||||
- [x] 2.3 在异步响应中校验原树、节点及源绑定;测试切换选择后仍命中原目标,删除、同名重建和改绑图片时安全退出。
|
||||
|
||||
## 3. 右键菜单
|
||||
|
||||
- [x] 3.1 添加纹理节点 AnyImage 菜单,复用两项操作及 AI 设置入口,显示目标图片的 Upscale 尺寸;测试可见条件、服务忙碌及环境准备入口。
|
||||
- [x] 3.2 接入节点菜单回调与菜单类型注册、逆序注销;通过注册测试验证重复启用与禁用没有遗留回调。
|
||||
|
||||
## 4. 综合验证
|
||||
|
||||
- [x] 4.1 使用真实 Blender 测试验证两项异步操作的撤销重做、图片打包保存重开、共享节点组实例与动画播放,确认透明结果及原节点结构正确。
|
||||
- [x] 4.2 运行 `uv run pytest` 全量测试并确认正常退出,检查最终差异与旧引用;保留既有未提交修改,不构建文档或产物。
|
||||
@@ -2,7 +2,12 @@ import bpy
|
||||
|
||||
from .operators import clipboard_image
|
||||
from .operators.clipboard_image import CLASSES as CLIPBOARD_CLASSES
|
||||
from .menu import AnyImageImageMenu, draw_image_context_menu
|
||||
from .menu import (
|
||||
AnyImageImageMenu,
|
||||
AnyImageTextureNodeMenu,
|
||||
draw_image_context_menu,
|
||||
draw_texture_node_context_menu,
|
||||
)
|
||||
from .operators import CLASSES as OPERATOR_CLASSES
|
||||
from .operators.cutout_tool import CutoutTool
|
||||
from .operators.frame_tool import FrameTool
|
||||
@@ -20,6 +25,7 @@ CLASSES = (
|
||||
*CLIPBOARD_CLASSES,
|
||||
*OPERATOR_CLASSES,
|
||||
AnyImageImageMenu,
|
||||
AnyImageTextureNodeMenu,
|
||||
ServerPanel,
|
||||
)
|
||||
|
||||
@@ -42,6 +48,7 @@ def register():
|
||||
bpy.types.Scene.anyimage_settings = bpy.props.PointerProperty(type=AnyImageSettings)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.prepend(draw_image_context_menu)
|
||||
bpy.types.OUTLINER_MT_object.prepend(draw_image_context_menu)
|
||||
bpy.types.NODE_MT_context_menu.prepend(draw_texture_node_context_menu)
|
||||
clipboard_image.register_keymaps()
|
||||
runtime.register()
|
||||
|
||||
@@ -53,8 +60,9 @@ def unregister():
|
||||
bpy.utils.unregister_tool(RectifyTool)
|
||||
bpy.utils.unregister_tool(MaskTool)
|
||||
bpy.utils.unregister_tool(FrameTool)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu)
|
||||
bpy.types.NODE_MT_context_menu.remove(draw_texture_node_context_menu)
|
||||
bpy.types.OUTLINER_MT_object.remove(draw_image_context_menu)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu)
|
||||
if hasattr(bpy.types.Scene, "anyimage_settings"):
|
||||
del bpy.types.Scene.anyimage_settings
|
||||
for class_type in reversed(CLASSES):
|
||||
|
||||
@@ -8,6 +8,14 @@ from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
VIEW_TRANSFORM_COLOR_SPACES = (
|
||||
("ACES 1.3", "ACES 1.3 sRGB"),
|
||||
("ACES 2.0", "ACES 2.0 sRGB"),
|
||||
("AgX", "AgX Base sRGB"),
|
||||
("Filmic", "Filmic sRGB"),
|
||||
("Khronos PBR Neutral", "Khronos PBR Neutral sRGB"),
|
||||
)
|
||||
|
||||
IMAGE_NAME_SUFFIXES = (
|
||||
".jpeg",
|
||||
".jpg",
|
||||
@@ -296,7 +304,7 @@ def video_total_frames(image):
|
||||
|
||||
|
||||
def image_user_settings(source_object):
|
||||
"""Return the Empty's image user settings as a four-item tuple."""
|
||||
"""Return an image owner's playback settings as a four-item tuple."""
|
||||
image_user = getattr(source_object, "image_user", None)
|
||||
if image_user is None:
|
||||
return 1, 0, 0, False
|
||||
@@ -308,14 +316,13 @@ def image_user_settings(source_object):
|
||||
)
|
||||
|
||||
|
||||
def extract_frame_range(source_object):
|
||||
def extract_frame_range(image, image_user):
|
||||
"""Return the clamped (start_frame, frame_count) range extracted from the source."""
|
||||
total_frames = video_total_frames(getattr(source_object, "data", None))
|
||||
total_frames = video_total_frames(image)
|
||||
if total_frames <= 1:
|
||||
return 1, total_frames
|
||||
_frame_start, frame_offset, image_frames, _use_cyclic = (
|
||||
image_user_settings(source_object)
|
||||
)
|
||||
frame_offset = int(getattr(image_user, "frame_offset", 0) or 0)
|
||||
image_frames = int(getattr(image_user, "frame_duration", 0) or 0)
|
||||
frame_count = (
|
||||
min(image_frames, total_frames)
|
||||
if image_frames >= 1
|
||||
@@ -326,14 +333,9 @@ def extract_frame_range(source_object):
|
||||
return start_frame, frame_count
|
||||
|
||||
|
||||
def default_extract_frames(source_object):
|
||||
"""Return the Empty's Image Frames value, or the full frame count when unset."""
|
||||
return extract_frame_range(source_object)[1]
|
||||
|
||||
|
||||
def source_start_frame(source_object_name):
|
||||
source_object = bpy.data.objects.get(source_object_name)
|
||||
return extract_frame_range(source_object)[0] if source_object else 1
|
||||
return extract_frame_range(source_object.data, source_object.image_user)[0] if source_object else 1
|
||||
|
||||
|
||||
def image_path(image):
|
||||
@@ -615,16 +617,22 @@ def load_normal_result_image(path, source_object):
|
||||
return image
|
||||
|
||||
|
||||
def color_alpha_mode(color_space):
|
||||
"""Pair inverse view color spaces with straight alpha."""
|
||||
return "STRAIGHT" if color_space in dict(VIEW_TRANSFORM_COLOR_SPACES).values() else "PREMUL"
|
||||
|
||||
|
||||
def configure_color_alpha(image):
|
||||
"""Apply the presentation mode to an owned Color image."""
|
||||
if image.alpha_mode != "PREMUL":
|
||||
alpha_mode = color_alpha_mode(image.colorspace_settings.name)
|
||||
if image.alpha_mode != alpha_mode:
|
||||
pixels = (
|
||||
image_pixels(image)
|
||||
if getattr(image, "source", None) == "GENERATED"
|
||||
or getattr(image, "is_dirty", False)
|
||||
else None
|
||||
)
|
||||
image.alpha_mode = "PREMUL"
|
||||
image.alpha_mode = alpha_mode
|
||||
if pixels is not None:
|
||||
image.pixels.foreach_set(pixels)
|
||||
image.update()
|
||||
@@ -645,7 +653,7 @@ def create_image_edit_result(source_image, pixels, output_size):
|
||||
)
|
||||
try:
|
||||
image.colorspace_settings.name = source_image.colorspace_settings.name
|
||||
configure_color_alpha(image)
|
||||
image.alpha_mode = source_image.alpha_mode
|
||||
image.pixels.foreach_set(pixels)
|
||||
image.pack()
|
||||
image.update()
|
||||
@@ -801,11 +809,12 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None,
|
||||
|
||||
|
||||
def _load_packed_result_image(
|
||||
source_object,
|
||||
source_image,
|
||||
output_directory,
|
||||
*,
|
||||
frame_prefix,
|
||||
result_name,
|
||||
alpha_mode,
|
||||
):
|
||||
output_directory = Path(output_directory)
|
||||
color_paths = sorted_frame_paths(output_directory, frame_prefix)
|
||||
@@ -813,7 +822,6 @@ def _load_packed_result_image(
|
||||
raise RuntimeError(
|
||||
f"Color result has no images: {output_directory}"
|
||||
)
|
||||
source_image = source_object.data
|
||||
source_color_space = source_image.colorspace_settings.name
|
||||
animated = is_animated_image(source_image)
|
||||
try:
|
||||
@@ -822,7 +830,7 @@ def _load_packed_result_image(
|
||||
check_existing=False,
|
||||
)
|
||||
result_image.colorspace_settings.name = source_color_space
|
||||
configure_color_alpha(result_image)
|
||||
result_image.alpha_mode = alpha_mode
|
||||
if animated:
|
||||
force_sequence_source(result_image)
|
||||
except (OSError, RuntimeError, TypeError, ValueError) as error:
|
||||
@@ -843,17 +851,18 @@ def _load_packed_result_image(
|
||||
|
||||
|
||||
def load_image_edit_result(
|
||||
source_object,
|
||||
source_image,
|
||||
output_directory,
|
||||
*,
|
||||
frame_prefix="image.",
|
||||
):
|
||||
"""Load one packed Job result that directly edits the source Image."""
|
||||
return _load_packed_result_image(
|
||||
source_object,
|
||||
source_image,
|
||||
output_directory,
|
||||
frame_prefix=frame_prefix,
|
||||
result_name=source_object.data.name,
|
||||
result_name=source_image.name,
|
||||
alpha_mode=source_image.alpha_mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -865,10 +874,11 @@ def load_color_result_image(
|
||||
):
|
||||
"""Load one packed Color texture result for a generated material."""
|
||||
return _load_packed_result_image(
|
||||
source_object,
|
||||
source_object.data,
|
||||
output_directory,
|
||||
frame_prefix=frame_prefix,
|
||||
result_name=color_data_name(source_object),
|
||||
alpha_mode=color_alpha_mode(source_object.data.colorspace_settings.name),
|
||||
)
|
||||
|
||||
|
||||
|
||||
167
src/anyimage/common/image_target.py
Normal file
167
src/anyimage/common/image_target.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Resolve and commit AI image edits for Empty objects and texture nodes."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import bpy
|
||||
|
||||
from .image import (
|
||||
configure_image_user,
|
||||
extract_frame_range,
|
||||
image_content_state,
|
||||
image_user_settings,
|
||||
is_animated_image,
|
||||
is_image_empty,
|
||||
load_image_edit_result,
|
||||
prepare_image_input,
|
||||
replace_empty_image,
|
||||
restore_image_content,
|
||||
video_total_frames,
|
||||
)
|
||||
|
||||
|
||||
def active_texture_node(context):
|
||||
"""Return the editable image texture in the current material edit tree."""
|
||||
space = getattr(context, "space_data", None)
|
||||
if (
|
||||
getattr(space, "type", None) != "NODE_EDITOR"
|
||||
or getattr(space, "tree_type", None) != "ShaderNodeTree"
|
||||
or getattr(space, "shader_type", None) != "OBJECT"
|
||||
or not isinstance(getattr(space, "id", None), bpy.types.Material)
|
||||
):
|
||||
return None
|
||||
tree = space.edit_tree
|
||||
if tree is None or not tree.is_editable:
|
||||
return None
|
||||
node = tree.nodes.active
|
||||
if (
|
||||
node is None
|
||||
or node.bl_idname != "ShaderNodeTexImage"
|
||||
or node.image is None
|
||||
or not node.image.is_editable
|
||||
):
|
||||
return None
|
||||
return node
|
||||
|
||||
|
||||
def image_edit_owner(context):
|
||||
"""Resolve the editor's image owner without falling back across editors."""
|
||||
if getattr(getattr(context, "space_data", None), "type", None) == "NODE_EDITOR":
|
||||
return active_texture_node(context)
|
||||
owner = getattr(context, "object", None)
|
||||
return owner if is_image_empty(owner) else None
|
||||
|
||||
|
||||
def owner_image(owner):
|
||||
"""Return the Image assigned to an image Empty or texture node."""
|
||||
return owner.data if is_image_empty(owner) else owner.image
|
||||
|
||||
|
||||
def has_other_texture_image_user(node):
|
||||
"""Check actual Image references, excluding editor display and Fake User."""
|
||||
tree = node.id_data
|
||||
image = node.image
|
||||
for user in bpy.data.user_map(subset={image})[image]:
|
||||
if isinstance(user, bpy.types.Screen):
|
||||
continue
|
||||
if user == tree or getattr(user, "node_tree", None) == tree:
|
||||
if any(
|
||||
other != node and getattr(other, "image", None) == image
|
||||
for other in tree.nodes
|
||||
):
|
||||
return True
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def replace_texture_image(node, result_image):
|
||||
"""Commit one texture image transaction while preserving other users."""
|
||||
try:
|
||||
source_image = node.image
|
||||
original_timing = image_user_settings(node)
|
||||
original_auto_refresh = node.image_user.use_auto_refresh
|
||||
original_content = None
|
||||
frame_count = video_total_frames(result_image) if is_animated_image(result_image) else None
|
||||
try:
|
||||
if has_other_texture_image_user(node):
|
||||
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
|
||||
node.image = final_image
|
||||
if frame_count is not None:
|
||||
configure_image_user(
|
||||
node.image_user,
|
||||
frame_count,
|
||||
frame_start=original_timing[0],
|
||||
use_cyclic=original_timing[3],
|
||||
)
|
||||
except Exception:
|
||||
if original_content is not None:
|
||||
restore_image_content(source_image, original_content)
|
||||
node.image = source_image
|
||||
user = node.image_user
|
||||
(user.frame_start, user.frame_offset, user.frame_duration, user.use_cyclic) = original_timing
|
||||
user.use_auto_refresh = original_auto_refresh
|
||||
raise
|
||||
return final_image
|
||||
finally:
|
||||
if result_image.users == 0:
|
||||
bpy.data.images.remove(result_image)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageEditTarget:
|
||||
"""Hold main-thread RNA identities for a single asynchronous image edit."""
|
||||
|
||||
owner: object
|
||||
image: object
|
||||
tree: object = None
|
||||
start_frame: int = 1
|
||||
frame_count: int = 1
|
||||
|
||||
@classmethod
|
||||
def capture(cls, context):
|
||||
owner = image_edit_owner(context)
|
||||
if owner is None:
|
||||
raise RuntimeError("Select an Image Empty or a material Image Texture")
|
||||
image = owner_image(owner)
|
||||
start_frame, frame_count = extract_frame_range(image, owner.image_user)
|
||||
return cls(
|
||||
owner, image,
|
||||
None if is_image_empty(owner) else owner.id_data,
|
||||
start_frame, frame_count,
|
||||
)
|
||||
|
||||
def validate(self):
|
||||
"""Reject a removed owner or a changed image assignment."""
|
||||
try:
|
||||
if self.tree is None:
|
||||
valid = is_image_empty(self.owner) and self.owner.data == self.image
|
||||
else:
|
||||
valid = (
|
||||
self.tree.is_editable
|
||||
and self.image.is_editable
|
||||
and self.tree.nodes.get(self.owner.name) == self.owner
|
||||
and self.owner.image == self.image
|
||||
)
|
||||
if valid:
|
||||
return
|
||||
except ReferenceError:
|
||||
pass
|
||||
raise RuntimeError("The source image target is no longer available or has changed")
|
||||
|
||||
def prepare(self, context):
|
||||
self.validate()
|
||||
if video_total_frames(self.image) > 1:
|
||||
context.scene.anyimage_settings.video_frames = self.frame_count
|
||||
return prepare_image_input(self.image, context.scene)
|
||||
|
||||
def apply(self, directory, *, frame_prefix="image."):
|
||||
self.validate()
|
||||
result_image = load_image_edit_result(self.image, directory, frame_prefix=frame_prefix)
|
||||
if self.tree is None:
|
||||
return replace_empty_image(self.owner, result_image)
|
||||
return replace_texture_image(self.owner, result_image)
|
||||
@@ -4,6 +4,8 @@ import bpy
|
||||
|
||||
from ..preferences import configured_material_view_adaptation
|
||||
from .image import (
|
||||
VIEW_TRANSFORM_COLOR_SPACES,
|
||||
color_alpha_mode,
|
||||
configure_image_user,
|
||||
image_base_name,
|
||||
image_pixels,
|
||||
@@ -15,13 +17,6 @@ from .node import load_node_group
|
||||
IMAGE_MATERIAL_NODE_GROUP_NAME = "O Image Layer"
|
||||
DEPTH_MATERIAL_NODE_GROUP_NAME = "O Image Depth Layer"
|
||||
SHADELESS_NODE_GROUP_NAME = "O Shadeless"
|
||||
VIEW_TRANSFORM_COLOR_SPACES = (
|
||||
("ACES 1.3", "ACES 1.3 sRGB"),
|
||||
("ACES 2.0", "ACES 2.0 sRGB"),
|
||||
("AgX", "AgX Base sRGB"),
|
||||
("Filmic", "Filmic sRGB"),
|
||||
("Khronos PBR Neutral", "Khronos PBR Neutral sRGB"),
|
||||
)
|
||||
|
||||
|
||||
def material_color_space_name(scene):
|
||||
@@ -64,7 +59,7 @@ def configure_material_color_image(image, scene=None):
|
||||
break
|
||||
else:
|
||||
raise RuntimeError("Unable to configure the material color space")
|
||||
result.alpha_mode = "PREMUL" if settings.name == "sRGB" else "STRAIGHT"
|
||||
result.alpha_mode = color_alpha_mode(settings.name)
|
||||
if pixels is not None:
|
||||
result.pixels.foreach_set(pixels)
|
||||
result.update()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import bpy
|
||||
|
||||
from .common.image import is_image_empty
|
||||
from .common.image_target import active_texture_node, image_edit_owner, owner_image
|
||||
from .operators.upscale import upscale_output_size
|
||||
|
||||
|
||||
@@ -9,8 +10,6 @@ class AnyImageImageMenu(bpy.types.Menu):
|
||||
bl_label = "AnyImage"
|
||||
|
||||
def draw(self, context):
|
||||
from .properties import ai_setup_label, ai_status
|
||||
|
||||
layout = self.layout
|
||||
layout.operator_context = "INVOKE_DEFAULT"
|
||||
layout.operator(
|
||||
@@ -29,37 +28,52 @@ class AnyImageImageMenu(bpy.types.Menu):
|
||||
icon="MOD_DISPLACE",
|
||||
)
|
||||
layout.separator()
|
||||
draw_image_actions(layout, context)
|
||||
|
||||
|
||||
class AnyImageTextureNodeMenu(bpy.types.Menu):
|
||||
bl_idname = "ANYIMAGE_MT_texture_node_context"
|
||||
bl_label = "AnyImage"
|
||||
|
||||
def draw(self, context):
|
||||
draw_image_actions(self.layout, context)
|
||||
|
||||
|
||||
def draw_image_actions(layout, context):
|
||||
from .properties import ai_setup_label, ai_status
|
||||
|
||||
layout.operator_context = "INVOKE_DEFAULT"
|
||||
layout.operator(
|
||||
"anyimage.remove_image_background",
|
||||
text="Remove Background",
|
||||
icon="IMAGE_ALPHA",
|
||||
)
|
||||
layout.operator(
|
||||
"anyimage.upscale_image",
|
||||
text=upscale_menu_label(context),
|
||||
icon="FULLSCREEN_ENTER",
|
||||
)
|
||||
layout.separator()
|
||||
status = ai_status()
|
||||
if status["ready"]:
|
||||
layout.operator(
|
||||
"anyimage.remove_image_background",
|
||||
text="Remove Background",
|
||||
icon="IMAGE_ALPHA",
|
||||
"anyimage.open_ai_environment_settings",
|
||||
text="Settings",
|
||||
icon="PREFERENCES",
|
||||
)
|
||||
else:
|
||||
layout.operator(
|
||||
"anyimage.upscale_image",
|
||||
text=upscale_menu_label(context),
|
||||
icon="FULLSCREEN_ENTER",
|
||||
"anyimage.setup_ai_environment",
|
||||
text=ai_setup_label(status),
|
||||
icon="IMPORT",
|
||||
)
|
||||
layout.separator()
|
||||
status = ai_status()
|
||||
if status["ready"]:
|
||||
layout.operator(
|
||||
"anyimage.open_ai_environment_settings",
|
||||
text="Settings",
|
||||
icon="PREFERENCES",
|
||||
)
|
||||
else:
|
||||
layout.operator(
|
||||
"anyimage.setup_ai_environment",
|
||||
text=ai_setup_label(status),
|
||||
icon="IMPORT",
|
||||
)
|
||||
|
||||
|
||||
def upscale_menu_label(context):
|
||||
source_object = getattr(context, "object", None)
|
||||
if not is_image_empty(source_object):
|
||||
owner = image_edit_owner(context)
|
||||
if owner is None:
|
||||
return "Upscale"
|
||||
output_size = upscale_output_size(source_object.data)
|
||||
output_size = upscale_output_size(owner_image(owner))
|
||||
if output_size is None:
|
||||
return "Upscale"
|
||||
width, height = output_size
|
||||
@@ -71,3 +85,10 @@ def draw_image_context_menu(self, context):
|
||||
return
|
||||
self.layout.menu(AnyImageImageMenu.bl_idname, text="AnyImage", icon="PLUGIN")
|
||||
self.layout.separator()
|
||||
|
||||
|
||||
def draw_texture_node_context_menu(self, context):
|
||||
if active_texture_node(context) is None:
|
||||
return
|
||||
self.layout.menu(AnyImageTextureNodeMenu.bl_idname, text="AnyImage", icon="PLUGIN")
|
||||
self.layout.separator()
|
||||
|
||||
@@ -10,14 +10,8 @@ from ..common.ai import (
|
||||
require_model,
|
||||
)
|
||||
|
||||
from ..common.image import (
|
||||
cleanup_image_input,
|
||||
default_extract_frames,
|
||||
is_image_empty,
|
||||
prepare_image_input,
|
||||
source_start_frame,
|
||||
video_total_frames,
|
||||
)
|
||||
from ..common.image import cleanup_image_input
|
||||
from ..common.image_target import ImageEditTarget, image_edit_owner
|
||||
|
||||
|
||||
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
|
||||
@@ -26,14 +20,14 @@ _HIDDEN = {"HIDDEN", "SKIP_SAVE"}
|
||||
class RemoveImageBackground(bpy.types.Operator):
|
||||
bl_idname = "anyimage.remove_image_background"
|
||||
bl_label = "Remove Background"
|
||||
bl_description = "Remove the background from this Image Empty with BEN2"
|
||||
bl_description = "Remove the image background with BEN2"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
from ..runtime import runtime
|
||||
|
||||
return is_image_empty(context.object) and not runtime.server_busy()
|
||||
return image_edit_owner(context) is not None and not runtime.server_busy()
|
||||
|
||||
def invoke(self, _context, _event):
|
||||
setup_result = invoke_ai_setup_if_needed()
|
||||
@@ -41,58 +35,35 @@ class RemoveImageBackground(bpy.types.Operator):
|
||||
return setup_result
|
||||
return self.execute(_context)
|
||||
|
||||
def execute(self, context):
|
||||
source_object = context.object
|
||||
total_frames = video_total_frames(source_object.data)
|
||||
if total_frames > 1:
|
||||
context.scene.anyimage_settings.video_frames = (
|
||||
default_extract_frames(source_object)
|
||||
)
|
||||
try:
|
||||
source_path, delete_input = prepare_image_input(
|
||||
source_object.data,
|
||||
context.scene,
|
||||
)
|
||||
except RuntimeError as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
return {"CANCELLED"}
|
||||
|
||||
result = bpy.ops.anyimage.run_background_removal(
|
||||
input_path=str(source_path),
|
||||
source_object_name=source_object.name,
|
||||
delete_input=delete_input,
|
||||
)
|
||||
def execute(self, _context):
|
||||
result = bpy.ops.anyimage.run_background_removal()
|
||||
if "CANCELLED" not in result:
|
||||
return {"FINISHED"}
|
||||
if delete_input:
|
||||
cleanup_image_input(source_path, True)
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
def apply_background_response(result, source_object_name):
|
||||
from ..common.image import (
|
||||
load_image_edit_result,
|
||||
replace_empty_image,
|
||||
require_image_empty,
|
||||
)
|
||||
|
||||
source_object = require_image_empty(source_object_name)
|
||||
result_image = load_image_edit_result(source_object, result.directory)
|
||||
replace_empty_image(source_object, result_image)
|
||||
if "FINISHED" not in bpy.ops.ed.undo_push(message="Remove Background"):
|
||||
raise RuntimeError("Unable to create the Remove Background Undo step")
|
||||
|
||||
|
||||
class RunBackgroundRemoval(JobOperatorBase, bpy.types.Operator):
|
||||
bl_idname = "anyimage.run_background_removal"
|
||||
bl_label = "Run Background Removal"
|
||||
job_type = "remove-background"
|
||||
starting_message = "Starting Image Empty background removal"
|
||||
starting_message = "Starting image background removal"
|
||||
|
||||
input_path: bpy.props.StringProperty(options=_HIDDEN)
|
||||
source_object_name: bpy.props.StringProperty(options=_HIDDEN)
|
||||
delete_input: bpy.props.BoolProperty(default=False, options=_HIDDEN)
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
self._image_target = ImageEditTarget.capture(context)
|
||||
source_path, self.delete_input = self._image_target.prepare(context)
|
||||
except RuntimeError as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
return {"CANCELLED"}
|
||||
self.input_path = str(source_path)
|
||||
result = super().execute(context)
|
||||
if "CANCELLED" in result:
|
||||
self.cleanup()
|
||||
return result
|
||||
|
||||
def request(self, context):
|
||||
require_environment()
|
||||
require_model(BEN2_MODEL_KEY)
|
||||
@@ -102,12 +73,15 @@ class RunBackgroundRemoval(JobOperatorBase, bpy.types.Operator):
|
||||
"model": BEN2_MODEL_KEY,
|
||||
"device": production_device().lower(),
|
||||
"video_frames": settings.video_frames,
|
||||
"start_frame": source_start_frame(self.source_object_name),
|
||||
"start_frame": self._image_target.start_frame,
|
||||
}
|
||||
|
||||
def response(self, _context, result):
|
||||
apply_background_response(result, self.source_object_name)
|
||||
self._image_target.apply(result.directory)
|
||||
if "FINISHED" not in bpy.ops.ed.undo_push(message="Remove Background"):
|
||||
raise RuntimeError("Unable to create the Remove Background Undo step")
|
||||
return "Image background is removed"
|
||||
|
||||
def cleanup(self):
|
||||
cleanup_image_input(self.input_path, self.delete_input)
|
||||
self._image_target = None
|
||||
|
||||
@@ -15,17 +15,8 @@ from ..common.ai import (
|
||||
require_environment,
|
||||
require_model,
|
||||
)
|
||||
from ..common.image import (
|
||||
cleanup_image_input,
|
||||
default_extract_frames,
|
||||
is_image_empty,
|
||||
load_image_edit_result,
|
||||
prepare_image_input,
|
||||
source_start_frame,
|
||||
replace_empty_image,
|
||||
require_image_empty,
|
||||
video_total_frames,
|
||||
)
|
||||
from ..common.image import cleanup_image_input
|
||||
from ..common.image_target import ImageEditTarget, image_edit_owner, owner_image
|
||||
|
||||
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
|
||||
|
||||
@@ -49,10 +40,10 @@ def upscale_input_size_allowed(image, max_input_size):
|
||||
class UpscaleImage(JobOperatorBase, bpy.types.Operator):
|
||||
bl_idname = "anyimage.upscale_image"
|
||||
bl_label = "Upscale"
|
||||
bl_description = "Upscale this Image Empty by 2x with an AI model"
|
||||
bl_description = "Upscale the image by 2x with an AI model"
|
||||
bl_options = {"UNDO"}
|
||||
job_type = "upscale-image"
|
||||
starting_message = "Starting Image Empty upscale"
|
||||
starting_message = "Starting image upscale"
|
||||
|
||||
model: bpy.props.StringProperty(
|
||||
default="REALESRGAN_GENERAL_WDN_X4V3",
|
||||
@@ -69,19 +60,17 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator):
|
||||
options=_HIDDEN,
|
||||
)
|
||||
input_path: bpy.props.StringProperty(options=_HIDDEN)
|
||||
source_object_name: bpy.props.StringProperty(options=_HIDDEN)
|
||||
source_image_name: bpy.props.StringProperty(options=_HIDDEN)
|
||||
delete_input: bpy.props.BoolProperty(default=False, options=_HIDDEN)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
from ..runtime import runtime
|
||||
|
||||
source_object = getattr(context, "object", None)
|
||||
owner = image_edit_owner(context)
|
||||
return (
|
||||
is_image_empty(source_object)
|
||||
owner is not None
|
||||
and upscale_input_size_allowed(
|
||||
source_object.data,
|
||||
owner_image(owner),
|
||||
configured_max_ai_input_size(context),
|
||||
)
|
||||
and not runtime.server_busy()
|
||||
@@ -96,11 +85,12 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator):
|
||||
def execute(self, context):
|
||||
self.model = configured_upscale_model(context)
|
||||
self.max_input_size = configured_max_ai_input_size(context)
|
||||
source_object = context.object
|
||||
if not is_image_empty(source_object):
|
||||
self.report({"ERROR"}, "Select an Image Empty to upscale")
|
||||
try:
|
||||
self._image_target = ImageEditTarget.capture(context)
|
||||
except RuntimeError as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
return {"CANCELLED"}
|
||||
source_image = source_object.data
|
||||
source_image = self._image_target.image
|
||||
if not upscale_input_size_allowed(source_image, self.max_input_size):
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
@@ -108,26 +98,17 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator):
|
||||
f"({self.max_input_size} px)",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
self.source_image_name = source_image.name
|
||||
if video_total_frames(source_image) > 1:
|
||||
context.scene.anyimage_settings.video_frames = (
|
||||
default_extract_frames(source_object)
|
||||
)
|
||||
try:
|
||||
source_path, delete_input = prepare_image_input(
|
||||
source_image,
|
||||
context.scene,
|
||||
)
|
||||
source_path, delete_input = self._image_target.prepare(context)
|
||||
except RuntimeError as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
return {"CANCELLED"}
|
||||
|
||||
self.input_path = str(source_path)
|
||||
self.source_object_name = source_object.name
|
||||
self.delete_input = delete_input
|
||||
result = super().execute(context)
|
||||
if "CANCELLED" in result:
|
||||
cleanup_image_input(self.input_path, self.delete_input)
|
||||
self.cleanup()
|
||||
return result
|
||||
|
||||
def request(self, context):
|
||||
@@ -139,23 +120,15 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator):
|
||||
"model": self.model,
|
||||
"device": production_device().lower(),
|
||||
"video_frames": settings.video_frames,
|
||||
"start_frame": source_start_frame(self.source_object_name),
|
||||
"start_frame": self._image_target.start_frame,
|
||||
"max_input_size": int(self.max_input_size),
|
||||
}
|
||||
|
||||
def response(self, _context, result):
|
||||
source_object = require_image_empty(self.source_object_name)
|
||||
source_image = bpy.data.images.get(self.source_image_name)
|
||||
if source_image is None:
|
||||
raise RuntimeError("The source image is no longer available")
|
||||
result_image = load_image_edit_result(
|
||||
source_object,
|
||||
result.directory,
|
||||
frame_prefix="upscale.",
|
||||
)
|
||||
replace_empty_image(source_object, result_image)
|
||||
self._image_target.apply(result.directory, frame_prefix="upscale.")
|
||||
model_label = upscale_model_label(self.model)
|
||||
return f"Image is upscaled with {model_label}"
|
||||
|
||||
def cleanup(self):
|
||||
cleanup_image_input(self.input_path, self.delete_input)
|
||||
self._image_target = None
|
||||
|
||||
@@ -24,7 +24,8 @@ def test_owned_color_images_preserve_source_and_use_premul(floating):
|
||||
try:
|
||||
assert original.alpha_mode == "STRAIGHT"
|
||||
np.testing.assert_allclose(images.image_rgba(original), expected)
|
||||
for image in (copied, cropped, edited):
|
||||
assert edited.alpha_mode == original.alpha_mode
|
||||
for image in (copied, cropped):
|
||||
assert image.alpha_mode == "PREMUL"
|
||||
np.testing.assert_allclose(images.image_rgba(image), expected, atol=1 / 255)
|
||||
finally:
|
||||
@@ -33,13 +34,14 @@ def test_owned_color_images_preserve_source_and_use_premul(floating):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("loader", [images.load_image_edit_result, images.load_color_result_image])
|
||||
def test_job_color_loader_defaults_to_premul(tmp_path, loader):
|
||||
def test_job_loader_uses_edit_or_material_alpha_policy(tmp_path, loader):
|
||||
rgba = np.array([[[51, 102, 204, 64]]], dtype=np.uint8)
|
||||
Image.fromarray(rgba).save(tmp_path / "image.0001.png")
|
||||
original = bpy.data.images.new("Job source", width=1, height=1, alpha=True)
|
||||
result = loader(SimpleNamespace(data=original), tmp_path)
|
||||
source = original if loader == images.load_image_edit_result else SimpleNamespace(data=original)
|
||||
result = loader(source, tmp_path)
|
||||
try:
|
||||
assert result.alpha_mode == "PREMUL"
|
||||
assert result.alpha_mode == ("STRAIGHT" if loader == images.load_image_edit_result else "PREMUL")
|
||||
assert result.packed_file is not None
|
||||
assert original.alpha_mode == "STRAIGHT"
|
||||
finally:
|
||||
@@ -70,6 +72,7 @@ def test_failed_empty_replacement_restores_blending_and_timing():
|
||||
|
||||
def test_business_pixels_ignore_premul_read_interpretation():
|
||||
original = bpy.data.images.new("Premul read source", width=1, height=1, alpha=True)
|
||||
original.alpha_mode = "PREMUL"
|
||||
expected = np.array([0.4, 0.25, 0.1, 0.5], dtype=np.float32)
|
||||
result = images.create_image_edit_result(original, expected, (1, 1))
|
||||
try:
|
||||
@@ -87,6 +90,7 @@ def test_business_pixels_ignore_premul_read_interpretation():
|
||||
@pytest.mark.parametrize("dirty", [False, True])
|
||||
def test_business_read_preserves_shared_packed_source(dirty):
|
||||
original = bpy.data.images.new("Read integrity", width=1, height=1, alpha=True)
|
||||
original.alpha_mode = "PREMUL"
|
||||
result = images.create_image_edit_result(original, np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32), (1, 1))
|
||||
owner = bpy.data.objects.new("Read owner", None)
|
||||
owner.empty_display_type = "IMAGE"
|
||||
@@ -219,7 +223,7 @@ def test_mask_pack_reopen_add_and_repeat_frame_preserve_hidden_color(tmp_path):
|
||||
bpy.data.images.remove(result)
|
||||
result = next_result
|
||||
result.reload()
|
||||
assert result.alpha_mode == "PREMUL" and result.packed_file is not None
|
||||
assert result.alpha_mode == "STRAIGHT" and result.packed_file is not None
|
||||
np.testing.assert_allclose(images.image_rgba(result), rgba, atol=1 / 255)
|
||||
finally:
|
||||
bpy.data.images.remove(result)
|
||||
|
||||
@@ -33,6 +33,27 @@ def edit_result(source):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha_mode", ["STRAIGHT", "PREMUL", "CHANNEL_PACKED", "NONE"])
|
||||
@pytest.mark.parametrize("shared", [False, True])
|
||||
@pytest.mark.parametrize("entry", ["pixels", "image.", "upscale."])
|
||||
def test_image_edit_replacement_preserves_alpha_mode(source, tmp_path, alpha_mode, shared, entry):
|
||||
original = source.data
|
||||
original.alpha_mode = alpha_mode
|
||||
if shared:
|
||||
other = create_empty("Other", original)
|
||||
if entry == "pixels":
|
||||
result = edit_result(source)
|
||||
else:
|
||||
Image.new("RGBA", (4, 3), (100, 150, 200, 128)).save(tmp_path / f"{entry}0001.png")
|
||||
result = images.load_image_edit_result(original, tmp_path, frame_prefix=entry)
|
||||
assert result.alpha_mode == alpha_mode
|
||||
images.replace_empty_image(source, result)
|
||||
assert source.data.alpha_mode == alpha_mode
|
||||
assert (source.data == original) == (not shared)
|
||||
if shared:
|
||||
assert other.data == original and original.alpha_mode == alpha_mode
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sharing", ["visible", "hidden", "other_scene"])
|
||||
def test_shared_edit_preserves_other_empty(source, sharing):
|
||||
original = source.data
|
||||
@@ -179,7 +200,7 @@ def test_animated_result_preserves_id_and_reopens(source, tmp_path):
|
||||
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)
|
||||
result = images.load_image_edit_result(source.data, tmp_path)
|
||||
expected_path = result.filepath_raw
|
||||
expected_packed = bytes(result.packed_file.data) if result.packed_file else None
|
||||
final = images.replace_empty_image(source, result)
|
||||
|
||||
@@ -46,30 +46,30 @@ class ImageDataTest(unittest.TestCase):
|
||||
self.assertEqual(self.modules.image.video_total_frames(image), 1)
|
||||
|
||||
|
||||
def test_default_extract_frames_uses_empty_image_frames(self):
|
||||
def test_extract_frame_count_uses_empty_image_frames(self):
|
||||
source_object = SimpleNamespace(
|
||||
data=SimpleNamespace(source="MOVIE", frame_duration=97),
|
||||
image_user=SimpleNamespace(frame_duration=24),
|
||||
)
|
||||
|
||||
self.assertEqual(self.modules.image.default_extract_frames(source_object), 24)
|
||||
self.assertEqual(self.modules.image.extract_frame_range(source_object.data, getattr(source_object, "image_user", None))[1], 24)
|
||||
|
||||
|
||||
def test_default_extract_frames_caps_at_video_total(self):
|
||||
def test_extract_frame_count_caps_at_video_total(self):
|
||||
source_object = SimpleNamespace(
|
||||
data=SimpleNamespace(source="MOVIE", frame_duration=97),
|
||||
image_user=SimpleNamespace(frame_duration=200),
|
||||
)
|
||||
|
||||
self.assertEqual(self.modules.image.default_extract_frames(source_object), 97)
|
||||
self.assertEqual(self.modules.image.extract_frame_range(source_object.data, getattr(source_object, "image_user", None))[1], 97)
|
||||
|
||||
|
||||
def test_default_extract_frames_falls_back_to_video_total(self):
|
||||
def test_extract_frame_count_falls_back_to_video_total(self):
|
||||
source_object = SimpleNamespace(
|
||||
data=SimpleNamespace(source="MOVIE", frame_duration=97),
|
||||
)
|
||||
|
||||
self.assertEqual(self.modules.image.default_extract_frames(source_object), 97)
|
||||
self.assertEqual(self.modules.image.extract_frame_range(source_object.data, getattr(source_object, "image_user", None))[1], 97)
|
||||
|
||||
|
||||
def test_extract_frame_range_honors_offset_within_clip(self):
|
||||
@@ -83,7 +83,7 @@ class ImageDataTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.modules.image.extract_frame_range(source_object),
|
||||
self.modules.image.extract_frame_range(source_object.data, source_object.image_user),
|
||||
(71, 24),
|
||||
)
|
||||
|
||||
@@ -99,7 +99,7 @@ class ImageDataTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.modules.image.extract_frame_range(source_object),
|
||||
self.modules.image.extract_frame_range(source_object.data, source_object.image_user),
|
||||
(74, 24),
|
||||
)
|
||||
|
||||
@@ -115,7 +115,7 @@ class ImageDataTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.modules.image.extract_frame_range(source_object),
|
||||
self.modules.image.extract_frame_range(source_object.data, source_object.image_user),
|
||||
(1, 24),
|
||||
)
|
||||
|
||||
@@ -131,7 +131,7 @@ class ImageDataTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.modules.image.extract_frame_range(source_object),
|
||||
self.modules.image.extract_frame_range(source_object.data, source_object.image_user),
|
||||
(1, 1),
|
||||
)
|
||||
|
||||
@@ -147,7 +147,7 @@ class ImageDataTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.modules.image.extract_frame_range(source_object),
|
||||
self.modules.image.extract_frame_range(source_object.data, source_object.image_user),
|
||||
(71, 1),
|
||||
)
|
||||
|
||||
|
||||
222
tests/anyimage/common/test_image_target.py
Normal file
222
tests/anyimage/common/test_image_target.py
Normal file
@@ -0,0 +1,222 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from anyimage.common import image as images
|
||||
from anyimage.common import image_target as targets
|
||||
from tests.support.image_texture import texture, texture_context, write_result
|
||||
|
||||
|
||||
def test_target_uses_edit_tree_and_survives_renaming(texture, tmp_path):
|
||||
context = texture_context(texture, pin=True)
|
||||
target = targets.ImageEditTarget.capture(context)
|
||||
texture.name = "Renamed texture"
|
||||
bpy.data.materials["Texture material"].name = "Renamed material"
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
texture.id_data.nodes.active = other
|
||||
context.space_data.edit_tree = None
|
||||
write_result(tmp_path)
|
||||
target.apply(tmp_path)
|
||||
assert tuple(texture.image.size) == (8, 6)
|
||||
assert other.image is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("color_space,alpha_mode", [
|
||||
("sRGB", "PREMUL"),
|
||||
("AgX Base sRGB", "STRAIGHT"),
|
||||
("Filmic sRGB", "STRAIGHT"),
|
||||
("sRGB", "STRAIGHT"),
|
||||
("AgX Base sRGB", "PREMUL"),
|
||||
])
|
||||
@pytest.mark.parametrize("prefix", ["image.", "upscale."])
|
||||
@pytest.mark.parametrize("shared", [False, True])
|
||||
def test_image_actions_preserve_source_color_space_and_alpha(texture, tmp_path, color_space, alpha_mode, prefix, shared):
|
||||
texture.image.colorspace_settings.name = color_space
|
||||
texture.image.alpha_mode = alpha_mode
|
||||
original = texture.image
|
||||
original_pixels = images.image_pixels(original)
|
||||
if shared:
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
other.image = original
|
||||
target = targets.ImageEditTarget.capture(texture_context(texture))
|
||||
write_result(tmp_path, prefix)
|
||||
target.apply(tmp_path, frame_prefix=prefix)
|
||||
result = texture.image
|
||||
assert result.colorspace_settings.name == color_space
|
||||
assert result.alpha_mode == alpha_mode
|
||||
np.testing.assert_allclose(
|
||||
images.image_pixels(result).reshape(-1, 4),
|
||||
np.tile(np.array([128, 64, 32, 80]) / 255, (48, 1)),
|
||||
atol=1 / 255,
|
||||
)
|
||||
if shared:
|
||||
assert other.image == original
|
||||
assert original.alpha_mode == alpha_mode
|
||||
np.testing.assert_array_equal(images.image_pixels(original), original_pixels)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", ["world", "light", "compositor", "geometry", "missing_image", "other_node", "no_tree"])
|
||||
def test_invalid_node_context_does_not_fall_back_to_empty(texture, invalid):
|
||||
context = texture_context(texture)
|
||||
context.object = bpy.data.objects.new("Fallback Empty", None)
|
||||
context.object.empty_display_type = "IMAGE"
|
||||
context.object.data = texture.image
|
||||
if invalid == "world":
|
||||
context.space_data.shader_type = "WORLD"
|
||||
elif invalid == "light":
|
||||
context.space_data.id = bpy.data.lights.new("Light", type="POINT")
|
||||
elif invalid in {"compositor", "geometry"}:
|
||||
context.space_data.tree_type = "CompositorNodeTree" if invalid == "compositor" else "GeometryNodeTree"
|
||||
elif invalid == "missing_image":
|
||||
texture.image = None
|
||||
elif invalid == "other_node":
|
||||
texture.id_data.nodes.active = texture.id_data.nodes.get("Principled BSDF")
|
||||
else:
|
||||
context.space_data.edit_tree = None
|
||||
assert targets.image_edit_owner(context) is None
|
||||
with pytest.raises(RuntimeError, match="Select"):
|
||||
targets.ImageEditTarget.capture(context)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["delete", "recreate", "rebind", "delete_material", "delete_image"])
|
||||
def test_target_rejects_changed_identity_before_loading_result(texture, tmp_path, change):
|
||||
target = targets.ImageEditTarget.capture(texture_context(texture))
|
||||
tree = texture.id_data
|
||||
if change in {"delete", "recreate"}:
|
||||
name = texture.name
|
||||
tree.nodes.remove(texture)
|
||||
if change == "recreate":
|
||||
replacement = tree.nodes.new("ShaderNodeTexImage")
|
||||
replacement.name = name
|
||||
replacement.image = target.image
|
||||
elif change == "rebind":
|
||||
texture.image = bpy.data.images.new("Replacement", width=2, height=2)
|
||||
elif change == "delete_material":
|
||||
bpy.data.materials.remove(bpy.data.materials["Texture material"])
|
||||
else:
|
||||
bpy.data.images.remove(texture.image)
|
||||
count = len(bpy.data.images)
|
||||
with pytest.raises(RuntimeError, match="no longer available"):
|
||||
target.apply(tmp_path)
|
||||
assert len(bpy.data.images) == count
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reference", ["sole", "fake_user", "editor", "same_tree", "other_material", "empty", "brush"])
|
||||
def test_texture_commit_preserves_structure_and_isolates_users(texture, tmp_path, reference):
|
||||
original = texture.image
|
||||
original.colorspace_settings.name = "Non-Color"
|
||||
expected_source = images.image_pixels(original)
|
||||
source_packed = bytes(original.packed_file.data)
|
||||
original.use_fake_user = reference == "fake_user"
|
||||
target = targets.ImageEditTarget.capture(texture_context(texture))
|
||||
# Add users after capture to exercise changes while an AI job is pending.
|
||||
if reference == "same_tree":
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
other.image = original
|
||||
elif reference == "other_material":
|
||||
material = bpy.data.materials.new("Other material")
|
||||
material.use_nodes = True
|
||||
other = material.node_tree.nodes.new("ShaderNodeTexImage")
|
||||
other.image = original
|
||||
elif reference == "empty":
|
||||
other = bpy.data.objects.new("Other Empty", None)
|
||||
other.empty_display_type = "IMAGE"
|
||||
other.data = original
|
||||
elif reference == "brush":
|
||||
other = bpy.data.textures.new("Brush texture", type="IMAGE")
|
||||
other.image = original
|
||||
elif reference == "editor":
|
||||
area = bpy.context.screen.areas[0]
|
||||
area.type = "IMAGE_EDITOR"
|
||||
area.spaces.active.image = original
|
||||
shared = reference in {"same_tree", "other_material", "empty", "brush"}
|
||||
links = [(link.from_socket, link.to_socket) for link in texture.id_data.links]
|
||||
write_result(tmp_path)
|
||||
target.apply(tmp_path)
|
||||
assert (texture.image == original) == (not shared)
|
||||
assert tuple(texture.image.size) == (8, 6)
|
||||
assert texture.image.colorspace_settings.name == "Non-Color"
|
||||
assert texture.image.alpha_mode == original.alpha_mode
|
||||
assert texture.image.packed_file is not None
|
||||
assert tuple(texture.location) == (-400, 100)
|
||||
assert (texture.interpolation, texture.extension, texture.projection) == ("Closest", "CLIP", "BOX")
|
||||
assert [(link.from_socket, link.to_socket) for link in texture.id_data.links] == links
|
||||
assert original.use_fake_user == (reference == "fake_user")
|
||||
if shared:
|
||||
assert (other.data if reference == "empty" else other.image) == original
|
||||
assert bytes(original.packed_file.data) == source_packed
|
||||
np.testing.assert_array_equal(images.image_pixels(original), expected_source)
|
||||
else:
|
||||
assert texture.image.name == "Texture.png"
|
||||
assert len(bpy.data.images) == 1
|
||||
expected = images.image_pixels(texture.image)
|
||||
path = str(tmp_path / "texture.blend")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=path)
|
||||
bpy.ops.wm.open_mainfile(filepath=path)
|
||||
reopened = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"]
|
||||
np.testing.assert_allclose(images.image_pixels(reopened.image), expected, atol=1 / 255)
|
||||
assert len(reopened.id_data.links) == len(links)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shared", [False, True])
|
||||
def test_animated_texture_preserves_playback_and_reopens(texture, tmp_path, shared):
|
||||
write_result(tmp_path, count=4)
|
||||
texture.image.source = "SEQUENCE"
|
||||
texture.image.filepath_raw = str(tmp_path / "image.0001.png")
|
||||
texture.image_user.frame_start = 9
|
||||
texture.image_user.frame_offset = 1
|
||||
texture.image_user.frame_duration = 2
|
||||
texture.image_user.use_cyclic = True
|
||||
context = texture_context(texture)
|
||||
target = targets.ImageEditTarget.capture(context)
|
||||
assert (target.start_frame, target.frame_count) == (2, 2)
|
||||
if shared:
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
other.image = texture.image
|
||||
with patch.object(targets, "prepare_image_input", return_value=(tmp_path, False)):
|
||||
target.prepare(context)
|
||||
assert context.scene.anyimage_settings.video_frames == 2
|
||||
target.apply(tmp_path)
|
||||
assert images.image_user_settings(texture) == (9, 0, 4, True)
|
||||
assert texture.image_user.use_auto_refresh
|
||||
assert texture.image.source == "SEQUENCE"
|
||||
path = str(tmp_path / "animated.blend")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=path)
|
||||
bpy.ops.wm.open_mainfile(filepath=path)
|
||||
reopened = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"]
|
||||
assert images.image_user_settings(reopened) == (9, 0, 4, True)
|
||||
assert len(images.collect_sequence_frames(reopened.image)) == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shared", [False, True])
|
||||
def test_texture_failed_commit_restores_dirty_pixels_and_playback(texture, tmp_path, shared):
|
||||
original = texture.image
|
||||
if shared:
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
other.image = original
|
||||
original.pixels.foreach_set(np.tile([0.1, 0.3, 0.7, 0.5], 12).astype(np.float32))
|
||||
packed = bytes(original.packed_file.data)
|
||||
pixels = images.image_pixels(original)
|
||||
timing = images.image_user_settings(texture)
|
||||
write_result(tmp_path, count=2)
|
||||
result = bpy.data.images.load(str(tmp_path / "image.0001.png"))
|
||||
result.source = "SEQUENCE"
|
||||
count = len(bpy.data.images) - 1
|
||||
|
||||
def fail_configuration(user, *args, **kwargs):
|
||||
user.frame_start = 80
|
||||
user.use_auto_refresh = True
|
||||
raise RuntimeError("Playback failed")
|
||||
|
||||
with patch.object(targets, "configure_image_user", side_effect=fail_configuration):
|
||||
with pytest.raises(RuntimeError, match="Playback failed"):
|
||||
targets.replace_texture_image(texture, result)
|
||||
assert texture.image == original
|
||||
assert tuple(original.size) == (4, 3)
|
||||
assert bytes(original.packed_file.data) == packed
|
||||
np.testing.assert_array_equal(images.image_pixels(original), pixels)
|
||||
assert images.image_user_settings(texture) == timing
|
||||
assert not texture.image_user.use_auto_refresh
|
||||
assert len(bpy.data.images) == count
|
||||
@@ -115,7 +115,7 @@ def test_material_pixels_survive_pack_and_library_reload(color_data, tmp_path, e
|
||||
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"
|
||||
assert edited.alpha_mode == selected[1]
|
||||
np.testing.assert_allclose(images.image_pixels(edited), before, atol=1 / 255)
|
||||
|
||||
|
||||
@@ -179,16 +179,16 @@ def test_new_material_uses_current_preference_and_preserves_existing(color_data,
|
||||
np.testing.assert_array_equal(images.image_pixels(image), pixels)
|
||||
|
||||
|
||||
def test_edit_results_keep_their_policy_with_adaptation_enabled(color_data, tmp_path):
|
||||
def test_edit_results_preserve_source_alpha_with_adaptation_enabled(color_data, tmp_path):
|
||||
source = bpy.data.images.new("Editing source", width=1, height=1, alpha=True)
|
||||
rgba = np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32)
|
||||
Image.fromarray(np.array([[[51, 102, 204, 64]]], dtype=np.uint8)).save(tmp_path / "image.0001.png")
|
||||
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)
|
||||
loaded = images.load_image_edit_result(source, tmp_path)
|
||||
for result in (edited, loaded):
|
||||
assert result.colorspace_settings.name == "sRGB"
|
||||
assert result.alpha_mode == "PREMUL"
|
||||
assert result.alpha_mode == source.alpha_mode
|
||||
np.testing.assert_allclose(images.image_pixels(result), rgba, atol=1 / 255)
|
||||
assert source.colorspace_settings.name == "sRGB"
|
||||
assert source.alpha_mode == "STRAIGHT"
|
||||
|
||||
@@ -21,7 +21,7 @@ class AiImageOperatorsTest(BlenderTestCase):
|
||||
self.assertEqual(operator_type.bl_options, {"UNDO"})
|
||||
operator = operator_type()
|
||||
operator.input_path = "input.png"
|
||||
operator.source_object_name = "Source"
|
||||
operator._image_target = SimpleNamespace(start_frame=4)
|
||||
operator.model = "SAFMN_REAL_X4"
|
||||
operator.max_input_size = 1024
|
||||
context = SimpleNamespace(
|
||||
@@ -34,7 +34,6 @@ class AiImageOperatorsTest(BlenderTestCase):
|
||||
self.upscale, "require_input_path", return_value=Path("input.png")
|
||||
),
|
||||
patch.object(self.upscale, "production_device", return_value="CUDA"),
|
||||
patch.object(self.upscale, "source_start_frame", return_value=4),
|
||||
):
|
||||
request = operator.request(context)
|
||||
|
||||
@@ -89,63 +88,37 @@ class AiImageOperatorsTest(BlenderTestCase):
|
||||
|
||||
|
||||
def test_remove_background_loads_a_plain_image_edit_result(self):
|
||||
source = SimpleNamespace()
|
||||
result_image = SimpleNamespace()
|
||||
result = SimpleNamespace(directory=Path("result"))
|
||||
operator = self.remove_background.RunBackgroundRemoval()
|
||||
operator._image_target = Mock()
|
||||
ops = SimpleNamespace(
|
||||
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
|
||||
)
|
||||
with (
|
||||
patch.object(self.image_data, "require_image_empty", return_value=source),
|
||||
patch.object(
|
||||
self.image_data,
|
||||
"load_image_edit_result",
|
||||
return_value=result_image,
|
||||
) as load,
|
||||
patch.object(self.image_data, "replace_empty_image") as replace,
|
||||
patch.object(self.remove_background.bpy, "ops", ops, create=True),
|
||||
):
|
||||
self.remove_background.apply_background_response(result, "Source")
|
||||
operator.response(None, result)
|
||||
|
||||
load.assert_called_once_with(source, Path("result"))
|
||||
replace.assert_called_once_with(source, result_image)
|
||||
operator._image_target.apply.assert_called_once_with(Path("result"))
|
||||
ops.ed.undo_push.assert_called_once_with(message="Remove Background")
|
||||
|
||||
|
||||
def test_upscale_loads_a_plain_image_edit_result(self):
|
||||
source_image = SimpleNamespace()
|
||||
source = SimpleNamespace(data=source_image)
|
||||
result_image = SimpleNamespace()
|
||||
result = SimpleNamespace(directory=Path("result"))
|
||||
operator = self.upscale.UpscaleImage()
|
||||
operator.source_object_name = "Source"
|
||||
operator.source_image_name = "Poster.png"
|
||||
operator._image_target = Mock()
|
||||
operator.model = "SAFMN_REAL_X4"
|
||||
images = SimpleNamespace(get=lambda name: source_image if name == "Poster.png" else None)
|
||||
with (
|
||||
patch.object(self.upscale, "require_image_empty", return_value=source),
|
||||
patch.object(
|
||||
self.upscale,
|
||||
"load_image_edit_result",
|
||||
return_value=result_image,
|
||||
) as load,
|
||||
patch.object(self.upscale, "replace_empty_image") as replace,
|
||||
patch.object(
|
||||
self.upscale,
|
||||
"upscale_model_label",
|
||||
return_value="SAFMN Real x4",
|
||||
),
|
||||
patch.object(
|
||||
self.upscale.bpy,
|
||||
"data",
|
||||
SimpleNamespace(images=images),
|
||||
),
|
||||
):
|
||||
message = operator.response(None, result)
|
||||
|
||||
load.assert_called_once_with(
|
||||
source,
|
||||
operator._image_target.apply.assert_called_once_with(
|
||||
Path("result"),
|
||||
frame_prefix="upscale.",
|
||||
)
|
||||
replace.assert_called_once_with(source, result_image)
|
||||
self.assertEqual(message, "Image is upscaled with SAFMN Real x4")
|
||||
|
||||
@@ -126,7 +126,7 @@ def test_packed_frame_mask_add_keeps_boundary_color(background):
|
||||
).reshape(1, 1, 4)
|
||||
np.testing.assert_allclose(restored[0, 0, :3], expected, atol=1 / 255)
|
||||
assert restored[0, 0, 3] == 1
|
||||
assert result.alpha_mode == "PREMUL"
|
||||
assert result.alpha_mode == "STRAIGHT"
|
||||
finally:
|
||||
bpy.data.images.remove(original)
|
||||
bpy.data.images.remove(result)
|
||||
|
||||
@@ -31,7 +31,7 @@ def test_image_edit_result_uses_complete_shared_source_name():
|
||||
|
||||
assert shared.data == source_image
|
||||
assert source_image.alpha_mode == "STRAIGHT"
|
||||
assert result_image.alpha_mode == "PREMUL"
|
||||
assert result_image.alpha_mode == "STRAIGHT"
|
||||
assert source.use_empty_image_alpha
|
||||
assert source.data == result_image
|
||||
assert result_image.name == "Poster.png.001"
|
||||
@@ -92,14 +92,14 @@ def test_mask_frame_mask_add_preserves_packed_hidden_rgb():
|
||||
)
|
||||
result = image_data.create_image_edit_result(source.data, framed, (4, 4))
|
||||
result = image_data.replace_empty_image(source, result)
|
||||
assert result.alpha_mode == "PREMUL"
|
||||
assert result.alpha_mode == "STRAIGHT"
|
||||
assert source.use_empty_image_alpha
|
||||
assert result.packed_file is not None
|
||||
packed = np.asarray(Image.open(BytesIO(result.packed_file.data)).convert("RGBA")) / 255.0
|
||||
np.testing.assert_allclose(packed[..., :3], expected[..., :3], atol=1 / 255)
|
||||
np.testing.assert_array_equal(packed[..., 3], 0)
|
||||
result.reload()
|
||||
assert result.alpha_mode == "PREMUL"
|
||||
assert result.alpha_mode == "STRAIGHT"
|
||||
operator.mode = "ADD"
|
||||
assert edit_mask.EditImageAlpha._apply_mask(
|
||||
operator, SimpleNamespace(), source, selection
|
||||
|
||||
@@ -9,8 +9,10 @@ from anyimage.operators import mask_tool as mask
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gesture", ["LASSO", "BRUSH", "POLYLINE"])
|
||||
def test_mask_commit_is_one_undo_transaction(gesture):
|
||||
@pytest.mark.parametrize("alpha_mode", ["STRAIGHT", "PREMUL"])
|
||||
def test_mask_commit_is_one_undo_transaction(gesture, alpha_mode):
|
||||
source = bpy.data.images.new("Undo source", width=8, height=8, alpha=True)
|
||||
source.alpha_mode = alpha_mode
|
||||
source.pixels.foreach_set(np.ones(8 * 8 * 4, dtype=np.float32))
|
||||
source.pack()
|
||||
owner = bpy.data.objects.new("Mask undo owner", None)
|
||||
@@ -47,11 +49,12 @@ def test_mask_commit_is_one_undo_transaction(gesture):
|
||||
assert bpy.ops.anyimage.edit_image_alpha("EXEC_DEFAULT", True) == {"FINISHED"}
|
||||
create.assert_called_once()
|
||||
result = bpy.data.objects[owner_name].data
|
||||
assert result.packed_file is not None and result.alpha_mode == "PREMUL"
|
||||
assert result.packed_file is not None and result.alpha_mode == alpha_mode
|
||||
assert bpy.data.objects[shared_name].data.name == source_name
|
||||
np.testing.assert_array_equal(image.image_pixels(bpy.data.images[source_name]), before)
|
||||
assert bpy.ops.ed.undo() == {"FINISHED"}
|
||||
assert bpy.data.objects[owner_name].data.name == source_name
|
||||
assert bpy.data.objects[owner_name].data.alpha_mode == alpha_mode
|
||||
np.testing.assert_array_equal(image.image_pixels(bpy.data.objects[owner_name].data), before)
|
||||
finally:
|
||||
bpy.utils.unregister_class(mask.EditImageAlpha)
|
||||
|
||||
225
tests/anyimage/operators/test_texture_node_actions.py
Normal file
225
tests/anyimage/operators/test_texture_node_actions.py
Normal file
@@ -0,0 +1,225 @@
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from anyimage import menu
|
||||
from anyimage.common import image as images
|
||||
from anyimage.common.image_target import ImageEditTarget, active_texture_node
|
||||
from anyimage.operators import remove_background, upscale
|
||||
from anyimage.runtime import runtime
|
||||
from tests.support.image_texture import texture, texture_context, write_result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shader_editor(texture):
|
||||
mesh = bpy.data.meshes.new("Material owner")
|
||||
obj = bpy.data.objects.new("Material owner", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
obj.data.materials.append(bpy.data.materials["Texture material"])
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D")
|
||||
area.type = "NODE_EDITOR"
|
||||
area.ui_type = "ShaderNodeTree"
|
||||
with bpy.context.temp_override(area=area):
|
||||
yield bpy.context
|
||||
|
||||
|
||||
def test_shader_editor_pin_and_shared_group_target(shader_editor, tmp_path):
|
||||
context = shader_editor
|
||||
node = active_texture_node(context)
|
||||
assert node is not None
|
||||
assert context.space_data.id == bpy.data.materials["Texture material"]
|
||||
context.space_data.pin = True
|
||||
bpy.context.view_layer.objects.active = None
|
||||
assert active_texture_node(context) == node
|
||||
# Use Blender's own grouping operation to exercise nested editor context.
|
||||
for candidate in node.id_data.nodes:
|
||||
candidate.select = candidate == node
|
||||
assert bpy.ops.node.group_make() == {"FINISHED"}
|
||||
nested = active_texture_node(context)
|
||||
assert nested is not None
|
||||
assert context.space_data.edit_tree != context.space_data.node_tree
|
||||
group = nested.id_data
|
||||
second_instance = context.space_data.node_tree.nodes.new("ShaderNodeGroup")
|
||||
second_instance.node_tree = group
|
||||
source = nested.image
|
||||
target = ImageEditTarget.capture(context)
|
||||
write_result(tmp_path)
|
||||
target.apply(tmp_path)
|
||||
assert nested.image == source
|
||||
assert tuple(source.size) == (8, 6)
|
||||
assert second_instance.node_tree.nodes[nested.name].image == source
|
||||
|
||||
|
||||
def test_linked_material_target_is_unavailable(texture, tmp_path):
|
||||
path = str(tmp_path / "linked.blend")
|
||||
bpy.data.libraries.write(path, {bpy.data.materials["Texture material"]})
|
||||
with bpy.data.libraries.load(path, link=True) as (source, destination):
|
||||
destination.materials = source.materials
|
||||
linked = destination.materials[0].node_tree.nodes["Source texture"]
|
||||
context = texture_context(linked)
|
||||
assert active_texture_node(context) is None
|
||||
target = ImageEditTarget(linked, linked.image, linked.id_data)
|
||||
with pytest.raises(RuntimeError, match="no longer available"):
|
||||
target.validate()
|
||||
|
||||
|
||||
def test_texture_menu_uses_node_dimensions_and_ai_settings(texture):
|
||||
context = texture_context(texture)
|
||||
layout = SimpleNamespace(operator=Mock(), separator=Mock(), menu=Mock())
|
||||
with patch("anyimage.properties.ai_status", return_value={"ready": True}):
|
||||
menu.AnyImageTextureNodeMenu.draw(SimpleNamespace(layout=layout), context)
|
||||
assert layout.operator_context == "INVOKE_DEFAULT"
|
||||
assert [call.args[0] for call in layout.operator.call_args_list] == [
|
||||
"anyimage.remove_image_background", "anyimage.upscale_image", "anyimage.open_ai_environment_settings",
|
||||
]
|
||||
assert layout.operator.call_args_list[1].kwargs["text"] == "Upscale (8 × 6)"
|
||||
menu.draw_texture_node_context_menu(SimpleNamespace(layout=layout), context)
|
||||
layout.menu.assert_called_once_with(menu.AnyImageTextureNodeMenu.bl_idname, text="AnyImage", icon="PLUGIN")
|
||||
texture.image = None
|
||||
layout.menu.reset_mock()
|
||||
menu.draw_texture_node_context_menu(SimpleNamespace(layout=layout), context)
|
||||
layout.menu.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("busy,limit,expected", [(False, 2048, True), (True, 2048, False), (False, 4, False)])
|
||||
def test_texture_poll_applies_busy_and_size_limits(texture, busy, limit, expected):
|
||||
context = texture_context(texture)
|
||||
with patch.object(runtime, "server_busy", return_value=busy), patch.object(
|
||||
upscale, "configured_max_ai_input_size", return_value=limit,
|
||||
):
|
||||
assert upscale.UpscaleImage.poll(context) == expected
|
||||
assert remove_background.RemoveImageBackground.poll(context) == (not busy)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation,prefix", [(upscale.UpscaleImage, "upscale."), (remove_background.RunBackgroundRemoval, "image.")])
|
||||
@pytest.mark.parametrize("shared", [False, True])
|
||||
def test_texture_action_undo_redo_restores_image_and_binding(texture, tmp_path, operation, prefix, shared):
|
||||
source_name = texture.image.name
|
||||
source_pixels = images.image_pixels(texture.image)
|
||||
if shared:
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
other.image = texture.image
|
||||
other.name = "Other texture"
|
||||
write_result(tmp_path, prefix)
|
||||
|
||||
def execute(operator, context):
|
||||
node = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"]
|
||||
operator._image_target = ImageEditTarget.capture(texture_context(node))
|
||||
if operation == upscale.UpscaleImage:
|
||||
operator.model = "PISA_SR"
|
||||
operator.response(context, SimpleNamespace(directory=tmp_path))
|
||||
return {"FINISHED"}
|
||||
|
||||
bpy.context.preferences.edit.use_global_undo = True
|
||||
with patch.object(operation, "execute", execute), patch.object(
|
||||
operation, "poll", classmethod(lambda cls, context: True), create=True,
|
||||
):
|
||||
bpy.utils.register_class(operation)
|
||||
try:
|
||||
bpy.ops.ed.undo_push(message="Before texture processing")
|
||||
call = getattr(bpy.ops.anyimage, operation.bl_idname.split(".")[1])
|
||||
assert call("EXEC_DEFAULT", True) == {"FINISHED"}
|
||||
result_pixels = images.image_pixels(texture.image)
|
||||
assert tuple(texture.image.size) == (8, 6)
|
||||
assert bpy.ops.ed.undo() == {"FINISHED"}
|
||||
restored = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"]
|
||||
assert restored.image.name == source_name
|
||||
assert tuple(restored.image.size) == (4, 3)
|
||||
np.testing.assert_array_equal(images.image_pixels(restored.image), source_pixels)
|
||||
assert bpy.ops.ed.redo() == {"FINISHED"}
|
||||
redone = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"]
|
||||
assert tuple(redone.image.size) == (8, 6)
|
||||
np.testing.assert_array_equal(images.image_pixels(redone.image), result_pixels)
|
||||
if shared:
|
||||
other = redone.id_data.nodes["Other texture"]
|
||||
np.testing.assert_array_equal(images.image_pixels(other.image), source_pixels)
|
||||
assert other.image != redone.image
|
||||
finally:
|
||||
bpy.utils.unregister_class(operation)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation,module,prefix", [
|
||||
(upscale.UpscaleImage, upscale, "upscale."),
|
||||
(remove_background.RunBackgroundRemoval, remove_background, "image."),
|
||||
])
|
||||
@pytest.mark.parametrize("outcome", ["succeeded", "cancelled", "failed", "invalid_target"])
|
||||
def test_texture_job_request_and_completion(texture, tmp_path, operation, module, prefix, outcome):
|
||||
context = texture_context(texture)
|
||||
context.window = object()
|
||||
context.window_manager = SimpleNamespace(
|
||||
progress_begin=Mock(), progress_end=Mock(), event_timer_add=Mock(return_value=object()),
|
||||
event_timer_remove=Mock(), modal_handler_add=Mock(),
|
||||
)
|
||||
write_result(tmp_path, prefix)
|
||||
controller = SimpleNamespace(
|
||||
submit=Mock(return_value={"job_id": "texture-job", "directory": str(tmp_path)}),
|
||||
status=Mock(return_value={"state": "failed" if outcome == "invalid_target" else outcome}),
|
||||
mark_job_complete=Mock(), cancel=Mock(),
|
||||
)
|
||||
original_pixels = images.image_pixels(texture.image)
|
||||
original_execute = operation.execute
|
||||
|
||||
def execute(operator, _context):
|
||||
with ExitStack() as stack:
|
||||
for name in ("require_environment", "require_model"):
|
||||
stack.enter_context(patch.object(module, name))
|
||||
stack.enter_context(patch.object(module, "production_device", return_value="CPU"))
|
||||
stack.enter_context(patch.object(operation, "controller", return_value=controller))
|
||||
stack.enter_context(patch.object(runtime, "update_ui"))
|
||||
stack.enter_context(patch.object(runtime, "redraw_ui"))
|
||||
if operation == upscale.UpscaleImage:
|
||||
stack.enter_context(patch.object(upscale, "configured_upscale_model", return_value="SAFMN_REAL_X4"))
|
||||
stack.enter_context(patch.object(upscale, "configured_max_ai_input_size", return_value=2048))
|
||||
assert original_execute(operator, context) == {"RUNNING_MODAL"}
|
||||
job = runtime.active_job
|
||||
job.start_thread.join(timeout=5)
|
||||
assert not job.start_thread.is_alive()
|
||||
kind, parameters = controller.submit.call_args.args
|
||||
assert kind == operation.job_type
|
||||
assert parameters["start_frame"] == 1
|
||||
assert parameters["device"] == "cpu"
|
||||
assert parameters["model"] == ("SAFMN_REAL_X4" if operation == upscale.UpscaleImage else "BEN2_BASE")
|
||||
input_path = operator.input_path
|
||||
assert operator.delete_input
|
||||
# Selection changes must not redirect completion.
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
texture.id_data.nodes.active = other
|
||||
if outcome == "invalid_target":
|
||||
texture.image = None
|
||||
controller.status.return_value = {"state": "succeeded"}
|
||||
bpy.ops.ed.undo_push(message="Before asynchronous texture result")
|
||||
result = operator.modal(context, SimpleNamespace(type="TIMER"))
|
||||
assert result == ({"FINISHED"} if outcome == "succeeded" else {"CANCELLED"})
|
||||
assert runtime.active_job is None
|
||||
assert operator._image_target is None
|
||||
assert not Path(input_path).exists()
|
||||
assert other.image is None
|
||||
if outcome == "succeeded":
|
||||
assert tuple(texture.image.size) == (8, 6)
|
||||
elif outcome != "invalid_target":
|
||||
np.testing.assert_array_equal(images.image_pixels(texture.image), original_pixels)
|
||||
context.window_manager.event_timer_remove.assert_called_once()
|
||||
return result
|
||||
|
||||
with patch.object(operation, "execute", execute), patch.object(
|
||||
operation, "poll", classmethod(lambda cls, context: True), create=True,
|
||||
):
|
||||
bpy.utils.register_class(operation)
|
||||
try:
|
||||
call = getattr(bpy.ops.anyimage, operation.bl_idname.split(".")[1])
|
||||
if outcome in {"failed", "invalid_target"}:
|
||||
message = "Server task failed" if outcome == "failed" else "no longer available"
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
call("EXEC_DEFAULT", True)
|
||||
else:
|
||||
assert call("EXEC_DEFAULT", True) == ({"FINISHED"} if outcome == "succeeded" else {"CANCELLED"})
|
||||
finally:
|
||||
runtime.close_active()
|
||||
bpy.utils.unregister_class(operation)
|
||||
@@ -6,6 +6,7 @@ from PIL import Image
|
||||
|
||||
from anyimage.operators.upscale import UpscaleImage
|
||||
from anyimage.common.image import image_pixels
|
||||
from anyimage.common.image_target import ImageEditTarget
|
||||
|
||||
|
||||
def test_upscale_result_replacement_undo_restores_source(tmp_path):
|
||||
@@ -21,8 +22,7 @@ def test_upscale_result_replacement_undo_restores_source(tmp_path):
|
||||
Image.new("RGBA", (16,16), (32,64,96,128)).save(tmp_path / "upscale.0001.png")
|
||||
|
||||
def execute(operator, context):
|
||||
operator.source_object_name = owner_name
|
||||
operator.source_image_name = source_name
|
||||
operator._image_target = ImageEditTarget.capture(SimpleNamespace(object=owner))
|
||||
operator.model = "PISA_SR"
|
||||
operator.response(context, SimpleNamespace(directory=tmp_path))
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -290,8 +290,10 @@ class AddonRegistrationTest(BlenderTestCase):
|
||||
)
|
||||
existing_context_draw = object()
|
||||
existing_outliner_draw = object()
|
||||
existing_node_draw = object()
|
||||
self.context_menu_draws.append(existing_context_draw)
|
||||
self.outliner_menu_draws.append(existing_outliner_draw)
|
||||
self.node_menu_draws.append(existing_node_draw)
|
||||
|
||||
self.assertFalse(any("debug" in cls.bl_idname.lower() for cls in self.anyimage.CLASSES if hasattr(cls, "bl_idname")))
|
||||
settings = self.anyimage.properties.AnyImageSettings.__annotations__
|
||||
@@ -346,6 +348,9 @@ class AddonRegistrationTest(BlenderTestCase):
|
||||
)
|
||||
self.assertFalse(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source"))
|
||||
|
||||
self.assertEqual(self.node_menu_draws, [self.anyimage.draw_texture_node_context_menu, existing_node_draw])
|
||||
self.assertIn(self.anyimage.AnyImageTextureNodeMenu, self.registered)
|
||||
|
||||
unregister_order = []
|
||||
original_unregister = self.fake_bpy.utils.unregister_class
|
||||
def unregister(cls):
|
||||
@@ -362,4 +367,9 @@ class AddonRegistrationTest(BlenderTestCase):
|
||||
self.assertEqual(self.status_bar_draws, [])
|
||||
self.assertEqual(self.context_menu_draws, [existing_context_draw])
|
||||
self.assertEqual(self.outliner_menu_draws, [existing_outliner_draw])
|
||||
self.assertEqual(self.node_menu_draws, [existing_node_draw])
|
||||
self.anyimage.register()
|
||||
self.assertEqual(self.node_menu_draws, [self.anyimage.draw_texture_node_context_menu, existing_node_draw])
|
||||
self.anyimage.unregister()
|
||||
self.assertEqual(self.node_menu_draws, [existing_node_draw])
|
||||
self.assertFalse(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source"))
|
||||
|
||||
@@ -96,12 +96,12 @@ class ImageMenuTest(BlenderTestCase):
|
||||
|
||||
def test_image_operators_stay_clickable_until_server_is_busy(self):
|
||||
context = SimpleNamespace(
|
||||
object=SimpleNamespace(data=SimpleNamespace()),
|
||||
object=SimpleNamespace(type="EMPTY", empty_display_type="IMAGE", data=SimpleNamespace()),
|
||||
)
|
||||
operators = (
|
||||
(self.convert_to_plane, self.convert_to_plane.ConvertToDepthPlane),
|
||||
(self.convert_to_plane, self.convert_to_plane.ConvertToReliefPlane),
|
||||
(self.remove_background, self.remove_background.RemoveImageBackground),
|
||||
self.convert_to_plane.ConvertToDepthPlane,
|
||||
self.convert_to_plane.ConvertToReliefPlane,
|
||||
self.remove_background.RemoveImageBackground,
|
||||
)
|
||||
|
||||
for busy, expected in ((False, True), (True, False)):
|
||||
@@ -113,18 +113,6 @@ class ImageMenuTest(BlenderTestCase):
|
||||
return_value=busy,
|
||||
),
|
||||
):
|
||||
for module, operator in operators:
|
||||
with (
|
||||
self.subTest(operator=operator.__name__),
|
||||
patch.object(
|
||||
module,
|
||||
"is_image_empty",
|
||||
return_value=True,
|
||||
),
|
||||
patch.object(
|
||||
module,
|
||||
"video_total_frames",
|
||||
return_value=1,
|
||||
),
|
||||
):
|
||||
for operator in operators:
|
||||
with self.subTest(operator=operator.__name__):
|
||||
self.assertEqual(operator.poll(context), expected)
|
||||
|
||||
@@ -439,6 +439,7 @@ class PreferencesTest(BlenderTestCase):
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=source_image,
|
||||
image_user=None,
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
object=source_object,
|
||||
@@ -457,8 +458,8 @@ class PreferencesTest(BlenderTestCase):
|
||||
return_value=1024,
|
||||
),
|
||||
patch.object(
|
||||
self.upscale,
|
||||
"prepare_image_input",
|
||||
self.upscale.ImageEditTarget,
|
||||
"prepare",
|
||||
return_value=(Path("input.png"), False),
|
||||
),
|
||||
patch.object(
|
||||
|
||||
@@ -78,12 +78,17 @@ class BlenderTestCase(unittest.TestCase):
|
||||
prepend=lambda draw: cls.outliner_menu_draws.insert(0, draw),
|
||||
remove=lambda draw: cls.outliner_menu_draws.remove(draw),
|
||||
),
|
||||
NODE_MT_context_menu=SimpleNamespace(
|
||||
prepend=lambda draw: cls.node_menu_draws.insert(0, draw),
|
||||
remove=lambda draw: cls.node_menu_draws.remove(draw),
|
||||
),
|
||||
)
|
||||
cls.registered = []
|
||||
cls.registered_tools = []
|
||||
cls.status_bar_draws = []
|
||||
cls.context_menu_draws = []
|
||||
cls.outliner_menu_draws = []
|
||||
cls.node_menu_draws = []
|
||||
fake_bpy.utils = SimpleNamespace(
|
||||
register_class=cls.registered.append,
|
||||
unregister_class=cls.registered.remove,
|
||||
@@ -177,6 +182,7 @@ class BlenderTestCase(unittest.TestCase):
|
||||
self.status_bar_draws.clear()
|
||||
self.context_menu_draws.clear()
|
||||
self.outliner_menu_draws.clear()
|
||||
self.node_menu_draws.clear()
|
||||
self.fake_bpy.data = SimpleNamespace(
|
||||
objects=SimpleNamespace(get=lambda _name: None),
|
||||
)
|
||||
|
||||
45
tests/support/image_texture.py
Normal file
45
tests/support/image_texture.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def texture():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
material = bpy.data.materials.new("Texture material")
|
||||
material.use_nodes = True
|
||||
material.use_fake_user = True
|
||||
tree = material.node_tree
|
||||
node = tree.nodes.new("ShaderNodeTexImage")
|
||||
node.name = "Source texture"
|
||||
node.image = bpy.data.images.new("Texture.png", width=4, height=3, alpha=True)
|
||||
node.image.pixels.foreach_set(np.tile([0.2, 0.4, 0.6, 0.75], 12).astype(np.float32))
|
||||
node.image.pack()
|
||||
node.location = (-400, 100)
|
||||
node.interpolation = "Closest"
|
||||
node.extension = "CLIP"
|
||||
node.projection = "BOX"
|
||||
tree.nodes.active = node
|
||||
tree.links.new(node.outputs["Color"], tree.nodes.get("Principled BSDF").inputs["Base Color"])
|
||||
yield node
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
|
||||
def texture_context(node, **space_options):
|
||||
return SimpleNamespace(
|
||||
space_data=SimpleNamespace(
|
||||
**dict(type="NODE_EDITOR", tree_type="ShaderNodeTree", shader_type="OBJECT",
|
||||
edit_tree=node.id_data, id=bpy.data.materials["Texture material"], **space_options),
|
||||
),
|
||||
object=None,
|
||||
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=16)),
|
||||
)
|
||||
|
||||
|
||||
def write_result(directory, prefix="image.", count=1):
|
||||
for index in range(1, count + 1):
|
||||
Image.new("RGBA", (8, 6), (128, 64, 32, 80)).save(directory / f"{prefix}{index:04d}.png")
|
||||
|
||||
Reference in New Issue
Block a user