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

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