This commit is contained in:
0
tests/anyimage/__init__.py
Normal file
0
tests/anyimage/__init__.py
Normal file
0
tests/anyimage/common/__init__.py
Normal file
0
tests/anyimage/common/__init__.py
Normal file
73
tests/anyimage/common/support.py
Normal file
73
tests/anyimage/common/support.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
import importlib
|
||||
import importlib.util
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from tests.support.blender import mock_bpy_imports
|
||||
|
||||
|
||||
def load_common_modules():
|
||||
fake_bpy = ModuleType("bpy")
|
||||
fake_bpy.path = SimpleNamespace(abspath=lambda path: path)
|
||||
fake_bpy.context = SimpleNamespace(scene=None)
|
||||
|
||||
module_paths = (
|
||||
(
|
||||
"anyimage.common.image",
|
||||
PROJECT_ROOT
|
||||
/ "src"
|
||||
/ "anyimage"
|
||||
/ "common"
|
||||
/ "image.py",
|
||||
),
|
||||
(
|
||||
"anyimage.common.node",
|
||||
PROJECT_ROOT
|
||||
/ "src"
|
||||
/ "anyimage"
|
||||
/ "common"
|
||||
/ "node.py",
|
||||
),
|
||||
(
|
||||
"anyimage.common.material",
|
||||
PROJECT_ROOT
|
||||
/ "src"
|
||||
/ "anyimage"
|
||||
/ "common"
|
||||
/ "material.py",
|
||||
),
|
||||
(
|
||||
"anyimage.common.object",
|
||||
PROJECT_ROOT
|
||||
/ "src"
|
||||
/ "anyimage"
|
||||
/ "common"
|
||||
/ "object.py",
|
||||
),
|
||||
)
|
||||
packages = {
|
||||
name: ModuleType(name)
|
||||
for name in (
|
||||
"anyimage",
|
||||
"anyimage.common",
|
||||
)
|
||||
}
|
||||
for package in packages.values():
|
||||
package.__path__ = []
|
||||
|
||||
loaded = []
|
||||
preferences = ModuleType("anyimage.preferences")
|
||||
preferences.configured_material_view_adaptation = lambda: True
|
||||
with mock_bpy_imports(fake_bpy), patch.dict(sys.modules, {"anyimage.preferences": preferences, **packages}):
|
||||
for module_name, module_path in module_paths:
|
||||
specification = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
module_path,
|
||||
)
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
sys.modules[module_name] = module
|
||||
specification.loader.exec_module(module)
|
||||
loaded.append(module)
|
||||
|
||||
return SimpleNamespace(image=loaded[0], material=loaded[2], object=loaded[3])
|
||||
226
tests/anyimage/common/test_alpha_presentation.py
Normal file
226
tests/anyimage/common/test_alpha_presentation.py
Normal file
@@ -0,0 +1,226 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
|
||||
from anyimage.common import image as images
|
||||
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)
|
||||
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 == ("PREMUL" if floating else "STRAIGHT")
|
||||
np.testing.assert_allclose(images.image_rgba(original), expected)
|
||||
assert edited.alpha_mode == original.alpha_mode
|
||||
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 (cropped, edited, original):
|
||||
bpy.data.images.remove(image)
|
||||
|
||||
|
||||
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 / "foreground.png")
|
||||
original = bpy.data.images.new("Job source", width=1, height=1, alpha=True)
|
||||
result = images.load_image_edit_result(original, tmp_path / "foreground.png")
|
||||
try:
|
||||
assert result.alpha_mode == "STRAIGHT"
|
||||
assert result.packed_file is not None
|
||||
assert original.alpha_mode == "STRAIGHT"
|
||||
finally:
|
||||
bpy.data.images.remove(result)
|
||||
bpy.data.images.remove(original)
|
||||
|
||||
|
||||
def test_business_pixels_ignore_premul_read_interpretation():
|
||||
original = bpy.data.images.new("Premul read source", width=1, height=1, alpha=True)
|
||||
original.alpha_mode = "PREMUL"
|
||||
expected = np.array([0.4, 0.25, 0.1, 0.5], dtype=np.float32)
|
||||
result = images.create_image_edit_result(original, expected, (1, 1))
|
||||
try:
|
||||
result.reload()
|
||||
interpreted = np.empty(4, dtype=np.float32)
|
||||
result.pixels.foreach_get(interpreted)
|
||||
assert interpreted[0] > expected[0]
|
||||
np.testing.assert_allclose(images.image_pixels(result), expected, atol=1 / 255)
|
||||
assert result.alpha_mode == "PREMUL"
|
||||
finally:
|
||||
bpy.data.images.remove(result)
|
||||
bpy.data.images.remove(original)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dirty", [False, True])
|
||||
def test_business_read_preserves_shared_packed_source(dirty):
|
||||
original = bpy.data.images.new("Read integrity", width=1, height=1, alpha=True)
|
||||
original.alpha_mode = "PREMUL"
|
||||
result = images.create_image_edit_result(original, np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32), (1, 1))
|
||||
owner = bpy.data.objects.new("Read owner", None)
|
||||
owner.empty_display_type = "IMAGE"
|
||||
owner.data = result
|
||||
try:
|
||||
result.reload()
|
||||
if dirty:
|
||||
result.pixels.foreach_set([0.9, 0.1, 0.3, 0.5])
|
||||
result.update()
|
||||
before = np.array(result.pixels[:])
|
||||
packed = bytes(result.packed_file.data)
|
||||
state = (result.alpha_mode, result.is_dirty, result.users)
|
||||
count = len(bpy.data.images)
|
||||
for _ in range(3):
|
||||
pixels = images.image_pixels(result)
|
||||
np.testing.assert_allclose(pixels, before if dirty else [0.2, 0.4, 0.8, 0.25], atol=1 / 255)
|
||||
np.testing.assert_array_equal(result.pixels[:], before)
|
||||
assert (result.alpha_mode, result.is_dirty, result.users) == state
|
||||
assert bytes(result.packed_file.data) == packed
|
||||
assert owner.data == result
|
||||
assert len(bpy.data.images) == count
|
||||
finally:
|
||||
bpy.data.objects.remove(owner)
|
||||
bpy.data.images.remove(result)
|
||||
bpy.data.images.remove(original)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("floating", [False, True])
|
||||
def test_generated_premul_reads_current_pixels(floating):
|
||||
source = bpy.data.images.new("Generated read", width=1, height=1, alpha=True, float_buffer=floating)
|
||||
try:
|
||||
source.alpha_mode = "PREMUL"
|
||||
source.pixels.foreach_set([0.2, 0.4, 0.8, 0.25])
|
||||
before = np.array(source.pixels[:])
|
||||
np.testing.assert_array_equal(images.image_pixels(source), before)
|
||||
np.testing.assert_array_equal(source.pixels[:], before)
|
||||
assert source.alpha_mode == "PREMUL"
|
||||
finally:
|
||||
bpy.data.images.remove(source)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("packed,floating,dirty", [
|
||||
(False, False, False),
|
||||
(True, False, True),
|
||||
(False, True, True),
|
||||
(True, True, False),
|
||||
])
|
||||
def test_file_read_preserves_byte_and_native_float_cache(tmp_path, packed, floating, dirty):
|
||||
path = tmp_path / ("source.exr" if floating else "source.png")
|
||||
if floating:
|
||||
generated = bpy.data.images.new("EXR source", width=1, height=1, alpha=True, float_buffer=True)
|
||||
try:
|
||||
generated.pixels.foreach_set([0.2, 0.4, 0.8, 0.25])
|
||||
generated.file_format = "OPEN_EXR"
|
||||
generated.filepath_raw = str(path)
|
||||
generated.save()
|
||||
finally:
|
||||
bpy.data.images.remove(generated)
|
||||
else:
|
||||
Image.fromarray(np.array([[[51, 102, 204, 64]]], dtype=np.uint8)).save(path)
|
||||
source = bpy.data.images.load(str(path), check_existing=False)
|
||||
try:
|
||||
if packed:
|
||||
source.pack()
|
||||
source.alpha_mode = "PREMUL"
|
||||
if dirty:
|
||||
source.pixels.foreach_set([0.9, 0.1, 0.3, 0.5])
|
||||
source.update()
|
||||
before = np.array(source.pixels[:])
|
||||
assert source.is_dirty == dirty
|
||||
assert source.is_float == floating
|
||||
expected = before if floating or dirty else np.array([51, 102, 204, 64]) / 255
|
||||
count = len(bpy.data.images)
|
||||
np.testing.assert_allclose(images.image_pixels(source), expected, atol=1e-7)
|
||||
np.testing.assert_array_equal(source.pixels[:], before)
|
||||
assert source.is_dirty == dirty and source.alpha_mode == "PREMUL"
|
||||
assert len(bpy.data.images) == count
|
||||
finally:
|
||||
bpy.data.images.remove(source)
|
||||
|
||||
|
||||
def test_isolated_decode_failure_releases_copy():
|
||||
class FailedReader:
|
||||
@property
|
||||
def alpha_mode(self):
|
||||
return "PREMUL"
|
||||
|
||||
@alpha_mode.setter
|
||||
def alpha_mode(self, value):
|
||||
raise RuntimeError("Decode failed")
|
||||
|
||||
reader = FailedReader()
|
||||
source = SimpleNamespace(alpha_mode="PREMUL", is_dirty=False, is_float=False,
|
||||
source="FILE", copy=lambda: reader)
|
||||
with patch.object(images, "bpy", SimpleNamespace(data=SimpleNamespace(images=SimpleNamespace(remove=lambda value: removed.append(value))))):
|
||||
removed = []
|
||||
with pytest.raises(RuntimeError, match="Decode failed"):
|
||||
images.image_pixels(source)
|
||||
assert removed == [reader]
|
||||
assert source.alpha_mode == "PREMUL"
|
||||
|
||||
|
||||
def test_mask_pack_reopen_extend_and_repeat_frame_preserve_hidden_color(tmp_path):
|
||||
from anyimage.common.selection import SelectionMask
|
||||
from anyimage.operators.mask_tool import apply_alpha_mask
|
||||
from anyimage.operators.frame_tool.projection import frame_source_projection
|
||||
from anyimage.operators.frame_tool.compositing import composite_frame_pixels
|
||||
|
||||
source = bpy.data.images.new("Roundtrip source", width=2, height=2, alpha=True)
|
||||
rgba = np.tile(np.array([0.2, 0.4, 0.8, 1], dtype=np.float32), (2, 2, 1))
|
||||
full = SelectionMask(np.ones((2, 2)), (0, 0, 2, 2))
|
||||
result = images.create_image_edit_result(source, apply_alpha_mask(rgba, full, "SUBTRACT"), (2, 2))
|
||||
result.use_fake_user = True
|
||||
name = result.name
|
||||
bpy.data.images.remove(source)
|
||||
path = str(tmp_path / "mask.blend")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=path)
|
||||
bpy.ops.wm.open_mainfile(filepath=path)
|
||||
result = bpy.data.images[name]
|
||||
try:
|
||||
restored = images.image_rgba(result)
|
||||
np.testing.assert_allclose(restored[..., :3], rgba[..., :3], atol=1 / 255)
|
||||
np.testing.assert_array_equal(restored[..., 3], 0)
|
||||
added = images.create_image_edit_result(result, apply_alpha_mask(restored, full, "EXTEND"), (2, 2))
|
||||
bpy.data.images.remove(result)
|
||||
result = added
|
||||
for _ in range(2):
|
||||
source = frame_source_projection(np.eye(4), np.eye(4), np.eye(4), (-1, 1, -1, 1), (2, 2), perspective=False)
|
||||
rgba_read = images.image_rgba(result)
|
||||
source.update(rgba=rgba_read, pixels=images.premultiplied_rgba(images.image_pixels(result), (2, 2)),
|
||||
image_size=(2, 2), active=True, name=result.name)
|
||||
pixels = composite_frame_pixels((source,), ((0, 2), (2, 2), (2, 0), (0, 0)), (2, 2))
|
||||
next_result = images.create_image_edit_result(result, pixels, (2, 2))
|
||||
bpy.data.images.remove(result)
|
||||
result = next_result
|
||||
result.reload()
|
||||
assert result.alpha_mode == "STRAIGHT" and result.packed_file is not None
|
||||
np.testing.assert_allclose(images.image_rgba(result), rgba, atol=1 / 255)
|
||||
finally:
|
||||
bpy.data.images.remove(result)
|
||||
|
||||
|
||||
def test_job_loader_reads_only_the_returned_file(tmp_path):
|
||||
Image.new("RGBA", (2, 2), (255, 0, 0, 255)).save(tmp_path / "foreground.png")
|
||||
Image.new("RGBA", (4, 4), (0, 0, 255, 255)).save(tmp_path / "upscale.png")
|
||||
original = bpy.data.images.new("Source", width=1, height=1, alpha=True)
|
||||
result = images.load_image_edit_result(original, tmp_path / "upscale.png")
|
||||
try:
|
||||
assert tuple(result.size) == (4, 4)
|
||||
assert result.source == "FILE"
|
||||
assert result.packed_file is not None
|
||||
with pytest.raises(RuntimeError, match="Color result image does not exist"):
|
||||
images.load_image_edit_result(original, tmp_path / "missing.png")
|
||||
finally:
|
||||
bpy.data.images.remove(result)
|
||||
bpy.data.images.remove(original)
|
||||
172
tests/anyimage/common/test_color_image.py
Normal file
172
tests/anyimage/common/test_color_image.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""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"), (True, "STRAIGHT")])
|
||||
@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()
|
||||
|
||||
196
tests/anyimage/common/test_depth.py
Normal file
196
tests/anyimage/common/test_depth.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Verify shared Depth calibration and metadata from Depth artifacts."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from anyimage.common.coordinate import canonical_direction_to_symmetry_legacy
|
||||
from anyimage.common.depth import (
|
||||
FLAT_DEPTH_DIRECTION,
|
||||
REFERENCE_DEPTH_BASELINE,
|
||||
depth_uniform_scale,
|
||||
fit_depth_direction,
|
||||
fit_symmetry_depth_direction,
|
||||
load_depth_metadata,
|
||||
reference_depth,
|
||||
)
|
||||
from tests.support.image_objects import _depth_image
|
||||
|
||||
|
||||
def _camera_depth_image(depth, alpha=None):
|
||||
depth = np.asarray(depth, dtype=np.float32)
|
||||
points = np.zeros((*depth.shape, 3), dtype=np.float32)
|
||||
points[..., 2] = depth
|
||||
if alpha is None:
|
||||
alpha = np.ones(depth.shape, dtype=np.float32)
|
||||
return _depth_image(points, alpha)
|
||||
|
||||
|
||||
def test_depth_metadata_loader_reads_only_size_and_intrinsics(tmp_path):
|
||||
path = tmp_path / "depth.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"image_size": [3, 2],
|
||||
"intrinsics": np.eye(3).tolist(),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
metadata = load_depth_metadata(path)
|
||||
|
||||
assert metadata == {
|
||||
"image_size": (3, 2),
|
||||
"intrinsics": ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
|
||||
}
|
||||
|
||||
|
||||
def test_base_plane_fit_is_stable_for_a_slender_diagonal_domain():
|
||||
along = np.linspace(-1.0, 1.0, 101)
|
||||
across = np.resize(np.asarray((-0.01, 0.0, 0.01)), len(along))
|
||||
coordinates = np.column_stack((along - across, along + across))
|
||||
values = 0.4 * along + np.sign(across) * 0.05
|
||||
|
||||
direction = fit_depth_direction(
|
||||
coordinates,
|
||||
values + 2.0,
|
||||
np.ones(len(values), dtype=bool),
|
||||
1.0,
|
||||
)
|
||||
slopes = np.asarray(direction[:2]) / direction[2]
|
||||
selected_plane = coordinates @ slopes
|
||||
corners = np.asarray(((-1.0, -1.0), (-1.0, 1.0), (1.0, -1.0), (1.0, 1.0)))
|
||||
corner_plane = corners @ slopes
|
||||
|
||||
assert np.ptp(corner_plane) <= np.ptp(selected_plane) * 1.25
|
||||
assert np.corrcoef(selected_plane, along)[0, 1] > 0.99
|
||||
|
||||
|
||||
def test_reference_depth_uses_the_upper_depth_percentile():
|
||||
depth = np.linspace(1.0, 1.2, 400, dtype=np.float32).reshape(20, 20)
|
||||
|
||||
reference = reference_depth(_camera_depth_image(depth))
|
||||
|
||||
assert reference == pytest.approx(float(np.percentile(depth, 95.0)))
|
||||
assert reference > float(np.median(depth))
|
||||
|
||||
|
||||
def test_reference_depth_ignores_transparent_invalid_and_non_positive_pixels():
|
||||
depth = np.asarray(
|
||||
((1.0, 1.05, 1.1, 9.0), (1.15, 0.0, np.nan, 1.2)),
|
||||
dtype=np.float32,
|
||||
)
|
||||
alpha = np.asarray(
|
||||
((True, True, True, False), (True, True, True, True)),
|
||||
)
|
||||
|
||||
reference = reference_depth(_camera_depth_image(depth, alpha))
|
||||
|
||||
assert reference == pytest.approx(
|
||||
float(np.percentile(np.asarray((1.0, 1.05, 1.1, 1.15, 1.2)), 95.0))
|
||||
)
|
||||
|
||||
|
||||
def test_reference_depth_narrows_its_domain_to_the_mask():
|
||||
depth = np.asarray(((1.0, 1.05, 1.1, 1.15),), dtype=np.float32)
|
||||
image = _camera_depth_image(depth)
|
||||
mask = np.asarray(((False, False, True, True),))
|
||||
|
||||
assert reference_depth(image) == pytest.approx(
|
||||
float(np.percentile(depth, 95.0))
|
||||
)
|
||||
assert reference_depth(image, mask) == pytest.approx(
|
||||
float(np.percentile(np.asarray((1.1, 1.15)), 95.0))
|
||||
)
|
||||
|
||||
|
||||
def test_reference_depth_ignores_soft_depth_alpha():
|
||||
depth = np.asarray(((1.0, 1.05, 1.1, 1.15),), dtype=np.float32)
|
||||
image = _camera_depth_image(depth, ((1.0, 1.0, 0.9, 0.6),))
|
||||
|
||||
assert reference_depth(image) == pytest.approx(
|
||||
float(np.percentile(np.asarray((1.0, 1.05)), 95.0))
|
||||
)
|
||||
|
||||
|
||||
def test_reference_depth_ignores_a_distant_tail():
|
||||
depth = np.asarray(((2.0, 4.0, 6.0, 1000.0),), dtype=np.float32)
|
||||
|
||||
reference = reference_depth(_camera_depth_image(depth))
|
||||
|
||||
assert reference == pytest.approx(
|
||||
float(np.percentile(np.asarray((2.0, 4.0)), 95.0))
|
||||
)
|
||||
|
||||
|
||||
def test_reference_depth_follows_the_content_scale_when_everything_is_far():
|
||||
depth = np.asarray(((9.4, 10.6, 11.6, 13.0),), dtype=np.float32)
|
||||
|
||||
reference = reference_depth(_camera_depth_image(depth))
|
||||
|
||||
assert reference == pytest.approx(
|
||||
float(np.percentile(np.asarray((9.4, 10.6, 11.6)), 95.0))
|
||||
)
|
||||
|
||||
|
||||
def test_reference_depth_falls_back_to_the_baseline_without_a_selection():
|
||||
transparent = _camera_depth_image(
|
||||
np.zeros((2, 2), dtype=np.float32),
|
||||
np.zeros((2, 2), dtype=np.float32),
|
||||
)
|
||||
|
||||
assert reference_depth(transparent) == pytest.approx(REFERENCE_DEPTH_BASELINE)
|
||||
|
||||
|
||||
def test_symmetry_depth_direction_fits_a_sloped_base_plane():
|
||||
vertices = []
|
||||
uv = []
|
||||
camera_depth = np.empty((3, 3), dtype=np.float32)
|
||||
for row in range(3):
|
||||
for column in range(3):
|
||||
local_x, local_y = column - 1.0, 1.0 - row
|
||||
vertices.append((local_x, 0.0, local_y))
|
||||
uv.append((column * 0.5, 1.0 - row * 0.5))
|
||||
camera_depth[row, column] = 2.0 + 0.2 * local_x + 0.1 * local_y
|
||||
|
||||
direction = fit_symmetry_depth_direction(
|
||||
_camera_depth_image(camera_depth),
|
||||
vertices,
|
||||
uv,
|
||||
0.5,
|
||||
)
|
||||
|
||||
expected = np.asarray((0.1, 1.0, 0.05), dtype=np.float64)
|
||||
expected /= np.linalg.norm(expected)
|
||||
assert np.allclose(direction, expected, atol=1e-6)
|
||||
|
||||
|
||||
def test_symmetry_depth_direction_returns_the_flat_direction_without_samples():
|
||||
vertices = ((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 0.0, 1.0))
|
||||
uv = ((0.0, 0.0), (1.0, 0.0), (0.0, 1.0))
|
||||
depth = np.ones((2, 2), dtype=np.float32)
|
||||
image = _camera_depth_image(depth, np.zeros((2, 2), dtype=bool))
|
||||
|
||||
direction = fit_symmetry_depth_direction(image, vertices, uv, 1.0)
|
||||
|
||||
assert direction == FLAT_DEPTH_DIRECTION
|
||||
assert canonical_direction_to_symmetry_legacy(direction) == (0.0, 0.0, 1.0)
|
||||
|
||||
|
||||
def test_depth_uniform_scale_converts_model_units_at_the_reference_depth():
|
||||
metadata = {
|
||||
"intrinsics": ((100.0, 0.0, 50.0), (0.0, 100.0, 25.0), (0.0, 0.0, 1.0)),
|
||||
"image_size": (101, 51),
|
||||
}
|
||||
|
||||
uniform_scale = depth_uniform_scale(
|
||||
SimpleNamespace(),
|
||||
metadata,
|
||||
2.0,
|
||||
bounds=(-2.0, 2.0, -1.0, 1.0),
|
||||
)
|
||||
|
||||
assert uniform_scale == pytest.approx(2.0)
|
||||
194
tests/anyimage/common/test_empty_image_edits.py
Normal file
194
tests/anyimage/common/test_empty_image_edits.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from unittest.mock import patch
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
|
||||
from anyimage.common import image as images
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Source.png", width=2, height=2, alpha=True)
|
||||
image.pixels.foreach_set(np.tile([0.2, 0.4, 0.6, 0.25], 4).astype(np.float32))
|
||||
image.pack()
|
||||
obj = create_empty("Source", image)
|
||||
yield obj
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
|
||||
def create_empty(name, image):
|
||||
obj = bpy.data.objects.new(name, None)
|
||||
obj.empty_display_type = "IMAGE"
|
||||
obj.data = image
|
||||
bpy.context.collection.objects.link(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def edit_result(source):
|
||||
return images.create_image_edit_result(
|
||||
source.data, np.tile([0.8, 0.3, 0.1, 0.0], 12).astype(np.float32), (4, 3),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha_mode,shared,entry", [
|
||||
("STRAIGHT", False, "pixels"),
|
||||
("PREMUL", True, "foreground.png"),
|
||||
("CHANNEL_PACKED", False, "upscale.png"),
|
||||
("NONE", True, "pixels"),
|
||||
])
|
||||
def test_image_edit_replacement_preserves_alpha_mode(source, tmp_path, alpha_mode, shared, entry):
|
||||
original = source.data
|
||||
original.alpha_mode = alpha_mode
|
||||
if shared:
|
||||
other = create_empty("Other", original)
|
||||
if entry == "pixels":
|
||||
result = edit_result(source)
|
||||
else:
|
||||
Image.new("RGBA", (4, 3), (100, 150, 200, 128)).save(tmp_path / entry)
|
||||
result = images.load_image_edit_result(original, tmp_path / entry)
|
||||
assert result.alpha_mode == alpha_mode
|
||||
images.replace_empty_image(source, result)
|
||||
assert source.data.alpha_mode == alpha_mode
|
||||
assert (source.data == original) == (not shared)
|
||||
if shared:
|
||||
assert other.data == original and original.alpha_mode == alpha_mode
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sharing", ["visible", "hidden", "other_scene"])
|
||||
def test_shared_edit_preserves_other_empty(source, sharing):
|
||||
original = source.data
|
||||
packed = bytes(original.packed_file.data)
|
||||
rgba = images.image_rgba(original)
|
||||
result = edit_result(source)
|
||||
# Create the second user after preparing the result, as during an async Job.
|
||||
other = create_empty("Other", original)
|
||||
other.hide_viewport = sharing == "hidden"
|
||||
if sharing == "other_scene":
|
||||
bpy.context.collection.objects.unlink(other)
|
||||
scene = bpy.data.scenes.new("Other scene")
|
||||
scene.collection.objects.link(other)
|
||||
other.empty_display_size = 7
|
||||
other.image_user.frame_start = 9
|
||||
assert images.has_other_image_empty(source)
|
||||
assert images.replace_empty_image(source, result) == result
|
||||
assert source.data == result
|
||||
assert result.name == "Source.png.001"
|
||||
assert other.data == original
|
||||
assert other.empty_display_size == 7
|
||||
assert other.image_user.frame_start == 9
|
||||
assert original.name == "Source.png"
|
||||
assert bytes(original.packed_file.data) == packed
|
||||
np.testing.assert_array_equal(images.image_rgba(original), rgba)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reference", ["none", "fake_user", "material", "collections"])
|
||||
def test_sole_edit_preserves_id_and_releases_result(source, reference, tmp_path):
|
||||
original = source.data
|
||||
pointer = original.as_pointer()
|
||||
original.use_fake_user = reference == "fake_user"
|
||||
if reference == "material":
|
||||
material = bpy.data.materials.new("Manual reference")
|
||||
material.use_nodes = True
|
||||
node = material.node_tree.nodes.new("ShaderNodeTexImage")
|
||||
node.image = original
|
||||
if reference == "collections":
|
||||
collection = bpy.data.collections.new("Another collection")
|
||||
bpy.context.scene.collection.children.link(collection)
|
||||
collection.objects.link(source)
|
||||
count = len(bpy.data.images)
|
||||
result = edit_result(source)
|
||||
expected = images.image_rgba(result)
|
||||
assert not images.has_other_image_empty(source)
|
||||
final = images.replace_empty_image(source, result)
|
||||
assert final.as_pointer() == pointer == source.data.as_pointer()
|
||||
assert final.name == "Source.png"
|
||||
assert final.use_fake_user == (reference == "fake_user")
|
||||
assert len(bpy.data.images) == count
|
||||
if reference == "material":
|
||||
assert node.image == final
|
||||
np.testing.assert_allclose(images.image_rgba(final), expected, atol=1 / 255)
|
||||
path = str(tmp_path / "edited.blend")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=path)
|
||||
bpy.ops.wm.open_mainfile(filepath=path)
|
||||
reopened = bpy.data.objects["Source"].data
|
||||
assert reopened.name == "Source.png"
|
||||
np.testing.assert_allclose(images.image_rgba(reopened), expected, atol=1 / 255)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outside", [False, True])
|
||||
def test_frame_sharing_uses_surviving_objects(source, outside):
|
||||
original = source.data
|
||||
merged = create_empty("Merged", original)
|
||||
if outside:
|
||||
other = create_empty("Outside", original)
|
||||
assert images.has_other_image_empty(source, removed_objects=(merged,)) == outside
|
||||
result = edit_result(source)
|
||||
final = images.replace_empty_image(source, result, removed_objects=(merged,))
|
||||
bpy.data.objects.remove(merged, do_unlink=True)
|
||||
assert (final == original) == (not outside)
|
||||
if outside:
|
||||
assert other.data == original
|
||||
assert tuple(original.size) == (2, 2)
|
||||
|
||||
|
||||
def test_failed_content_write_restores_source(source):
|
||||
original = source.data
|
||||
packed = bytes(original.packed_file.data)
|
||||
result = edit_result(source)
|
||||
restore = images.restore_image_content
|
||||
calls = 0
|
||||
|
||||
def fail_once(image, state):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
restore(image, state)
|
||||
if calls == 1:
|
||||
raise RuntimeError("Write failed")
|
||||
|
||||
with patch.object(images, "restore_image_content", side_effect=fail_once):
|
||||
with pytest.raises(RuntimeError, match="Write failed"):
|
||||
images.replace_empty_image(source, result)
|
||||
assert source.data == original
|
||||
assert tuple(original.size) == (2, 2)
|
||||
assert bytes(original.packed_file.data) == packed
|
||||
assert len(bpy.data.images) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("floating", [False, True])
|
||||
def test_failed_generated_commit_restores_unsaved_pixels(source, floating):
|
||||
original = bpy.data.images.new("Generated", width=3, height=2, alpha=True, float_buffer=floating)
|
||||
source.data = original
|
||||
original.pixels.foreach_set(np.tile([0.12, 0.24, 0.48, 0.5], 6).astype(np.float32))
|
||||
expected = images.image_pixels(original)
|
||||
result = edit_result(source)
|
||||
restore = images.restore_image_content
|
||||
calls = 0
|
||||
|
||||
def fail_once(image, state):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
restore(image, state)
|
||||
if calls == 1:
|
||||
raise RuntimeError("Write failed")
|
||||
|
||||
with patch.object(images, "restore_image_content", side_effect=fail_once):
|
||||
with pytest.raises(RuntimeError, match="Write failed"):
|
||||
images.replace_empty_image(source, result)
|
||||
assert original.source == "GENERATED"
|
||||
assert original.is_float == floating
|
||||
assert original.packed_file is None
|
||||
assert tuple(original.size) == (3, 2)
|
||||
np.testing.assert_array_equal(images.image_pixels(original), expected)
|
||||
|
||||
|
||||
def test_failed_preparation_releases_result(source):
|
||||
result = edit_result(source)
|
||||
count = len(bpy.data.images) - 1
|
||||
with patch.object(images, "empty_display_alignment", side_effect=ValueError("Invalid bounds")):
|
||||
with pytest.raises(ValueError, match="Invalid bounds"):
|
||||
images.replace_empty_image(source, result, placement_bounds=(0, 0, 1, 1))
|
||||
assert len(bpy.data.images) == count
|
||||
assert tuple(source.data.size) == (2, 2)
|
||||
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)
|
||||
108
tests/anyimage/common/test_image_data.py
Normal file
108
tests/anyimage/common/test_image_data.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from tests.anyimage.common.support import load_common_modules
|
||||
|
||||
class ImageDataTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.modules = load_common_modules()
|
||||
|
||||
def test_is_animated_image_accepts_movie_and_sequence(self):
|
||||
self.assertTrue(
|
||||
self.modules.image.is_animated_image(SimpleNamespace(source="MOVIE"))
|
||||
)
|
||||
self.assertTrue(
|
||||
self.modules.image.is_animated_image(SimpleNamespace(source="SEQUENCE"))
|
||||
)
|
||||
self.assertFalse(
|
||||
self.modules.image.is_animated_image(SimpleNamespace(source="FILE"))
|
||||
)
|
||||
|
||||
def test_panorama_crop_bounds_returns_largest_centered_two_to_one_region(self):
|
||||
cases = (
|
||||
((32, 16), (0, 0, 32, 16)),
|
||||
((40, 16), (4, 0, 36, 16)),
|
||||
((30, 20), (0, 2, 30, 17)),
|
||||
((31, 20), (0, 2, 30, 17)),
|
||||
((35, 16), (1, 0, 33, 16)),
|
||||
)
|
||||
for size, expected in cases:
|
||||
with self.subTest(size=size):
|
||||
self.assertEqual(
|
||||
self.modules.image.panorama_crop_bounds(*size), expected
|
||||
)
|
||||
|
||||
def test_panorama_crop_bounds_rejects_unusable_dimensions(self):
|
||||
for size in ((0, 1), (1, 1), (2, 0), (-2, 1)):
|
||||
with self.subTest(size=size):
|
||||
with self.assertRaisesRegex(ValueError, "at least 2 x 1 pixels"):
|
||||
self.modules.image.panorama_crop_bounds(*size)
|
||||
|
||||
|
||||
def test_image_user_settings_reads_empty_values(self):
|
||||
source_object = SimpleNamespace(
|
||||
image_user=SimpleNamespace(
|
||||
frame_start=4,
|
||||
frame_offset=70,
|
||||
frame_duration=12,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.modules.image.image_user_settings(source_object),
|
||||
(4, 70, 12, False),
|
||||
)
|
||||
|
||||
source_object.image_user.use_cyclic = True
|
||||
self.assertEqual(
|
||||
self.modules.image.image_user_settings(source_object),
|
||||
(4, 70, 12, True),
|
||||
)
|
||||
|
||||
|
||||
def test_image_user_settings_defaults_without_user(self):
|
||||
self.assertEqual(
|
||||
self.modules.image.image_user_settings(SimpleNamespace(image_user=None)),
|
||||
(1, 0, 0, False),
|
||||
)
|
||||
|
||||
|
||||
def test_depth_data_uses_source_image_name(self):
|
||||
source_object = SimpleNamespace(
|
||||
name="obj_img",
|
||||
data=SimpleNamespace(name="img.png"),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.modules.image.depth_data_name(source_object),
|
||||
"img_depth.exr",
|
||||
)
|
||||
source_object.data.name = "img.png.001"
|
||||
self.assertEqual(
|
||||
self.modules.image.depth_data_name(source_object),
|
||||
"img.001_depth.exr",
|
||||
)
|
||||
|
||||
|
||||
def test_moge_normal_name_uses_source_image_name(self):
|
||||
source_object = SimpleNamespace(
|
||||
name="obj_img",
|
||||
data=SimpleNamespace(name="img.png"),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.modules.image.normal_data_name(source_object),
|
||||
"img_normal.png",
|
||||
)
|
||||
|
||||
|
||||
def test_image_empty_bounds_keep_image_aspect_and_empty_offset(self):
|
||||
source = SimpleNamespace(
|
||||
data=SimpleNamespace(size=(800, 400)),
|
||||
empty_display_size=2.0,
|
||||
empty_image_offset=(-0.5, -0.5),
|
||||
)
|
||||
|
||||
bounds = self.modules.image.image_empty_bounds(source)
|
||||
|
||||
self.assertEqual(bounds, (-1.0, 1.0, -0.5, 0.5))
|
||||
212
tests/anyimage/common/test_image_edit_tool.py
Normal file
212
tests/anyimage/common/test_image_edit_tool.py
Normal file
@@ -0,0 +1,212 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class ImageEditToolTest(BlenderTestCase):
|
||||
|
||||
|
||||
def test_packed_image_export_does_not_use_scene_render_transform(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output_directory = Path(directory)
|
||||
calls = []
|
||||
|
||||
class Image:
|
||||
file_format = "JPEG"
|
||||
|
||||
def save(self, **options):
|
||||
calls.append(options)
|
||||
Path(options["filepath"]).write_bytes(b"raw image")
|
||||
|
||||
image = Image()
|
||||
scene = SimpleNamespace(
|
||||
view_settings=SimpleNamespace(look="AgX - Medium High Contrast")
|
||||
)
|
||||
with patch.object(
|
||||
self.image_data.tempfile,
|
||||
"mkdtemp",
|
||||
return_value=str(output_directory),
|
||||
):
|
||||
output = self.image_data.save_packed_image(image, scene)
|
||||
|
||||
self.assertEqual(output, output_directory / "input.png")
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[{"filepath": str(output), "save_copy": True}],
|
||||
)
|
||||
self.assertEqual(image.file_format, "JPEG")
|
||||
|
||||
|
||||
def test_expected_empty_image_edits_are_reported_as_warnings(self):
|
||||
reports = []
|
||||
operator = SimpleNamespace(
|
||||
report=lambda level, message: reports.append((level, message))
|
||||
)
|
||||
|
||||
self.image_interaction.report_image_edit_exception(
|
||||
operator,
|
||||
self.image_interaction.ImageEditWarning(
|
||||
"The edit contains no visible image pixels"
|
||||
),
|
||||
)
|
||||
self.image_interaction.report_image_edit_exception(
|
||||
operator,
|
||||
ValueError("Unexpected invalid data"),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
reports,
|
||||
[
|
||||
(
|
||||
{"WARNING"},
|
||||
"The edit contains no visible image pixels",
|
||||
),
|
||||
({"ERROR"}, "Unexpected invalid data"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_image_edit_first_click_selects_another_image_without_editing(self):
|
||||
selected = []
|
||||
|
||||
def image(name):
|
||||
return SimpleNamespace(
|
||||
name=name,
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=object(),
|
||||
select_set=lambda value: selected.append((name, value)),
|
||||
)
|
||||
|
||||
active = image("Active")
|
||||
clicked = image("Clicked")
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=(active,),
|
||||
view_layer=SimpleNamespace(objects=SimpleNamespace(active=active)),
|
||||
)
|
||||
event = SimpleNamespace(mouse_region_x=10, mouse_region_y=20)
|
||||
previous_ops = getattr(self.fake_bpy, "ops", None)
|
||||
previous_objects = self.fake_bpy.data.objects
|
||||
|
||||
def select(**_kwargs):
|
||||
context.active_object = clicked
|
||||
|
||||
self.fake_bpy.ops = SimpleNamespace(
|
||||
view3d=SimpleNamespace(select=select),
|
||||
object=SimpleNamespace(select_all=lambda **_kwargs: None),
|
||||
)
|
||||
self.fake_bpy.data.objects = {
|
||||
active.name: active,
|
||||
clicked.name: clicked,
|
||||
}
|
||||
try:
|
||||
source, should_edit = self.image_interaction.resolve_image_edit_click(
|
||||
context, event, lambda *_args: None
|
||||
)
|
||||
finally:
|
||||
self.fake_bpy.data.objects = previous_objects
|
||||
if previous_ops is None:
|
||||
del self.fake_bpy.ops
|
||||
else:
|
||||
self.fake_bpy.ops = previous_ops
|
||||
|
||||
self.assertIs(source, clicked)
|
||||
self.assertFalse(should_edit)
|
||||
self.assertEqual(selected, [])
|
||||
|
||||
|
||||
def test_image_edit_first_click_selects_another_object_without_editing(self):
|
||||
active = SimpleNamespace(
|
||||
name="Active",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=object(),
|
||||
)
|
||||
clicked = SimpleNamespace(name="Mesh", type="MESH", data=object())
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=(active,),
|
||||
)
|
||||
event = SimpleNamespace(mouse_region_x=10, mouse_region_y=20)
|
||||
previous_ops = getattr(self.fake_bpy, "ops", None)
|
||||
select_options = []
|
||||
|
||||
def select(**options):
|
||||
select_options.append(options)
|
||||
context.active_object = clicked
|
||||
|
||||
self.fake_bpy.ops = SimpleNamespace(
|
||||
view3d=SimpleNamespace(select=select),
|
||||
)
|
||||
try:
|
||||
source, should_edit = self.image_interaction.resolve_image_edit_click(
|
||||
context, event, lambda *_args: None
|
||||
)
|
||||
finally:
|
||||
if previous_ops is None:
|
||||
del self.fake_bpy.ops
|
||||
else:
|
||||
self.fake_bpy.ops = previous_ops
|
||||
|
||||
self.assertIs(source, clicked)
|
||||
self.assertFalse(should_edit)
|
||||
self.assertEqual(select_options[0]["deselect_all"], False)
|
||||
|
||||
|
||||
def test_image_edit_empty_click_keeps_active_image_and_starts_editing(self):
|
||||
active = SimpleNamespace(
|
||||
name="Active",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=object(),
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=(active,),
|
||||
)
|
||||
event = SimpleNamespace(mouse_region_x=10, mouse_region_y=20)
|
||||
previous_ops = getattr(self.fake_bpy, "ops", None)
|
||||
select_options = []
|
||||
self.fake_bpy.ops = SimpleNamespace(
|
||||
view3d=SimpleNamespace(
|
||||
select=lambda **options: select_options.append(options)
|
||||
),
|
||||
)
|
||||
try:
|
||||
source, should_edit = self.image_interaction.resolve_image_edit_click(
|
||||
context, event, lambda *_args: None
|
||||
)
|
||||
finally:
|
||||
if previous_ops is None:
|
||||
del self.fake_bpy.ops
|
||||
else:
|
||||
self.fake_bpy.ops = previous_ops
|
||||
|
||||
self.assertIs(source, active)
|
||||
self.assertTrue(should_edit)
|
||||
self.assertIs(context.active_object, active)
|
||||
self.assertEqual(select_options[0]["deselect_all"], False)
|
||||
|
||||
|
||||
def test_image_edit_source_requires_active_image_to_be_selected(self):
|
||||
active = SimpleNamespace(
|
||||
name="Active",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=object(),
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=(),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
self.image_interaction.ImageEditWarning,
|
||||
"Select an active Image Empty",
|
||||
):
|
||||
self.image_interaction.active_image_edit_source(context)
|
||||
254
tests/anyimage/common/test_image_materials.py
Normal file
254
tests/anyimage/common/test_image_materials.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from types import SimpleNamespace
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
|
||||
from anyimage.common import image as image_data
|
||||
from anyimage.common import material as material_data
|
||||
from tests.support.image_objects import _create_cutout_shape, _group_node
|
||||
|
||||
|
||||
def test_generated_materials_use_implicit_uv_and_readable_layout():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source = SimpleNamespace(name="Source")
|
||||
color = bpy.data.images.new("Color", width=4, height=2)
|
||||
normal = bpy.data.images.new("Normal", width=4, height=2)
|
||||
depth = bpy.data.images.new("Depth", width=4, height=2)
|
||||
|
||||
material = material_data.create_image_material(source, color, normal_image=normal)
|
||||
nodes = material.node_tree.nodes
|
||||
assert not any(node.bl_idname == "ShaderNodeUVMap" for node in nodes)
|
||||
assert all(not node.inputs["Vector"].is_linked for node in nodes if node.type == "TEX_IMAGE")
|
||||
texture_location = lambda image: tuple(next(
|
||||
node for node in nodes if node.type == "TEX_IMAGE" and node.image == image
|
||||
).location)
|
||||
assert texture_location(color) == (-700.0, 300.0)
|
||||
assert texture_location(normal) == (-700.0, 0.0)
|
||||
assert tuple(next(node for node in nodes if node.type == "GROUP").location) == (-300.0, 300.0)
|
||||
assert tuple(next(node for node in nodes if node.type == "BSDF_PRINCIPLED").location) == (100.0, 300.0)
|
||||
assert tuple(next(node for node in nodes if node.type == "OUTPUT_MATERIAL").location) == (500.0, 300.0)
|
||||
|
||||
depth_material = material_data.create_image_material(
|
||||
source,
|
||||
color,
|
||||
displacement_image=depth,
|
||||
displacement_midlevel=0.5,
|
||||
displacement_scale=2.0,
|
||||
)
|
||||
nodes = depth_material.node_tree.nodes
|
||||
assert not any(node.bl_idname == "ShaderNodeUVMap" for node in nodes)
|
||||
assert all(not node.inputs["Vector"].is_linked for node in nodes if node.type == "TEX_IMAGE")
|
||||
depth_texture = next(node for node in nodes if node.type == "TEX_IMAGE" and node.image == depth)
|
||||
assert tuple(depth_texture.location) == (-1200.0, -300.0)
|
||||
math_locations = {node.operation: tuple(node.location) for node in nodes if node.type == "MATH"}
|
||||
assert math_locations == {
|
||||
"SUBTRACT": (-950.0, -300.0),
|
||||
"MULTIPLY": (-700.0, -300.0),
|
||||
}
|
||||
|
||||
panorama = material_data.create_image_material(
|
||||
source, color, shadeless=True, texture_extension="REPEAT")
|
||||
nodes = panorama.node_tree.nodes
|
||||
assert not any(node.bl_idname in {
|
||||
"ShaderNodeUVMap", "ShaderNodeSeparateXYZ", "ShaderNodeClamp", "ShaderNodeCombineXYZ",
|
||||
} for node in nodes)
|
||||
texture = next(node for node in nodes if node.type == "TEX_IMAGE")
|
||||
shader = next(node for node in nodes if node.type == "GROUP")
|
||||
output = next(node for node in nodes if node.type == "OUTPUT_MATERIAL")
|
||||
assert not texture.inputs["Vector"].is_linked
|
||||
assert texture.extension == "REPEAT"
|
||||
assert (tuple(texture.location), tuple(shader.location), tuple(output.location)) == (
|
||||
(-700.0, 300.0), (-300.0, 300.0), (100.0, 300.0))
|
||||
|
||||
|
||||
def test_image_layer_switches_between_tangent_and_object_space_normal():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
assert material_data.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer"
|
||||
group = material_data.material_node_group()
|
||||
normal = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Normal"
|
||||
)
|
||||
assert tuple(normal.default_value) == (0.5, 0.5, 1.0, 1.0)
|
||||
bump_scale = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Bump Scale"
|
||||
)
|
||||
assert bump_scale.default_value == 0.0
|
||||
normal_scale = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Normal Scale"
|
||||
)
|
||||
assert normal_scale.default_value == 1.0
|
||||
object_space = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Object Space"
|
||||
)
|
||||
assert object_space.default_value is False
|
||||
normal_maps = {
|
||||
node.space for node in group.nodes if node.bl_idname == "ShaderNodeNormalMap"
|
||||
}
|
||||
assert normal_maps == {"TANGENT", "OBJECT"}
|
||||
assert all(
|
||||
not node.label
|
||||
for node in group.nodes
|
||||
if node.bl_idname in {"ShaderNodeNormalMap", "ShaderNodeMix"}
|
||||
)
|
||||
|
||||
color = bpy.data.images.new("Color", width=2, height=2)
|
||||
normal_image = bpy.data.images.new("Normal", width=2, height=2)
|
||||
object_material = material_data.create_image_material(
|
||||
SimpleNamespace(name="Object Normal"),
|
||||
color,
|
||||
1.0,
|
||||
normal_image=normal_image,
|
||||
normal_space="OBJECT",
|
||||
)
|
||||
layer = _group_node(group, object_material.node_tree)
|
||||
assert layer.inputs["Object Space"].default_value is True
|
||||
|
||||
tangent_material = material_data.create_image_material(
|
||||
SimpleNamespace(name="Tangent Normal"),
|
||||
color,
|
||||
1.0,
|
||||
normal_image=normal_image,
|
||||
)
|
||||
layer = _group_node(group, tangent_material.node_tree)
|
||||
assert layer.inputs["Object Space"].default_value is False
|
||||
reused = material_data.material_node_group()
|
||||
assert {
|
||||
node.space for node in reused.nodes if node.bl_idname == "ShaderNodeNormalMap"
|
||||
} == {"TANGENT", "OBJECT"}
|
||||
|
||||
|
||||
def test_cutout_material_uses_ior_1_2():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Cutout", width=16, height=16, alpha=True)
|
||||
source = bpy.data.objects.new("Cutout", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
|
||||
result = _create_cutout_shape(source, "FLAT", 0.1)
|
||||
|
||||
shader = next(
|
||||
node
|
||||
for node in result.data.materials[0].node_tree.nodes
|
||||
if node.bl_idname == "ShaderNodeBsdfPrincipled"
|
||||
)
|
||||
assert np.isclose(shader.inputs["IOR"].default_value, 1.2)
|
||||
image_layer = _group_node(
|
||||
material_data.material_node_group(),
|
||||
result.data.materials[0].node_tree,
|
||||
)
|
||||
ior_level_link = next(
|
||||
link
|
||||
for link in result.data.materials[0].node_tree.links
|
||||
if link.to_node == shader
|
||||
and link.to_socket == shader.inputs["Specular IOR Level"]
|
||||
)
|
||||
assert ior_level_link.from_node == image_layer
|
||||
assert ior_level_link.from_socket == image_layer.outputs["Color"]
|
||||
|
||||
|
||||
def test_plane_material_color_drives_ior_level_but_shadeless_does_not():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Plane", width=2, height=2, alpha=True)
|
||||
source = SimpleNamespace(data=SimpleNamespace(name="Plane"))
|
||||
|
||||
material = material_data.create_image_material(source.data, image)
|
||||
shader = next(
|
||||
node
|
||||
for node in material.node_tree.nodes
|
||||
if node.bl_idname == "ShaderNodeBsdfPrincipled"
|
||||
)
|
||||
assert np.isclose(shader.inputs["IOR"].default_value, 1.2)
|
||||
image_layer = _group_node(material_data.material_node_group(), material.node_tree)
|
||||
assert image_layer.inputs["Bump Scale"].default_value == 0.0
|
||||
assert any(
|
||||
link.from_node == image_layer
|
||||
and link.from_socket == image_layer.outputs["Color"]
|
||||
and link.to_node == shader
|
||||
and link.to_socket == shader.inputs["Specular IOR Level"]
|
||||
for link in material.node_tree.links
|
||||
)
|
||||
|
||||
shadeless = material_data.create_image_material(
|
||||
source.data,
|
||||
image,
|
||||
0.0,
|
||||
shadeless=True,
|
||||
)
|
||||
assert not any(
|
||||
node.bl_idname == "ShaderNodeBsdfPrincipled"
|
||||
for node in shadeless.node_tree.nodes
|
||||
)
|
||||
|
||||
|
||||
def test_cutout_material_uses_flat_tangent_normal():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source = bpy.data.objects.new("Source", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = bpy.data.images.new("Source", width=2, height=2)
|
||||
color = bpy.data.images.new("Color", width=2, height=2)
|
||||
normal = bpy.data.images.new("Normal", width=1, height=1)
|
||||
|
||||
result = material_data.create_image_material(
|
||||
source.data,
|
||||
color,
|
||||
0.0,
|
||||
normal_image=normal,
|
||||
scene=bpy.context.scene,
|
||||
)
|
||||
|
||||
layer = _group_node(material_data.material_node_group(), result.node_tree)
|
||||
assert layer.inputs["Object Space"].default_value is False
|
||||
|
||||
|
||||
def test_loaded_normal_image_uses_normal_texture_name(tmp_path):
|
||||
from PIL import Image
|
||||
|
||||
path = tmp_path / "normal.png"
|
||||
Image.new("RGB", (1, 1), (128, 128, 255)).save(path)
|
||||
source = SimpleNamespace(data=SimpleNamespace(name="Poster.png"))
|
||||
|
||||
image = image_data.load_normal_result_image(path, source)
|
||||
|
||||
assert image.name == "Poster_normal.png"
|
||||
assert image.colorspace_settings.name == "Non-Color"
|
||||
assert image.packed_file is not None
|
||||
|
||||
|
||||
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)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
bpy.context.collection.objects.link(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
|
||||
assert first is not second
|
||||
assert first.name == "Poster_color.png"
|
||||
assert second.name == "Poster_color.png.001"
|
||||
206
tests/anyimage/common/test_image_target.py
Normal file
206
tests/anyimage/common/test_image_target.py
Normal file
@@ -0,0 +1,206 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from anyimage.common import image as images
|
||||
from anyimage.common import image_target as targets
|
||||
from tests.support.image_texture import texture, texture_context, write_result
|
||||
|
||||
|
||||
def test_target_uses_edit_tree_and_survives_renaming(texture, tmp_path):
|
||||
context = texture_context(texture, pin=True)
|
||||
target = targets.ImageEditTarget.capture(context)
|
||||
texture.name = "Renamed texture"
|
||||
bpy.data.materials["Texture material"].name = "Renamed material"
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
texture.id_data.nodes.active = other
|
||||
context.space_data.edit_tree = None
|
||||
write_result(tmp_path)
|
||||
target.apply(tmp_path / "foreground.png")
|
||||
assert tuple(texture.image.size) == (8, 6)
|
||||
assert other.image is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("color_space,alpha_mode,filename,shared", [
|
||||
("sRGB", "PREMUL", "foreground.png", True),
|
||||
("AgX Base sRGB", "STRAIGHT", "upscale.png", False),
|
||||
("Filmic sRGB", "STRAIGHT", "foreground.png", False),
|
||||
("sRGB", "STRAIGHT", "upscale.png", True),
|
||||
("AgX Base sRGB", "PREMUL", "foreground.png", True),
|
||||
])
|
||||
def test_image_actions_preserve_source_color_space_and_alpha(texture, tmp_path, color_space, alpha_mode, filename, shared):
|
||||
texture.image.colorspace_settings.name = color_space
|
||||
texture.image.alpha_mode = alpha_mode
|
||||
original = texture.image
|
||||
original_pixels = images.image_pixels(original)
|
||||
if shared:
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
other.image = original
|
||||
target = targets.ImageEditTarget.capture(texture_context(texture))
|
||||
write_result(tmp_path, filename)
|
||||
target.apply(tmp_path / filename)
|
||||
result = texture.image
|
||||
assert result.colorspace_settings.name == color_space
|
||||
assert result.alpha_mode == alpha_mode
|
||||
np.testing.assert_allclose(
|
||||
images.image_pixels(result).reshape(-1, 4),
|
||||
np.tile(np.array([128, 64, 32, 80]) / 255, (48, 1)),
|
||||
atol=1 / 255,
|
||||
)
|
||||
if shared:
|
||||
assert other.image == original
|
||||
assert original.alpha_mode == alpha_mode
|
||||
np.testing.assert_array_equal(images.image_pixels(original), original_pixels)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", ["world", "light", "compositor", "geometry", "missing_image", "other_node", "no_tree"])
|
||||
def test_invalid_node_context_does_not_fall_back_to_empty(texture, invalid):
|
||||
context = texture_context(texture)
|
||||
context.object = bpy.data.objects.new("Fallback Empty", None)
|
||||
context.object.empty_display_type = "IMAGE"
|
||||
context.object.data = texture.image
|
||||
if invalid == "world":
|
||||
context.space_data.shader_type = "WORLD"
|
||||
elif invalid == "light":
|
||||
context.space_data.id = bpy.data.lights.new("Light", type="POINT")
|
||||
elif invalid in {"compositor", "geometry"}:
|
||||
context.space_data.tree_type = "CompositorNodeTree" if invalid == "compositor" else "GeometryNodeTree"
|
||||
elif invalid == "missing_image":
|
||||
texture.image = None
|
||||
elif invalid == "other_node":
|
||||
texture.id_data.nodes.active = texture.id_data.nodes.get("Principled BSDF")
|
||||
else:
|
||||
context.space_data.edit_tree = None
|
||||
assert targets.image_edit_owner(context) is None
|
||||
with pytest.raises(RuntimeError, match="Select"):
|
||||
targets.ImageEditTarget.capture(context)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["delete", "recreate", "rebind", "delete_material", "delete_image", "identity"])
|
||||
def test_target_rejects_changed_identity_before_loading_result(texture, tmp_path, change):
|
||||
target = targets.ImageEditTarget.capture(texture_context(texture))
|
||||
tree = texture.id_data
|
||||
if change in {"delete", "recreate"}:
|
||||
name = texture.name
|
||||
tree.nodes.remove(texture)
|
||||
if change == "recreate":
|
||||
replacement = tree.nodes.new("ShaderNodeTexImage")
|
||||
replacement.name = name
|
||||
replacement.image = target.image
|
||||
elif change == "rebind":
|
||||
texture.image = bpy.data.images.new("Replacement", width=2, height=2)
|
||||
elif change == "delete_material":
|
||||
bpy.data.materials.remove(bpy.data.materials["Texture material"])
|
||||
elif change == "identity":
|
||||
# A reused RNA address must not make a replacement node the original target.
|
||||
texture["anyimage_identity"] = "replacement"
|
||||
else:
|
||||
bpy.data.images.remove(texture.image)
|
||||
count = len(bpy.data.images)
|
||||
with pytest.raises(RuntimeError, match="no longer available"):
|
||||
target.apply(tmp_path / "foreground.png")
|
||||
assert len(bpy.data.images) == count
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reference", ["sole", "fake_user", "editor", "same_tree", "other_material", "empty", "brush"])
|
||||
def test_texture_commit_preserves_structure_and_isolates_users(texture, tmp_path, reference):
|
||||
original = texture.image
|
||||
original.colorspace_settings.name = "Non-Color"
|
||||
expected_source = images.image_pixels(original)
|
||||
source_packed = bytes(original.packed_file.data)
|
||||
original.use_fake_user = reference == "fake_user"
|
||||
target = targets.ImageEditTarget.capture(texture_context(texture))
|
||||
# Add users after capture to exercise changes while an AI job is pending.
|
||||
if reference == "same_tree":
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
other.image = original
|
||||
elif reference == "other_material":
|
||||
material = bpy.data.materials.new("Other material")
|
||||
material.use_nodes = True
|
||||
other = material.node_tree.nodes.new("ShaderNodeTexImage")
|
||||
other.image = original
|
||||
elif reference == "empty":
|
||||
other = bpy.data.objects.new("Other Empty", None)
|
||||
other.empty_display_type = "IMAGE"
|
||||
other.data = original
|
||||
elif reference == "brush":
|
||||
other = bpy.data.textures.new("Brush texture", type="IMAGE")
|
||||
other.image = original
|
||||
elif reference == "editor":
|
||||
area = bpy.context.screen.areas[0]
|
||||
area.type = "IMAGE_EDITOR"
|
||||
area.spaces.active.image = original
|
||||
shared = reference in {"same_tree", "other_material", "empty", "brush"}
|
||||
links = [(link.from_socket, link.to_socket) for link in texture.id_data.links]
|
||||
write_result(tmp_path)
|
||||
target.apply(tmp_path / "foreground.png")
|
||||
assert (texture.image == original) == (not shared)
|
||||
assert tuple(texture.image.size) == (8, 6)
|
||||
assert texture.image.colorspace_settings.name == "Non-Color"
|
||||
assert texture.image.alpha_mode == original.alpha_mode
|
||||
assert texture.image.packed_file is not None
|
||||
assert tuple(texture.location) == (-400, 100)
|
||||
assert (texture.interpolation, texture.extension, texture.projection) == ("Closest", "CLIP", "BOX")
|
||||
assert [(link.from_socket, link.to_socket) for link in texture.id_data.links] == links
|
||||
assert original.use_fake_user == (reference == "fake_user")
|
||||
if shared:
|
||||
assert (other.data if reference == "empty" else other.image) == original
|
||||
assert bytes(original.packed_file.data) == source_packed
|
||||
np.testing.assert_array_equal(images.image_pixels(original), expected_source)
|
||||
else:
|
||||
assert texture.image.name == "Texture.png"
|
||||
assert len(bpy.data.images) == 1
|
||||
expected = images.image_pixels(texture.image)
|
||||
path = str(tmp_path / "texture.blend")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=path)
|
||||
bpy.ops.wm.open_mainfile(filepath=path)
|
||||
reopened = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"]
|
||||
np.testing.assert_allclose(images.image_pixels(reopened.image), expected, atol=1 / 255)
|
||||
assert len(reopened.id_data.links) == len(links)
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["MOVIE", "SEQUENCE"])
|
||||
def test_target_rejects_animated_image(texture, source):
|
||||
texture.image.source = source
|
||||
with pytest.raises(RuntimeError, match="Only still images"):
|
||||
targets.ImageEditTarget.capture(texture_context(texture))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shared", [False, True])
|
||||
def test_failed_texture_assignment_restores_source(texture, tmp_path, shared):
|
||||
original = texture.image
|
||||
original.pixels.foreach_set(np.tile([0.1, 0.3, 0.7, 0.5], 12).astype(np.float32))
|
||||
packed = bytes(original.packed_file.data)
|
||||
pixels = images.image_pixels(original)
|
||||
result = images.load_image_edit_result(original, write_result(tmp_path))
|
||||
count = len(bpy.data.images) - 1
|
||||
|
||||
class FailingTarget:
|
||||
failed = False
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(texture, name)
|
||||
|
||||
@property
|
||||
def image(self):
|
||||
return texture.image
|
||||
|
||||
@image.setter
|
||||
def image(self, value):
|
||||
texture.image = value
|
||||
if not self.failed:
|
||||
self.failed = True
|
||||
raise RuntimeError("Assignment failed")
|
||||
|
||||
with patch.object(targets, "has_other_image_user", return_value=shared):
|
||||
with pytest.raises(RuntimeError, match="Assignment failed"):
|
||||
targets.replace_texture_image(FailingTarget(), result)
|
||||
assert texture.image == original
|
||||
assert tuple(original.size) == (4, 3)
|
||||
assert bytes(original.packed_file.data) == packed
|
||||
np.testing.assert_array_equal(images.image_pixels(original), pixels)
|
||||
assert len(bpy.data.images) == count
|
||||
30
tests/anyimage/common/test_input_preparation.py
Normal file
30
tests/anyimage/common/test_input_preparation.py
Normal file
@@ -0,0 +1,30 @@
|
||||
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_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_animated_inputs_are_rejected_before_export(self):
|
||||
for source in ("MOVIE", "SEQUENCE"):
|
||||
with self.subTest(source=source), patch.object(self.image_data, "save_packed_image") as export:
|
||||
image = SimpleNamespace(source=source, packed_file=object())
|
||||
with self.assertRaisesRegex(RuntimeError, "Only still images"):
|
||||
self.image_data.prepare_image_input(image, None)
|
||||
export.assert_not_called()
|
||||
34
tests/anyimage/common/test_material_alpha.py
Normal file
34
tests/anyimage/common/test_material_alpha.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import bpy
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from anyimage.common import material
|
||||
from tests.support.materials import image_layer
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
@pytest.mark.parametrize("shadeless", [False, True])
|
||||
@pytest.mark.parametrize("view", ["AgX", "Unknown"])
|
||||
def test_material_alpha_strength_follows_preference(image_layer, monkeypatch, enabled, shadeless, view):
|
||||
monkeypatch.setattr(material, "material_node_group", lambda depth_plane=False: image_layer)
|
||||
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: enabled)
|
||||
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 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 == "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)
|
||||
second_layer = next(node for node in second.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer)
|
||||
assert second_layer.inputs["Alpha Fix"].default_value == float(not enabled)
|
||||
assert layer.inputs["Alpha Fix"].default_value == 0.5
|
||||
42
tests/anyimage/common/test_material_settings.py
Normal file
42
tests/anyimage/common/test_material_settings.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from tests.anyimage.common.support import load_common_modules
|
||||
|
||||
class MaterialSettingsTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.modules = load_common_modules()
|
||||
|
||||
def test_material_color_space_matches_scene_view_transform(self):
|
||||
mappings = {
|
||||
"ACES 1.3": "ACES 1.3 sRGB",
|
||||
"ACES 2.0": "ACES 2.0 sRGB",
|
||||
"AgX": "AgX Base sRGB",
|
||||
"Filmic": "Filmic sRGB",
|
||||
"Khronos PBR Neutral": "Khronos PBR Neutral sRGB",
|
||||
"Standard": "sRGB",
|
||||
}
|
||||
for view_transform, expected in mappings.items():
|
||||
with self.subTest(view_transform=view_transform):
|
||||
scene = SimpleNamespace(
|
||||
view_settings=SimpleNamespace(
|
||||
view_transform=view_transform,
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.modules.material.material_color_space_name(scene),
|
||||
expected,
|
||||
)
|
||||
|
||||
|
||||
def test_surface_displacement_is_set_to_displacement_only(self):
|
||||
class Material:
|
||||
surface_displacement_method = "BUMP"
|
||||
|
||||
material = Material()
|
||||
self.modules.material.set_displacement_only(material)
|
||||
|
||||
self.assertEqual(
|
||||
material.surface_displacement_method,
|
||||
"DISPLACEMENT",
|
||||
)
|
||||
175
tests/anyimage/common/test_material_view_adaptation.py
Normal file
175
tests/anyimage/common/test_material_view_adaptation.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
|
||||
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 tests.support.color_image import color_image
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def color_data():
|
||||
original_images = set(bpy.data.images)
|
||||
original_materials = set(bpy.data.materials)
|
||||
original_objects = set(bpy.data.objects)
|
||||
yield
|
||||
for obj in set(bpy.data.objects) - original_objects:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
for item in set(bpy.data.materials) - original_materials:
|
||||
bpy.data.materials.remove(item, do_unlink=True)
|
||||
for item in set(bpy.data.images) - original_images:
|
||||
bpy.data.images.remove(item, do_unlink=True)
|
||||
|
||||
|
||||
def view_scene(view):
|
||||
return SimpleNamespace(view_settings=SimpleNamespace(view_transform=view))
|
||||
|
||||
|
||||
def color_texture(result):
|
||||
return next(node.image for node in result.node_tree.nodes if node.type == "TEX_IMAGE")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
@pytest.mark.parametrize("view,space", [*material.VIEW_TRANSFORM_COLOR_SPACES, ("Standard", "sRGB"), ("Unknown", "sRGB")])
|
||||
def test_color_space_and_alpha_follow_preference(color_data, enabled, view, space):
|
||||
image = bpy.data.images.new("Material policy", width=2, height=1, alpha=True)
|
||||
expected = np.array([0.2, 0.4, 0.8, 0.25, 0.6, 0.3, 0.1, 0], dtype=np.float32)
|
||||
image.pixels.foreach_set(expected)
|
||||
settings = SimpleNamespace(adapt_material_to_view_transform=enabled)
|
||||
# The installed bpy may not include newer view transforms such as ACES 2.0.
|
||||
try:
|
||||
image.colorspace_settings.name = space
|
||||
except TypeError:
|
||||
space = "sRGB"
|
||||
image.colorspace_settings.name = "sRGB"
|
||||
image.pixels.foreach_set(expected)
|
||||
with patch.object(preferences, "addon_preferences", return_value=settings):
|
||||
result = material.configure_material_color_image(image, view_scene(view))
|
||||
selected = space if enabled else "sRGB"
|
||||
assert result.colorspace_settings.name == selected
|
||||
assert result.alpha_mode == ("PREMUL" if selected == "sRGB" else "STRAIGHT")
|
||||
assert settings.adapt_material_to_view_transform == enabled
|
||||
np.testing.assert_allclose(images.image_pixels(result), expected, atol=1 / 255)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
@pytest.mark.parametrize("kind", [
|
||||
"byte", "packed", "file", "dirty",
|
||||
"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)
|
||||
if kind in {"file", "dirty"}:
|
||||
path = tmp_path / "source.png"
|
||||
Image.fromarray(expected).save(path)
|
||||
source = bpy.data.images.load(str(path), check_existing=False)
|
||||
if kind == "dirty":
|
||||
source.pixels.foreach_set((expected.astype(np.float32) / 255).ravel())
|
||||
else:
|
||||
source = bpy.data.images.new("Material pixels", width=2, height=1, alpha=True, float_buffer=kind == "float")
|
||||
source.alpha_mode = "PREMUL"
|
||||
source.pixels.foreach_set((expected.astype(np.float32) / 255).ravel())
|
||||
if kind == "packed":
|
||||
source.pack()
|
||||
source.reload()
|
||||
before = images.image_pixels(source)
|
||||
owner = bpy.data.objects.new("Color source owner", None)
|
||||
owner.empty_display_type = "IMAGE"
|
||||
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
|
||||
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:
|
||||
result.pack()
|
||||
selected = (result.colorspace_settings.name, result.alpha_mode)
|
||||
path = tmp_path / "material.blend"
|
||||
bpy.data.libraries.write(str(path), {result})
|
||||
with bpy.data.libraries.load(str(path)) as (available, loaded):
|
||||
loaded.images = available.images
|
||||
restored = loaded.images[0]
|
||||
assert (restored.colorspace_settings.name, restored.alpha_mode) == selected
|
||||
np.testing.assert_allclose(images.image_pixels(restored), before, atol=1 / 255)
|
||||
edited = images.create_image_edit_result(restored, images.image_pixels(restored), (2, 1))
|
||||
assert edited.alpha_mode == selected[1]
|
||||
np.testing.assert_allclose(images.image_pixels(edited), before, atol=1 / 255)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shadeless", [False, True])
|
||||
@pytest.mark.parametrize("entry", ["plane", "cutout", "depth", "clipboard"])
|
||||
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)
|
||||
owner.empty_display_type = "IMAGE"
|
||||
owner.data = source
|
||||
before = images.image_pixels(source)
|
||||
scene = view_scene("AgX")
|
||||
settings = SimpleNamespace(adapt_material_to_view_transform=True)
|
||||
data_textures = {}
|
||||
if entry == "depth":
|
||||
for key in ("displacement_image", "normal_image"):
|
||||
image = bpy.data.images.new(key, width=2, height=2, float_buffer=True)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
data_textures[key] = image
|
||||
data_states = {
|
||||
key: (image.colorspace_settings.name, image.alpha_mode, images.image_pixels(image))
|
||||
for key, image in data_textures.items()
|
||||
}
|
||||
with patch.object(preferences, "addon_preferences", return_value=settings):
|
||||
if entry == "clipboard":
|
||||
first = clipboard.create_image_material(source, source, shadeless=shadeless, scene=scene)
|
||||
else:
|
||||
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)
|
||||
original_color = color_texture(first)
|
||||
assert original_color.colorspace_settings.name == "AgX Base sRGB"
|
||||
assert original_color.alpha_mode == "STRAIGHT"
|
||||
settings.adapt_material_to_view_transform = False
|
||||
assert original_color.alpha_mode == "STRAIGHT"
|
||||
if entry == "clipboard":
|
||||
second = clipboard.create_image_material(original_color, original_color, shadeless=shadeless, scene=scene)
|
||||
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.colorspace_settings.name == "sRGB"
|
||||
assert new_color.alpha_mode == "PREMUL"
|
||||
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" 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():
|
||||
space, alpha, pixels = data_states[key]
|
||||
assert image.colorspace_settings.name == space
|
||||
assert image.alpha_mode == alpha
|
||||
np.testing.assert_array_equal(images.image_pixels(image), pixels)
|
||||
|
||||
|
||||
def test_edit_results_preserve_source_alpha_with_adaptation_enabled(color_data, tmp_path):
|
||||
source = bpy.data.images.new("Editing source", width=1, height=1, alpha=True)
|
||||
rgba = np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32)
|
||||
Image.fromarray(np.array([[[51, 102, 204, 64]]], dtype=np.uint8)).save(tmp_path / "foreground.png")
|
||||
with patch.object(preferences, "addon_preferences", return_value=SimpleNamespace(adapt_material_to_view_transform=True)):
|
||||
edited = images.create_image_edit_result(source, rgba, (1, 1))
|
||||
loaded = images.load_image_edit_result(source, tmp_path / "foreground.png")
|
||||
for result in (edited, loaded):
|
||||
assert result.colorspace_settings.name == "sRGB"
|
||||
assert result.alpha_mode == source.alpha_mode
|
||||
np.testing.assert_allclose(images.image_pixels(result), rgba, atol=1 / 255)
|
||||
assert source.colorspace_settings.name == "sRGB"
|
||||
assert source.alpha_mode == "STRAIGHT"
|
||||
52
tests/anyimage/common/test_modifier_inputs.py
Normal file
52
tests/anyimage/common/test_modifier_inputs.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from tests.anyimage.common.support import load_common_modules
|
||||
|
||||
class ModifierInputsTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.modules = load_common_modules()
|
||||
|
||||
def test_runtime_modifier_enum_input_uses_string_identifier(self):
|
||||
enum_items = (
|
||||
SimpleNamespace(identifier="Balloon"),
|
||||
SimpleNamespace(identifier="Shell"),
|
||||
)
|
||||
value_property = SimpleNamespace(type="ENUM", enum_items=enum_items)
|
||||
input_slot = SimpleNamespace(
|
||||
value=None,
|
||||
bl_rna=SimpleNamespace(properties={"value": value_property}),
|
||||
)
|
||||
modifier = SimpleNamespace(
|
||||
properties=SimpleNamespace(
|
||||
inputs=SimpleNamespace(Mode=input_slot),
|
||||
)
|
||||
)
|
||||
|
||||
self.modules.object.set_modifier_input(modifier, "Mode", 1)
|
||||
|
||||
self.assertEqual(input_slot.value, "Shell")
|
||||
|
||||
|
||||
def test_modifier_inputs_support_rna_and_dictionary_values(self):
|
||||
value_property = SimpleNamespace(type="FLOAT")
|
||||
input_slot = SimpleNamespace(
|
||||
value=None,
|
||||
bl_rna=SimpleNamespace(properties={"value": value_property}),
|
||||
)
|
||||
runtime_modifier = SimpleNamespace(
|
||||
properties=SimpleNamespace(
|
||||
inputs=SimpleNamespace(Thickness=input_slot),
|
||||
)
|
||||
)
|
||||
|
||||
self.modules.object.set_modifier_input(runtime_modifier, "Thickness", 2.0)
|
||||
|
||||
self.assertEqual(input_slot.value, 2.0)
|
||||
|
||||
class DictionaryModifier(dict):
|
||||
properties = None
|
||||
|
||||
dictionary_modifier = DictionaryModifier()
|
||||
self.modules.object.set_modifier_input(dictionary_modifier, "Mode", 1)
|
||||
self.assertEqual(dictionary_modifier["Mode"], 1)
|
||||
192
tests/anyimage/common/test_selection.py
Normal file
192
tests/anyimage/common/test_selection.py
Normal file
@@ -0,0 +1,192 @@
|
||||
import json
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
from anyimage.common import selection
|
||||
|
||||
|
||||
def reference_selection_mask(
|
||||
image_size,
|
||||
selection_path,
|
||||
*,
|
||||
antialias=False,
|
||||
):
|
||||
width, height = map(int, image_size)
|
||||
points = np.asarray(selection_path.points, dtype=np.float64)
|
||||
x = np.arange(width, dtype=np.float64) + 0.5
|
||||
y = np.arange(height, dtype=np.float64) + 0.5
|
||||
winding = np.zeros((height, width), dtype=np.int16)
|
||||
previous = points[-1]
|
||||
for current in points:
|
||||
crosses = (current[1] > y) != (previous[1] > y)
|
||||
edge_x = (previous[0] - current[0]) * (y - current[1]) / (
|
||||
previous[1] - current[1] + 1e-300
|
||||
) + current[0]
|
||||
selected = crosses[:, None] & (x[None, :] < edge_x[:, None])
|
||||
winding += selected * (1 if current[1] > previous[1] else -1)
|
||||
previous = current
|
||||
mask = winding != 0
|
||||
mask = (
|
||||
selection._antialias_mask(mask)
|
||||
if antialias
|
||||
else mask.astype(np.float32)
|
||||
)
|
||||
bounds = selection._selection_canvas_bounds(
|
||||
image_size,
|
||||
selection_path,
|
||||
2 if antialias else 0,
|
||||
)
|
||||
left, top, right, bottom = bounds
|
||||
mask = mask[top:bottom, left:right]
|
||||
if not np.any(mask > 0.0):
|
||||
raise selection.ImageEditWarning(
|
||||
"The selection contains no visible image pixels"
|
||||
)
|
||||
return selection.SelectionMask(mask, bounds)
|
||||
|
||||
|
||||
def assert_matches_reference(image_size, selection_path, **options):
|
||||
try:
|
||||
expected = reference_selection_mask(image_size, selection_path, **options)
|
||||
except selection.ImageEditWarning:
|
||||
with pytest.raises(selection.ImageEditWarning):
|
||||
selection.rasterize_selection_path(
|
||||
image_size,
|
||||
selection_path,
|
||||
**options,
|
||||
)
|
||||
return
|
||||
actual = selection.rasterize_selection_path(
|
||||
image_size,
|
||||
selection_path,
|
||||
**options,
|
||||
)
|
||||
assert actual.bounds == expected.bounds
|
||||
np.testing.assert_array_equal(actual.values, expected.values)
|
||||
|
||||
|
||||
def test_selection_path_json_contains_only_points():
|
||||
selection_path = selection.SelectionPath(
|
||||
points=((1, 2), (8, 2), (4, 9)),
|
||||
)
|
||||
value = selection_path.to_json()
|
||||
|
||||
assert set(json.loads(value)) == {"points"}
|
||||
assert selection.SelectionPath.from_json(value) == selection_path
|
||||
with pytest.raises(ValueError, match="Selection Path data is invalid"):
|
||||
selection.SelectionPath.from_json(
|
||||
json.dumps(
|
||||
{
|
||||
"points": selection_path.points,
|
||||
"fill_rule": "NONZERO",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_nonzero_winding_keeps_same_direction_overlap():
|
||||
points = (
|
||||
(1, 1),
|
||||
(7, 1),
|
||||
(7, 7),
|
||||
(1, 7),
|
||||
(1, 1),
|
||||
(4, 1),
|
||||
(10, 1),
|
||||
(10, 7),
|
||||
(4, 7),
|
||||
(4, 1),
|
||||
(1, 1),
|
||||
)
|
||||
|
||||
mask = selection.rasterize_selection_path(
|
||||
(12, 9),
|
||||
selection.SelectionPath(points),
|
||||
).full_values((12, 9))
|
||||
|
||||
assert mask[3, 5] == 1.0
|
||||
|
||||
|
||||
def test_scanline_matches_reference_for_edge_cases():
|
||||
paths = (
|
||||
((1, 1), (15, 1), (15, 10), (1, 10)),
|
||||
((0.2, 0.3), (15.8, 2.1), (8.4, 11.7)),
|
||||
((1, 1), (15, 1), (15, 5), (7, 5), (7, 11), (1, 11)),
|
||||
((1, 1), (15, 11), (1, 11), (15, 1)),
|
||||
((-8, -3), (10, 1), (23, 8), (7, 18), (-4, 10)),
|
||||
)
|
||||
for points in paths:
|
||||
for antialias in (False, True):
|
||||
assert_matches_reference(
|
||||
(18, 14),
|
||||
selection.SelectionPath(points),
|
||||
antialias=antialias,
|
||||
)
|
||||
|
||||
|
||||
def test_scanline_matches_reference_for_random_paths():
|
||||
random = np.random.default_rng(20260901)
|
||||
for _index in range(40):
|
||||
point_count = int(random.integers(3, 60))
|
||||
points = tuple(
|
||||
map(
|
||||
tuple,
|
||||
random.uniform((-8, -6), (40, 30), size=(point_count, 2)),
|
||||
)
|
||||
)
|
||||
for antialias in (False, True):
|
||||
assert_matches_reference(
|
||||
(32, 24),
|
||||
selection.SelectionPath(points),
|
||||
antialias=antialias,
|
||||
)
|
||||
|
||||
|
||||
def test_antialias_uses_only_padded_local_canvas(monkeypatch):
|
||||
selection_path = selection.SelectionPath(
|
||||
((100, 200), (500, 200), (500, 600), (100, 600))
|
||||
)
|
||||
calls = []
|
||||
rasterize = selection._rasterize_path
|
||||
|
||||
def record(bounds, points):
|
||||
calls.append(bounds)
|
||||
return rasterize(bounds, points)
|
||||
|
||||
monkeypatch.setattr(selection, "_rasterize_path", record)
|
||||
|
||||
selection.rasterize_selection_path(
|
||||
(5120, 2880),
|
||||
selection_path,
|
||||
antialias=True,
|
||||
)
|
||||
|
||||
assert calls == [(98, 198, 502, 602)]
|
||||
|
||||
|
||||
def test_high_resolution_lasso_allocates_one_local_scanline_table(monkeypatch):
|
||||
angles = np.arange(1000, dtype=np.float64) * (2.0 * np.pi / 1000.0)
|
||||
points = tuple(
|
||||
zip(
|
||||
2560.0 + 500.0 * np.cos(angles),
|
||||
1440.0 + 300.0 * np.sin(angles),
|
||||
)
|
||||
)
|
||||
bounds = selection.selection_path_bounds(points)
|
||||
allocations = []
|
||||
zeros = np.zeros
|
||||
|
||||
def record(shape, *args, **kwargs):
|
||||
allocations.append(shape)
|
||||
return zeros(shape, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(np, "zeros", record)
|
||||
|
||||
mask = selection._rasterize_path(bounds, points)
|
||||
|
||||
width = bounds[2] - bounds[0]
|
||||
height = bounds[3] - bounds[1]
|
||||
assert mask.shape == (height, width)
|
||||
assert allocations == [(height, width + 1)]
|
||||
assert (height, width) != (2880, 5120)
|
||||
0
tests/anyimage/operators/__init__.py
Normal file
0
tests/anyimage/operators/__init__.py
Normal file
113
tests/anyimage/operators/test_ai_image_operators.py
Normal file
113
tests/anyimage/operators/test_ai_image_operators.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class AiImageOperatorsTest(BlenderTestCase):
|
||||
def test_upscale_operator_sends_the_selected_model(self):
|
||||
operator_type = self.upscale.UpscaleImage
|
||||
self.assertIn(operator_type, self.anyimage.CLASSES)
|
||||
self.assertIn(self.runtime.JobOperatorBase, operator_type.__mro__)
|
||||
self.assertEqual(operator_type.bl_options, {"UNDO"})
|
||||
operator = operator_type()
|
||||
operator.input_path = "input.png"
|
||||
operator._image_target = SimpleNamespace()
|
||||
operator.model = "HAT_GAN_X4_SHARPER"
|
||||
operator.max_input_size = 1024
|
||||
context = SimpleNamespace(
|
||||
scene=SimpleNamespace(anyimage_settings=SimpleNamespace())
|
||||
)
|
||||
with (
|
||||
patch.object(self.upscale, "require_environment"),
|
||||
patch.object(self.upscale, "require_model") as require_model,
|
||||
patch.object(
|
||||
self.upscale, "require_input_path", return_value=Path("input.png")
|
||||
),
|
||||
patch.object(self.upscale, "production_device", return_value="CUDA"),
|
||||
):
|
||||
request = operator.request(context)
|
||||
|
||||
require_model.assert_called_once_with("HAT_GAN_X4_SHARPER")
|
||||
self.assertEqual(
|
||||
request,
|
||||
{
|
||||
"input": "input.png",
|
||||
"model": "HAT_GAN_X4_SHARPER",
|
||||
"device": "cuda",
|
||||
"max_input_size": 1024,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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 = operator.invoke(None, None)
|
||||
|
||||
setup.assert_called_once_with(operator)
|
||||
self.assertIs(result, expected)
|
||||
|
||||
|
||||
def test_upscale_operator_runs_immediately_when_ai_is_ready(self):
|
||||
operator = self.upscale.UpscaleImage()
|
||||
context = object()
|
||||
expected = {"FINISHED"}
|
||||
with (
|
||||
patch.object(
|
||||
self.upscale,
|
||||
"invoke_ai_setup_if_needed",
|
||||
return_value=None,
|
||||
),
|
||||
patch.object(
|
||||
operator,
|
||||
"execute",
|
||||
return_value=expected,
|
||||
) as execute,
|
||||
):
|
||||
result = operator.invoke(context, None)
|
||||
|
||||
execute.assert_called_once_with(context)
|
||||
self.assertIs(result, expected)
|
||||
self.assertNotIn("REGISTER", operator.bl_options)
|
||||
|
||||
|
||||
def test_remove_background_loads_a_plain_image_edit_result(self):
|
||||
result = SimpleNamespace(file=lambda key: Path("result") / f"{key}.png")
|
||||
operator = self.remove_background.RunBackgroundRemoval()
|
||||
operator._image_target = Mock()
|
||||
ops = SimpleNamespace(
|
||||
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
|
||||
)
|
||||
with (
|
||||
patch.object(self.remove_background.bpy, "ops", ops, create=True),
|
||||
):
|
||||
operator.response(None, result)
|
||||
|
||||
operator._image_target.apply.assert_called_once_with(Path("result/foreground.png"))
|
||||
ops.ed.undo_push.assert_called_once_with(message="Remove Background")
|
||||
|
||||
|
||||
def test_upscale_loads_a_plain_image_edit_result(self):
|
||||
result = SimpleNamespace(file=lambda key: Path("result") / f"{key}.png")
|
||||
operator = self.upscale.UpscaleImage()
|
||||
operator._image_target = Mock()
|
||||
operator.model = "HAT_GAN_X4_SHARPER"
|
||||
with (
|
||||
patch.object(
|
||||
self.upscale,
|
||||
"upscale_model_label",
|
||||
return_value="HAT Sharper",
|
||||
),
|
||||
):
|
||||
message = operator.response(None, result)
|
||||
|
||||
operator._image_target.apply.assert_called_once_with(
|
||||
Path("result/upscale.png"),
|
||||
)
|
||||
self.assertEqual(message, "Image is upscaled with HAT Sharper")
|
||||
74
tests/anyimage/operators/test_ai_input_failures.py
Normal file
74
tests/anyimage/operators/test_ai_input_failures.py
Normal file
@@ -0,0 +1,74 @@
|
||||
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)))
|
||||
target.prepare.return_value = (path, False)
|
||||
context = SimpleNamespace(scene=SimpleNamespace(
|
||||
anyimage_settings=SimpleNamespace()))
|
||||
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="HAT_GAN_X4_SHARPER"))
|
||||
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_invoke_ai_setup_reports_nested_failure(self):
|
||||
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())
|
||||
192
tests/anyimage/operators/test_ai_setup.py
Normal file
192
tests/anyimage/operators/test_ai_setup.py
Normal file
@@ -0,0 +1,192 @@
|
||||
import importlib
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class AiSetupTest(BlenderTestCase):
|
||||
def test_anyimage_post_install_is_ordinary_code_using_runtime_request(self):
|
||||
calls = []
|
||||
fake_runtime = SimpleNamespace(
|
||||
request=lambda job_type, parameters: calls.append((job_type, parameters))
|
||||
)
|
||||
self.runtime.post_install(fake_runtime)
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
("download-model", {"model": "MOGE2_VITS_NORMAL"}),
|
||||
("download-model", {"model": "BIREFNET_LITE"}),
|
||||
(
|
||||
"download-model",
|
||||
{"model": "REALESRGAN_GENERAL_WDN_X4V3"},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_anyimage_post_install_names_each_failed_model(self):
|
||||
calls = []
|
||||
|
||||
def request(_job_type, parameters):
|
||||
calls.append(parameters["model"])
|
||||
raise RuntimeError("network unavailable")
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
(
|
||||
"MoGe-2 ViT-S Normal: network unavailable; "
|
||||
"BiRefNet Lite: network unavailable; "
|
||||
"Real-ESRGAN General WDN x4v3: network unavailable"
|
||||
),
|
||||
):
|
||||
self.runtime.post_install(SimpleNamespace(request=request))
|
||||
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
"MOGE2_VITS_NORMAL",
|
||||
"BIREFNET_LITE",
|
||||
"REALESRGAN_GENERAL_WDN_X4V3",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_required_model_operator_requests_all_default_models(self):
|
||||
operator = self.anyimage.operators.DownloadRequiredModels()
|
||||
with patch.object(self.ai_setup, "require_environment"):
|
||||
parameters = operator.request(None)
|
||||
|
||||
self.assertEqual(
|
||||
parameters,
|
||||
{
|
||||
"models": [
|
||||
"MOGE2_VITS_NORMAL",
|
||||
"BIREFNET_LITE",
|
||||
"REALESRGAN_GENERAL_WDN_X4V3",
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_ai_status_checks_environment_and_required_model_files(self):
|
||||
properties = self.anyimage.properties
|
||||
with (
|
||||
patch.object(self.anyimage.runtime, "environment_ready", return_value=True),
|
||||
patch.object(
|
||||
properties,
|
||||
"model_catalog",
|
||||
return_value=(
|
||||
{"key": "MOGE2_VITS_NORMAL", "ready": True},
|
||||
{"key": "BIREFNET_LITE", "ready": False},
|
||||
{
|
||||
"key": "REALESRGAN_GENERAL_WDN_X4V3",
|
||||
"ready": True,
|
||||
},
|
||||
),
|
||||
),
|
||||
):
|
||||
status = properties.ai_status()
|
||||
self.assertTrue(status["environment_ready"])
|
||||
self.assertEqual(status["missing_models"], ("BIREFNET_LITE",))
|
||||
self.assertFalse(status["ready"])
|
||||
self.assertEqual(properties.ai_setup_label(status), "Download Required Models…")
|
||||
|
||||
with (
|
||||
patch.object(self.anyimage.runtime, "environment_ready", return_value=False),
|
||||
patch.object(
|
||||
properties,
|
||||
"model_catalog",
|
||||
return_value=tuple(
|
||||
{"key": key, "ready": True}
|
||||
for key in properties.shared_model_catalog.DEFAULT_MODEL_KEYS
|
||||
),
|
||||
),
|
||||
):
|
||||
status = properties.ai_status()
|
||||
self.assertFalse(status["ready"])
|
||||
self.assertEqual(
|
||||
properties.ai_setup_label(status), "Set Up AI Server…"
|
||||
)
|
||||
|
||||
|
||||
def test_setup_ai_environment_selects_the_needed_install_step(self):
|
||||
calls = []
|
||||
ops = SimpleNamespace(
|
||||
anyimage=SimpleNamespace(
|
||||
install_environment=lambda *_args: (
|
||||
calls.append("official") or {"FINISHED"}
|
||||
),
|
||||
install_environment_mirror=lambda *_args: (
|
||||
calls.append("mirror") or {"FINISHED"}
|
||||
),
|
||||
download_required_models=lambda *_args: (
|
||||
calls.append("models") or {"FINISHED"}
|
||||
),
|
||||
)
|
||||
)
|
||||
operator = self.ai_setup.SetupAIEnvironment()
|
||||
with patch.object(self.fake_bpy, "ops", ops, create=True):
|
||||
operator.use_mirror = True
|
||||
with patch.object(
|
||||
self.ai_setup,
|
||||
"ai_status",
|
||||
return_value={
|
||||
"environment_ready": False,
|
||||
"missing_models": ("MOGE2_VITS_NORMAL", "BIREFNET_LITE"),
|
||||
"ready": False,
|
||||
},
|
||||
):
|
||||
self.assertEqual(operator.execute(None), {"FINISHED"})
|
||||
|
||||
with patch.object(
|
||||
self.ai_setup,
|
||||
"ai_status",
|
||||
return_value={
|
||||
"environment_ready": True,
|
||||
"missing_models": ("BIREFNET_LITE",),
|
||||
"ready": False,
|
||||
},
|
||||
):
|
||||
self.assertEqual(operator.execute(None), {"FINISHED"})
|
||||
|
||||
self.assertEqual(calls, ["mirror", "models"])
|
||||
|
||||
|
||||
def test_ai_environment_ui_types_are_registered(self):
|
||||
self.assertIn(self.ai_setup.SetupAIEnvironment, self.anyimage.CLASSES)
|
||||
self.assertIn(
|
||||
self.ai_setup.OpenAIEnvironmentSettings,
|
||||
self.anyimage.CLASSES,
|
||||
)
|
||||
|
||||
|
||||
def test_ui_reads_required_model_files_without_starting_server(self):
|
||||
properties = importlib.import_module("anyimage.properties")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
moge = root / "models" / "moge-2-vits-normal-onnx"
|
||||
lite = root / "models" / "birefnet-lite"
|
||||
wdn = root / "models" / "realesr-general-wdn-x4v3"
|
||||
moge.mkdir(parents=True)
|
||||
lite.mkdir(parents=True)
|
||||
wdn.mkdir(parents=True)
|
||||
(moge / "model.onnx").touch()
|
||||
(lite / "model.onnx").touch()
|
||||
(lite / "LICENSE.txt").touch()
|
||||
(wdn / "realesr-general-wdn-x4v3.onnx").touch()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.anyimage.runtime,
|
||||
"storage_root",
|
||||
return_value=root,
|
||||
),
|
||||
patch.object(
|
||||
self.anyimage.runtime,
|
||||
"resource",
|
||||
side_effect=AssertionError("UI must not start the Server"),
|
||||
),
|
||||
):
|
||||
self.assertEqual(properties.ai_status()["missing_models"], ())
|
||||
78
tests/anyimage/operators/test_balloon_profiles.py
Normal file
78
tests/anyimage/operators/test_balloon_profiles.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from unittest.mock import patch
|
||||
import numpy as np
|
||||
|
||||
|
||||
from anyimage.operators.cutout_tool import balloon
|
||||
from anyimage.operators.cutout_tool.balloon import poisson_balloon_profile
|
||||
|
||||
|
||||
def test_poisson_balloon_profile_is_zero_on_boundary_and_positive_inside():
|
||||
points = np.asarray(
|
||||
((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)),
|
||||
dtype=float,
|
||||
)
|
||||
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
|
||||
boundary = np.asarray((True, True, True, True, False))
|
||||
|
||||
profile = poisson_balloon_profile(points, faces, boundary)
|
||||
|
||||
assert np.all(profile[:4] == 0.0)
|
||||
assert profile[4] > 0.0
|
||||
|
||||
|
||||
def test_poisson_balloon_profile_scales_in_world_units():
|
||||
points = np.asarray(
|
||||
((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)),
|
||||
dtype=float,
|
||||
)
|
||||
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
|
||||
boundary = np.asarray((True, True, True, True, False))
|
||||
|
||||
original = poisson_balloon_profile(points, faces, boundary)
|
||||
scaled = poisson_balloon_profile(points * 2.0, faces, boundary)
|
||||
|
||||
assert np.isclose(scaled[4], original[4] * 2.0, atol=1e-6)
|
||||
|
||||
|
||||
def test_poisson_balloon_profile_makes_wide_selections_thicker():
|
||||
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
|
||||
boundary = np.asarray((True, True, True, True, False))
|
||||
wide = np.asarray(
|
||||
((0, 0), (4, 0), (4, 2), (0, 2), (2, 1)),
|
||||
dtype=float,
|
||||
)
|
||||
narrow = np.asarray(
|
||||
((0, 0), (4, 0), (4, 0.6), (0, 0.6), (2, 0.3)),
|
||||
dtype=float,
|
||||
)
|
||||
|
||||
wide_profile = poisson_balloon_profile(wide, faces, boundary)
|
||||
narrow_profile = poisson_balloon_profile(narrow, faces, boundary)
|
||||
|
||||
assert wide_profile[4] > narrow_profile[4]
|
||||
|
||||
|
||||
def test_teddy_balloon_profile_uses_chordal_axis_radius():
|
||||
points = np.asarray(
|
||||
((0, 0), (4, 0), (4, 2), (0, 2), (2, 1)),
|
||||
dtype=float,
|
||||
)
|
||||
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
|
||||
boundary = np.asarray((True, True, True, True, False))
|
||||
nodes = np.asarray(((1, 1), (3, 1)), dtype=float)
|
||||
|
||||
with patch.object(
|
||||
balloon,
|
||||
"chordal_axis",
|
||||
return_value=(nodes, [(0, 1)], np.asarray((1.0, 1.0))),
|
||||
):
|
||||
profile = balloon.teddy_balloon_heights(
|
||||
points,
|
||||
faces,
|
||||
points[:4],
|
||||
boundary,
|
||||
0.45,
|
||||
)
|
||||
|
||||
assert np.all(profile[:4] == 0.0)
|
||||
assert np.isclose(profile[4], 0.45, atol=1e-6)
|
||||
37
tests/anyimage/operators/test_brush_preview_integration.py
Normal file
37
tests/anyimage/operators/test_brush_preview_integration.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
from anyimage.operators.clipboard_image.actions import _set_brush_preview
|
||||
|
||||
|
||||
def test_packed_brush_preview_preserves_image_and_survives_reopen(tmp_path):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Preview Source", width=80, height=40, alpha=True)
|
||||
pixels = np.zeros((40, 80, 4), dtype=np.float32)
|
||||
pixels[:, :40] = (1.0, 0.0, 0.0, 1.0)
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.pack()
|
||||
original_pixels = tuple(image.pixels)
|
||||
original_format = image.file_format
|
||||
brush = bpy.data.brushes.new("Preview Brush", mode="TEXTURE_PAINT")
|
||||
brush.asset_mark()
|
||||
try:
|
||||
_set_brush_preview(bpy.context, brush, image)
|
||||
assert tuple(brush.preview.image_size) == (80, 40)
|
||||
preview = np.array(brush.preview.image_pixels_float[:]).reshape(40, 80, 4)
|
||||
assert np.allclose(preview[:, :40, 0], 1.0, atol=1 / 255)
|
||||
assert np.all(preview[:, :40, 3] == 1.0)
|
||||
assert np.all(preview[:, 40:, 3] == 0.0)
|
||||
assert tuple(image.pixels) == original_pixels
|
||||
assert tuple(image.size) == (80, 40)
|
||||
assert image.packed_file is not None
|
||||
assert image.file_format == original_format
|
||||
expected_preview = tuple(brush.preview.image_pixels)
|
||||
path = str(tmp_path / "brush-preview.blend")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=path)
|
||||
bpy.ops.wm.open_mainfile(filepath=path)
|
||||
restored = bpy.data.brushes["Preview Brush"]
|
||||
assert tuple(restored.preview.image_size) == (80, 40)
|
||||
assert tuple(restored.preview.image_pixels) == expected_preview
|
||||
finally:
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
129
tests/anyimage/operators/test_brush_strokes.py
Normal file
129
tests/anyimage/operators/test_brush_strokes.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
from anyimage.common import selection, viewport
|
||||
from anyimage.operators import mask_tool as mask
|
||||
|
||||
|
||||
def test_simplified_segments_bound_every_original_sample():
|
||||
raw = [(float(x), float(20 * np.sin(x / 15))) for x in np.arange(0, 160, 0.2)]
|
||||
path, samples = [], []
|
||||
for point in raw:
|
||||
viewport.append_lasso_point(path, point, samples)
|
||||
assert len(path) < len(raw) / 5
|
||||
for start, end in zip(path, path[1:]):
|
||||
segment = np.array(end) - start
|
||||
points = np.array([point for point in raw if start[0] <= point[0] <= end[0]])
|
||||
factor = np.clip((points - start) @ segment / (segment @ segment), 0, 1)
|
||||
distances = np.linalg.norm(points - (np.array(start) + factor[:, None] * segment), axis=1)
|
||||
assert distances.max() <= 0.75 + 1e-10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("radius", [2, 5, 25])
|
||||
@pytest.mark.parametrize("path", [
|
||||
((40, 70), (70, 70), (70, 100)),
|
||||
((35, 35), (100, 100), (35, 100), (100, 35)),
|
||||
])
|
||||
def test_brush_antialiases_the_hard_union_once(radius, path):
|
||||
size = (150, 150)
|
||||
hard = np.zeros(size[::-1], dtype=bool)
|
||||
for polygon in viewport.brush_footprint_polygons(path, radius):
|
||||
hard |= selection._rasterize_path((0, 0, *size), polygon)
|
||||
result = mask.rasterize_brush_path(size, path, radius, lambda points: points)
|
||||
np.testing.assert_allclose(result.full_values(size), selection._antialias_mask(hard))
|
||||
assert result.values.size < size[0] * size[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("radius", [2, 5, 25])
|
||||
def test_straight_stroke_is_independent_of_segmentation(radius):
|
||||
size = (160, 160)
|
||||
sparse = ((40, 70), (110, 70))
|
||||
dense = tuple((x, 70) for x in range(40, 111, 5))
|
||||
results = [mask.rasterize_brush_path(size, path, radius, lambda p: p).full_values(size)
|
||||
for path in (sparse, dense)]
|
||||
np.testing.assert_array_equal(*results)
|
||||
|
||||
|
||||
def test_incremental_preview_matches_full_union_when_tail_moves_and_crosses():
|
||||
cache = viewport.BrushPreview()
|
||||
path = [(10, 10)]
|
||||
operations = [(False, (100, 100)), (True, (110, 90)), (False, (10, 90)),
|
||||
(False, (110, 10)), (True, (105, 15)), (False, (10, 10))]
|
||||
for replace, point in [(False, None), *operations]:
|
||||
if point is not None:
|
||||
if replace:
|
||||
path[-1] = point
|
||||
else:
|
||||
path.append(point)
|
||||
cache.update(path, 8)
|
||||
reference = {round(bottom / cache.step): intervals
|
||||
for bottom, _, intervals in viewport.scanline_union_bands(
|
||||
viewport.brush_footprint_polygons(path, 8), cache.step)
|
||||
if intervals}
|
||||
assert cache.coverage.keys() == reference.keys()
|
||||
for row in reference:
|
||||
np.testing.assert_allclose(cache.coverage[row], reference[row], atol=1e-10)
|
||||
|
||||
|
||||
def test_preview_reuses_batches_and_limits_updates_to_changed_rows():
|
||||
cache = viewport.BrushPreview()
|
||||
path = [(10, y) for y in range(0, 800, 10)]
|
||||
cache.update(path, 5)
|
||||
gpu = SimpleNamespace(shader=Mock(), state=Mock())
|
||||
build = Mock(side_effect=lambda *args: Mock())
|
||||
with patch.dict(sys.modules, {"gpu": gpu, "gpu_extras.batch": SimpleNamespace(batch_for_shader=build)}):
|
||||
cache.draw((1, 1, 1, 0.16))
|
||||
initial = dict(cache.batches)
|
||||
builds = build.call_count
|
||||
with patch.object(cache, "_polygon_rows", wraps=cache._polygon_rows) as rasterize:
|
||||
cache.update(path, 5)
|
||||
cache.draw((1, 1, 1, 0.16))
|
||||
rasterize.assert_not_called()
|
||||
assert build.call_count == builds
|
||||
path.append((10, 800))
|
||||
cache.update(path, 5)
|
||||
assert rasterize.call_count == 2
|
||||
assert all(len(call.args[0]) <= 2 for call in rasterize.call_args_list)
|
||||
cache.draw((1, 1, 1, 0.16))
|
||||
changed = {chunk for chunk, batches in initial.items() if cache.batches[chunk] is not batches}
|
||||
assert len(changed) <= 2
|
||||
assert len(initial) > 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exit_kind", ["ESC", "SWITCH", "ERROR", "COMPLETE"])
|
||||
def test_gesture_releases_preview_and_samples_on_every_exit(exit_kind):
|
||||
gesture = viewport.ImageGesture()
|
||||
gesture.gesture = "BRUSH"
|
||||
gesture.active_tool_id = "anyimage.mask"
|
||||
gesture._path = [(0, 0), (10, 10)]
|
||||
gesture._path_samples = list(gesture._path)
|
||||
gesture._brush_preview = viewport.BrushPreview()
|
||||
handle = gesture._handle = object()
|
||||
gesture._brush_preview.update(gesture._path, 5)
|
||||
context = SimpleNamespace(workspace=Mock(), area=Mock())
|
||||
event = SimpleNamespace(type="ESC" if exit_kind == "ESC" else "LEFTMOUSE",
|
||||
value="PRESS" if exit_kind == "ESC" else "RELEASE",
|
||||
mouse_region_x=10, mouse_region_y=10)
|
||||
gesture.report = Mock()
|
||||
def complete(context):
|
||||
if exit_kind == "ERROR":
|
||||
raise ValueError("Failed projection")
|
||||
gesture._finish(context)
|
||||
return {"FINISHED"}
|
||||
gesture._complete = complete
|
||||
tool = "builtin.select" if exit_kind == "SWITCH" else gesture.active_tool_id
|
||||
remove = Mock()
|
||||
fake_bpy = SimpleNamespace(types=SimpleNamespace(SpaceView3D=SimpleNamespace(draw_handler_remove=remove)))
|
||||
with patch.object(viewport, "active_view3d_tool_id", return_value=tool), patch.object(viewport, "bpy", fake_bpy):
|
||||
result = gesture.modal(context, event)
|
||||
gesture._finish(context)
|
||||
remove.assert_called_once_with(handle, "WINDOW")
|
||||
assert gesture._handle is None
|
||||
assert result == ({"FINISHED"} if exit_kind == "COMPLETE" else {"CANCELLED"})
|
||||
assert gesture._path is gesture._path_samples is gesture._brush_preview is None
|
||||
assert gesture._fill_triangles == gesture._outline_segments == ()
|
||||
assert viewport.ImageGesture()._brush_preview is None
|
||||
111
tests/anyimage/operators/test_clipboard_cache.py
Normal file
111
tests/anyimage/operators/test_clipboard_cache.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""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", "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"
|
||||
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
|
||||
523
tests/anyimage/operators/test_clipboard_image.py
Normal file
523
tests/anyimage/operators/test_clipboard_image.py
Normal file
@@ -0,0 +1,523 @@
|
||||
import importlib
|
||||
import struct
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class ClipboardImageTest(BlenderTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.clipboard_image = importlib.import_module(
|
||||
"anyimage.operators.clipboard_image.operators"
|
||||
)
|
||||
self.actions = importlib.import_module(
|
||||
"anyimage.operators.clipboard_image.actions"
|
||||
)
|
||||
self.clipboard = importlib.import_module(
|
||||
"anyimage.operators.clipboard_image.clipboard"
|
||||
)
|
||||
|
||||
def test_registers_and_removes_clipboard_shortcuts(self):
|
||||
class KeyMapItems:
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
|
||||
def new(self, operator_id, event_type, value, **modifiers):
|
||||
item = SimpleNamespace(
|
||||
idname=operator_id,
|
||||
type=event_type,
|
||||
value=value,
|
||||
**modifiers,
|
||||
)
|
||||
self.items.append(item)
|
||||
return item
|
||||
|
||||
def remove(self, item):
|
||||
self.items.remove(item)
|
||||
|
||||
class KeyMaps:
|
||||
def __init__(self):
|
||||
self.items = []
|
||||
|
||||
def new(self, name, space_type):
|
||||
keymap = SimpleNamespace(
|
||||
name=name,
|
||||
space_type=space_type,
|
||||
keymap_items=KeyMapItems(),
|
||||
)
|
||||
self.items.append(keymap)
|
||||
return keymap
|
||||
|
||||
keymaps = KeyMaps()
|
||||
window_manager = SimpleNamespace(
|
||||
keyconfigs=SimpleNamespace(
|
||||
addon=SimpleNamespace(keymaps=keymaps),
|
||||
),
|
||||
)
|
||||
clipboard_image = self.clipboard_image
|
||||
with patch.object(
|
||||
self.fake_bpy.context,
|
||||
"window_manager",
|
||||
window_manager,
|
||||
create=True,
|
||||
):
|
||||
clipboard_image.register_keymaps()
|
||||
items = [
|
||||
item
|
||||
for keymap in keymaps.items
|
||||
for item in keymap.keymap_items.items
|
||||
]
|
||||
self.assertEqual(
|
||||
[item.idname for item in items],
|
||||
[
|
||||
"anyimage.paste_clipboard_image",
|
||||
"anyimage.track_native_copy",
|
||||
"anyimage.paste_clipboard_image",
|
||||
"anyimage.track_native_copy",
|
||||
],
|
||||
)
|
||||
modifier = "oskey" if sys.platform == "darwin" else "ctrl"
|
||||
self.assertTrue(all(getattr(item, modifier) for item in items))
|
||||
clipboard_image.unregister_keymaps()
|
||||
|
||||
self.assertTrue(
|
||||
all(not keymap.keymap_items.items for keymap in keymaps.items)
|
||||
)
|
||||
|
||||
def test_routes_supported_contexts(self):
|
||||
contexts = (
|
||||
(self.make_context("VIEW_3D"), "PLANE"),
|
||||
(
|
||||
self.make_context("VIEW_3D", mode="SCULPT"),
|
||||
"TOOL_TEXTURE",
|
||||
),
|
||||
(
|
||||
self.make_context("VIEW_3D", mode="PAINT_VERTEX"),
|
||||
"TOOL_TEXTURE",
|
||||
),
|
||||
(
|
||||
self.make_context("VIEW_3D", mode="PAINT_TEXTURE"),
|
||||
"TOOL_TEXTURE",
|
||||
),
|
||||
(
|
||||
self.make_context(
|
||||
"NODE_EDITOR",
|
||||
tree_type="ShaderNodeTree",
|
||||
),
|
||||
"NODE",
|
||||
),
|
||||
(
|
||||
self.make_context(
|
||||
"NODE_EDITOR",
|
||||
tree_type="CompositorNodeTree",
|
||||
),
|
||||
"NODE",
|
||||
),
|
||||
(
|
||||
self.make_context(
|
||||
"NODE_EDITOR",
|
||||
tree_type="GeometryNodeTree",
|
||||
),
|
||||
"NODE",
|
||||
),
|
||||
)
|
||||
|
||||
for context, expected_target in contexts:
|
||||
with self.subTest(expected_target=expected_target):
|
||||
self.assertEqual(
|
||||
self.actions.target_for_context(context),
|
||||
expected_target,
|
||||
)
|
||||
|
||||
def test_rejects_unsupported_node_tree(self):
|
||||
context = self.make_context(
|
||||
"NODE_EDITOR",
|
||||
tree_type="TextureNodeTree",
|
||||
)
|
||||
|
||||
self.assertIsNone(self.actions.target_for_context(context))
|
||||
|
||||
def test_node_type_follows_the_world_space(self):
|
||||
self.assertEqual(
|
||||
self.actions._node_type_for_space(
|
||||
SimpleNamespace(tree_type="ShaderNodeTree", shader_type="WORLD")
|
||||
),
|
||||
"ShaderNodeTexEnvironment",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.actions._node_type_for_space(
|
||||
SimpleNamespace(tree_type="ShaderNodeTree", shader_type="OBJECT")
|
||||
),
|
||||
"ShaderNodeTexImage",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.actions._node_type_for_space(
|
||||
SimpleNamespace(
|
||||
tree_type="ShaderNodeTree",
|
||||
shader_type="OBJECT",
|
||||
id=SimpleNamespace(
|
||||
bl_rna=SimpleNamespace(identifier="World"),
|
||||
),
|
||||
)
|
||||
),
|
||||
"ShaderNodeTexEnvironment",
|
||||
)
|
||||
|
||||
def test_node_location_matches_native_ui_scale_and_anchor_offset(self):
|
||||
view2d = SimpleNamespace(
|
||||
region_to_view=Mock(return_value=(200.0, 100.0)),
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
region=SimpleNamespace(view2d=view2d),
|
||||
preferences=SimpleNamespace(
|
||||
system=SimpleNamespace(ui_scale=2.0),
|
||||
),
|
||||
space_data=SimpleNamespace(cursor_location=(1.0, 2.0)),
|
||||
)
|
||||
event = SimpleNamespace(mouse_region_x=80, mouse_region_y=60)
|
||||
|
||||
location = self.actions._node_location(context, event)
|
||||
|
||||
self.assertEqual(location, (85.0, 55.0))
|
||||
view2d.region_to_view.assert_called_once_with(80, 60)
|
||||
cursor_context = SimpleNamespace(
|
||||
space_data=SimpleNamespace(cursor_location=(12.0, -8.0)),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.actions._node_location(cursor_context, None),
|
||||
(12.0, -8.0),
|
||||
)
|
||||
|
||||
def test_preserves_image_aspect_ratio_for_plane(self):
|
||||
self.assertEqual(
|
||||
self.actions._plane_half_dimensions(1920, 1080),
|
||||
(1.0, 0.5625),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.actions._plane_half_dimensions(800, 1200),
|
||||
(2 / 3, 1.0),
|
||||
)
|
||||
|
||||
def test_native_copy_defers_paste_until_the_clipboard_changes(self):
|
||||
clipboard_image = self.clipboard_image
|
||||
original_token = clipboard_image._native_copy_token
|
||||
try:
|
||||
clipboard_image._native_copy_token = 41
|
||||
with patch.object(
|
||||
clipboard_image,
|
||||
"clipboard_change_token",
|
||||
return_value=41,
|
||||
):
|
||||
self.assertTrue(clipboard_image._should_defer_to_native_paste())
|
||||
with patch.object(
|
||||
clipboard_image,
|
||||
"clipboard_change_token",
|
||||
return_value=42,
|
||||
):
|
||||
self.assertFalse(clipboard_image._should_defer_to_native_paste())
|
||||
with patch.object(
|
||||
clipboard_image,
|
||||
"clipboard_change_token",
|
||||
return_value=73,
|
||||
):
|
||||
result = clipboard_image.TrackNativeCopy().execute(None)
|
||||
self.assertEqual(result, {"PASS_THROUGH"})
|
||||
self.assertEqual(clipboard_image._native_copy_token, 73)
|
||||
finally:
|
||||
clipboard_image._native_copy_token = original_token
|
||||
|
||||
def test_reference_image_uses_drag_and_drop_size(self):
|
||||
reference = SimpleNamespace(select_set=Mock())
|
||||
objects = SimpleNamespace(new=Mock(return_value=reference))
|
||||
context = SimpleNamespace(
|
||||
mode="OBJECT",
|
||||
collection=SimpleNamespace(objects=SimpleNamespace(link=Mock())),
|
||||
selected_objects=(),
|
||||
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None)),
|
||||
region_data=None,
|
||||
)
|
||||
image = SimpleNamespace(name="Clipboard", size=(1920, 1080))
|
||||
|
||||
with patch.object(self.actions.bpy.data, "objects", objects, create=True):
|
||||
result = self.actions.add_reference_image(
|
||||
context,
|
||||
image,
|
||||
location=(1.0, 2.0, 3.0),
|
||||
)
|
||||
|
||||
self.assertIs(result, reference)
|
||||
self.assertEqual(reference.empty_display_size, 5.0)
|
||||
|
||||
def test_redo_reuses_captured_node_location(self):
|
||||
operator = self.clipboard_image.PasteClipboardImage()
|
||||
operator.paste_target = "NODE"
|
||||
operator.location = (10.0, 20.0, 0.0)
|
||||
operator.location_set = True
|
||||
operator.import_as = "REFERENCE"
|
||||
operator.shadeless = False
|
||||
operator.report = Mock()
|
||||
context = self.make_context(
|
||||
"NODE_EDITOR",
|
||||
tree_type="ShaderNodeTree",
|
||||
)
|
||||
image = SimpleNamespace(name="Clipboard Image")
|
||||
|
||||
clipboard_image = self.clipboard_image
|
||||
with (
|
||||
patch.object(
|
||||
clipboard_image,
|
||||
"_should_defer_to_native_paste",
|
||||
return_value=False,
|
||||
),
|
||||
patch.object(
|
||||
clipboard_image,
|
||||
"read_clipboard_image",
|
||||
return_value=(b"image", ".png"),
|
||||
),
|
||||
patch.object(
|
||||
clipboard_image,
|
||||
"acquire_packed_image",
|
||||
return_value=(image, True),
|
||||
),
|
||||
patch.object(clipboard_image, "paste_image") as paste_image,
|
||||
):
|
||||
result = operator.execute(context)
|
||||
|
||||
self.assertEqual(result, {"FINISHED"})
|
||||
paste_image.assert_called_once_with(
|
||||
context,
|
||||
image,
|
||||
None,
|
||||
location=(10.0, 20.0),
|
||||
import_as="REFERENCE",
|
||||
shadeless=False,
|
||||
)
|
||||
|
||||
def test_image_plane_requires_object_mode(self):
|
||||
context = SimpleNamespace(mode="EDIT_MESH")
|
||||
image = SimpleNamespace(size=(100, 100))
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
self.actions.PasteTargetError,
|
||||
"Object Mode",
|
||||
):
|
||||
self.actions.add_image_plane(context, image)
|
||||
|
||||
def test_stencil_dimensions_preserve_scale_and_image_aspect(self):
|
||||
self.assertEqual(
|
||||
self.actions._stencil_dimensions(
|
||||
(1920, 1080),
|
||||
(400.0, 200.0),
|
||||
),
|
||||
(400.0, 225.0),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.actions._stencil_dimensions(
|
||||
(800, 1200),
|
||||
(128.0, 256.0),
|
||||
),
|
||||
(256.0 * 2 / 3, 256.0),
|
||||
)
|
||||
|
||||
def test_finds_existing_packed_clipboard_image(self):
|
||||
matching_image = FakeImage(
|
||||
self.actions.CLIPBOARD_HASH_PROPERTY,
|
||||
"matching-hash",
|
||||
packed=True,
|
||||
)
|
||||
unpacked_image = FakeImage(
|
||||
self.actions.CLIPBOARD_HASH_PROPERTY,
|
||||
"unpacked-hash",
|
||||
packed=False,
|
||||
)
|
||||
original_data = self.actions.bpy.data
|
||||
self.actions.bpy.data = SimpleNamespace(
|
||||
images=[matching_image, unpacked_image],
|
||||
)
|
||||
try:
|
||||
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")
|
||||
)
|
||||
finally:
|
||||
self.actions.bpy.data = original_data
|
||||
|
||||
def test_names_new_packed_image_clipboard(self):
|
||||
image = FakeLoadedImage()
|
||||
images = FakeLoadedImages(image)
|
||||
original_data = self.actions.bpy.data
|
||||
self.actions.bpy.data = SimpleNamespace(images=images)
|
||||
try:
|
||||
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
|
||||
|
||||
self.assertIs(loaded_image, image)
|
||||
self.assertFalse(reused)
|
||||
self.assertEqual(image.name, "Clipboard")
|
||||
self.assertTrue(image.packed)
|
||||
|
||||
def test_sets_and_reuses_stencil_brush_texture(self):
|
||||
textures = FakeTextures()
|
||||
original_data = self.actions.bpy.data
|
||||
self.actions.bpy.data = SimpleNamespace(textures=textures)
|
||||
brush = SimpleNamespace(
|
||||
is_editable=True,
|
||||
texture=None,
|
||||
texture_slot=SimpleNamespace(
|
||||
texture=None,
|
||||
map_mode="VIEW_PLANE",
|
||||
),
|
||||
stencil_dimension=(256.0, 256.0),
|
||||
update_tag=lambda: None,
|
||||
)
|
||||
context = SimpleNamespace(brush=brush)
|
||||
image = SimpleNamespace(name="Clipboard Image", size=(1920, 1080))
|
||||
try:
|
||||
first_texture = self.actions.set_tool_texture(context, image)
|
||||
second_texture = self.actions.set_tool_texture(context, image)
|
||||
finally:
|
||||
self.actions.bpy.data = original_data
|
||||
|
||||
self.assertIs(first_texture, second_texture)
|
||||
self.assertEqual(len(textures), 1)
|
||||
self.assertIs(brush.texture, first_texture)
|
||||
self.assertIs(brush.texture_slot.texture, first_texture)
|
||||
self.assertEqual(brush.texture_slot.map_mode, "STENCIL")
|
||||
self.assertEqual(brush.stencil_dimension, (256.0, 144.0))
|
||||
|
||||
def test_vertex_and_texture_paint_use_unbiased_white(self):
|
||||
for mode in ("PAINT_VERTEX", "PAINT_TEXTURE"):
|
||||
with self.subTest(mode=mode):
|
||||
brush = SimpleNamespace(color=(0.2, 0.4, 0.8))
|
||||
unified_settings = SimpleNamespace(color=(0.8, 0.4, 0.2))
|
||||
context = SimpleNamespace(
|
||||
mode=mode,
|
||||
tool_settings=SimpleNamespace(
|
||||
**{
|
||||
self.actions.PAINT_SETTINGS_BY_MODE[mode][0]:
|
||||
SimpleNamespace(
|
||||
unified_paint_settings=unified_settings,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
self.actions._set_unbiased_paint_color(context, brush)
|
||||
|
||||
self.assertEqual(brush.color, (1.0, 1.0, 1.0))
|
||||
self.assertEqual(
|
||||
unified_settings.color,
|
||||
(1.0, 1.0, 1.0),
|
||||
)
|
||||
|
||||
def test_sculpt_keeps_brush_color(self):
|
||||
brush = SimpleNamespace(color=(0.2, 0.4, 0.8))
|
||||
|
||||
self.actions._set_unbiased_paint_color(
|
||||
SimpleNamespace(mode="SCULPT"),
|
||||
brush,
|
||||
)
|
||||
|
||||
self.assertEqual(brush.color, (0.2, 0.4, 0.8))
|
||||
|
||||
def test_wraps_info_dib_as_bmp(self):
|
||||
dib_header = struct.pack(
|
||||
"<IiiHHIIiiII",
|
||||
40,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
24,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
dib_data = dib_header + b"\x00\x00\xff\x00"
|
||||
|
||||
bmp_data = self.clipboard.dib_to_bmp(dib_data)
|
||||
|
||||
signature, file_size, _, _, pixel_offset = struct.unpack_from(
|
||||
"<2sIHHI",
|
||||
bmp_data,
|
||||
)
|
||||
self.assertEqual(signature, b"BM")
|
||||
self.assertEqual(file_size, len(bmp_data))
|
||||
self.assertEqual(pixel_offset, 54)
|
||||
self.assertEqual(bmp_data[14:], dib_data)
|
||||
|
||||
def test_rejects_truncated_dib(self):
|
||||
with self.assertRaises(self.clipboard.ClipboardImageError):
|
||||
self.clipboard.dib_to_bmp(b"\x28\x00\x00\x00")
|
||||
|
||||
@staticmethod
|
||||
def make_context(area_type, mode="OBJECT", **space_options):
|
||||
return SimpleNamespace(
|
||||
area=SimpleNamespace(type=area_type),
|
||||
mode=mode,
|
||||
region=SimpleNamespace(type="WINDOW"),
|
||||
space_data=SimpleNamespace(**space_options),
|
||||
)
|
||||
|
||||
class FakeImage:
|
||||
def __init__(self, property_name, image_hash, packed):
|
||||
self.properties = {property_name: image_hash}
|
||||
self.packed_file = object() if packed else None
|
||||
self.packed_files = ()
|
||||
|
||||
def get(self, property_name, default=None):
|
||||
return self.properties.get(property_name, default)
|
||||
|
||||
|
||||
class FakeLoadedImage(dict):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = None
|
||||
self.packed = False
|
||||
|
||||
def pack(self):
|
||||
self.packed = True
|
||||
|
||||
|
||||
class FakeLoadedImages(list):
|
||||
def __init__(self, loaded_image):
|
||||
super().__init__()
|
||||
self.loaded_image = loaded_image
|
||||
|
||||
def load(self, _path, check_existing):
|
||||
return self.loaded_image
|
||||
|
||||
def remove(self, image):
|
||||
super().remove(image)
|
||||
|
||||
|
||||
class FakeTexture(dict):
|
||||
def __init__(self, name, texture_type):
|
||||
super().__init__()
|
||||
self.name = name
|
||||
self.type = texture_type
|
||||
self.image = None
|
||||
|
||||
|
||||
class FakeTextures(list):
|
||||
def new(self, name, type):
|
||||
texture = FakeTexture(name, type)
|
||||
self.append(texture)
|
||||
return texture
|
||||
|
||||
def remove(self, texture):
|
||||
super().remove(texture)
|
||||
190
tests/anyimage/operators/test_conversion_source_images.py
Normal file
190
tests/anyimage/operators/test_conversion_source_images.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""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),
|
||||
"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"
|
||||
image.pixels.foreach_set(np.ones(8, dtype=np.float32))
|
||||
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,sharing", [
|
||||
("PLANE", "none"),
|
||||
("PLANE", "material"),
|
||||
("DEPTH", "visible"),
|
||||
])
|
||||
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,failure,entry", [
|
||||
(False, "material", "PLANE"),
|
||||
(True, "modifier", "DEPTH"),
|
||||
(False, "finalize", "PLANE"),
|
||||
])
|
||||
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,kind", [(False, "generated"), (True, "file")])
|
||||
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)
|
||||
143
tests/anyimage/operators/test_cutout_boundary_padding.py
Normal file
143
tests/anyimage/operators/test_cutout_boundary_padding.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import numpy as np
|
||||
|
||||
from anyimage.operators.cutout_tool.boundary_padding import (
|
||||
apply_boundary_mapping,
|
||||
apply_depth_boundary_mapping,
|
||||
build_boundary_mapping,
|
||||
image_rgba_buffer,
|
||||
pad_cutout_images,
|
||||
)
|
||||
|
||||
|
||||
def square_with_hole():
|
||||
values = np.zeros((17, 17), dtype=np.float32)
|
||||
values[3:14, 3:14] = 1
|
||||
values[7:10, 7:10] = 0
|
||||
return values
|
||||
|
||||
|
||||
def test_outer_and_hole_boundaries_follow_the_local_interior():
|
||||
mapping = build_boundary_mapping(square_with_hole(), 0.9, 2)
|
||||
assert mapping.target[2, 8]
|
||||
assert mapping.source_y[2, 8] > 3
|
||||
assert abs(mapping.source_x[2, 8] - 8) < 0.25
|
||||
assert mapping.target[7, 8]
|
||||
assert mapping.source_y[7, 8] < 7
|
||||
assert abs(mapping.source_x[7, 8] - 8) < 0.5
|
||||
|
||||
|
||||
def test_mapping_moves_along_one_direction_instead_of_nearest_content():
|
||||
values = square_with_hole()
|
||||
yy, xx = np.mgrid[:17, :17]
|
||||
rgba = np.stack((xx, yy, xx + yy, np.ones_like(xx)), axis=-1).astype(np.float32)
|
||||
mapping = build_boundary_mapping(values, 0.9, 3)
|
||||
result = apply_boundary_mapping(rgba, mapping)
|
||||
assert result[1, 8, 1] > 3
|
||||
assert abs(result[1, 8, 0] - 8) < 0.5
|
||||
|
||||
|
||||
def test_boundary_mapping_aligns_inner_and_outer_source_coordinates():
|
||||
values = np.zeros((9, 9), dtype=np.float32)
|
||||
values[3:6, 3:6] = 1.0
|
||||
|
||||
mapping = build_boundary_mapping(values, 0.9, 2)
|
||||
|
||||
np.testing.assert_allclose(
|
||||
mapping.source_x[4, 2],
|
||||
mapping.source_x[4, 3],
|
||||
atol=0.25,
|
||||
)
|
||||
|
||||
|
||||
def test_thin_structure_stops_at_the_last_point_on_the_same_ray():
|
||||
values = np.zeros((11, 15), dtype=np.float32)
|
||||
values[5:7, 2:13] = 1
|
||||
mapping = build_boundary_mapping(values, 0.9, 4)
|
||||
sampled_inside = values[
|
||||
np.rint(mapping.source_y[mapping.target]).astype(int),
|
||||
np.rint(mapping.source_x[mapping.target]).astype(int),
|
||||
]
|
||||
assert sampled_inside.size
|
||||
assert np.all(sampled_inside == 1)
|
||||
assert np.isfinite(mapping.source_y).all()
|
||||
assert np.isfinite(mapping.source_x).all()
|
||||
|
||||
|
||||
def test_scaled_mapping_uses_proportional_padding_and_zero_is_identity():
|
||||
values = square_with_hole()
|
||||
original = build_boundary_mapping(values, 0.9, 2)
|
||||
scaled = build_boundary_mapping(values, 0.9, 2, (34, 34))
|
||||
assert scaled.target.sum() > original.target.sum() * 3
|
||||
disabled = build_boundary_mapping(values, 0.9, 0)
|
||||
assert not disabled.target.any()
|
||||
|
||||
|
||||
def test_depth_moves_z_and_validity_but_reprojects_xy_on_target_rays():
|
||||
values = np.zeros((9, 9), dtype=np.float32)
|
||||
values[2:7, 2:7] = 1
|
||||
mapping = build_boundary_mapping(values, 0.9, 2)
|
||||
yy, xx = np.mgrid[:9, :9]
|
||||
rgba = np.zeros((9, 9, 4), dtype=np.float32)
|
||||
rgba[..., 2] = yy + 10
|
||||
rgba[..., 3] = yy / 10
|
||||
intrinsics = np.array(((4, 0, 4), (0, 4, 4), (0, 0, 1)), dtype=np.float32)
|
||||
result = apply_depth_boundary_mapping(rgba, mapping, intrinsics)
|
||||
y, x = 1, 4
|
||||
assert mapping.target[y, x]
|
||||
assert result[y, x, 2] > rgba[2, x, 2]
|
||||
assert result[y, x, 0] == 0
|
||||
assert result[y, x, 1] == (y - 4) / 4 * result[y, x, 2]
|
||||
assert result[y, x, 3] > rgba[2, x, 3]
|
||||
|
||||
|
||||
class PixelBuffer:
|
||||
def __init__(self, values):
|
||||
self.values = np.asarray(values, dtype=np.float32).copy()
|
||||
|
||||
def foreach_get(self, target):
|
||||
target[:] = self.values
|
||||
|
||||
def foreach_set(self, values):
|
||||
self.values[:] = values
|
||||
|
||||
|
||||
class Image:
|
||||
def __init__(self, top_down):
|
||||
height, width = top_down.shape[:2]
|
||||
self.size = (width, height)
|
||||
self.pixels = PixelBuffer(np.flipud(top_down).ravel())
|
||||
self.updated = 0
|
||||
self.packed = 0
|
||||
|
||||
def update(self):
|
||||
self.updated += 1
|
||||
|
||||
def pack(self):
|
||||
self.packed += 1
|
||||
|
||||
|
||||
def test_client_processing_updates_and_packs_color_and_depth_together():
|
||||
content = square_with_hole()
|
||||
yy, xx = np.mgrid[:17, :17]
|
||||
color_values = np.stack((xx, yy, xx + yy, content), axis=-1).astype(np.float32)
|
||||
original = color_values.copy()
|
||||
color = Image(color_values)
|
||||
depth_values = np.zeros((17, 17, 4), dtype=np.float32)
|
||||
depth_values[..., 2] = yy + 10
|
||||
depth_values[..., 3] = content
|
||||
depth = Image(depth_values)
|
||||
metadata = {
|
||||
"intrinsics": ((8.0, 0.0, 8.0), (0.0, 8.0, 8.0), (0.0, 0.0, 1.0)),
|
||||
}
|
||||
original_metadata = dict(metadata)
|
||||
|
||||
pad_cutout_images(color, depth, metadata, content, 0.9, 2)
|
||||
|
||||
padded_color = image_rgba_buffer(color)
|
||||
padded_depth = image_rgba_buffer(depth)
|
||||
np.testing.assert_array_equal(padded_color[5, 5], original[5, 5])
|
||||
assert padded_color[2, 8, 3] == 1
|
||||
assert padded_depth[2, 8, 2] > depth_values[3, 8, 2]
|
||||
assert padded_depth[2, 8, 0] == 0
|
||||
assert metadata == original_metadata
|
||||
assert (color.updated, color.packed, depth.updated, depth.packed) == (1, 1, 1, 1)
|
||||
40
tests/anyimage/operators/test_cutout_depth.py
Normal file
40
tests/anyimage/operators/test_cutout_depth.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
from anyimage.operators.cutout_tool.object import load_cutout_node_group
|
||||
from anyimage.operators.cutout_tool.operators import normal_mode_for_shape
|
||||
from anyimage.operators.cutout_tool.mesh import build_mesh
|
||||
from anyimage.operators.cutout_tool.balloon import poisson_balloon_profile
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
def test_depth_presets_always_generate_the_correct_normal_space(enabled):
|
||||
assert normal_mode_for_shape("DEPTH_SOLID", enabled) == "OBJECT"
|
||||
assert normal_mode_for_shape("DEPTH_SYMMETRY", enabled) == "OBJECT"
|
||||
for shape in ("FLAT", "SOLID"):
|
||||
assert normal_mode_for_shape(shape, enabled) == ("TANGENT" if enabled else "NONE")
|
||||
|
||||
|
||||
def test_new_cutout_reuses_a_saved_wrapper():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
saved = bpy.data.node_groups.new("O Image Cutout", "GeometryNodeTree")
|
||||
saved.interface.new_socket(name="Shape", in_out="INPUT", socket_type="NodeSocketMenu")
|
||||
saved.use_fake_user = True
|
||||
assert load_cutout_node_group("SOLID") == saved
|
||||
assert load_cutout_node_group("FLAT") == saved
|
||||
assert saved.name == "O Image Cutout"
|
||||
assert any(item.name == "Shape" for item in saved.interface.items_tree)
|
||||
|
||||
|
||||
def test_depth_cutout_reuses_existing_group():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
saved = bpy.data.node_groups.new("O Image Depth Cutout", "GeometryNodeTree")
|
||||
assert load_cutout_node_group("DEPTH_SOLID") == saved
|
||||
assert saved.name == "O Image Depth Cutout"
|
||||
|
||||
|
||||
def test_small_quality_mesh_has_positive_balloon_support():
|
||||
points, faces, boundary, _, _ = build_mesh(np.ones((2, 2)), 32, .9)
|
||||
profile = poisson_balloon_profile(points, faces, boundary)
|
||||
assert np.all(profile[boundary] == 0)
|
||||
assert profile.max() > 0
|
||||
147
tests/anyimage/operators/test_cutout_edge_length.py
Normal file
147
tests/anyimage/operators/test_cutout_edge_length.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
from mathutils import Matrix
|
||||
|
||||
from anyimage import properties
|
||||
from anyimage.operators.cutout_tool import geometry as g
|
||||
from anyimage.operators.cutout_tool import operators
|
||||
|
||||
|
||||
def source_plane(size=(100, 100), display_size=1, matrix=None):
|
||||
return SimpleNamespace(
|
||||
data=SimpleNamespace(size=size),
|
||||
empty_display_size=display_size,
|
||||
empty_image_offset=(-0.5, -0.5),
|
||||
matrix_world=Matrix.Identity(4) if matrix is None else matrix,
|
||||
)
|
||||
|
||||
|
||||
def test_relative_floor_uses_visible_world_bounds(monkeypatch):
|
||||
monkeypatch.setattr(g, "configured_min_cutout_relative_edge_length", lambda _: 0.01)
|
||||
alpha = np.zeros((100, 100))
|
||||
alpha[25:75, 25:75] = 1
|
||||
source = source_plane(display_size=2)
|
||||
resolve = lambda length: g.effective_cutout_edge_length(None, source, length, alpha, (0, 0, 100, 100))
|
||||
assert resolve(0.001) == 0.01
|
||||
assert resolve(0.1) == 0.1
|
||||
source.matrix_world = Matrix.Rotation(0.7, 4, "Y") @ Matrix.Diagonal((2, 3, 1, 1))
|
||||
assert resolve(0.001) == pytest.approx(0.03)
|
||||
monkeypatch.setattr(g, "configured_min_cutout_relative_edge_length", lambda _: 0.02)
|
||||
assert resolve(0.001) == pytest.approx(0.06)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fine_outline", (False, True))
|
||||
def test_mesh_density_survives_resolution_and_canvas_changes(fine_outline):
|
||||
meshes = []
|
||||
for size, display_size, inset in ((100, 1, 0), (200, 1, 0), (200, 2, 50)):
|
||||
source = source_plane((size, size), display_size)
|
||||
alpha = np.zeros((size, size))
|
||||
alpha[inset:size-inset, inset:size-inset] = 1
|
||||
spacing = g.effective_cutout_edge_length(bpy.context, source, 0.1, alpha, (0, 0, size, size))
|
||||
assert spacing == 0.1
|
||||
meshes.append(g.build_base_shape(bpy.context, source, spacing, alpha, (0, 0, size, size), fine_outline=fine_outline))
|
||||
counts = [len(mesh.vertices) for mesh in meshes]
|
||||
assert max(counts) / min(counts) < 1.05
|
||||
for mesh in meshes:
|
||||
assert np.isfinite(mesh.vertices).all()
|
||||
np.testing.assert_allclose(np.ptp(mesh.vertices[:, [0, 2]], axis=0), (1, 1), atol=0.03)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fine_outline", (False, True))
|
||||
def test_world_scaling_adds_samples_and_rotation_preserves_density(fine_outline):
|
||||
alpha = np.ones((100, 100))
|
||||
counts = []
|
||||
for matrix in (Matrix.Identity(4), Matrix.Diagonal((2, 3, 1, 1)),
|
||||
Matrix.Rotation(0.6, 4, "Z") @ Matrix.Diagonal((2, 3, 1, 1))):
|
||||
source = source_plane(matrix=matrix)
|
||||
mesh = g.build_base_shape(bpy.context, source, 0.1, alpha, (0, 0, 100, 100), fine_outline=fine_outline)
|
||||
counts.append(len(mesh.vertices))
|
||||
planar = mesh.vertices[:, [0, 2]] @ g.metric_transform(g.world_plane_metric(matrix)[0])
|
||||
edges = np.unique(np.sort(np.concatenate([mesh.faces[:, [0, 1]], mesh.faces[:, [1, 2]], mesh.faces[:, [2, 0]]]), axis=1), axis=0)
|
||||
lengths = np.linalg.norm(planar[edges[:, 0]] - planar[edges[:, 1]], axis=1)
|
||||
assert 0.06 < np.median(lengths) < 0.14
|
||||
assert counts[1] > counts[0] * 4
|
||||
assert abs(counts[2] - counts[1]) < counts[1] * 0.02
|
||||
|
||||
|
||||
def test_length_property_uses_scene_units_and_preserves_physical_value():
|
||||
class CutoutLengthTestSettings(bpy.types.PropertyGroup):
|
||||
edge_length: properties.cutout_edge_length_property(store_meters=True)
|
||||
|
||||
scene = bpy.context.scene
|
||||
previous_scale, previous_system = scene.unit_settings.scale_length, scene.unit_settings.system
|
||||
bpy.utils.register_class(CutoutLengthTestSettings)
|
||||
bpy.types.Scene.cutout_length_test = bpy.props.PointerProperty(type=CutoutLengthTestSettings)
|
||||
try:
|
||||
scene.unit_settings.system = "METRIC"
|
||||
scene.unit_settings.scale_length = 0.01
|
||||
settings = scene.cutout_length_test
|
||||
assert settings.edge_length == pytest.approx(10)
|
||||
settings.edge_length = bpy.utils.units.to_value("METRIC", "LENGTH", "0.2m") / scene.unit_settings.scale_length
|
||||
assert settings.edge_length == pytest.approx(20)
|
||||
scene.unit_settings.scale_length = 1
|
||||
assert settings.edge_length == pytest.approx(0.2)
|
||||
finally:
|
||||
scene.unit_settings.scale_length = previous_scale
|
||||
scene.unit_settings.system = previous_system
|
||||
if "cutout_length_test" in scene:
|
||||
del scene["cutout_length_test"]
|
||||
del bpy.types.Scene.cutout_length_test
|
||||
bpy.utils.unregister_class(CutoutLengthTestSettings)
|
||||
|
||||
|
||||
def test_operator_length_property_accepts_world_units():
|
||||
captured = []
|
||||
|
||||
class CutoutLengthTestOperator(bpy.types.Operator):
|
||||
bl_idname = "anyimage.test_cutout_length"
|
||||
bl_label = "Test Cutout Length"
|
||||
edge_length: properties.cutout_edge_length_property()
|
||||
|
||||
def execute(self, context):
|
||||
captured.append(self.edge_length)
|
||||
return {"FINISHED"}
|
||||
|
||||
units = bpy.context.scene.unit_settings
|
||||
previous = units.scale_length
|
||||
bpy.utils.register_class(CutoutLengthTestOperator)
|
||||
try:
|
||||
units.scale_length = 0.01
|
||||
bpy.ops.anyimage.test_cutout_length(edge_length=20)
|
||||
assert captured == pytest.approx([20])
|
||||
finally:
|
||||
units.scale_length = previous
|
||||
bpy.utils.unregister_class(CutoutLengthTestOperator)
|
||||
|
||||
|
||||
def test_effective_length_report_and_geometry_build_share_value():
|
||||
report = Mock()
|
||||
context = SimpleNamespace(scene=SimpleNamespace(unit_settings=SimpleNamespace(system="METRIC", scale_length=0.01)))
|
||||
source = object()
|
||||
content = np.ones((10, 10))
|
||||
with patch.object(operators, "effective_cutout_edge_length", return_value=20), \
|
||||
patch.object(operators, "build_base_shape") as build, \
|
||||
patch.object(operators, "create_shape_object") as create, \
|
||||
patch.object(operators.bpy.ops.ed, "undo_push", return_value={"FINISHED"}):
|
||||
operators.create_cutout_shape(
|
||||
context,
|
||||
source,
|
||||
"FLAT",
|
||||
10,
|
||||
content,
|
||||
(0, 0, 10, 10),
|
||||
boundary_padding=0.0,
|
||||
color_image=object(),
|
||||
report=report,
|
||||
)
|
||||
assert build.call_args.args[2] == 20
|
||||
assert create.call_args.args[0] is context
|
||||
assert create.call_args.args[1] is source
|
||||
assert create.call_args.args[2] == "FLAT"
|
||||
assert create.call_args.args[3] is content
|
||||
assert report.call_args.args[0] == {"INFO"}
|
||||
assert "20 cm" in report.call_args.args[1]
|
||||
264
tests/anyimage/operators/test_cutout_geometry.py
Normal file
264
tests/anyimage/operators/test_cutout_geometry.py
Normal file
@@ -0,0 +1,264 @@
|
||||
import builtins
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import numpy as np
|
||||
|
||||
|
||||
from anyimage.common.selection import SelectionPath, rasterize_selection_path
|
||||
from anyimage.operators.cutout_tool import geometry as cutout_geometry
|
||||
from anyimage.operators.cutout_tool import object as cutout_object
|
||||
from anyimage.operators.cutout_tool import shape as cutout_shape
|
||||
from anyimage.operators.cutout_tool.geometry import SelectionContours, extract_selection_contours
|
||||
|
||||
|
||||
def mesh_component_count(faces):
|
||||
adjacency = {}
|
||||
for face in faces:
|
||||
for vertex in face:
|
||||
adjacency.setdefault(int(vertex), set())
|
||||
for start, end in zip(face, np.roll(face, -1)):
|
||||
adjacency[int(start)].add(int(end))
|
||||
adjacency[int(end)].add(int(start))
|
||||
remaining = set(adjacency)
|
||||
count = 0
|
||||
while remaining:
|
||||
count += 1
|
||||
pending = [remaining.pop()]
|
||||
while pending:
|
||||
vertex = pending.pop()
|
||||
connected = adjacency[vertex] & remaining
|
||||
remaining.difference_update(connected)
|
||||
pending.extend(connected)
|
||||
return count
|
||||
|
||||
|
||||
def test_cutout_node_groups_are_loaded_from_the_asset():
|
||||
loaded = object()
|
||||
with patch.object(
|
||||
cutout_object, "load_node_group", return_value=loaded
|
||||
) as load:
|
||||
for shape in cutout_shape.CUTOUT_NODE_GROUP_NAMES:
|
||||
assert cutout_object.load_cutout_node_group(shape) is loaded
|
||||
load.assert_called_with(cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape])
|
||||
assert load.call_count == 4
|
||||
|
||||
|
||||
def test_selection_contours_group_holes_with_their_connected_component():
|
||||
alpha = np.zeros((120, 200), dtype=np.uint8)
|
||||
alpha[20:100, 50:160] = 255
|
||||
alpha[45:75, 80:120] = 0
|
||||
alpha[5:15, 5:15] = 255
|
||||
|
||||
selection_contours = extract_selection_contours(
|
||||
alpha,
|
||||
max_resolution=200,
|
||||
)
|
||||
|
||||
assert selection_contours.image_size == (200, 120)
|
||||
assert sorted(len(component) for component in selection_contours.components) == [1, 2]
|
||||
contours = [
|
||||
contour for component in selection_contours.components for contour in component
|
||||
]
|
||||
bounds = [
|
||||
(*np.min(contour, axis=0), *np.max(contour, axis=0)) for contour in contours
|
||||
]
|
||||
assert (50.0, 20.0, 160.0, 100.0) in bounds
|
||||
assert (80.0, 45.0, 120.0, 75.0) in bounds
|
||||
assert (5.0, 5.0, 15.0, 15.0) in bounds
|
||||
|
||||
|
||||
def test_selection_contours_accept_blender_float_mask_with_a_hole():
|
||||
alpha = np.zeros((120, 200), dtype=np.float32)
|
||||
alpha[20:100, 50:160] = 1.0
|
||||
alpha[45:75, 80:120] = 0.0
|
||||
|
||||
selection_contours = extract_selection_contours(
|
||||
alpha,
|
||||
max_resolution=200,
|
||||
)
|
||||
|
||||
assert selection_contours.image_size == (200, 120)
|
||||
assert len(selection_contours.components) == 1
|
||||
assert len(selection_contours.components[0]) == 2
|
||||
|
||||
|
||||
def test_cutout_crop_bounds_map_to_the_source_plane():
|
||||
source = SimpleNamespace(
|
||||
data=SimpleNamespace(size=(200, 100)),
|
||||
empty_display_size=2.0,
|
||||
empty_image_offset=(-0.5, -0.5),
|
||||
)
|
||||
|
||||
bounds = cutout_geometry.crop_plane_bounds(
|
||||
source,
|
||||
(50, 25, 150, 75),
|
||||
(200, 100),
|
||||
)
|
||||
|
||||
np.testing.assert_allclose(bounds, (-0.5, 0.5, -0.25, 0.25))
|
||||
|
||||
def test_cutout_reference_mask_resamples_content_to_depth_resolution():
|
||||
content = np.zeros((4, 4), dtype=np.float32)
|
||||
content[:, 2:] = 1.0
|
||||
|
||||
mask = cutout_geometry.build_reference_mask(content, (2, 2))
|
||||
|
||||
assert mask.dtype == np.bool_
|
||||
np.testing.assert_array_equal(mask, ((False, True), (False, True)))
|
||||
|
||||
|
||||
def test_blender_cutout_alpha_pipeline_does_not_import_pillow():
|
||||
original_import = builtins.__import__
|
||||
|
||||
def without_pillow(name, *args, **kwargs):
|
||||
if name == "PIL" or name.startswith("PIL."):
|
||||
raise ModuleNotFoundError("Pillow is unavailable in Blender")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=without_pillow):
|
||||
selection_mask = rasterize_selection_path(
|
||||
(48, 32),
|
||||
SelectionPath(points=((6, 4), (42, 4), (42, 28), (6, 28))),
|
||||
antialias=True,
|
||||
)
|
||||
antialiased = selection_mask.full_values((48, 32))
|
||||
selection_contours = extract_selection_contours(
|
||||
antialiased,
|
||||
max_resolution=24,
|
||||
)
|
||||
|
||||
assert selection_contours.image_size == (48, 32)
|
||||
assert selection_contours.components
|
||||
assert np.any((antialiased > 0.0) & (antialiased < 1.0))
|
||||
|
||||
def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_selection():
|
||||
components = ((((2046, 2046), (2050, 2046), (2050, 2050), (2046, 2050)),),)
|
||||
|
||||
domain = quality_domain(
|
||||
SelectionContours(components, (4096, 4096)),
|
||||
(-1.0, 1.0, -1.0, 1.0),
|
||||
0.5,
|
||||
((1.0, 0.0), (0.0, 1.0)),
|
||||
)
|
||||
|
||||
assert len(domain[0]) >= 3
|
||||
assert len(domain[1]) >= 1
|
||||
|
||||
|
||||
def test_cutout_domain_builds_a_full_image_at_minimum_detail():
|
||||
components = ((((0, 0), (4096, 0), (4096, 4096), (0, 4096)),),)
|
||||
|
||||
domain = quality_domain(
|
||||
SelectionContours(components, (4096, 4096)),
|
||||
(-1.0, 1.0, -1.0, 1.0),
|
||||
0.5,
|
||||
((1.0, 0.0), (0.0, 1.0)),
|
||||
)
|
||||
|
||||
assert len(domain[0]) >= 4
|
||||
assert len(domain[1]) >= 2
|
||||
|
||||
|
||||
def test_cutout_domain_centers_vertices_on_crop_bounds():
|
||||
components = ((((0, 0), (100, 0), (100, 50), (0, 50)),),)
|
||||
|
||||
vertices, _faces, uv, _boundary, _planar, _profile = quality_domain(
|
||||
SelectionContours(components, (100, 50)),
|
||||
(2.0, 6.0, -3.0, 1.0),
|
||||
0.5,
|
||||
((1.0, 0.0), (0.0, 1.0)),
|
||||
)
|
||||
|
||||
center = (vertices[:, :2].min(axis=0) + vertices[:, :2].max(axis=0)) * 0.5
|
||||
assert np.allclose(center, 0.0, atol=1e-6)
|
||||
expected_uv = vertices[:, [0, 2]] / np.asarray((4.0, 4.0)) + 0.5
|
||||
assert np.allclose(uv, expected_uv, atol=1e-6)
|
||||
|
||||
|
||||
def test_cutout_domain_keeps_overlapping_selection_components_disconnected():
|
||||
components = (
|
||||
(
|
||||
((20, 0), (100, 0), (100, 80), (20, 80)),
|
||||
((50, 20), (50, 60), (70, 60), (70, 20)),
|
||||
),
|
||||
(((0, 20), (40, 20), (40, 50), (0, 50)),),
|
||||
)
|
||||
domain = quality_domain(
|
||||
SelectionContours(components, (120, 80)),
|
||||
(0.0, 1.0, 0.0, 1.0),
|
||||
0.025,
|
||||
((1.0, 0.0), (0.0, 1.0)),
|
||||
)
|
||||
|
||||
centers = domain[2][domain[1]].mean(axis=1)
|
||||
in_hole = (
|
||||
(centers[:, 0] > 0.45)
|
||||
& (centers[:, 0] < 0.55)
|
||||
& (centers[:, 1] > 0.35)
|
||||
& (centers[:, 1] < 0.65)
|
||||
)
|
||||
in_left_component = (
|
||||
(centers[:, 0] > 0.02)
|
||||
& (centers[:, 0] < 0.12)
|
||||
& (centers[:, 1] > 0.4)
|
||||
& (centers[:, 1] < 0.75)
|
||||
)
|
||||
|
||||
assert not in_hole.any()
|
||||
assert in_left_component.any()
|
||||
assert mesh_component_count(domain[1]) == 2
|
||||
|
||||
|
||||
class CutoutContourTest(unittest.TestCase):
|
||||
def test_cutout_contour_is_smooth_and_evenly_resampled(self):
|
||||
import numpy as np
|
||||
|
||||
contour = cutout_geometry.smooth_selection_contour(
|
||||
((0, 0), (3, 0), (4, 0), (4, 2), (0, 2)),
|
||||
0.25,
|
||||
)
|
||||
closed = np.vstack((contour, contour[0]))
|
||||
lengths = np.linalg.norm(np.diff(closed, axis=0), axis=1)
|
||||
|
||||
self.assertGreater(len(contour), 20)
|
||||
self.assertGreater(cutout_geometry.contour_area(contour), 0.0)
|
||||
self.assertLess(float(lengths.std() / lengths.mean()), 0.15)
|
||||
|
||||
|
||||
def test_cutout_contour_smooths_pixel_stairs_before_coarse_resampling(self):
|
||||
import numpy as np
|
||||
|
||||
staircase = [(0.0, 0.0)]
|
||||
for value in range(16):
|
||||
staircase.append((value + 1.0, float(value)))
|
||||
staircase.append((value + 1.0, value + 1.0))
|
||||
staircase.append((0.0, 16.0))
|
||||
smooth = cutout_geometry.smooth_selection_contour(staircase, 2.0)
|
||||
|
||||
def diagonal_noise(points):
|
||||
points = np.asarray(points)
|
||||
selected = (
|
||||
(points[:, 0] > 2.0)
|
||||
& (points[:, 0] < 14.0)
|
||||
& (np.abs(points[:, 1] - points[:, 0]) < 2.0)
|
||||
)
|
||||
return float(np.std(points[selected, 1] - points[selected, 0]))
|
||||
|
||||
self.assertLess(diagonal_noise(smooth), 0.05)
|
||||
|
||||
|
||||
def quality_domain(selection, bounds, spacing, metric):
|
||||
from anyimage.operators.cutout_tool import mesh
|
||||
ps, fs, cs = [], [], []
|
||||
offset = 0
|
||||
width, height = selection.image_size
|
||||
pixel_scale = max((bounds[1]-bounds[0])/width, (bounds[3]-bounds[2])/height)
|
||||
for component in selection.components:
|
||||
contours = [np.array(c, dtype=float) for c in component]
|
||||
p, f = mesh.triangulate(contours, spacing/pixel_scale)
|
||||
p, f = mesh.support_mesh(p, f)
|
||||
ps.append(p);fs.append(f+offset)
|
||||
cs.append((offset, offset+len(p), f, contours[0]));offset += len(p)
|
||||
p, f = np.concatenate(ps), np.concatenate(fs)
|
||||
return cutout_geometry.cutout_domain((p, f, cutout_geometry.boundary_vertices_from_faces(len(p), f), cs, selection.image_size), bounds, metric)
|
||||
283
tests/anyimage/operators/test_cutout_interaction.py
Normal file
283
tests/anyimage/operators/test_cutout_interaction.py
Normal file
@@ -0,0 +1,283 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from types import MethodType
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
from anyimage.common.selection import SelectionPath
|
||||
from anyimage.common import viewport
|
||||
from anyimage.operators.cutout_tool import interaction
|
||||
from anyimage.operators.cutout_tool import operators as cutout_main
|
||||
|
||||
|
||||
class Pie:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def operator(self, identifier, **options):
|
||||
operator = SimpleNamespace()
|
||||
self.calls.append((identifier, options, operator))
|
||||
return operator
|
||||
|
||||
|
||||
class WindowManager:
|
||||
def __init__(self):
|
||||
self.pie = Pie()
|
||||
self.events = []
|
||||
|
||||
def popup_menu_pie(self, _event, draw, **_options):
|
||||
self.events.append(_event)
|
||||
menu = SimpleNamespace(layout=SimpleNamespace(menu_pie=lambda: self.pie))
|
||||
draw(menu, None)
|
||||
|
||||
|
||||
def cutout_context(
|
||||
window_manager,
|
||||
edge_length=0.1,
|
||||
generate_normal=False,
|
||||
alpha_threshold=0.9,
|
||||
fine_outline=True,
|
||||
):
|
||||
settings = SimpleNamespace(
|
||||
cutout_edge_length=edge_length,
|
||||
cutout_generate_normal=generate_normal,
|
||||
cutout_alpha_threshold=alpha_threshold,
|
||||
cutout_fine_outline=fine_outline,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
window_manager=window_manager,
|
||||
scene=SimpleNamespace(anyimage_settings=settings),
|
||||
)
|
||||
|
||||
|
||||
def test_cutout_menu_captures_fine_outline_before_later_redraw(monkeypatch):
|
||||
window_manager = WindowManager()
|
||||
context = cutout_context(window_manager)
|
||||
monkeypatch.setattr(interaction, "ai_ready", lambda: False)
|
||||
|
||||
def delayed_popup(_event, draw, **_options):
|
||||
context.scene.anyimage_settings.cutout_fine_outline = False
|
||||
draw(SimpleNamespace(layout=SimpleNamespace(menu_pie=lambda: window_manager.pie)), None)
|
||||
|
||||
window_manager.popup_menu_pie = delayed_popup
|
||||
interaction.open_shape_pie(context, object(), cutout_source(),
|
||||
SelectionPath(points=((0, 0), (10, 0), (0, 10))))
|
||||
assert all(call[2].fine_outline is True for call in window_manager.pie.calls)
|
||||
|
||||
|
||||
def cutout_source(name="Source", size=(2048, 1024)):
|
||||
return SimpleNamespace(name=name, data=SimpleNamespace(size=size))
|
||||
|
||||
|
||||
def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch):
|
||||
window_manager = WindowManager()
|
||||
context = cutout_context(window_manager, generate_normal=True)
|
||||
source = cutout_source()
|
||||
monkeypatch.setattr(interaction, "ai_ready", lambda: False)
|
||||
|
||||
interaction.open_shape_pie(
|
||||
context,
|
||||
object(),
|
||||
source,
|
||||
SelectionPath(points=((0, 0), (10, 0), (0, 10))),
|
||||
)
|
||||
|
||||
assert [call[2].shape for call in window_manager.pie.calls] == [
|
||||
"FLAT",
|
||||
"SOLID",
|
||||
]
|
||||
assert [call[1]["text"] for call in window_manager.pie.calls] == [
|
||||
"Flat",
|
||||
"Solid",
|
||||
]
|
||||
assert all(
|
||||
call[0] == "anyimage.cutout_selection_to_shape"
|
||||
for call in window_manager.pie.calls
|
||||
)
|
||||
assert not any(call[2].generate_normal for call in window_manager.pie.calls)
|
||||
assert all(call[2].edge_length == 0.1 for call in window_manager.pie.calls)
|
||||
|
||||
|
||||
def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
|
||||
window_manager = WindowManager()
|
||||
context = cutout_context(
|
||||
window_manager,
|
||||
edge_length=0.05,
|
||||
generate_normal=True,
|
||||
alpha_threshold=0.1,
|
||||
fine_outline=False,
|
||||
)
|
||||
source = cutout_source(size=(4096, 2048))
|
||||
monkeypatch.setattr(interaction, "ai_ready", lambda: True)
|
||||
serialized = []
|
||||
original_to_json = SelectionPath.to_json
|
||||
|
||||
def record_to_json(selection_path):
|
||||
value = original_to_json(selection_path)
|
||||
serialized.append(value)
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(SelectionPath, "to_json", record_to_json)
|
||||
|
||||
interaction.open_shape_pie(
|
||||
context,
|
||||
object(),
|
||||
source,
|
||||
SelectionPath(
|
||||
points=((0, 0), (10, 0), (0, 10)),
|
||||
),
|
||||
)
|
||||
|
||||
assert [call[2].shape for call in window_manager.pie.calls] == [
|
||||
"FLAT",
|
||||
"SOLID",
|
||||
"DEPTH_SYMMETRY",
|
||||
"DEPTH_SOLID",
|
||||
]
|
||||
assert all(call[2].generate_normal for call in window_manager.pie.calls)
|
||||
assert all(call[2].alpha_threshold == 0.1 for call in window_manager.pie.calls)
|
||||
assert all(call[2].fine_outline is False for call in window_manager.pie.calls)
|
||||
assert len(serialized) == 1
|
||||
assert set(json.loads(serialized[0])) == {"points"}
|
||||
assert all(
|
||||
call[2].selection_path_json == serialized[0]
|
||||
for call in window_manager.pie.calls
|
||||
)
|
||||
assert all(
|
||||
SelectionPath.from_json(call[2].selection_path_json).points
|
||||
== ((0.0, 0.0), (10.0, 0.0), (0.0, 10.0))
|
||||
for call in window_manager.pie.calls
|
||||
)
|
||||
assert all(call[2].edge_length == 0.05 for call in window_manager.pie.calls)
|
||||
|
||||
|
||||
def test_cutout_lasso_submission_passes_only_the_selected_path(monkeypatch):
|
||||
source = cutout_source()
|
||||
window_manager = WindowManager()
|
||||
context = cutout_context(window_manager)
|
||||
monkeypatch.setattr(cutout_main, "require_image_empty", lambda _name: source)
|
||||
monkeypatch.setattr(interaction, "ai_ready", lambda: False)
|
||||
path = SelectionPath(points=((0, 0), (10, 0), (0, 10)))
|
||||
operator = SimpleNamespace(source_object_name=source.name, _pie_event=object(), gesture="LASSO")
|
||||
result = cutout_main.SelectCutoutSelection._submit_selection_path(operator, context, path)
|
||||
assert result == {"FINISHED"}
|
||||
assert SelectionPath.from_json(window_manager.pie.calls[0][2].selection_path_json) == path
|
||||
|
||||
|
||||
def test_cutout_tool_starts_on_press_and_keeps_shift_selection():
|
||||
keymap = cutout_main.CutoutTool.bl_keymap
|
||||
assert keymap[0][1]["value"] == "PRESS"
|
||||
assert len(keymap) == 2
|
||||
assert keymap[1][0] == "view3d.select"
|
||||
assert keymap[1][2] == {"properties": [("toggle", True)]}
|
||||
assert cutout_main.SelectCutoutSelection.resolve_on_press
|
||||
|
||||
|
||||
def test_cutout_f_has_no_option_shortcut(monkeypatch):
|
||||
operator = SimpleNamespace(
|
||||
gesture="LASSO",
|
||||
active_tool_id="anyimage.cutout_lasso",
|
||||
)
|
||||
monkeypatch.setattr(viewport, "active_view3d_tool_id", lambda _context: None)
|
||||
|
||||
result = cutout_main.SelectCutoutSelection.modal(
|
||||
operator,
|
||||
SimpleNamespace(),
|
||||
SimpleNamespace(type="F", value="PRESS"),
|
||||
)
|
||||
|
||||
assert result == {"PASS_THROUGH"}
|
||||
|
||||
|
||||
def test_cutout_menu_keeps_submitted_gesture_during_redraw(monkeypatch):
|
||||
window_manager = WindowManager()
|
||||
context = cutout_context(window_manager)
|
||||
monkeypatch.setattr(interaction, "ai_ready", lambda: True)
|
||||
|
||||
def delayed_popup(_event, draw, **_options):
|
||||
context.scene.anyimage_settings.cutout_gesture = "LASSO"
|
||||
draw(SimpleNamespace(layout=SimpleNamespace(menu_pie=lambda: window_manager.pie)), None)
|
||||
|
||||
window_manager.popup_menu_pie = delayed_popup
|
||||
interaction.open_shape_pie(
|
||||
context, object(), cutout_source(),
|
||||
SelectionPath(points=((0, 0), (10, 0), (0, 10))), gesture="POLYLINE",
|
||||
)
|
||||
assert all(call[2].gesture == "POLYLINE" for call in window_manager.pie.calls)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("finish", ("START", "DOUBLE_CLICK", "RET", "NUMPAD_ENTER"))
|
||||
def test_cutout_polyline_edits_and_submits_through_shared_gesture(monkeypatch, finish):
|
||||
window_manager = WindowManager()
|
||||
context = cutout_context(window_manager)
|
||||
context.area = SimpleNamespace(tag_redraw=Mock())
|
||||
context.region = context.region_data = object()
|
||||
context.scene.anyimage_settings.cutout_gesture = "POLYLINE"
|
||||
source = cutout_source()
|
||||
monkeypatch.setattr(viewport, "active_view3d_tool_id", lambda _context: None)
|
||||
monkeypatch.setattr(viewport, "require_image_empty", lambda _name: source)
|
||||
monkeypatch.setattr(cutout_main, "require_image_empty", lambda _name: source)
|
||||
monkeypatch.setattr(viewport, "deserialize_matrix", lambda _data: None)
|
||||
monkeypatch.setattr(viewport, "image_empty_bounds", lambda _source: None)
|
||||
monkeypatch.setattr(viewport, "screen_path_to_image_pixels", lambda path, *_args: path)
|
||||
monkeypatch.setattr(interaction, "ai_ready", lambda: False)
|
||||
operator = SimpleNamespace(
|
||||
source_object_name=source.name, source_matrix_data="matrix",
|
||||
active_tool_id="anyimage.cutout_lasso", _path=[(0, 0)],
|
||||
_polyline_confirm_type=None,
|
||||
_finish=Mock(),
|
||||
)
|
||||
for name in ("_modal_polyline", "_confirm_polyline", "_complete", "_submit_selection_path"):
|
||||
setattr(operator, name, MethodType(getattr(cutout_main.SelectCutoutSelection, name), operator))
|
||||
cutout_main.SelectCutoutSelection._read_gesture_settings(operator, context)
|
||||
|
||||
def event(kind, value="PRESS", point=(100, 100)):
|
||||
return cutout_main.SelectCutoutSelection.modal(operator, context, SimpleNamespace(
|
||||
type=kind, value=value, mouse_region_x=point[0], mouse_region_y=point[1],
|
||||
))
|
||||
|
||||
assert event("RET") == {"RUNNING_MODAL"}
|
||||
event("LEFTMOUSE", point=(100, 0))
|
||||
event("LEFTMOUSE", point=(100, 100))
|
||||
event("BACK_SPACE")
|
||||
assert operator._path == [(0, 0), (100, 0)]
|
||||
event("LEFTMOUSE", point=(100, 100))
|
||||
if finish == "START":
|
||||
result = event("LEFTMOUSE", point=(0, 0))
|
||||
elif finish == "DOUBLE_CLICK":
|
||||
result = event("LEFTMOUSE", "DOUBLE_CLICK")
|
||||
else:
|
||||
result = event(finish)
|
||||
assert result == {"RUNNING_MODAL"}
|
||||
assert window_manager.events == []
|
||||
operator._finish.assert_not_called()
|
||||
points = tuple(operator._path)
|
||||
assert event("MOUSEMOVE", point=(200, 200)) == {"RUNNING_MODAL"}
|
||||
assert event("BACK_SPACE") == {"RUNNING_MODAL"}
|
||||
assert tuple(operator._path) == points
|
||||
release_type = "LEFTMOUSE" if finish in {"START", "DOUBLE_CLICK"} else finish
|
||||
assert event("SPACE", "RELEASE") == {"RUNNING_MODAL"}
|
||||
result = event(release_type, "RELEASE")
|
||||
assert result == {"FINISHED"}
|
||||
assert len(window_manager.events) == 1
|
||||
assert window_manager.events[0].value == "RELEASE"
|
||||
operator._finish.assert_called_once_with(context)
|
||||
submitted = window_manager.pie.calls[0][2]
|
||||
assert submitted.gesture == "POLYLINE"
|
||||
assert SelectionPath.from_json(submitted.selection_path_json).points == ((0, 0), (100, 0), (100, 100))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reason", ("ESC", "RIGHTMOUSE", "TOOL_SWITCH"))
|
||||
def test_cutout_polyline_cancel_cleans_preview(monkeypatch, reason):
|
||||
context = SimpleNamespace()
|
||||
operator = SimpleNamespace(
|
||||
gesture="POLYLINE", active_tool_id="anyimage.cutout_lasso", _finish=Mock(),
|
||||
_polyline_confirm_type="LEFTMOUSE",
|
||||
)
|
||||
operator._cancel_and_finish = MethodType(viewport.ImageGesture._cancel_and_finish, operator)
|
||||
monkeypatch.setattr(viewport, "active_view3d_tool_id", lambda _context: "other" if reason == "TOOL_SWITCH" else None)
|
||||
result = cutout_main.SelectCutoutSelection.modal(operator, context, SimpleNamespace(type=reason, value="PRESS"))
|
||||
assert result == {"CANCELLED"}
|
||||
operator._finish.assert_called_once_with(context)
|
||||
276
tests/anyimage/operators/test_cutout_mesh.py
Normal file
276
tests/anyimage/operators/test_cutout_mesh.py
Normal file
@@ -0,0 +1,276 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.sparse import coo_matrix
|
||||
from scipy.sparse.csgraph import connected_components
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
from anyimage.operators.cutout_tool import geometry as g
|
||||
from anyimage.operators.cutout_tool.balloon import poisson_balloon_profile
|
||||
from anyimage.operators.cutout_tool import mesh
|
||||
|
||||
|
||||
def boundary_distance(points, contours):
|
||||
starts = np.concatenate(contours)
|
||||
delta = np.concatenate([np.roll(c, -1, axis=0) - c for c in contours])
|
||||
offset = points[:, None] - starts
|
||||
fraction = np.clip(np.sum(offset * delta, axis=2) / np.sum(delta**2, axis=1), 0, 1)
|
||||
return np.linalg.norm(offset - fraction[..., None] * delta, axis=2).min(axis=1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fine_outline", [False, True])
|
||||
@pytest.mark.parametrize("spacing", [2, 48])
|
||||
def test_local_support_connects_coarse_and_dense_branches(fine_outline, spacing):
|
||||
result = mesh.build_mesh(branch_alpha(), spacing, 0.9, fine_outline=fine_outline)
|
||||
assert_supported(*result[:4])
|
||||
|
||||
|
||||
def test_support_splits_shared_chords_and_preserves_boundary_and_area():
|
||||
points = np.array(((0., 0.), (20., 0.), (20., 2.), (0., 2.)))
|
||||
faces = np.array(((0, 1, 2), (0, 2, 3)))
|
||||
p, f = mesh.support_mesh(points, faces)
|
||||
assert len(p) == 5 and len(f) == 4
|
||||
assert_supported(p, f, g.boundary_vertices_from_faces(len(p), f), [None])
|
||||
triangles = p[f]
|
||||
assert np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0]).sum() == pytest.approx(80)
|
||||
np.testing.assert_array_equal(p[:4], points)
|
||||
with pytest.raises(ValueError, match="limit"):
|
||||
mesh.support_mesh(points, faces, max_vertices=4)
|
||||
|
||||
|
||||
def test_support_adds_one_sample_to_isolated_triangle_and_is_idempotent():
|
||||
points = np.array(((0., 0.), (20., 0.), (0., 2.)))
|
||||
faces = np.array(((0, 1, 2),))
|
||||
p, f = mesh.support_mesh(points, faces)
|
||||
assert len(p) == 4 and len(f) == 3
|
||||
assert_supported(p, f, g.boundary_vertices_from_faces(len(p), f), [None])
|
||||
again_p, again_f = mesh.support_mesh(p, f)
|
||||
np.testing.assert_array_equal(p, again_p)
|
||||
np.testing.assert_array_equal(f, again_f)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spacing", [1, 16])
|
||||
def test_pixel_corner_hole_contacts_produce_valid_supported_mesh(spacing):
|
||||
alpha = np.ones((40, 40))
|
||||
alpha[10:17, 10:17] = 0
|
||||
alpha[17:24, 17:24] = 0
|
||||
original = alpha.copy()
|
||||
result = mesh.build_mesh(alpha, spacing, .9)
|
||||
assert_supported(*result[:4])
|
||||
np.testing.assert_array_equal(alpha, original)
|
||||
p, f, b, _, _mask = result
|
||||
edges, counts = np.unique(np.sort(np.concatenate((f[:, :2], f[:, 1:], f[:, ::2])), axis=1), axis=0, return_counts=True)
|
||||
assert np.all(np.bincount(edges[counts == 1].ravel(), minlength=len(p))[b] == 2)
|
||||
centers = p[f].mean(axis=1)
|
||||
# The pixel-scale smoothing may round the hole corners; its interior stays open.
|
||||
assert not np.any((centers[:, 0] > 11) & (centers[:, 0] < 16) & (centers[:, 1] > 11) & (centers[:, 1] < 16))
|
||||
|
||||
|
||||
def branch_alpha(offset=0):
|
||||
yy, xx = np.mgrid[:192, :192]
|
||||
xx = xx - offset
|
||||
yy = yy - offset
|
||||
alpha = (xx - 46) ** 2 + (yy - 48) ** 2 < 30**2
|
||||
path = np.asarray(((48, 48), (91, 70), (130, 110), (149, 167)))
|
||||
for a, b in zip(path[:-1], path[1:]):
|
||||
d = np.stack((xx - a[0], yy - a[1]), axis=-1)
|
||||
v = b - a
|
||||
t = np.clip(d @ v / (v @ v), 0, 1)
|
||||
alpha |= np.sum((d - t[..., None] * v) ** 2, axis=-1) < 2.5**2
|
||||
alpha |= (xx - 130) ** 2 + (yy - 110) ** 2 < 7**2
|
||||
return alpha.astype(float)
|
||||
|
||||
|
||||
def assert_supported(points, faces, boundary, components):
|
||||
triangles = points[faces]
|
||||
area = np.abs(
|
||||
np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])
|
||||
)
|
||||
assert np.isfinite(points).all() and np.all(area > 1e-8)
|
||||
edges = np.sort(np.concatenate((faces[:, :2], faces[:, 1:], faces[:, ::2])), axis=1)
|
||||
edges, counts = np.unique(edges, axis=0, return_counts=True)
|
||||
assert counts.max() <= 2
|
||||
interior = np.flatnonzero(~boundary)
|
||||
links = edges[~boundary[edges].any(axis=1)]
|
||||
graph = coo_matrix(
|
||||
(np.ones(len(links)), (links[:, 0], links[:, 1])),
|
||||
shape=(len(points), len(points)),
|
||||
).tocsr()
|
||||
count, _labels = connected_components(graph[interior][:, interior], directed=False)
|
||||
assert count == len(components)
|
||||
assert not np.any(boundary[faces].all(axis=1))
|
||||
assert len(interior) >= len(components)
|
||||
profile = poisson_balloon_profile(points, faces, boundary)
|
||||
assert np.isfinite(profile).all()
|
||||
assert np.all(profile[interior] > 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spacing", (4, 32))
|
||||
@pytest.mark.parametrize("offset", (0, 1))
|
||||
def test_fine_bent_leg_and_joint_have_connected_positive_support(spacing, offset):
|
||||
points, faces, boundary, components, _mask = mesh.build_mesh(
|
||||
branch_alpha(offset), spacing, 0.9
|
||||
)
|
||||
assert_supported(points, faces, boundary, components)
|
||||
interior = points[~boundary]
|
||||
checkpoints = np.array(((48, 48), (90, 70), (130, 110), (146, 159))) + offset
|
||||
distance, nearest = cKDTree(interior).query(checkpoints)
|
||||
assert distance.max() < 12
|
||||
edges = np.unique(
|
||||
np.sort(np.concatenate((faces[:, :2], faces[:, 1:], faces[:, ::2])), axis=1),
|
||||
axis=0,
|
||||
)
|
||||
links = edges[~boundary[edges].any(1)]
|
||||
graph = coo_matrix(
|
||||
(np.ones(len(links)), (links[:, 0], links[:, 1])),
|
||||
shape=(len(points), len(points)),
|
||||
).tocsr()
|
||||
_, labels = connected_components(graph, directed=False)
|
||||
assert len(set(labels[np.flatnonzero(~boundary)[nearest]])) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spacing", (8, 32))
|
||||
def test_fine_comb_hole_and_one_pixel_bridge(spacing):
|
||||
alpha = np.zeros((160, 180))
|
||||
alpha[15:145, 15:42] = 1
|
||||
alpha[45:115, 125:165] = 1
|
||||
alpha[75, 40:126] = 1
|
||||
alpha[65:90, 140:150] = 0
|
||||
for row in range(20, 140, 12):
|
||||
alpha[row : row + 2, 35:110] = 1
|
||||
result = mesh.build_mesh(alpha, spacing, 0.9)
|
||||
assert_supported(*result[:4])
|
||||
points, faces, _boundary, components, _mask = result
|
||||
assert len(components) == 1
|
||||
assert not np.any(
|
||||
g.points_inside_contours(
|
||||
points[faces].mean(axis=1),
|
||||
[np.asarray(((140, 65), (150, 65), (150, 90), (140, 90)))],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_fine_cleanup_uses_threshold_and_preserves_input_and_largest_island():
|
||||
alpha = np.zeros((40, 60))
|
||||
alpha[5:25, 5:25] = 0.5
|
||||
alpha[12, 20:45] = 0.5
|
||||
alpha[32:34, 50:52] = 1
|
||||
original = alpha.copy()
|
||||
filtered = mesh.filter_alpha(alpha, 0.3)
|
||||
assert filtered[12, 44] == 0.5 and filtered[32, 50] == 0
|
||||
np.testing.assert_array_equal(alpha, original)
|
||||
assert_supported(*mesh.build_mesh(alpha, 32, 0.3)[:4])
|
||||
assert_supported(*mesh.build_mesh(alpha, 32, 0.9)[:4])
|
||||
with pytest.raises(ValueError, match="no visible"):
|
||||
mesh.build_mesh(np.zeros((10, 10)), 32, 0.9)
|
||||
|
||||
|
||||
def test_higher_detail_recovers_curve_instead_of_splitting_low_edges():
|
||||
yy, xx = np.mgrid[:240, :240]
|
||||
alpha = ((xx - 120) ** 2 + (yy - 120) ** 2 < 92**2).astype(float)
|
||||
reference = g.extract_selection_contours(alpha, max_resolution=240, threshold=0.9)
|
||||
source = g.resample_closed_contour(np.asarray(reference.components[0][0]), 0.5)
|
||||
errors = []
|
||||
for spacing in (32, 8, 4):
|
||||
contours = mesh.contours_for_component(reference.components[0], spacing)
|
||||
sampled = g.resample_closed_contour(contours[0], 0.5)
|
||||
forward = boundary_distance(source, contours)
|
||||
backward = boundary_distance(sampled, [np.asarray(reference.components[0][0])])
|
||||
errors.append(max(np.percentile(forward, 95), np.percentile(backward, 95)))
|
||||
assert errors[1] < errors[0]
|
||||
assert errors[2] <= errors[1] + 0.1
|
||||
|
||||
|
||||
def test_fine_outline_softens_pixel_steps_in_normalized_coordinates():
|
||||
yy, xx = np.mgrid[:120, :120]
|
||||
alpha = ((xx - 60)**2 + (yy - 60)**2 < 45**2).astype(float)
|
||||
source = np.asarray(g.extract_selection_contours(alpha, max_resolution=120).components[0][0])
|
||||
transform = np.diag((8., 12.))
|
||||
contours = mesh.contours_for_component([source @ transform], 32, pixel_transform=transform)
|
||||
smoothed = contours[0] @ np.linalg.inv(transform)
|
||||
|
||||
def turning(points):
|
||||
edges = np.roll(points, -1, axis=0) - points
|
||||
angles = np.arctan2(edges[:, 1], edges[:, 0])
|
||||
change = (np.roll(angles, -1) - angles + np.pi) % (2*np.pi) - np.pi
|
||||
return abs(change).sum()
|
||||
|
||||
assert turning(smoothed) < turning(source) * .5
|
||||
assert boundary_distance(smoothed, [source]).max() < 1
|
||||
assert abs(g.contour_area(smoothed) / g.contour_area(source) - 1) < .02
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offset", [0, 4096, 16384])
|
||||
def test_cdt_collinear_outline_samples_do_not_become_sliver_faces(offset):
|
||||
contour = np.asarray(((0.0, 0.0), (240.0, 0.0), (240.0, 160.0), (0.0, 160.0)))
|
||||
contour = contour @ np.asarray(((0.763, -0.646), (0.646, 0.763)))
|
||||
contour += np.asarray((420.123456, 540.234567)) + offset
|
||||
contours = [g.resample_closed_contour(contour, 16)]
|
||||
points, faces = mesh.triangulate(contours, 16)
|
||||
triangles = points[faces]
|
||||
area = abs(
|
||||
np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])
|
||||
)
|
||||
longest_squared = np.max(
|
||||
np.sum((triangles - np.roll(triangles, 1, axis=1)) ** 2, axis=2), axis=1
|
||||
)
|
||||
assert np.min(area / longest_squared) > 1e-4
|
||||
assert area.sum() / 2 == pytest.approx(abs(g.contour_area(contour)), rel=1e-9)
|
||||
boundary = g.boundary_vertices_from_faces(len(points), faces)
|
||||
np.testing.assert_allclose(
|
||||
boundary_distance(points[boundary], [contour]), 0, atol=1e-9
|
||||
)
|
||||
# Every subdivided outline point survives; removing slivers must not skip it.
|
||||
assert cKDTree(points[boundary]).query(contours[0])[0].max() < 1e-9
|
||||
|
||||
|
||||
def test_contour_validation_requires_separation_of_pixel_contacts():
|
||||
crossing = np.asarray(((0, 0), (4, 4), (0, 4), (4, 0)))
|
||||
assert not mesh.valid_contours([crossing])
|
||||
touching = np.asarray(
|
||||
((0, 0), (2, 0), (2, 2), (4, 2), (4, 4), (2, 4), (2, 2), (0, 2))
|
||||
)
|
||||
assert not mesh.valid_contours([touching])
|
||||
assert mesh.valid_contours(mesh.separate_contour_contacts([touching.astype(float)]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fine_outline", [False, True])
|
||||
def test_hole_policy_preserves_alpha_and_external_notches(fine_outline):
|
||||
alpha = np.ones((80, 80))
|
||||
alpha[10:12, 10:12] = 0
|
||||
alpha[20:29, 20:29] = 0
|
||||
alpha[40:41, 10:40] = 0
|
||||
alpha[:15, 60:65] = 0
|
||||
original = alpha.copy()
|
||||
mask = mesh.prepare_mask(alpha, 0.9, np.eye(2) * 3.2, fine_outline)
|
||||
assert mask[10, 10]
|
||||
assert mask[24, 24] == (not fine_outline)
|
||||
assert mask[40, 25] == (not fine_outline)
|
||||
assert not mask[0, 60]
|
||||
np.testing.assert_array_equal(alpha, original)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size,filled", [(2, True), (3, False)])
|
||||
def test_micro_hole_threshold_respects_world_transform(size, filled):
|
||||
alpha = np.ones((40, 40))
|
||||
alpha[10 : 10 + size, 10 : 10 + size] = 0
|
||||
mask = mesh.prepare_mask(alpha, 0.9, np.eye(2) * 3.2, True)
|
||||
assert bool(mask[10, 10]) == filled
|
||||
stretched = mesh.prepare_mask(alpha, 0.9, np.diag((6.4, 3.2)), True)
|
||||
assert not stretched[10, 10]
|
||||
|
||||
|
||||
def test_filling_nested_hole_merges_island_without_overlap():
|
||||
alpha = np.zeros((80, 80))
|
||||
alpha[5:75, 5:75] = 1
|
||||
alpha[20:60, 20:60] = 0
|
||||
alpha[30:50, 30:50] = 1
|
||||
result = mesh.build_mesh(alpha, 8, 0.9, fine_outline=False)
|
||||
p, f, _, components, _mask = result
|
||||
assert len(components) == 1
|
||||
assert_supported(*result[:4])
|
||||
edges = np.unique(
|
||||
np.sort(np.concatenate((f[:, :2], f[:, 1:], f[:, ::2])), axis=1), axis=0
|
||||
)
|
||||
assert 1 - len(p) + len(edges) - len(f) == 0
|
||||
|
||||
|
||||
512
tests/anyimage/operators/test_cutout_objects.py
Normal file
512
tests/anyimage/operators/test_cutout_objects.py
Normal file
@@ -0,0 +1,512 @@
|
||||
from unittest.mock import patch
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
from mathutils import Vector
|
||||
|
||||
|
||||
from anyimage.common import depth as depth_data
|
||||
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
|
||||
from anyimage.common.selection import ImageEditWarning, SelectionPath, rasterize_selection_path
|
||||
from anyimage.operators.cutout_tool import geometry as cutout_geometry
|
||||
from anyimage.operators.cutout_tool import operators as cutout_main
|
||||
from anyimage.operators.cutout_tool import shape as cutout_shape
|
||||
from anyimage.operators.cutout_tool.boundary_padding import (
|
||||
image_rgba_buffer,
|
||||
pad_cutout_images,
|
||||
)
|
||||
from anyimage.server.geometry.depth_texture import (
|
||||
write_depth_texture,
|
||||
)
|
||||
from anyimage.server.models.geometry import GeometryFrame
|
||||
from tests.support.image_objects import _create_cutout_shape, _depth_image, _depth_metadata, _evaluated_positions, _group_node
|
||||
|
||||
|
||||
def test_depth_result_image_loads_camera_exr_and_keeps_data_name(tmp_path):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source_image = bpy.data.images.new("Source.png", width=2, height=2)
|
||||
source = bpy.data.objects.new("Source", None)
|
||||
source.data = source_image
|
||||
points = np.arange(12, dtype=np.float32).reshape((2, 2, 3))
|
||||
validity = np.asarray(((1.0, 0.0), (0.5, 1.0)), dtype=np.float32)
|
||||
frame = GeometryFrame(
|
||||
depth=points[..., 2].copy(),
|
||||
validity=validity,
|
||||
intrinsics=np.eye(3, dtype=np.float32),
|
||||
points=points,
|
||||
)
|
||||
path = tmp_path / "depth.exr"
|
||||
alpha = np.asarray(((0.8, 1.0), (0.75, 0.0)), dtype=np.float32)
|
||||
write_depth_texture(frame, path, alpha=alpha)
|
||||
metadata = _depth_metadata(np.eye(3), (2, 2))
|
||||
|
||||
image = depth_data.load_depth_result_image(path, source, metadata)
|
||||
|
||||
packed = np.empty(16, dtype=np.float32)
|
||||
image.pixels.foreach_get(packed)
|
||||
packed = np.flipud(packed.reshape((2, 2, 4)))
|
||||
assert np.array_equal(packed[..., :3], points)
|
||||
assert np.array_equal(packed[..., 3], alpha * validity)
|
||||
camera_depth, valid = depth_data.camera_depth_values(image)
|
||||
np.testing.assert_array_equal(camera_depth, points[..., 2])
|
||||
np.testing.assert_array_equal(
|
||||
valid,
|
||||
alpha * validity > depth_data.DEPTH_ALPHA_THRESHOLD,
|
||||
)
|
||||
assert image.alpha_mode == "CHANNEL_PACKED"
|
||||
assert image.name == "Source_depth.exr"
|
||||
|
||||
|
||||
def test_depth_texture_keeps_points_outside_positive_validity(tmp_path):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source_image = bpy.data.images.new("Source.png", width=2, height=1)
|
||||
source = bpy.data.objects.new("Source", None)
|
||||
source.data = source_image
|
||||
frame = GeometryFrame(
|
||||
depth=np.asarray(((2.0, 7.0),), dtype=np.float32),
|
||||
points=np.asarray((((-1.0, 3.0, 2.0), (4.0, -2.0, 7.0)),), dtype=np.float32),
|
||||
validity=np.asarray(((1.0, 0.0),), dtype=np.float32),
|
||||
intrinsics=np.eye(3, dtype=np.float32),
|
||||
)
|
||||
path = tmp_path / "depth.exr"
|
||||
write_depth_texture(frame, path, alpha=np.ones_like(frame.validity))
|
||||
|
||||
image = depth_data.load_depth_result_image(
|
||||
path,
|
||||
source,
|
||||
_depth_metadata(np.eye(3), (2, 1)),
|
||||
)
|
||||
|
||||
packed = np.empty(8, dtype=np.float32)
|
||||
image.pixels.foreach_get(packed)
|
||||
packed = packed.reshape((1, 2, 4))
|
||||
assert np.array_equal(packed[..., :3], frame.points)
|
||||
assert np.array_equal(packed[..., 3], frame.validity)
|
||||
|
||||
|
||||
def test_balloon_build_starts_from_the_unified_single_sheet():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Balloon Sheet", width=64, height=64, alpha=True)
|
||||
source = bpy.data.objects.new("Balloon Sheet", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
result = _create_cutout_shape(
|
||||
source,
|
||||
"SOLID",
|
||||
0.1,
|
||||
)
|
||||
|
||||
assert all(abs(vertex.co.y) < 1e-8 for vertex in result.data.vertices)
|
||||
assert len(result.data.vertices) > 4
|
||||
assert len(result.data.polygons) > 1
|
||||
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
||||
profile_attribute = result.data.attributes[cutout_shape.BALLOON_ATTRIBUTE_NAME]
|
||||
assert profile_attribute.data_type == "FLOAT"
|
||||
assert profile_attribute.domain == "POINT"
|
||||
profile = np.empty(len(result.data.vertices), dtype=np.float32)
|
||||
profile_attribute.data.foreach_get("value", profile)
|
||||
assert profile.min() == 0.0 and profile.max() > 0.0
|
||||
material_tree = result.data.materials[0].node_tree
|
||||
assert not any(node.bl_idname == "ShaderNodeAttribute" for node in material_tree.nodes)
|
||||
image_layer = _group_node(material_data.material_node_group(), material_tree)
|
||||
assert not image_layer.inputs["Normal Scale"].is_linked
|
||||
assert image_layer.inputs["Normal Scale"].default_value == 1.0
|
||||
assert result.modifiers[0].node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["SOLID"]
|
||||
inward = result.matrix_world.to_3x3() @ Vector((0.0, 1.0, 0.0))
|
||||
assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
|
||||
def test_depth_surface_object_evaluates(gesture):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Surface", width=64, height=64)
|
||||
source = bpy.data.objects.new("Surface", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
size = 8
|
||||
depth = np.ones((size, size), dtype=np.float32)
|
||||
x, y = np.meshgrid(np.arange(size), np.arange(size))
|
||||
intrinsics = np.asarray(
|
||||
((8.0, 0.0, 3.5), (0.0, 8.0, 3.5), (0.0, 0.0, 1.0)),
|
||||
dtype=np.float32,
|
||||
)
|
||||
points = np.empty((size, size, 3), dtype=np.float32)
|
||||
points[..., 0] = (x - intrinsics[0, 2]) / intrinsics[0, 0] * depth
|
||||
points[..., 1] = (y - intrinsics[1, 2]) / intrinsics[1, 1] * depth
|
||||
points[..., 2] = depth
|
||||
valid = np.ones((size, size), dtype=bool)
|
||||
depth_image = _depth_image(points, valid)
|
||||
depth_metadata = _depth_metadata(intrinsics, (size, size))
|
||||
|
||||
result = _create_cutout_shape(
|
||||
source,
|
||||
"DEPTH_SOLID",
|
||||
0.25,
|
||||
depth_image=depth_image,
|
||||
depth_metadata=depth_metadata,
|
||||
gesture=gesture,
|
||||
)
|
||||
|
||||
assert all(abs(vertex.co.y) < 1e-8 for vertex in result.data.vertices)
|
||||
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
||||
modifier = result.modifiers[0]
|
||||
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["DEPTH_SOLID"]
|
||||
layer = _group_node(material_data.material_node_group(), result.data.materials[0].node_tree)
|
||||
assert layer.inputs["Object Space"].default_value is True
|
||||
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Mode")) == (1 if gesture == "POLYLINE" else 0)
|
||||
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE")) == pytest.approx(0.2)
|
||||
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")) == 1.0
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
|
||||
0.0,
|
||||
)
|
||||
for name, value in (("Mode", 1), ("Front Inflation", 0.0)):
|
||||
object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, name), value)
|
||||
scale_id = object_data.modifier_input_identifier(
|
||||
modifier.node_group,
|
||||
"Uniform Scale",
|
||||
)
|
||||
reference_id = object_data.modifier_input_identifier(
|
||||
modifier.node_group,
|
||||
"Reference Depth",
|
||||
)
|
||||
# The Depth texture holds a constant unit camera depth.
|
||||
assert modifier_value(modifier, reference_id) == pytest.approx(
|
||||
modifier_value(modifier, scale_id)
|
||||
)
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Boundary Smooth"),
|
||||
0,
|
||||
)
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
baseline_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
baseline_y = np.asarray([vertex.co.y for vertex in baseline_mesh.vertices])
|
||||
baseline_min = baseline_y.min()
|
||||
baseline_max = baseline_y.max()
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
|
||||
1.0,
|
||||
)
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
evaluated_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
assert len(evaluated_mesh.polygons) > 0
|
||||
y = np.asarray([vertex.co.y for vertex in evaluated_mesh.vertices])
|
||||
assert np.isclose(y.min(), baseline_min, atol=1e-5)
|
||||
assert np.isclose(y.max(), baseline_max + 1.0, atol=1e-5)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Surface", width=64, height=64)
|
||||
source = bpy.data.objects.new("Surface", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
size = 8
|
||||
depth = np.ones((size, size), dtype=np.float32)
|
||||
x, y = np.meshgrid(np.arange(size), np.arange(size))
|
||||
intrinsics = np.asarray(
|
||||
((8.0, 0.0, 3.5), (0.0, 8.0, 3.5), (0.0, 0.0, 1.0)),
|
||||
dtype=np.float32,
|
||||
)
|
||||
points = np.empty((size, size, 3), dtype=np.float32)
|
||||
points[..., 0] = (x - intrinsics[0, 2]) / intrinsics[0, 0] * depth
|
||||
points[..., 1] = (y - intrinsics[1, 2]) / intrinsics[1, 1] * depth
|
||||
points[..., 2] = depth
|
||||
validity = np.ones((size, size), dtype=bool)
|
||||
validity[[0, -1], :] = False
|
||||
validity[:, [0, -1]] = False
|
||||
|
||||
result = _create_cutout_shape(
|
||||
source,
|
||||
"DEPTH_SOLID",
|
||||
0.1,
|
||||
depth_image=_depth_image(points, validity),
|
||||
depth_metadata=_depth_metadata(intrinsics, (size, size)),
|
||||
)
|
||||
modifier = result.modifiers[0]
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
|
||||
0.0,
|
||||
)
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
positions = _evaluated_positions(result)
|
||||
|
||||
assert np.isfinite(positions).all()
|
||||
assert np.ptp(positions[:, 0]) > 1.5
|
||||
assert np.ptp(positions[:, 2]) > 1.5
|
||||
assert np.ptp(positions[:, 1]) < 1e-4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
|
||||
def test_depth_symmetry_uses_metric_plane_inputs(gesture):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Depth Symmetry", width=100, height=100, alpha=True)
|
||||
source = bpy.data.objects.new("Depth Symmetry", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
crop_bounds = (50, 20, 90, 80)
|
||||
local_bounds = cutout_geometry.crop_plane_bounds(
|
||||
source,
|
||||
crop_bounds,
|
||||
(100, 100),
|
||||
)
|
||||
y, x = np.indices((61, 41), dtype=np.float32)
|
||||
normalized_x = x / 40.0 * 2.0 - 1.0
|
||||
normalized_y = y / 60.0 * 2.0 - 1.0
|
||||
points = np.zeros((61, 41, 3), dtype=np.float32)
|
||||
points[..., 0] = normalized_x
|
||||
points[..., 1] = normalized_y
|
||||
points[..., 2] = 2.0 + normalized_x * 0.15 - normalized_y * 0.1
|
||||
points[27:34, 17:24, 2] -= 0.25
|
||||
intrinsics = np.asarray(
|
||||
((50.0, 0.0, 20.0), (0.0, 50.0, 30.0), (0.0, 0.0, 1.0)),
|
||||
dtype=np.float32,
|
||||
)
|
||||
valid = np.ones((61, 41), dtype=bool)
|
||||
depth_image = _depth_image(points, valid)
|
||||
depth_metadata = _depth_metadata(intrinsics, (41, 61))
|
||||
|
||||
selection_values = np.ones((60, 40), dtype=np.float32)
|
||||
selection_values[:, :2] = 0.0
|
||||
result = _create_cutout_shape(
|
||||
source,
|
||||
"DEPTH_SYMMETRY",
|
||||
0.1,
|
||||
depth_image=depth_image,
|
||||
depth_metadata=depth_metadata,
|
||||
bounds=crop_bounds,
|
||||
selection_values=selection_values,
|
||||
gesture=gesture,
|
||||
)
|
||||
|
||||
assert all(abs(vertex.co.y) < 1e-8 for vertex in result.data.vertices)
|
||||
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
||||
assert {obj.name for obj in bpy.context.scene.objects} == {
|
||||
source.name,
|
||||
result.name,
|
||||
}
|
||||
assert len(result.modifiers) == 2
|
||||
modifier, symmetry = result.modifiers
|
||||
assert modifier.node_group.name == "O Image Depth Cutout"
|
||||
assert symmetry.node_group.name == "O Image Cutout Symmetry"
|
||||
layer = _group_node(material_data.material_node_group(), result.data.materials[0].node_tree)
|
||||
assert layer.inputs["Object Space"].default_value is True
|
||||
assert not modifier.show_group_selector and not symmetry.show_group_selector
|
||||
|
||||
def value(modifier, name, subtype=None):
|
||||
return modifier_value(modifier, object_data.modifier_input_identifier(
|
||||
modifier.node_group, name, subtype=subtype))
|
||||
|
||||
assert value(modifier, "Mode") == (1 if gesture == "POLYLINE" else 0)
|
||||
for subtype in ("NONE", "DISTANCE"):
|
||||
assert value(modifier, "Thickness", subtype) == 0
|
||||
initialized = {"Mode", "Depth Scale", "Uniform Scale", "Reference Depth", "Depth Image"}
|
||||
for socket in modifier.node_group.interface.items_tree:
|
||||
if (socket.item_type == "SOCKET" and socket.in_out == "INPUT"
|
||||
and socket.name not in initialized
|
||||
and socket.socket_type in {"NodeSocketFloat", "NodeSocketInt"}):
|
||||
assert modifier_value(modifier, socket.identifier) == pytest.approx(socket.default_value)
|
||||
assert isinstance(value(modifier, "Depth Image"), bpy.types.Image)
|
||||
assert value(modifier, "Uniform Scale") > 0
|
||||
assert value(modifier, "Reference Depth") > 0
|
||||
assert not np.allclose(value(symmetry, "Direction"), (0, 1, 0))
|
||||
crop_center = Vector(((local_bounds[0] + local_bounds[1]) / 2,
|
||||
(local_bounds[2] + local_bounds[3]) / 2, 0))
|
||||
np.testing.assert_allclose(result.matrix_world.translation, source.matrix_world @ crop_center, atol=1e-5)
|
||||
from scipy.spatial import cKDTree
|
||||
positions = _evaluated_positions(result)
|
||||
assert len(positions) and np.isfinite(positions).all()
|
||||
assert cKDTree(positions).query(positions * (-1, 1, 1))[0].max() < 1e-5
|
||||
# Both modes start from the same front, including after changing the menu.
|
||||
object_data.set_modifier_input(modifier,
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Mode"),
|
||||
0 if gesture == "POLYLINE" else 1)
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
changed = _evaluated_positions(result)
|
||||
np.testing.assert_array_equal(changed, positions)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("float_buffer", (False, True))
|
||||
def test_boundary_padding_preserves_independent_blender_image_interpretation(float_buffer):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source = bpy.data.images.new(
|
||||
"Padding Source", width=9, height=9, alpha=True, float_buffer=float_buffer
|
||||
)
|
||||
padded = bpy.data.images.new(
|
||||
"Padding Color", width=9, height=9, alpha=True, float_buffer=float_buffer
|
||||
)
|
||||
source.alpha_mode = padded.alpha_mode = "CHANNEL_PACKED"
|
||||
values = np.zeros((9, 9, 4), dtype=np.float32)
|
||||
values[2:7, 2:7] = (0.25, 0.5, 0.75, 1.0)
|
||||
source.pixels.foreach_set(np.flipud(values).ravel())
|
||||
padded.pixels.foreach_set(np.flipud(values).ravel())
|
||||
original = image_rgba_buffer(source)
|
||||
|
||||
pad_cutout_images(padded, None, None, values[..., 3], 0.5, 2.0)
|
||||
|
||||
np.testing.assert_array_equal(image_rgba_buffer(source), original)
|
||||
assert image_rgba_buffer(padded)[1, 4, 3] == pytest.approx(1.0)
|
||||
assert padded.is_float is float_buffer
|
||||
assert padded.alpha_mode == "CHANNEL_PACKED"
|
||||
assert padded.packed_file is not None
|
||||
|
||||
|
||||
def test_local_cutout_selection_matches_uint8_tenth_alpha_boundary():
|
||||
top_down = np.ones((4, 4, 4), dtype=np.float32)
|
||||
top_down[1, 1, 3] = 25.0 / 255.0
|
||||
top_down[1, 2, 3] = 26.0 / 255.0
|
||||
selection_mask = rasterize_selection_path(
|
||||
(4, 4),
|
||||
SelectionPath(
|
||||
points=((0, 0), (4, 0), (4, 4), (0, 4)),
|
||||
),
|
||||
)
|
||||
selection = cutout_main._cutout_content_values(
|
||||
top_down[:, :, 3],
|
||||
selection_mask.values,
|
||||
)
|
||||
|
||||
assert selection[1, 1] <= cutout_geometry.CUTOUT_ALPHA_THRESHOLD
|
||||
assert selection[1, 2] > 0.1
|
||||
|
||||
|
||||
def test_cutout_content_merge_keeps_the_geometric_selection_unchanged():
|
||||
selection = np.asarray(((0.25, 1.0), (0.5, 0.75)), dtype=np.float32)
|
||||
original = selection.copy()
|
||||
alpha = np.asarray(((1.0, 0.5), (0.0, 0.25)), dtype=np.float32)
|
||||
|
||||
content = cutout_main._cutout_content_values(
|
||||
alpha, selection, alpha_threshold=0.5
|
||||
)
|
||||
|
||||
np.testing.assert_array_equal(selection, original)
|
||||
np.testing.assert_allclose(content, alpha * original)
|
||||
with pytest.raises(ImageEditWarning, match="no visible image pixels"):
|
||||
cutout_main._cutout_content_values(np.zeros((2, 2)), selection)
|
||||
|
||||
|
||||
def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Local Cutout", width=64, height=48, alpha=True)
|
||||
source_rgba = np.ones((48, 64, 4), dtype=np.float32)
|
||||
source_rgba[:, :, 3] = np.linspace(0.25, 1.0, 48, dtype=np.float32)[:, None]
|
||||
image.pixels.foreach_set(np.flipud(source_rgba).ravel())
|
||||
source = bpy.data.objects.new("Local Cutout", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
bpy.context.collection.objects.link(source)
|
||||
source.select_set(True)
|
||||
bpy.context.view_layer.objects.active = source
|
||||
selection_path = SelectionPath(
|
||||
points=((-8, 8), (72, 8), (72, 40), (-8, 40)),
|
||||
)
|
||||
source_bounds = image_data.image_empty_bounds(source)
|
||||
|
||||
with patch.object(
|
||||
image_data,
|
||||
"image_rgba",
|
||||
wraps=image_data.image_rgba,
|
||||
) as read_rgba:
|
||||
rgba = image_data.image_rgba(image)
|
||||
selection_mask = rasterize_selection_path(
|
||||
tuple(image.size),
|
||||
selection_path,
|
||||
antialias=True,
|
||||
)
|
||||
left, top, right, bottom = selection_mask.bounds
|
||||
content_values = cutout_main._cutout_content_values(
|
||||
rgba[top:bottom, left:right, 3],
|
||||
selection_mask.values,
|
||||
alpha_threshold=0.5,
|
||||
)
|
||||
color_image = prepared_color_image(
|
||||
source.data,
|
||||
selection_mask.bounds,
|
||||
rgba,
|
||||
)
|
||||
cutout_main.create_cutout_shape(
|
||||
bpy.context,
|
||||
source,
|
||||
"FLAT",
|
||||
0.1,
|
||||
content_values,
|
||||
selection_mask.bounds,
|
||||
alpha_threshold=0.5,
|
||||
boundary_padding=0.0,
|
||||
color_image=color_image,
|
||||
)
|
||||
|
||||
read_rgba.assert_called_once_with(image)
|
||||
|
||||
result = bpy.context.view_layer.objects.active
|
||||
assert result is not source
|
||||
assert result.type == "MESH"
|
||||
assert result["anyimage_mesh_shape"] == "FLAT"
|
||||
modifier = result.modifiers[0]
|
||||
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["FLAT"]
|
||||
assert len(result.data.vertices) > 4
|
||||
assert len(result.data.polygons) > 1
|
||||
assert all(polygon.use_smooth for polygon in result.data.polygons)
|
||||
assert all(abs(vertex.co.y) < 1e-8 for vertex in result.data.vertices)
|
||||
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
||||
source_inverse = source.matrix_world.inverted()
|
||||
source_x = [
|
||||
(source_inverse @ (result.matrix_world @ vertex.co)).x
|
||||
for vertex in result.data.vertices
|
||||
]
|
||||
assert min(source_x) >= source_bounds[0]
|
||||
assert max(source_x) <= source_bounds[1]
|
||||
assert bpy.data.objects.get(source.name) is source
|
||||
assert result.data.materials[0].node_tree is not None
|
||||
color_texture = next(
|
||||
node
|
||||
for node in result.data.materials[0].node_tree.nodes
|
||||
if node.bl_idname == "ShaderNodeTexImage"
|
||||
)
|
||||
assert color_texture.image is not image
|
||||
assert tuple(color_texture.image.size) == (64, 36)
|
||||
copied = np.empty(36 * 64 * 4, dtype=np.float32)
|
||||
color_texture.image.pixels.foreach_get(copied)
|
||||
copied = copied.reshape((36, 64, 4))
|
||||
assert np.allclose(copied[:, :, :3], 1.0)
|
||||
left, top, right, bottom = selection_mask.bounds
|
||||
assert np.allclose(
|
||||
copied[:, :, 3], np.flipud(rgba[top:bottom, left:right, 3])
|
||||
)
|
||||
|
||||
|
||||
def modifier_value(modifier, identifier):
|
||||
"""Read modifier values through the runtime's socket API."""
|
||||
inputs = object_data._modifier_input_slots(modifier)
|
||||
if inputs is None:
|
||||
return modifier[identifier]
|
||||
slot = getattr(inputs, identifier)
|
||||
prop = slot.bl_rna.properties["value"]
|
||||
if prop.type == "ENUM":
|
||||
return list(prop.enum_items.keys()).index(slot.value)
|
||||
return slot.value
|
||||
435
tests/anyimage/operators/test_cutout_operators.py
Normal file
435
tests/anyimage/operators/test_cutout_operators.py
Normal file
@@ -0,0 +1,435 @@
|
||||
from contextlib import nullcontext
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class CutoutOperatorsTest(BlenderTestCase):
|
||||
def test_cutout_gestures_start_on_empty_pick_and_lasso_finishes_on_release(self):
|
||||
viewport = importlib.import_module("anyimage.common.viewport")
|
||||
source = SimpleNamespace(name="Source", matrix_world="matrix", data=object())
|
||||
context = SimpleNamespace(
|
||||
area=SimpleNamespace(as_pointer=lambda: 1, tag_redraw=Mock()),
|
||||
region=SimpleNamespace(as_pointer=lambda: 2),
|
||||
workspace=SimpleNamespace(status_text_set=Mock()),
|
||||
window_manager=SimpleNamespace(modal_handler_add=Mock()),
|
||||
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(mask_gesture="BRUSH")),
|
||||
)
|
||||
event = SimpleNamespace(type="LEFTMOUSE", value="PRESS", mouse_region_x=12, mouse_region_y=18)
|
||||
with (
|
||||
patch.object(viewport, "resolve_image_edit_click", return_value=(source, True)),
|
||||
patch.object(viewport, "is_animated_image", return_value=False),
|
||||
patch.object(viewport, "serialize_matrix", return_value="matrix"),
|
||||
patch.object(viewport, "active_view3d_tool_id", return_value=None),
|
||||
patch.object(self.fake_bpy.types, "SpaceView3D", SimpleNamespace(draw_handler_add=Mock()), create=True),
|
||||
):
|
||||
for gesture in ("LASSO", "POLYLINE"):
|
||||
with self.subTest(gesture=gesture):
|
||||
context.scene.anyimage_settings.cutout_gesture = gesture
|
||||
operator = self.cutout_main.SelectCutoutSelection()
|
||||
operator.report = Mock()
|
||||
self.assertEqual(operator.invoke(context, event), {"RUNNING_MODAL"})
|
||||
self.assertEqual(operator._path, [(12.0, 18.0)])
|
||||
self.assertEqual(operator.gesture, gesture)
|
||||
self.assertEqual(context.scene.anyimage_settings.mask_gesture, "BRUSH")
|
||||
operator._complete = Mock(return_value={"FINISHED"})
|
||||
release = SimpleNamespace(type="LEFTMOUSE", value="RELEASE", mouse_region_x=20, mouse_region_y=30)
|
||||
result = operator.modal(context, release)
|
||||
if gesture == "LASSO":
|
||||
self.assertEqual(result, {"FINISHED"})
|
||||
operator._complete.assert_called_once_with(context)
|
||||
else:
|
||||
operator._complete.assert_not_called()
|
||||
status = context.workspace.status_text_set.call_args.args[0]
|
||||
self.assertIn("Polyline", status)
|
||||
self.assertIn("Backspace", status)
|
||||
|
||||
def test_flat_and_solid_without_ai_build_locally(self):
|
||||
import numpy as np
|
||||
|
||||
source = SimpleNamespace(
|
||||
data=SimpleNamespace(size=(10, 10)), select_set=lambda _value: None
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
|
||||
)
|
||||
selection_mask = self.image_selection.SelectionMask(
|
||||
np.ones((8, 8), dtype=np.float32), (1, 1, 9, 9)
|
||||
)
|
||||
source_rgba = np.ones((10, 10, 4), dtype=np.float32)
|
||||
for shape in ("FLAT", "SOLID"):
|
||||
with self.subTest(shape=shape):
|
||||
operator = SimpleNamespace(
|
||||
source_object_name="Source",
|
||||
selection_path_json=json.dumps(
|
||||
{"points": [[1, 1], [9, 1], [9, 9], [1, 9]]}
|
||||
),
|
||||
shape=shape,
|
||||
gesture="POLYLINE",
|
||||
edge_length=0.1,
|
||||
alpha_threshold=0.25,
|
||||
fine_outline=False,
|
||||
generate_normal=False,
|
||||
report=lambda *_args: None,
|
||||
_needs_generation=lambda: False,
|
||||
)
|
||||
with (
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"require_image_empty",
|
||||
return_value=source,
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main, "is_animated_image", return_value=False
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main, "image_rgba", return_value=source_rgba
|
||||
) as read_rgba,
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"rasterize_selection_path",
|
||||
return_value=selection_mask,
|
||||
) as rasterize,
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"configured_max_ai_input_size",
|
||||
return_value=2048,
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"_cutout_content_values",
|
||||
return_value=selection_mask.values,
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"material_color_image",
|
||||
return_value=nullcontext("Color"),
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main, "create_cutout_shape"
|
||||
) as convert,
|
||||
):
|
||||
result = self.cutout_main.CutoutSelectionToShape.execute(
|
||||
operator, context
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"FINISHED"})
|
||||
read_rgba.assert_called_once_with(source.data)
|
||||
rasterize.assert_called_once()
|
||||
convert.assert_called_once_with(
|
||||
context,
|
||||
source,
|
||||
shape,
|
||||
0.1,
|
||||
selection_mask.values,
|
||||
selection_mask.bounds,
|
||||
alpha_threshold=0.25,
|
||||
fine_outline=False,
|
||||
color_image="Color",
|
||||
boundary_padding=2.0,
|
||||
gesture="POLYLINE",
|
||||
report=operator.report,
|
||||
)
|
||||
|
||||
|
||||
def test_ai_cutout_writes_the_selection_bounds_image(self):
|
||||
import numpy as np
|
||||
|
||||
source = SimpleNamespace(
|
||||
data=SimpleNamespace(size=(4096, 2048)),
|
||||
select_set=lambda _value: None,
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
|
||||
)
|
||||
source_rgba = np.ones((4, 8, 4), dtype=np.float32)
|
||||
selection_mask = self.image_selection.SelectionMask(
|
||||
np.ones((2, 6), dtype=np.float32),
|
||||
(1, 1, 7, 3),
|
||||
)
|
||||
operator = self.cutout_main.CutoutSelectionToShape()
|
||||
operator.alpha_threshold = 0.25
|
||||
operator.fine_outline = False
|
||||
operator.source_object_name = "Source"
|
||||
operator.selection_path_json = json.dumps(
|
||||
{"points": [[1, 1], [7, 1], [7, 3], [1, 3]]}
|
||||
)
|
||||
operator.shape = "DEPTH_SOLID"
|
||||
operator.report = lambda *_args: None
|
||||
operator.edge_length = 0.1
|
||||
operator.generate_normal = False
|
||||
operator.report = lambda *_args: None
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"require_image_empty",
|
||||
return_value=source,
|
||||
),
|
||||
patch.object(self.cutout_main, "is_animated_image", return_value=False),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"image_rgba",
|
||||
return_value=source_rgba,
|
||||
) as read_rgba,
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"rasterize_selection_path",
|
||||
return_value=selection_mask,
|
||||
) as rasterize,
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"configured_max_ai_input_size",
|
||||
return_value=2048,
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"invoke_ai_setup_if_needed",
|
||||
return_value=None,
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"cutout_geometry_settings",
|
||||
return_value=("MODEL", 5),
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"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",
|
||||
return_value={"FINISHED"},
|
||||
),
|
||||
):
|
||||
result = operator.execute(context)
|
||||
|
||||
self.assertEqual(result, {"FINISHED"})
|
||||
read_rgba.assert_called_once_with(source.data)
|
||||
rasterize.assert_called_once()
|
||||
prepare_input.assert_called_once_with(
|
||||
source.data,
|
||||
selection_mask.bounds,
|
||||
source_rgba,
|
||||
)
|
||||
|
||||
|
||||
def test_empty_cutout_selection_is_rejected_after_shape_choice(self):
|
||||
source = SimpleNamespace(
|
||||
data=SimpleNamespace(size=(4096, 2048)),
|
||||
select_set=lambda _value: None,
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
|
||||
)
|
||||
reports = []
|
||||
operator = SimpleNamespace(
|
||||
source_object_name="Source",
|
||||
selection_path_json=json.dumps(
|
||||
{"points": [[1, 1], [7, 1], [7, 3], [1, 3]]}
|
||||
),
|
||||
shape="FLAT",
|
||||
edge_length=0.1,
|
||||
generate_normal=False,
|
||||
report=lambda level, message: reports.append((level, message)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"require_image_empty",
|
||||
return_value=source,
|
||||
),
|
||||
patch.object(self.cutout_main, "is_animated_image", return_value=False),
|
||||
patch.object(self.cutout_main, "image_rgba"),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"rasterize_selection_path",
|
||||
side_effect=self.image_selection.ImageEditWarning(
|
||||
"The selection contains no visible image pixels"
|
||||
),
|
||||
),
|
||||
patch.object(self.cutout_main, "create_cutout_shape") as create_shape,
|
||||
):
|
||||
result = self.cutout_main.CutoutSelectionToShape.execute(
|
||||
operator,
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"CANCELLED"})
|
||||
create_shape.assert_not_called()
|
||||
self.assertEqual(
|
||||
reports,
|
||||
[
|
||||
(
|
||||
{"WARNING"},
|
||||
"The selection contains no visible image pixels",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_cutout_request_describes_ai_capabilities(self):
|
||||
operator = self.cutout_main.CutoutSelectionToShape()
|
||||
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.preferences.DEFAULT_GEOMETRY_MODEL_KEY
|
||||
operator.resolution_level = 5
|
||||
operator.max_ai_input_size = 2048
|
||||
operator.generate_normal = True
|
||||
context = SimpleNamespace(
|
||||
scene=SimpleNamespace(anyimage_settings=SimpleNamespace())
|
||||
)
|
||||
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,
|
||||
"moge2_parameters",
|
||||
return_value={"model": operator.model, "resolution_level": 5},
|
||||
),
|
||||
):
|
||||
parameters = operator.request(context)
|
||||
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in require_model.call_args_list],
|
||||
[operator.model],
|
||||
)
|
||||
self.assertTrue(parameters["generate_depth"])
|
||||
self.assertNotIn("alpha_threshold", parameters)
|
||||
self.assertEqual(parameters["normal_mode"], "OBJECT")
|
||||
|
||||
|
||||
def test_cutout_tool_settings_gate_normal_generation(self):
|
||||
class Row:
|
||||
def __init__(self, events):
|
||||
self.events = events
|
||||
self.enabled = True
|
||||
|
||||
def prop(self, _owner, name, **_options):
|
||||
self.events.append(("prop", name, self.enabled))
|
||||
|
||||
class Layout:
|
||||
def __init__(self):
|
||||
self.events = []
|
||||
|
||||
def operator(self, identifier, **_options):
|
||||
self.events.append(("operator", identifier, True))
|
||||
|
||||
def row(self):
|
||||
return Row(self.events)
|
||||
|
||||
def prop(self, _owner, name, **_options):
|
||||
self.events.append(("prop", name, True))
|
||||
|
||||
context = SimpleNamespace(
|
||||
scene=SimpleNamespace(anyimage_settings=SimpleNamespace())
|
||||
)
|
||||
missing = {
|
||||
"environment_ready": False,
|
||||
"missing_models": (),
|
||||
"ready": False,
|
||||
}
|
||||
setup = ("operator", "anyimage.setup_ai_environment", True)
|
||||
with patch.object(self.ai, "ai_status", return_value=missing):
|
||||
cutout_tool = importlib.import_module(
|
||||
"anyimage.operators.cutout_tool"
|
||||
).CutoutTool
|
||||
layout = Layout()
|
||||
cutout_tool.draw_settings(context, layout, None)
|
||||
self.assertEqual(
|
||||
layout.events,
|
||||
[
|
||||
("prop", "cutout_gesture", True),
|
||||
("prop", "cutout_edge_length", True),
|
||||
("prop", "cutout_fine_outline", True),
|
||||
("prop", "cutout_alpha_threshold", True),
|
||||
setup,
|
||||
("prop", "cutout_generate_normal", False),
|
||||
],
|
||||
)
|
||||
|
||||
ready = {"environment_ready": True, "missing_models": (), "ready": True}
|
||||
with patch.object(self.ai, "ai_status", return_value=ready):
|
||||
layout = Layout()
|
||||
cutout_tool.draw_settings(context, layout, None)
|
||||
self.assertNotIn(setup, layout.events)
|
||||
self.assertTrue(all(event[2] for event in layout.events))
|
||||
|
||||
|
||||
def test_cutout_ai_response_reuses_bounds_color_and_gesture_snapshot(self):
|
||||
import numpy as np
|
||||
|
||||
source = SimpleNamespace(data=SimpleNamespace())
|
||||
color_image = SimpleNamespace(users=0)
|
||||
pure_values = np.ones((3, 4), dtype=np.float32)
|
||||
pure_values[:, 0] = 0.0
|
||||
selection_mask = self.image_selection.SelectionMask(
|
||||
pure_values,
|
||||
(10, 20, 14, 23),
|
||||
)
|
||||
operator = self.cutout_main.CutoutSelectionToShape()
|
||||
operator.alpha_threshold = 0.25
|
||||
operator.fine_outline = False
|
||||
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.gesture = "POLYLINE"
|
||||
operator.report = lambda *_args: None
|
||||
operator.edge_length = 0.1
|
||||
operator.generate_normal = True
|
||||
operator._selection_mask = selection_mask
|
||||
operator.boundary_padding = 3.5
|
||||
result = SimpleNamespace(value={}, file=lambda _key: Path(__file__))
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"require_image_empty",
|
||||
return_value=source,
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"_generated_color_result",
|
||||
return_value=(
|
||||
color_image,
|
||||
np.ones((3, 4), dtype=np.float32),
|
||||
selection_mask.bounds,
|
||||
),
|
||||
) as color_result,
|
||||
patch.object(self.cutout_main, "create_cutout_shape") as create_shape,
|
||||
):
|
||||
context = SimpleNamespace(scene=SimpleNamespace(
|
||||
anyimage_settings=SimpleNamespace(cutout_gesture="LASSO")
|
||||
))
|
||||
message = operator.response(context, result)
|
||||
|
||||
color_result.assert_called_once_with(
|
||||
__file__,
|
||||
selection_mask.bounds,
|
||||
)
|
||||
|
||||
self.assertEqual(message, "Flat is ready")
|
||||
self.assertEqual(create_shape.call_args.kwargs["gesture"], "POLYLINE")
|
||||
content_values = create_shape.call_args.args[4]
|
||||
np.testing.assert_array_equal(content_values, pure_values)
|
||||
self.assertEqual(create_shape.call_args.args[5], selection_mask.bounds)
|
||||
self.assertIs(create_shape.call_args.kwargs["color_image"], color_image)
|
||||
self.assertEqual(create_shape.call_args.kwargs["alpha_threshold"], 0.25)
|
||||
self.assertIs(create_shape.call_args.kwargs["fine_outline"], False)
|
||||
self.assertEqual(create_shape.call_args.kwargs["boundary_padding"], 3.5)
|
||||
133
tests/anyimage/operators/test_frame_antialiasing.py
Normal file
133
tests/anyimage/operators/test_frame_antialiasing.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from unittest.mock import patch
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
from anyimage.common.image import premultiplied_rgba
|
||||
from anyimage.operators.frame_tool import compositing as frame
|
||||
from anyimage.operators.frame_tool.projection import frame_source_projection
|
||||
|
||||
|
||||
def source(rgba, *, bounds=(-1, 1, -1, 1), depth=1, slope=0, active=True):
|
||||
rgba = np.asarray(rgba, dtype=np.float32)
|
||||
height, width = rgba.shape[:2]
|
||||
world = np.eye(4)
|
||||
world[2, 3] = -depth
|
||||
world[2, 0] = slope
|
||||
result = frame_source_projection(
|
||||
np.eye(4), np.eye(4), world, bounds, (1, 1), perspective=False
|
||||
)
|
||||
result.update(
|
||||
rgba=rgba, pixels=premultiplied_rgba(np.flipud(rgba).ravel(), (width, height)),
|
||||
image_size=(width, height), active=active, name="Active" if active else "Bottom",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def render(sources, size=(1, 1)):
|
||||
return np.flipud(frame.composite_frame_pixels(
|
||||
sources, ((0, 1), (1, 1), (1, 0), (0, 0)), size
|
||||
).reshape(size[1], size[0], 4))
|
||||
|
||||
|
||||
def test_white_half_coverage_does_not_mix_canvas_black():
|
||||
image = source([[[1, 1, 1, 1]]], bounds=(-1, 0, -1, 1))
|
||||
np.testing.assert_allclose(render((image,)), [[[1, 1, 1, 0.5]]])
|
||||
|
||||
|
||||
def test_uncovered_canvas_has_no_rgb_to_reveal_with_mask_add():
|
||||
from anyimage.common.selection import SelectionMask
|
||||
from anyimage.operators.mask_tool import apply_alpha_mask
|
||||
|
||||
result = render((source([[[1, 1, 1, 1]]], bounds=(-0.5, 0.5, -0.5, 0.5)),), (8, 8))
|
||||
uncovered = result[..., 3] == 0
|
||||
assert np.any(uncovered)
|
||||
np.testing.assert_array_equal(result[uncovered], 0)
|
||||
restored = np.flipud(apply_alpha_mask(
|
||||
result, SelectionMask(np.ones((8, 8)), (0, 0, 8, 8)), "EXTEND"
|
||||
).reshape((8, 8, 4)))
|
||||
np.testing.assert_array_equal(restored[..., :3][uncovered], 0)
|
||||
np.testing.assert_array_equal(restored[..., 3], 1)
|
||||
|
||||
|
||||
def test_partial_foreground_over_transparent_blue():
|
||||
bottom = source([[[0, 0, 1, 0]]], depth=2, active=False)
|
||||
top = source([[[1, 0, 0, 0.5]]], bounds=(-1, 0, -1, 1))
|
||||
np.testing.assert_allclose(render((bottom, top)), [[[0.25, 0, 0.75, 0.25]]])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha", [0, 1e-12, 1e-8, 1e-7])
|
||||
def test_near_zero_alpha_keeps_covered_rgb(alpha):
|
||||
result = render((source([[[0.2, 0.4, 0.8, alpha]]]),))
|
||||
np.testing.assert_allclose(result[..., :3], [[[0.2, 0.4, 0.8]]])
|
||||
np.testing.assert_allclose(result[..., 3], 0 if alpha <= 1e-8 else alpha, atol=1e-15)
|
||||
|
||||
|
||||
def test_narrow_source_missed_by_center_is_sampled():
|
||||
image = source([[[1, 1, 1, 1]]], bounds=(-0.8, -0.7, -1, 1))
|
||||
np.testing.assert_allclose(render((image,)), [[[1, 1, 1, 0.25]]])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha", [0, 1])
|
||||
def test_crossing_planes_resolve_per_sample_depth(alpha):
|
||||
first = source([[[1, 0, 0, alpha]]], slope=1)
|
||||
second = source([[[0, 0, 1, alpha]]], slope=-1, active=False)
|
||||
np.testing.assert_allclose(render((first, second)), [[[0.5, 0, 0.5, alpha]]])
|
||||
|
||||
|
||||
def test_aligned_interior_keeps_checkerboard():
|
||||
rgba = np.ones((8, 8, 4), dtype=np.float32)
|
||||
rgba[..., :3] = (np.indices((8, 8)).sum(axis=0) % 2)[..., None]
|
||||
np.testing.assert_allclose(render((source(rgba),), (8, 8)), rgba)
|
||||
|
||||
|
||||
def test_oblique_boundary_matches_reference_and_chunk_sizes():
|
||||
top = source([[[1, 0, 0, 0.5]]], bounds=(-0.7, 0.7, -0.7, 0.7))
|
||||
angle = 0.3
|
||||
world = np.eye(4)
|
||||
world[:2, :2] = ((np.cos(angle), -np.sin(angle)), (np.sin(angle), np.cos(angle)))
|
||||
world[2, 3] = -1
|
||||
top.update(frame_source_projection(
|
||||
np.eye(4), np.eye(4), world, (-0.7, 0.7, -0.7, 0.7), (1, 1), perspective=False
|
||||
))
|
||||
bottom = source([[[0, 0, 1, 0]]], depth=2, active=False)
|
||||
sources = (bottom, top)
|
||||
size = 17
|
||||
reference = np.zeros((size, size, 4), dtype=np.float32)
|
||||
for dy in (0.125, 0.375, 0.625, 0.875):
|
||||
for dx in (0.125, 0.375, 0.625, 0.875):
|
||||
x, y = np.meshgrid((np.arange(size) + dx) / size, 1 - (np.arange(size) + dy) / size)
|
||||
sample, _ = frame._composite_frame_samples(sources, np.stack((x, y), axis=-1))
|
||||
reference += sample / 16
|
||||
for rows in (1, 3, 256):
|
||||
with patch.object(frame, "PROJECTIVE_CHUNK_ROWS", rows):
|
||||
np.testing.assert_allclose(render(sources, (size, size)), reference, atol=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("background", [0, 1])
|
||||
def test_packed_frame_mask_extend_keeps_boundary_color(background):
|
||||
from io import BytesIO
|
||||
import bpy
|
||||
from PIL import Image
|
||||
from anyimage.common.image import create_image_edit_result, image_rgba
|
||||
from anyimage.common.selection import SelectionMask
|
||||
from anyimage.operators.mask_tool import apply_alpha_mask
|
||||
|
||||
bottom = source([[[background, background, background, 0]]], depth=2, active=False)
|
||||
top = source([[[1, 0, 0, 1]]], bounds=(-1, 0, -1, 1))
|
||||
rgba = render((bottom, top))
|
||||
original = bpy.data.images.new("Boundary source", width=1, height=1, alpha=True)
|
||||
result = create_image_edit_result(original, rgba.ravel(), (1, 1))
|
||||
try:
|
||||
packed = np.asarray(Image.open(BytesIO(result.packed_file.data))) / 255
|
||||
expected = (np.array([1, 0, 0]) + background) / 2
|
||||
np.testing.assert_allclose(packed[0, 0, :3], expected, atol=1 / 255)
|
||||
restored = apply_alpha_mask(
|
||||
image_rgba(result), SelectionMask(np.ones((1, 1)), (0, 0, 1, 1)), "EXTEND"
|
||||
).reshape(1, 1, 4)
|
||||
np.testing.assert_allclose(restored[0, 0, :3], expected, atol=1 / 255)
|
||||
assert restored[0, 0, 3] == 1
|
||||
assert result.alpha_mode == "STRAIGHT"
|
||||
finally:
|
||||
bpy.data.images.remove(original)
|
||||
bpy.data.images.remove(result)
|
||||
304
tests/anyimage/operators/test_frame_compositing.py
Normal file
304
tests/anyimage/operators/test_frame_compositing.py
Normal file
@@ -0,0 +1,304 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from anyimage.common import image as image_data, selection
|
||||
from anyimage.operators import mask_tool
|
||||
from anyimage.operators.frame_tool import compositing, projection as frame_projection
|
||||
|
||||
|
||||
class FrameCompositingTest(unittest.TestCase):
|
||||
def test_frame_composites_far_to_near_with_active_on_top(self):
|
||||
import numpy as np
|
||||
|
||||
frame = ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0))
|
||||
|
||||
def source(name, color, depth, active=False):
|
||||
pixels = np.empty((2, 2, 4), dtype=np.float32)
|
||||
pixels[:] = color
|
||||
return {
|
||||
"active": active,
|
||||
"image_size": (2, 2),
|
||||
"name": name,
|
||||
"perspective": True,
|
||||
"pixels": pixels,
|
||||
"rgba": image_data.straight_rgba(pixels),
|
||||
"region_size": (2.0, 2.0),
|
||||
"screen_to_source": np.asarray(
|
||||
((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0))
|
||||
),
|
||||
"source_to_view": np.asarray(
|
||||
(
|
||||
(2.0, 0.0, -1.0),
|
||||
(0.0, -2.0, 1.0),
|
||||
(0.0, 0.0, -depth),
|
||||
(0.0, 0.0, 1.0),
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
far = source("Far", (1.0, 0.0, 0.0, 1.0), 2.0)
|
||||
active = source("Active", (0.0, 0.0, 0.5, 0.5), 1.0, active=True)
|
||||
pixels = compositing.composite_frame_pixels(
|
||||
(active, far), frame, (2, 2)
|
||||
)
|
||||
result = np.flipud(np.asarray(pixels).reshape((2, 2, 4)))
|
||||
|
||||
np.testing.assert_allclose(result[:, :, 0], 0.5, atol=1e-6)
|
||||
np.testing.assert_allclose(result[:, :, 2], 0.5, atol=1e-6)
|
||||
np.testing.assert_allclose(result[:, :, 3], 1.0, atol=1e-6)
|
||||
|
||||
|
||||
def test_frame_puts_active_above_an_equal_depth_source(self):
|
||||
import numpy as np
|
||||
|
||||
frame = ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0))
|
||||
screen_to_source = np.asarray(
|
||||
((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0))
|
||||
)
|
||||
|
||||
def source(name, color, active):
|
||||
pixels = np.empty((2, 2, 4), dtype=np.float32)
|
||||
pixels[:] = color
|
||||
return {
|
||||
"active": active,
|
||||
"image_size": (2, 2),
|
||||
"name": name,
|
||||
"perspective": True,
|
||||
"pixels": pixels,
|
||||
"rgba": pixels.copy(),
|
||||
"region_size": (2.0, 2.0),
|
||||
"screen_to_source": screen_to_source,
|
||||
"source_to_view": np.asarray(
|
||||
(
|
||||
(2.0, 0.0, -1.0),
|
||||
(0.0, -2.0, 1.0),
|
||||
(0.0, 0.0, -1.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
pixels = compositing.composite_frame_pixels(
|
||||
(
|
||||
source("Active", (0.0, 0.0, 1.0, 1.0), True),
|
||||
source("Other", (1.0, 0.0, 0.0, 1.0), False),
|
||||
),
|
||||
frame,
|
||||
(2, 2),
|
||||
)
|
||||
result = np.asarray(pixels).reshape((2, 2, 4))
|
||||
|
||||
np.testing.assert_allclose(result[:, :, 2], 1.0, atol=1e-6)
|
||||
np.testing.assert_allclose(result[:, :, 0], 0.0, atol=1e-6)
|
||||
|
||||
|
||||
def test_frame_composites_orthographic_samples_at_negative_depth(self):
|
||||
import numpy as np
|
||||
|
||||
pixels = np.empty((2, 2, 4), dtype=np.float32)
|
||||
pixels[:] = (0.25, 0.5, 0.75, 1.0)
|
||||
source = {
|
||||
"active": True,
|
||||
"image_size": (2, 2),
|
||||
"name": "Active",
|
||||
"perspective": False,
|
||||
"pixels": pixels,
|
||||
"rgba": pixels.copy(),
|
||||
"region_size": (2.0, 2.0),
|
||||
"screen_to_source": np.asarray(
|
||||
((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0))
|
||||
),
|
||||
"source_to_view": np.asarray(
|
||||
(
|
||||
(2.0, 0.0, -1.0),
|
||||
(0.0, -2.0, 1.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
result = compositing.composite_frame_pixels(
|
||||
(source,),
|
||||
((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)),
|
||||
(2, 2),
|
||||
)
|
||||
|
||||
np.testing.assert_allclose(
|
||||
np.asarray(result).reshape((2, 2, 4)),
|
||||
pixels,
|
||||
atol=1e-6,
|
||||
)
|
||||
|
||||
|
||||
def test_frame_preserves_masked_rgb_and_can_restore_alpha(self):
|
||||
import numpy as np
|
||||
|
||||
rgba = np.random.default_rng(7).random((4, 4, 4)).astype(np.float32)
|
||||
mask = selection.SelectionMask(np.ones((4, 4)), (0, 0, 4, 4))
|
||||
removed = mask_tool.apply_alpha_mask(rgba, mask, "SUBTRACT")
|
||||
source = self._frame_pixel_source(np.flipud(removed.reshape(rgba.shape)))
|
||||
framed = compositing.composite_frame_pixels(
|
||||
(source,), ((0, 4), (4, 4), (4, 0), (0, 0)), (4, 4)
|
||||
)
|
||||
result = np.flipud(framed.reshape(rgba.shape))
|
||||
np.testing.assert_allclose(result[..., :3], rgba[..., :3])
|
||||
np.testing.assert_array_equal(result[..., 3], 0)
|
||||
restored = mask_tool.apply_alpha_mask(result, mask, "EXTEND")
|
||||
restored = np.flipud(restored.reshape(rgba.shape))
|
||||
np.testing.assert_allclose(restored[..., :3], rgba[..., :3])
|
||||
np.testing.assert_array_equal(restored[..., 3], 1)
|
||||
|
||||
|
||||
def test_frame_hidden_rgb_uses_bottom_valid_depth_and_stable_ties(self):
|
||||
import numpy as np
|
||||
|
||||
blue = np.tile((0, 0, 1, 0), (2, 2, 1))
|
||||
red = np.tile((1, 0, 0, 0), (2, 2, 1))
|
||||
frame = ((0, 2), (2, 2), (2, 0), (0, 0))
|
||||
for depth in (2.0, 1.0, -1.0):
|
||||
with self.subTest(depth=depth):
|
||||
bottom = self._frame_pixel_source(blue, depth=depth, name="A", active=False)
|
||||
top = self._frame_pixel_source(red, depth=min(depth, 1.0))
|
||||
result = compositing.composite_frame_pixels((top, bottom), frame, (2, 2))
|
||||
np.testing.assert_allclose(result.reshape((2, 2, 4)), blue)
|
||||
middle = self._frame_pixel_source(red, name="B", active=False)
|
||||
bottom = self._frame_pixel_source(blue, name="A", active=False)
|
||||
top = self._frame_pixel_source(red)
|
||||
result = compositing.composite_frame_pixels((middle, top, bottom), frame, (2, 2))
|
||||
np.testing.assert_allclose(result.reshape((2, 2, 4)), blue)
|
||||
bottom["screen_to_source"][0, 2] += 0.5
|
||||
result = np.flipud(compositing.composite_frame_pixels(
|
||||
(top, bottom), frame, (2, 2)
|
||||
).reshape((2, 2, 4)))
|
||||
np.testing.assert_allclose(result[:, 0], blue[:, 0])
|
||||
np.testing.assert_allclose(result[:, 1], red[:, 1])
|
||||
bottom["perspective"] = True
|
||||
bottom["source_to_view"][2, 2] = 1.0
|
||||
result = compositing.composite_frame_pixels((top, bottom), frame, (2, 2))
|
||||
np.testing.assert_allclose(result.reshape((2, 2, 4)), red)
|
||||
|
||||
|
||||
def test_frame_hidden_rgb_tracks_crossing_planes(self):
|
||||
import numpy as np
|
||||
|
||||
bottom = self._frame_pixel_source(np.tile((0, 0, 1, 0), (2, 2, 1)), active=False)
|
||||
top = self._frame_pixel_source(np.tile((1, 0, 0, 0), (2, 2, 1)))
|
||||
bottom["source_to_view"][2] = (2, 0, -2)
|
||||
result = np.flipud(compositing.composite_frame_pixels(
|
||||
(top, bottom), ((0, 2), (2, 2), (2, 0), (0, 0)), (2, 2)
|
||||
).reshape((2, 2, 4)))
|
||||
np.testing.assert_allclose(result[:, 0], np.tile((0, 0, 1, 0), (2, 1)))
|
||||
np.testing.assert_allclose(result[:, 1], np.tile((1, 0, 0, 0), (2, 1)))
|
||||
|
||||
|
||||
def test_frame_blends_rgb_over_bottom_color_with_independent_alpha(self):
|
||||
import numpy as np
|
||||
|
||||
for bottom_alpha in (0.0, 0.5, 1.0):
|
||||
blue = np.tile((0, 0, 1, bottom_alpha), (2, 5, 1)).astype(np.float32)
|
||||
red = np.tile((1, 0, 0, 0), (2, 5, 1)).astype(np.float32)
|
||||
red[..., 3] = (0, 0.25, 0.5, 0.75, 1)
|
||||
sources = (self._frame_pixel_source(blue, depth=2, active=False),
|
||||
self._frame_pixel_source(red))
|
||||
result = np.flipud(compositing.composite_frame_pixels(
|
||||
sources, ((0, 2), (5, 2), (5, 0), (0, 0)), (5, 2)
|
||||
).reshape((2, 5, 4)))
|
||||
alpha = red[..., 3] + bottom_alpha * (1 - red[..., 3])
|
||||
expected = np.zeros_like(red)
|
||||
expected[..., 0] = red[..., 3]
|
||||
expected[..., 2] = 1 - red[..., 3]
|
||||
np.testing.assert_allclose(result[..., 3], alpha)
|
||||
np.testing.assert_allclose(result[..., :3], expected[..., :3])
|
||||
|
||||
|
||||
def test_frame_three_layers_preserve_foreground_gradient_over_white(self):
|
||||
import numpy as np
|
||||
|
||||
white = np.tile((1, 1, 1, 0), (2, 6, 1)).astype(np.float32)
|
||||
red = np.tile((1, 0, 0, 0), (2, 6, 1)).astype(np.float32)
|
||||
red[..., 3] = (0, 0.01, 0.1, 0.25, 0.5, 1)
|
||||
blue = np.tile((0, 0, 1, 0.25), (2, 6, 1)).astype(np.float32)
|
||||
sources = (
|
||||
self._frame_pixel_source(white, depth=3, active=False),
|
||||
self._frame_pixel_source(red, depth=2, active=False),
|
||||
self._frame_pixel_source(blue),
|
||||
)
|
||||
result = np.flipud(compositing.composite_frame_pixels(
|
||||
sources, ((0, 2), (6, 2), (6, 0), (0, 0)), (6, 2)
|
||||
).reshape((2, 6, 4)))
|
||||
red_alpha = red[..., 3:4]
|
||||
expected = ((red[..., :3] * red_alpha + 1 - red_alpha) * 0.75
|
||||
+ blue[..., :3] * 0.25)
|
||||
np.testing.assert_allclose(result[..., :3], expected)
|
||||
np.testing.assert_allclose(result[..., 3], 0.25 + red[..., 3] * 0.75)
|
||||
|
||||
|
||||
def test_frame_foreground_resampling_excludes_hidden_black(self):
|
||||
import numpy as np
|
||||
|
||||
white = self._frame_pixel_source(
|
||||
np.tile((1, 1, 1, 0), (1, 2, 1)), depth=2, active=False
|
||||
)
|
||||
foreground = self._frame_pixel_source(
|
||||
np.asarray([[(1, 0, 0, 1), (0, 0, 0, 0)]])
|
||||
)
|
||||
result = compositing.composite_frame_pixels(
|
||||
(foreground, white), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1)
|
||||
)
|
||||
np.testing.assert_allclose(result, (1, 0.5, 0.5, 0.5))
|
||||
|
||||
|
||||
def test_frame_raw_sampling_and_empty_canvas_are_chunk_independent(self):
|
||||
import numpy as np
|
||||
|
||||
rgba = np.tile((0, 1, 0, 0), (4, 4, 1)).astype(np.float32)
|
||||
rgba[1:3, 2:] = (1, 0, 0, 1)
|
||||
source = self._frame_pixel_source(rgba)
|
||||
frame = ((-3, 7), (7, 7), (7, -3), (-3, -3))
|
||||
results = []
|
||||
for rows in (1, 2, 256):
|
||||
with patch.object(compositing, "PROJECTIVE_CHUNK_ROWS", rows):
|
||||
results.append(np.flipud(compositing.composite_frame_pixels(
|
||||
(source,), frame, (10, 10)
|
||||
).reshape((10, 10, 4))))
|
||||
for result in results:
|
||||
np.testing.assert_array_equal(result, results[0])
|
||||
np.testing.assert_allclose(result[3:7, 3:7], rgba)
|
||||
np.testing.assert_array_equal(result[4, 7], 0)
|
||||
np.testing.assert_array_equal(result[0], 0)
|
||||
transparent = np.asarray([[(1, 0, 0, 0), (0, 0, 1, 0)]], dtype=np.float32)
|
||||
source = self._frame_pixel_source(transparent)
|
||||
framed = compositing.composite_frame_pixels(
|
||||
(source,), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1)
|
||||
)
|
||||
np.testing.assert_allclose(framed, (0.5, 0, 0.5, 0))
|
||||
sampled = image_data.sample_rgba(
|
||||
transparent, np.asarray(((1, 0.5), (-1, 0.5)))
|
||||
)
|
||||
np.testing.assert_allclose(sampled, ((0.5, 0, 0.5, 0), (0, 0, 0, 0)))
|
||||
transparent[0, 0, 3] = 1
|
||||
source = self._frame_pixel_source(transparent)
|
||||
framed = compositing.composite_frame_pixels(
|
||||
(source,), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1)
|
||||
)
|
||||
np.testing.assert_allclose(framed, (0.5, 0, 0.5, 0.5))
|
||||
|
||||
|
||||
def _frame_pixel_source(self, rgba, *, depth=1.0, name="Active", active=True):
|
||||
import numpy as np
|
||||
|
||||
rgba = np.asarray(rgba, dtype=np.float32)
|
||||
height, width = rgba.shape[:2]
|
||||
world = np.identity(4)
|
||||
world[2, 3] = -depth
|
||||
source = frame_projection.frame_source_projection(
|
||||
np.identity(4), np.identity(4), world,
|
||||
(-1.0, 1.0, -1.0, 1.0), (width, height), perspective=False,
|
||||
)
|
||||
source.update(
|
||||
active=active, name=name, image_size=(width, height), rgba=rgba,
|
||||
pixels=image_data.premultiplied_rgba(
|
||||
np.flipud(rgba).ravel(), (width, height)
|
||||
),
|
||||
)
|
||||
return source
|
||||
322
tests/anyimage/operators/test_frame_projection.py
Normal file
322
tests/anyimage/operators/test_frame_projection.py
Normal file
@@ -0,0 +1,322 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from anyimage.operators.frame_tool import projection as frame_projection
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
from anyimage.common.selection import clip_polygon_halfplanes
|
||||
|
||||
|
||||
@pytest.mark.parametrize("focal", [1, 1.7, 2, 4])
|
||||
@pytest.mark.parametrize("angle,depth", [(30, 0.2), (0, 1e-14)])
|
||||
def test_frame_overlap_matches_finite_front_samples(focal, angle, depth):
|
||||
projection = np.eye(4)
|
||||
projection[0, 0] = projection[1, 1] = focal
|
||||
projection[3] = (0, 0, -1, 0)
|
||||
c, s = np.cos(np.radians(angle)), np.sin(np.radians(angle))
|
||||
world = np.eye(4)
|
||||
world[:3, :3] = ((c, 0, s), (0, 1, 0), (-s, 0, c))
|
||||
world[2, 3] = -depth
|
||||
source = frame_projection.frame_source_projection(
|
||||
projection, np.eye(4), world, (-1, 1, -1, 1), (100, 100), perspective=True
|
||||
)
|
||||
source["image_size"] = (100, 100)
|
||||
assert frame_projection.frame_source_coordinates(source, np.array(((50, 50),)))[2][0]
|
||||
overlap = frame_projection.frame_source_overlap(source, ((0, 100), (100, 100), (100, 0), (0, 0)))
|
||||
assert len(overlap) >= 3
|
||||
assert np.isfinite(overlap).all()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scale", [1e-14, 1, 1e14])
|
||||
def test_halfplane_clipping_is_scale_invariant(scale):
|
||||
square = ((0, 0), (2, 0), (2, 2), (0, 2))
|
||||
result = clip_polygon_halfplanes(square, ((-scale, 0, scale),))
|
||||
assert set(result) == {(0, 0), (1, 0), (1, 2), (0, 2)}
|
||||
assert clip_polygon_halfplanes(square, ((1, 0, -3),)) == ()
|
||||
edge = clip_polygon_halfplanes(square, ((1, 0, -2),))
|
||||
assert all(x == 2 for x, y in edge)
|
||||
|
||||
|
||||
class FrameProjectionTest(unittest.TestCase):
|
||||
def test_frame_output_size_uses_viewport_pixels(self):
|
||||
frame = (
|
||||
(-50.0, 150.0),
|
||||
(150.0, 150.0),
|
||||
(150.0, -50.0),
|
||||
(-50.0, -50.0),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
frame_projection.frame_output_size(
|
||||
frame,
|
||||
(1000, 800),
|
||||
2048,
|
||||
),
|
||||
(200, 200),
|
||||
)
|
||||
|
||||
|
||||
def test_frame_output_size_obeys_region_and_preference_limits(self):
|
||||
frame = (
|
||||
(0.0, 1000.0),
|
||||
(2000.0, 1000.0),
|
||||
(2000.0, 0.0),
|
||||
(0.0, 0.0),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
frame_projection.frame_output_size(frame, (1000, 800), 2048),
|
||||
(1000, 500),
|
||||
)
|
||||
self.assertEqual(
|
||||
frame_projection.frame_output_size(frame, (4000, 2000), 500),
|
||||
(500, 250),
|
||||
)
|
||||
|
||||
|
||||
def test_frame_requires_positive_valid_projection_overlap(self):
|
||||
import numpy as np
|
||||
|
||||
source = frame_projection.frame_source_projection(
|
||||
np.identity(4),
|
||||
np.identity(4),
|
||||
np.identity(4),
|
||||
(-1.0, 1.0, -1.0, 1.0),
|
||||
(100, 100),
|
||||
perspective=False,
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
frame_projection.frame_source_overlap(
|
||||
source,
|
||||
((50.0, 150.0), (150.0, 150.0), (150.0, 50.0), (50.0, 50.0)),
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
frame_projection.frame_source_overlap(
|
||||
source,
|
||||
((100.0, 100.0), (200.0, 100.0), (200.0, 0.0), (100.0, 0.0)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_frame_orthographic_projection_accepts_non_positive_depth(self):
|
||||
import numpy as np
|
||||
|
||||
matrix_world = np.identity(4)
|
||||
matrix_world[2, 3] = 1.0
|
||||
source = frame_projection.frame_source_projection(
|
||||
np.identity(4),
|
||||
np.identity(4),
|
||||
matrix_world,
|
||||
(-1.0, 1.0, -1.0, 1.0),
|
||||
(100, 100),
|
||||
perspective=False,
|
||||
)
|
||||
source["image_size"] = (100, 100)
|
||||
points, depth, valid, normalized = frame_projection.frame_source_coordinates(
|
||||
source,
|
||||
np.asarray(((25.0, 75.0),)),
|
||||
)
|
||||
|
||||
np.testing.assert_allclose(points[0], (25.0, 25.0))
|
||||
np.testing.assert_allclose(normalized[0], (0.25, 0.25))
|
||||
self.assertEqual(depth[0], -1.0)
|
||||
self.assertTrue(valid[0])
|
||||
self.assertTrue(
|
||||
frame_projection.frame_source_overlap(
|
||||
source,
|
||||
((0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_frame_rejects_an_orthographic_plane_parallel_to_view_rays(self):
|
||||
import numpy as np
|
||||
|
||||
matrix_world = np.asarray(
|
||||
(
|
||||
(0.0, 0.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 0.0, 0.0),
|
||||
(-1.0, 0.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 0.0, 1.0),
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "edge-on"):
|
||||
frame_projection.frame_source_projection(
|
||||
np.identity(4),
|
||||
np.identity(4),
|
||||
matrix_world,
|
||||
(-1.0, 1.0, -1.0, 1.0),
|
||||
(100, 100),
|
||||
perspective=False,
|
||||
)
|
||||
|
||||
|
||||
def test_frame_perspective_projection_samples_front_of_crossing_image(self):
|
||||
import numpy as np
|
||||
|
||||
projection = np.identity(4)
|
||||
projection[3] = (0.0, 0.0, -1.0, 0.0)
|
||||
matrix_world = np.identity(4)
|
||||
matrix_world[0, 3] = 1.0
|
||||
matrix_world[2, 0] = 1.0
|
||||
matrix_world[2, 3] = 0.2
|
||||
source = frame_projection.frame_source_projection(
|
||||
projection,
|
||||
np.identity(4),
|
||||
matrix_world,
|
||||
(-1.0, 1.0, -1.0, 1.0),
|
||||
(100, 100),
|
||||
perspective=True,
|
||||
)
|
||||
source["image_size"] = (100, 100)
|
||||
front_points, front_depth, front_valid, _normalized = (
|
||||
frame_projection.frame_source_coordinates(
|
||||
source,
|
||||
np.asarray(((75.0, 50.0),)),
|
||||
)
|
||||
)
|
||||
_rear_points, rear_depth, rear_valid, _normalized = (
|
||||
frame_projection.frame_source_coordinates(
|
||||
source,
|
||||
np.asarray(((-50.0, 50.0),)),
|
||||
)
|
||||
)
|
||||
overlap = frame_projection.frame_source_overlap(
|
||||
source,
|
||||
((50.0, 100.0), (100.0, 100.0), (100.0, 0.0), (50.0, 0.0)),
|
||||
)
|
||||
|
||||
self.assertTrue(front_valid[0])
|
||||
self.assertGreater(front_depth[0], 0.0)
|
||||
self.assertGreaterEqual(front_points[0, 0], 0.0)
|
||||
self.assertFalse(rear_valid[0])
|
||||
self.assertLess(rear_depth[0], 0.0)
|
||||
self.assertTrue(overlap)
|
||||
|
||||
_parallel_points, _parallel_depth, parallel_valid, _normalized = (
|
||||
frame_projection.frame_source_coordinates(
|
||||
source,
|
||||
np.asarray(((0.0, 50.0),)),
|
||||
)
|
||||
)
|
||||
self.assertFalse(parallel_valid[0])
|
||||
|
||||
|
||||
def test_frame_perspective_projection_accepts_extremely_close_image(self):
|
||||
import numpy as np
|
||||
|
||||
projection = np.identity(4)
|
||||
projection[3] = (0.0, 0.0, -1.0, 0.0)
|
||||
matrix_world = np.identity(4)
|
||||
matrix_world[2, 3] = -1e-14
|
||||
source = frame_projection.frame_source_projection(
|
||||
projection,
|
||||
np.identity(4),
|
||||
matrix_world,
|
||||
(-1.0, 1.0, -1.0, 1.0),
|
||||
(100, 100),
|
||||
perspective=True,
|
||||
)
|
||||
source["image_size"] = (100, 100)
|
||||
_points, depth, valid, _normalized = frame_projection.frame_source_coordinates(
|
||||
source,
|
||||
np.asarray(((50.0, 50.0),)),
|
||||
)
|
||||
|
||||
self.assertTrue(valid[0])
|
||||
self.assertEqual(depth[0], 1e-14)
|
||||
|
||||
|
||||
def test_frame_overlap_does_not_depend_on_output_pixel_centers(self):
|
||||
import numpy as np
|
||||
|
||||
source = frame_projection.frame_source_projection(
|
||||
np.identity(4),
|
||||
np.identity(4),
|
||||
np.identity(4),
|
||||
(-1.0, 1.0, -1.0, 1.0),
|
||||
(100, 100),
|
||||
perspective=False,
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
frame_projection.frame_source_overlap(
|
||||
source,
|
||||
(
|
||||
(99.999, 60.0),
|
||||
(100.001, 60.0),
|
||||
(100.001, 40.0),
|
||||
(99.999, 40.0),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_frame_uses_crossing_active_overlap_for_visible_result_depth(self):
|
||||
import numpy as np
|
||||
|
||||
projection = np.identity(4)
|
||||
projection[3] = (0.0, 0.0, -1.0, 0.0)
|
||||
matrix_world = np.identity(4)
|
||||
matrix_world[0, 3] = 1.0
|
||||
matrix_world[2, 0] = 1.0
|
||||
matrix_world[2, 3] = 0.2
|
||||
source = frame_projection.frame_source_projection(
|
||||
projection,
|
||||
np.identity(4),
|
||||
matrix_world,
|
||||
(-1.0, 1.0, -1.0, 1.0),
|
||||
(100, 100),
|
||||
perspective=True,
|
||||
)
|
||||
source.update(
|
||||
{
|
||||
"image_size": (100, 100),
|
||||
"object": SimpleNamespace(matrix_world=matrix_world),
|
||||
}
|
||||
)
|
||||
frame = ((50.0, 100.0), (100.0, 100.0), (100.0, 0.0), (50.0, 0.0))
|
||||
overlap = frame_projection.frame_source_overlap(source, frame)
|
||||
depth_location = frame_projection.frame_depth_location(source, overlap)
|
||||
|
||||
self.assertLess(depth_location[2], 0.0)
|
||||
|
||||
|
||||
def test_frame_keeps_positive_active_origin_as_result_depth_location(self):
|
||||
marker = SimpleNamespace(copy=lambda: "active-origin")
|
||||
source = {
|
||||
"object": SimpleNamespace(
|
||||
matrix_world=SimpleNamespace(translation=marker),
|
||||
),
|
||||
"origin_depth": 1e-14,
|
||||
"perspective": True,
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
frame_projection.frame_depth_location(source, ((0.0, 0.0),)),
|
||||
"active-origin",
|
||||
)
|
||||
|
||||
|
||||
def test_frame_geometry_faces_the_view_and_uses_frame_center(self):
|
||||
import numpy as np
|
||||
|
||||
matrix, display_size = frame_projection.view_facing_frame_geometry(
|
||||
(
|
||||
(-2.0, 1.0, 5.0),
|
||||
(2.0, 1.0, 5.0),
|
||||
(2.0, -1.0, 5.0),
|
||||
(-2.0, -1.0, 5.0),
|
||||
),
|
||||
(400, 200),
|
||||
)
|
||||
|
||||
np.testing.assert_allclose(matrix[:3, 0], (1.0, 0.0, 0.0))
|
||||
np.testing.assert_allclose(matrix[:3, 1], (0.0, 1.0, 0.0))
|
||||
np.testing.assert_allclose(matrix[:3, 2], (0.0, 0.0, 1.0))
|
||||
np.testing.assert_allclose(matrix[:3, 3], (0.0, 0.0, 5.0))
|
||||
self.assertEqual(display_size, 4.0)
|
||||
327
tests/anyimage/operators/test_frame_tool.py
Normal file
327
tests/anyimage/operators/test_frame_tool.py
Normal file
@@ -0,0 +1,327 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class FrameToolTest(BlenderTestCase):
|
||||
def test_frame_builds_a_canonical_rectangular_screen_path(self):
|
||||
self.assertEqual(
|
||||
self.image_interaction.rectangle_screen_path((2, 3), (8, 9)),
|
||||
((2.0, 9.0), (8.0, 9.0), (8.0, 3.0), (2.0, 3.0)),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.image_interaction.rectangle_screen_path((2, 3), (3, 9)),
|
||||
(),
|
||||
)
|
||||
|
||||
|
||||
def test_frame_reports_missing_active_overlap_as_warning(self):
|
||||
active = SimpleNamespace(name="Active")
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
region=object(),
|
||||
region_data=SimpleNamespace(view_matrix="view-matrix"),
|
||||
)
|
||||
descriptor = {
|
||||
"active": True,
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(self.image_frame, "_frame_source", return_value=descriptor),
|
||||
patch.object(self.image_frame, "frame_source_overlap", return_value=()),
|
||||
self.assertRaises(self.image_selection.ImageEditWarning),
|
||||
):
|
||||
self.image_frame.apply_frame(
|
||||
context,
|
||||
(active,),
|
||||
((2, 3), (3, 3), (3, 2), (2, 2)),
|
||||
)
|
||||
|
||||
|
||||
def test_frame_uses_selected_still_images_and_ignores_other_objects(self):
|
||||
active = SimpleNamespace(
|
||||
name="Active",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=SimpleNamespace(source="FILE"),
|
||||
)
|
||||
other = SimpleNamespace(
|
||||
name="Other",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=SimpleNamespace(source="FILE"),
|
||||
)
|
||||
mesh = SimpleNamespace(name="Mesh", type="MESH")
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=(mesh, active, other),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.image_frame.selected_frame_sources(context),
|
||||
(other, active),
|
||||
)
|
||||
|
||||
|
||||
def test_frame_rejects_active_image_that_is_not_selected(self):
|
||||
active = SimpleNamespace(
|
||||
name="Active",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=SimpleNamespace(source="FILE"),
|
||||
)
|
||||
other = SimpleNamespace(
|
||||
name="Other",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=SimpleNamespace(source="FILE"),
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=(other,),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
self.image_frame.ImageEditWarning,
|
||||
"Select an active Image Empty",
|
||||
):
|
||||
self.image_frame.selected_frame_sources(context)
|
||||
|
||||
|
||||
def test_frame_warns_when_active_is_not_an_image(self):
|
||||
image = SimpleNamespace(
|
||||
name="Image",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=SimpleNamespace(source="FILE"),
|
||||
)
|
||||
mesh = SimpleNamespace(name="Mesh", type="MESH")
|
||||
context = SimpleNamespace(
|
||||
active_object=mesh,
|
||||
selected_objects=(image, mesh),
|
||||
area=SimpleNamespace(type="VIEW_3D"),
|
||||
)
|
||||
operator = self.image_frame.FrameImages()
|
||||
operator.report = Mock()
|
||||
|
||||
self.assertTrue(operator.poll(context))
|
||||
self.assertEqual(operator.invoke(context, object()), {"CANCELLED"})
|
||||
operator.report.assert_called_once_with(
|
||||
{"WARNING"},
|
||||
"Select an active Image Empty to use an Image tool",
|
||||
)
|
||||
|
||||
|
||||
def test_frame_modal_can_start_outside_source_projection(self):
|
||||
active = SimpleNamespace(name="Active")
|
||||
other = SimpleNamespace(name="Other")
|
||||
redraw = Mock()
|
||||
status = Mock()
|
||||
modal_handler_add = Mock()
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
area=SimpleNamespace(as_pointer=lambda: 1, tag_redraw=redraw),
|
||||
region=SimpleNamespace(as_pointer=lambda: 2),
|
||||
workspace=SimpleNamespace(status_text_set=status),
|
||||
window_manager=SimpleNamespace(modal_handler_add=modal_handler_add),
|
||||
)
|
||||
event = SimpleNamespace(mouse_region_x=-20, mouse_region_y=300)
|
||||
space = SimpleNamespace(
|
||||
draw_handler_add=Mock(return_value="handle"),
|
||||
draw_handler_remove=Mock(),
|
||||
)
|
||||
operator = self.image_frame.FrameImages()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.image_frame,
|
||||
"selected_frame_sources",
|
||||
return_value=(other, active),
|
||||
),
|
||||
patch.object(
|
||||
self.image_frame.bpy.types,
|
||||
"SpaceView3D",
|
||||
space,
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
result = operator.invoke(context, event)
|
||||
operator.cancel(context)
|
||||
|
||||
self.assertEqual(result, {"RUNNING_MODAL"})
|
||||
self.assertEqual(operator._active_name, "Active")
|
||||
self.assertEqual(operator._source_names, ("Other", "Active"))
|
||||
self.assertEqual(operator._start, (-20.0, 300.0))
|
||||
modal_handler_add.assert_called_once_with(operator)
|
||||
space.draw_handler_remove.assert_called_once_with("handle", "WINDOW")
|
||||
|
||||
|
||||
def test_frame_rejects_an_animated_selected_image(self):
|
||||
active = SimpleNamespace(
|
||||
name="Active",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=SimpleNamespace(source="FILE"),
|
||||
)
|
||||
video = SimpleNamespace(
|
||||
name="Video",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=SimpleNamespace(source="MOVIE", frame_duration=2),
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=(active, video),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "still images"):
|
||||
self.image_frame.selected_frame_sources(context)
|
||||
|
||||
|
||||
def test_frame_keeps_active_object_and_removes_other_image_empties(self):
|
||||
import numpy as np
|
||||
|
||||
class MatrixStub:
|
||||
translation = SimpleNamespace(copy=lambda: "active-origin")
|
||||
|
||||
def copy(self):
|
||||
return "old-matrix"
|
||||
|
||||
active_image = Mock(users=1, use_fake_user=False)
|
||||
other_image = Mock(users=1, use_fake_user=False)
|
||||
active = SimpleNamespace(
|
||||
name="Active",
|
||||
data=active_image,
|
||||
matrix_world=MatrixStub(),
|
||||
empty_display_size=1.0,
|
||||
empty_image_offset=(-0.5, -0.5),
|
||||
)
|
||||
other = SimpleNamespace(name="Other", data=other_image)
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
region=SimpleNamespace(width=100, height=100),
|
||||
region_data=SimpleNamespace(view_matrix="view-matrix"),
|
||||
)
|
||||
frame = ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0))
|
||||
descriptors = iter(
|
||||
(
|
||||
{
|
||||
"active": False,
|
||||
"image_size": (2, 2),
|
||||
},
|
||||
{
|
||||
"active": True,
|
||||
"image_size": (2, 2),
|
||||
},
|
||||
)
|
||||
)
|
||||
result_image = Mock(users=1)
|
||||
remove_object = Mock()
|
||||
|
||||
def replace(source, result, *, removed_objects):
|
||||
self.assertEqual(removed_objects, (other,))
|
||||
source.data = result
|
||||
|
||||
with (
|
||||
patch.object(self.image_frame, "_frame_source", side_effect=descriptors),
|
||||
patch.object(
|
||||
self.image_frame,
|
||||
"frame_source_overlap",
|
||||
return_value=frame,
|
||||
),
|
||||
patch.object(self.image_frame, "frame_output_size", return_value=(2, 2)),
|
||||
patch.object(
|
||||
self.image_frame,
|
||||
"composite_frame_pixels",
|
||||
return_value=np.zeros(16, dtype=np.float32),
|
||||
),
|
||||
patch.object(
|
||||
self.image_frame,
|
||||
"frame_result_transform",
|
||||
return_value=("new-matrix", 2.0),
|
||||
),
|
||||
patch.object(
|
||||
self.image_frame,
|
||||
"frame_depth_location",
|
||||
return_value="active-origin",
|
||||
),
|
||||
patch.object(
|
||||
self.image_frame,
|
||||
"create_image_edit_result",
|
||||
return_value=result_image,
|
||||
),
|
||||
patch.object(self.image_frame, "replace_empty_image", side_effect=replace),
|
||||
patch.object(
|
||||
self.image_frame.bpy.data,
|
||||
"objects",
|
||||
SimpleNamespace(remove=remove_object),
|
||||
),
|
||||
patch.object(
|
||||
self.image_frame.bpy.data,
|
||||
"images",
|
||||
SimpleNamespace(remove=Mock()),
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
result = self.image_frame.apply_frame(
|
||||
context,
|
||||
(other, active),
|
||||
frame,
|
||||
)
|
||||
|
||||
self.assertIs(result, active)
|
||||
self.assertIs(active.data, result_image)
|
||||
self.assertEqual(active.matrix_world, "new-matrix")
|
||||
self.assertEqual(active.empty_display_size, 2.0)
|
||||
self.assertEqual(active.empty_image_offset, (-0.5, -0.5))
|
||||
remove_object.assert_called_once_with(other, do_unlink=True)
|
||||
|
||||
|
||||
def test_frame_invalid_result_depth_does_not_create_or_replace_image(self):
|
||||
import numpy as np
|
||||
|
||||
active_image = Mock(users=1, use_fake_user=False)
|
||||
active = SimpleNamespace(
|
||||
name="Active",
|
||||
data=active_image,
|
||||
matrix_world=SimpleNamespace(
|
||||
translation=SimpleNamespace(copy=lambda: "active-origin")
|
||||
),
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
region=SimpleNamespace(width=100, height=100),
|
||||
region_data=SimpleNamespace(view_matrix="view-matrix"),
|
||||
)
|
||||
frame = ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0))
|
||||
descriptor = {"active": True, "image_size": (2, 2)}
|
||||
create_result = Mock()
|
||||
replace_result = Mock()
|
||||
|
||||
with (
|
||||
patch.object(self.image_frame, "_frame_source", return_value=descriptor),
|
||||
patch.object(self.image_frame, "frame_source_overlap", return_value=frame),
|
||||
patch.object(self.image_frame, "frame_output_size", return_value=(2, 2)),
|
||||
patch.object(
|
||||
self.image_frame,
|
||||
"composite_frame_pixels",
|
||||
return_value=np.zeros(16, dtype=np.float32),
|
||||
),
|
||||
patch.object(
|
||||
self.image_frame,
|
||||
"frame_depth_location",
|
||||
side_effect=ValueError("The Frame result depth is invalid"),
|
||||
),
|
||||
patch.object(
|
||||
self.image_frame,
|
||||
"create_image_edit_result",
|
||||
create_result,
|
||||
),
|
||||
patch.object(self.image_frame, "replace_empty_image", replace_result),
|
||||
self.assertRaisesRegex(ValueError, "result depth"),
|
||||
):
|
||||
self.image_frame.apply_frame(context, (active,), frame)
|
||||
|
||||
create_result.assert_not_called()
|
||||
replace_result.assert_not_called()
|
||||
self.assertIs(active.data, active_image)
|
||||
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())
|
||||
context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=SimpleNamespace()))
|
||||
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 "start_frame" not in parameters and "video_frames" not in parameters
|
||||
finally:
|
||||
cleanup_image_input(snapshot.path, True)
|
||||
247
tests/anyimage/operators/test_image_edit_integration.py
Normal file
247
tests/anyimage/operators/test_image_edit_integration.py
Normal file
@@ -0,0 +1,247 @@
|
||||
from types import SimpleNamespace
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Matrix
|
||||
|
||||
|
||||
from anyimage.common import image as image_data
|
||||
from anyimage.common.selection import SelectionMask
|
||||
from anyimage.operators import mask_tool as edit_mask
|
||||
from anyimage.operators.frame_tool import operators as edit_frame
|
||||
from anyimage.operators.frame_tool.compositing import composite_frame_pixels
|
||||
|
||||
|
||||
def test_image_edit_result_uses_complete_shared_source_name():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source_image = bpy.data.images.new("Poster.png", width=2, height=2, alpha=True)
|
||||
source = bpy.data.objects.new("Poster", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = source_image
|
||||
shared = bpy.data.objects.new("Poster Shared", None)
|
||||
shared.empty_display_type = "IMAGE"
|
||||
shared.data = source_image
|
||||
bpy.context.collection.objects.link(source)
|
||||
bpy.context.collection.objects.link(shared)
|
||||
|
||||
result_image = image_data.create_image_edit_result(
|
||||
source_image,
|
||||
np.zeros(4 * 4 * 4, dtype=np.float32),
|
||||
(4, 4),
|
||||
)
|
||||
result_image = image_data.replace_empty_image(source, result_image)
|
||||
|
||||
assert shared.data == source_image
|
||||
assert source_image.alpha_mode == "STRAIGHT"
|
||||
assert result_image.alpha_mode == "STRAIGHT"
|
||||
assert source.use_empty_image_alpha
|
||||
assert source.data == result_image
|
||||
assert result_image.name == "Poster.png.001"
|
||||
assert "_color" not in result_image.name
|
||||
|
||||
|
||||
def test_image_edit_result_recovers_unshared_source_name():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source_image = bpy.data.images.new("Poster.png", width=2, height=2, alpha=True)
|
||||
source = bpy.data.objects.new("Poster", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = source_image
|
||||
bpy.context.collection.objects.link(source)
|
||||
|
||||
result_image = image_data.create_image_edit_result(
|
||||
source_image,
|
||||
np.zeros(4 * 4 * 4, dtype=np.float32),
|
||||
(4, 4),
|
||||
)
|
||||
result_image = image_data.replace_empty_image(source, result_image)
|
||||
|
||||
assert source.data == result_image
|
||||
assert result_image.name == "Poster.png"
|
||||
assert bpy.data.images.get("Poster.png") == result_image
|
||||
|
||||
|
||||
def test_mask_frame_mask_extend_preserves_packed_hidden_rgb():
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source_image = bpy.data.images.new("Frame RGB", width=4, height=4, alpha=True)
|
||||
rgba = np.ones((4, 4, 4), dtype=np.float32)
|
||||
rgba[..., :3] = np.arange(48).reshape((4, 4, 3)) / 47.0
|
||||
source_image.pixels.foreach_set(np.flipud(rgba).ravel())
|
||||
source_image.update()
|
||||
expected = image_data.image_rgba(source_image).copy()
|
||||
source = bpy.data.objects.new("Frame RGB", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.empty_display_size = 2.0
|
||||
source.empty_image_offset = (-0.5, -0.5)
|
||||
source.data = source_image
|
||||
bpy.context.collection.objects.link(source)
|
||||
selection = SelectionMask(np.ones((4, 4), dtype=np.float32), (0, 0, 4, 4))
|
||||
operator = SimpleNamespace(mode="SUBTRACT", report=lambda *_args: None)
|
||||
assert edit_mask.EditImageAlpha._apply_mask(
|
||||
operator, SimpleNamespace(), source, selection
|
||||
) == {"FINISHED"}
|
||||
descriptor = edit_frame._frame_source(
|
||||
source, SimpleNamespace(width=4, height=4),
|
||||
SimpleNamespace(perspective_matrix=Matrix.Identity(4), is_perspective=False),
|
||||
Matrix.Identity(4), active=True,
|
||||
)
|
||||
np.testing.assert_allclose(descriptor["rgba"][..., :3], expected[..., :3])
|
||||
np.testing.assert_array_equal(descriptor["pixels"], 0)
|
||||
framed = composite_frame_pixels(
|
||||
(descriptor,), ((0, 4), (4, 4), (4, 0), (0, 0)), (4, 4)
|
||||
)
|
||||
result = image_data.create_image_edit_result(source.data, framed, (4, 4))
|
||||
result = image_data.replace_empty_image(source, result)
|
||||
assert result.alpha_mode == "STRAIGHT"
|
||||
assert source.use_empty_image_alpha
|
||||
assert result.packed_file is not None
|
||||
packed = np.asarray(Image.open(BytesIO(result.packed_file.data)).convert("RGBA")) / 255.0
|
||||
np.testing.assert_allclose(packed[..., :3], expected[..., :3], atol=1 / 255)
|
||||
np.testing.assert_array_equal(packed[..., 3], 0)
|
||||
result.reload()
|
||||
assert result.alpha_mode == "STRAIGHT"
|
||||
operator.mode = "EXTEND"
|
||||
assert edit_mask.EditImageAlpha._apply_mask(
|
||||
operator, SimpleNamespace(), source, selection
|
||||
) == {"FINISHED"}
|
||||
restored = image_data.image_rgba(source.data)
|
||||
np.testing.assert_allclose(restored[..., :3], expected[..., :3], atol=1 / 255)
|
||||
np.testing.assert_array_equal(restored[..., 3], 1)
|
||||
|
||||
|
||||
def test_frame_restored_alpha_keeps_foreground_blended_into_white():
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
sources = []
|
||||
for name, color, depth in (("Background", (1, 1, 1, 0), 2),
|
||||
("Foreground", (0.2, 0.4, 0.6, 1), 1)):
|
||||
image = bpy.data.images.new(name, width=6, height=2, alpha=True)
|
||||
rgba = np.tile(color, (2, 6, 1)).astype(np.float32)
|
||||
if name == "Foreground":
|
||||
rgba[..., 3] = (0, 0.01, 0.1, 0.25, 0.5, 1)
|
||||
rgba[:, 0, :3] = 0
|
||||
image.pixels.foreach_set(np.flipud(rgba).ravel())
|
||||
image.update()
|
||||
source = bpy.data.objects.new(name, None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.empty_display_size = 2
|
||||
source.empty_image_offset = (-0.5, -0.5)
|
||||
source.data = image
|
||||
source.matrix_world = Matrix.Translation((0, 0, -depth))
|
||||
bpy.context.collection.objects.link(source)
|
||||
sources.append(edit_frame._frame_source(
|
||||
source, SimpleNamespace(width=6, height=2),
|
||||
SimpleNamespace(perspective_matrix=Matrix.Identity(4), is_perspective=False),
|
||||
Matrix.Identity(4), active=name == "Foreground",
|
||||
))
|
||||
front = sources[1]
|
||||
source_alpha = front["rgba"][..., 3:4]
|
||||
expected = front["rgba"][..., :3] * source_alpha + 1 - source_alpha
|
||||
# The image aspect makes its orthographic screen projection two thirds of a pixel high.
|
||||
pixels = composite_frame_pixels(
|
||||
sources, ((0, 4 / 3), (6, 4 / 3), (6, 2 / 3), (0, 2 / 3)), (6, 2)
|
||||
)
|
||||
result = image_data.create_image_edit_result(front["object"].data, pixels, (6, 2))
|
||||
result = image_data.replace_empty_image(front["object"], result)
|
||||
encoded = np.asarray(Image.open(BytesIO(result.packed_file.data)).convert("RGBA")) / 255
|
||||
np.testing.assert_allclose(encoded[..., :3], expected, atol=1 / 255)
|
||||
np.testing.assert_allclose(encoded[..., 3:4], source_alpha, atol=1 / 255)
|
||||
selection = SelectionMask(np.ones((2, 6), dtype=np.float32), (0, 0, 6, 2))
|
||||
operator = SimpleNamespace(mode="EXTEND", report=lambda *_args: None)
|
||||
assert edit_mask.EditImageAlpha._apply_mask(
|
||||
operator, SimpleNamespace(), front["object"], selection
|
||||
) == {"FINISHED"}
|
||||
restored = image_data.image_rgba(front["object"].data)
|
||||
np.testing.assert_allclose(restored[..., :3], expected, atol=1 / 255)
|
||||
np.testing.assert_array_equal(restored[..., 3], 1)
|
||||
assert np.all(np.diff(restored[0, :, 0]) <= 0)
|
||||
np.testing.assert_array_equal(restored[0, 0, :3], 1)
|
||||
|
||||
|
||||
def test_brush_release_commits_one_packed_image_without_touching_shared_source():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source_image = bpy.data.images.new("Mask Source", width=8, height=6, alpha=True)
|
||||
source_rgba = np.ones((6, 8, 4), dtype=np.float32)
|
||||
source_rgba[:, :, :3] = (0.2, 0.4, 0.6)
|
||||
source_image.pixels.foreach_set(np.flipud(source_rgba).ravel())
|
||||
source_image.update()
|
||||
source = bpy.data.objects.new("Mask Source", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = source_image
|
||||
source.empty_display_size = 3.0
|
||||
source.empty_image_offset = (-0.25, -0.75)
|
||||
source.matrix_world = Matrix.Translation((1.0, 2.0, 3.0))
|
||||
shared = bpy.data.objects.new("Mask Shared", None)
|
||||
shared.empty_display_type = "IMAGE"
|
||||
shared.data = source_image
|
||||
bpy.context.collection.objects.link(source)
|
||||
bpy.context.collection.objects.link(shared)
|
||||
matrix = source.matrix_world.copy()
|
||||
|
||||
selection_mask = SelectionMask(
|
||||
np.ones((2, 3), dtype=np.float32),
|
||||
(2, 1, 5, 3),
|
||||
)
|
||||
operator = SimpleNamespace(mode="SUBTRACT", report=lambda *_args: None)
|
||||
status = edit_mask.EditImageAlpha._apply_mask(
|
||||
operator,
|
||||
SimpleNamespace(),
|
||||
source,
|
||||
selection_mask,
|
||||
)
|
||||
|
||||
result = source.data
|
||||
assert status == {"FINISHED"}
|
||||
assert source.data == result
|
||||
assert result != source_image
|
||||
assert result.packed_file is not None
|
||||
assert shared.data == source_image
|
||||
np.testing.assert_allclose(image_data.image_rgba(source_image), source_rgba)
|
||||
result_rgba = image_data.image_rgba(result)
|
||||
np.testing.assert_array_equal(result_rgba[1:3, 2:5, 3], 0.0)
|
||||
assert source.matrix_world == matrix
|
||||
assert source.empty_display_size == 3.0
|
||||
assert tuple(source.empty_image_offset) == (-0.25, -0.75)
|
||||
|
||||
|
||||
def test_brush_drag_does_not_create_or_update_an_image():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source_image = bpy.data.images.new("Mask Cancel", width=4, height=3, alpha=True)
|
||||
source = bpy.data.objects.new("Mask Cancel", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = source_image
|
||||
bpy.context.collection.objects.link(source)
|
||||
image_count = len(bpy.data.images)
|
||||
shared = bpy.data.objects.new("Mask Cancel shared", None)
|
||||
shared.empty_display_type = "IMAGE"
|
||||
shared.data = source_image
|
||||
before = np.array(source_image.pixels[:])
|
||||
operator = edit_mask.ImageGesture()
|
||||
operator.active_tool_id = "anyimage.mask"
|
||||
operator.gesture = "BRUSH"
|
||||
operator._path = [(10.0, 10.0)]
|
||||
operator._path_samples = list(operator._path)
|
||||
operator._cursor = (10.0, 10.0)
|
||||
context = SimpleNamespace(area=SimpleNamespace(tag_redraw=lambda: None),
|
||||
workspace=SimpleNamespace(status_text_set=lambda value: None))
|
||||
event = SimpleNamespace(
|
||||
type="MOUSEMOVE",
|
||||
value="NOTHING",
|
||||
mouse_region_x=20,
|
||||
mouse_region_y=25,
|
||||
)
|
||||
|
||||
result = edit_mask.ImageGesture.modal(operator, context, event)
|
||||
|
||||
assert result == {"RUNNING_MODAL"}
|
||||
assert source.data == source_image
|
||||
assert len(bpy.data.images) == image_count
|
||||
event.type, event.value = "ESC", "PRESS"
|
||||
assert operator.modal(context, event) == {"CANCELLED"}
|
||||
assert source.data == shared.data == source_image
|
||||
np.testing.assert_array_equal(source_image.pixels[:], before)
|
||||
assert len(bpy.data.images) == image_count
|
||||
assert operator._path is operator._path_samples is operator._brush_preview is None
|
||||
398
tests/anyimage/operators/test_mask_tool.py
Normal file
398
tests/anyimage/operators/test_mask_tool.py
Normal file
@@ -0,0 +1,398 @@
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class MaskToolTest(BlenderTestCase):
|
||||
def test_selection_mask_keeps_nonzero_winding_overlap(self):
|
||||
path = (
|
||||
(1, 1),
|
||||
(7, 1),
|
||||
(7, 7),
|
||||
(1, 7),
|
||||
(1, 1),
|
||||
(4, 1),
|
||||
(10, 1),
|
||||
(10, 7),
|
||||
(4, 7),
|
||||
(4, 1),
|
||||
(1, 1),
|
||||
)
|
||||
|
||||
selection = self.image_selection.rasterize_selection_path(
|
||||
(12, 9),
|
||||
self.image_selection.SelectionPath(points=path),
|
||||
).full_values((12, 9))
|
||||
|
||||
self.assertTrue(selection[3, 5])
|
||||
|
||||
triangles = self.image_interaction.preview_fill_geometry(path)[0]
|
||||
preview_area = sum(
|
||||
abs(
|
||||
self.image_interaction._triangle_cross(
|
||||
triangles[index],
|
||||
triangles[index + 1],
|
||||
triangles[index + 2],
|
||||
)
|
||||
)
|
||||
* 0.5
|
||||
for index in range(0, len(triangles), 3)
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(preview_area, 54.0)
|
||||
|
||||
|
||||
def test_mask_freezes_scene_settings_on_invoke(self):
|
||||
settings = SimpleNamespace(
|
||||
mask_gesture="BRUSH",
|
||||
mask_mode="EXTEND",
|
||||
mask_radius=64,
|
||||
)
|
||||
context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings))
|
||||
operator = SimpleNamespace(gesture="LASSO", mode="SET", radius=25)
|
||||
|
||||
self.edit_mask.EditImageAlpha._read_gesture_settings(operator, context)
|
||||
|
||||
self.assertEqual(operator.gesture, "BRUSH")
|
||||
self.assertEqual(operator.mode, "EXTEND")
|
||||
self.assertEqual(operator.radius, 64)
|
||||
|
||||
|
||||
def test_mask_and_rectify_start_after_an_empty_pick(self):
|
||||
source = SimpleNamespace(
|
||||
name="Source",
|
||||
data=SimpleNamespace(size=(8, 6)),
|
||||
matrix_world="matrix",
|
||||
)
|
||||
area = SimpleNamespace(as_pointer=lambda: 1, tag_redraw=Mock())
|
||||
region = SimpleNamespace(as_pointer=lambda: 2, width=100, height=80)
|
||||
window_manager = SimpleNamespace(
|
||||
modal_handler_add=Mock(),
|
||||
event_timer_add=Mock(return_value="timer"),
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
area=area,
|
||||
region=region,
|
||||
window=object(),
|
||||
window_manager=window_manager,
|
||||
workspace=SimpleNamespace(status_text_set=Mock()),
|
||||
scene=SimpleNamespace(
|
||||
anyimage_settings=SimpleNamespace(
|
||||
mask_gesture="LASSO",
|
||||
mask_mode="SUBTRACT",
|
||||
mask_radius=25,
|
||||
)
|
||||
),
|
||||
)
|
||||
event = SimpleNamespace(
|
||||
mouse_region_x=12,
|
||||
mouse_region_y=18,
|
||||
value="PRESS",
|
||||
)
|
||||
previous_space = getattr(self.fake_bpy.types, "SpaceView3D", None)
|
||||
self.fake_bpy.types.SpaceView3D = SimpleNamespace(
|
||||
draw_handler_add=Mock(return_value="handler")
|
||||
)
|
||||
try:
|
||||
with (
|
||||
patch.object(
|
||||
self.image_interaction,
|
||||
"resolve_image_edit_click",
|
||||
return_value=(source, True),
|
||||
),
|
||||
patch.object(
|
||||
self.image_interaction,
|
||||
"is_animated_image",
|
||||
return_value=False,
|
||||
),
|
||||
patch.object(
|
||||
self.image_interaction,
|
||||
"serialize_matrix",
|
||||
return_value="serialized",
|
||||
),
|
||||
):
|
||||
mask = self.edit_mask.EditImageAlpha()
|
||||
mask.report = Mock()
|
||||
self.assertEqual(mask.invoke(context, event), {"RUNNING_MODAL"})
|
||||
self.assertEqual(mask._path, [(12.0, 18.0)])
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.rectify,
|
||||
"resolve_image_edit_click",
|
||||
return_value=(source, True),
|
||||
),
|
||||
patch.object(
|
||||
self.rectify,
|
||||
"is_animated_image",
|
||||
return_value=False,
|
||||
),
|
||||
patch.object(
|
||||
self.rectify,
|
||||
"serialize_matrix",
|
||||
return_value="serialized",
|
||||
),
|
||||
):
|
||||
rectify = self.rectify.RectifyImagePerspective()
|
||||
rectify.report = Mock()
|
||||
self.assertEqual(rectify.invoke(context, event), {"RUNNING_MODAL"})
|
||||
self.assertEqual(rectify._points, [(12.0, 18.0)])
|
||||
finally:
|
||||
if previous_space is None:
|
||||
del self.fake_bpy.types.SpaceView3D
|
||||
else:
|
||||
self.fake_bpy.types.SpaceView3D = previous_space
|
||||
|
||||
|
||||
def test_mask_and_rectify_cancel_without_an_active_image(self):
|
||||
context = SimpleNamespace(
|
||||
scene=SimpleNamespace(
|
||||
anyimage_settings=SimpleNamespace(
|
||||
mask_gesture="LASSO",
|
||||
mask_mode="SUBTRACT",
|
||||
mask_radius=25,
|
||||
)
|
||||
)
|
||||
)
|
||||
event = SimpleNamespace()
|
||||
|
||||
with patch.object(
|
||||
self.image_interaction,
|
||||
"resolve_image_edit_click",
|
||||
return_value=(None, False),
|
||||
):
|
||||
mask = self.edit_mask.EditImageAlpha()
|
||||
mask.report = Mock()
|
||||
self.assertEqual(mask.invoke(context, event), {"CANCELLED"})
|
||||
with patch.object(
|
||||
self.rectify,
|
||||
"resolve_image_edit_click",
|
||||
return_value=(None, False),
|
||||
):
|
||||
rectify = self.rectify.RectifyImagePerspective()
|
||||
rectify.report = Mock()
|
||||
self.assertEqual(rectify.invoke(context, event), {"CANCELLED"})
|
||||
|
||||
|
||||
def test_image_mask_fill_triangulates_a_concave_polygon_without_overlap(self):
|
||||
polygon = (
|
||||
(0, 0),
|
||||
(4, 0),
|
||||
(4, 4),
|
||||
(3, 4),
|
||||
(3, 1),
|
||||
(1, 1),
|
||||
(1, 4),
|
||||
(0, 4),
|
||||
)
|
||||
triangles = self.image_interaction.triangulated_polygon_vertices(polygon)
|
||||
area = sum(
|
||||
abs(
|
||||
self.image_interaction._triangle_cross(
|
||||
triangles[index],
|
||||
triangles[index + 1],
|
||||
triangles[index + 2],
|
||||
)
|
||||
)
|
||||
* 0.5
|
||||
for index in range(0, len(triangles), 3)
|
||||
)
|
||||
|
||||
self.assertEqual(len(triangles), 18)
|
||||
self.assertAlmostEqual(area, 10.0)
|
||||
|
||||
|
||||
def test_image_mask_fill_uses_blender_geometry_without_stopping_the_draw(self):
|
||||
mathutils = ModuleType("mathutils")
|
||||
mathutils.Vector = lambda values: values
|
||||
mathutils.geometry = SimpleNamespace(
|
||||
tessellate_polygon=lambda _loops: (
|
||||
((0.0, 0.0, 0.0), (2.0, 0.0, 0.0), (0.0, 2.0, 0.0)),
|
||||
)
|
||||
)
|
||||
with patch.dict(sys.modules, {"mathutils": mathutils}):
|
||||
triangles = self.image_interaction.triangulated_polygon_vertices(
|
||||
((0, 0), (2, 0), (0, 2))
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
triangles,
|
||||
((0.0, 0.0), (2.0, 0.0), (0.0, 2.0)),
|
||||
)
|
||||
|
||||
|
||||
def test_image_mask_fill_falls_back_when_blender_tessellation_rejects_path(self):
|
||||
mathutils = ModuleType("mathutils")
|
||||
mathutils.Vector = lambda values: values
|
||||
mathutils.geometry = SimpleNamespace(
|
||||
tessellate_polygon=lambda _loops: (_ for _ in ()).throw(TypeError())
|
||||
)
|
||||
with patch.dict(sys.modules, {"mathutils": mathutils}):
|
||||
triangles = self.image_interaction.triangulated_polygon_vertices(
|
||||
((0, 0), (2, 0), (0, 2))
|
||||
)
|
||||
|
||||
self.assertEqual(len(triangles), 3)
|
||||
|
||||
|
||||
def test_image_mask_preview_fills_a_self_intersecting_lasso(self):
|
||||
polygon = ((0, 0), (4, 4), (0, 4), (4, 0))
|
||||
triangles = self.image_interaction.preview_fill_geometry(polygon)[0]
|
||||
area = sum(
|
||||
abs(
|
||||
self.image_interaction._triangle_cross(
|
||||
triangles[index],
|
||||
triangles[index + 1],
|
||||
triangles[index + 2],
|
||||
)
|
||||
)
|
||||
* 0.5
|
||||
for index in range(0, len(triangles), 3)
|
||||
)
|
||||
|
||||
self.assertTrue(triangles)
|
||||
self.assertAlmostEqual(area, 8.0)
|
||||
|
||||
|
||||
def test_image_mask_preview_detects_only_actual_self_intersections(self):
|
||||
self.assertTrue(
|
||||
self.image_interaction.polygon_self_intersects(
|
||||
((0, 0), (4, 4), (0, 4), (4, 0))
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
self.image_interaction.polygon_self_intersects(
|
||||
((0, 0), (4, 0), (2, 2), (4, 4), (0, 4))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_mask_alpha_modes_preserve_rgb_and_canvas(self):
|
||||
import numpy as np
|
||||
|
||||
source = np.zeros((2, 3, 4), dtype=np.float32)
|
||||
source[:, :, :3] = np.asarray((0.2, 0.4, 0.6), dtype=np.float32)
|
||||
source[:, :, 3] = np.asarray(((0.2, 0.5, 0.8), (1.0, 0.4, 0.0)))
|
||||
mask = self.image_selection.SelectionMask(
|
||||
np.asarray(((0.25, 1.0), (0.5, 0.75)), dtype=np.float32),
|
||||
(1, 0, 3, 2),
|
||||
)
|
||||
full_mask = mask.full_values((3, 2))
|
||||
expected_alpha = {
|
||||
"SET": source[:, :, 3] * full_mask,
|
||||
"EXTEND": np.maximum(source[:, :, 3], full_mask),
|
||||
"SUBTRACT": source[:, :, 3] * (1.0 - full_mask),
|
||||
}
|
||||
|
||||
for mode, expected in expected_alpha.items():
|
||||
with self.subTest(mode=mode):
|
||||
pixels = self.edit_mask.apply_alpha_mask(source, mask, mode)
|
||||
result = np.flipud(pixels.reshape(source.shape))
|
||||
np.testing.assert_allclose(result[:, :, :3], source[:, :, :3])
|
||||
np.testing.assert_allclose(result[:, :, 3], expected)
|
||||
|
||||
|
||||
def test_mask_brush_stroke_is_circular_and_continuous(self):
|
||||
import numpy as np
|
||||
|
||||
single = self.edit_mask.rasterize_brush_path(
|
||||
(120, 50),
|
||||
((20, 25),),
|
||||
10,
|
||||
lambda path: path,
|
||||
)
|
||||
stroke = self.edit_mask.rasterize_brush_path(
|
||||
(120, 50),
|
||||
((20, 25), (100, 25)),
|
||||
10,
|
||||
lambda path: path,
|
||||
)
|
||||
single_values = single.full_values((120, 50))
|
||||
stroke_values = stroke.full_values((120, 50))
|
||||
|
||||
self.assertGreater(single_values[25, 20], 0.9)
|
||||
self.assertEqual(single_values[0, 0], 0.0)
|
||||
self.assertTrue(np.all(stroke_values[25, 20:101] > 0.9))
|
||||
|
||||
|
||||
def test_mask_brush_clips_bounds_and_projects_screen_radius(self):
|
||||
clipped = self.edit_mask.rasterize_brush_path(
|
||||
(80, 40),
|
||||
((0, 0),),
|
||||
10,
|
||||
lambda path: path,
|
||||
)
|
||||
projected = self.edit_mask.rasterize_brush_path(
|
||||
(160, 80),
|
||||
((30, 30),),
|
||||
10,
|
||||
lambda path: tuple((x * 2.0, y * 0.5) for x, y in path),
|
||||
)
|
||||
|
||||
self.assertEqual(clipped.bounds[:2], (0, 0))
|
||||
projected_width = projected.bounds[2] - projected.bounds[0]
|
||||
projected_height = projected.bounds[3] - projected.bounds[1]
|
||||
self.assertGreater(projected_width, projected_height * 3)
|
||||
|
||||
|
||||
def test_mask_brush_round_turn_stays_inside_the_radius(self):
|
||||
polygons = self.image_interaction.brush_footprint_polygons(
|
||||
((10.0, 10.0), (30.0, 10.0), (15.0, 11.0)),
|
||||
5.0,
|
||||
)
|
||||
bands = self.image_interaction.scanline_union_bands(polygons)
|
||||
center_band = next(
|
||||
band for band in bands if band[0] <= 10.0 < band[1]
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
all(len(intervals) == 1 for _bottom, _top, intervals in bands if intervals)
|
||||
)
|
||||
self.assertEqual(len(center_band[2]), 1)
|
||||
sample_y = (center_band[0] + center_band[1]) / 2
|
||||
half_width = (25 - (sample_y - 10) ** 2) ** 0.5
|
||||
self.assertAlmostEqual(center_band[2][0][0], 10 - half_width, delta=0.03)
|
||||
self.assertAlmostEqual(center_band[2][0][1], 30 + half_width, delta=0.03)
|
||||
|
||||
|
||||
def test_mask_brush_self_intersection_uses_non_zero_winding(self):
|
||||
selection_mask = self.edit_mask.rasterize_brush_path(
|
||||
(80, 80),
|
||||
((15, 15), (65, 65), (15, 65), (65, 15)),
|
||||
6,
|
||||
lambda path: path,
|
||||
)
|
||||
|
||||
values = selection_mask.full_values((80, 80))
|
||||
self.assertGreater(values[40, 40], 0.9)
|
||||
|
||||
|
||||
def test_mask_brush_path_builds_one_coverage_after_release(self):
|
||||
import numpy as np
|
||||
|
||||
selection_mask = self.edit_mask.rasterize_brush_path(
|
||||
(80, 40),
|
||||
((10, 20), (70, 20)),
|
||||
6,
|
||||
lambda path: path,
|
||||
)
|
||||
|
||||
full = selection_mask.full_values((80, 40))
|
||||
self.assertTrue(np.all(full[20, 10:71] > 0.9))
|
||||
self.assertLess(selection_mask.bounds[0], 10)
|
||||
self.assertGreater(selection_mask.bounds[2], 70)
|
||||
|
||||
|
||||
def test_image_lasso_does_not_add_stationary_or_nearly_collinear_points(self):
|
||||
path = [(0.0, 0.0)]
|
||||
samples = list(path)
|
||||
|
||||
self.assertFalse(self.image_interaction.append_lasso_point(path, (0.0, 0.0), samples))
|
||||
self.assertEqual(path, [(0.0, 0.0)])
|
||||
self.assertTrue(self.image_interaction.append_lasso_point(path, (3.0, 0.0), samples))
|
||||
self.assertTrue(self.image_interaction.append_lasso_point(path, (6.0, 0.2), samples))
|
||||
self.assertEqual(len(path), 2)
|
||||
self.assertEqual(path[-1], (6.0, 0.2))
|
||||
self.assertTrue(self.image_interaction.append_lasso_point(path, (6.0, 4.0), samples))
|
||||
self.assertEqual(len(path), 3)
|
||||
67
tests/anyimage/operators/test_mask_undo.py
Normal file
67
tests/anyimage/operators/test_mask_undo.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from unittest.mock import patch
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
from anyimage.common import image, viewport
|
||||
from anyimage.operators import mask_tool as mask
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gesture", ["LASSO", "BRUSH", "POLYLINE"])
|
||||
@pytest.mark.parametrize("alpha_mode", ["STRAIGHT", "PREMUL"])
|
||||
def test_mask_commit_is_one_undo_transaction(gesture, alpha_mode):
|
||||
source = bpy.data.images.new("Undo source", width=8, height=8, alpha=True)
|
||||
source.alpha_mode = alpha_mode
|
||||
source.pixels.foreach_set(np.ones(8 * 8 * 4, dtype=np.float32))
|
||||
source.pack()
|
||||
owner = bpy.data.objects.new("Mask undo owner", None)
|
||||
owner.empty_display_type = "IMAGE"
|
||||
owner.data = source
|
||||
bpy.context.scene.collection.objects.link(owner)
|
||||
shared = bpy.data.objects.new("Mask undo shared", None)
|
||||
shared.empty_display_type = "IMAGE"
|
||||
shared.data = source
|
||||
bpy.context.scene.collection.objects.link(shared)
|
||||
owner_name, shared_name, source_name = owner.name, shared.name, source.name
|
||||
before = image.image_pixels(source)
|
||||
options = mask.EditImageAlpha.bl_options
|
||||
assert options == {"UNDO"}
|
||||
|
||||
def execute(operator, context):
|
||||
"""Drive gesture completion without a window event in background bpy."""
|
||||
operator.gesture, operator.mode, operator.radius = gesture, "SUBTRACT", 2
|
||||
operator.source_object_name = owner_name
|
||||
operator.source_matrix_data = viewport.serialize_matrix(owner.matrix_world)
|
||||
operator._path = [(2, 2), (6, 2), (6, 6), (2, 6)]
|
||||
operator._path_samples = list(operator._path)
|
||||
return operator._complete(context)
|
||||
|
||||
bpy.context.preferences.edit.use_global_undo = True
|
||||
with patch.object(mask.EditImageAlpha, "execute", execute, create=True), patch.object(
|
||||
mask.EditImageAlpha, "poll", classmethod(lambda cls, context: True)
|
||||
), patch.object(viewport, "screen_path_to_image_pixels", side_effect=lambda path, *args, **kwargs: path), patch.object(
|
||||
mask, "screen_path_to_image_pixels", side_effect=lambda path, *args, **kwargs: path
|
||||
), patch.object(mask, "create_image_edit_result", wraps=mask.create_image_edit_result) as create:
|
||||
bpy.utils.register_class(mask.EditImageAlpha)
|
||||
try:
|
||||
bpy.ops.ed.undo_push(message="Before Mask")
|
||||
assert bpy.ops.anyimage.edit_image_alpha("EXEC_DEFAULT", True) == {"FINISHED"}
|
||||
create.assert_called_once()
|
||||
result = bpy.data.objects[owner_name].data
|
||||
assert result.packed_file is not None and result.alpha_mode == alpha_mode
|
||||
assert bpy.data.objects[shared_name].data.name == source_name
|
||||
np.testing.assert_array_equal(image.image_pixels(bpy.data.images[source_name]), before)
|
||||
assert bpy.ops.ed.undo() == {"FINISHED"}
|
||||
assert bpy.data.objects[owner_name].data.name == source_name
|
||||
assert bpy.data.objects[owner_name].data.alpha_mode == alpha_mode
|
||||
np.testing.assert_array_equal(image.image_pixels(bpy.data.objects[owner_name].data), before)
|
||||
finally:
|
||||
bpy.utils.unregister_class(mask.EditImageAlpha)
|
||||
for name in (owner_name, shared_name):
|
||||
obj = bpy.data.objects.get(name)
|
||||
if obj is not None:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
source = bpy.data.images.get(source_name)
|
||||
if source is not None:
|
||||
bpy.data.images.remove(source)
|
||||
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)
|
||||
224
tests/anyimage/operators/test_panorama_conversion.py
Normal file
224
tests/anyimage/operators/test_panorama_conversion.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""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_geometry_model", lambda _: "MOGE2_VITB_NORMAL")
|
||||
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_NORMAL" and submitted["resolution_level"] == 2
|
||||
assert submitted["max_input_size"] == 1024 and submitted["mesh_detail"] == 4
|
||||
assert submitted["color_path"] == submitted["input_path"]
|
||||
operator.report.assert_not_called()
|
||||
from pathlib import Path
|
||||
assert not Path(submitted["input_path"]).exists()
|
||||
assert bpy.data.objects.get(source.name) == source
|
||||
|
||||
|
||||
def test_entry_crops_arbitrary_aspect_before_submitting_and_warns(source, monkeypatch):
|
||||
source.data.scale(31, 20)
|
||||
source.data.pixels.foreach_set(np.ones((20, 31, 4), np.float32).ravel())
|
||||
submitted = {}
|
||||
|
||||
def submit(**parameters):
|
||||
submitted.update(parameters)
|
||||
with Image.open(parameters["input_path"]) as image:
|
||||
assert image.size == (30, 15)
|
||||
with Image.open(parameters["color_path"]) as image:
|
||||
assert image.size == (30, 15)
|
||||
return {"CANCELLED"}
|
||||
|
||||
monkeypatch.setattr(operators, "invoke_ai_setup_if_needed", lambda _operator: None)
|
||||
monkeypatch.setattr(operators, "addon_preferences", lambda _: SimpleNamespace(geometry_resolution_level=5))
|
||||
monkeypatch.setattr(operators, "configured_geometry_model", lambda _: "MOGE2_VITB_NORMAL")
|
||||
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"}
|
||||
operator.report.assert_called_once_with(
|
||||
{"WARNING"}, "Panorama input cropped from 31 x 20 to 30 x 15")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("image_source,size", [("MOVIE", (32, 16)), ("SEQUENCE", (32, 16)), ("FILE", (1, 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]}))
|
||||
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_emission_material(source, artifacts, monkeypatch):
|
||||
result, operator = artifacts
|
||||
from anyimage.common.node import load_node_group
|
||||
saved = load_node_group("O Image Depth Panorama")
|
||||
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.modifiers[0].node_group == saved
|
||||
assert saved.name == "O Image Depth Panorama"
|
||||
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]
|
||||
nodes = material.node_tree.nodes
|
||||
assert not any(n.bl_idname == "ShaderNodeUVMap" or n.type == "GROUP" for n in nodes)
|
||||
texture = next(n for n in nodes if n.type == "TEX_IMAGE")
|
||||
emission = next(n for n in nodes if n.type == "EMISSION")
|
||||
output = next(n for n in nodes if n.type == "OUTPUT_MATERIAL")
|
||||
assert texture.extension == "REPEAT"
|
||||
assert emission.inputs["Color"].links[0].from_socket == texture.outputs["Color"]
|
||||
assert output.inputs["Surface"].links[0].from_socket == emission.outputs["Emission"]
|
||||
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]}))
|
||||
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)
|
||||
70
tests/anyimage/operators/test_plane_conversion.py
Normal file
70
tests/anyimage/operators/test_plane_conversion.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.support.planes import surface
|
||||
|
||||
|
||||
def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surface, monkeypatch):
|
||||
from anyimage.operators.convert_to_plane import object as conversion
|
||||
from anyimage.common.image import image_empty_bounds
|
||||
from anyimage.common.depth import reference_depth
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
obj, _, _, inputs, image = surface
|
||||
group = obj.modifiers[0].node_group
|
||||
monkeypatch.setattr(conversion, "load_node_group", lambda name: group)
|
||||
source = bpy.data.objects.new("Offset Image", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 4
|
||||
source.empty_image_offset = (-0.2, -0.8)
|
||||
source.matrix_world = Matrix.Translation((3, -2, 5)) @ Matrix.Rotation(0.7, 4, "Y") @ Matrix.Diagonal((2, 3, 1, 1))
|
||||
bpy.context.collection.objects.link(source)
|
||||
bounds = image_empty_bounds(source)
|
||||
matrix = source.matrix_world.copy()
|
||||
material = bpy.data.materials.new("Conversion")
|
||||
metadata = {"image_size": (16, 8),
|
||||
"intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))}
|
||||
result = conversion.create_depth_plane_object(
|
||||
bpy.context, source, 2, material, image, metadata, "DEPTH")
|
||||
assert result.name == "Offset Image"
|
||||
assert result["anyimage_mesh_shape"] == "DEPTH_PLANE"
|
||||
assert bpy.context.view_layer.objects.active == result
|
||||
expected = sorted(tuple(matrix @ Vector((x, y, 0)))
|
||||
for x in bounds[:2] for y in bounds[2:])
|
||||
actual = sorted(tuple(result.matrix_world @ v.co) for v in result.data.vertices)
|
||||
np.testing.assert_allclose(actual, expected, atol=1e-6)
|
||||
center = matrix @ Vector(((bounds[0] + bounds[1]) / 2, (bounds[2] + bounds[3]) / 2, 0))
|
||||
np.testing.assert_allclose(result.matrix_world.translation, center, atol=1e-6)
|
||||
modifier = result.modifiers[0]
|
||||
assert modifier[inputs["Thickness"].identifier] == 0
|
||||
assert modifier[inputs["Depth Image"].identifier] == image
|
||||
assert modifier[inputs["Depth Mask"].identifier] is True
|
||||
assert modifier[inputs["Reference Depth"].identifier] == pytest.approx(
|
||||
reference_depth(image) * modifier[inputs["Uniform Scale"].identifier])
|
||||
assert result.data.materials[0] == material
|
||||
|
||||
|
||||
def test_failed_modifier_initialization_removes_partial_object(surface, monkeypatch):
|
||||
from anyimage.operators.convert_to_plane import object as conversion
|
||||
|
||||
obj, _, _, _, image = surface
|
||||
monkeypatch.setattr(conversion, "load_node_group", lambda name: obj.modifiers[0].node_group)
|
||||
source = bpy.data.objects.new("Source", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
bpy.context.collection.objects.link(source)
|
||||
material = bpy.data.materials.new("Unused")
|
||||
before_objects, before_meshes = set(bpy.data.objects), set(bpy.data.meshes)
|
||||
|
||||
def fail(*args):
|
||||
raise RuntimeError("input failed")
|
||||
|
||||
monkeypatch.setattr(conversion, "set_modifier_input", fail)
|
||||
metadata = {"image_size": (16, 8),
|
||||
"intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))}
|
||||
with pytest.raises(RuntimeError, match="input failed"):
|
||||
conversion.create_depth_plane_object(bpy.context, source, 2, material, image, metadata, "DEPTH")
|
||||
assert set(bpy.data.objects) == before_objects
|
||||
assert set(bpy.data.meshes) == before_meshes
|
||||
assert material.users == 0
|
||||
320
tests/anyimage/operators/test_plane_objects.py
Normal file
320
tests/anyimage/operators/test_plane_objects.py
Normal file
@@ -0,0 +1,320 @@
|
||||
from unittest.mock import patch
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
|
||||
from anyimage.common import image as image_data
|
||||
from anyimage.common import object as object_data
|
||||
from anyimage.common.coordinate import canonical_direction_to_legacy
|
||||
from anyimage.operators.clipboard_image import actions as clipboard_actions
|
||||
from anyimage.operators.convert_to_plane import object as plane_object
|
||||
from anyimage.operators.cutout_tool import shape as cutout_shape
|
||||
from tests.support.image_objects import _depth_image, _depth_metadata, _evaluated_positions, _inputs
|
||||
from nodes.groups.image_plane import build_image_plane_group
|
||||
from nodes.groups.image_depth_plane import build_image_depth_plane_group
|
||||
from nodes.groups.image_relief_plane import build_image_relief_plane_group
|
||||
|
||||
|
||||
def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Source", width=4096, height=2048)
|
||||
source = bpy.data.objects.new("Source", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
material = bpy.data.materials.new("Source.material")
|
||||
|
||||
result = plane_object.create_plane_object(
|
||||
bpy.context,
|
||||
source,
|
||||
2,
|
||||
material,
|
||||
)
|
||||
|
||||
assert result["anyimage_mesh_shape"] == "PLANE"
|
||||
assert len(result.data.vertices) == 4
|
||||
assert len(result.data.polygons) == 1
|
||||
assert all(polygon.use_smooth for polygon in result.data.polygons)
|
||||
assert cutout_shape.BALLOON_ATTRIBUTE_NAME not in result.data.attributes
|
||||
assert result.modifiers[0].node_group.name == "O Image Plane"
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
evaluated_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
assert len(evaluated_mesh.polygons) > 0
|
||||
assert result.data.materials[0] == material
|
||||
assert evaluated_mesh.materials[0].original == material
|
||||
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
def test_clipboard_plane_uses_the_mesh_material_slot():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Clipboard Plane", width=128, height=64, alpha=True)
|
||||
|
||||
with patch.object(clipboard_actions, "_place_in_view"):
|
||||
result = clipboard_actions.add_image_plane(bpy.context, image)
|
||||
|
||||
assert _inputs(result.modifiers[0].node_group) == [
|
||||
"Geometry",
|
||||
"Subdivide",
|
||||
"Thickness",
|
||||
]
|
||||
assert len(result.data.materials) == 1
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
evaluated_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
assert evaluated_mesh.materials[0].original == result.data.materials[0]
|
||||
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
def test_converted_planes_center_the_origin_without_moving_world_corners():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
saved = build_image_depth_plane_group(build_image_plane_group())
|
||||
|
||||
def create_source(name):
|
||||
image = bpy.data.images.new(name, width=200, height=100)
|
||||
source = bpy.data.objects.new(name, None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
source.empty_image_offset = (0.25, -0.2)
|
||||
source.matrix_world = (
|
||||
Matrix.Translation((3.0, -2.0, 5.0))
|
||||
@ Matrix.Rotation(0.4, 4, "Z")
|
||||
@ Matrix.Diagonal((1.5, 0.75, 2.0, 1.0))
|
||||
)
|
||||
bpy.context.collection.objects.link(source)
|
||||
return source
|
||||
|
||||
def assert_centered(result, bounds, source_matrix):
|
||||
x_min, x_max, y_min, y_max = bounds
|
||||
center = Vector(
|
||||
((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, 0.0)
|
||||
)
|
||||
assert np.allclose(
|
||||
tuple(result.matrix_world.translation),
|
||||
tuple(source_matrix @ center),
|
||||
atol=1e-6,
|
||||
)
|
||||
local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices])
|
||||
assert np.allclose(local[:, [0, 2]].min(axis=0), (-1.0, -0.5), atol=1e-6)
|
||||
assert np.allclose(local[:, [0, 2]].max(axis=0), (1.0, 0.5), atol=1e-6)
|
||||
expected = sorted(
|
||||
tuple(source_matrix @ Vector((x, y, 0.0)))
|
||||
for x in (x_min, x_max)
|
||||
for y in (y_min, y_max)
|
||||
)
|
||||
actual = sorted(
|
||||
tuple(result.matrix_world @ vertex.co)
|
||||
for vertex in result.data.vertices
|
||||
)
|
||||
assert np.allclose(actual, expected, atol=1e-6)
|
||||
|
||||
plane_source = create_source("Centered Plane")
|
||||
plane_bounds = image_data.image_empty_bounds(plane_source)
|
||||
plane_matrix = plane_source.matrix_world.copy()
|
||||
plane_material = bpy.data.materials.new("Centered Plane Material")
|
||||
plane = plane_object.create_plane_object(
|
||||
bpy.context,
|
||||
plane_source,
|
||||
0,
|
||||
plane_material,
|
||||
)
|
||||
assert_centered(plane, plane_bounds, plane_matrix)
|
||||
assert plane["anyimage_mesh_shape"] == "PLANE"
|
||||
|
||||
depth_source = create_source("Centered Depth Plane")
|
||||
depth_bounds = image_data.image_empty_bounds(depth_source)
|
||||
depth_matrix = depth_source.matrix_world.copy()
|
||||
depth_values = np.ones((2, 2, 3), dtype=np.float32)
|
||||
depth_image = _depth_image(depth_values, np.ones((2, 2), dtype=bool))
|
||||
depth_material = bpy.data.materials.new("Centered Depth Material")
|
||||
depth_plane = plane_object.create_depth_plane_object(
|
||||
bpy.context,
|
||||
depth_source,
|
||||
0,
|
||||
depth_material,
|
||||
depth_image,
|
||||
_depth_metadata(
|
||||
((0.5, 0.0, 0.5), (0.0, 0.5, 0.5), (0.0, 0.0, 1.0)),
|
||||
(2, 2),
|
||||
),
|
||||
"DEPTH",
|
||||
)
|
||||
assert_centered(depth_plane, depth_bounds, depth_matrix)
|
||||
assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE"
|
||||
depth_modifier = depth_plane.modifiers[0]
|
||||
assert depth_modifier.node_group == saved
|
||||
assert saved.name == "O Image Depth Plane"
|
||||
uniform_scale = depth_modifier[
|
||||
object_data.modifier_input_identifier(
|
||||
depth_modifier.node_group,
|
||||
"Uniform Scale",
|
||||
)
|
||||
]
|
||||
assert np.isclose(
|
||||
depth_modifier[
|
||||
object_data.modifier_input_identifier(
|
||||
depth_modifier.node_group,
|
||||
"Reference Depth",
|
||||
)
|
||||
],
|
||||
uniform_scale,
|
||||
)
|
||||
|
||||
|
||||
def test_relief_plane_builds_a_fixed_base_terrain_block():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
build_image_relief_plane_group(build_image_plane_group())
|
||||
image = bpy.data.images.new("Terrain", width=4, height=4)
|
||||
source = bpy.data.objects.new("Terrain", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
material = bpy.data.materials.new("Terrain Material")
|
||||
depth_image = _depth_image(
|
||||
np.ones((4, 4, 3), dtype=np.float32),
|
||||
np.ones((4, 4), dtype=bool),
|
||||
)
|
||||
result = plane_object.create_depth_plane_object(
|
||||
bpy.context,
|
||||
source,
|
||||
2,
|
||||
material,
|
||||
depth_image,
|
||||
_depth_metadata(
|
||||
((1.5, 0.0, 1.5), (0.0, 1.5, 1.5), (0.0, 0.0, 1.0)),
|
||||
(4, 4),
|
||||
),
|
||||
"RELIEF",
|
||||
)
|
||||
assert result["anyimage_mesh_shape"] == "RELIEF_PLANE"
|
||||
assert result.modifiers[0].node_group.name == "O Image Relief Plane"
|
||||
modifier = result.modifiers[0]
|
||||
group = modifier.node_group
|
||||
assert modifier[
|
||||
object_data.modifier_input_identifier(group, "Thickness")
|
||||
] == 0.0
|
||||
assert modifier[
|
||||
object_data.modifier_input_identifier(group, "Depth Scale")
|
||||
] == 0.5
|
||||
assert modifier[
|
||||
object_data.modifier_input_identifier(group, "Depth Offset")
|
||||
] == 0.0
|
||||
|
||||
def set_input(name, value):
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(group, name),
|
||||
value,
|
||||
)
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
set_input("Depth Scale", 0.0)
|
||||
set_input("Thickness", 0.1)
|
||||
set_input("Depth Offset", 0.0)
|
||||
baseline = _evaluated_positions(result)
|
||||
assert np.isclose(baseline[:, 1].min(), 0.0, atol=1e-6)
|
||||
assert np.isclose(baseline[:, 1].max(), 0.1, atol=1e-6)
|
||||
baseline_base = {
|
||||
(round(float(x), 6), round(float(y), 6))
|
||||
for x, y, z in baseline
|
||||
if np.isclose(y, 0.0, atol=1e-6)
|
||||
}
|
||||
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
evaluated_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
assert result.data.materials[0] == material
|
||||
assert evaluated_mesh.materials[0].original == material
|
||||
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
|
||||
assert "UVMap" in evaluated_mesh.uv_layers
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
set_input("Depth Scale", 1.0)
|
||||
set_input("Reference Depth", 1.2)
|
||||
outward = _evaluated_positions(result)
|
||||
assert np.isclose(outward[:, 1].min(), -0.2, atol=1e-6)
|
||||
assert np.isclose(outward[:, 1].max(), 0.1, atol=1e-5)
|
||||
outward_base = {
|
||||
(round(float(x), 6), round(float(y), 6))
|
||||
for x, y, z in outward
|
||||
if np.isclose(y, 0.0, atol=1e-6)
|
||||
}
|
||||
assert outward_base == baseline_base
|
||||
y_levels = np.unique(np.round(outward[:, 1], 6))
|
||||
assert len(y_levels) >= 4
|
||||
assert np.allclose(np.diff(y_levels), np.diff(y_levels)[0], atol=1e-5)
|
||||
|
||||
set_input("Reference Depth", 0.95)
|
||||
inward = _evaluated_positions(result)
|
||||
assert np.isclose(inward[:, 1].min(), 0.0, atol=1e-6)
|
||||
assert np.isclose(inward[:, 1].max(), 0.1, atol=1e-5)
|
||||
|
||||
set_input("Reference Depth", 0.0)
|
||||
limited = _evaluated_positions(result)
|
||||
assert np.isclose(limited[:, 1].min(), 0.0, atol=1e-6)
|
||||
assert np.isclose(limited[:, 1].max(), 0.1, atol=1e-5)
|
||||
|
||||
set_input("Reference Depth", 1.3)
|
||||
outward_beyond_thickness = _evaluated_positions(result)
|
||||
assert np.isclose(outward_beyond_thickness[:, 1].max(), 0.1, atol=1e-5)
|
||||
|
||||
set_input("Thickness", 0.0)
|
||||
set_input("Reference Depth", 1.2)
|
||||
sheet = _evaluated_positions(result)
|
||||
assert np.isfinite(sheet).all()
|
||||
set_input("Reference Depth", 0.8)
|
||||
assert np.allclose(_evaluated_positions(result)[:, 1], 0.0, atol=1e-5)
|
||||
|
||||
|
||||
def test_relief_plane_initializes_a_fitted_depth_direction():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
build_image_relief_plane_group(build_image_plane_group())
|
||||
image = bpy.data.images.new("Sloped Relief", width=5, height=3)
|
||||
source = bpy.data.objects.new("Sloped Relief", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
bounds = image_data.image_empty_bounds(source)
|
||||
x = bounds[0] + (np.arange(5) + 0.5) / 5 * (bounds[1] - bounds[0])
|
||||
y = bounds[3] - (np.arange(3) + 0.5) / 3 * (bounds[3] - bounds[2])
|
||||
xx, yy = np.meshgrid(x, y)
|
||||
depth = 2.0 + 0.2 * xx + 0.1 * yy
|
||||
depth_image = _depth_image(
|
||||
np.repeat(depth[..., None], 3, axis=2),
|
||||
np.ones(depth.shape, dtype=bool),
|
||||
)
|
||||
metadata = _depth_metadata(
|
||||
((2.0, 0.0, 2.0), (0.0, 2.0, 1.0), (0.0, 0.0, 1.0)),
|
||||
(5, 3),
|
||||
)
|
||||
result = plane_object.create_depth_plane_object(
|
||||
bpy.context,
|
||||
source,
|
||||
2,
|
||||
bpy.data.materials.new("Sloped Relief Material"),
|
||||
depth_image,
|
||||
metadata,
|
||||
"RELIEF",
|
||||
)
|
||||
modifier = result.modifiers[0]
|
||||
direction = modifier[
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Depth Direction")
|
||||
]
|
||||
uniform_scale = modifier[
|
||||
object_data.modifier_input_identifier(modifier.node_group, "Uniform Scale")
|
||||
]
|
||||
canonical = np.asarray((0.2 * uniform_scale, 1.0, -0.1 * uniform_scale))
|
||||
expected = np.asarray(canonical_direction_to_legacy(canonical))
|
||||
expected /= np.linalg.norm(expected)
|
||||
np.testing.assert_allclose(direction, expected, atol=1e-6)
|
||||
191
tests/anyimage/operators/test_plane_operators.py
Normal file
191
tests/anyimage/operators/test_plane_operators.py
Normal file
@@ -0,0 +1,191 @@
|
||||
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_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, "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, "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_material_color_input") as prepare,
|
||||
):
|
||||
self.assertEqual(self.convert_to_plane.ConvertToDepthPlane.execute(operator, context), {"FINISHED"})
|
||||
setup.assert_called_once()
|
||||
prepare.assert_not_called()
|
||||
|
||||
|
||||
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", 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, "invoke_ai_setup_if_needed", return_value=None),
|
||||
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),
|
||||
):
|
||||
self.assertEqual(operator_class.execute(operator, context), {"FINISHED"})
|
||||
self.assertEqual(generate.call_args.kwargs["source_object_name"], "Source")
|
||||
self.assertEqual(generate.call_args.kwargs["plane_type"], plane_type)
|
||||
|
||||
def test_depth_and_relief_results_use_their_normal_space(self):
|
||||
for plane_type, normal_space in (("DEPTH", "OBJECT"), ("RELIEF", "TANGENT")):
|
||||
normal_key = f"{normal_space.lower()}_normal"
|
||||
module = self.convert_to_plane_object
|
||||
source = SimpleNamespace(data=SimpleNamespace(name="Source"))
|
||||
color_image = SimpleNamespace(name="", users=0)
|
||||
depth_image = SimpleNamespace(users=0)
|
||||
normal_image = SimpleNamespace(users=0)
|
||||
material = SimpleNamespace(users=0)
|
||||
metadata = {
|
||||
"image_size": (2, 1),
|
||||
"intrinsics": ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
|
||||
}
|
||||
files = {
|
||||
"depth_metadata": Path("depth.json"),
|
||||
"depth": Path("depth.exr"),
|
||||
normal_key: Path(f"{normal_key}.png"),
|
||||
}
|
||||
result = SimpleNamespace(
|
||||
directory=Path("result"),
|
||||
file=lambda key: files[key],
|
||||
)
|
||||
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_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(
|
||||
module,
|
||||
"load_normal_result_image",
|
||||
return_value=normal_image,
|
||||
) as load_normal,
|
||||
patch.object(
|
||||
module,
|
||||
"create_image_material",
|
||||
return_value=material,
|
||||
) as create_material,
|
||||
patch.object(module, "create_depth_plane_object") as create_object,
|
||||
patch.object(module.bpy, "ops", ops, create=True),
|
||||
):
|
||||
module.create_depth_plane_from_result(context, result, operator)
|
||||
|
||||
load_depth.assert_called_once_with(files["depth"], source, metadata)
|
||||
ops.ed.undo_push.assert_called_once_with(message=f"Convert to {plane_type.title()} Plane")
|
||||
load_normal.assert_called_once_with(files[normal_key], source)
|
||||
create_material.assert_called_once_with(
|
||||
source.data,
|
||||
color_image,
|
||||
normal_image=normal_image,
|
||||
normal_space=normal_space,
|
||||
scene=context.scene,
|
||||
)
|
||||
create_object.assert_called_once_with(
|
||||
context,
|
||||
source,
|
||||
6,
|
||||
material,
|
||||
depth_image,
|
||||
metadata,
|
||||
plane_type,
|
||||
)
|
||||
|
||||
|
||||
def test_depth_plane_result_cleans_unused_depth_and_normal_on_failure(self):
|
||||
module = self.convert_to_plane_object
|
||||
source = SimpleNamespace(data=SimpleNamespace(name="Source"))
|
||||
color_image = SimpleNamespace(name="", users=0)
|
||||
depth_image = SimpleNamespace(users=0)
|
||||
normal_image = SimpleNamespace(users=0)
|
||||
material = SimpleNamespace(users=0)
|
||||
files = {
|
||||
"depth_metadata": Path("depth.json"),
|
||||
"depth": Path("depth.exr"),
|
||||
"object_normal": Path("object-normal.png"),
|
||||
}
|
||||
result = SimpleNamespace(
|
||||
directory=Path("result"),
|
||||
file=lambda key: files[key],
|
||||
)
|
||||
remove_material = Mock()
|
||||
remove_image = Mock()
|
||||
data = SimpleNamespace(
|
||||
materials=SimpleNamespace(remove=remove_material),
|
||||
images=SimpleNamespace(remove=remove_image),
|
||||
)
|
||||
|
||||
with (
|
||||
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={
|
||||
"image_size": (2, 1),
|
||||
"intrinsics": ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
|
||||
},
|
||||
),
|
||||
patch.object(module, "load_depth_result_image", return_value=depth_image),
|
||||
patch.object(module, "load_normal_result_image", return_value=normal_image),
|
||||
patch.object(module, "create_image_material", return_value=material),
|
||||
patch.object(
|
||||
module,
|
||||
"create_depth_plane_object",
|
||||
side_effect=RuntimeError("failed"),
|
||||
),
|
||||
patch.object(module.bpy, "data", data),
|
||||
self.assertRaisesRegex(RuntimeError, "failed"),
|
||||
):
|
||||
module.create_depth_plane_from_result(
|
||||
SimpleNamespace(scene=object()),
|
||||
result,
|
||||
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],
|
||||
[depth_image, normal_image],
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
entry.kwargs == {"do_unlink": True}
|
||||
for entry in remove_image.call_args_list
|
||||
)
|
||||
)
|
||||
214
tests/anyimage/operators/test_rectify_tool.py
Normal file
214
tests/anyimage/operators/test_rectify_tool.py
Normal file
@@ -0,0 +1,214 @@
|
||||
from types import SimpleNamespace
|
||||
from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class RectifyToolTest(BlenderTestCase):
|
||||
def test_rectify_rectifies_identity_pixels(self):
|
||||
import numpy as np
|
||||
|
||||
top_down = np.arange(3 * 4 * 4, dtype=np.float32).reshape((3, 4, 4))
|
||||
top_down /= float(top_down.max())
|
||||
top_down[:, :, 3] = 1.0
|
||||
result = self.image_data.warp_projective_pixels(
|
||||
np.flipud(top_down).ravel(),
|
||||
(4, 3),
|
||||
((0, 0), (4, 0), (4, 3), (0, 3)),
|
||||
(4, 3),
|
||||
)
|
||||
|
||||
np.testing.assert_allclose(
|
||||
np.flipud(np.asarray(result).reshape((3, 4, 4))),
|
||||
top_down,
|
||||
atol=1e-6,
|
||||
)
|
||||
|
||||
|
||||
def test_projective_sampling_keeps_outside_pixels_transparent(self):
|
||||
import numpy as np
|
||||
|
||||
source = np.ones((2, 2, 4), dtype=np.float32)
|
||||
result = self.image_data.warp_projective_pixels(
|
||||
np.flipud(source).ravel(),
|
||||
(2, 2),
|
||||
((-2, 0), (2, 0), (2, 2), (-2, 2)),
|
||||
(4, 2),
|
||||
)
|
||||
top_down = np.flipud(np.asarray(result).reshape((2, 4, 4)))
|
||||
|
||||
np.testing.assert_array_equal(top_down[:, :2, 3], 0.0)
|
||||
np.testing.assert_allclose(top_down[:, 1, :3], 1.0)
|
||||
np.testing.assert_allclose(top_down[:, 2:, 3], 1.0)
|
||||
|
||||
|
||||
def test_projective_sampling_uses_premultiplied_alpha(self):
|
||||
import numpy as np
|
||||
|
||||
source = np.zeros((1, 2, 4), dtype=np.float32)
|
||||
source[0, 0] = (1.0, 0.0, 0.0, 1.0)
|
||||
source[0, 1] = (0.0, 1.0, 0.0, 0.0)
|
||||
sampled = self.image_data.sample_rgba(
|
||||
self.image_data.premultiplied_rgba(
|
||||
np.flipud(source).ravel(),
|
||||
(2, 1),
|
||||
),
|
||||
np.asarray([[[1.0, 0.5]]]),
|
||||
)
|
||||
straight = self.image_data.straight_rgba(sampled)
|
||||
|
||||
np.testing.assert_allclose(straight[0, 0], (1.0, 0.0, 0.0, 0.5))
|
||||
|
||||
|
||||
def test_homography_rejects_degenerate_points(self):
|
||||
with self.assertRaisesRegex(ValueError, "degenerate"):
|
||||
self.image_data.homography_from_points(
|
||||
((0, 0), (1, 0), (2, 0), (3, 0)),
|
||||
((0, 0), (1, 0), (1, 1), (0, 1)),
|
||||
)
|
||||
|
||||
|
||||
def test_rectify_preview_uses_srgb_without_changing_pixels(self):
|
||||
written = []
|
||||
preview = SimpleNamespace(
|
||||
colorspace_settings=SimpleNamespace(name="Non-Color"),
|
||||
alpha_mode=None,
|
||||
pixels=SimpleNamespace(foreach_set=written.extend),
|
||||
update=lambda: None,
|
||||
)
|
||||
previous_data = self.fake_bpy.data
|
||||
self.fake_bpy.data = SimpleNamespace(
|
||||
images=SimpleNamespace(new=lambda *_args, **_options: preview)
|
||||
)
|
||||
pixels = (0.1, 0.2, 0.3, 0.4)
|
||||
source = SimpleNamespace(
|
||||
name="Source.png",
|
||||
colorspace_settings=SimpleNamespace(name="Non-Color"),
|
||||
)
|
||||
try:
|
||||
result = self.rectify_preview.create_perspective_preview_image(
|
||||
source, pixels
|
||||
)
|
||||
finally:
|
||||
self.fake_bpy.data = previous_data
|
||||
|
||||
self.assertIs(result, preview)
|
||||
self.assertEqual(preview.colorspace_settings.name, "sRGB")
|
||||
self.assertEqual(preview.alpha_mode, "STRAIGHT")
|
||||
self.assertEqual(written, list(pixels))
|
||||
|
||||
|
||||
def test_rectify_preview_skips_cursor_at_last_clicked_point(self):
|
||||
points = ((10.0, 10.0), (80.0, 10.0), (70.0, 60.0))
|
||||
|
||||
stationary = self.rectify_preview.interactive_preview_polygon(
|
||||
points, points[-1]
|
||||
)
|
||||
moving = self.rectify_preview.interactive_preview_polygon(
|
||||
points, (20.0, 70.0)
|
||||
)
|
||||
|
||||
self.assertEqual(len(stationary), 3)
|
||||
self.assertEqual(len(moving), 4)
|
||||
|
||||
|
||||
def test_rectify_tightly_crops_alpha(self):
|
||||
import numpy as np
|
||||
|
||||
top_down = np.zeros((4, 4, 4), dtype=np.float32)
|
||||
top_down[1:3, 1:3] = (0.2, 0.4, 0.6, 1.0)
|
||||
pixels, output_size, placement_bounds = (
|
||||
self.rectify_geometry.extract_perspective_pixels(
|
||||
np.flipud(top_down).ravel(),
|
||||
(4, 4),
|
||||
((0, 0), (4, 0), (4, 4), (0, 4)),
|
||||
1.0,
|
||||
)
|
||||
)
|
||||
result = np.flipud(np.asarray(pixels).reshape((2, 2, 4)))
|
||||
|
||||
self.assertEqual(output_size, (2, 2))
|
||||
self.assertEqual(placement_bounds, (1.0, 1.0, 3.0, 3.0))
|
||||
self.assertTrue(np.all(result[:, :, 3] == 1.0))
|
||||
|
||||
|
||||
def test_rectify_rejects_non_convex_corners(self):
|
||||
with self.assertRaisesRegex(ValueError, "convex"):
|
||||
self.rectify_geometry.validate_perspective_quad(
|
||||
((0, 0), (10, 0), (4, 4), (0, 10))
|
||||
)
|
||||
|
||||
|
||||
def test_rectify_corner_order_is_click_order_independent(self):
|
||||
import itertools
|
||||
|
||||
import numpy as np
|
||||
|
||||
corners = ((10, 20), (80, 15), (90, 70), (5, 75))
|
||||
ordered = self.rectify_geometry.canonical_perspective_quad(corners)
|
||||
for permutation in itertools.permutations(corners):
|
||||
np.testing.assert_allclose(
|
||||
self.rectify_geometry.canonical_perspective_quad(permutation),
|
||||
ordered,
|
||||
)
|
||||
|
||||
|
||||
def test_rectify_detects_source_image_overlap(self):
|
||||
overlaps = self.rectify_geometry.perspective_quad_overlaps_image
|
||||
|
||||
self.assertFalse(overlaps(((20, 20), (30, 20), (30, 30), (20, 30)), (10, 10)))
|
||||
self.assertTrue(overlaps(((5, 5), (15, 5), (15, 15), (5, 15)), (10, 10)))
|
||||
self.assertTrue(overlaps(((-5, -5), (15, -5), (15, 15), (-5, 15)), (10, 10)))
|
||||
|
||||
|
||||
def test_rectify_limits_output_pixel_count(self):
|
||||
width, height = self.image_data.projective_output_size(
|
||||
((0, 0), (10000, 0), (10000, 10000), (0, 10000)),
|
||||
1.0,
|
||||
)
|
||||
|
||||
self.assertLessEqual(
|
||||
width * height,
|
||||
self.image_data.MAX_PROJECTIVE_OUTPUT_PIXELS,
|
||||
)
|
||||
|
||||
|
||||
def test_rectify_mouse_adjusts_preview_aspect_exponentially(self):
|
||||
adjust = self.rectify_preview.aspect_ratio_from_mouse
|
||||
|
||||
self.assertAlmostEqual(adjust(1.5, 240.0), 3.0)
|
||||
self.assertAlmostEqual(adjust(1.5, -240.0), 0.75)
|
||||
self.assertEqual(
|
||||
adjust(1.0, 100000.0),
|
||||
self.rectify_geometry.MAX_INTERACTIVE_ASPECT,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.rectify_preview.snapped_aspect_ratio(1.7),
|
||||
(16.0 / 9.0, "16 : 9"),
|
||||
)
|
||||
|
||||
|
||||
def test_rectify_preview_is_centered_at_requested_aspect(self):
|
||||
left, bottom, width, height = self.rectify_preview.preview_draw_bounds(
|
||||
(1000, 800), 2.0
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(width / height, 2.0)
|
||||
self.assertAlmostEqual(left + width * 0.5, 500.0)
|
||||
self.assertAlmostEqual(bottom + height * 0.5, 400.0)
|
||||
|
||||
|
||||
def test_rectify_uses_quad_edges_for_initial_aspect(self):
|
||||
ratio = self.rectify_geometry.perspective_quad_aspect(
|
||||
((0, 0), (8, 0), (8, 4), (0, 4))
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(ratio, 2.0)
|
||||
|
||||
|
||||
def test_rectify_maps_crop_to_source_plane_bounds(self):
|
||||
bounds = self.rectify_geometry.perspective_placement_bounds(
|
||||
((10, 20), (30, 20), (30, 40), (10, 40)),
|
||||
(100, 50),
|
||||
(-10, -5, 110, 55),
|
||||
)
|
||||
|
||||
self.assertEqual(bounds, (8.0, 18.0, 32.0, 42.0))
|
||||
226
tests/anyimage/operators/test_texture_node_actions.py
Normal file
226
tests/anyimage/operators/test_texture_node_actions.py
Normal file
@@ -0,0 +1,226 @@
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from anyimage import menu
|
||||
from anyimage.common import image as images
|
||||
from anyimage.common.image_target import ImageEditTarget, active_texture_node
|
||||
from anyimage.operators import remove_background, upscale
|
||||
from anyimage.runtime import runtime
|
||||
from tests.support.image_texture import texture, texture_context, write_result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shader_editor(texture):
|
||||
mesh = bpy.data.meshes.new("Material owner")
|
||||
obj = bpy.data.objects.new("Material owner", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
obj.data.materials.append(bpy.data.materials["Texture material"])
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D")
|
||||
area.type = "NODE_EDITOR"
|
||||
area.ui_type = "ShaderNodeTree"
|
||||
with bpy.context.temp_override(area=area):
|
||||
yield bpy.context
|
||||
|
||||
|
||||
def test_shader_editor_pin_and_shared_group_target(shader_editor, tmp_path):
|
||||
context = shader_editor
|
||||
node = active_texture_node(context)
|
||||
assert node is not None
|
||||
assert context.space_data.id == bpy.data.materials["Texture material"]
|
||||
context.space_data.pin = True
|
||||
bpy.context.view_layer.objects.active = None
|
||||
assert active_texture_node(context) == node
|
||||
# Use Blender's own grouping operation to exercise nested editor context.
|
||||
for candidate in node.id_data.nodes:
|
||||
candidate.select = candidate == node
|
||||
assert bpy.ops.node.group_make() == {"FINISHED"}
|
||||
nested = active_texture_node(context)
|
||||
assert nested is not None
|
||||
assert context.space_data.edit_tree != context.space_data.node_tree
|
||||
group = nested.id_data
|
||||
second_instance = context.space_data.node_tree.nodes.new("ShaderNodeGroup")
|
||||
second_instance.node_tree = group
|
||||
source = nested.image
|
||||
target = ImageEditTarget.capture(context)
|
||||
write_result(tmp_path)
|
||||
target.apply(tmp_path / "foreground.png")
|
||||
assert nested.image == source
|
||||
assert tuple(source.size) == (8, 6)
|
||||
assert second_instance.node_tree.nodes[nested.name].image == source
|
||||
|
||||
|
||||
def test_linked_material_target_is_unavailable(texture, tmp_path):
|
||||
path = str(tmp_path / "linked.blend")
|
||||
bpy.data.libraries.write(path, {bpy.data.materials["Texture material"]})
|
||||
with bpy.data.libraries.load(path, link=True) as (source, destination):
|
||||
destination.materials = source.materials
|
||||
linked = destination.materials[0].node_tree.nodes["Source texture"]
|
||||
context = texture_context(linked)
|
||||
assert active_texture_node(context) is None
|
||||
target = ImageEditTarget(linked, linked.image, linked.id_data)
|
||||
with pytest.raises(RuntimeError, match="no longer available"):
|
||||
target.validate()
|
||||
|
||||
|
||||
def test_texture_menu_uses_node_dimensions_and_ai_settings(texture):
|
||||
context = texture_context(texture)
|
||||
layout = SimpleNamespace(operator=Mock(), separator=Mock(), menu=Mock())
|
||||
with patch("anyimage.properties.ai_status", return_value={"ready": True}):
|
||||
menu.AnyImageTextureNodeMenu.draw(SimpleNamespace(layout=layout), context)
|
||||
assert layout.operator_context == "INVOKE_DEFAULT"
|
||||
assert [call.args[0] for call in layout.operator.call_args_list] == [
|
||||
"anyimage.remove_image_background", "anyimage.upscale_image", "anyimage.open_ai_environment_settings",
|
||||
]
|
||||
assert layout.operator.call_args_list[1].kwargs["text"] == "Upscale (8 × 6)"
|
||||
menu.draw_texture_node_context_menu(SimpleNamespace(layout=layout), context)
|
||||
layout.menu.assert_called_once_with(menu.AnyImageTextureNodeMenu.bl_idname, text="AnyImage", icon="PLUGIN")
|
||||
texture.image = None
|
||||
layout.menu.reset_mock()
|
||||
menu.draw_texture_node_context_menu(SimpleNamespace(layout=layout), context)
|
||||
layout.menu.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("busy,limit,expected", [(False, 2048, True), (True, 2048, False), (False, 4, False)])
|
||||
def test_texture_poll_applies_busy_and_size_limits(texture, busy, limit, expected):
|
||||
context = texture_context(texture)
|
||||
with patch.object(runtime, "server_busy", return_value=busy), patch.object(
|
||||
upscale, "configured_max_ai_input_size", return_value=limit,
|
||||
):
|
||||
assert upscale.UpscaleImage.poll(context) == expected
|
||||
assert remove_background.RemoveImageBackground.poll(context) == (not busy)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation,filename", [(upscale.UpscaleImage, "upscale.png"), (remove_background.RunBackgroundRemoval, "foreground.png")])
|
||||
@pytest.mark.parametrize("shared", [False, True])
|
||||
def test_texture_action_undo_redo_restores_image_and_binding(texture, tmp_path, operation, filename, shared):
|
||||
source_name = texture.image.name
|
||||
source_pixels = images.image_pixels(texture.image)
|
||||
if shared:
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
other.image = texture.image
|
||||
other.name = "Other texture"
|
||||
write_result(tmp_path, filename)
|
||||
|
||||
def execute(operator, context):
|
||||
node = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"]
|
||||
operator._image_target = ImageEditTarget.capture(texture_context(node))
|
||||
if operation == upscale.UpscaleImage:
|
||||
operator.model = "HAT_GAN_X4_SHARPER"
|
||||
operator.response(context, SimpleNamespace(file=lambda key: tmp_path / f"{key}.png"))
|
||||
return {"FINISHED"}
|
||||
|
||||
bpy.context.preferences.edit.use_global_undo = True
|
||||
with patch.object(operation, "execute", execute), patch.object(
|
||||
operation, "poll", classmethod(lambda cls, context: True), create=True,
|
||||
):
|
||||
bpy.utils.register_class(operation)
|
||||
try:
|
||||
bpy.ops.ed.undo_push(message="Before texture processing")
|
||||
call = getattr(bpy.ops.anyimage, operation.bl_idname.split(".")[1])
|
||||
assert call("EXEC_DEFAULT", True) == {"FINISHED"}
|
||||
result_pixels = images.image_pixels(texture.image)
|
||||
assert tuple(texture.image.size) == (8, 6)
|
||||
assert bpy.ops.ed.undo() == {"FINISHED"}
|
||||
restored = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"]
|
||||
assert restored.image.name == source_name
|
||||
assert tuple(restored.image.size) == (4, 3)
|
||||
np.testing.assert_array_equal(images.image_pixels(restored.image), source_pixels)
|
||||
assert bpy.ops.ed.redo() == {"FINISHED"}
|
||||
redone = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"]
|
||||
assert tuple(redone.image.size) == (8, 6)
|
||||
np.testing.assert_array_equal(images.image_pixels(redone.image), result_pixels)
|
||||
if shared:
|
||||
other = redone.id_data.nodes["Other texture"]
|
||||
np.testing.assert_array_equal(images.image_pixels(other.image), source_pixels)
|
||||
assert other.image != redone.image
|
||||
finally:
|
||||
bpy.utils.unregister_class(operation)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation,module,filename", [
|
||||
(upscale.UpscaleImage, upscale, "upscale.png"),
|
||||
(remove_background.RunBackgroundRemoval, remove_background, "foreground.png"),
|
||||
])
|
||||
@pytest.mark.parametrize("outcome", ["succeeded", "cancelled", "failed", "invalid_target"])
|
||||
def test_texture_job_request_and_completion(texture, tmp_path, operation, module, filename, outcome):
|
||||
context = texture_context(texture)
|
||||
context.window = object()
|
||||
context.window_manager = SimpleNamespace(
|
||||
progress_begin=Mock(), progress_end=Mock(), event_timer_add=Mock(return_value=object()),
|
||||
event_timer_remove=Mock(), modal_handler_add=Mock(),
|
||||
)
|
||||
write_result(tmp_path, filename)
|
||||
controller = SimpleNamespace(
|
||||
submit=Mock(return_value={"job_id": "texture-job", "directory": str(tmp_path)}),
|
||||
status=Mock(return_value={"state": "failed" if outcome == "invalid_target" else outcome,
|
||||
"result": {Path(filename).stem: filename}}),
|
||||
mark_job_complete=Mock(), cancel=Mock(),
|
||||
)
|
||||
original_pixels = images.image_pixels(texture.image)
|
||||
original_execute = operation.execute
|
||||
|
||||
def execute(operator, _context):
|
||||
with ExitStack() as stack:
|
||||
for name in ("require_environment", "require_model"):
|
||||
stack.enter_context(patch.object(module, name))
|
||||
stack.enter_context(patch.object(module, "production_device", return_value="CPU"))
|
||||
stack.enter_context(patch.object(operation, "controller", return_value=controller))
|
||||
stack.enter_context(patch.object(runtime, "update_ui"))
|
||||
stack.enter_context(patch.object(runtime, "redraw_ui"))
|
||||
if operation == upscale.UpscaleImage:
|
||||
stack.enter_context(patch.object(upscale, "configured_upscale_model", return_value="HAT_GAN_X4_SHARPER"))
|
||||
stack.enter_context(patch.object(upscale, "configured_max_ai_input_size", return_value=2048))
|
||||
assert original_execute(operator, context) == {"RUNNING_MODAL"}
|
||||
job = runtime.active_job
|
||||
job.start_thread.join(timeout=5)
|
||||
assert not job.start_thread.is_alive()
|
||||
kind, parameters = controller.submit.call_args.args
|
||||
assert kind == operation.job_type
|
||||
assert "start_frame" not in parameters and "video_frames" not in parameters
|
||||
assert parameters["device"] == "cpu"
|
||||
assert parameters["model"] == ("HAT_GAN_X4_SHARPER" if operation == upscale.UpscaleImage else "BIREFNET_LITE")
|
||||
input_path = operator.input_path
|
||||
assert operator.delete_input
|
||||
# Selection changes must not redirect completion.
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
texture.id_data.nodes.active = other
|
||||
if outcome == "invalid_target":
|
||||
texture.image = None
|
||||
controller.status.return_value = {"state": "succeeded", "result": {Path(filename).stem: filename}}
|
||||
bpy.ops.ed.undo_push(message="Before asynchronous texture result")
|
||||
result = operator.modal(context, SimpleNamespace(type="TIMER"))
|
||||
assert result == ({"FINISHED"} if outcome == "succeeded" else {"CANCELLED"})
|
||||
assert runtime.active_job is None
|
||||
assert operator._image_target is None
|
||||
assert not Path(input_path).exists()
|
||||
assert other.image is None
|
||||
if outcome == "succeeded":
|
||||
assert tuple(texture.image.size) == (8, 6)
|
||||
elif outcome != "invalid_target":
|
||||
np.testing.assert_array_equal(images.image_pixels(texture.image), original_pixels)
|
||||
context.window_manager.event_timer_remove.assert_called_once()
|
||||
return result
|
||||
|
||||
with patch.object(operation, "execute", execute), patch.object(
|
||||
operation, "poll", classmethod(lambda cls, context: True), create=True,
|
||||
):
|
||||
bpy.utils.register_class(operation)
|
||||
try:
|
||||
call = getattr(bpy.ops.anyimage, operation.bl_idname.split(".")[1])
|
||||
if outcome in {"failed", "invalid_target"}:
|
||||
message = "Server task failed" if outcome == "failed" else "no longer available"
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
call("EXEC_DEFAULT", True)
|
||||
else:
|
||||
assert call("EXEC_DEFAULT", True) == ({"FINISHED"} if outcome == "succeeded" else {"CANCELLED"})
|
||||
finally:
|
||||
runtime.close_active()
|
||||
bpy.utils.unregister_class(operation)
|
||||
46
tests/anyimage/operators/test_upscale_undo.py
Normal file
46
tests/anyimage/operators/test_upscale_undo.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import bpy
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from anyimage.operators.upscale import UpscaleImage
|
||||
from anyimage.common.image import image_pixels
|
||||
from anyimage.common.image_target import ImageEditTarget
|
||||
|
||||
|
||||
def test_upscale_result_replacement_undo_restores_source(tmp_path):
|
||||
source = bpy.data.images.new("Upscale undo source", width=8, height=8, alpha=True)
|
||||
source.pixels.foreach_set(np.ones(8*8*4, dtype=np.float32))
|
||||
source.pack()
|
||||
owner = bpy.data.objects.new("Upscale undo owner", None)
|
||||
owner.empty_display_type = "IMAGE"
|
||||
owner.data = source
|
||||
bpy.context.scene.collection.objects.link(owner)
|
||||
owner_name, source_name = owner.name, source.name
|
||||
before = image_pixels(source)
|
||||
Image.new("RGBA", (16,16), (32,64,96,128)).save(tmp_path / "upscale.png")
|
||||
|
||||
def execute(operator, context):
|
||||
operator._image_target = ImageEditTarget.capture(SimpleNamespace(object=owner))
|
||||
operator.model = "HAT_GAN_X4_SHARPER"
|
||||
operator.response(context, SimpleNamespace(file=lambda key: tmp_path / f"{key}.png"))
|
||||
return {"FINISHED"}
|
||||
|
||||
bpy.context.preferences.edit.use_global_undo = True
|
||||
with patch.object(UpscaleImage, "execute", execute), patch.object(
|
||||
UpscaleImage, "poll", classmethod(lambda cls, context: True)
|
||||
):
|
||||
bpy.utils.register_class(UpscaleImage)
|
||||
try:
|
||||
bpy.ops.ed.undo_push(message="Before Upscale")
|
||||
assert bpy.ops.anyimage.upscale_image("EXEC_DEFAULT", True) == {"FINISHED"}
|
||||
assert tuple(bpy.data.objects[owner_name].data.size) == (16,16)
|
||||
assert bpy.ops.ed.undo() == {"FINISHED"}
|
||||
restored = bpy.data.objects[owner_name].data
|
||||
assert tuple(restored.size) == (8,8)
|
||||
np.testing.assert_array_equal(image_pixels(restored), before)
|
||||
finally:
|
||||
bpy.utils.unregister_class(UpscaleImage)
|
||||
bpy.data.objects.remove(bpy.data.objects[owner_name], do_unlink=True)
|
||||
bpy.data.images.remove(bpy.data.images[source_name])
|
||||
0
tests/anyimage/server/__init__.py
Normal file
0
tests/anyimage/server/__init__.py
Normal file
51
tests/anyimage/server/runtime_support.py
Normal file
51
tests/anyimage/server/runtime_support.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import importlib
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ServerTestCase(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.server_module = importlib.import_module("server")
|
||||
importlib.import_module("server.geometry.prediction_artifacts")
|
||||
importlib.import_module("server.jobs.cutout")
|
||||
from blendjob import JobServer
|
||||
from server.model_manager import ModelManager, ModelSessionCache
|
||||
from server.models import onnx_runtime
|
||||
|
||||
cls.JobServer = JobServer
|
||||
|
||||
cls.ModelManager, cls.ModelSessionCache = ModelManager, ModelSessionCache
|
||||
cls.onnx_runtime = onnx_runtime
|
||||
cls.app_module = importlib.import_module("server.app")
|
||||
|
||||
|
||||
def setUp(self):
|
||||
self.jobs_temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.jobs_temp.cleanup)
|
||||
self.storage_root = Path(self.jobs_temp.name)
|
||||
|
||||
|
||||
def create_cache(self):
|
||||
return self.ModelSessionCache()
|
||||
|
||||
|
||||
def create_server(self):
|
||||
source = self.server_module.server
|
||||
server = self.JobServer("AnyImage Job Server", storage_root=self.storage_root)
|
||||
self.addCleanup(server.close)
|
||||
server.handlers.update(source.handlers)
|
||||
server.resource_factories.update(source.resource_factories)
|
||||
server.bind(self.storage_root)
|
||||
return server
|
||||
|
||||
|
||||
def wait_for_job(self, context, timeout=2.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if context._snapshot()["state"] in {"succeeded", "failed", "cancelled"}:
|
||||
return context._snapshot()
|
||||
time.sleep(0.01)
|
||||
self.fail(f"Job did not finish: {context._snapshot()}")
|
||||
56
tests/anyimage/server/support.py
Normal file
56
tests/anyimage/server/support.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_server_module(name):
|
||||
addon_directory = PROJECT_ROOT / "src" / "anyimage"
|
||||
sys.path.insert(0, str(addon_directory))
|
||||
try:
|
||||
return __import__(f"server.{name}", fromlist=[name])
|
||||
finally:
|
||||
sys.path.remove(str(addon_directory))
|
||||
|
||||
|
||||
class FakeModels:
|
||||
def __init__(self, directory="BEN2-ONNX"):
|
||||
self.model_directory = Path(directory)
|
||||
|
||||
def directory(self, _model):
|
||||
return self.model_directory
|
||||
|
||||
def get_background(self, _model_directory, _device):
|
||||
from server.models.onnx_ben2 import create_session
|
||||
|
||||
return create_session(self.model_directory, "cpu"), 0.0
|
||||
|
||||
|
||||
def get_upscale(self, _model_directory, _device):
|
||||
return object()
|
||||
|
||||
|
||||
class FakeJobContext:
|
||||
def __init__(self, directory, job_type, models=None):
|
||||
self.directory = Path(directory)
|
||||
self.job_type = job_type
|
||||
self.models = models or FakeModels()
|
||||
self.status = {}
|
||||
self.logs = []
|
||||
|
||||
def resource(self, name):
|
||||
if name != "model_manager":
|
||||
raise KeyError(name)
|
||||
return self.models
|
||||
|
||||
def progress(self, progress, message, **details):
|
||||
self.status = {
|
||||
"progress": progress,
|
||||
"message": message,
|
||||
**details,
|
||||
}
|
||||
|
||||
def log(self, message, *, level="INFO"):
|
||||
self.logs.append((level, message))
|
||||
|
||||
def check_cancelled(self):
|
||||
return None
|
||||
74
tests/anyimage/server/test_input_validation.py
Normal file
74
tests/anyimage/server/test_input_validation.py
Normal file
@@ -0,0 +1,74 @@
|
||||
|
||||
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", ".JPG", ".webp", ".jpeg"])
|
||||
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,kind", [(".png", "image"), (".mp4", "image")])
|
||||
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_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
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".mp4", ".mts", ".gif", ".tiff", ""])
|
||||
def test_unsupported_formats_are_rejected(media, tmp_path, suffix):
|
||||
path = tmp_path / f"input{suffix}"
|
||||
path.touch()
|
||||
with pytest.raises(RuntimeError, match="Unsupported input format"):
|
||||
media.validate_input_path(path)
|
||||
|
||||
|
||||
def test_frame_directory_is_rejected(media, tmp_path):
|
||||
Image.new("RGB", (2, 2)).save(tmp_path / "frame.png")
|
||||
with pytest.raises(RuntimeError, match="Source image file does not exist"):
|
||||
media.validate_input_path(tmp_path)
|
||||
|
||||
|
||||
def test_animated_png_is_rejected(media, tmp_path):
|
||||
path = tmp_path / "animated.png"
|
||||
Image.new("RGB", (2, 2), "red").save(
|
||||
path, save_all=True, append_images=[Image.new("RGB", (2, 2), "blue")], duration=100,
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Only still images"):
|
||||
with media.open_image(path):
|
||||
pytest.fail("Animated images must not reach processing")
|
||||
|
||||
|
||||
def test_ai_tasks_support_cooperative_cancellation(media):
|
||||
with pytest.raises(media.TaskCancelled):
|
||||
media.check_cancelled(lambda: True)
|
||||
media.check_cancelled(lambda: False)
|
||||
173
tests/anyimage/server/test_job_server.py
Normal file
173
tests/anyimage/server/test_job_server.py
Normal file
@@ -0,0 +1,173 @@
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from tests.anyimage.server.runtime_support import ServerTestCase
|
||||
|
||||
class JobServerTest(ServerTestCase):
|
||||
def test_dedicated_server_runs_only_one_job_at_a_time(self):
|
||||
server = self.JobServer("Test", storage_root=self.storage_root)
|
||||
self.addCleanup(server.close)
|
||||
first_started = threading.Event()
|
||||
release_first = threading.Event()
|
||||
order = []
|
||||
|
||||
@server.job("example")
|
||||
def example(_context, parameters):
|
||||
order.append(parameters["name"])
|
||||
if parameters["name"] == "first":
|
||||
first_started.set()
|
||||
release_first.wait(1.0)
|
||||
|
||||
first = server.submit("example", {"name": "first"}, job_id="job")
|
||||
self.assertTrue(first_started.wait(1.0))
|
||||
second = server.submit("example", {"name": "second"}, job_id="other")
|
||||
|
||||
self.assertIsNotNone(first)
|
||||
self.assertIsNotNone(second)
|
||||
self.assertTrue(server.snapshot("instance")["busy"])
|
||||
self.assertEqual(server.snapshot("instance")["queued_jobs"], 1)
|
||||
release_first.set()
|
||||
self.wait_for_job(first)
|
||||
self.wait_for_job(second)
|
||||
self.assertEqual(order, ["first", "second"])
|
||||
|
||||
|
||||
def test_queued_job_can_be_cancelled_before_it_runs(self):
|
||||
server = self.JobServer("Test", storage_root=self.storage_root)
|
||||
self.addCleanup(server.close)
|
||||
first_started = threading.Event()
|
||||
release_first = threading.Event()
|
||||
executed = []
|
||||
|
||||
@server.job("example")
|
||||
def example(_context, parameters):
|
||||
executed.append(parameters["name"])
|
||||
if parameters["name"] == "first":
|
||||
first_started.set()
|
||||
release_first.wait(1.0)
|
||||
|
||||
first = server.submit("example", {"name": "first"})
|
||||
self.assertTrue(first_started.wait(1.0))
|
||||
second = server.submit("example", {"name": "second"})
|
||||
|
||||
self.assertIs(server.cancel(second.job_id), second)
|
||||
release_first.set()
|
||||
self.wait_for_job(first)
|
||||
second_status = self.wait_for_job(second)
|
||||
|
||||
self.assertEqual(second_status["state"], "cancelled")
|
||||
self.assertEqual(executed, ["first"])
|
||||
|
||||
|
||||
def test_cancelling_job_keeps_server_and_model_cache_alive(self):
|
||||
fake_models = SimpleNamespace(
|
||||
snapshot=lambda: {"loaded": {"background": "BEN2_BASE", "geometry": ""}},
|
||||
clear=lambda: None,
|
||||
close=lambda: None,
|
||||
)
|
||||
with patch.object(self.app_module, "ModelManager", return_value=fake_models):
|
||||
server = self.create_server()
|
||||
started = threading.Event()
|
||||
|
||||
@server.job("cancellable")
|
||||
def cancellable(context, _parameters):
|
||||
started.set()
|
||||
while not context._is_cancelled():
|
||||
time.sleep(0.001)
|
||||
context.check_cancelled()
|
||||
|
||||
context = server.submit("cancellable", {}, job_id="job")
|
||||
self.assertTrue(started.wait(1.0))
|
||||
self.assertIsNotNone(server.cancel("job"))
|
||||
self.assertTrue(context._is_cancelled())
|
||||
status = self.wait_for_job(context)
|
||||
|
||||
self.assertEqual(status["state"], "cancelled")
|
||||
self.assertFalse(server.shutdown_event.is_set())
|
||||
self.assertEqual(
|
||||
server.snapshot("instance")["resources"]["model_manager"]["loaded"]["background"],
|
||||
"BEN2_BASE",
|
||||
)
|
||||
|
||||
|
||||
def test_job_progress_is_kept_in_server_memory(self):
|
||||
server = self.JobServer("Test", storage_root=self.storage_root)
|
||||
self.addCleanup(server.close)
|
||||
|
||||
@server.job("example")
|
||||
def example(context, _parameters):
|
||||
context.progress(0.5, "Half way")
|
||||
|
||||
context = server.submit("example", {})
|
||||
self.wait_for_job(context)
|
||||
|
||||
self.assertRegex(context.job_id, r"^[0-9a-f]{32}$")
|
||||
self.assertEqual(context.directory.name, context.job_id)
|
||||
self.assertEqual(context._snapshot()["state"], "succeeded")
|
||||
self.assertEqual(context._snapshot()["progress"], 1.0)
|
||||
|
||||
|
||||
def test_runtime_storage_root_owns_the_jobs_directory(self):
|
||||
storage_root = self.storage_root
|
||||
server = self.JobServer("Test")
|
||||
self.addCleanup(server.close)
|
||||
server.bind(storage_root)
|
||||
|
||||
@server.job("example")
|
||||
def example(_context, _parameters):
|
||||
return {"value": 42}
|
||||
|
||||
context = server.submit("example", {})
|
||||
self.wait_for_job(context)
|
||||
|
||||
self.assertEqual(server.jobs_directory, storage_root / "jobs")
|
||||
self.assertEqual(context.storage_root, storage_root.resolve())
|
||||
self.assertEqual(context._snapshot()["result"], {"value": 42})
|
||||
|
||||
|
||||
def test_job_server_owns_and_clears_shared_resources(self):
|
||||
events = []
|
||||
resource = SimpleNamespace(
|
||||
snapshot=lambda: {"loaded": "model"},
|
||||
clear=lambda: events.append("clear"),
|
||||
close=lambda: events.append("close"),
|
||||
)
|
||||
server = self.JobServer("Test", storage_root=self.storage_root)
|
||||
self.addCleanup(server.close)
|
||||
server.add_resource("model_manager", resource)
|
||||
|
||||
@server.job("example")
|
||||
def example(context, _parameters):
|
||||
self.assertIs(context.resource("model_manager"), resource)
|
||||
release.wait(1.0)
|
||||
|
||||
release = threading.Event()
|
||||
context = server.submit("example", {}, job_id="job")
|
||||
self.assertFalse(server.clear_resource("model_manager"))
|
||||
release.set()
|
||||
self.wait_for_job(context)
|
||||
self.assertTrue(server.clear_resource("model_manager"))
|
||||
server.close()
|
||||
|
||||
self.assertEqual(
|
||||
server.snapshot("instance")["resources"]["model_manager"]["loaded"],
|
||||
"model",
|
||||
)
|
||||
self.assertEqual(events, ["clear", "close"])
|
||||
|
||||
|
||||
def test_server_registers_tasks_instead_of_model_names(self):
|
||||
server = self.create_server()
|
||||
self.assertEqual(
|
||||
set(server.handlers),
|
||||
{
|
||||
"download-model",
|
||||
"download-required-models",
|
||||
"remove-background",
|
||||
"upscale-image",
|
||||
"generate-depth-plane-geometry",
|
||||
"generate-panorama-geometry",
|
||||
"generate-cutout-artifacts",
|
||||
},
|
||||
)
|
||||
170
tests/anyimage/server/test_model_download.py
Normal file
170
tests/anyimage/server/test_model_download.py
Normal file
@@ -0,0 +1,170 @@
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def load_download_module():
|
||||
addon_directory = PROJECT_ROOT / "src" / "anyimage"
|
||||
sys.path.insert(0, str(addon_directory))
|
||||
try:
|
||||
return __import__(
|
||||
"server.model_download",
|
||||
fromlist=["model_download"],
|
||||
)
|
||||
finally:
|
||||
sys.path.remove(str(addon_directory))
|
||||
|
||||
|
||||
class FakeTqdm:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.n = kwargs.get("initial", 0)
|
||||
self.total = kwargs.get("total", 0)
|
||||
|
||||
def update(self, amount=1):
|
||||
self.n += amount
|
||||
return True
|
||||
|
||||
def refresh(self, *args, **kwargs):
|
||||
return True
|
||||
|
||||
|
||||
class ModelDownloadTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.download = load_download_module()
|
||||
|
||||
def create_progress(self, description="Downloading bytes", **options):
|
||||
reports = []
|
||||
progress_class = self.download.create_download_progress_class(
|
||||
lambda value, message: reports.append((value, message)),
|
||||
"owner/model",
|
||||
tqdm_type=options.pop("tqdm_type", FakeTqdm),
|
||||
clock=options.pop("clock", lambda: 1.0),
|
||||
**options,
|
||||
)
|
||||
progress = progress_class(
|
||||
desc=description,
|
||||
total=100,
|
||||
initial=0,
|
||||
unit="B",
|
||||
)
|
||||
return progress, reports
|
||||
|
||||
def test_https_connection_bounds_connect_and_idle_read_time(self):
|
||||
connect_timeouts = []
|
||||
read_timeouts = []
|
||||
|
||||
def connect(connection):
|
||||
connect_timeouts.append(connection.timeout)
|
||||
connection.sock = SimpleNamespace(
|
||||
settimeout=read_timeouts.append,
|
||||
)
|
||||
|
||||
connection = self.download._ShortConnectHTTPSConnection("example.com")
|
||||
with patch.object(
|
||||
self.download.http.client.HTTPSConnection,
|
||||
"connect",
|
||||
new=connect,
|
||||
):
|
||||
connection.connect()
|
||||
|
||||
self.assertEqual(connect_timeouts, [10.0])
|
||||
self.assertEqual(read_timeouts, [60.0])
|
||||
self.assertIsNone(connection.timeout)
|
||||
|
||||
def test_reports_aggregated_download_bytes(self):
|
||||
progress, reports = self.create_progress()
|
||||
|
||||
progress.update(25)
|
||||
|
||||
self.assertAlmostEqual(reports[-1][0], 0.275)
|
||||
self.assertEqual(reports[-1][1], "Downloading owner/model")
|
||||
|
||||
def test_reports_hugging_face_aggregated_bar(self):
|
||||
progress, reports = self.create_progress(
|
||||
"Downloading (incomplete total...)"
|
||||
)
|
||||
|
||||
progress.update(25)
|
||||
|
||||
self.assertAlmostEqual(reports[-1][0], 0.275)
|
||||
|
||||
def test_reports_download_speed(self):
|
||||
class FakeTqdmWithRate(FakeTqdm):
|
||||
@property
|
||||
def format_dict(self):
|
||||
return {"rate": 14_000_000}
|
||||
|
||||
progress, reports = self.create_progress(
|
||||
tqdm_type=FakeTqdmWithRate,
|
||||
label="MoGe-2",
|
||||
)
|
||||
|
||||
progress.update(50)
|
||||
|
||||
self.assertEqual(
|
||||
reports[-1][1],
|
||||
"Downloading MoGe-2 13.4 MB/s",
|
||||
)
|
||||
|
||||
def test_progress_callback_has_no_eta_or_state_protocol(self):
|
||||
times = iter((0.0, 10.0))
|
||||
progress, reports = self.create_progress(clock=lambda: next(times))
|
||||
|
||||
progress.update(50)
|
||||
|
||||
self.assertEqual(len(reports[-1]), 2)
|
||||
|
||||
def test_ignores_unrelated_progress_bars(self):
|
||||
progress, reports = self.create_progress("Reconstructing")
|
||||
|
||||
progress.update(25)
|
||||
|
||||
self.assertEqual(reports, [])
|
||||
|
||||
def test_download_endpoints_uses_direct_endpoint(self):
|
||||
with patch.dict(os.environ, {"HF_ENDPOINT": ""}):
|
||||
endpoints = self.download.download_endpoints()
|
||||
|
||||
self.assertEqual(endpoints, ["https://huggingface.co"])
|
||||
|
||||
def test_download_endpoints_respects_environment_override(self):
|
||||
with patch.dict(os.environ, {"HF_ENDPOINT": "https://hf.example"}):
|
||||
endpoints = self.download.download_endpoints()
|
||||
|
||||
self.assertEqual(endpoints, ["https://hf.example"])
|
||||
|
||||
def test_download_endpoints_strips_trailing_slash(self):
|
||||
with patch.dict(os.environ, {"HF_ENDPOINT": "https://hf.example/"}):
|
||||
endpoints = self.download.download_endpoints()
|
||||
|
||||
self.assertEqual(endpoints, ["https://hf.example"])
|
||||
|
||||
def test_declared_download_skips_hf_when_no_public_onnx_exists(self):
|
||||
args = SimpleNamespace(
|
||||
repository="",
|
||||
progress=lambda *_args: None,
|
||||
cancel_check=lambda: None,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(
|
||||
self.download, "download_from_hf"
|
||||
) as hugging_face, patch.object(
|
||||
self.download, "download_from_r2"
|
||||
) as mirror, patch.object(
|
||||
self.download, "_verify_model_complete"
|
||||
):
|
||||
self.download._download_declared_or_fail(
|
||||
args, Path(directory), "Model", 0.05, 0.90
|
||||
)
|
||||
|
||||
hugging_face.assert_not_called()
|
||||
mirror.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
154
tests/anyimage/server/test_model_download_jobs.py
Normal file
154
tests/anyimage/server/test_model_download_jobs.py
Normal file
@@ -0,0 +1,154 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from tests.anyimage.server.support import load_server_module
|
||||
|
||||
|
||||
class ModelDownloadJobsTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.download = load_server_module("model_download")
|
||||
|
||||
|
||||
def test_download_model_uses_actual_request_without_preflight(self):
|
||||
calls = []
|
||||
fake_hugging_face = ModuleType("huggingface_hub")
|
||||
fake_hugging_face.snapshot_download = lambda **options: calls.append(
|
||||
options
|
||||
)
|
||||
args = SimpleNamespace(
|
||||
repository="Ruicheng/moge-2-vits-normal-onnx",
|
||||
model_dir="model",
|
||||
progress=lambda *_args, **_kwargs: None,
|
||||
allow_pattern=[],
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(
|
||||
sys.modules,
|
||||
{"huggingface_hub": fake_hugging_face},
|
||||
), patch.object(
|
||||
self.download,
|
||||
"create_download_progress_class",
|
||||
return_value=object,
|
||||
), patch.dict(os.environ, {"HF_ENDPOINT": ""}), patch.object(
|
||||
self.download,
|
||||
"_verify_model_complete",
|
||||
return_value=None,
|
||||
):
|
||||
args.model_dir = str(Path(directory) / "models" / "MoGe2")
|
||||
self.download.download_model(args)
|
||||
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
|
||||
def test_download_model_refuses_success_when_files_missing(self):
|
||||
fake_hugging_face = ModuleType("huggingface_hub")
|
||||
fake_hugging_face.snapshot_download = lambda **options: None
|
||||
args = SimpleNamespace(
|
||||
repository="PramaLLC/BEN2",
|
||||
model_dir="model",
|
||||
progress=lambda *_args, **_kwargs: None,
|
||||
allow_pattern=[],
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(
|
||||
sys.modules,
|
||||
{"huggingface_hub": fake_hugging_face},
|
||||
), patch.object(
|
||||
self.download,
|
||||
"create_download_progress_class",
|
||||
return_value=object,
|
||||
), patch.dict(os.environ, {"HF_ENDPOINT": ""}):
|
||||
args.model_dir = str(Path(directory) / "model")
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"All download sources failed",
|
||||
):
|
||||
self.download.download_model(args)
|
||||
|
||||
|
||||
def test_download_model_reports_all_failed_sources(self):
|
||||
args = SimpleNamespace(
|
||||
repository="Ruicheng/moge-2-vits-normal-onnx",
|
||||
model_dir="model",
|
||||
progress=lambda *_args, **_kwargs: None,
|
||||
allow_pattern=[],
|
||||
r2_directory="moge-2-vits-normal-onnx",
|
||||
r2_file=["config.json|100|abc"],
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(
|
||||
self.download,
|
||||
"download_from_hf",
|
||||
side_effect=OSError("direct down"),
|
||||
), patch.object(
|
||||
self.download,
|
||||
"download_from_r2",
|
||||
side_effect=OSError("mirror down"),
|
||||
):
|
||||
args.model_dir = str(Path(directory) / "model")
|
||||
with self.assertRaisesRegex(RuntimeError, "models.omoolab.xyz"):
|
||||
self.download.download_model(args)
|
||||
|
||||
|
||||
def test_download_model_prefers_direct_hf_then_r2(self):
|
||||
args = SimpleNamespace(
|
||||
repository="Ruicheng/moge-2-vits-normal-onnx",
|
||||
model_dir="model",
|
||||
progress=lambda *_args, **_kwargs: None,
|
||||
allow_pattern=["config.json"],
|
||||
r2_directory="moge-2-vits-normal-onnx",
|
||||
r2_file=["config.json|100|abc"],
|
||||
)
|
||||
order = []
|
||||
|
||||
def fake_download_from_hf(*_args, **_kwargs):
|
||||
order.append("hf")
|
||||
raise OSError("hf down")
|
||||
|
||||
def fake_download_from_r2(*_args, **_kwargs):
|
||||
order.append("r2")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(
|
||||
self.download,
|
||||
"download_from_hf",
|
||||
side_effect=fake_download_from_hf,
|
||||
), patch.object(
|
||||
self.download,
|
||||
"download_from_r2",
|
||||
side_effect=fake_download_from_r2,
|
||||
), patch.object(
|
||||
self.download,
|
||||
"_verify_model_complete",
|
||||
return_value=None,
|
||||
):
|
||||
args.model_dir = str(Path(directory) / "models" / "MoGe2")
|
||||
self.download.download_model(args)
|
||||
|
||||
self.assertEqual(order, ["hf", "r2"])
|
||||
|
||||
|
||||
def test_download_from_r2_downloads_declared_files(self):
|
||||
args = SimpleNamespace(
|
||||
repository="Ruicheng/moge-2-vits-normal-onnx",
|
||||
model_dir="model",
|
||||
progress=lambda *_args, **_kwargs: None,
|
||||
allow_pattern=["config.json"],
|
||||
r2_directory="moge-2-vits-normal-onnx",
|
||||
r2_file=["config.json|100|abc"],
|
||||
)
|
||||
downloaded = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(
|
||||
self.download,
|
||||
"_download_file_from_url",
|
||||
side_effect=lambda *args, **kwargs: downloaded.append(args[1].name),
|
||||
):
|
||||
args.model_dir = str(Path(directory) / "model")
|
||||
self.download.download_from_r2(args, args.model_dir)
|
||||
|
||||
self.assertEqual(downloaded, ["config.json"])
|
||||
192
tests/anyimage/test_addon_registration.py
Normal file
192
tests/anyimage/test_addon_registration.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class AddonRegistrationTest(BlenderTestCase):
|
||||
def test_import_does_not_add_addon_directories_to_sys_path(self):
|
||||
self.assertEqual(self.paths_after_import, self.paths_before_import)
|
||||
|
||||
|
||||
def test_operator_properties_use_plain_names(self):
|
||||
operators = self.anyimage.operators
|
||||
for operator in operators.CLASSES:
|
||||
for name in getattr(operator, "__annotations__", {}):
|
||||
self.assertFalse(name.startswith("anyimage_"), (operator, name))
|
||||
|
||||
|
||||
def test_mesh_operators_are_registered_and_ordered(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)
|
||||
self.assertIn(self.convert_to_plane.ConvertToReliefPlane, self.anyimage.CLASSES)
|
||||
self.assertIn(self.cutout_main.CutoutSelectionToShape, self.anyimage.CLASSES)
|
||||
self.assertEqual(operator.bl_idname, "anyimage.convert_to_plane")
|
||||
self.assertIn("REGISTER", operator.bl_options)
|
||||
self.assertIn("UNDO", operator.bl_options)
|
||||
|
||||
image_empty = SimpleNamespace(
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=object(),
|
||||
)
|
||||
image_plane = SimpleNamespace(
|
||||
type="MESH",
|
||||
modifiers=[
|
||||
SimpleNamespace(node_group=SimpleNamespace(name="O Image Plane"))
|
||||
],
|
||||
)
|
||||
plain_mesh = SimpleNamespace(type="MESH", modifiers=[])
|
||||
self.assertTrue(operator.poll(SimpleNamespace(object=image_empty)))
|
||||
self.assertFalse(operator.poll(SimpleNamespace(object=image_plane)))
|
||||
self.assertFalse(operator.poll(SimpleNamespace(object=plain_mesh)))
|
||||
|
||||
|
||||
def test_image_edit_tools_and_operators_are_registered(self):
|
||||
operator = self.edit_mask.EditImageAlpha
|
||||
frame_operator = self.image_frame.FrameImages
|
||||
self.assertIn(operator, self.anyimage.CLASSES)
|
||||
self.assertIn(frame_operator, self.anyimage.CLASSES)
|
||||
self.assertIn(
|
||||
self.rectify.RectifyImagePerspective,
|
||||
self.anyimage.CLASSES,
|
||||
)
|
||||
mask_tool = self.edit_mask.MaskTool
|
||||
self.assertNotIn(mask_tool, self.anyimage.CLASSES)
|
||||
self.assertEqual(mask_tool.bl_operator, operator.bl_idname)
|
||||
self.assertEqual(operator.bl_options, {"UNDO"})
|
||||
frame_tool = self.image_frame.FrameTool
|
||||
self.assertNotIn(frame_tool, self.anyimage.CLASSES)
|
||||
self.assertEqual(frame_tool.bl_operator, frame_operator.bl_idname)
|
||||
self.assertEqual(frame_operator.bl_options, {"UNDO"})
|
||||
rectify_tool = self.rectify.RectifyTool
|
||||
self.assertNotIn(rectify_tool, self.anyimage.CLASSES)
|
||||
self.assertEqual(
|
||||
rectify_tool.bl_operator,
|
||||
self.rectify.RectifyImagePerspective.bl_idname,
|
||||
)
|
||||
|
||||
def test_mask_tool_settings_follow_the_active_gesture(self):
|
||||
mask_tool = self.edit_mask.MaskTool
|
||||
settings_value = SimpleNamespace(
|
||||
mask_gesture="LASSO",
|
||||
mask_mode="SET",
|
||||
mask_radius=25,
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
scene=SimpleNamespace(anyimage_settings=settings_value)
|
||||
)
|
||||
drawn_settings = []
|
||||
layout = SimpleNamespace(
|
||||
prop=lambda _settings, name, **_options: drawn_settings.append(name)
|
||||
)
|
||||
mask_tool.draw_settings(context, layout, None)
|
||||
self.assertEqual(drawn_settings, ["mask_gesture", "mask_mode"])
|
||||
settings_value.mask_gesture = "BRUSH"
|
||||
drawn_settings.clear()
|
||||
mask_tool.draw_settings(context, layout, None)
|
||||
self.assertEqual(
|
||||
drawn_settings,
|
||||
["mask_gesture", "mask_mode", "mask_radius"],
|
||||
)
|
||||
settings_value.mask_gesture = "POLYLINE"
|
||||
drawn_settings.clear()
|
||||
mask_tool.draw_settings(context, layout, None)
|
||||
self.assertEqual(drawn_settings, ["mask_gesture", "mask_mode"])
|
||||
|
||||
|
||||
def test_registration_and_reverse_cleanup_preserve_other_addons(self):
|
||||
self.assertIn(
|
||||
self.anyimage.clipboard_image.PasteClipboardImage,
|
||||
self.anyimage.CLASSES,
|
||||
)
|
||||
self.assertIn(
|
||||
self.anyimage.clipboard_image.TrackNativeCopy,
|
||||
self.anyimage.CLASSES,
|
||||
)
|
||||
existing_context_draw = object()
|
||||
existing_outliner_draw = object()
|
||||
existing_node_draw = object()
|
||||
self.context_menu_draws.append(existing_context_draw)
|
||||
self.outliner_menu_draws.append(existing_outliner_draw)
|
||||
self.node_menu_draws.append(existing_node_draw)
|
||||
|
||||
self.anyimage.register()
|
||||
system_classes = self.anyimage.runtime.operator_classes()
|
||||
|
||||
self.assertTrue(set(system_classes) <= set(self.registered))
|
||||
self.assertEqual(
|
||||
self.registered_tools,
|
||||
[
|
||||
(
|
||||
self.image_frame.FrameTool,
|
||||
{
|
||||
"group": True,
|
||||
"separator": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
self.edit_mask.MaskTool,
|
||||
{
|
||||
"after": {self.image_frame.FrameTool.bl_idname},
|
||||
"separator": False,
|
||||
},
|
||||
),
|
||||
(
|
||||
self.rectify.RectifyTool,
|
||||
{
|
||||
"after": {self.edit_mask.MaskTool.bl_idname},
|
||||
"separator": False,
|
||||
},
|
||||
),
|
||||
(
|
||||
self.anyimage.CutoutTool,
|
||||
{},
|
||||
),
|
||||
],
|
||||
)
|
||||
self.assertEqual(len(self.status_bar_draws), 1)
|
||||
self.assertEqual(self.status_bar_draws[0]._owner, "anyimage")
|
||||
self.assertIs(
|
||||
self.context_menu_draws[0],
|
||||
self.anyimage.draw_image_context_menu,
|
||||
)
|
||||
self.assertIs(
|
||||
self.outliner_menu_draws[0],
|
||||
self.anyimage.draw_image_context_menu,
|
||||
)
|
||||
|
||||
self.assertEqual(self.node_menu_draws, [self.anyimage.draw_texture_node_context_menu, existing_node_draw])
|
||||
self.assertIn(self.anyimage.AnyImageTextureNodeMenu, self.registered)
|
||||
|
||||
unregister_order = []
|
||||
original_unregister = self.fake_bpy.utils.unregister_class
|
||||
def unregister(cls):
|
||||
unregister_order.append(cls)
|
||||
original_unregister(cls)
|
||||
with patch.object(self.fake_bpy.utils, "unregister_class", side_effect=unregister):
|
||||
self.anyimage.unregister()
|
||||
self.assertEqual(
|
||||
[cls for cls in unregister_order if cls in self.anyimage.CLASSES],
|
||||
list(reversed(self.anyimage.CLASSES)),
|
||||
)
|
||||
self.assertEqual(self.registered, [])
|
||||
self.assertEqual(self.registered_tools, [])
|
||||
self.assertEqual(self.status_bar_draws, [])
|
||||
self.assertEqual(self.context_menu_draws, [existing_context_draw])
|
||||
self.assertEqual(self.outliner_menu_draws, [existing_outliner_draw])
|
||||
self.assertEqual(self.node_menu_draws, [existing_node_draw])
|
||||
self.anyimage.register()
|
||||
self.assertEqual(self.node_menu_draws, [self.anyimage.draw_texture_node_context_menu, existing_node_draw])
|
||||
self.anyimage.unregister()
|
||||
self.assertEqual(self.node_menu_draws, [existing_node_draw])
|
||||
98
tests/anyimage/test_image_menu.py
Normal file
98
tests/anyimage/test_image_menu.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class ImageMenuTest(BlenderTestCase):
|
||||
def test_image_menu_keeps_ai_features_clickable_and_offers_models(self):
|
||||
calls, labels = self.draw_menu(
|
||||
{
|
||||
"environment_ready": True,
|
||||
"missing_models": ("BEN2_BASE",),
|
||||
"ready": False,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
("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),
|
||||
("separator", True),
|
||||
("anyimage.setup_ai_environment", True),
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
labels["anyimage.setup_ai_environment"],
|
||||
"Download Required Models…",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
labels["anyimage.upscale_image"],
|
||||
"Upscale (1280 × 960)",
|
||||
)
|
||||
|
||||
calls, _labels = self.draw_menu(
|
||||
{"environment_ready": True, "missing_models": (), "ready": True}
|
||||
)
|
||||
self.assertIn(("anyimage.open_ai_environment_settings", True), calls)
|
||||
self.assertNotIn(("anyimage.setup_ai_environment", True), calls)
|
||||
|
||||
def draw_menu(self, status):
|
||||
calls = []
|
||||
labels = {}
|
||||
|
||||
class Layout:
|
||||
operator_context = ""
|
||||
enabled = True
|
||||
|
||||
def operator(self, identifier, **options):
|
||||
calls.append((identifier, self.enabled))
|
||||
labels[identifier] = options.get("text")
|
||||
return SimpleNamespace()
|
||||
|
||||
def separator(self):
|
||||
calls.append(("separator", self.enabled))
|
||||
|
||||
menu = self.anyimage.AnyImageImageMenu()
|
||||
menu.layout = Layout()
|
||||
with patch("anyimage.properties.ai_status", return_value=status):
|
||||
menu.draw(
|
||||
SimpleNamespace(
|
||||
object=SimpleNamespace(
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=SimpleNamespace(size=(640, 480)),
|
||||
)
|
||||
)
|
||||
)
|
||||
return calls, labels
|
||||
|
||||
|
||||
def test_image_operators_stay_clickable_until_server_is_busy(self):
|
||||
context = SimpleNamespace(
|
||||
object=SimpleNamespace(type="EMPTY", empty_display_type="IMAGE", data=SimpleNamespace()),
|
||||
)
|
||||
operators = (
|
||||
self.convert_to_plane.ConvertToDepthPlane,
|
||||
self.convert_to_plane.ConvertToReliefPlane,
|
||||
self.remove_background.RemoveImageBackground,
|
||||
)
|
||||
|
||||
for busy, expected in ((False, True), (True, False)):
|
||||
with (
|
||||
self.subTest(busy=busy),
|
||||
patch.object(
|
||||
self.anyimage.runtime,
|
||||
"server_busy",
|
||||
return_value=busy,
|
||||
),
|
||||
):
|
||||
for operator in operators:
|
||||
with self.subTest(operator=operator.__name__):
|
||||
self.assertEqual(operator.poll(context), expected)
|
||||
136
tests/anyimage/test_job_operator.py
Normal file
136
tests/anyimage/test_job_operator.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class JobOperatorTest(BlenderTestCase):
|
||||
def test_runtime_submits_job_type_and_plain_dict(self):
|
||||
calls = []
|
||||
submitted = threading.Event()
|
||||
|
||||
class Controller:
|
||||
def submit(self, job_type, parameters):
|
||||
calls.append((job_type, parameters))
|
||||
submitted.set()
|
||||
return {"job_id": "job", "directory": "."}
|
||||
|
||||
def cancel(self, _job_id):
|
||||
pass
|
||||
|
||||
def mark_job_complete(self, _job_id):
|
||||
pass
|
||||
|
||||
class Example(self.runtime.JobOperatorBase):
|
||||
job_type = "example"
|
||||
value: object()
|
||||
|
||||
def controller(self, _runtime):
|
||||
return Controller()
|
||||
|
||||
timers = []
|
||||
window_manager = SimpleNamespace(
|
||||
progress_begin=lambda *_args: None,
|
||||
progress_update=lambda _value: None,
|
||||
event_timer_add=lambda *_args, **_kwargs: (
|
||||
timers.append(object()) or timers[-1]
|
||||
),
|
||||
event_timer_remove=lambda _timer: None,
|
||||
modal_handler_add=lambda _operator: None,
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
window_manager=window_manager,
|
||||
window=object(),
|
||||
)
|
||||
operator = Example()
|
||||
operator.value = 9
|
||||
|
||||
self.assertEqual(operator.execute(context), {"RUNNING_MODAL"})
|
||||
self.assertTrue(submitted.wait(1.0))
|
||||
self.assertEqual(calls, [("example", {"value": 9})])
|
||||
|
||||
|
||||
def test_runtime_owns_environment_and_storage_paths(self):
|
||||
runtime = self.anyimage.runtime
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
with patch.object(runtime, "storage_root_factory", root):
|
||||
self.assertEqual(runtime.storage_root(), root.resolve())
|
||||
self.assertEqual(runtime.environment_directory(), root / ".venv")
|
||||
self.assertEqual(
|
||||
runtime.install_status_path(), root / "install-status.json"
|
||||
)
|
||||
self.assertEqual(runtime.install_log_path(), root / "install.log")
|
||||
self.assertEqual(runtime.server_log_path(), root / "server.log")
|
||||
|
||||
|
||||
def test_runtime_checks_its_environment_dictionary_hash(self):
|
||||
runtime = self.anyimage.runtime
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
python = (
|
||||
root
|
||||
/ ".venv"
|
||||
/ ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
|
||||
)
|
||||
python.parent.mkdir(parents=True)
|
||||
python.touch()
|
||||
(root / "manifest.json").write_text(
|
||||
json.dumps({"environment_hash": runtime.environment_hash}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch.object(runtime, "storage_root_factory", root):
|
||||
self.assertTrue(runtime.environment_ready())
|
||||
|
||||
|
||||
def test_runtime_builds_the_standard_installer_command(self):
|
||||
runtime = self.anyimage.runtime
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
with patch.object(runtime, "storage_root_factory", root):
|
||||
command = runtime.install_command()
|
||||
self.assertEqual(command[0], sys.executable)
|
||||
self.assertEqual(Path(command[1]).name, "environment.py")
|
||||
self.assertEqual(Path(command[1]).parent.name, "installer")
|
||||
self.assertIn("--storage-root", command)
|
||||
self.assertIn("--config", command)
|
||||
|
||||
|
||||
def test_server_request_returns_job_result_and_calls_response(self):
|
||||
controller_type = importlib.import_module(
|
||||
"blendjob.controller"
|
||||
).ServerController
|
||||
controller = controller_type.__new__(controller_type)
|
||||
statuses = iter(
|
||||
(
|
||||
{"state": "running", "progress": 0.5},
|
||||
{
|
||||
"state": "succeeded",
|
||||
"progress": 1.0,
|
||||
"result": {"value": 7},
|
||||
},
|
||||
)
|
||||
)
|
||||
controller.submit = lambda _job_type, _parameters: {
|
||||
"job_id": "job",
|
||||
"directory": ".",
|
||||
}
|
||||
controller.status = lambda _job_id: next(statuses)
|
||||
controller.mark_job_complete = lambda _job_id: None
|
||||
responses = []
|
||||
|
||||
result = controller.request(
|
||||
"example",
|
||||
{"input": 3},
|
||||
response=responses.append,
|
||||
poll_interval=0.01,
|
||||
)
|
||||
|
||||
self.assertEqual(result.value, {"value": 7})
|
||||
self.assertEqual(responses, [result])
|
||||
|
||||
289
tests/anyimage/test_preferences.py
Normal file
289
tests/anyimage/test_preferences.py
Normal file
@@ -0,0 +1,289 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class RecordingLayout:
|
||||
"""Minimal Blender layout that records the properties and operators drawn."""
|
||||
|
||||
enabled = True
|
||||
|
||||
def __init__(self, operators):
|
||||
self.properties = []
|
||||
self.operators = operators
|
||||
|
||||
def prop(self, _owner, name, **_options):
|
||||
self.properties.append(name)
|
||||
|
||||
def operator(self, identifier, **_options):
|
||||
operator = SimpleNamespace(model="")
|
||||
self.operators.append((identifier, operator))
|
||||
return operator
|
||||
|
||||
def box(self):
|
||||
return self
|
||||
|
||||
def row(self, **_options):
|
||||
return self
|
||||
|
||||
def split(self, **_options):
|
||||
return self
|
||||
|
||||
def column(self, **_options):
|
||||
return self
|
||||
|
||||
def label(self, **_options):
|
||||
return None
|
||||
|
||||
def separator(self, **_options):
|
||||
return None
|
||||
|
||||
|
||||
class PreferencesTest(BlenderTestCase):
|
||||
def test_apple_silicon_architectures_support_coreml(self):
|
||||
properties = self.anyimage.properties
|
||||
for architecture in ("arm64", "aarch64"):
|
||||
with (
|
||||
self.subTest(architecture=architecture),
|
||||
patch.object(
|
||||
self.anyimage.runtime,
|
||||
"environment_manifest",
|
||||
return_value={},
|
||||
),
|
||||
patch.object(properties.sys, "platform", "darwin"),
|
||||
patch.object(
|
||||
properties.platform,
|
||||
"machine",
|
||||
return_value=architecture,
|
||||
),
|
||||
):
|
||||
self.assertEqual(
|
||||
properties.supported_devices(),
|
||||
["cpu", "coreml"],
|
||||
)
|
||||
|
||||
|
||||
def test_non_apple_silicon_mac_uses_cpu(self):
|
||||
properties = self.anyimage.properties
|
||||
with (
|
||||
patch.object(
|
||||
self.anyimage.runtime,
|
||||
"environment_manifest",
|
||||
return_value={},
|
||||
),
|
||||
patch.object(properties.sys, "platform", "darwin"),
|
||||
patch.object(
|
||||
properties.platform,
|
||||
"machine",
|
||||
return_value="x86_64",
|
||||
),
|
||||
):
|
||||
self.assertEqual(properties.supported_devices(), ["cpu"])
|
||||
|
||||
|
||||
def test_manifest_coreml_requires_apple_silicon(self):
|
||||
properties = self.anyimage.properties
|
||||
manifest = {"devices": ["coreml", "cpu"]}
|
||||
with (
|
||||
patch.object(
|
||||
self.anyimage.runtime,
|
||||
"environment_manifest",
|
||||
return_value=manifest,
|
||||
),
|
||||
patch.object(properties.sys, "platform", "linux"),
|
||||
):
|
||||
self.assertEqual(properties.supported_devices(), ["cpu"])
|
||||
with (
|
||||
patch.object(
|
||||
self.anyimage.runtime,
|
||||
"environment_manifest",
|
||||
return_value=manifest,
|
||||
),
|
||||
patch.object(properties.sys, "platform", "darwin"),
|
||||
patch.object(
|
||||
properties.platform,
|
||||
"machine",
|
||||
return_value="aarch64",
|
||||
),
|
||||
):
|
||||
self.assertEqual(
|
||||
properties.supported_devices(),
|
||||
["cpu", "coreml"],
|
||||
)
|
||||
|
||||
|
||||
def test_windows_keeps_default_directml_device(self):
|
||||
properties = self.anyimage.properties
|
||||
with (
|
||||
patch.object(
|
||||
self.anyimage.runtime,
|
||||
"environment_manifest",
|
||||
return_value={},
|
||||
),
|
||||
patch.object(properties.sys, "platform", "win32"),
|
||||
):
|
||||
self.assertEqual(
|
||||
properties.supported_devices(),
|
||||
["directml", "cpu"],
|
||||
)
|
||||
|
||||
|
||||
def test_model_preferences_keep_valid_choices_and_reset_unknown_keys(self):
|
||||
module = self.anyimage.preferences
|
||||
stored = self.fake_bpy.context.preferences.addons["anyimage"].preferences
|
||||
for attribute, reader, valid, default in (
|
||||
("geometry_model", module.configured_geometry_model, "MOGE3_VITL", "MOGE2_VITS_NORMAL"),
|
||||
("background_model", module.configured_background_model, "BEN2_BASE", "BIREFNET_LITE"),
|
||||
("upscale_model", module.configured_upscale_model, "REALESRGAN_X4PLUS", "REALESRGAN_GENERAL_WDN_X4V3"),
|
||||
):
|
||||
setattr(stored, attribute, valid)
|
||||
self.assertEqual(reader(), valid)
|
||||
setattr(stored, attribute, "UNKNOWN")
|
||||
self.assertEqual(reader(), default)
|
||||
self.assertEqual(getattr(stored, attribute), default)
|
||||
|
||||
|
||||
def test_numeric_preferences_clamp_to_supported_ranges(self):
|
||||
module = self.anyimage.preferences
|
||||
preferences = self.fake_bpy.context.preferences.addons["anyimage"].preferences
|
||||
cases = (
|
||||
(module.configured_max_ai_input_size, "max_ai_input_size", (10000, 1), (8192, 256)),
|
||||
(module.configured_max_frame_resolution, "max_frame_resolution", (10000, 1), (8192, 64)),
|
||||
(module.configured_cutout_boundary_padding, "cutout_boundary_padding", (3.5, 100, -1), (3.5, 4, 1)),
|
||||
(
|
||||
module.configured_min_cutout_relative_edge_length,
|
||||
"min_cutout_relative_edge_length",
|
||||
(2, 200, 0),
|
||||
(0.02, 1, 0.00001),
|
||||
),
|
||||
)
|
||||
for reader, attribute, values, expected in cases:
|
||||
with self.subTest(attribute=attribute):
|
||||
for value, result in zip(values, expected):
|
||||
setattr(preferences, attribute, value)
|
||||
self.assertAlmostEqual(reader(), result)
|
||||
|
||||
|
||||
def test_preferences_draw_follows_ai_status(self):
|
||||
catalog = tuple(
|
||||
{
|
||||
"key": key,
|
||||
"label": key,
|
||||
"family": family,
|
||||
"enabled": True,
|
||||
"ready": False,
|
||||
}
|
||||
for key, family in (
|
||||
("MOGE2_VITS_NORMAL", "moge"),
|
||||
("BEN2_BASE", "ben2"),
|
||||
("HAT_GAN_X4_SHARPER", "upscale"),
|
||||
)
|
||||
)
|
||||
missing_environment = {
|
||||
"environment_ready": False,
|
||||
"missing_models": (),
|
||||
"ready": False,
|
||||
}
|
||||
operators = self.draw_preferences(missing_environment, ())
|
||||
self.assertEqual(
|
||||
[identifier for identifier, _options in operators],
|
||||
["anyimage.setup_ai_environment", "anyimage.open_server_log"],
|
||||
)
|
||||
|
||||
ready = {"environment_ready": True, "missing_models": (), "ready": True}
|
||||
operators = self.draw_preferences(ready, catalog)
|
||||
downloads = [
|
||||
operator.model
|
||||
for identifier, operator in operators
|
||||
if identifier == "anyimage.download_model"
|
||||
]
|
||||
self.assertEqual(
|
||||
downloads,
|
||||
["MOGE2_VITS_NORMAL", "BEN2_BASE", "HAT_GAN_X4_SHARPER"],
|
||||
)
|
||||
|
||||
def draw_preferences(self, status, catalog):
|
||||
operators = []
|
||||
preferences = self.anyimage.preferences.AnyImagePreferences()
|
||||
preferences.layout = RecordingLayout(operators)
|
||||
preferences.geometry_model = "MOGE2_VITS_NORMAL"
|
||||
preferences.background_model = "BEN2_BASE"
|
||||
preferences.upscale_model = "HAT_GAN_X4_SHARPER"
|
||||
with (
|
||||
patch.object(self.anyimage.properties, "ai_status", return_value=status),
|
||||
patch.object(self.anyimage.properties, "model_catalog", return_value=catalog),
|
||||
patch.object(self.anyimage.runtime, "server_busy", return_value=False),
|
||||
):
|
||||
preferences.draw(None)
|
||||
return operators
|
||||
|
||||
|
||||
def test_depth_plane_request_includes_the_global_ai_input_limit(self):
|
||||
operator = self.convert_to_plane.GenerateDepthPlane()
|
||||
operator.plane_type = "DEPTH"
|
||||
operator.input_path = __file__
|
||||
operator.analysis_input_path = __file__
|
||||
operator.source_object_name = "Source"
|
||||
operator.mesh_detail = 0
|
||||
operator.model = self.anyimage.preferences.DEFAULT_GEOMETRY_MODEL_KEY
|
||||
operator.resolution_level = 5
|
||||
operator.max_input_size = 1536
|
||||
context = SimpleNamespace(
|
||||
scene=SimpleNamespace(anyimage_settings=SimpleNamespace())
|
||||
)
|
||||
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"),
|
||||
):
|
||||
parameters = operator.request(context)
|
||||
|
||||
self.assertEqual(parameters["max_input_size"], 1536)
|
||||
|
||||
|
||||
def test_upscale_execute_reads_model_and_size_from_preferences(self):
|
||||
source_image = SimpleNamespace(
|
||||
name="Original",
|
||||
source="FILE",
|
||||
)
|
||||
source_object = SimpleNamespace(
|
||||
name="Source",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=source_image,
|
||||
image_user=None,
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
object=source_object,
|
||||
scene=SimpleNamespace(anyimage_settings=SimpleNamespace()),
|
||||
)
|
||||
operator = self.upscale.UpscaleImage()
|
||||
with (
|
||||
patch.object(
|
||||
self.upscale,
|
||||
"configured_upscale_model",
|
||||
return_value="HAT_GAN_X4_SHARPER",
|
||||
),
|
||||
patch.object(
|
||||
self.upscale,
|
||||
"configured_max_ai_input_size",
|
||||
return_value=1024,
|
||||
),
|
||||
patch.object(
|
||||
self.upscale.ImageEditTarget,
|
||||
"prepare",
|
||||
return_value=(Path("input.png"), False),
|
||||
),
|
||||
patch.object(
|
||||
self.runtime.JobOperatorBase,
|
||||
"execute",
|
||||
return_value={"RUNNING_MODAL"},
|
||||
),
|
||||
):
|
||||
result = operator.execute(context)
|
||||
|
||||
self.assertEqual(result, {"RUNNING_MODAL"})
|
||||
self.assertEqual(operator.model, "HAT_GAN_X4_SHARPER")
|
||||
self.assertEqual(operator.max_input_size, 1024)
|
||||
113
tests/anyimage/test_server_panel.py
Normal file
113
tests/anyimage/test_server_panel.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class ServerPanelTest(BlenderTestCase):
|
||||
|
||||
|
||||
def test_server_panel_shows_controls_for_every_running_state(self):
|
||||
for state, label, icon in (
|
||||
("READY", "Running", "RADIOBUT_ON"),
|
||||
("BUSY", "Running (Busy)", "TIME"),
|
||||
):
|
||||
with self.subTest(state=state):
|
||||
events = self._draw_server_panel(state)
|
||||
operators = [identifier for identifier, _enabled in events.operators]
|
||||
self.assertIn(("row", label, icon), events.labels)
|
||||
self.assertIn("anyimage.restart_server", operators)
|
||||
self.assertIn("anyimage.stop_server", operators)
|
||||
self.assertIn(("row", "No Models Loaded", None), events.labels)
|
||||
self.assertIn(
|
||||
("anyimage.clear_models", state == "READY"), events.operators
|
||||
)
|
||||
|
||||
for state in ("STOPPED", "STARTING", "ERROR"):
|
||||
with self.subTest(state=state):
|
||||
events = self._draw_server_panel(state)
|
||||
operators = [identifier for identifier, _enabled in events.operators]
|
||||
self.assertNotIn("anyimage.restart_server", operators)
|
||||
self.assertNotIn("anyimage.stop_server", operators)
|
||||
|
||||
|
||||
def test_status_bar_reads_runtime_state(self):
|
||||
calls = []
|
||||
|
||||
class Layout:
|
||||
def row(self, **_options):
|
||||
return self
|
||||
|
||||
def progress(self, **options):
|
||||
calls.append(("progress", options))
|
||||
|
||||
def operator(self, identifier, **_options):
|
||||
calls.append(("operator", identifier))
|
||||
|
||||
runtime = self.anyimage.runtime
|
||||
runtime.active_job = object()
|
||||
runtime.progress = 0.4
|
||||
runtime.message = "Working"
|
||||
runtime._draw_status_bar(SimpleNamespace(layout=Layout()), None)
|
||||
runtime.active_job = None
|
||||
|
||||
self.assertEqual(calls[0][1]["factor"], 0.4)
|
||||
self.assertEqual(calls[0][1]["text"], "Working")
|
||||
self.assertEqual(calls[1], ("operator", "anyimage.cancel_job"))
|
||||
|
||||
|
||||
def _draw_server_panel(self, state, cached_names=()):
|
||||
events = SimpleNamespace(labels=[], operators=[])
|
||||
|
||||
class Layout:
|
||||
def __init__(self, kind="layout"):
|
||||
self.kind = kind
|
||||
self.enabled = True
|
||||
|
||||
def row(self, **_options):
|
||||
return Layout("row")
|
||||
|
||||
def column(self, **_options):
|
||||
return Layout("column")
|
||||
|
||||
def label(self, **options):
|
||||
events.labels.append(
|
||||
(self.kind, options.get("text"), options.get("icon"))
|
||||
)
|
||||
|
||||
def operator(self, identifier, **_options):
|
||||
events.operators.append((identifier, self.enabled))
|
||||
return SimpleNamespace()
|
||||
|
||||
panel_module = importlib.import_module("anyimage.panel")
|
||||
panel = panel_module.ServerPanel()
|
||||
panel.layout = Layout()
|
||||
loaded = {
|
||||
str(index): name
|
||||
for index, name in enumerate(cached_names)
|
||||
}
|
||||
with (
|
||||
patch.object(
|
||||
panel_module.runtime,
|
||||
"environment_ready",
|
||||
return_value=True,
|
||||
),
|
||||
patch.object(
|
||||
panel_module.runtime,
|
||||
"server_busy",
|
||||
return_value=state == "BUSY",
|
||||
),
|
||||
patch.object(
|
||||
panel_module.runtime,
|
||||
"server_status",
|
||||
return_value={
|
||||
"state": state,
|
||||
"message": state.title(),
|
||||
"resources": {
|
||||
"model_manager": {"loaded": loaded},
|
||||
},
|
||||
},
|
||||
),
|
||||
):
|
||||
panel.draw(None)
|
||||
return events
|
||||
Reference in New Issue
Block a user