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

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