Files
AnyImage/tests/test_empty_image_edits.py
nan 4c902ed7db feat(node_assets): add image plane geometry nodes and validation scripts
- Implemented `image_plane.py` to build the Image Plane Geometry Nodes group with customizable parameters for geometry, subdivide, and thickness.
- Created `image_plane_validation.py` to validate the functionality of the image plane, ensuring correct geometry generation and material assignment.
- Developed `material_layers.py` to construct shader node groups for image materials, including depth and normal handling.
- Authored `plane_layouts.py` to define node arrangements for image geometry assets.
- Introduced `shadeless.py` to create a shadeless shader node group that displays color without scene lighting while preserving shadows and reflections.
2026-09-08 23:24:27 +08:00

254 lines
10 KiB
Python

import sys
from pathlib import Path
from unittest.mock import patch
import bpy
import numpy as np
import pytest
from PIL import Image
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
from anyimage.common import image as images
@pytest.fixture
def source():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Source.png", width=2, height=2, alpha=True)
image.pixels.foreach_set(np.tile([0.2, 0.4, 0.6, 0.25], 4).astype(np.float32))
image.pack()
obj = create_empty("Source", image)
yield obj
bpy.ops.wm.read_factory_settings(use_empty=True)
def create_empty(name, image):
obj = bpy.data.objects.new(name, None)
obj.empty_display_type = "IMAGE"
obj.data = image
bpy.context.collection.objects.link(obj)
return obj
def edit_result(source):
return images.create_image_edit_result(
source.data, np.tile([0.8, 0.3, 0.1, 0.0], 12).astype(np.float32), (4, 3),
)
@pytest.mark.parametrize("sharing", ["visible", "hidden", "other_scene"])
def test_shared_edit_preserves_other_empty(source, sharing):
original = source.data
packed = bytes(original.packed_file.data)
rgba = images.image_rgba(original)
result = edit_result(source)
# Create the second user after preparing the result, as during an async Job.
other = create_empty("Other", original)
other.hide_viewport = sharing == "hidden"
if sharing == "other_scene":
bpy.context.collection.objects.unlink(other)
scene = bpy.data.scenes.new("Other scene")
scene.collection.objects.link(other)
other.empty_display_size = 7
other.image_user.frame_start = 9
assert images.has_other_image_empty(source)
assert images.replace_empty_image(source, result) == result
assert source.data == result
assert result.name == "Source.png.001"
assert other.data == original
assert other.empty_display_size == 7
assert other.image_user.frame_start == 9
assert original.name == "Source.png"
assert bytes(original.packed_file.data) == packed
np.testing.assert_array_equal(images.image_rgba(original), rgba)
@pytest.mark.parametrize("reference", ["none", "fake_user", "material", "collections"])
def test_sole_edit_preserves_id_and_releases_result(source, reference, tmp_path):
original = source.data
pointer = original.as_pointer()
original.use_fake_user = reference == "fake_user"
if reference == "material":
material = bpy.data.materials.new("Manual reference")
material.use_nodes = True
node = material.node_tree.nodes.new("ShaderNodeTexImage")
node.image = original
if reference == "collections":
collection = bpy.data.collections.new("Another collection")
bpy.context.scene.collection.children.link(collection)
collection.objects.link(source)
count = len(bpy.data.images)
result = edit_result(source)
expected = images.image_rgba(result)
assert not images.has_other_image_empty(source)
final = images.replace_empty_image(source, result)
assert final.as_pointer() == pointer == source.data.as_pointer()
assert final.name == "Source.png"
assert final.use_fake_user == (reference == "fake_user")
assert len(bpy.data.images) == count
if reference == "material":
assert node.image == final
np.testing.assert_allclose(images.image_rgba(final), expected, atol=1 / 255)
path = str(tmp_path / "edited.blend")
bpy.ops.wm.save_as_mainfile(filepath=path)
bpy.ops.wm.open_mainfile(filepath=path)
reopened = bpy.data.objects["Source"].data
assert reopened.name == "Source.png"
np.testing.assert_allclose(images.image_rgba(reopened), expected, atol=1 / 255)
@pytest.mark.parametrize("outside", [False, True])
def test_frame_sharing_uses_surviving_objects(source, outside):
original = source.data
merged = create_empty("Merged", original)
if outside:
other = create_empty("Outside", original)
assert images.has_other_image_empty(source, removed_objects=(merged,)) == outside
result = edit_result(source)
final = images.replace_empty_image(source, result, removed_objects=(merged,))
bpy.data.objects.remove(merged, do_unlink=True)
assert (final == original) == (not outside)
if outside:
assert other.data == original
assert tuple(original.size) == (2, 2)
@pytest.mark.parametrize("shared", [False, True])
def test_failed_display_configuration_restores_content(source, tmp_path, shared):
original = source.data
if shared:
create_empty("Other", original)
# Preserve unsaved pixels as well as the previous packed representation.
original.pixels.foreach_set(np.tile([0.1, 0.9, 0.2, 0.5], 4).astype(np.float32))
packed = bytes(original.packed_file.data)
pixels = images.image_pixels(original)
alpha = original.alpha_mode
source.image_user.frame_start = 5
source.image_user.use_auto_refresh = False
for index in (1, 2):
Image.new("RGBA", (4, 3), (100, 150, 200, 255)).save(tmp_path / f"image.{index:04d}.png")
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 = 99
user.use_auto_refresh = True
raise RuntimeError("Display failed")
with patch.object(images, "configure_image_user", side_effect=fail_configuration):
with pytest.raises(RuntimeError, match="Display failed"):
images.replace_empty_image(source, result)
assert source.data == original
assert tuple(original.size) == (2, 2)
assert original.alpha_mode == alpha
assert original.is_dirty
assert bytes(original.packed_file.data) == packed
np.testing.assert_array_equal(images.image_pixels(original), pixels)
assert source.image_user.frame_start == 5
assert not source.image_user.use_auto_refresh
assert not source.use_empty_image_alpha
assert not original.use_fake_user
assert len(bpy.data.images) == count
def test_failed_content_write_restores_source(source):
original = source.data
packed = bytes(original.packed_file.data)
result = edit_result(source)
restore = images.restore_image_content
calls = 0
def fail_once(image, state):
nonlocal calls
calls += 1
restore(image, state)
if calls == 1:
raise RuntimeError("Write failed")
with patch.object(images, "restore_image_content", side_effect=fail_once):
with pytest.raises(RuntimeError, match="Write failed"):
images.replace_empty_image(source, result)
assert source.data == original
assert tuple(original.size) == (2, 2)
assert bytes(original.packed_file.data) == packed
assert len(bpy.data.images) == 1
def test_animated_result_preserves_id_and_reopens(source, tmp_path):
original = source.data
source.image_user.frame_start = 4
source.image_user.use_cyclic = True
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)
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)
assert final == original
assert final.source == "SEQUENCE"
assert final.filepath_raw == expected_path
assert (bytes(final.packed_file.data) if final.packed_file else None) == expected_packed
assert len(images.collect_sequence_frames(final)) == 2
assert source.image_user.frame_start == 4
assert source.image_user.frame_duration == 2
assert source.image_user.use_cyclic
assert source.image_user.use_auto_refresh
path = str(tmp_path / "animation.blend")
bpy.ops.wm.save_as_mainfile(filepath=path)
bpy.ops.wm.open_mainfile(filepath=path)
reopened = bpy.data.objects["Source"].data
assert reopened.name == "Source.png"
assert reopened.source == "SEQUENCE"
frames = images.collect_sequence_frames(reopened)
assert len(frames) == 2
for index, frame in enumerate(frames, 1):
loaded = bpy.data.images.load(str(frame), check_existing=False)
try:
assert tuple(loaded.size) == (4, 3)
np.testing.assert_allclose(
images.image_pixels(loaded)[:4],
np.array([index * 80, 100, 50, 255]) / 255,
atol=1 / 255,
)
finally:
bpy.data.images.remove(loaded)
@pytest.mark.parametrize("floating", [False, True])
def test_failed_generated_commit_restores_unsaved_pixels(source, floating):
original = bpy.data.images.new("Generated", width=3, height=2, alpha=True, float_buffer=floating)
source.data = original
original.pixels.foreach_set(np.tile([0.12, 0.24, 0.48, 0.5], 6).astype(np.float32))
expected = images.image_pixels(original)
result = edit_result(source)
restore = images.restore_image_content
calls = 0
def fail_once(image, state):
nonlocal calls
calls += 1
restore(image, state)
if calls == 1:
raise RuntimeError("Write failed")
with patch.object(images, "restore_image_content", side_effect=fail_once):
with pytest.raises(RuntimeError, match="Write failed"):
images.replace_empty_image(source, result)
assert original.source == "GENERATED"
assert original.is_float == floating
assert original.packed_file is None
assert tuple(original.size) == (3, 2)
np.testing.assert_array_equal(images.image_pixels(original), expected)
def test_failed_preparation_releases_result(source):
result = edit_result(source)
count = len(bpy.data.images) - 1
with patch.object(images, "empty_display_alignment", side_effect=ValueError("Invalid bounds")):
with pytest.raises(ValueError, match="Invalid bounds"):
images.replace_empty_image(source, result, placement_bounds=(0, 0, 1, 1))
assert len(bpy.data.images) == count
assert tuple(source.data.size) == (2, 2)