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"