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:
@@ -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:
|
||||
|
||||
233
tests/anyimage/common/test_color_image.py
Normal file
233
tests/anyimage/common/test_color_image.py
Normal 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)
|
||||
189
tests/anyimage/common/test_hdr_image.py
Normal file
189
tests/anyimage/common/test_hdr_image.py
Normal 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)
|
||||
80
tests/anyimage/common/test_hdr_rgb_render.py
Normal file
80
tests/anyimage/common/test_hdr_rgb_render.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
54
tests/anyimage/common/test_input_preparation.py
Normal file
54
tests/anyimage/common/test_input_preparation.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
96
tests/anyimage/operators/test_ai_input_failures.py
Normal file
96
tests/anyimage/operators/test_ai_input_failures.py
Normal 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())
|
||||
113
tests/anyimage/operators/test_clipboard_cache.py
Normal file
113
tests/anyimage/operators/test_clipboard_cache.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
185
tests/anyimage/operators/test_conversion_source_images.py
Normal file
185
tests/anyimage/operators/test_conversion_source_images.py
Normal 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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
115
tests/anyimage/operators/test_hdr_background.py
Normal file
115
tests/anyimage/operators/test_hdr_background.py
Normal 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)
|
||||
77
tests/anyimage/operators/test_native_ai_failures.py
Normal file
77
tests/anyimage/operators/test_native_ai_failures.py
Normal 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)
|
||||
188
tests/anyimage/operators/test_panorama_conversion.py
Normal file
188
tests/anyimage/operators/test_panorama_conversion.py
Normal 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)
|
||||
@@ -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(
|
||||
|
||||
36
tests/anyimage/server/test_background_alpha.py
Normal file
36
tests/anyimage/server/test_background_alpha.py
Normal 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()
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
93
tests/anyimage/server/test_input_validation.py
Normal file
93
tests/anyimage/server/test_input_validation.py
Normal 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()
|
||||
@@ -165,6 +165,7 @@ class JobServerTest(ServerTestCase):
|
||||
"remove-background",
|
||||
"upscale-image",
|
||||
"generate-depth-plane-geometry",
|
||||
"generate-panorama-geometry",
|
||||
"generate-cutout-artifacts",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
139
tests/anyimage/server/test_model_moge3_onnx.py
Normal file
139
tests/anyimage/server/test_model_moge3_onnx.py
Normal 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
|
||||
133
tests/anyimage/server/test_panorama.py
Normal file
133
tests/anyimage/server/test_panorama.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
11
tests/support/color_image.py
Normal file
11
tests/support/color_image.py
Normal 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)
|
||||
15
tests/support/hdr_image.py
Normal file
15
tests/support/hdr_image.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
259
tests/tools/nodes/test_image_depth_panorama.py
Normal file
259
tests/tools/nodes/test_image_depth_panorama.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
52
tests/tools/nodes/test_shadeless.py
Normal file
52
tests/tools/nodes/test_shadeless.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user