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:
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user