- Implemented a preview tool for rectifying images with interactive aspect controls in `preview.py`. - Added tests for depth metadata loading to ensure consistent schema usage in `test_depth_metadata.py`. - Developed comprehensive tests for cutout calibration processes in `test_cutout_calibration.py`, including stability checks and depth calibration. - Created tests for frame compositing operations in `test_frame_compositing.py`, ensuring correct handling of depth and transparency. - Introduced material slot inheritance functionality in `material_slots.py` to manage geometry material assignments. - Built shared shader nodes for material processing in `shader.py`, including roughness and bump mapping setups.
207 lines
9.0 KiB
Python
207 lines
9.0 KiB
Python
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 / "foreground.png")
|
|
assert tuple(texture.image.size) == (8, 6)
|
|
assert other.image is None
|
|
|
|
|
|
@pytest.mark.parametrize("color_space,alpha_mode,filename,shared", [
|
|
("sRGB", "PREMUL", "foreground.png", True),
|
|
("AgX Base sRGB", "STRAIGHT", "upscale.png", False),
|
|
("Filmic sRGB", "STRAIGHT", "foreground.png", False),
|
|
("sRGB", "STRAIGHT", "upscale.png", True),
|
|
("AgX Base sRGB", "PREMUL", "foreground.png", True),
|
|
])
|
|
def test_image_actions_preserve_source_color_space_and_alpha(texture, tmp_path, color_space, alpha_mode, filename, 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, filename)
|
|
target.apply(tmp_path / filename)
|
|
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", "identity"])
|
|
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"])
|
|
elif change == "identity":
|
|
# A reused RNA address must not make a replacement node the original target.
|
|
texture["anyimage_identity"] = "replacement"
|
|
else:
|
|
bpy.data.images.remove(texture.image)
|
|
count = len(bpy.data.images)
|
|
with pytest.raises(RuntimeError, match="no longer available"):
|
|
target.apply(tmp_path / "foreground.png")
|
|
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 / "foreground.png")
|
|
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("source", ["MOVIE", "SEQUENCE"])
|
|
def test_target_rejects_animated_image(texture, source):
|
|
texture.image.source = source
|
|
with pytest.raises(RuntimeError, match="Only still images"):
|
|
targets.ImageEditTarget.capture(texture_context(texture))
|
|
|
|
|
|
@pytest.mark.parametrize("shared", [False, True])
|
|
def test_failed_texture_assignment_restores_source(texture, tmp_path, shared):
|
|
original = texture.image
|
|
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)
|
|
result = images.load_image_edit_result(original, write_result(tmp_path))
|
|
count = len(bpy.data.images) - 1
|
|
|
|
class FailingTarget:
|
|
failed = False
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(texture, name)
|
|
|
|
@property
|
|
def image(self):
|
|
return texture.image
|
|
|
|
@image.setter
|
|
def image(self, value):
|
|
texture.image = value
|
|
if not self.failed:
|
|
self.failed = True
|
|
raise RuntimeError("Assignment failed")
|
|
|
|
with patch.object(targets, "has_other_image_user", return_value=shared):
|
|
with pytest.raises(RuntimeError, match="Assignment failed"):
|
|
targets.replace_texture_image(FailingTarget(), 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 len(bpy.data.images) == count
|