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:
2026-09-10 00:00:13 +08:00
parent f48bbb3d05
commit 1622afa7a2
28 changed files with 1068 additions and 236 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-09

View 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 使用。

View 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 协议,依赖和节点资产保持现状。

View File

@@ -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** 撤销恢复原图、尺寸、绑定和播放设置,重做恢复处理结果且无需再次推理

View 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` 全量测试并确认正常退出,检查最终差异与旧引用;保留既有未提交修改,不构建文档或产物。

View File

@@ -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):

View File

@@ -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),
)

View 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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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),
)

View 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

View File

@@ -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"

View File

@@ -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")

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View 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)

View File

@@ -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"}

View File

@@ -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"))

View File

@@ -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)

View File

@@ -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(

View File

@@ -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),
)

View 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")