Add new tests and functionality for image processing and geometry handling

- Introduced color_image.py for preparing standalone color images for tests.
- Added hdr_image.py to create HDR textures for testing.
- Implemented test_image_depth_panorama.py to evaluate panorama topology, UVs, and depth discontinuities.
- Created test_shadeless.py to validate the opacity and HDR color endpoints of the Shadeless group.
- Developed moge3_export.py for exporting dynamic MoGe-3 backbone and ONNX graphs.
- Added material.py to inherit material slots onto generated geometry.
- Built image_depth_panorama.py to construct a quad panorama with depth separation and sampling.
This commit is contained in:
2026-09-10 14:42:17 +08:00
parent 27ca25224a
commit 489003c2cc
136 changed files with 5758 additions and 1083 deletions

View File

@@ -7,41 +7,39 @@ from PIL import Image
from anyimage.common import image as images
from anyimage.operators.convert_to_plane.object import create_color_image
from anyimage.operators.cutout_tool.object import create_cropped_color_image
from tests.support.color_image import color_image
@pytest.mark.parametrize("floating", [False, True])
def test_owned_color_images_preserve_source_and_use_premul(floating):
original = bpy.data.images.new("Alpha source", width=2, height=2, alpha=True, float_buffer=floating)
if floating:
original.alpha_mode = "PREMUL"
rgba = np.tile(np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32), (2, 2, 1))
original.pixels.foreach_set(rgba.ravel())
source = SimpleNamespace(data=original)
expected = images.image_rgba(original)
copied = create_color_image(source)
cropped = create_cropped_color_image(source, (0, 0, 2, 2), expected)
cropped = color_image(source.data, (0, 0, 2, 2), expected)
edited = images.create_image_edit_result(original, rgba.ravel(), (2, 2))
try:
assert original.alpha_mode == "STRAIGHT"
assert original.alpha_mode == ("PREMUL" if floating else "STRAIGHT")
np.testing.assert_allclose(images.image_rgba(original), expected)
assert edited.alpha_mode == original.alpha_mode
for image in (copied, cropped):
assert image.alpha_mode == "PREMUL"
for image in (cropped,):
assert image.alpha_mode == original.alpha_mode
np.testing.assert_allclose(images.image_rgba(image), expected, atol=1 / 255)
finally:
for image in (copied, cropped, edited, original):
for image in (cropped, edited, original):
bpy.data.images.remove(image)
@pytest.mark.parametrize("loader", [images.load_image_edit_result, images.load_color_result_image])
def test_job_loader_uses_edit_or_material_alpha_policy(tmp_path, loader):
def test_job_loader_preserves_edit_alpha_policy(tmp_path):
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)
source = original if loader == images.load_image_edit_result else SimpleNamespace(data=original)
result = loader(source, tmp_path)
result = images.load_image_edit_result(original, tmp_path)
try:
assert result.alpha_mode == ("STRAIGHT" if loader == images.load_image_edit_result else "PREMUL")
assert result.alpha_mode == "STRAIGHT"
assert result.packed_file is not None
assert original.alpha_mode == "STRAIGHT"
finally:

View File

@@ -0,0 +1,233 @@
"""Verify isolated static color artifacts and source fidelity."""
from pathlib import Path
from types import SimpleNamespace
import bpy
import numpy as np
import pytest
from PIL import Image
from anyimage.common import color_image as colors, image as images, material
@pytest.fixture
def source():
bpy.ops.wm.read_factory_settings(use_empty=True)
source = bpy.data.images.new("Source.png", width=4, height=2, alpha=True)
values = np.arange(32, dtype=np.float32).reshape(2, 4, 4) / 32
values[0, 0, 3] = 0
source.pixels.foreach_set(values.ravel())
yield source
bpy.ops.wm.read_factory_settings(use_empty=True)
@pytest.mark.parametrize("floating,alpha", [(False, "STRAIGHT"), (False, "PREMUL"), (True, "STRAIGHT"), (True, "PREMUL"), (True, "CHANNEL_PACKED")])
@pytest.mark.parametrize("bounds", [None, (1, 0, 3, 2)])
def test_color_artifact_preserves_bounds_and_library_reload(source, tmp_path, monkeypatch, floating, alpha, bounds):
if floating:
source = bpy.data.images.new("Source.exr", width=4, height=2, alpha=True, float_buffer=True)
source.pixels.foreach_set(np.tile(np.array([4, 2, .5, .25, 8, 3, 1, 0], dtype=np.float32), 4))
source.alpha_mode = alpha
source["anyimage_clipboard_sha256"] = "original"
before = images.image_rgba(source)
state = (source.filepath_raw, source.colorspace_settings.name, source.alpha_mode)
path = colors.prepare_material_color_input(source, bounds)
color = colors.load_material_color_image(path)
try:
assert color != source and color.name.endswith("_color" + path.suffix)
assert "anyimage_clipboard_sha256" not in color
assert Path(color.filepath_raw) == path
expected = before if bounds is None else before[bounds[1]:bounds[3], bounds[0]:bounds[2]]
np.testing.assert_allclose(images.image_rgba(color), expected, atol=1e-6 if floating else 1/255)
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: True)
result = material.create_image_material(source, color)
assert next(n.image for n in result.node_tree.nodes if n.type == "TEX_IMAGE") == color
colors.cleanup_material_color_input(path)
assert not path.parent.exists()
library = tmp_path / "colors.blend"
bpy.data.libraries.write(str(library), {color})
with bpy.data.libraries.load(str(library)) as (saved, loaded):
loaded.images = saved.images
np.testing.assert_allclose(images.image_rgba(loaded.images[0]), expected, atol=1e-6 if floating else 1/255)
if floating:
assert color.colorspace_settings.name == "Linear Rec.709"
assert color.alpha_mode == ("PREMUL" if alpha == "STRAIGHT" else alpha)
layer = next(n for n in result.node_tree.nodes if n.type == "GROUP")
assert layer.inputs["Alpha Fix"].default_value == 0
assert (source.filepath_raw, source.colorspace_settings.name, source.alpha_mode) == state
np.testing.assert_array_equal(images.image_rgba(source), before)
finally:
colors.cleanup_material_color_input(path)
def test_original_path_never_matches_material_color(tmp_path, monkeypatch):
path = tmp_path / "original.png"
Image.new("RGBA", (2, 2), (100, 150, 200, 128)).save(path)
source = bpy.data.images.load(str(path), check_existing=False)
with colors.material_color_image(source) as color:
result = material.create_image_material(source, color)
color.pixels[0] = 1
bpy.data.images.remove(source)
imported = bpy.data.images.load(str(path), check_existing=True)
assert imported != color
assert imported.alpha_mode == "STRAIGHT"
assert images.image_pixels(imported)[0] == pytest.approx(100/255, abs=1/255)
bpy.data.materials.remove(result)
bpy.data.images.remove(imported)
@pytest.mark.parametrize("kind", ["MOVIE", "SEQUENCE"])
def test_static_input_rejects_single_frame_animation(kind):
with pytest.raises(ValueError, match="static images"):
colors.require_static_color_image(SimpleNamespace(source=kind, frame_duration=1))
@pytest.mark.parametrize("stage", ["export", "load", "material", "object"])
def test_failed_color_preparation_releases_owned_resources(source, tmp_path, monkeypatch, stage):
directory = tmp_path / "owned-color"
directory.mkdir()
monkeypatch.setattr(colors.tempfile, "mkdtemp", lambda **_: str(directory))
before = set(bpy.data.images)
original = images.image_pixels(source)
def fail(*_, **__):
raise RuntimeError("Color failure")
if stage == "export":
monkeypatch.setattr(colors, "_save_byte_color", fail)
elif stage == "load":
monkeypatch.setattr(colors, "load_material_color_image", fail)
elif stage == "material":
monkeypatch.setattr(material, "material_node_group", fail)
result = None
try:
with pytest.raises(RuntimeError, match="Color failure"):
with colors.material_color_image(source) as color:
result = material.create_image_material(source, color)
fail()
finally:
if result is not None:
bpy.data.materials.remove(result)
assert set(bpy.data.images) == before
assert not directory.exists()
np.testing.assert_array_equal(images.image_pixels(source), original)
def test_ai_color_uses_submitted_crop_after_source_changes(source):
from anyimage.operators.cutout_tool.operators import _generated_color_result
bounds = (1, 0, 3, 2)
expected = images.image_rgba(source)[:, 1:3].copy()
with colors.material_color_image(source, bounds=bounds) as direct:
path = colors.prepare_material_color_input(source, bounds)
try:
assert colors.material_analysis_input(path) == path
source.pixels.foreach_set(np.zeros(32, np.float32))
result, alpha, result_bounds = _generated_color_result(path, bounds)
try:
assert result_bounds == bounds and tuple(result.size) == (2, 2)
np.testing.assert_array_equal(images.image_rgba(result), images.image_rgba(direct))
np.testing.assert_allclose(images.image_rgba(result), expected, atol=1/255)
np.testing.assert_allclose(alpha, expected[..., 3], atol=1/255)
finally:
bpy.data.images.remove(result)
finally:
colors.cleanup_material_color_input(path)
@pytest.mark.parametrize("color_space", ["Linear Rec.709", "sRGB", "Non-Color"])
def test_float_straight_uses_exr_interpretation_without_rejecting_hidden_rgb(source, tmp_path, color_space):
source = bpy.data.images.new("Straight HDR", width=2, height=1, alpha=True, float_buffer=True)
source.colorspace_settings.name = color_space
rgba = np.array([4, 2, .5, .25, 8, 3, 1, 0], np.float32)
source.pixels.foreach_set(rgba)
with colors.material_color_image(source) as color:
assert color.alpha_mode == "PREMUL"
assert color.colorspace_settings.name == "Linear Rec.709"
library = tmp_path / "straight.blend"
bpy.data.libraries.write(str(library), {color})
with bpy.data.libraries.load(str(library)) as (saved, loaded):
loaded.images = saved.images
np.testing.assert_array_equal(loaded.images[0].pixels[:], rgba)
assert (source.colorspace_settings.name, source.alpha_mode) == (color_space, "STRAIGHT")
np.testing.assert_array_equal(source.pixels[:], rgba)
def test_hdr_analysis_preview_is_separate_from_full_precision_color(source):
source = bpy.data.images.new("HDR", width=2, height=1, alpha=True, float_buffer=True)
source.alpha_mode = "PREMUL"
source.pixels.foreach_set(np.array([4, 2, .5, .25, 8, 3, 1, 0], np.float32))
path = colors.prepare_material_color_input(source)
try:
preview = colors.material_analysis_input(path)
assert preview != path and preview.suffix == ".png" and path.suffix == ".exr"
with Image.open(preview) as image:
assert image.size == tuple(source.size)
color = colors.load_material_color_image(path)
try:
np.testing.assert_array_equal(images.image_rgba(color), images.image_rgba(source))
finally:
bpy.data.images.remove(color)
finally:
colors.cleanup_material_color_input(path)
assert not preview.exists()
@pytest.mark.parametrize("alpha_mode", ["STRAIGHT", "PREMUL", "CHANNEL_PACKED", "NONE"])
@pytest.mark.parametrize("source_kind", ["generated", "file", "dirty", "packed"])
def test_float_color_conversion_preserves_rendered_color_and_opacity(source, tmp_path, alpha_mode, source_kind):
from anyimage.common.hdr_image import save_hdr_result
rgba = np.array([[[8, 3, 1, 0], [4, 2, .5, .25], [2, 1, .25, .5], [1, .5, .125, 1]]], np.float32)
if source_kind != "generated":
path = tmp_path / "source.exr"
save_hdr_result(rgba, path)
source = bpy.data.images.load(str(path), check_existing=False)
else:
source = bpy.data.images.new("Float Render", width=4, height=1, alpha=True, float_buffer=True)
source.pixels.foreach_set(rgba.ravel())
source.alpha_mode = alpha_mode
if source_kind == "dirty":
source.pixels.foreach_set(rgba.ravel())
elif source_kind == "packed":
source.pack()
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.device = "CPU"
scene.cycles.samples = 16
scene.cycles.use_denoising = False
scene.render.film_transparent = True
scene.render.resolution_x, scene.render.resolution_y = 64, 16
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "OPEN_EXR"
scene.render.image_settings.color_mode = "RGBA"
scene.render.image_settings.color_depth = "32"
bpy.ops.object.camera_add(location=(0, 0, 5))
scene.camera = bpy.context.object
scene.camera.data.type = "ORTHO"
scene.camera.data.ortho_scale = 4
bpy.ops.mesh.primitive_plane_add(size=2)
bpy.context.object.scale = (2, .5, 1)
result = bpy.data.materials.new("Float Interpretation")
result.use_nodes = True
nodes, links = result.node_tree.nodes, result.node_tree.links
nodes.clear()
texture = nodes.new("ShaderNodeTexImage")
texture.interpolation = "Closest"
emission = nodes.new("ShaderNodeEmission")
transparent = nodes.new("ShaderNodeBsdfTransparent")
mix = nodes.new("ShaderNodeMixShader")
output = nodes.new("ShaderNodeOutputMaterial")
links.new(texture.outputs["Color"], emission.inputs["Color"])
links.new(texture.outputs["Alpha"], mix.inputs[0])
links.new(transparent.outputs[0], mix.inputs[1])
links.new(emission.outputs[0], mix.inputs[2])
links.new(mix.outputs[0], output.inputs["Surface"])
bpy.context.object.data.materials.append(result)
with colors.material_color_image(source) as color:
rendered = []
for name, image in (("before", source), ("after", color)):
texture.image = image
scene.render.filepath = str(tmp_path / (name + ".exr"))
bpy.ops.render.render(write_still=True)
image = bpy.data.images.load(scene.render.filepath, check_existing=False)
rendered.append(images.image_rgba(image))
np.testing.assert_allclose(rendered[1], rendered[0], rtol=2e-5, atol=1e-6)

View File

@@ -0,0 +1,189 @@
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import numpy as np
import pytest
from PIL import Image
from anyimage.common.hdr_image import HdrBackgroundInput, recognition_rgba, save_hdr_result
from anyimage.common.image import cleanup_image_input, image_content_state, image_rgba, restore_image_content
from anyimage.common.image_target import ImageEditTarget
from anyimage.common import image_target as targets
from tests.support.image_texture import texture, texture_context
from tests.support.hdr_image import hdr_texture
@pytest.mark.parametrize("source_kind", ["generated", "file", "packed", "dirty"])
@pytest.mark.parametrize("owner_kind", ["texture", "empty"])
@pytest.mark.parametrize("shared", [False, True])
def test_hdr_commit_keeps_float_pixels_and_transaction(hdr_texture, tmp_path, source_kind, owner_kind, shared):
texture = hdr_texture
source = texture.image
rgba = image_rgba(source).copy()
if source_kind != "generated":
path = tmp_path / "source.exr"
save_hdr_result(rgba, path)
source = bpy.data.images.load(str(path), check_existing=False)
texture.image = source
if source_kind == "packed":
source.pack()
if source_kind == "dirty":
rgba[1, 1, 0] = 19
source.pixels.foreach_set(np.flipud(rgba).ravel())
source.use_fake_user = True
if owner_kind == "empty":
owner = bpy.data.objects.new("HDR Empty", None)
owner.empty_display_type = "IMAGE"
owner.data = source
bpy.context.scene.collection.objects.link(owner)
texture.image = None
context = SimpleNamespace(object=owner, space_data=None)
else:
owner = texture
context = texture_context(texture)
if shared:
other = texture.id_data.nodes.new("ShaderNodeTexImage")
other.image = source
target = ImageEditTarget.capture(context)
snapshot = HdrBackgroundInput.prepare(source)
original_state = image_content_state(source)
alpha = np.linspace(0.03, 0.91, 12, dtype=np.float32).reshape(3, 4)
alpha_path = tmp_path / "alpha.npy"
np.save(alpha_path, alpha)
try:
with Image.open(snapshot.path) as preview:
assert preview.size == (4, 3)
assert np.asarray(preview)[0, 0, :3].max() == 0
result = snapshot.apply(target, alpha_path)
expected = rgba.copy()
expected[..., 3] *= alpha
assert result.is_float and result.packed_file and result.alpha_mode == "CHANNEL_PACKED"
np.testing.assert_allclose(image_rgba(result), expected, atol=1e-7)
assert (result == source) == (not shared)
if shared:
np.testing.assert_array_equal(image_rgba(other.image), rgba)
else:
assert result.use_fake_user
# The encoded result restores the same ID and floats after a source-state restore.
result_state = image_content_state(result)
restore_image_content(result, original_state)
np.testing.assert_allclose(image_rgba(result), rgba, atol=1e-7)
restore_image_content(result, result_state)
np.testing.assert_allclose(image_rgba(result), expected, atol=1e-7)
blend_path = tmp_path / "roundtrip.blend"
result_name = result.name
bpy.ops.wm.save_as_mainfile(filepath=str(blend_path))
bpy.ops.wm.open_mainfile(filepath=str(blend_path))
restored = bpy.data.images[result_name]
assert restored.is_float and restored.packed_file
np.testing.assert_allclose(image_rgba(restored), expected, atol=1e-7)
finally:
cleanup_image_input(snapshot.path, True)
@pytest.mark.parametrize("invalid", ["shape", "dtype", "nan", "range", "corrupt", "source", "colorspace", "binding"])
def test_hdr_rejects_invalid_result_and_source_changes(hdr_texture, tmp_path, invalid):
target = ImageEditTarget.capture(texture_context(hdr_texture))
snapshot = HdrBackgroundInput.prepare(target.image)
alpha = np.ones((3, 4), np.float32)
if invalid == "shape": alpha = alpha[:1]
if invalid == "dtype": alpha = alpha.astype(np.float64)
if invalid == "nan": alpha[0, 0] = np.nan
if invalid == "range": alpha[0, 0] = 2
path = tmp_path / "alpha.npy"
np.save(path, alpha)
if invalid == "corrupt": path.write_bytes(b"broken")
if invalid == "source": target.image.pixels[4] = 20
if invalid == "colorspace": target.image.colorspace_settings.name = "ACEScg"
if invalid == "binding": hdr_texture.image = bpy.data.images.new("Other", width=1, height=1)
before = image_rgba(hdr_texture.image).copy()
count = len(bpy.data.images)
try:
with pytest.raises(RuntimeError):
snapshot.apply(target, path)
np.testing.assert_array_equal(image_rgba(hdr_texture.image), before)
assert len(bpy.data.images) == count
finally:
cleanup_image_input(snapshot.path, True)
def test_hdr_preview_is_fixed_and_preserves_source(hdr_texture):
image = hdr_texture.image
before = image_rgba(image).copy()
first = HdrBackgroundInput.prepare(image)
bpy.context.scene.view_settings.view_transform = "Standard"
bpy.context.scene.view_settings.exposure = 7
second = HdrBackgroundInput.prepare(image)
try:
with Image.open(first.path) as a, Image.open(second.path) as b:
np.testing.assert_array_equal(np.asarray(a), np.asarray(b))
np.testing.assert_array_equal(image_rgba(image), before)
np.testing.assert_array_equal(recognition_rgba(np.zeros((1, 1, 4), np.float32)), [[[0, 0, 0, 1]]])
finally:
cleanup_image_input(first.path, True)
cleanup_image_input(second.path, True)
def test_hdr_rejects_animation_before_export(hdr_texture):
hdr_texture.image.source = "SEQUENCE"
with pytest.raises(RuntimeError, match="still images"):
HdrBackgroundInput.prepare(hdr_texture.image)
def test_hdr_export_failure_releases_blender_resources(hdr_texture, tmp_path):
images, scenes = len(bpy.data.images), len(bpy.data.scenes)
blocked = tmp_path / "file"
blocked.write_bytes(b"not a directory")
with pytest.raises(RuntimeError):
save_hdr_result(image_rgba(hdr_texture.image), blocked / "output.exr")
assert (len(bpy.data.images), len(bpy.data.scenes)) == (images, scenes)
def test_straight_exr_only_changes_alpha(hdr_texture, tmp_path):
path = tmp_path / "straight.exr"
encoded = image_rgba(hdr_texture.image).copy()
save_hdr_result(encoded, path)
source = bpy.data.images.load(str(path), check_existing=False)
source.alpha_mode = "STRAIGHT"
hdr_texture.image = source
before = image_rgba(source).copy()
np.testing.assert_allclose(before[..., :3], encoded[..., :3] * encoded[..., 3:4], atol=1e-7)
snapshot = HdrBackgroundInput.prepare(source)
alpha_path = tmp_path / "alpha.npy"
np.save(alpha_path, np.full((3, 4), 0.3, np.float32))
try:
result = snapshot.apply(ImageEditTarget.capture(texture_context(hdr_texture)), alpha_path)
rgba = image_rgba(result)
np.testing.assert_array_equal(rgba[..., :3], before[..., :3])
np.testing.assert_allclose(rgba[..., 3], before[..., 3] * 0.3, atol=1e-7)
finally:
cleanup_image_input(snapshot.path, True)
def test_hdr_failed_commit_restores_unsaved_source(hdr_texture, tmp_path):
target = ImageEditTarget.capture(texture_context(hdr_texture))
snapshot = HdrBackgroundInput.prepare(target.image)
before = image_rgba(target.image).copy()
original_source = target.image.source
alpha_path = tmp_path / "alpha.npy"
np.save(alpha_path, np.full((3, 4), 0.3, np.float32))
calls = 0
def fail_first(image, state):
nonlocal calls
calls += 1
restore_image_content(image, state)
if calls == 1:
raise RuntimeError("Commit failed")
count = len(bpy.data.images)
try:
with patch.object(targets, "restore_image_content", side_effect=fail_first):
with pytest.raises(RuntimeError, match="Commit failed"):
snapshot.apply(target, alpha_path, prepare_undo=True)
np.testing.assert_array_equal(image_rgba(target.image), before)
assert target.image.source == original_source
assert len(bpy.data.images) == count
finally:
cleanup_image_input(snapshot.path, True)

View File

@@ -0,0 +1,80 @@
import bpy
import numpy as np
import pytest
from anyimage.common.hdr_image import HdrBackgroundInput
from anyimage.common.image import cleanup_image_input, image_rgba
from anyimage.common.image_target import ImageEditTarget
from tests.support.hdr_image import hdr_texture
from tests.support.image_texture import texture, texture_context
@pytest.mark.parametrize("use_alpha", [False, True])
def test_hdr_texture_rgb_remains_visible_independently_of_alpha(hdr_texture, tmp_path, use_alpha):
source = hdr_texture.image
source.pixels.foreach_set(np.tile([4, 2, 0.5, 1], 12).astype(np.float32))
alpha = np.tile([0, 0.25, 0.5, 1], (3, 1)).astype(np.float32)
path = tmp_path / "alpha.npy"
np.save(path, alpha)
snapshot = HdrBackgroundInput.prepare(source)
try:
result = snapshot.apply(ImageEditTarget.capture(texture_context(hdr_texture)), path)
finally:
cleanup_image_input(snapshot.path, True)
# A second removal must accept its own independent-channel result and keep RGB intact.
snapshot = HdrBackgroundInput.prepare(result)
try:
snapshot.apply(ImageEditTarget.capture(texture_context(hdr_texture)), path)
np.testing.assert_array_equal(image_rgba(result)[..., :3], np.tile([4, 2, 0.5], (3, 4, 1)))
finally:
cleanup_image_input(snapshot.path, True)
result.reload()
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.device = "CPU"
scene.cycles.samples = 32
scene.cycles.use_denoising = False
scene.render.film_transparent = True
scene.render.resolution_x, scene.render.resolution_y = 64, 16
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "OPEN_EXR"
scene.render.image_settings.color_mode = "RGBA"
scene.render.image_settings.color_depth = "32"
scene.render.filepath = str(tmp_path / "render.exr")
bpy.ops.object.camera_add(location=(0, 0, 5))
scene.camera = bpy.context.object
scene.camera.data.type = "ORTHO"
scene.camera.data.ortho_scale = 4
bpy.ops.mesh.primitive_plane_add(size=2)
bpy.context.object.scale = (2, 0.5, 1)
material = bpy.data.materials.new("HDR RGB validation")
material.use_nodes = True
nodes, links = material.node_tree.nodes, material.node_tree.links
nodes.clear()
image_node = nodes.new("ShaderNodeTexImage")
image_node.image = result
image_node.interpolation = "Closest"
emission = nodes.new("ShaderNodeEmission")
links.new(image_node.outputs["Color"], emission.inputs["Color"])
output = nodes.new("ShaderNodeOutputMaterial")
if use_alpha:
transparent = nodes.new("ShaderNodeBsdfTransparent")
mix = nodes.new("ShaderNodeMixShader")
links.new(image_node.outputs["Alpha"], mix.inputs[0])
links.new(transparent.outputs[0], mix.inputs[1])
links.new(emission.outputs[0], mix.inputs[2])
links.new(mix.outputs[0], output.inputs["Surface"])
else:
links.new(emission.outputs[0], output.inputs["Surface"])
bpy.context.object.data.materials.append(material)
bpy.ops.render.render(write_still=True)
rendered = bpy.data.images.load(scene.render.filepath, check_existing=False)
pixels = image_rgba(rendered)
for index, opacity in enumerate((0, 0.0625, 0.25, 1)):
patch = pixels[4:12, index * 16 + 4:index * 16 + 12]
expected_alpha = opacity if use_alpha else 1
np.testing.assert_allclose(patch[..., 3].mean(), expected_alpha, atol=0.035)
expected_rgb = patch[..., 3:4] * np.array([4, 2, 0.5])
np.testing.assert_allclose(patch[..., :3], expected_rgb, rtol=2e-5, atol=1e-6)

View File

@@ -217,13 +217,6 @@ class ImageDataTest(unittest.TestCase):
)
def test_color_name_strips_video_extension(self):
source_object = SimpleNamespace(data=SimpleNamespace(name="clip.mp4"))
self.assertEqual(
self.modules.image.color_data_name(source_object),
"clip_color.png",
)
def test_force_sequence_source_keeps_loaded_sequence(self):
@@ -322,20 +315,6 @@ class ImageDataTest(unittest.TestCase):
)
def test_color_name_uses_source_image_base_name(self):
source_object = SimpleNamespace(
data=SimpleNamespace(name="img.png"),
)
self.assertEqual(
self.modules.image.color_data_name(source_object),
"img_color.png",
)
source_object.data.name = "img.png.001"
self.assertEqual(
self.modules.image.color_data_name(source_object),
"img.001_color.png",
)
def test_image_empty_bounds_keep_image_aspect_and_empty_offset(self):

View File

@@ -183,7 +183,7 @@ def test_loaded_normal_image_uses_normal_texture_name(tmp_path):
assert image.packed_file is not None
def test_converted_color_images_are_new_and_use_blender_duplicate_suffix():
def test_shared_conversion_images_use_blender_duplicate_suffix():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Poster", width=16, height=16)
source = bpy.data.objects.new("Poster", None)
@@ -191,8 +191,11 @@ def test_converted_color_images_are_new_and_use_blender_duplicate_suffix():
source.data = image
bpy.context.collection.objects.link(source)
first = plane_object.create_color_image(source)
second = plane_object.create_color_image(source)
other = source.copy()
bpy.context.collection.objects.link(other)
from tests.support.color_image import color_image
first = color_image(source.data)
second = color_image(source.data)
assert first is not image
assert second is not image

View File

@@ -80,7 +80,7 @@ def test_invalid_node_context_does_not_fall_back_to_empty(texture, invalid):
targets.ImageEditTarget.capture(context)
@pytest.mark.parametrize("change", ["delete", "recreate", "rebind", "delete_material", "delete_image"])
@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
@@ -95,6 +95,9 @@ def test_target_rejects_changed_identity_before_loading_result(texture, tmp_path
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)

View File

@@ -0,0 +1,54 @@
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from tests.support.blender import BlenderTestCase
class InputPreparationTest(BlenderTestCase):
def test_sequence_format_is_checked_before_copy(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "frame.0001.exr"
path.touch()
image = SimpleNamespace(source="SEQUENCE")
with (
patch.object(self.image_data, "image_path", return_value=path),
patch.object(self.image_data, "image_is_packed", return_value=False),
patch.object(self.image_data.tempfile, "mkdtemp") as create,
):
with self.assertRaisesRegex(RuntimeError, "Unsupported input format: .exr"):
self.image_data.prepare_image_input(image, None)
create.assert_not_called()
self.assertTrue(path.exists())
def test_packed_export_failure_removes_temporary_directory(self):
for failure in (OSError("disk full"), None):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "export"
output.mkdir()
image = SimpleNamespace(file_format="OPEN_EXR", save=Mock(side_effect=failure))
with patch.object(self.image_data.tempfile, "mkdtemp", return_value=str(output)):
with self.assertRaises(RuntimeError):
self.image_data.save_packed_image(image, None)
self.assertEqual(image.file_format, "OPEN_EXR")
self.assertFalse(output.exists())
def test_sequence_copy_failure_preserves_originals(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "frame.0001.png"
source.write_bytes(b"source")
output = root / "prepared"
output.mkdir()
with (
patch.object(self.image_data, "image_path", return_value=source),
patch.object(self.image_data, "image_is_packed", return_value=False),
patch.object(self.image_data, "collect_sequence_frames", return_value=[source]),
patch.object(self.image_data.tempfile, "mkdtemp", return_value=str(output)),
patch.object(self.image_data.shutil, "copy2", side_effect=OSError("disk full")),
):
with self.assertRaises(OSError):
self.image_data.prepare_image_input(SimpleNamespace(source="SEQUENCE"), None)
self.assertFalse(output.exists())
self.assertEqual(source.read_bytes(), b"source")

View File

@@ -14,11 +14,19 @@ def test_material_alpha_strength_follows_preference(image_layer, monkeypatch, en
color = bpy.data.images.new("Alpha edge", width=2, height=2, alpha=True)
scene = SimpleNamespace(view_settings=SimpleNamespace(view_transform=view))
result = material.create_image_material(color, color, shadeless=shadeless, scene=scene)
if shadeless:
shader = next(node for node in result.node_tree.nodes if node.type == "GROUP")
assert shader.node_tree.name == "O Shadeless"
assert shader.inputs["Color"].links[0].from_node.type == "TEX_IMAGE"
assert shader.inputs["Alpha"].links[0].from_socket.name == "Alpha"
assert not any(node.type in {"MIX_SHADER", "BSDF_TRANSPARENT"} for node in result.node_tree.nodes)
assert len([node for node in result.node_tree.nodes if node.type == "GROUP"]) == 1
return
layer = next(node for node in result.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer)
assert layer.inputs["Alpha Fix"].default_value == float(enabled)
alpha_link = layer.outputs["Alpha"].links[0]
assert alpha_link.to_node.type == ("MIX_SHADER" if shadeless else "BSDF_PRINCIPLED")
assert alpha_link.to_socket.name == ("Fac" if shadeless else "Alpha")
assert alpha_link.to_node.type == "BSDF_PRINCIPLED"
assert alpha_link.to_socket.name == "Alpha"
layer.inputs["Alpha Fix"].default_value = 0.5
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: not enabled)
second = material.create_image_material(color, color, shadeless=shadeless, scene=scene)

View File

@@ -10,8 +10,7 @@ from anyimage import preferences
from anyimage.common import image as images
from anyimage.common import material
from anyimage.operators.clipboard_image import actions as clipboard
from anyimage.operators.convert_to_plane.object import create_color_image
from anyimage.operators.cutout_tool.object import create_cropped_color_image
from tests.support.color_image import color_image
@pytest.fixture
@@ -74,10 +73,7 @@ def test_color_space_and_alpha_follow_preference(color_data, enabled, view, spac
@pytest.mark.parametrize("enabled", [False, True])
@pytest.mark.parametrize("kind", [
"byte", "packed", "file", "dirty",
pytest.param("float", marks=pytest.mark.xfail(
strict=True,
reason="Native float RGB is reinterpreted by Blender after packing and reloading with a display color space",
)),
"float",
])
def test_material_pixels_survive_pack_and_library_reload(color_data, tmp_path, enabled, kind):
expected = np.array([[[51, 102, 204, 64], [153, 76, 25, 0]]], dtype=np.uint8)
@@ -100,8 +96,7 @@ def test_material_pixels_survive_pack_and_library_reload(color_data, tmp_path, e
owner.data = source
with patch.object(material, "configured_material_view_adaptation", return_value=enabled):
result = material.configure_material_color_image(source, view_scene("AgX"))
assert result != source
assert source.alpha_mode == "PREMUL"
assert result == source
np.testing.assert_allclose(images.image_pixels(source), before, atol=1 / 255)
np.testing.assert_allclose(images.image_pixels(result), before, atol=1 / 255)
if result.packed_file is None or result.is_dirty:
@@ -121,7 +116,7 @@ def test_material_pixels_survive_pack_and_library_reload(color_data, tmp_path, e
@pytest.mark.parametrize("shadeless", [False, True])
@pytest.mark.parametrize("entry", ["plane", "cutout", "depth", "clipboard"])
def test_new_material_uses_current_preference_and_preserves_existing(color_data, entry, shadeless):
def test_material_configures_the_image_selected_by_its_caller(color_data, entry, shadeless):
source = bpy.data.images.new("Shared source", width=2, height=2, alpha=True)
source.pixels.foreach_set(np.tile(np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32), 4))
owner = bpy.data.objects.new("Shared Empty", None)
@@ -144,10 +139,8 @@ def test_new_material_uses_current_preference_and_preserves_existing(color_data,
if entry == "clipboard":
first = clipboard.create_image_material(source, source, shadeless=shadeless, scene=scene)
else:
if entry == "plane":
color = create_color_image(owner)
elif entry == "cutout":
color = create_cropped_color_image(owner, (0, 0, 2, 2), images.image_rgba(source))
if entry == "cutout":
color = color_image(owner.data, (0, 0, 2, 2), images.image_rgba(source))
else:
color = source
first = material.create_image_material(owner.data, color, 0, shadeless=shadeless, scene=scene, **data_textures)
@@ -161,15 +154,15 @@ def test_new_material_uses_current_preference_and_preserves_existing(color_data,
else:
second = material.create_image_material(owner.data, original_color, 0, shadeless=shadeless, scene=scene)
new_color = color_texture(second)
assert new_color != original_color
assert new_color == original_color
assert new_color.colorspace_settings.name == "sRGB"
assert new_color.alpha_mode == "PREMUL"
assert original_color.colorspace_settings.name == "AgX Base sRGB"
assert original_color.alpha_mode == "STRAIGHT"
assert original_color.colorspace_settings.name == "sRGB"
assert original_color.alpha_mode == "PREMUL"
assert not settings.adapt_material_to_view_transform
assert owner.data == source
assert source.colorspace_settings.name == "sRGB"
assert source.alpha_mode == "STRAIGHT"
assert source.alpha_mode == ("STRAIGHT" if entry == "cutout" else "PREMUL")
for image in (source, original_color, new_color):
np.testing.assert_allclose(images.image_pixels(image), before, atol=1 / 255)
for key, image in data_textures.items():

View File

@@ -53,14 +53,15 @@ class AiImageOperatorsTest(BlenderTestCase):
def test_upscale_operator_opens_setup_when_required_ai_is_missing(self):
expected = {"CANCELLED"}
operator = self.upscale.UpscaleImage()
with patch.object(
self.upscale,
"invoke_ai_setup_if_needed",
return_value=expected,
) as setup:
result = self.upscale.UpscaleImage().invoke(None, None)
result = operator.invoke(None, None)
setup.assert_called_once_with()
setup.assert_called_once_with(operator)
self.assertIs(result, expected)

View File

@@ -0,0 +1,96 @@
import tempfile
from contextlib import ExitStack
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from tests.support.blender import BlenderTestCase
class AiInputFailuresTest(BlenderTestCase):
def test_image_edits_reject_same_input_before_submission(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "input.tiff"
path.touch()
messages = []
for module, operator_type in (
(self.upscale, self.upscale.UpscaleImage),
(self.remove_background, self.remove_background.RunBackgroundRemoval),
):
operator = operator_type()
operator.report = Mock()
target = Mock(image=SimpleNamespace(size=(16, 16)), start_frame=1)
target.prepare.return_value = (path, False)
context = SimpleNamespace(scene=SimpleNamespace(
anyimage_settings=SimpleNamespace(video_frames=1)))
with ExitStack() as stack:
stack.enter_context(patch.object(module.ImageEditTarget, "capture", return_value=target))
stack.enter_context(patch.object(module, "require_environment"))
stack.enter_context(patch.object(module, "require_model"))
stack.enter_context(patch.object(self.runtime.runtime, "active_job", None))
submit = stack.enter_context(patch.object(self.runtime.runtime, "begin_job"))
if module == self.upscale:
stack.enter_context(patch.object(module, "configured_upscale_model", return_value="SAFMN_REAL_X4"))
stack.enter_context(patch.object(module, "configured_max_ai_input_size", return_value=2048))
self.assertEqual(operator.execute(context), {"CANCELLED"})
submit.assert_not_called()
messages.append(operator.report.call_args.args[1])
self.assertIsNone(operator._image_target)
self.assertTrue(path.exists())
self.assertEqual(messages[0], messages[1])
self.assertIn("Unsupported input format: .tiff", messages[0])
def test_remove_background_catches_nested_error(self):
operator = self.remove_background.RemoveImageBackground()
operator.report = Mock()
ops = SimpleNamespace(anyimage=SimpleNamespace(
run_background_removal=Mock(side_effect=RuntimeError("Error: invalid input\n"))))
with patch.object(self.remove_background.bpy, "ops", ops, create=True):
self.assertEqual(operator.execute(None), {"CANCELLED"})
operator.report.assert_called_once_with({"ERROR"}, "invalid input")
def test_setup_failures_cancel_at_each_boundary(self):
for ready in (True, False):
operator = self.ai_setup.SetupAIEnvironment()
operator.use_mirror = False
operator.report = Mock()
failing = Mock(side_effect=RuntimeError("Error: setup failed\n"))
ops = SimpleNamespace(anyimage=SimpleNamespace(
download_required_models=failing, install_environment=failing))
with (
patch.object(self.ai_setup, "ai_status", return_value={"ready": False, "environment_ready": ready}),
patch.object(self.ai_setup.bpy, "ops", ops, create=True),
):
self.assertEqual(operator.execute(None), {"CANCELLED"})
operator.report.assert_called_once_with({"ERROR"}, "setup failed")
owner = SimpleNamespace(report=Mock())
with (
patch.object(self.ai, "ai_ready", return_value=False),
patch.object(self.ai.bpy, "ops", SimpleNamespace(anyimage=SimpleNamespace(
setup_ai_environment=Mock(side_effect=RuntimeError("Error: setup failed\n")))), create=True),
):
self.assertEqual(self.ai.invoke_ai_setup_if_needed(owner), {"CANCELLED"})
owner.report.assert_called_once_with({"ERROR"}, "setup failed")
def test_depth_and_relief_cleanup_on_nested_failure(self):
module = self.convert_to_plane
for plane_type in ("DEPTH", "RELIEF"):
for failure in (RuntimeError("Error: invalid input\n"), None):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "prepared" / "input.png"
path.parent.mkdir()
path.touch()
operator = SimpleNamespace(mesh_detail=4, report=Mock())
context = SimpleNamespace(object=SimpleNamespace(name="source", as_pointer=lambda: 1,
data=SimpleNamespace(as_pointer=lambda: 2)), scene=object())
generate = Mock(side_effect=failure, return_value={"CANCELLED"})
with (
patch.object(module, "invoke_ai_setup_if_needed", return_value=None),
patch.object(module, "prepare_material_color_input", return_value=path),
patch.object(module, "material_analysis_input", return_value=path),
patch.object(module, "depth_plane_settings", return_value=("model", 5)),
patch.object(module, "configured_max_ai_input_size", return_value=2048),
patch.object(module.bpy, "ops", SimpleNamespace(anyimage=SimpleNamespace(generate_depth_plane=generate)), create=True),
):
self.assertEqual(module.start_depth_plane_job(operator, context, plane_type), {"CANCELLED"})
self.assertFalse(path.parent.exists())

View File

@@ -0,0 +1,113 @@
"""Verify cache identity against edits to real Blender images."""
from io import BytesIO
from types import SimpleNamespace
import bpy
import numpy as np
import pytest
from PIL import Image
from anyimage.common import image as images
from anyimage.common.selection import SelectionMask
from anyimage.operators.clipboard_image import actions
from anyimage.operators.mask_tool import EditImageAlpha
@pytest.fixture
def clipboard():
bpy.ops.wm.read_factory_settings(use_empty=True)
stream = BytesIO()
Image.fromarray(np.array([[[100, 150, 200, 255], [50, 80, 120, 0]]], np.uint8)).save(stream, format="PNG")
yield stream.getvalue()
bpy.ops.wm.read_factory_settings(use_empty=True)
@pytest.mark.parametrize("edit", ["paint", "packed", "hidden_rgb", "size", "space", "alpha", "legacy", "unreadable"])
def test_changed_cache_is_skipped_without_modifying_candidate(clipboard, edit, monkeypatch):
original, reused = actions.acquire_packed_image(clipboard, ".png")
expected = images.image_pixels(original)
assert not reused
assert actions.acquire_packed_image(clipboard, ".png") == (original, True)
if edit in {"paint", "packed"}:
original.pixels[0] = 1
if edit == "packed":
original.pack()
elif edit == "hidden_rgb":
original.pixels[4] = 1
elif edit == "size":
original.scale(4, 2)
elif edit == "space":
original.colorspace_settings.name = "Non-Color"
elif edit == "alpha":
original.alpha_mode = "PREMUL"
elif edit == "legacy":
del original[actions.CLIPBOARD_CONTENT_PROPERTY]
else:
read = actions._clipboard_content_hash
def fail_candidate(image):
if image == original:
raise RuntimeError("Unreadable candidate")
return read(image)
monkeypatch.setattr(actions, "_clipboard_content_hash", fail_candidate)
state = images.image_content_state(original)
current_pixels = original.pixels[:]
replacement, reused = actions.acquire_packed_image(clipboard, ".png")
assert replacement != original and not reused
np.testing.assert_array_equal(images.image_pixels(replacement), expected)
assert (original.colorspace_settings.name, original.alpha_mode) == (state["colorspace"], state["alpha_mode"])
assert bytes(original.packed_file.data) == state["packed"]
np.testing.assert_array_equal(original.pixels[:], current_pixels)
@pytest.mark.parametrize("shared", [False, True])
def test_mask_edit_then_paste_preserves_original_clipboard_content(clipboard, shared):
original, _ = actions.acquire_packed_image(clipboard, ".png")
expected = images.image_pixels(original)
owner = bpy.data.objects.new("Clipboard Empty", None)
owner.empty_display_type = "IMAGE"
owner.data = original
bpy.context.collection.objects.link(owner)
if shared:
other = owner.copy()
bpy.context.collection.objects.link(other)
mask = SelectionMask(np.full((1, 2), .5, np.float32), (0, 0, 2, 1))
assert EditImageAlpha._apply_mask(SimpleNamespace(mode="SUBTRACT"), bpy.context, owner, mask) == {"FINISHED"}
edited = owner.data
edited_pixels = images.image_pixels(edited)
assert edited.packed_file
assert (edited == original) == (not shared)
pasted, reused = actions.acquire_packed_image(clipboard, ".png")
assert reused == shared
assert pasted != edited
np.testing.assert_array_equal(images.image_pixels(pasted), expected)
np.testing.assert_array_equal(images.image_pixels(edited), edited_pixels)
def test_painted_clipboard_plane_keeps_reference_node_and_brush_cache(clipboard, monkeypatch):
original, _ = actions.acquire_packed_image(clipboard, ".png")
expected = images.image_pixels(original)
monkeypatch.setattr(actions, "_place_in_view", lambda *_: None)
monkeypatch.setattr(actions, "_select_only", lambda *_: None)
obj = actions.add_image_plane(bpy.context, original, shadeless=False)
color = next(n.image for n in obj.data.materials[0].node_tree.nodes if n.type == "TEX_IMAGE")
assert color != original and actions.CLIPBOARD_HASH_PROPERTY not in color
assert actions.CLIPBOARD_CONTENT_PROPERTY not in color
color.pixels[0] = 1
color.pack()
for _ in range(3):
pasted, reused = actions.acquire_packed_image(clipboard, ".png")
assert pasted == original and reused
np.testing.assert_array_equal(images.image_pixels(pasted), expected)
def test_failed_clipboard_plane_placement_releases_created_data(clipboard, monkeypatch):
original, _ = actions.acquire_packed_image(clipboard, ".png")
before = {name: set(getattr(bpy.data, name)) for name in ("images", "objects", "meshes", "materials")}
def fail(*_):
raise RuntimeError("Placement failed")
monkeypatch.setattr(actions, "_place_in_view", fail)
with pytest.raises(RuntimeError, match="Placement failed"):
actions.add_image_plane(bpy.context, original)
for name, items in before.items():
assert set(getattr(bpy.data, name)) == items

View File

@@ -1,5 +1,6 @@
from tests.support.paths import PROJECT_ROOT
import importlib
from contextlib import nullcontext
import struct
import sys
import tempfile
@@ -400,13 +401,14 @@ class ClipboardImageTest(unittest.TestCase):
for shadeless in (False, True):
with (
patch.object(self.actions, "create_image_material", return_value="Material") as create_material,
patch.object(self.actions, "material_color_image", return_value=nullcontext("Color")),
patch.object(image_plane, "create_image_plane_object") as create_plane,
patch.object(self.actions, "_place_in_view"),
patch.object(self.actions, "_select_only"),
):
self.actions.add_image_plane(context, image, shadeless=shadeless)
create_material.assert_called_once_with(
image, image, shadeless=shadeless, scene=context.scene,
image, "Color", shadeless=shadeless, scene=context.scene,
)
self.assertEqual(create_plane.call_args.args[3], "Material")
@@ -570,10 +572,11 @@ class ClipboardImageTest(unittest.TestCase):
images=[matching_image, unpacked_image],
)
try:
self.assertIs(
self.actions._find_packed_clipboard_image("matching-hash"),
matching_image,
)
with patch.object(matching_image, "get", side_effect=lambda key: "matching-hash" if key == self.actions.CLIPBOARD_HASH_PROPERTY else "pixels"), patch.object(self.actions, "_clipboard_content_hash", return_value="pixels"):
self.assertIs(
self.actions._find_packed_clipboard_image("matching-hash"),
matching_image,
)
self.assertIsNone(
self.actions._find_packed_clipboard_image("unpacked-hash")
)
@@ -586,10 +589,11 @@ class ClipboardImageTest(unittest.TestCase):
original_data = self.actions.bpy.data
self.actions.bpy.data = SimpleNamespace(images=images)
try:
loaded_image, reused = self.actions.acquire_packed_image(
b"image-data",
".png",
)
with patch.object(self.actions, "_clipboard_content_hash", return_value="pixels"):
loaded_image, reused = self.actions.acquire_packed_image(
b"image-data",
".png",
)
finally:
self.actions.bpy.data = original_data

View File

@@ -0,0 +1,185 @@
"""Verify conversion ownership, source recovery, and persistent color data."""
from types import SimpleNamespace
import bpy
import numpy as np
import pytest
from PIL import Image
from anyimage.common import image as images, material
from anyimage.common.color_image import prepare_material_color_input, cleanup_material_color_input
from anyimage.operators.convert_to_plane import operators, object as conversion, image_plane
@pytest.fixture
def source():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Source.png", width=2, height=1, alpha=True)
image.pixels.foreach_set([0.2, 0.4, 0.8, 0.25, 0.6, 0.3, 0.1, 0])
obj = bpy.data.objects.new("Source", None)
obj.empty_display_type = "IMAGE"
obj.data = image
bpy.context.collection.objects.link(obj)
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
yield obj
bpy.ops.wm.read_factory_settings(use_empty=True)
def share_image(source, sharing):
if sharing in {"visible", "hidden", "other_scene", "same_path"}:
other = source.copy()
if sharing == "same_path":
source.data.filepath_raw = "//source.png"
other.data = source.data.copy()
collection = bpy.context.collection
if sharing == "other_scene":
collection = bpy.data.scenes.new("Other scene").collection
collection.objects.link(other)
other.hide_viewport = sharing == "hidden"
elif sharing == "material":
existing = bpy.data.materials.new("Existing")
existing.use_nodes = True
existing.node_tree.nodes.new("ShaderNodeTexImage").image = source.data
def run_conversion(source, entry, monkeypatch):
if entry == "PLANE":
operators.ConvertToPlane.execute(SimpleNamespace(mesh_detail=0), bpy.context)
else:
metadata = {"image_size": (2, 1), "reference_depth": 1,
"intrinsics": ((2, 0, 1), (0, 2, 0.5), (0, 0, 1))}
monkeypatch.setattr(conversion, "load_depth_metadata", lambda _: metadata)
def load_depth(*_):
image = bpy.data.images.new("Depth", width=2, height=1, float_buffer=True)
image.colorspace_settings.name = "Non-Color"
return image
monkeypatch.setattr(conversion, "load_depth_result_image", load_depth)
monkeypatch.setattr(conversion, "load_normal_result_image", load_depth)
monkeypatch.setattr(conversion, "bpy", SimpleNamespace(data=bpy.data,
ops=SimpleNamespace(ed=SimpleNamespace(undo_push=lambda **_: {"FINISHED"}))))
path = prepare_material_color_input(source.data)
try:
conversion.create_depth_plane_from_result(bpy.context, SimpleNamespace(file=lambda key: key),
SimpleNamespace(source_object_name=source.name, source_identity=str(source.as_pointer()),
image_identity=str(source.data.as_pointer()), color_path=str(path), plane_type=entry, mesh_detail=0))
finally:
cleanup_material_color_input(path)
return bpy.context.object
@pytest.mark.parametrize("entry", ["PLANE", "DEPTH", "RELIEF"])
@pytest.mark.parametrize("sharing", ["none", "visible", "hidden", "other_scene", "material", "same_path"])
def test_conversion_selects_source_by_empty_identity(source, monkeypatch, entry, sharing):
share_image(source, sharing)
original = source.data
before = images.image_pixels(original)
name, path = original.name, original.filepath_raw
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: False)
obj = run_conversion(source, entry, monkeypatch)
color = next(n.image for n in obj.data.materials[0].node_tree.nodes if n.type == "TEX_IMAGE")
assert color != original
assert color.name == "Source_color.png"
assert color.filepath_raw != original.filepath_raw
assert original.name == name and original.filepath_raw == path
assert color.colorspace_settings.name == "sRGB" and color.alpha_mode == "PREMUL"
assert original.alpha_mode == "STRAIGHT"
np.testing.assert_array_equal(images.image_pixels(original), before)
np.testing.assert_allclose(images.image_pixels(color), before, atol=1 / 255)
if sharing == "material":
existing = bpy.data.materials["Existing"]
assert next(n.image for n in existing.node_tree.nodes if n.type == "TEX_IMAGE") == original
@pytest.mark.parametrize("shared", [False, True])
@pytest.mark.parametrize("failure", ["material", "modifier", "finalize"])
@pytest.mark.parametrize("entry", ["PLANE", "DEPTH", "RELIEF"])
def test_conversion_failure_restores_image_and_releases_resources(source, monkeypatch, shared, failure, entry):
if shared:
share_image(source, "visible")
source.data.pack()
source.data.pixels[0] = 0.75
original = source.data
state = images.image_content_state(original)
before = {name: set(getattr(bpy.data, name)) for name in ("images", "objects", "meshes", "materials")}
def fail(*args, **kwargs):
raise RuntimeError("Conversion failed")
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: True)
if failure == "material":
monkeypatch.setattr(material, "material_node_group", fail)
elif failure == "finalize":
monkeypatch.setattr(conversion, "finalize_object_result", fail)
elif entry == "PLANE":
monkeypatch.setattr(image_plane, "add_image_plane_processing", fail)
else:
monkeypatch.setattr(conversion, "set_modifier_input", fail)
with pytest.raises(RuntimeError, match="Conversion failed"):
run_conversion(source, entry, monkeypatch)
for name, values in before.items():
assert set(getattr(bpy.data, name)) == values
assert source.data == original
assert (original.colorspace_settings.name, original.alpha_mode) == (state["colorspace"], state["alpha_mode"])
assert bytes(original.packed_file.data) == state["packed"]
np.testing.assert_array_equal(original.pixels[:], state["pixels"])
@pytest.mark.parametrize("shared", [False, True])
@pytest.mark.parametrize("kind", ["generated", "file", "dirty", "packed"])
def test_conversion_pixels_survive_library_reload(source, tmp_path, monkeypatch, kind, shared):
if kind in {"file", "dirty"}:
path = tmp_path / "source.png"
Image.fromarray(np.array([[[51, 102, 204, 64], [153, 76, 25, 0]]], np.uint8)).save(path)
source.data = bpy.data.images.load(str(path))
if kind == "dirty":
source.data.pixels[0] = 0.8
elif kind == "packed":
source.data.pack()
source.data.reload()
if shared:
share_image(source, "visible")
expected = images.image_pixels(source.data)
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: True)
obj = run_conversion(source, "PLANE", monkeypatch)
color = next(n.image for n in obj.data.materials[0].node_tree.nodes if n.type == "TEX_IMAGE")
library = tmp_path / "converted.blend"
bpy.data.libraries.write(str(library), {color})
with bpy.data.libraries.load(str(library)) as (saved, loaded):
loaded.images = saved.images
restored = loaded.images[0]
assert (restored.colorspace_settings.name, restored.alpha_mode) == (color.colorspace_settings.name, color.alpha_mode)
np.testing.assert_allclose(images.image_pixels(restored), expected, atol=1 / 255)
@pytest.mark.parametrize("entry", ["DEPTH", "RELIEF"])
def test_depth_result_rejects_replaced_source_image(source, entry):
operator = SimpleNamespace(source_object_name=source.name, source_identity=str(source.as_pointer()),
image_identity=str(source.data.as_pointer()), plane_type=entry)
source.data = source.data.copy()
with pytest.raises(ValueError, match="source image has changed"):
conversion.create_depth_plane_from_result(bpy.context, None, operator)
@pytest.mark.parametrize("shared", [False, True])
def test_plane_undo_redo_restores_image_configuration(source, monkeypatch, shared):
if shared:
share_image(source, "visible")
original_name = source.data.name
expected = images.image_pixels(source.data)
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: False)
bpy.context.preferences.edit.use_global_undo = True
bpy.utils.register_class(operators.ConvertToPlane)
try:
bpy.ops.ed.undo_push(message="Before Plane")
assert bpy.ops.anyimage.convert_to_plane("EXEC_DEFAULT", True) == {"FINISHED"}
color_name = "Source_color.png"
assert bpy.data.images[color_name].alpha_mode == "PREMUL"
assert bpy.ops.ed.undo() == {"FINISHED"}
assert bpy.data.objects["Source"].type == "EMPTY"
assert bpy.data.images[original_name].alpha_mode == "STRAIGHT"
np.testing.assert_allclose(images.image_pixels(bpy.data.images[original_name]), expected, atol=1 / 255)
assert bpy.ops.ed.redo() == {"FINISHED"}
assert bpy.data.objects["Source"].type == "MESH"
assert bpy.data.images[color_name].alpha_mode == "PREMUL"
finally:
bpy.utils.unregister_class(operators.ConvertToPlane)

View File

@@ -7,6 +7,8 @@ from mathutils import Matrix, Vector
from anyimage.common import depth as depth_data
from anyimage.common import color_image as colors
from tests.support.color_image import color_image as prepared_color_image
from anyimage.common import image as image_data
from anyimage.common import material as material_data
from anyimage.common import object as object_data
@@ -808,24 +810,20 @@ def test_bounds_input_preserves_rectangular_rgba_and_stays_packed_after_cleanup(
),
dtype=np.float32,
)
path = image_data.prepare_image_bounds_input(image, rgba, (1, 1, 5, 4))
path = colors.prepare_material_color_input(image, (1, 1, 5, 4), rgba)
result = None
try:
result = image_data.load_packed_rgba_image(
path,
image,
"Bounds Result",
)
result = colors.load_material_color_image(path)
loaded = image_data.image_rgba(result)
np.testing.assert_allclose(loaded, rgba[1:4, 1:5], atol=1.0 / 255.0)
assert result.packed_file is not None
image_data.cleanup_image_input(path, True)
colors.cleanup_material_color_input(path)
assert not path.exists()
assert result.packed_file is not None
finally:
image_data.cleanup_image_input(path, True)
colors.cleanup_material_color_input(path)
if result is not None:
bpy.data.images.remove(result, do_unlink=True)
@@ -898,8 +896,8 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
selection_mask.values,
alpha_threshold=0.5,
)
color_image = cutout_object.create_cropped_color_image(
source,
color_image = prepared_color_image(
source.data,
selection_mask.bounds,
rgba,
)

View File

@@ -1,3 +1,4 @@
from contextlib import nullcontext
import importlib
import json
from pathlib import Path
@@ -69,8 +70,8 @@ class CutoutOperatorsTest(BlenderTestCase):
),
patch.object(
self.cutout_main,
"create_cropped_color_image",
return_value="Color",
"material_color_image",
return_value=nullcontext("Color"),
),
patch.object(
self.cutout_main, "create_cutout_shape"
@@ -163,9 +164,10 @@ class CutoutOperatorsTest(BlenderTestCase):
),
patch.object(
self.cutout_main,
"prepare_image_bounds_input",
"prepare_material_color_input",
return_value=Path(__file__),
) as prepare_input,
patch.object(self.cutout_main, "material_analysis_input", return_value=Path(__file__)),
patch.object(
self.cutout_main.JobOperatorBase,
"execute",
@@ -179,8 +181,8 @@ class CutoutOperatorsTest(BlenderTestCase):
rasterize.assert_called_once()
prepare_input.assert_called_once_with(
source.data,
source_rgba,
selection_mask.bounds,
source_rgba,
)
@@ -244,6 +246,7 @@ class CutoutOperatorsTest(BlenderTestCase):
operator.alpha_threshold = 0.25
operator.fine_outline = False
operator.input_path = __file__
operator.color_path = __file__
operator.source_object_name = "Source"
operator.shape = "DEPTH_SOLID"
operator.model = self.anyimage.properties.DEFAULT_MOGE2_MODEL_KEY
@@ -256,10 +259,10 @@ class CutoutOperatorsTest(BlenderTestCase):
with (
patch.object(self.cutout_main, "require_environment"),
patch.object(self.cutout_main, "require_model") as require_model,
patch.object(self.cutout_main, "require_input_path", return_value=Path("input.png")),
patch.object(
self.cutout_main, "production_device", return_value="CPU"
),
patch.object(self.cutout_main, "source_start_frame", return_value=1),
patch.object(
self.cutout_main,
"moge2_parameters",
@@ -372,6 +375,7 @@ class CutoutOperatorsTest(BlenderTestCase):
operator.source_object_name = "Source"
operator.selection_bounds_json = "[10,20,14,23]"
operator.input_path = __file__
operator.color_path = __file__
operator.shape = "FLAT"
operator.point_spacing = 32
operator.generate_normal = True
@@ -399,7 +403,6 @@ class CutoutOperatorsTest(BlenderTestCase):
color_result.assert_called_once_with(
__file__,
source,
selection_mask.bounds,
)

View File

@@ -0,0 +1,115 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import numpy as np
import pytest
from anyimage.common.hdr_image import HdrBackgroundInput
from anyimage.common.image import cleanup_image_input, image_rgba
from anyimage.common.image_target import ImageEditTarget
from anyimage.operators import remove_background as operators
from anyimage.runtime import JobOperatorBase
from tests.support.hdr_image import hdr_texture
from tests.support.image_texture import texture, texture_context
@pytest.mark.parametrize("owner_kind", ["texture", "empty"])
@pytest.mark.parametrize("shared", [False, True])
def test_hdr_background_undo_redo(hdr_texture, tmp_path, owner_kind, shared):
node = hdr_texture
source = node.image
before = image_rgba(source).copy()
if owner_kind == "empty":
owner = bpy.data.objects.new("HDR Empty", None)
owner.empty_display_type = "IMAGE"
owner.data = source
bpy.context.scene.collection.objects.link(owner)
node.image = source if shared else None
elif shared:
other = node.id_data.nodes.new("ShaderNodeTexImage")
other.image = source
alpha_path = tmp_path / "alpha.npy"
np.save(alpha_path, np.full((3, 4), 0.37123, np.float32))
def current_owner():
if owner_kind == "empty":
return bpy.data.objects["HDR Empty"]
return bpy.data.materials["Texture material"].node_tree.nodes["Source texture"]
def current_image():
owner = current_owner()
return owner.data if owner_kind == "empty" else owner.image
def execute(operator, context):
owner = current_owner()
target_context = SimpleNamespace(object=owner, space_data=None) if owner_kind == "empty" else texture_context(owner)
operator._image_target = ImageEditTarget.capture(target_context)
operator._hdr_input = HdrBackgroundInput.prepare(current_image())
operator.input_path, operator.delete_input = str(operator._hdr_input.path), True
try:
operator.response(context, SimpleNamespace(file=lambda key: alpha_path))
finally:
operator.cleanup()
return {"FINISHED"}
operation = operators.RunBackgroundRemoval
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 HDR removal")
assert bpy.ops.anyimage.run_background_removal("EXEC_DEFAULT", True) == {"FINISHED"}
after = image_rgba(current_image()).copy()
np.testing.assert_array_equal(after[..., :3], before[..., :3])
np.testing.assert_allclose(after[..., 3], before[..., 3] * np.float32(0.37123), atol=1e-7)
assert bpy.ops.ed.undo() == {"FINISHED"}
np.testing.assert_array_equal(image_rgba(current_image()), before)
assert bpy.ops.ed.redo() == {"FINISHED"}
np.testing.assert_array_equal(image_rgba(current_image()), after)
assert current_image().is_float
finally:
bpy.utils.unregister_class(operation)
@pytest.mark.parametrize("outcome", ["cancel", "raise"])
def test_hdr_submission_failure_releases_preview_and_snapshot(hdr_texture, outcome):
target = ImageEditTarget.capture(texture_context(hdr_texture))
paths = []
def submit(operator, context):
paths.append(Path(operator.input_path))
assert paths[-1].is_file()
if outcome == "raise":
raise RuntimeError("Submission failed")
return {"CANCELLED"}
# Use the registered type so super().execute follows the real Operator inheritance.
operation = operators.RunBackgroundRemoval
with patch.object(ImageEditTarget, "capture", return_value=target), patch.object(JobOperatorBase, "execute", submit):
bpy.utils.register_class(operation)
try:
if outcome == "raise":
with pytest.raises(RuntimeError, match="Submission failed"):
bpy.ops.anyimage.run_background_removal("EXEC_DEFAULT")
else:
assert bpy.ops.anyimage.run_background_removal("EXEC_DEFAULT") == {"CANCELLED"}
finally:
bpy.utils.unregister_class(operation)
assert paths and not paths[0].parent.exists()
def test_hdr_request_uses_alpha_output_and_current_still(hdr_texture):
operation = operators.RunBackgroundRemoval
snapshot = HdrBackgroundInput.prepare(hdr_texture.image)
operator = SimpleNamespace(input_path=str(snapshot.path), _hdr_input=snapshot,
_image_target=SimpleNamespace(start_frame=17))
context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=12)))
try:
with patch.object(operators, "require_environment"), patch.object(operators, "require_model"), patch.object(operators, "production_device", return_value="CPU"):
parameters = operation.request(operator, context)
assert parameters["output_kind"] == "alpha"
assert parameters["start_frame"] == parameters["video_frames"] == 1
finally:
cleanup_image_input(snapshot.path, True)

View File

@@ -0,0 +1,77 @@
from unittest.mock import patch
import bpy
import pytest
from anyimage.operators import ai_setup, remove_background
from anyimage.operators.convert_to_plane import operators as plane
@pytest.mark.parametrize("kind", ["background", "depth", "relief"])
def test_nested_native_operator_preserves_plain_error(kind, tmp_path, capfd):
reason = "Unsupported input format: .tiff"
if kind == "background":
inner, outer = remove_background.RunBackgroundRemoval, remove_background.RemoveImageBackground
else:
inner = plane.GenerateDepthPlane
outer = plane.ConvertToDepthPlane if kind == "depth" else plane.ConvertToReliefPlane
def reject(operator, context):
operator.report({"ERROR"}, reason)
return {"CANCELLED"}
prepared = tmp_path / "prepared"
prepared.mkdir()
path = prepared / "input.png"
path.touch()
with (
patch.object(inner, "execute", reject),
patch.object(outer, "poll", classmethod(lambda cls, context: True)),
patch.object(plane, "invoke_ai_setup_if_needed", return_value=None),
patch.object(plane, "prepare_material_color_input", return_value=path),
patch.object(plane, "material_analysis_input", return_value=path),
patch.object(plane, "depth_plane_settings", return_value=("model", 5)),
patch.object(plane, "configured_max_ai_input_size", return_value=2048),
):
owner = bpy.data.objects.new("Native validation source", None)
image = bpy.data.images.new("Native validation image", width=1, height=1)
owner.empty_display_type = "IMAGE"
owner.data = image
bpy.context.scene.collection.objects.link(owner)
bpy.context.view_layer.objects.active = owner
bpy.utils.register_class(inner)
bpy.utils.register_class(outer)
try:
with pytest.raises(RuntimeError) as error:
getattr(bpy.ops.anyimage, outer.bl_idname.split(".")[1])()
assert str(error.value).strip() == f"Error: {reason}"
assert "Traceback" not in capfd.readouterr().err
if kind != "background":
assert not prepared.exists()
finally:
bpy.utils.unregister_class(outer)
bpy.utils.unregister_class(inner)
bpy.data.objects.remove(owner, do_unlink=True)
bpy.data.images.remove(image)
def test_native_setup_operator_preserves_plain_error(capfd):
class RejectInstall(bpy.types.Operator):
bl_idname = "anyimage.install_environment"
bl_label = "Reject Install"
def execute(self, context):
self.report({"ERROR"}, "Setup unavailable")
return {"CANCELLED"}
with patch.object(ai_setup, "ai_status", return_value={"ready": False, "environment_ready": False}):
bpy.utils.register_class(RejectInstall)
bpy.utils.register_class(ai_setup.SetupAIEnvironment)
try:
with pytest.raises(RuntimeError) as error:
bpy.ops.anyimage.setup_ai_environment(use_mirror=False)
assert str(error.value).strip() == "Error: Setup unavailable"
assert "Traceback" not in capfd.readouterr().err
finally:
bpy.utils.unregister_class(ai_setup.SetupAIEnvironment)
bpy.utils.unregister_class(RejectInstall)

View File

@@ -0,0 +1,188 @@
"""Verify the public panorama entry and transactional Blender result handling."""
import json
from types import SimpleNamespace
from unittest.mock import Mock, patch
import bpy
import numpy as np
import pytest
from PIL import Image
from mathutils import Matrix
from anyimage.operators.convert_to_panorama import operators, object as conversion
from server.geometry.depth_texture import write_float_exr
from anyimage.common.color_image import prepare_material_color_input, cleanup_material_color_input
@pytest.fixture
def source():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Current panorama", width=32, height=16, alpha=True)
rgba = np.ones((16, 32, 4), np.float32)
rgba[:8, :, 3] = 0
rgba[8:12, :, 3] = 0.5
image.pixels.foreach_set(rgba.ravel())
obj = bpy.data.objects.new("Panorama Empty", None)
obj.empty_display_type = "IMAGE"
obj.data = image
bpy.context.collection.objects.link(obj)
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
return obj
def test_entry_submits_current_alpha_preferences_and_cleans_cancelled_input(source, monkeypatch):
submitted = {}
def submit(**parameters):
submitted.update(parameters)
with Image.open(parameters["input_path"]) as image:
alpha = np.asarray(image)[..., 3]
assert not alpha[8:].any()
assert np.all(abs(alpha[4:8].astype(float) - 128) <= 1)
return {"CANCELLED"}
monkeypatch.setattr(operators, "invoke_ai_setup_if_needed", lambda _operator: None)
monkeypatch.setattr(operators, "addon_preferences", lambda _: SimpleNamespace(geometry_model="MOGE2_VITB", geometry_resolution_level=2))
monkeypatch.setattr(operators, "configured_max_ai_input_size", lambda _: 1024)
monkeypatch.setattr(operators, "bpy", SimpleNamespace(ops=SimpleNamespace(anyimage=SimpleNamespace(generate_panorama=submit))))
operator = SimpleNamespace(mesh_detail=4, report=Mock())
assert operators.ConvertToPanorama.execute(operator, bpy.context) == {"CANCELLED"}
assert submitted["model"] == "MOGE2_VITB" and submitted["resolution_level"] == 2
assert submitted["max_input_size"] == 1024 and submitted["mesh_detail"] == 4
assert submitted["color_path"] == submitted["input_path"]
from pathlib import Path
assert not Path(submitted["input_path"]).exists()
assert bpy.data.objects.get(source.name) == source
@pytest.mark.parametrize("image_source,size", [("MOVIE", (32, 16)), ("SEQUENCE", (32, 16)), ("FILE", (32, 32))])
def test_invalid_input_is_rejected_before_setup(image_source, size, monkeypatch):
setup = Mock()
monkeypatch.setattr(operators, "invoke_ai_setup_if_needed", setup)
obj = SimpleNamespace(data=SimpleNamespace(source=image_source, size=size))
assert operators.ConvertToPanorama.execute(SimpleNamespace(report=Mock()), SimpleNamespace(object=obj)) == {"CANCELLED"}
setup.assert_not_called()
@pytest.fixture
def artifacts(tmp_path, source):
rgba = np.ones((16, 32, 4), np.float32)
rgba[..., :3] = 3
write_float_exr(rgba, tmp_path / "depth.exr")
(tmp_path / "depth.json").write_text(json.dumps({"projection": "equirectangular", "image_size": [32, 16], "reference_distance": 3}))
result = SimpleNamespace(directory=tmp_path, file=lambda key: tmp_path / {"depth": "depth.exr", "depth_metadata": "depth.json"}[key])
operator = SimpleNamespace(source_object_name=source.name, source_identity=str(source.as_pointer()),
image_identity=str(source.data.as_pointer()), mesh_detail=2,
color_path=str(prepare_material_color_input(source.data)))
yield result, operator
cleanup_material_color_input(operator.color_path)
def test_result_replaces_source_with_zero_rotation_and_shadeless_material(source, artifacts, monkeypatch):
result, operator = artifacts
matrix = Matrix.Translation((2, -3, 4)) @ Matrix.Rotation(0.6, 4, "Y") @ Matrix.Diagonal((2, 3, 4, 1))
source.matrix_world = matrix
source.empty_image_offset = (0.3, -0.8)
undo = Mock(return_value={"FINISHED"})
monkeypatch.setattr(conversion, "bpy", SimpleNamespace(data=bpy.data, ops=SimpleNamespace(ed=SimpleNamespace(undo_push=undo))))
obj = conversion.create_panorama_from_result(bpy.context, result, operator)
assert obj.name == operator.source_object_name and obj.type == "MESH"
np.testing.assert_allclose(obj.location, matrix.translation, atol=1e-6)
np.testing.assert_allclose(obj.scale, matrix.to_scale(), atol=1e-6)
np.testing.assert_array_equal(obj.rotation_euler, (0, 0, 0))
material = obj.data.materials[0]
assert any(n.type == "GROUP" and n.node_tree.name == "O Shadeless" for n in material.node_tree.nodes)
assert [n.node_tree.name for n in material.node_tree.nodes if n.type == "GROUP"] == ["O Shadeless"]
texture = next(n for n in material.node_tree.nodes if n.type == "TEX_IMAGE")
assert texture.extension == "REPEAT"
assert bpy.context.view_layer.objects.active == obj
assert all(image.packed_file for image in bpy.data.images if image.name.endswith(("_color.png", "_depth.exr")))
graph = bpy.context.evaluated_depsgraph_get()
mesh = obj.evaluated_get(graph).to_mesh()
try:
assert len(mesh.polygons) == 96 and mesh.uv_layers.active
assert mesh.materials[0].original == material
assert all(face.material_index == 0 and face.use_smooth for face in mesh.polygons)
finally:
obj.evaluated_get(graph).to_mesh_clear()
undo.assert_called_once_with(message="Convert to Panorama")
@pytest.mark.parametrize("shared", [False, True])
def test_panorama_preserves_hdr_pixels_color_space_and_alpha(source, artifacts, monkeypatch, shared):
result, operator = artifacts
hdr = bpy.data.images.new("Original HDR", width=32, height=16, alpha=True, float_buffer=True)
hdr.alpha_mode = "PREMUL"
rgba = np.ones((16, 32, 4), np.float32)
rgba[..., :3] = (4.0, 2.0, 0.5)
rgba[..., 3] = 0.5
hdr.pixels.foreach_set(rgba.ravel())
source.data = hdr
operator.image_identity = str(hdr.as_pointer())
cleanup_material_color_input(operator.color_path)
operator.color_path = str(prepare_material_color_input(hdr))
if shared:
other = source.copy()
bpy.context.collection.objects.link(other)
original_space, original_alpha = hdr.colorspace_settings.name, hdr.alpha_mode
monkeypatch.setattr(conversion, "bpy", SimpleNamespace(data=bpy.data, ops=SimpleNamespace(ed=SimpleNamespace(undo_push=lambda **kw: {"FINISHED"})) ))
obj = conversion.create_panorama_from_result(bpy.context, result, operator)
color = next(n.image for n in obj.data.materials[0].node_tree.nodes if n.type == "TEX_IMAGE")
assert color != hdr
assert color.is_float and color.packed_file
assert (color.colorspace_settings.name, color.alpha_mode) == (original_space, original_alpha)
np.testing.assert_allclose(color.pixels[:], rgba.ravel(), atol=1e-6)
assert (hdr.colorspace_settings.name, hdr.alpha_mode) == (original_space, original_alpha)
library = result.directory / "hdr-copy.blend"
bpy.data.libraries.write(str(library), {color})
with bpy.data.libraries.load(str(library)) as (saved, loaded):
loaded.images = saved.images
restored = loaded.images[0]
assert restored.is_float
assert (restored.colorspace_settings.name, restored.alpha_mode) == (original_space, original_alpha)
np.testing.assert_allclose(restored.pixels[:], rgba.ravel(), atol=1e-6)
@pytest.mark.parametrize("failure", ["modifier", "depth_size", "stale", "replaced_image", "finalize"])
def test_failed_response_keeps_source_and_cleans_owned_data(source, artifacts, monkeypatch, failure):
result, operator = artifacts
before = {name: set(getattr(bpy.data, name)) for name in ("images", "objects", "meshes", "materials")}
if failure == "modifier":
monkeypatch.setattr(conversion, "set_modifier_input", Mock(side_effect=RuntimeError("Failed modifier")))
elif failure == "finalize":
monkeypatch.setattr(conversion, "finalize_object_result", Mock(side_effect=RuntimeError("Failed finalize")))
elif failure == "stale":
operator.source_identity = "replaced"
elif failure == "replaced_image":
operator.image_identity = "replaced"
else:
(result.directory / "depth.json").write_text(json.dumps({"projection": "equirectangular", "image_size": [16, 8], "reference_distance": 3}))
with pytest.raises((RuntimeError, ValueError)):
conversion.create_panorama_from_result(bpy.context, result, operator)
for name, values in before.items():
assert set(getattr(bpy.data, name)) == values
def test_panorama_conversion_undo_restores_empty(source, artifacts):
result, operator = artifacts
name = source.name
image_name = source.data.name
original_space, original_alpha = source.data.colorspace_settings.name, source.data.alpha_mode
bpy.context.preferences.edit.use_global_undo = True
def execute(self, context):
conversion.create_panorama_from_result(context, result, operator)
return {"FINISHED"}
with patch.object(operators.GeneratePanorama, "execute", execute):
bpy.utils.register_class(operators.GeneratePanorama)
try:
bpy.ops.ed.undo_push(message="Before Panorama")
assert bpy.ops.anyimage.generate_panorama("EXEC_DEFAULT") == {"FINISHED"}
assert bpy.data.objects[name].type == "MESH"
assert bpy.ops.ed.undo() == {"FINISHED"}
assert bpy.data.objects[name].type == "EMPTY"
assert (bpy.data.images[image_name].colorspace_settings.name,
bpy.data.images[image_name].alpha_mode) == (original_space, original_alpha)
assert bpy.ops.ed.redo() == {"FINISHED"}
assert bpy.data.objects[name].type == "MESH"
finally:
bpy.utils.unregister_class(operators.GeneratePanorama)

View File

@@ -1,54 +1,37 @@
from pathlib import Path
from contextlib import nullcontext
from types import SimpleNamespace
from unittest.mock import Mock, patch
from tests.support.blender import BlenderTestCase
class PlaneOperatorsTest(BlenderTestCase):
def test_plane_depth_off_accepts_animated_image_without_server(self):
operator = SimpleNamespace(
depth_mode="OFF",
remove_background=False,
shadeless=False,
profile="SURFACE",
mesh_detail=64,
thickness=0.1,
inflation=0.1,
algorithm="POISSON",
)
source = SimpleNamespace(data=SimpleNamespace(name="Sequence"))
context = SimpleNamespace(object=source, scene=object())
with (
patch.object(self.convert_to_plane, "video_total_frames", return_value=24),
patch.object(
self.convert_to_plane,
"image_user_settings",
return_value=(1, 0, 24, True),
),
patch.object(self.convert_to_plane, "create_image_material", return_value="Material"),
patch.object(self.convert_to_plane, "create_color_image", return_value="Color"),
patch.object(self.convert_to_plane, "create_plane_object") as build_mesh,
):
result = self.convert_to_plane.ConvertToPlane.execute(operator, context)
self.assertEqual(result, {"FINISHED"})
build_mesh.assert_called_once()
def test_plane_rejects_animated_images_before_creating_resources(self):
for kind in ("MOVIE", "SEQUENCE"):
for duration in (1, 24):
source = SimpleNamespace(data=SimpleNamespace(source=kind, frame_duration=duration))
operator = SimpleNamespace(report=Mock())
with patch.object(self.convert_to_plane, "material_color_image") as prepare:
result = self.convert_to_plane.ConvertToPlane.execute(
operator, SimpleNamespace(object=source))
self.assertEqual(result, {"CANCELLED"})
prepare.assert_not_called()
operator.report.assert_called_once_with({"ERROR"}, "Material conversion supports static images only")
def test_depth_plane_rejects_animation_and_requests_ai_setup(self):
operator = SimpleNamespace(mesh_detail=6, report=Mock())
context = SimpleNamespace(object=SimpleNamespace(data=object()))
with (
patch.object(self.convert_to_plane, "video_total_frames", return_value=2),
patch.object(self.convert_to_plane, "require_static_color_image", side_effect=ValueError("Material conversion supports static images only")),
patch.object(self.convert_to_plane, "invoke_ai_setup_if_needed") as setup,
):
self.assertEqual(self.convert_to_plane.ConvertToDepthPlane.execute(operator, context), {"CANCELLED"})
setup.assert_not_called()
with (
patch.object(self.convert_to_plane, "video_total_frames", return_value=1),
patch.object(self.convert_to_plane, "require_static_color_image"),
patch.object(self.convert_to_plane, "invoke_ai_setup_if_needed", return_value={"FINISHED"}) as setup,
patch.object(self.convert_to_plane, "prepare_image_input") as prepare,
patch.object(self.convert_to_plane, "prepare_material_color_input") as prepare,
):
self.assertEqual(self.convert_to_plane.ConvertToDepthPlane.execute(operator, context), {"FINISHED"})
setup.assert_called_once()
@@ -58,14 +41,15 @@ class PlaneOperatorsTest(BlenderTestCase):
def test_depth_and_relief_start_jobs_with_their_plane_type(self):
for plane_type, operator_class in (("DEPTH", self.convert_to_plane.ConvertToDepthPlane), ("RELIEF", self.convert_to_plane.ConvertToReliefPlane)):
module = self.convert_to_plane
source = SimpleNamespace(name="Source", data=object())
source = SimpleNamespace(name="Source", as_pointer=lambda: 1,
data=SimpleNamespace(as_pointer=lambda: 2))
context = SimpleNamespace(object=source, scene=object())
operator = SimpleNamespace(mesh_detail=4, report=Mock())
generate = Mock(return_value={"FINISHED"})
with (
patch.object(module, "video_total_frames", return_value=1),
patch.object(module, "invoke_ai_setup_if_needed", return_value=None),
patch.object(module, "prepare_image_input", return_value=(Path("input.png"), True)),
patch.object(module, "prepare_material_color_input", return_value=Path("input.png")),
patch.object(module, "material_analysis_input", return_value=Path("input.png")),
patch.object(module, "depth_plane_settings", return_value=("moge", 5)),
patch.object(module, "configured_max_ai_input_size", return_value=1024),
patch.object(module.bpy, "ops", SimpleNamespace(anyimage=SimpleNamespace(generate_depth_plane=generate)), create=True),
@@ -94,15 +78,15 @@ class PlaneOperatorsTest(BlenderTestCase):
directory=Path("result"),
file=lambda key: files[key],
)
operator = SimpleNamespace(source_object_name="Source", mesh_detail=6, plane_type=plane_type)
operator = SimpleNamespace(source_object_name="Source", color_path="color.png", mesh_detail=6, plane_type=plane_type)
context = SimpleNamespace(scene=object())
ops = SimpleNamespace(
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
)
with (
patch.object(module, "require_image_empty", return_value=source),
patch.object(module, "load_color_result_image", return_value=color_image),
patch.object(module, "require_conversion_source", return_value=source),
patch.object(module, "material_color_image", return_value=nullcontext(color_image)),
patch.object(module, "load_depth_metadata", return_value=metadata),
patch.object(module, "load_depth_result_image", return_value=depth_image) as load_depth,
patch.object(
@@ -165,8 +149,8 @@ class PlaneOperatorsTest(BlenderTestCase):
)
with (
patch.object(module, "require_image_empty", return_value=source),
patch.object(module, "load_color_result_image", return_value=color_image),
patch.object(module, "require_conversion_source", return_value=source),
patch.object(module, "material_color_image", return_value=nullcontext(color_image)),
patch.object(
module,
"load_depth_metadata",
@@ -186,13 +170,13 @@ class PlaneOperatorsTest(BlenderTestCase):
module.create_depth_plane_from_result(
SimpleNamespace(scene=object()),
result,
SimpleNamespace(source_object_name="Source", mesh_detail=6, plane_type="DEPTH"),
SimpleNamespace(source_object_name="Source", color_path="color.png", mesh_detail=6, plane_type="DEPTH"),
)
remove_material.assert_called_once_with(material, do_unlink=True)
self.assertEqual(
[entry.args[0] for entry in remove_image.call_args_list],
[color_image, depth_image, normal_image],
[depth_image, normal_image],
)
self.assertTrue(
all(

View File

@@ -0,0 +1,36 @@
from unittest.mock import patch
import numpy as np
from PIL import Image
import pytest
from server.jobs import remove_background
from server.models import onnx_ben2
from tests.anyimage.server.support import FakeJobContext
@pytest.mark.parametrize("failure", ["cancel", "prediction", "sequence", "output_kind"])
def test_alpha_job_failure_does_not_write_result(tmp_path, failure):
source = tmp_path / "input.png"
Image.new("RGB", (2, 2)).save(source)
context = FakeJobContext(tmp_path / "job", "remove-background")
parameters = {"input": str(source), "model": "BEN2_BASE", "device": "cpu", "output_kind": "alpha"}
if failure == "sequence":
frames = tmp_path / "frames"
frames.mkdir()
for i in (1, 2):
Image.new("RGB", (2, 2)).save(frames / f"{i}.png")
parameters["input"] = str(frames)
if failure == "output_kind":
parameters["output_kind"] = "invalid"
def infer(*args, **kwargs):
if failure == "prediction":
raise RuntimeError("Prediction failed")
context.check_cancelled = lambda: (_ for _ in ()).throw(RuntimeError("Cancelled"))
return np.ones((2, 2), np.float32)
with patch.object(onnx_ben2, "create_session", return_value=object()), patch.object(onnx_ben2, "infer_alpha", side_effect=infer):
with pytest.raises((RuntimeError, ValueError)):
remove_background.run(context, parameters)
assert not (context.directory / "alpha.npy").exists()

View File

@@ -204,7 +204,7 @@ def test_artifacts_use_corrected_depth_and_original_normals(
check_cancelled=lambda: None,
)
with (
patch.object(artifacts.moge2, "infer", return_value=[frame]),
patch.object(artifacts.moge, "infer", return_value=[frame]),
patch.object(
artifacts, "write_depth_texture",
return_value=tmp_path / "depth.exr",
@@ -217,7 +217,7 @@ def test_artifacts_use_corrected_depth_and_original_normals(
) as normal,
patch.object(artifacts, "extend_edge_depth", wraps=extend_edge_depth) as repair,
):
artifacts.generate_moge2_artifacts(
artifacts.generate_moge_artifacts(
context, {}, path, generate_depth=generate_depth, normal_mode=normal_mode,
)
if normal_mode != "NONE":

View File

@@ -19,7 +19,7 @@ class GeometryArtifactsTest(ServerTestCase):
validity=validity.copy(), intrinsics=np.eye(3, dtype=np.float32),
)
output = self.storage_root / "depth.exr"
with patch.object(depth, "_write_float_exr") as write:
with patch.object(depth, "write_float_exr") as write:
depth.write_depth_texture(frame, output, alpha=alpha)
packed = write.call_args.args[0]
np.testing.assert_allclose(packed[..., 3], [[0.6, 0, 0, 1]])
@@ -246,10 +246,10 @@ class GeometryArtifactsTest(ServerTestCase):
texture = importlib.import_module("server.geometry.depth_texture")
with (
patch.object(artifacts.moge2, "infer", return_value=[frame]) as infer,
patch.object(texture, "_write_float_exr", wraps=texture._write_float_exr) as write,
patch.object(artifacts.moge, "infer", return_value=[frame]) as infer,
patch.object(texture, "write_float_exr", wraps=texture.write_float_exr) as write,
):
result = artifacts.generate_moge2_artifacts(
result = artifacts.generate_moge_artifacts(
context,
{"max_input_size": 2048},
input_path,
@@ -284,7 +284,7 @@ class GeometryArtifactsTest(ServerTestCase):
normal = context.directory / "object-normal.png"
with patch.object(
job,
"generate_moge2_artifacts",
"generate_moge_artifacts",
return_value={
"depth": depth_texture,
"depth_metadata": metadata,

View File

@@ -0,0 +1,93 @@
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from PIL import Image
from tests.anyimage.server.support import load_server_module
@pytest.fixture
def media():
return load_server_module("media.input")
@pytest.mark.parametrize("suffix", [".PNG", ".MP4", ".webp", ".mts"])
def test_supported_format_is_case_insensitive(media, tmp_path, suffix):
path = tmp_path / f"input{suffix}"
path.touch()
assert media.validate_input_path(path) == path
@pytest.mark.parametrize("suffix", [".tiff", ""])
def test_frontend_and_backend_share_format_reason(media, tmp_path, suffix):
path = tmp_path / f"input{suffix}"
path.touch()
with pytest.raises(RuntimeError) as preflight:
media.validate_input_path(path)
with pytest.raises(RuntimeError) as backend:
media.prepare_input(path, 16, tmp_path)
assert str(preflight.value) == str(backend.value)
assert f"Unsupported input format: {suffix or '(no extension)'}" in str(backend.value)
assert "Supported images: .jpeg, .jpg, .png, .webp." in str(backend.value)
assert "Supported videos:" in str(backend.value)
@pytest.mark.parametrize("suffix,kind", [(".png", "image"), (".mp4", "video")])
def test_missing_path_precedes_format_validation(media, tmp_path, suffix, kind):
with pytest.raises(RuntimeError, match=f"Source {kind} file does not exist"):
media.validate_input_path(tmp_path / f"missing{suffix}")
def test_directory_filtering_and_empty_reason(media, tmp_path):
(tmp_path / "note.txt").touch()
(tmp_path / "frame.exr").touch()
with pytest.raises(RuntimeError, match="No supported image frames found"):
media.validate_input_path(tmp_path)
for name in ["b.PNG", "a.jpg"]:
(tmp_path / name).touch()
frames, temporary = media.prepare_input(tmp_path, 1, tmp_path, start_frame=2)
assert frames == [tmp_path / "b.PNG"]
assert temporary is None
def test_image_subset_rejects_video(media, tmp_path):
path = tmp_path / "clip.mp4"
path.touch()
with pytest.raises(RuntimeError) as error:
media.validate_input_path(path, images_only=True)
assert "Supported images:" in str(error.value)
assert "Supported videos:" not in str(error.value)
def test_image_decode_error_keeps_cause(media, tmp_path):
path = tmp_path / "broken.png"
path.write_bytes(b"broken")
with pytest.raises(RuntimeError, match="Unable to read image") as error:
with media.open_image(path):
pytest.fail("Broken image must not reach processing")
assert isinstance(error.value.__cause__, OSError)
def test_processing_error_is_not_relabelled(media, tmp_path):
path = tmp_path / "valid.png"
Image.new("RGB", (2, 2)).save(path)
failure = OSError("inference failed")
with pytest.raises(OSError) as error:
with media.open_image(path):
raise failure
assert error.value is failure
def test_video_decode_error_closes_container(media, tmp_path):
failure = ValueError("broken packet")
def frames():
raise failure
yield
container = SimpleNamespace(streams=SimpleNamespace(video=[object()]),
decode=lambda _stream: frames(), close=Mock())
with pytest.raises(RuntimeError, match="Unable to read video") as error:
media.extract_video_frames("broken.mp4", tmp_path, 2,
SimpleNamespace(open=lambda _path: container))
assert error.value.__cause__ is failure
container.close.assert_called_once()

View File

@@ -165,6 +165,7 @@ class JobServerTest(ServerTestCase):
"remove-background",
"upscale-image",
"generate-depth-plane-geometry",
"generate-panorama-geometry",
"generate-cutout-artifacts",
},
)

View File

@@ -103,22 +103,6 @@ class MediaInputTest(unittest.TestCase):
self.assertIsNone(temporary_directory)
def test_copy_color_images_numbers_a_still_image(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "source.png"
source.write_bytes(b"frame")
output = root / "result"
output.mkdir()
self.media.copy_color_images([source], output)
self.assertEqual(
(output / "image.0001.png").read_bytes(),
b"frame",
)
def test_extract_video_frames_reads_frames_sequentially(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
@@ -179,5 +163,5 @@ class MediaInputTest(unittest.TestCase):
input_path = Path(directory) / "input.txt"
input_path.touch()
with self.assertRaisesRegex(RuntimeError, "Unsupported input type"):
with self.assertRaisesRegex(RuntimeError, "Unsupported input format: .txt"):
self.media.prepare_input(input_path, 16, directory)

View File

@@ -36,13 +36,13 @@ class Ben2OnnxTest(unittest.TestCase):
names, feeds
),
) as run:
result = onnx_ben2.infer(
result = onnx_ben2.infer_alpha(
session,
Image.new("RGB", (40, 20)),
release_memory=False,
)
self.assertEqual(result.size, (40, 20))
self.assertEqual(result.shape, (20, 40))
self.assertFalse(run.call_args.kwargs["release_memory"])
def test_preprocess_matches_model_layout(self):
@@ -57,13 +57,18 @@ class Ben2OnnxTest(unittest.TestCase):
session = FakeSession()
image = Image.new("RGB", (40, 20), (10, 20, 30))
result = onnx_ben2.infer(session, image)
result = onnx_ben2.infer_alpha(session, image)
self.assertEqual(result.mode, "RGBA")
self.assertEqual(result.size, image.size)
self.assertEqual(result.dtype, np.float32)
self.assertEqual(result.shape, (20, 40))
self.assertEqual(session.feeds["input"].shape, (1, 3, 1024, 1024))
alpha = np.asarray(result.getchannel("A"))
self.assertLess(int(alpha[:, 0].max()), int(alpha[:, -1].min()))
self.assertLess(result[:, 0].max(), result[:, -1].min())
self.assertTrue(np.any(np.abs(result * 255 - np.rint(result * 255)) > 0.01))
def test_infer_rejects_nonfinite_mask(self):
with patch.object(onnx_ben2, "run_session", return_value=[np.full((1, 1, 2, 2), np.nan)]):
with self.assertRaisesRegex(RuntimeError, "invalid alpha"):
onnx_ben2.infer_alpha(FakeSession(), Image.new("RGB", (2, 2)))
if __name__ == "__main__":

View File

@@ -2,7 +2,7 @@ from unittest.mock import patch
import numpy as np
from anyimage.server.models import moge2 as moge2_model
from anyimage.server.models import moge as moge2_model
from anyimage.server.models.geometry import GeometryFrame

View File

@@ -12,7 +12,7 @@ from tests.anyimage.server.support import load_server_module, FakeModels
class ModelInferenceTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.moge2_model = load_server_module("models.moge2")
cls.moge2_model = load_server_module("models.moge")
cls.upscale_model = load_server_module("models.upscale")

View File

@@ -83,7 +83,7 @@ class ModelManagerTest(ServerTestCase):
fake_moge = ModuleType("server.models.onnx_moge2")
fake_moge.create_session = lambda _model_dir, _device: new_moge
with patch.dict(sys.modules, {"server.models.onnx_moge2": fake_moge}):
self.assertIs(cache.get_moge2("new-moge", "cuda"), new_moge)
self.assertIs(cache.get_moge("new-moge", "cuda"), new_moge)
self.assertIs(cache.ben2_model, ben2)
self.assertIs(cache.upscale_model, upscale)
@@ -167,10 +167,10 @@ class ModelManagerTest(ServerTestCase):
"release_ben2",
)
first_moge2 = cache.get_moge2("moge2", "cuda")
first_moge2 = cache.get_moge("moge2", "cuda")
assert_reused(
first_moge2,
lambda: cache.get_moge2("moge2", "cuda"),
lambda: cache.get_moge("moge2", "cuda"),
"release_geometry",
)

View File

@@ -0,0 +1,139 @@
from dataclasses import replace
from unittest.mock import patch
import numpy as np
import pytest
from server.models import moge, onnx_moge3
from server import model_catalog, model_manager
def test_sparse_neighbors_and_pooling_follow_voxel_coordinates():
coord = np.zeros((1, 16, 32, 3), dtype=np.float32)
inputs = onnx_moge3.build_sparse_inputs(coord)
neighbors = inputs["neighbor_0"]
assert np.array_equal(neighbors[:, 13], np.arange(512))
assert neighbors[0, 10] == 512 # Missing pixel at x=-1.
assert neighbors[0, 16] == 1
for level in range(4):
children = inputs[f"pool_{level}"]
parent = inputs[f"up_{level}"]
for row, values in enumerate(children):
valid = values < len(parent)
assert np.all(parent[values[valid]] == row)
shifted = coord.copy()
shifted[..., 2] += 1
for key, values in inputs.items():
assert np.array_equal(values, onnx_moge3.build_sparse_inputs(shifted)[key])
def test_inference_runs_three_refinements_and_preserves_image_dimensions():
calls = []
coord = np.zeros((1, 16, 16, 3), dtype=np.float32)
backbone = object()
refiner = object()
def run(session, names, inputs, *, release_memory):
calls.append((session, inputs.copy(), release_memory))
if session is backbone:
return [
coord,
np.zeros((1, 1026, 1, 1), np.float32),
np.ones((1, 3, 16, 16), np.float32),
np.ones((1, 1, 16, 16), np.float32),
np.zeros((1, 1), np.float32),
]
assert "neighbor_4" in inputs
return [np.full((1, 16, 16), 0.1, np.float32)]
with (
patch.object(onnx_moge3, "run_session", side_effect=run),
patch.object(
onnx_moge3,
"postprocess",
side_effect=lambda value, **kwargs: (value, kwargs),
),
):
output, options = moge.infer_image(
onnx_moge3.Moge3Session(backbone, refiner),
np.zeros((7, 11, 3), np.float32),
9,
fov_x=90,
)
assert len(calls) == 4
assert calls[0][1]["num_tokens"] == 3600
assert [call[2] for call in calls[1:]] == [False, False, True]
assert output["points"].shape == (1, 7, 11, 3)
assert np.allclose(output["points"][..., 2], np.exp(0.3))
assert options["fov_x"] == 90
def test_bilinear_resize_preserves_edges_and_pixel_centers():
image = np.array([[0, 2], [4, 6]], np.float32)
assert np.array_equal(onnx_moge3._resize(image, 2, 2), image)
assert onnx_moge3._resize(image, 1, 1)[0, 0] == 3
def test_moge3_requires_all_verified_model_files(tmp_path):
model = replace(
model_catalog.MOGE3_VITL,
r2_files=(
(
"backbone.onnx",
1,
"ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb",
),
(
"refiner.onnx",
1,
"3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d",
),
),
)
manager = model_manager.ModelManager(tmp_path)
directory = manager.directory("MOGE3_VITL")
directory.mkdir()
with patch.object(
model_manager.model_catalog, "get_downloadable_model", return_value=model
):
(directory / "backbone.onnx").write_bytes(b"a")
assert not manager.ready("MOGE3_VITL")
(directory / "refiner.onnx").write_bytes(b"b")
assert manager.ready("MOGE3_VITL")
(directory / "refiner.onnx").write_bytes(b"c")
assert not manager.ready("MOGE3_VITL")
def test_cache_reuses_moge3_and_releases_it_when_switching():
cache = model_manager.ModelSessionCache()
session = onnx_moge3.Moge3Session(object(), object())
with patch.object(onnx_moge3, "create_session", return_value=session) as load:
assert cache.get_moge("moge-3-vitl-onnx", "cpu") is session
assert cache.get_moge("moge-3-vitl-onnx", "cpu") is session
load.assert_called_once()
from server.models import onnx_moge2
with patch.object(onnx_moge2, "create_session", return_value=object()):
assert cache.get_moge("moge-2-vits-normal-onnx", "cpu") is not session
cache.close()
assert cache.moge_model is None
def test_moge3_catalog_uses_onnx_assets_and_geometry_family():
model = model_catalog.get_downloadable_model("MOGE3_VITL")
assert model.ready_patterns == ("backbone.onnx", "refiner.onnx")
assert model_catalog.model_record(model)["family"] == "moge"
assert model.huggingface_repository == ""
def test_cache_recovers_after_moge3_session_load_failure():
cache = model_manager.ModelSessionCache()
session = onnx_moge3.Moge3Session(object(), object())
with patch.object(
onnx_moge3, "create_session", side_effect=[RuntimeError("load failed"), session]
):
with pytest.raises(RuntimeError, match="load failed"):
cache.get_moge("moge-3-vitl-onnx", "cpu")
assert cache.moge_model is None
assert cache.moge_key is None
assert cache.get_moge("moge-3-vitl-onnx", "cpu") is session

View File

@@ -0,0 +1,133 @@
"""Analytic projection, production inference and artifact lifecycle checks."""
import json
from types import SimpleNamespace
import numpy as np
import pytest
from PIL import Image
from tests.anyimage.server.support import FakeJobContext, load_server_module
geometry = load_server_module("geometry.panorama")
onnx = load_server_module("models.onnx_moge2")
job = load_server_module("jobs.panorama")
def test_known_fov_recovers_shift_metric_and_excludes_invisible_points():
rays = geometry.view_rays(64)
depth = 2 + 0.3 * rays[..., 0]
points = rays * depth[..., None]
points[..., 2] -= 0.7
visible = np.ones((64, 64), bool)
visible[:8] = False
points[:8] = np.nan
raw = {"points": points[None], "normal": np.zeros_like(points)[None],
"mask": np.ones((1, 64, 64)), "metric_scale": [1.5]}
prediction = onnx.postprocess(raw, fov_x=90, source_valid=visible)
np.testing.assert_allclose(prediction["depth"][visible], depth[visible] * 1.5, rtol=1e-5)
assert not prediction["mask"][:8].any()
assert np.isfinite(prediction["points"]).all()
def test_periodic_fusion_removes_view_scale_offsets():
bases = geometry.camera_bases()
distances = []
for scale, basis in zip(np.exp(np.linspace(-0.4, 0.4, 12)), bases):
rays = geometry.view_rays(128) @ basis
rays /= np.linalg.norm(rays, axis=-1, keepdims=True)
distances.append((3 + 0.4 * rays[..., 0] + 0.2 * rays[..., 2]) * scale)
merged, valid = geometry.merge_distances(distances, np.ones((12, 128, 128), bool), bases, 64, 32)
rays = geometry.panorama_directions(64, 32)
expected = 3 + 0.4 * rays[..., 0] + 0.2 * rays[..., 2]
merged *= np.median(expected / merged)
assert valid.all()
assert np.mean(abs(merged / expected - 1)) < 0.002
assert np.max(abs(merged[:, [0, -1]] / expected[:, [0, -1]] - 1)) < 0.005
sampled = geometry.sample_panorama(expected, np.array(((1, 1e-8, 0), (1, -1e-8, 0))))
np.testing.assert_allclose(sampled[0], sampled[1], atol=1e-6)
@pytest.fixture
def panorama_job(tmp_path, monkeypatch):
source = tmp_path / "source.png"
context = FakeJobContext(tmp_path / "result", "generate-panorama-geometry",
SimpleNamespace(directory=lambda _: tmp_path, get_moge=lambda *_: object()))
calls, pixels = [], []
def infer(session, color, level, **options):
calls.append((color, level, options))
rays = geometry.view_rays(color.shape[0])
points = rays / np.linalg.norm(rays, axis=-1, keepdims=True) * 3
return {"points": points, "mask": np.ones(color.shape[:2])}
monkeypatch.setattr(job, "infer", infer)
write = job.write_float_exr
monkeypatch.setattr(job, "write_float_exr", lambda rgba, path: (pixels.append(rgba.copy()), write(rgba, path))[1])
parameters = {"input": str(source), "model": "MOGE2", "device": "cpu", "resolution_level": 3,
"max_input_size": 32, "video_frames": 1, "start_frame": 1}
return source, context, parameters, calls, pixels
def test_job_outputs_geometry_combines_alpha_once_and_skips_transparent_views(panorama_job):
source, context, parameters, calls, pixels = panorama_job
image = np.full((32, 64, 4), 255, np.uint8)
image[:24, :, 3] = 0
image[26:28, :, 3] = 128
Image.fromarray(image).save(source)
result = job.run(context, parameters)
assert 0 < len(calls) < 12
assert all(level == 3 and opts["fov_x"] == 90 for _, level, opts in calls)
assert all(np.all(color[~opts["source_valid"]] == 0) for color, _, opts in calls)
assert {path.name for path in context.directory.iterdir()} == {"depth.exr", "depth.json"}
alpha = pixels[0][..., 3]
assert pixels[0].shape == image.shape
assert not alpha[:24].any()
assert np.all(alpha <= image[..., 3] / 255 + 1e-7)
assert np.any(np.isclose(alpha, 128 / 255))
np.testing.assert_allclose(pixels[0][..., 0], pixels[0][..., 2])
metadata = json.loads((context.directory / result["depth_metadata"]).read_text())
assert metadata["projection"] == "equirectangular"
assert metadata["image_size"] == [64, 32]
assert metadata["reference_distance"] == pytest.approx(3, rel=1e-5)
def test_full_resolution_fusion_preserves_detail_and_periodic_alignment():
bases = geometry.camera_bases()
distances = []
def radius(rays):
angle = np.arctan2(rays[..., 1], rays[..., 0])
return 3 + 0.15 * np.sin(80 * angle) * (1 - rays[..., 2] ** 2) ** 4
for scale, basis in zip(np.exp(np.linspace(-0.2, 0.2, 12)), bases):
rays = geometry.view_rays(512) @ basis
rays /= np.linalg.norm(rays, axis=-1, keepdims=True)
distances.append(radius(rays) * scale)
merged, valid = geometry.merge_distances(distances, np.ones((12, 512, 512), bool),
bases, 1024, 512)
expected = radius(geometry.panorama_directions(1024, 512))
merged *= np.median(expected / merged)
assert merged.shape == (512, 1024) and valid.all()
assert np.sqrt(np.mean((merged - expected) ** 2)) < 0.001
assert np.max(abs(merged[:, [0, -1]] / expected[:, [0, -1]] - 1)) < 0.01
@pytest.mark.parametrize("kind", ["transparent", "invalid_model", "wrong_ratio", "cancel"])
def test_failed_jobs_leave_no_partial_artifacts(panorama_job, monkeypatch, kind):
source, context, parameters, calls, _ = panorama_job
Image.new("RGBA", (64 if kind != "wrong_ratio" else 32, 32),
(255, 255, 255, 0 if kind == "transparent" else 255)).save(source)
if kind == "invalid_model":
monkeypatch.setattr(job, "infer", lambda *a, **k: {"points": np.ones((32, 32, 3)), "mask": np.zeros((32, 32))})
if kind == "cancel":
count = [0]
def cancel():
count[0] += 1
if count[0] == 6:
raise RuntimeError("Cancelled")
context.check_cancelled = cancel
with pytest.raises((ValueError, RuntimeError)):
job.run(context, parameters)
assert not list(context.directory.glob("depth.*"))
assert not (context.directory / "image.0001.png").exists()
if kind == "transparent":
assert not calls

View File

@@ -54,10 +54,9 @@ class ServerJobsTest(unittest.TestCase):
"input_frames",
return_value=nullcontext([input_path]),
),
patch.object(self.depth_plane_job, "copy_color_images"),
patch.object(
self.depth_plane_job,
"generate_moge2_artifacts",
"generate_moge_artifacts",
return_value={
"depth": depth_texture,
"depth_metadata": metadata,
@@ -85,100 +84,23 @@ class ServerJobsTest(unittest.TestCase):
def test_ben2_background_removal_writes_fast_transparent_png(self):
inference_options = []
save_options = []
class FakeImage:
size = (1, 1)
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def convert(self, mode):
self.mode = mode
return self
def getchannel(self, _name):
return self
def putalpha(self, _alpha):
return None
def paste(self, *_args):
return None
def save(self, path, **options):
save_options.append(options)
Path(path).touch()
class FakeModel:
@classmethod
def from_pretrained(cls, model_dir):
self.assertEqual(model_dir, "model")
return cls()
def to(model_self, device):
self.assertEqual(device.type, "cpu")
return model_self
def eval(self):
return None
def inference(self, image, **options):
inference_options.append((image, options))
return FakeImage()
fake_torch = ModuleType("torch")
fake_torch.cuda = SimpleNamespace(
is_available=lambda: False,
synchronize=lambda: None,
)
fake_torch.device = lambda device_type: SimpleNamespace(type=device_type)
fake_torch.inference_mode = nullcontext
fake_ben2 = ModuleType("server.models.onnx_ben2")
fake_ben2.create_session = lambda _model_dir, _device: object()
fake_ben2.infer = lambda _model, image, **options: (
inference_options.append((image, options)) or FakeImage()
)
fake_pil = ModuleType("PIL")
fake_pil.Image = SimpleNamespace(
open=lambda _path: FakeImage(),
new=lambda *_args, **_kwargs: FakeImage(),
)
fake_pil.ImageChops = SimpleNamespace(
multiply=lambda _source, _foreground: FakeImage()
)
with tempfile.TemporaryDirectory() as directory, patch.dict(
sys.modules,
{
"torch": fake_torch,
"server.models.onnx_ben2": fake_ben2,
"PIL": fake_pil,
},
):
from PIL import Image
onnx_ben2 = load_server_module("models.onnx_ben2")
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
input_path = root / "input.png"
input_path.touch()
Image.new("RGBA", (2, 1), (255, 64, 32, 128)).save(input_path)
context = FakeJobContext(root / "result", "remove-background")
self.background.run(
context,
{"input": str(input_path), "model": "BEN2", "device": "cpu"},
)
self.assertTrue((root / "result" / "image.0001.png").is_file())
self.assertEqual(
inference_options[0][1],
{"release_memory": True},
)
self.assertEqual(
save_options[0],
{"format": "PNG", "compress_level": 1},
)
with (
patch.object(onnx_ben2, "create_session", return_value=object()),
patch.object(onnx_ben2, "infer_alpha", return_value=np.full((1, 2), 128 / 255, np.float32)) as infer,
patch.object(Image.Image, "save", autospec=True, side_effect=Image.Image.save) as save,
):
self.background.run(context, {"input": str(input_path), "model": "BEN2", "device": "cpu"})
with Image.open(root / "result" / "image.0001.png") as image:
self.assertEqual(image.getpixel((0, 0)), (255, 64, 32, 64))
self.assertEqual(infer.call_args.kwargs, {"release_memory": True})
self.assertEqual(save.call_args.kwargs, {"format": "PNG", "compress_level": 1})
self.assertIn("Background removed in", context.status["message"])
@@ -190,7 +112,7 @@ class ServerJobsTest(unittest.TestCase):
(((255, 0, 0, 0), (0, 255, 0, 128)),),
dtype=np.uint8,
)
model_result = Image.new("RGBA", (2, 1), (10, 20, 30, 128))
model_result = np.full((1, 2), 128 / 255, np.float32)
received = []
def infer(_model, image, **_options):
@@ -202,7 +124,7 @@ class ServerJobsTest(unittest.TestCase):
Image.fromarray(source_pixels, mode="RGBA").save(input_path)
with (
patch.object(onnx_ben2, "create_session", return_value=object()),
patch.object(onnx_ben2, "infer", side_effect=infer),
patch.object(onnx_ben2, "infer_alpha", side_effect=infer),
):
result, _load_ms, _inference_ms = self.ben2.infer_foreground(
input_path,
@@ -226,111 +148,48 @@ class ServerJobsTest(unittest.TestCase):
self.assertEqual(tuple(result_pixels[0, 1]), (0, 255, 0, 64))
self.assertEqual(
tuple(np.asarray(raw_result.convert("RGBA"))[0, 0]),
(10, 20, 30, 128),
(0, 0, 0, 128),
)
def test_ben2_background_removal_processes_each_frame(self):
inference_options = []
class FakeImage:
size = (1, 1)
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def convert(self, mode):
self.mode = mode
return self
def getchannel(self, _name):
return self
def putalpha(self, _alpha):
return None
def paste(self, *_args):
return None
def save(self, path, **options):
Path(path).touch()
class FakeModel:
@classmethod
def from_pretrained(cls, model_dir):
return cls()
def to(model_self, device):
return model_self
def eval(self):
return None
def inference(self, image, **options):
inference_options.append((image, options))
return FakeImage()
fake_torch = ModuleType("torch")
fake_torch.cuda = SimpleNamespace(
is_available=lambda: False,
synchronize=lambda: None,
)
fake_torch.device = lambda device_type: SimpleNamespace(type=device_type)
fake_torch.inference_mode = nullcontext
fake_ben2 = ModuleType("server.models.onnx_ben2")
fake_ben2.create_session = lambda _model_dir, _device: object()
fake_ben2.infer = lambda _model, image, **options: (
inference_options.append((image, options)) or FakeImage()
)
fake_pil = ModuleType("PIL")
fake_pil.Image = SimpleNamespace(
open=lambda _path: FakeImage(),
new=lambda *_args, **_kwargs: FakeImage(),
)
fake_pil.ImageChops = SimpleNamespace(
multiply=lambda _source, _foreground: FakeImage()
)
with tempfile.TemporaryDirectory() as directory, patch.dict(
sys.modules,
{
"torch": fake_torch,
"server.models.onnx_ben2": fake_ben2,
"PIL": fake_pil,
},
):
from PIL import Image
onnx_ben2 = load_server_module("models.onnx_ben2")
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
input_directory = root / "frames"
input_directory.mkdir()
(input_directory / "frame-0001.jpg").touch()
(input_directory / "frame-0002.jpg").touch()
frames = root / "frames"
frames.mkdir()
for index in (1, 2):
Image.new("RGB", (2, 1), (index * 50, 0, 0)).save(frames / f"frame.{index:04d}.png")
context = FakeJobContext(root / "result", "remove-background")
self.background.run(
context,
{
"input": str(input_directory),
"model": "BEN2",
"device": "cpu",
"video_frames": 8,
},
)
with (
patch.object(onnx_ben2, "create_session", return_value=object()),
patch.object(onnx_ben2, "infer_alpha", return_value=np.ones((1, 2), np.float32)) as infer,
):
self.background.run(context, {"input": str(frames), "model": "BEN2", "device": "cpu", "video_frames": 8})
self.assertEqual([call.kwargs["release_memory"] for call in infer.call_args_list], [False, True])
for index in (1, 2):
with Image.open(context.directory / f"image.{index:04d}.png") as image:
self.assertEqual(image.getpixel((0, 0)), (index * 50, 0, 0, 255))
result_directory = context.directory
self.assertTrue(
(result_directory / "image.0001.png").is_file()
)
self.assertTrue(
(result_directory / "image.0002.png").is_file()
)
self.assertEqual(len(inference_options), 2)
self.assertEqual(
[options["release_memory"] for _image, options in inference_options],
[False, True],
)
def test_background_alpha_job_preserves_float_precision(self):
from PIL import Image
onnx_ben2 = load_server_module("models.onnx_ben2")
alpha = np.array([[0.123456, 0.987654]], dtype=np.float32)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
path = root / "input.png"
Image.new("RGB", (2, 1)).save(path)
context = FakeJobContext(root / "result", "remove-background")
with (
patch.object(onnx_ben2, "create_session", return_value=object()),
patch.object(onnx_ben2, "infer_alpha", return_value=alpha),
):
result = self.background.run(context, {"input": str(path), "model": "BEN2", "device": "cpu", "output_kind": "alpha"})
self.assertEqual(result, {"alpha": "alpha.npy"})
np.testing.assert_array_equal(np.load(context.directory / result["alpha"], allow_pickle=False), alpha)
self.assertFalse(list(context.directory.glob("*.png")))
def test_upscale_job_saves_importable_upscale_sequence(self):

View File

@@ -18,6 +18,16 @@ class AddonRegistrationTest(BlenderTestCase):
def test_semantic_mesh_operators_are_registered_without_dialog_options(self):
from anyimage.operators.convert_to_panorama import ConvertToPanorama, GeneratePanorama
self.assertIn(ConvertToPanorama, self.anyimage.CLASSES)
self.assertIn(GeneratePanorama, self.anyimage.CLASSES)
for generator in (GeneratePanorama, self.convert_to_plane.GenerateDepthPlane):
self.assertIn(generator, self.anyimage.CLASSES)
for name in ("source_object_name", "source_identity", "image_identity"):
self.assertIn(name, generator.__annotations__)
self.assertIn("HIDDEN", generator.__annotations__[name]["options"])
self.assertLess(self.anyimage.CLASSES.index(GeneratePanorama), self.anyimage.CLASSES.index(ConvertToPanorama))
operator = self.convert_to_plane.ConvertToPlane
self.assertIn(operator, self.anyimage.CLASSES)
self.assertIn(self.convert_to_plane.ConvertToDepthPlane, self.anyimage.CLASSES)

View File

@@ -46,6 +46,7 @@ class ImageMenuTest(BlenderTestCase):
("anyimage.convert_to_plane", True),
("anyimage.convert_to_depth_plane", True),
("anyimage.convert_to_relief_plane", True),
("anyimage.convert_to_panorama", True),
("separator", True),
("anyimage.remove_image_background", True),
("anyimage.upscale_image", True),

View File

@@ -114,7 +114,7 @@ class PreferencesTest(BlenderTestCase):
def test_geometry_model_choices_match_moge_catalog(self):
properties = self.anyimage.properties
expected = tuple(properties.shared_model_catalog.MOGE2_MODELS)
expected = tuple(properties.shared_model_catalog.MOGE_MODELS)
geometry = properties.geometry_model_enum_items(None, None)
self.assertEqual(tuple(item[0] for item in geometry), expected)
@@ -222,7 +222,7 @@ class PreferencesTest(BlenderTestCase):
"ready": False,
}
for key, family in (
("MOGE2_VITS_NORMAL", "moge2"),
("MOGE2_VITS_NORMAL", "moge"),
("BEN2_BASE", "ben2"),
("REALESRGAN_GENERAL_WDN_X4V3", "upscale"),
("SAFMN_REAL_X4", "upscale"),
@@ -418,8 +418,8 @@ class PreferencesTest(BlenderTestCase):
with (
patch.object(self.convert_to_plane, "require_environment"),
patch.object(self.convert_to_plane, "require_model"),
patch.object(self.convert_to_plane, "require_input_path", return_value=Path("input.png")),
patch.object(self.convert_to_plane, "production_device", return_value="CPU"),
patch.object(self.convert_to_plane, "source_start_frame", return_value=1),
):
parameters = operator.request(context)

View File

@@ -0,0 +1,11 @@
"""Prepare a standalone color image for material and geometry tests."""
from anyimage.common.color_image import prepare_material_color_input, load_material_color_image, cleanup_material_color_input
def color_image(source, bounds=None, rgba=None):
path = prepare_material_color_input(source, bounds, rgba)
try:
return load_material_color_image(path)
finally:
cleanup_material_color_input(path)

View File

@@ -0,0 +1,15 @@
import bpy
import numpy as np
import pytest
@pytest.fixture
def hdr_texture(texture):
image = bpy.data.images.new("HDR source", width=4, height=3, alpha=True, float_buffer=True)
image.alpha_mode = "PREMUL"
rgba = np.tile([4.0, 2.0, -0.25, 0.5], (3, 4, 1)).astype(np.float32)
rgba[0, 0] = [9, 3, -0.5, 0]
rgba[2, 3] = [25, 12, 6, 1]
image.pixels.foreach_set(np.flipud(rgba).ravel())
texture.image = image
return texture

View File

@@ -7,6 +7,7 @@ from anyimage.common import object as object_data
from anyimage.common.selection import SelectionMask
from anyimage.operators.cutout_tool import geometry as cutout_geometry
from anyimage.operators.cutout_tool import object as cutout_object
from tests.support.color_image import color_image as prepared_color_image
def _inputs(group):
@@ -43,8 +44,8 @@ def _create_cutout_shape(
selection_values = np.ones((bottom - top, right - left), dtype=np.float32)
selection_mask = SelectionMask(selection_values, bounds)
source_rgba = image_data.image_rgba(source.data)
color_image = cutout_object.create_cropped_color_image(
source,
color_image = prepared_color_image(
source.data,
selection_mask.bounds,
source_rgba,
)

View File

@@ -197,7 +197,7 @@ def test_surface_interface_and_node_structure(surface):
assert inputs["Split Threshold"].subtype == "FACTOR"
assert inputs["Split Threshold"].parent == inputs["Smooth"].parent
assert inputs["Split Threshold"].parent.name != "Options"
assert inputs["Validity Threshold"].default_value == pytest.approx(0.9)
assert inputs["Validity Threshold"].default_value == pytest.approx(0.1)
assert inputs["Validity Threshold"].parent.name == "Options"
assert inputs["Validity Threshold"].subtype == "FACTOR"
assert inputs["Valid Only"].default_value is True
@@ -234,7 +234,7 @@ def test_relief_interface_and_node_structure(relief):
@pytest.mark.parametrize("threshold", [0.0, 0.4, 0.9, 1.0])
@pytest.mark.parametrize("pattern", ["valid", "invalid", "partial", "half", "stripe"])
def test_valid_only_recomputes_per_point_and_preserves_boundary_faces(surface, pattern, threshold):
def test_valid_only_samples_face_centers(surface, pattern, threshold):
obj, set_value, _, inputs, _ = surface
set_value("Validity Threshold", threshold)
depth_image = obj.modifiers[0][inputs["Depth Image"].identifier]
@@ -256,17 +256,15 @@ def test_valid_only_recomputes_per_point_and_preserves_boundary_faces(surface, p
set_value("Subdivide", subdivisions)
set_value("Valid Only", False)
base = evaluated(obj)
u = (base[0][:, 0] + 2) / 4
u = np.array([(base[0][list(face), 0].mean() + 2) / 4 for face in base[1]])
sampled = np.interp(u * 16 - 0.5, np.arange(16), alpha)
expected = [face for face in base[1] if any(sampled[index] >= threshold for index in face)]
expected = [face for face, alpha_value in zip(base[1], sampled) if alpha_value >= threshold]
set_value("Valid Only", True)
culled = evaluated(obj)
assert len(culled[1]) == len(expected)
expected_points = {tuple(base[0][index]) for face in expected for index in face}
assert {tuple(point) for point in culled[0]} == expected_points
assert np.isfinite(culled[2]).all()
if pattern == "half" and subdivisions >= 2 and threshold == 1.0:
assert 0 <= min(point[0] for point in culled[0]) < 0.125
set_value("Valid Only", False)
np.testing.assert_allclose(evaluated(obj)[0], base[0])
@@ -293,9 +291,7 @@ def test_validity_graph_matches_reference_domains_and_connections(surface):
group = obj.modifiers[0].node_group
delete = next(node for node in group.nodes if node.bl_idname == "GeometryNodeDeleteGeometry")
assert (delete.domain, delete.mode) == ("FACE", "ALL")
points = origin_socket(delete.inputs["Selection"].links[0].from_socket).node
assert (points.bl_idname, points.data_type, points.domain) == ("GeometryNodeFieldOnDomain", "BOOLEAN", "POINT")
compare = origin_socket(points.inputs["Value"].links[0].from_socket).node
compare = origin_socket(delete.inputs["Selection"].links[0].from_socket).node
assert (compare.bl_idname, compare.data_type, compare.operation) == ("FunctionNodeCompare", "FLOAT", "LESS_THAN")
assert origin_socket(compare.inputs["B"].links[0].from_socket).name == "Validity Threshold"
alpha = origin_socket(compare.inputs["A"].links[0].from_socket)

View File

@@ -0,0 +1,259 @@
"""Evaluate panorama topology, validity, UVs and depth discontinuities."""
from collections import Counter
import bpy
import numpy as np
import pytest
from anyimage.common.object import modifier_input_identifier, set_modifier_input
from tools.nodes.groups.image_depth_panorama import build_image_depth_panorama_group
from tests.support.nodes import validate_node_structure
def panorama(depth=3, alpha=1, subdivide=3, reference=3):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = build_image_depth_panorama_group()
assert group.name == "O Image Depth Panorama"
validate_node_structure(group)
assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes)
image = bpy.data.images.new("Radial Depth", width=128, height=64, float_buffer=True)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
rgba = np.ones((64, 128, 4), np.float32)
rgba[..., :3] = np.broadcast_to(depth, (64, 128))[..., None]
rgba[..., 3] = alpha
image.pixels.foreach_set(rgba.ravel())
obj = bpy.data.objects.new("Panorama", bpy.data.meshes.new("Panorama"))
bpy.context.collection.objects.link(obj)
modifier = obj.modifiers.new("Panorama", "NODES")
modifier.node_group = group
def set_value(name, value):
set_modifier_input(modifier, modifier_input_identifier(group, name), value)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
for name, value in (("Depth Image", image), ("Subdivide", subdivide), ("Reference Distance", reference)):
set_value(name, value)
return obj, set_value
def evaluated(obj):
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
points = np.array([v.co[:] for v in mesh.vertices]).reshape(-1, 3)
faces = [tuple(f.vertices) for f in mesh.polygons]
uv = np.array([v.uv[:] for v in mesh.uv_layers.active.data]) if mesh.uv_layers.active else np.empty((0, 2))
assert not any(a.name.startswith(("o_depth_", "o_panorama_")) for a in mesh.attributes)
return points, faces, uv
finally:
result.to_mesh_clear()
@pytest.mark.parametrize("subdivide", [0, 2, 4])
def test_quad_sphere_topology_and_radial_endpoints(subdivide):
obj, set_value = panorama(subdivide=subdivide)
inputs = [s for s in obj.modifiers[0].node_group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"]
assert [s.name for s in inputs if s.socket_type == "NodeSocketImage"] == ["Depth Image"]
for scale, radius in ((0, 1), (0.5, 2), (1, 3)):
set_value("Depth Scale", scale)
points, faces, uv = evaluated(obj)
assert len(faces) == 6 * 4 ** subdivide
assert all(len(face) == 4 for face in faces)
np.testing.assert_allclose(np.linalg.norm(points, axis=1), radius, atol=1e-5)
edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0])))
assert set(edges.values()) == {2}
assert len(points) - len(edges) + len(faces) == 2
assert np.isfinite(uv).all()
if subdivide > 0:
assert np.max(np.ptp(uv.reshape(-1, 4, 2)[..., 0], axis=1)) <= 0.5 + 1e-5
def test_depth_alpha_culls_faces_without_double_multiplication():
obj, set_value = panorama(alpha=0.5)
assert not len(evaluated(obj)[0])
set_value("Valid Only", False)
assert len(evaluated(obj)[1]) == 384
set_value("Valid Only", True)
set_value("Validity Threshold", 0.49)
assert len(evaluated(obj)[1]) == 384
def test_invalid_depth_does_not_bleed_into_alpha_boundary():
alpha = np.ones((64, 128))
alpha[:, 64:] = 0
depth = np.where(alpha, 3, 10000)
obj, _ = panorama(depth, alpha, subdivide=5)
points, _, _ = evaluated(obj)
np.testing.assert_allclose(np.linalg.norm(points, axis=1), 3, atol=1e-5)
def test_linear_depth_reconstructs_flat_floor_without_sampling_rings():
z = -np.cos((np.arange(64) + 0.5) * np.pi / 64)
depth = np.broadcast_to((2 / np.maximum(-z, 0.1))[:, None], (64, 128))
obj, _ = panorama(depth, subdivide=6)
points, _, _ = evaluated(obj)
rays = points / np.linalg.norm(points, axis=1, keepdims=True)
floor = rays[:, 2] < -0.5
error = points[floor, 2] + 2
assert np.sqrt(np.mean(error ** 2)) < 0.002
assert np.max(abs(error)) < 0.004
@pytest.mark.parametrize("sky_alpha", [0, 1])
def test_invalid_distance_removes_faces_between_valid_and_invalid_regions(sky_alpha):
depth = np.full((64, 128), 3.0)
depth[:, 64:] = 1e-7
alpha = np.ones((64, 128))
alpha[:, 64:] = sky_alpha
obj, set_value = panorama(depth, alpha, subdivide=4)
points, original_faces, _ = evaluated(obj)
assert np.linalg.norm(points, axis=1).min() > 1
assert np.linalg.norm(points, axis=1).max() < 3.01
set_value("Invalid Distance", 250)
assert evaluated(obj)[1] == original_faces
set_value("Valid Only", False)
for background in (100, 250):
set_value("Invalid Distance", background)
points, faces, _ = evaluated(obj)
radii = np.linalg.norm(points, axis=1)
assert np.any(np.isclose(radii, background))
assert np.any(np.isclose(radii, 3))
assert len(original_faces) < len(faces) < 6 * 4 ** 4
assert all(np.all(radii[list(face)] > 10) or np.all(radii[list(face)] < 10) for face in faces)
set_value("Depth Scale", 0)
np.testing.assert_allclose(np.linalg.norm(evaluated(obj)[0], axis=1), 1, atol=1e-5)
def test_controls_smoothing_and_material_slot_inheritance():
obj, set_value = panorama()
group = obj.modifiers[0].node_group
inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"}
assert inputs["Validity Threshold"].parent.name == "Options"
assert inputs["Invalid Distance"].parent.name == "Options"
assert inputs["Invalid Distance"].default_value == 100
assert inputs["Invalid Distance"].subtype == "DISTANCE"
assert inputs["Smooth Weight"].parent.name == "Options"
assert inputs["Valid Only"].parent == inputs["Subdivide"].parent
assert inputs["Valid Only"].default_value
assert "Material" not in inputs
obj.data.from_pydata([(0, 0, 0)], [], [])
material = bpy.data.materials.new("Slot Material")
obj.data.materials.append(material)
set_value("Smooth", 0)
original = evaluated(obj)
set_value("Smooth", 2)
smoothed = evaluated(obj)
assert smoothed[1] == original[1]
assert np.max(abs(smoothed[0] - original[0])) > 1e-4
np.testing.assert_array_equal(smoothed[2], original[2])
set_value("Smooth Weight", 0)
np.testing.assert_allclose(evaluated(obj)[0], original[0], atol=1e-6)
for name in ("First Slot", "Replacement Slot"):
current = bpy.data.materials.new(name)
obj.data.materials[0] = current
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
assert mesh.materials[0].original == current
assert all(f.material_index == 0 and f.use_smooth for f in mesh.polygons)
finally:
result.to_mesh_clear()
def test_material_uv_matches_radial_sampling_and_faces_point_inward():
obj, _ = panorama(subdivide=4)
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
assert all(np.dot(face.normal, face.center) < 0 for face in mesh.polygons)
assert any(loop.uv.x < 0 for loop in mesh.uv_layers.active.data)
for loop in mesh.loops:
ray = np.array(mesh.vertices[loop.vertex_index].co)
ray /= np.linalg.norm(ray)
u, v = mesh.uv_layers.active.data[loop.index].uv
if abs(ray[2]) < 0.999999:
expected_u = np.arctan2(ray[1], ray[0]) / (2 * np.pi)
assert abs((u - expected_u + 0.5) % 1 - 0.5) < 1e-5
assert abs(v - (1 - np.arccos(np.clip(ray[2], -1, 1)) / np.pi)) < 1e-5
finally:
result.to_mesh_clear()
def test_split_strength_and_distance_scale_invariance():
depth = np.ones((64, 128))
depth[:, 64:] = 7
signatures = []
for scale in (0.01, 1, 100):
obj, set_value = panorama(depth * scale, reference=3 * scale)
counts = []
for split in (0, 0.1, 0.5, 1):
set_value("Split Threshold", split)
points, faces, uv = evaluated(obj)
assert np.isfinite(points).all() and np.isfinite(uv).all()
edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0])))
counts.append(sum(n == 1 for n in edges.values()))
assert counts[0] == 0 and counts[-1] > 0
assert counts == sorted(counts)
signatures.append(counts)
assert signatures[0] == signatures[1] == signatures[2]
def test_continuous_spherical_slope_does_not_split_at_seam_or_poles():
u, v = np.meshgrid((np.arange(128) + 0.5) / 128, (np.arange(64) + 0.5) / 64)
depth = 3 + 0.3 * np.cos(2 * np.pi * u) * np.sin(np.pi * v)
obj, set_value = panorama(depth)
original = evaluated(obj)
set_value("Split Threshold", 1)
points, faces, uv = evaluated(obj)
np.testing.assert_allclose(points, original[0], atol=1e-6)
assert faces == original[1]
np.testing.assert_allclose(uv, original[2])
def test_split_corners_keep_their_rays_and_own_face_distance():
from scipy.spatial import cKDTree
depth = np.ones((64, 128))
depth[:, 64:] = 7
obj, set_value = panorama(depth)
original, _, _ = evaluated(obj)
original_radii = np.linalg.norm(original, axis=1)
original_rays = original / original_radii[:, None]
set_value("Split Threshold", 1)
points, _, uv = evaluated(obj)
radii = np.linalg.norm(points, axis=1)
distance, indices = cKDTree(original_rays).query(points / radii[:, None])
assert distance.max() < 1e-5
counts = Counter(indices)
uncut = np.array([counts[index] == 1 for index in indices])
np.testing.assert_allclose(radii[uncut], original_radii[indices[uncut]], atol=1e-5)
assert any(np.ptp(radii[indices == index]) > 4 for index, count in counts.items() if count > 1)
assert np.max(np.ptp(uv.reshape(-1, 4, 2)[..., 0], axis=1)) <= 0.5 + 1e-5
def test_zero_split_bypasses_single_snapshot_strip_cleanup(monkeypatch):
from tools.nodes.common import depth_surface
depth = np.broadcast_to(1 + 6 * (np.arange(128) % 16 < 8), (64, 128))
with monkeypatch.context() as patch:
patch.setattr(depth_surface, "_remove_strip_faces", lambda group, geometry, **kw: geometry)
obj, set_value = panorama(depth)
set_value("Split Threshold", 1)
_, faces, _ = evaluated(obj)
edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0])))
strips = 0
for face in faces:
boundaries = [edges[tuple(sorted((a, b)))] == 1 for a, b in zip(face, (*face[1:], face[0]))]
strips += (boundaries[0] and boundaries[2]) or (boundaries[1] and boundaries[3])
assert strips > 0
obj, set_value = panorama(depth)
original_faces = evaluated(obj)[1]
set_value("Split Threshold", 1)
assert len(evaluated(obj)[1]) == len(faces) - strips
set_value("Split Threshold", 0)
assert evaluated(obj)[1] == original_faces

View File

@@ -18,6 +18,6 @@ def test_saved_asset_inventory():
if bpy.app.version >= (5, 0, 0):
assert node_group.library is not None
if name in {"O Image Depth Layer", "O Shadeless"}:
expected = ["Color", "Alpha", "Depth", "Bump Scale"] if name == "O Image Depth Layer" else ["Color"]
expected = ["Color", "Alpha", "Depth", "Bump Scale"] if name == "O Image Depth Layer" else ["Color", "Alpha"]
assert [item.name for item in node_group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"] == expected

View File

@@ -22,7 +22,7 @@ class NodeAssetsTest(unittest.TestCase):
for group in target.node_groups
if group.bl_idname == "GeometryNodeTree"
]
self.assertEqual(len(groups), 6)
self.assertEqual(len(groups), 7)
for group in groups:
validate_node_structure(group)
positions = {node.name: tuple(node.location) for node in group.nodes

View File

@@ -0,0 +1,52 @@
"""Render the bundled Shadeless group's opacity and HDR color endpoints."""
import bpy
import numpy as np
from anyimage.common.node import load_node_group
def test_shadeless_alpha_is_mixed_inside_the_group(tmp_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = load_node_group("O Shadeless")
inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"}
assert list(inputs) == ["Color", "Alpha"] and inputs["Alpha"].default_value == 1
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.device = "CPU"
scene.cycles.samples = 8
scene.cycles.use_denoising = False
scene.render.film_transparent = True
scene.render.resolution_x, scene.render.resolution_y = 96, 32
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "OPEN_EXR"
scene.render.image_settings.color_mode = "RGBA"
scene.render.image_settings.color_depth = "32"
scene.render.filepath = str(tmp_path / "shadeless.exr")
bpy.ops.object.camera_add(location=(0, 0, 5))
scene.camera = bpy.context.object
scene.camera.data.type = "ORTHO"
scene.camera.data.ortho_scale = 3
for index, alpha in enumerate((0, 0.5, 1)):
bpy.ops.mesh.primitive_plane_add(size=1, location=(index - 1, 0, 0))
material = bpy.data.materials.new(f"Alpha {alpha}")
material.use_nodes = True
material.node_tree.nodes.clear()
shader = material.node_tree.nodes.new("ShaderNodeGroup")
shader.node_tree = group
shader.inputs["Color"].default_value = (4, 2, 0.5, 1)
shader.inputs["Alpha"].default_value = alpha
output = material.node_tree.nodes.new("ShaderNodeOutputMaterial")
material.node_tree.links.new(shader.outputs[0], output.inputs["Surface"])
bpy.context.object.data.materials.append(material)
bpy.ops.render.render(write_still=True)
image = bpy.data.images.load(scene.render.filepath, check_existing=False)
try:
pixels = np.array(image.pixels[:]).reshape(32, 96, 4)
for index, alpha in enumerate((0, 0.5, 1)):
patch = pixels[8:24, index * 32 + 8:index * 32 + 24]
np.testing.assert_allclose(patch[..., 3].mean(), alpha, atol=0.035)
np.testing.assert_allclose(pixels[16, 80, :3], (4, 2, 0.5), atol=1e-4)
finally:
bpy.data.images.remove(image)
bpy.ops.wm.read_factory_settings(use_empty=True)

View File

@@ -101,6 +101,9 @@ class ExtensionPackagingTest(unittest.TestCase):
"operators/convert_to_plane/object.py",
"operators/convert_to_plane/operators.py",
"operators/convert_to_plane/image_plane.py",
"operators/convert_to_panorama/__init__.py",
"operators/convert_to_panorama/operators.py",
"operators/convert_to_panorama/object.py",
"operators/frame_tool.py",
"operators/mask_tool.py",
"operators/rectify_tool.py",
@@ -124,6 +127,8 @@ class ExtensionPackagingTest(unittest.TestCase):
self.assertIn("server/jobs/remove_background.py", names)
self.assertIn("server/jobs/cutout.py", names)
self.assertIn("server/jobs/depth_plane.py", names)
self.assertIn("server/jobs/panorama.py", names)
self.assertIn("server/geometry/panorama.py", names)
self.assertIn("server/geometry/depth_texture.py", names)
self.assertIn("server/geometry/prediction_artifacts.py", names)
self.assertIn("server/geometry/edge_depth.py", names)
@@ -138,7 +143,7 @@ class ExtensionPackagingTest(unittest.TestCase):
self.assertNotIn("server/geometry/cutout.py", names)
self.assertIn("server/models/ben2.py", names)
self.assertNotIn("server/models/da3.py", names)
self.assertIn("server/models/moge2.py", names)
self.assertIn("server/models/moge.py", names)
self.assertNotIn("server/jobs/debug_predictions.py", names)
self.assertNotIn("operators/debug.py", names)
self.assertFalse(any(name.startswith("server/outputs/") for name in names))
@@ -147,6 +152,7 @@ class ExtensionPackagingTest(unittest.TestCase):
self.assertIn("server/models/onnx_ben2.py", names)
self.assertNotIn("server/models/onnx_da3.py", names)
self.assertIn("server/models/onnx_moge2.py", names)
self.assertIn("server/models/onnx_moge3.py", names)
self.assertIn("server/models/onnx_upscale.py", names)
self.assertIn("server/models/upscale.py", names)
self.assertIn("server/__init__.py", names)