- 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.
227 lines
11 KiB
Python
227 lines
11 KiB
Python
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 / "foreground.png")
|
||
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,filename", [(upscale.UpscaleImage, "upscale.png"), (remove_background.RunBackgroundRemoval, "foreground.png")])
|
||
@pytest.mark.parametrize("shared", [False, True])
|
||
def test_texture_action_undo_redo_restores_image_and_binding(texture, tmp_path, operation, filename, 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, filename)
|
||
|
||
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 = "HAT_GAN_X4_SHARPER"
|
||
operator.response(context, SimpleNamespace(file=lambda key: tmp_path / f"{key}.png"))
|
||
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,filename", [
|
||
(upscale.UpscaleImage, upscale, "upscale.png"),
|
||
(remove_background.RunBackgroundRemoval, remove_background, "foreground.png"),
|
||
])
|
||
@pytest.mark.parametrize("outcome", ["succeeded", "cancelled", "failed", "invalid_target"])
|
||
def test_texture_job_request_and_completion(texture, tmp_path, operation, module, filename, 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, filename)
|
||
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,
|
||
"result": {Path(filename).stem: filename}}),
|
||
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="HAT_GAN_X4_SHARPER"))
|
||
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 "start_frame" not in parameters and "video_frames" not in parameters
|
||
assert parameters["device"] == "cpu"
|
||
assert parameters["model"] == ("HAT_GAN_X4_SHARPER" if operation == upscale.UpscaleImage else "BIREFNET_LITE")
|
||
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", "result": {Path(filename).stem: filename}}
|
||
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)
|