Initial public release
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-09-19 21:31:36 +08:00
commit 198fcbf55b
837 changed files with 67239 additions and 0 deletions

View File

View File

View 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])

View 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)

View 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()

View 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)

View 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)

View File

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

View File

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

View File

@@ -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))

View 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)

View 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"

View 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

View 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()

View 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

View 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",
)

View 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"

View 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)

View 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)

View File

View 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")

View 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())

View 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"], ())

View 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)

View 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)

View 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

View 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

View 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)

View 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)

View 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)

View 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

View 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]

View 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)

View 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)

View 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

View 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

View 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)

View 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)

View 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

View 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)

View 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)

View File

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

View 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

View 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)

View 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)

View File

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

View File

@@ -0,0 +1,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)

View 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

View 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)

View 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
)
)

View 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))

View 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)

View 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])

View File

View 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()}")

View 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

View 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)

View 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",
},
)

View 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()

View 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"])

View 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])

View 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)

View 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])

View 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)

View 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