Add tests for image layer, image plane, inventory, saved layout, commands, documentation, manifest, and packaging

- Implement tests for image layer functionality including alpha curve and depth layer behavior.
- Create tests for image plane evaluation and modifier input handling.
- Add tests to verify the saved asset inventory and its persistence.
- Introduce tests for the documentation command to ensure correct project version deployment.
- Validate the extension manifest structure and required permissions.
- Develop tests for the packaging process, ensuring correct platform handling and dependency management.
- Enhance image depth plane group functionality with new split threshold input and surface processing.
This commit is contained in:
2026-09-09 22:18:59 +08:00
parent 89087fc2e2
commit f48bbb3d05
97 changed files with 436 additions and 705 deletions

View File

View File

View File

@@ -0,0 +1,83 @@
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.props = SimpleNamespace(
BoolProperty=lambda **options: options,
EnumProperty=lambda **options: options,
FloatProperty=lambda **options: options,
IntProperty=lambda **options: options,
StringProperty=lambda **options: options,
)
fake_bpy.types = SimpleNamespace(Menu=object, Operator=object)
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.operators",
"anyimage.common",
"anyimage.operators.convert_to_plane",
)
}
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], node=loaded[1], material=loaded[2], object=loaded[3])

View File

@@ -0,0 +1,225 @@
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 anyimage.operators.convert_to_plane.object import create_color_image
from anyimage.operators.cutout_tool.object import create_cropped_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)
rgba = np.tile(np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32), (2, 2, 1))
original.pixels.foreach_set(rgba.ravel())
source = SimpleNamespace(data=original)
expected = images.image_rgba(original)
copied = create_color_image(source)
cropped = create_cropped_color_image(source, (0, 0, 2, 2), expected)
edited = images.create_image_edit_result(original, rgba.ravel(), (2, 2))
try:
assert original.alpha_mode == "STRAIGHT"
np.testing.assert_allclose(images.image_rgba(original), expected)
for image in (copied, cropped, edited):
assert image.alpha_mode == "PREMUL"
np.testing.assert_allclose(images.image_rgba(image), expected, atol=1 / 255)
finally:
for image in (copied, cropped, edited, original):
bpy.data.images.remove(image)
@pytest.mark.parametrize("loader", [images.load_image_edit_result, images.load_color_result_image])
def test_job_color_loader_defaults_to_premul(tmp_path, loader):
rgba = np.array([[[51, 102, 204, 64]]], dtype=np.uint8)
Image.fromarray(rgba).save(tmp_path / "image.0001.png")
original = bpy.data.images.new("Job source", width=1, height=1, alpha=True)
result = loader(SimpleNamespace(data=original), tmp_path)
try:
assert result.alpha_mode == "PREMUL"
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_failed_empty_replacement_restores_blending_and_timing():
original = SimpleNamespace(name="Original", use_fake_user=False, users=1)
timing = SimpleNamespace(frame_start=3, frame_offset=2, frame_duration=7, use_cyclic=True)
source = SimpleNamespace(data=original, use_empty_image_alpha=False, image_user=timing)
result = SimpleNamespace(source="SEQUENCE", users=1)
def fail_configuration(*args, **kwargs):
timing.frame_start = 99
raise RuntimeError("Failed configuration")
with patch.object(images, "has_other_image_empty", return_value=True), patch.object(images, "video_total_frames", return_value=3), patch.object(
images, "configure_image_user", side_effect=fail_configuration
):
with pytest.raises(RuntimeError, match="Failed configuration"):
images.replace_empty_image(source, result)
assert source.data is original
assert not source.use_empty_image_alpha
assert not original.use_fake_user
assert images.image_user_settings(source) == (3, 2, 7, True)
def test_business_pixels_ignore_premul_read_interpretation():
original = bpy.data.images.new("Premul read source", width=1, height=1, alpha=True)
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)
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", [False, True])
@pytest.mark.parametrize("floating", [False, True])
@pytest.mark.parametrize("dirty", [False, True])
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_add_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 import frame_tool as frame
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, "ADD"), (2, 2))
bpy.data.images.remove(result)
result = added
for _ in range(2):
source = frame.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 = frame.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 == "PREMUL" 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)

View File

@@ -0,0 +1,176 @@
import json
from types import SimpleNamespace
import numpy as np
from anyimage.common.selection import SelectionMask
from anyimage.common.depth import (
depth_uniform_scale,
load_depth_metadata,
)
from anyimage.operators.cutout_tool import geometry as cutout_geometry
def test_depth_metadata_loader_uses_one_schema_for_plane_and_cutout(tmp_path):
path = tmp_path / "depth.json"
path.write_text(
json.dumps(
{
"image_size": [3, 2],
"intrinsics": np.eye(3).tolist(),
"reference_depth": 1.0,
}
),
encoding="utf-8",
)
metadata = load_depth_metadata(path)
assert metadata["image_size"] == (3, 2)
assert metadata["intrinsics"] == ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))
assert metadata["reference_depth"] == 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
plane = cutout_geometry._stable_plane(coordinates, values)
selected_plane = coordinates @ plane[:2] + plane[2]
corners = np.asarray(((-1.0, -1.0), (-1.0, 1.0), (1.0, -1.0), (1.0, 1.0)))
corner_plane = corners @ plane[:2] + plane[2]
assert np.ptp(corner_plane) <= np.ptp(selected_plane) * 1.25
assert np.corrcoef(selected_plane, along)[0, 1] > 0.99
def test_depth_balloon_plane_calibration_matches_cutout_axes():
vertices = []
uv = []
camera_depth = np.empty((3, 3), dtype=np.float32)
for row in range(3):
local_y = 1.0 - row
for column in range(3):
local_x = column - 1.0
vertices.append((local_x, local_y, 0.0))
uv.append((column * 0.5, 1.0 - row * 0.5))
camera_depth[row, column] = 2.0 + 0.2 * local_x + 0.1 * local_y
reference, direction = cutout_geometry.depth_balloon_projection_parameters(
vertices,
uv,
camera_depth,
np.ones((3, 3), dtype=bool),
0.5,
)
expected_direction = np.asarray((0.1, 0.05, 1.0), dtype=np.float64)
expected_direction /= np.linalg.norm(expected_direction)
assert np.isclose(
reference,
np.percentile(camera_depth, 95.0) * 0.5,
atol=1e-6,
)
assert np.allclose(direction, expected_direction, atol=1e-6)
def test_depth_balloon_reference_uses_the_shared_upper_depth_percentile():
size = 10
row, column = np.indices((size, size))
camera_depth = np.full((size, size), 2.0, dtype=np.float32)
camera_depth[(row + column) % 2 == 0] = 2.2
vertices = []
uv = []
for y in range(size):
for x in range(size):
vertices.append((float(x), float(size - 1 - y), 0.0))
uv.append((x / (size - 1), 1.0 - y / (size - 1)))
reference, direction = cutout_geometry.depth_balloon_projection_parameters(
vertices,
uv,
camera_depth,
np.ones((size, size), dtype=bool),
1.0,
)
assert reference > float(np.median(camera_depth))
assert np.isclose(reference, 2.2, atol=1e-6)
assert np.allclose(direction, (0.0, 0.0, 1.0), atol=1e-6)
def test_depth_surface_projection_uses_one_scale_for_all_camera_axes():
points = np.zeros((51, 101, 3), dtype=np.float32)
points[..., 2] = np.linspace(1.0, 3.0, 51 * 101).reshape(51, 101)
metadata = {
"intrinsics": (
(100.0, 0.0, 50.0),
(0.0, 100.0, 25.0),
(0.0, 0.0, 1.0),
),
"image_size": (101, 51),
"reference_depth": 2.0,
}
uniform_scale = depth_uniform_scale(
SimpleNamespace(),
metadata,
bounds=(-2.0, 2.0, -1.0, 1.0),
)
assert uniform_scale == 2.0
reference_depth = cutout_geometry.metric_depth_reference(
points[..., 2],
np.ones((51, 101), dtype=bool),
uniform_scale,
)
assert np.isclose(reference_depth, 5.8, atol=1e-6)
def test_cutout_threshold_controls_contour_and_calibration():
alpha = np.zeros((12, 16), dtype=np.float32)
alpha[2:10, 2:14] = 0.2
alpha[4:8, 5:11] = 1.0
for threshold, lower, upper in [
(0.0, (2, 2), (14, 10)),
(0.1, (2, 2), (14, 10)),
(0.5, (5, 4), (11, 8)),
(1.0, (5, 4), (11, 8)),
]:
contours = cutout_geometry.extract_cutout_contours(
alpha, 64, threshold=threshold
)
contour = np.asarray(contours.components[0][0])
np.testing.assert_allclose(contour.min(axis=0), lower)
np.testing.assert_allclose(contour.max(axis=0), upper)
valid, _ = cutout_geometry.selection_calibration(
alpha, alpha.shape, threshold=threshold
)
np.testing.assert_array_equal(valid, (alpha > 0) & (alpha >= threshold))
def test_cutout_depth_calibration_uses_five_percent_transparency_threshold():
assert not cutout_geometry.use_adaptive_depth_calibration(0.0499)
assert cutout_geometry.use_adaptive_depth_calibration(0.05)
def test_selection_calibration_uses_the_final_selection_mask():
selection_mask = SelectionMask(
np.asarray(((1.0, 1.0), (1.0, 0.0)), dtype=np.float32),
(0, 0, 2, 2),
)
valid, transparent_fraction = cutout_geometry.selection_calibration(
selection_mask.values,
(2, 2),
)
assert np.array_equal(valid, ((True, True), (True, False)))
assert transparent_fraction == 0.25

View File

@@ -0,0 +1,250 @@
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("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)
@pytest.mark.parametrize("shared", [False, True])
def test_failed_display_configuration_restores_content(source, tmp_path, shared):
original = source.data
if shared:
create_empty("Other", original)
# Preserve unsaved pixels as well as the previous packed representation.
original.pixels.foreach_set(np.tile([0.1, 0.9, 0.2, 0.5], 4).astype(np.float32))
packed = bytes(original.packed_file.data)
pixels = images.image_pixels(original)
alpha = original.alpha_mode
source.image_user.frame_start = 5
source.image_user.use_auto_refresh = False
for index in (1, 2):
Image.new("RGBA", (4, 3), (100, 150, 200, 255)).save(tmp_path / f"image.{index:04d}.png")
result = bpy.data.images.load(str(tmp_path / "image.0001.png"))
result.source = "SEQUENCE"
count = len(bpy.data.images) - 1
def fail_configuration(user, *args, **kwargs):
user.frame_start = 99
user.use_auto_refresh = True
raise RuntimeError("Display failed")
with patch.object(images, "configure_image_user", side_effect=fail_configuration):
with pytest.raises(RuntimeError, match="Display failed"):
images.replace_empty_image(source, result)
assert source.data == original
assert tuple(original.size) == (2, 2)
assert original.alpha_mode == alpha
assert original.is_dirty
assert bytes(original.packed_file.data) == packed
np.testing.assert_array_equal(images.image_pixels(original), pixels)
assert source.image_user.frame_start == 5
assert not source.image_user.use_auto_refresh
assert not source.use_empty_image_alpha
assert not original.use_fake_user
assert len(bpy.data.images) == count
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
def test_animated_result_preserves_id_and_reopens(source, tmp_path):
original = source.data
source.image_user.frame_start = 4
source.image_user.use_cyclic = True
for index in (1, 2):
Image.new("RGBA", (4, 3), (index * 80, 100, 50, 255)).save(tmp_path / f"image.{index:04d}.png")
original.source = "SEQUENCE"
result = images.load_image_edit_result(source, tmp_path)
expected_path = result.filepath_raw
expected_packed = bytes(result.packed_file.data) if result.packed_file else None
final = images.replace_empty_image(source, result)
assert final == original
assert final.source == "SEQUENCE"
assert final.filepath_raw == expected_path
assert (bytes(final.packed_file.data) if final.packed_file else None) == expected_packed
assert len(images.collect_sequence_frames(final)) == 2
assert source.image_user.frame_start == 4
assert source.image_user.frame_duration == 2
assert source.image_user.use_cyclic
assert source.image_user.use_auto_refresh
path = str(tmp_path / "animation.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"
assert reopened.source == "SEQUENCE"
frames = images.collect_sequence_frames(reopened)
assert len(frames) == 2
for index, frame in enumerate(frames, 1):
loaded = bpy.data.images.load(str(frame), check_existing=False)
try:
assert tuple(loaded.size) == (4, 3)
np.testing.assert_allclose(
images.image_pixels(loaded)[:4],
np.array([index * 80, 100, 50, 255]) / 255,
atol=1 / 255,
)
finally:
bpy.data.images.remove(loaded)
@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,350 @@
import tempfile
import unittest
from pathlib import Path
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_video_total_frames_reads_movie_duration(self):
image = SimpleNamespace(source="MOVIE", frame_duration=97)
self.assertEqual(self.modules.image.video_total_frames(image), 97)
def test_video_total_frames_counts_sequence_files(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "foreground-0001.png").touch()
(root / "foreground-0002.png").touch()
image = SimpleNamespace(
source="SEQUENCE",
filepath=str(root / "foreground-0001.png"),
)
self.assertEqual(self.modules.image.video_total_frames(image), 2)
def test_video_total_frames_returns_one_for_still_image(self):
image = SimpleNamespace(source="FILE", frame_duration=1)
self.assertEqual(self.modules.image.video_total_frames(image), 1)
def test_default_extract_frames_uses_empty_image_frames(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(frame_duration=24),
)
self.assertEqual(self.modules.image.default_extract_frames(source_object), 24)
def test_default_extract_frames_caps_at_video_total(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(frame_duration=200),
)
self.assertEqual(self.modules.image.default_extract_frames(source_object), 97)
def test_default_extract_frames_falls_back_to_video_total(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
)
self.assertEqual(self.modules.image.default_extract_frames(source_object), 97)
def test_extract_frame_range_honors_offset_within_clip(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(
frame_start=4,
frame_offset=70,
frame_duration=24,
),
)
self.assertEqual(
self.modules.image.extract_frame_range(source_object),
(71, 24),
)
def test_extract_frame_range_clamps_large_offset_to_clip_tail(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(
frame_start=1,
frame_offset=90,
frame_duration=24,
),
)
self.assertEqual(
self.modules.image.extract_frame_range(source_object),
(74, 24),
)
def test_extract_frame_range_clamps_negative_offset_to_start(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(
frame_start=1,
frame_offset=-5,
frame_duration=24,
),
)
self.assertEqual(
self.modules.image.extract_frame_range(source_object),
(1, 24),
)
def test_extract_frame_range_returns_full_clip_for_still(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="FILE", frame_duration=1),
image_user=SimpleNamespace(
frame_start=1,
frame_offset=50,
frame_duration=24,
),
)
self.assertEqual(
self.modules.image.extract_frame_range(source_object),
(1, 1),
)
def test_extract_frame_range_honors_single_frame_setting(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(
frame_start=1,
frame_offset=70,
frame_duration=1,
),
)
self.assertEqual(
self.modules.image.extract_frame_range(source_object),
(71, 1),
)
def test_configure_image_user_preserves_cyclic_setting(self):
image_user = SimpleNamespace(
frame_start=0,
frame_offset=5,
use_auto_refresh=False,
use_cyclic=False,
frame_duration=1,
)
self.modules.image.configure_image_user(
image_user,
97,
frame_start=4,
frame_offset=70,
)
self.assertFalse(image_user.use_cyclic)
self.assertTrue(image_user.use_auto_refresh)
self.assertEqual(image_user.frame_start, 4)
self.assertEqual(image_user.frame_offset, 70)
self.assertEqual(image_user.frame_duration, 97)
self.modules.image.configure_image_user(
image_user,
use_cyclic=True,
)
self.assertTrue(image_user.use_cyclic)
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_image_base_name_strips_video_extension(self):
image = SimpleNamespace(name="微信视频2026-07-23_192756_414.mp4")
self.assertEqual(
self.modules.image.image_base_name(image),
"微信视频2026-07-23_192756_414",
)
def test_color_name_strips_video_extension(self):
source_object = SimpleNamespace(data=SimpleNamespace(name="clip.mp4"))
self.assertEqual(
self.modules.image.color_data_name(source_object),
"clip_color.png",
)
def test_force_sequence_source_keeps_loaded_sequence(self):
image = SimpleNamespace(source="SEQUENCE")
result = self.modules.image.force_sequence_source(image)
self.assertIs(result, image)
self.assertEqual(image.source, "SEQUENCE")
def test_force_sequence_source_switches_single_image(self):
image = SimpleNamespace(source="FILE")
result = self.modules.image.force_sequence_source(image)
self.assertIs(result, image)
self.assertEqual(image.source, "SEQUENCE")
def test_force_sequence_source_reports_unloadable_sequence(self):
class BlockedImage:
@property
def source(self):
return "FILE"
@source.setter
def source(self, value):
raise RuntimeError("blocked")
with self.assertRaisesRegex(RuntimeError, "cannot load"):
self.modules.image.force_sequence_source(BlockedImage())
def test_collect_sequence_frames_returns_numbered_files_in_order(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "frame-0001.jpg").touch()
(root / "frame-0002.jpg").touch()
(root / "frame-0003.jpg").touch()
(root / "notes.txt").touch()
image = SimpleNamespace(
source="SEQUENCE",
filepath=str(root / "frame-0002.jpg"),
)
frames = self.modules.image.collect_sequence_frames(image)
self.assertEqual(
[path.name for path in frames],
["frame-0001.jpg", "frame-0002.jpg", "frame-0003.jpg"],
)
def test_sorted_frame_paths_uses_numeric_frame_order(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "frame-2.jpg").touch()
(root / "frame-10.jpg").touch()
(root / "frame-1.jpg").touch()
frames = self.modules.image.sorted_frame_paths(root)
self.assertEqual(
[path.name for path in frames],
["frame-1.jpg", "frame-2.jpg", "frame-10.jpg"],
)
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_color_name_uses_source_image_base_name(self):
source_object = SimpleNamespace(
data=SimpleNamespace(name="img.png"),
)
self.assertEqual(
self.modules.image.color_data_name(source_object),
"img_color.png",
)
source_object.data.name = "img.png.001"
self.assertEqual(
self.modules.image.color_data_name(source_object),
"img.001_color.png",
)
def test_image_empty_bounds_keep_image_aspect_and_empty_offset(self):
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,226 @@
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 anyimage.operators.convert_to_plane import object as plane_object
from anyimage.operators.cutout_tool import object as cutout_object
from anyimage.operators.cutout_tool import shape as cutout_shape
from tests.support.image_objects import _create_cutout_shape, _group_node, _inputs
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", 8)
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_converted_color_images_are_new_and_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)
first = plane_object.create_color_image(source)
second = plane_object.create_color_image(source)
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"
def test_balloon_normal_scale_uses_existing_image_layer_input():
bpy.ops.wm.read_factory_settings(use_empty=True)
group = material_data.material_node_group()
original_inputs = _inputs(group)
image_material = bpy.data.materials.new("Balloon")
image_material.use_nodes = True
image_material.node_tree.nodes.clear()
image_layer = image_material.node_tree.nodes.new("ShaderNodeGroup")
image_layer.node_tree = group
cutout_object._connect_cutout_normal_scale(image_material)
assert _inputs(group) == original_inputs
attribute = next(
node
for node in image_material.node_tree.nodes
if node != image_layer
)
assert attribute.bl_idname == "ShaderNodeAttribute"
assert attribute.attribute_name == cutout_shape.NORMAL_SCALE_ATTRIBUTE_NAME
link = next(iter(image_material.node_tree.links))
assert link.from_socket == attribute.outputs["Fac"]
assert link.to_socket == image_layer.inputs["Normal Scale"]

View File

@@ -0,0 +1,27 @@
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)
layer = next(node for node in result.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer)
assert layer.inputs["Alpha Fix"].default_value == float(enabled)
alpha_link = layer.outputs["Alpha"].links[0]
assert alpha_link.to_node.type == ("MIX_SHADER" if shadeless else "BSDF_PRINCIPLED")
assert alpha_link.to_socket.name == ("Fac" if shadeless else "Alpha")
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,72 @@
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_material_color_space_falls_back_when_unsupported(self):
class ColorSpaceSettings:
def __init__(self):
self._name = "sRGB"
@property
def name(self):
return self._name
@name.setter
def name(self, value):
if value != "sRGB":
raise TypeError("unsupported color space")
self._name = value
image = SimpleNamespace(
colorspace_settings=ColorSpaceSettings(),
users=0, use_fake_user=False, alpha_mode="STRAIGHT",
)
scene = SimpleNamespace(
view_settings=SimpleNamespace(view_transform="ACES 2.0")
)
result = self.modules.material.configure_material_color_image(image, scene)
self.assertIs(result, image)
self.assertEqual(image.alpha_mode, "PREMUL")
self.assertEqual(image.colorspace_settings.name, "sRGB")
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,232 @@
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 anyimage.operators.convert_to_plane.object import create_color_image
from anyimage.operators.cutout_tool.object import create_cropped_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")
def test_adaptation_preference_defaults_and_reads_current_value():
with patch.object(preferences, "addon_preferences", return_value=None):
assert preferences.configured_material_view_adaptation()
with patch.object(preferences, "addon_preferences", return_value=SimpleNamespace()):
assert preferences.configured_material_view_adaptation()
settings = SimpleNamespace(adapt_material_to_view_transform=False)
with patch.object(preferences, "addon_preferences", return_value=settings):
assert not preferences.configured_material_view_adaptation()
settings.adapt_material_to_view_transform = True
assert preferences.configured_material_view_adaptation()
@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",
pytest.param("float", marks=pytest.mark.xfail(
strict=True,
reason="Native float RGB is reinterpreted by Blender after packing and reloading with a display color space",
)),
])
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.pixels.foreach_set((expected.astype(np.float32) / 255).ravel())
if kind == "packed":
source.pack()
source.reload()
images.configure_color_alpha(source)
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
assert source.alpha_mode == "PREMUL"
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 == "PREMUL"
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_new_material_uses_current_preference_and_preserves_existing(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 == "plane":
color = create_color_image(owner)
elif entry == "cutout":
color = create_cropped_color_image(owner, (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 == "AgX Base sRGB"
assert original_color.alpha_mode == "STRAIGHT"
assert not settings.adapt_material_to_view_transform
assert owner.data == source
assert source.colorspace_settings.name == "sRGB"
assert source.alpha_mode == "STRAIGHT"
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_keep_their_policy_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 / "image.0001.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(SimpleNamespace(data=source), tmp_path)
for result in (edited, loaded):
assert result.colorspace_settings.name == "sRGB"
assert result.alpha_mode == "PREMUL"
np.testing.assert_allclose(images.image_pixels(result), rgba, atol=1 / 255)
assert source.colorspace_settings.name == "sRGB"
assert source.alpha_mode == "STRAIGHT"
@pytest.mark.parametrize("supported", [{"sRGB", "Non-Color"}, {"Non-Color"}])
def test_unavailable_color_space_falls_back_or_preserves_original(supported):
class ColorSpace:
value = "Non-Color"
@property
def name(self):
return self.value
@name.setter
def name(self, value):
if value not in supported:
raise TypeError("Unsupported color space")
self.value = value
image = SimpleNamespace(colorspace_settings=ColorSpace(), users=0, use_fake_user=False, alpha_mode="STRAIGHT")
with patch.object(material, "configured_material_view_adaptation", return_value=True):
if "sRGB" in supported:
result = material.configure_material_color_image(image, view_scene("AgX"))
assert result.colorspace_settings.name == "sRGB"
assert result.alpha_mode == "PREMUL"
else:
with pytest.raises(RuntimeError, match="material color space"):
material.configure_material_color_image(image, view_scene("AgX"))
assert image.colorspace_settings.name == "Non-Color"
assert image.alpha_mode == "STRAIGHT"
def test_missing_scene_uses_srgb(color_data):
image = bpy.data.images.new("Missing scene", width=1, height=1)
with patch.object(material, "bpy", SimpleNamespace(context=SimpleNamespace(scene=None))), patch.object(
material, "configured_material_view_adaptation", return_value=True
):
result = material.configure_material_color_image(image)
assert result.colorspace_settings.name == "sRGB"
assert result.alpha_mode == "PREMUL"

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="SURFACE"),
SimpleNamespace(identifier="BALLOON"),
)
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(Shape=input_slot),
)
)
self.modules.object.set_modifier_input(modifier, "Shape", 1)
self.assertEqual(input_slot.value, "BALLOON")
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 LegacyModifier(dict):
properties = None
legacy_modifier = LegacyModifier()
self.modules.object.set_modifier_input(legacy_modifier, "Shape", 1)
self.assertEqual(legacy_modifier["Shape"], 1)

View File

@@ -0,0 +1,14 @@
from tests.support.paths import PROJECT_ROOT
import unittest
from tests.anyimage.common.support import load_common_modules
class NodeLoadingTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.modules = load_common_modules()
def test_node_asset_path_resolves_bundled_library(self):
self.assertEqual(
self.modules.node.node_asset_path(),
PROJECT_ROOT / "src" / "anyimage" / "assets" / "O_AnyImage.blend",
)

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,151 @@
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_defaults_to_wdn_and_sends_selected_model(self):
operator_type = self.upscale.UpscaleImage
self.assertIn(operator_type, self.anyimage.CLASSES)
self.assertEqual(
operator_type.__annotations__["model"]["default"],
"REALESRGAN_GENERAL_WDN_X4V3",
)
self.assertEqual(
operator_type.__annotations__["max_input_size"]["default"],
2048,
)
self.assertIn(self.runtime.JobOperatorBase, operator_type.__mro__)
self.assertEqual(operator_type.bl_options, {"UNDO"})
operator = operator_type()
operator.input_path = "input.png"
operator.source_object_name = "Source"
operator.model = "SAFMN_REAL_X4"
operator.max_input_size = 1024
context = SimpleNamespace(
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=7))
)
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"),
patch.object(self.upscale, "source_start_frame", return_value=4),
):
request = operator.request(context)
require_model.assert_called_once_with("SAFMN_REAL_X4")
self.assertEqual(
request,
{
"input": "input.png",
"model": "SAFMN_REAL_X4",
"device": "cuda",
"video_frames": 7,
"start_frame": 4,
"max_input_size": 1024,
},
)
def test_upscale_operator_opens_setup_when_required_ai_is_missing(self):
expected = {"CANCELLED"}
with patch.object(
self.upscale,
"invoke_ai_setup_if_needed",
return_value=expected,
) as setup:
result = self.upscale.UpscaleImage().invoke(None, None)
setup.assert_called_once_with()
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):
source = SimpleNamespace()
result_image = SimpleNamespace()
result = SimpleNamespace(directory=Path("result"))
ops = SimpleNamespace(
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
)
with (
patch.object(self.image_data, "require_image_empty", return_value=source),
patch.object(
self.image_data,
"load_image_edit_result",
return_value=result_image,
) as load,
patch.object(self.image_data, "replace_empty_image") as replace,
patch.object(self.remove_background.bpy, "ops", ops, create=True),
):
self.remove_background.apply_background_response(result, "Source")
load.assert_called_once_with(source, Path("result"))
replace.assert_called_once_with(source, result_image)
def test_upscale_loads_a_plain_image_edit_result(self):
source_image = SimpleNamespace()
source = SimpleNamespace(data=source_image)
result_image = SimpleNamespace()
result = SimpleNamespace(directory=Path("result"))
operator = self.upscale.UpscaleImage()
operator.source_object_name = "Source"
operator.source_image_name = "Poster.png"
operator.model = "SAFMN_REAL_X4"
images = SimpleNamespace(get=lambda name: source_image if name == "Poster.png" else None)
with (
patch.object(self.upscale, "require_image_empty", return_value=source),
patch.object(
self.upscale,
"load_image_edit_result",
return_value=result_image,
) as load,
patch.object(self.upscale, "replace_empty_image") as replace,
patch.object(
self.upscale,
"upscale_model_label",
return_value="SAFMN Real x4",
),
patch.object(
self.upscale.bpy,
"data",
SimpleNamespace(images=images),
),
):
message = operator.response(None, result)
load.assert_called_once_with(
source,
Path("result"),
frame_prefix="upscale.",
)
replace.assert_called_once_with(source, result_image)
self.assertEqual(message, "Image is upscaled with SAFMN Real x4")

View File

@@ -0,0 +1,209 @@
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_runtime_exposes_declared_environment_and_post_install(self):
runtime = self.anyimage.runtime
self.assertEqual(runtime.environment["python"], "3.12")
self.assertIn("numpy==2.4.2", runtime.environment["packages"])
self.assertIn("scipy==1.15.3", runtime.environment["packages"])
self.assertFalse(
any(
"blendjob" in package.lower()
for package in runtime.environment["packages"]
)
)
self.assertTrue(callable(runtime.post_install))
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": "BEN2_BASE"}),
(
"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; "
"BEN2 Base: network unavailable; "
"Real-ESRGAN General WDN x4v3: network unavailable"
),
):
self.runtime.post_install(SimpleNamespace(request=request))
self.assertEqual(
calls,
[
"MOGE2_VITS_NORMAL",
"BEN2_BASE",
"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",
"BEN2_BASE",
"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": "BEN2_BASE", "ready": False},
{
"key": "REALESRGAN_GENERAL_WDN_X4V3",
"ready": True,
},
),
),
):
status = properties.ai_status()
self.assertTrue(status["environment_ready"])
self.assertEqual(status["missing_models"], ("BEN2_BASE",))
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.REQUIRED_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", "BEN2_BASE"),
"ready": False,
},
):
self.assertEqual(operator.execute(None), {"FINISHED"})
with patch.object(
self.ai_setup,
"ai_status",
return_value={
"environment_ready": True,
"missing_models": ("BEN2_BASE",),
"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,
)
self.assertEqual(self.ai_setup.SetupAIEnvironment.bl_label, "Set Up AI Server")
self.assertEqual(
self.ai_setup.OpenAIEnvironmentSettings.bl_label, "Settings"
)
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"
ben2 = root / "models" / "BEN2-ONNX" / "onnx"
wdn = root / "models" / "realesr-general-wdn-x4v3"
moge.mkdir(parents=True)
ben2.mkdir(parents=True)
wdn.mkdir(parents=True)
(moge / "model.onnx").touch()
(ben2 / "model_fp16.onnx").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 geometry as cutout_geometry
from anyimage.operators.cutout_tool.geometry 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(
cutout_geometry,
"chordal_axis",
return_value=(nodes, [(0, 1)], np.asarray((1.0, 1.0))),
):
profile = cutout_geometry.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,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,933 @@
from tests.support.paths import PROJECT_ROOT
import importlib
import struct
import sys
import tempfile
import unittest
from types import ModuleType, SimpleNamespace
from unittest.mock import Mock, patch
from tests.support.blender import mock_bpy_imports
class ClipboardImageTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.previous_addon_modules = {
name: module for name, module in sys.modules.items()
if name == "anyimage" or name.startswith("anyimage.")
}
cls.temporary_directory = tempfile.TemporaryDirectory()
cls.remove_anyimage_modules()
fake_bpy = ModuleType("bpy")
fake_bpy.app = SimpleNamespace(version=(5, 1, 0))
fake_bpy.props = SimpleNamespace(
BoolProperty=lambda **options: options,
CollectionProperty=lambda **options: options,
EnumProperty=lambda **options: options,
FloatProperty=lambda **options: options,
FloatVectorProperty=lambda **options: options,
IntProperty=lambda **options: options,
PointerProperty=lambda **options: options,
StringProperty=lambda **options: options,
)
fake_bpy.types = SimpleNamespace(
AddonPreferences=object,
GizmoGroup=object,
Image=object,
Menu=object,
Operator=object,
Panel=object,
PropertyGroup=object,
Scene=object,
TOPBAR_MT_file_defaults=SimpleNamespace(),
)
fake_bpy.utils = SimpleNamespace()
fake_bpy.context = SimpleNamespace(
window_manager=SimpleNamespace(
keyconfigs=SimpleNamespace(addon=None),
)
)
fake_bpy.data = SimpleNamespace()
cls.bpy_import_patch = mock_bpy_imports(fake_bpy)
cls.bpy_import_patch.start()
cls.addClassCleanup(cls.bpy_import_patch.stop)
cls.fake_bpy = fake_bpy
cls.addon = ModuleType("anyimage")
cls.addon.__path__ = [str(PROJECT_ROOT / "src" / "anyimage")]
sys.modules["anyimage"] = cls.addon
operators = ModuleType("anyimage.operators")
operators.__path__ = [str(PROJECT_ROOT / "src" / "anyimage" / "operators")]
sys.modules["anyimage.operators"] = operators
cls.addon.clipboard_image = importlib.import_module(
"anyimage.operators.clipboard_image.operators"
)
cls.actions = importlib.import_module("anyimage.operators.clipboard_image.actions")
cls.clipboard = importlib.import_module("anyimage.operators.clipboard_image.clipboard")
@classmethod
def tearDownClass(cls):
cls.remove_anyimage_modules()
cls.temporary_directory.cleanup()
sys.modules.update(cls.previous_addon_modules)
def test_operators_use_anyimage_prefix_and_plain_properties(self):
clipboard_image = self.addon.clipboard_image
self.assertEqual(
clipboard_image.PasteClipboardImage.bl_idname,
"anyimage.paste_clipboard_image",
)
self.assertEqual(
clipboard_image.TrackNativeCopy.bl_idname,
"anyimage.track_native_copy",
)
for name in clipboard_image.PasteClipboardImage.__annotations__:
self.assertFalse(name.startswith("anyimage_"), name)
self.assertFalse(name.startswith("o_"), name)
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.addon.clipboard_image
with patch.object(
self.fake_bpy.context,
"window_manager",
window_manager,
):
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_world_shader_uses_environment_texture(self):
world_space = SimpleNamespace(
tree_type="ShaderNodeTree",
shader_type="WORLD",
)
material_space = SimpleNamespace(
tree_type="ShaderNodeTree",
shader_type="OBJECT",
)
self.assertEqual(
self.actions._node_type_for_space(world_space),
"ShaderNodeTexEnvironment",
)
self.assertEqual(
self.actions._node_type_for_space(material_space),
"ShaderNodeTexImage",
)
def test_pinned_world_tree_uses_environment_texture(self):
world_space = SimpleNamespace(
tree_type="ShaderNodeTree",
shader_type="OBJECT",
id=SimpleNamespace(
bl_rna=SimpleNamespace(identifier="World"),
),
)
self.assertEqual(
self.actions._node_type_for_space(world_space),
"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)
def test_node_location_without_event_uses_node_cursor(self):
context = SimpleNamespace(
space_data=SimpleNamespace(cursor_location=(12.0, -8.0)),
)
self.assertEqual(
self.actions._node_location(context, None),
(12.0, -8.0),
)
def test_view3d_location_projects_mouse_at_cursor_depth(self):
projected_location = (4.0, 5.0, 6.0)
project = Mock(return_value=projected_location)
bpy_extras = ModuleType("bpy_extras")
view3d_utils = ModuleType("bpy_extras.view3d_utils")
view3d_utils.region_2d_to_location_3d = project
bpy_extras.view3d_utils = view3d_utils
cursor_location = Mock()
context = SimpleNamespace(
scene=SimpleNamespace(
cursor=SimpleNamespace(location=cursor_location),
),
region=object(),
region_data=object(),
)
event = SimpleNamespace(mouse_region_x=120, mouse_region_y=75)
with patch.dict(
sys.modules,
{
"bpy_extras": bpy_extras,
"bpy_extras.view3d_utils": view3d_utils,
},
):
result = self.actions._view3d_location(context, event)
self.assertIs(result, projected_location)
project.assert_called_once_with(
context.region,
context.region_data,
(120, 75),
cursor_location,
)
def test_view3d_location_without_event_copies_cursor_location(self):
copied_location = object()
cursor_location = SimpleNamespace(
copy=Mock(return_value=copied_location),
)
context = SimpleNamespace(
scene=SimpleNamespace(
cursor=SimpleNamespace(location=cursor_location),
),
)
result = self.actions._view3d_location(context)
self.assertIs(result, copied_location)
cursor_location.copy.assert_called_once_with()
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_plane_import_defaults_to_reference_image(self):
options = (
self.addon.clipboard_image.PasteClipboardImage
.__annotations__["import_as"]
)
self.assertEqual(options["default"], "REFERENCE")
self.assertEqual(
[item[0] for item in options["items"]],
["PLANE", "REFERENCE"],
)
def test_plane_shadeless_is_disabled_by_default(self):
options = (
self.addon.clipboard_image.PasteClipboardImage
.__annotations__["shadeless"]
)
self.assertFalse(options["default"])
annotations = self.addon.clipboard_image.PasteClipboardImage.__annotations__
self.assertNotIn("subdivisions", annotations)
self.assertNotIn("thickness", annotations)
self.assertNotIn("unshaded", annotations)
def test_native_copy_takes_priority_while_clipboard_is_unchanged(self):
clipboard_image = self.addon.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())
finally:
clipboard_image._native_copy_token = original_token
def test_new_system_clipboard_content_restores_image_paste(self):
clipboard_image = self.addon.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=42,
):
self.assertFalse(clipboard_image._should_defer_to_native_paste())
finally:
clipboard_image._native_copy_token = original_token
def test_native_copy_tracker_passes_through(self):
clipboard_image = self.addon.clipboard_image
original_token = clipboard_image._native_copy_token
try:
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_plane_paste_uses_shared_material_for_both_shading_modes(self):
from anyimage.operators.convert_to_plane import image_plane
image = SimpleNamespace(name="Clipboard", size=(16, 8))
context = SimpleNamespace(mode="OBJECT", scene=object())
for shadeless in (False, True):
with (
patch.object(self.actions, "create_image_material", return_value="Material") as create_material,
patch.object(image_plane, "create_image_plane_object") as create_plane,
patch.object(self.actions, "_place_in_view"),
patch.object(self.actions, "_select_only"),
):
self.actions.add_image_plane(context, image, shadeless=shadeless)
create_material.assert_called_once_with(
image, image, shadeless=shadeless, scene=context.scene,
)
self.assertEqual(create_plane.call_args.args[3], "Material")
def test_reference_import_routes_to_image_empty(self):
context = self.make_context("VIEW_3D")
image = object()
expected_reference = object()
expected_location = (1.0, 2.0, 3.0)
original_add_reference_image = self.actions.add_reference_image
self.actions.add_reference_image = (
lambda received_context, received_image, location=None: expected_reference
if (
received_context is context
and received_image is image
and location == expected_location
)
else None
)
try:
result = self.actions.paste_image(
context,
image,
location=expected_location,
import_as="REFERENCE",
)
finally:
self.actions.add_reference_image = original_add_reference_image
self.assertIs(result, expected_reference)
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_only_import_as_and_shadeless_are_drawn_for_plane_paste(self):
operator = self.addon.clipboard_image.PasteClipboardImage()
drawn_properties = []
operator.layout = SimpleNamespace(
prop=lambda owner, name: drawn_properties.append((owner, name)),
)
operator.paste_target = "PLANE"
operator.import_as = "PLANE"
operator.draw(None)
operator.import_as = "REFERENCE"
operator.draw(None)
operator.paste_target = "NODE"
operator.draw(None)
self.assertEqual(
drawn_properties,
[
(operator, "import_as"),
(operator, "shadeless"),
(operator, "import_as"),
],
)
def test_redo_reuses_captured_node_location(self):
operator = self.addon.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.addon.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:
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:
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_localizes_read_only_active_brush(self):
local_brush = SimpleNamespace(is_editable=True, library=None)
linked_brush = SimpleNamespace(
is_editable=False,
library=None,
make_local=lambda: local_brush,
)
self.assertIs(
self.actions._ensure_editable_brush(
SimpleNamespace(),
linked_brush,
),
local_brush,
)
def test_copies_and_activates_runtime_library_brush(self):
local_brush = SimpleNamespace(
name="Paint Hard Clipboard",
is_editable=True,
library=None,
)
library_brush = SimpleNamespace(
name="Paint Hard",
is_editable=True,
library=object(),
)
paint_settings = SimpleNamespace(brush=library_brush)
context = SimpleNamespace(
mode="SCULPT",
tool_settings=SimpleNamespace(sculpt=paint_settings),
)
original_ops = getattr(self.actions.bpy, "ops", None)
def save_brush_as(**options):
self.assertEqual(
options["asset_library_reference"],
"LOCAL",
)
self.assertEqual(options["name"], "Paint Hard Clipboard")
paint_settings.brush = local_brush
return {"FINISHED"}
self.actions.bpy.ops = SimpleNamespace(
brush=SimpleNamespace(asset_save_as=save_brush_as),
)
try:
result = self.actions._ensure_editable_brush(
context,
library_brush,
)
finally:
if original_ops is None:
del self.actions.bpy.ops
else:
self.actions.bpy.ops = original_ops
self.assertIs(result, local_brush)
def test_active_brush_prefers_current_mode_settings(self):
correct_brush = object()
context = SimpleNamespace(
brush=object(),
mode="SCULPT",
tool_settings=SimpleNamespace(
sculpt=SimpleNamespace(brush=correct_brush),
),
)
self.assertIs(self.actions._active_brush(context), correct_brush)
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")
def test_reads_macos_clipboard_image_as_png(self):
def export_image(_osascript, output_file):
output_file.write_bytes(b"png image")
with patch.object(
self.clipboard.shutil,
"which",
return_value="/usr/bin/osascript",
), patch.object(
self.clipboard,
"_export_macos_clipboard_image",
side_effect=export_image,
):
image_data, suffix = self.clipboard._read_macos_clipboard_image()
self.assertEqual(image_data, b"png image")
self.assertEqual(suffix, ".png")
def test_macos_clipboard_without_image_is_unavailable(self):
with patch.object(
self.clipboard.shutil,
"which",
return_value="/usr/bin/osascript",
), patch.object(
self.clipboard,
"_export_macos_clipboard_image",
):
with self.assertRaises(self.clipboard.ClipboardImageUnavailable):
self.clipboard._read_macos_clipboard_image()
def test_linux_clipboard_prefers_wayland_png(self):
def find_command(command_name):
if command_name == "wl-paste":
return "/usr/bin/wl-paste"
return None
with patch.object(
self.clipboard.shutil,
"which",
side_effect=find_command,
), patch.object(
self.clipboard,
"_run_image_command",
return_value=b"png image",
) as run_command:
image_data, suffix = self.clipboard._read_linux_clipboard_image()
self.assertEqual(image_data, b"png image")
self.assertEqual(suffix, ".png")
run_command.assert_called_once_with(
[
"/usr/bin/wl-paste",
"--no-newline",
"--type",
"image/png",
]
)
def test_linux_clipboard_falls_back_to_xclip(self):
def find_command(command_name):
if command_name == "xclip":
return "/usr/bin/xclip"
return None
with patch.object(
self.clipboard.shutil,
"which",
side_effect=find_command,
), patch.object(
self.clipboard,
"_run_image_command",
return_value=b"png image",
) as run_command:
image_data, suffix = self.clipboard._read_linux_clipboard_image()
self.assertEqual(image_data, b"png image")
self.assertEqual(suffix, ".png")
run_command.assert_called_once_with(
[
"/usr/bin/xclip",
"-selection",
"clipboard",
"-t",
"image/png",
"-o",
]
)
def test_linux_clipboard_requires_supported_command(self):
with patch.object(
self.clipboard.shutil,
"which",
return_value=None,
):
with self.assertRaises(self.clipboard.ClipboardImageUnavailable):
self.clipboard._read_linux_clipboard_image()
@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),
)
@staticmethod
def remove_anyimage_modules():
for module_name in tuple(sys.modules):
if module_name == "anyimage" or module_name.startswith("anyimage."):
sys.modules.pop(module_name)
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)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,34 @@
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.geometry import refine_balloon_triangles, 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_BALLOON", enabled) == "TANGENT"
for shape in ("FLAT", "SOLID"):
assert normal_mode_for_shape(shape, enabled) == ("TANGENT" if enabled else "NONE")
def test_new_cutout_does_not_reuse_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
current = load_cutout_node_group("SOLID")
assert current != saved and current.get("anyimage_asset_revision") == 11
assert saved in bpy.data.node_groups.values()
assert current == load_cutout_node_group("FLAT")
assert any(item.name == "Shape" for item in saved.interface.items_tree)
def test_refined_boundary_ears_have_interior_balloon_samples():
points = np.array([(0, 0), (1, 0), (1, 1), (0, 1)], dtype=float)
points, faces, boundary = refine_balloon_triangles(points, [(0, 1, 2), (0, 2, 3)], np.ones(4, bool))
profile = poisson_balloon_profile(points, faces, boundary)
assert np.all(profile[boundary] == 0)
assert all(any(profile[index] > 0 for index in face) for face in faces)

View File

@@ -0,0 +1,299 @@
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 import fine_mesh as fine
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)
assert not boundary[faces].all(axis=1).any()
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
assert not boundary[edges[counts == 2]].all(axis=1).any()
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)
profile = g.poisson_balloon_profile(points, faces, boundary)
assert np.isfinite(profile).all()
assert np.all(profile[interior] > 0)
@pytest.mark.parametrize("spacing", (4, 8, 16, 28, 32, 36))
@pytest.mark.parametrize("offset", (0, 1))
def test_fine_bent_leg_and_joint_have_connected_positive_support(spacing, offset):
points, faces, boundary, components = fine.build_fine_mesh(
branch_alpha(offset), spacing, 0.9
)
assert_supported(points, faces, boundary, components)
interior = points[~boundary]
for checkpoint in ((90, 70), (130, 110), (146, 159)):
assert cKDTree(interior).query(np.asarray(checkpoint) + offset)[0] < 12
@pytest.mark.parametrize("spacing", (4, 8, 16, 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 = fine.build_fine_mesh(alpha, spacing, 0.9)
assert_supported(*result)
points, faces, _boundary, components = 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, stats = fine.filter_alpha(alpha, 0.3)
assert stats["components_after"] == 1
assert filtered[12, 44] == 0.5 and filtered[32, 50] == 0
np.testing.assert_array_equal(alpha, original)
assert_supported(*fine.build_fine_mesh(alpha, 32, 0.3))
assert_supported(*fine.build_fine_mesh(alpha, 32, 0.9))
with pytest.raises(ValueError, match="no visible"):
fine.build_fine_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 = fine.contours_for_component(reference.components[0], spacing)
sampled = g.resample_closed_contour(contours[0], 0.5)
forward = fine.ContourDomain(contours).clearance(source)
backward = fine.ContourDomain(
[np.asarray(reference.components[0][0])]
).clearance(sampled)
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_segment_validation_rejects_holes_tangency_and_outside():
domain = fine.ContourDomain(
[
np.asarray(((0, 0), (20, 0), (20, 20), (0, 20))),
np.asarray(((8, 8), (12, 8), (12, 12), (8, 12))),
]
)
starts = np.asarray(((2, 2), (2, 10), (2, 8), (-1, 2), (2, 2)))
ends = np.asarray(((18, 2), (18, 10), (18, 8), (2, 2), (2, 2)))
assert domain.valid_segments(starts, ends).tolist() == [
True,
False,
False,
False,
True,
]
def test_closed_mode_uses_original_geometry_without_fine_import(monkeypatch):
from types import SimpleNamespace
from mathutils import Matrix
from anyimage.common.image import image_empty_bounds
source = SimpleNamespace(
data=SimpleNamespace(size=(80, 60)),
empty_display_size=2.0,
empty_image_offset=(-0.5, -0.5),
matrix_world=Matrix.Identity(4),
)
monkeypatch.setattr(
g,
"addon_preferences",
lambda _context: SimpleNamespace(balloon_profile_algorithm="POISSON"),
)
monkeypatch.setattr(
fine,
"build_fine_mesh",
lambda *_args: pytest.fail("Fine algorithm called while disabled"),
)
alpha = np.ones((60, 80))
actual = g.build_base_shape(
None, source, 16, alpha, (0, 0, 80, 60), fine_outline=False
)
contours = g.extract_cutout_contours(alpha, 16, threshold=0.9)
expected = g.cutout_domain(
contours,
image_empty_bounds(source),
g.cutout_sampling_spacing(source, 16),
((1.0, 0.0), (0.0, 1.0)),
)
np.testing.assert_array_equal(actual.vertices, expected[0])
np.testing.assert_array_equal(actual.uv, expected[2])
np.testing.assert_array_equal(actual.balloon, expected[-1])
def test_width_index_matches_exhaustive_covering_disks():
from scipy import ndimage
width = fine.WidthSamples(branch_alpha().astype(bool))
points = np.asarray(((48, 48), (85, 70), (130, 110), (147, 160), (2, 2)))
radii = []
for point in points:
covered = np.sum((width.centers - point) ** 2, axis=1) <= width.reach_squared
radii.append(max(0.5, width.radius[covered].max() if covered.any() else 0.5))
distances = ndimage.map_coordinates(
width.distance,
[points[:, 1] - 0.5, points[:, 0] - 0.5],
order=1,
mode="nearest",
)
expected = np.maximum(
0.7, np.minimum(32, np.minimum(16, np.asarray(radii) * 0.9) + 0.4 * distances)
)
np.testing.assert_allclose(width(points), expected)
def test_relaxation_reduces_sampling_error_without_adding_points():
contours = [np.asarray(((4.0, 4.0), (194.0, 4.0), (194.0, 104.0), (4.0, 104.0)))]
spine = np.empty((0, 2))
original = g.uniform_interior_points_for_contours(contours, 16)
relaxed = fine.relax_points(contours, spine, (110, 200), 16)
yy, xx = np.mgrid[6:104:4, 6:194:4]
samples = np.column_stack((xx.ravel() + 0.5, yy.ravel() + 0.5))
def energy(free):
return np.mean(
cKDTree(np.concatenate((*contours, free))).query(samples)[0] ** 2
)
assert len(relaxed) == len(original)
assert energy(relaxed) < energy(original)
assert fine.ContourDomain(contours).contains(relaxed).all()
def test_cdt_preserves_constraint_split_by_an_interior_vertex():
contours = [np.asarray(((0.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0)))]
spine = np.asarray(((2.0, 5.0), (18.0, 5.0)))
points, faces, constraints = fine.triangulate(
contours, spine, [(0, 1)], np.asarray(((10.0, 5.0),)), 8
)
assert len(constraints) == 2
assert np.isclose(
np.linalg.norm(
points[constraints[:, 1]] - points[constraints[:, 0]], axis=1
).sum(),
16,
)
assert not g.boundary_vertices_from_faces(len(points), faces)[constraints].any()
@pytest.mark.parametrize("offset", [0, 4096, 16384])
def test_cdt_collinear_outline_samples_do_not_become_sliver_faces(offset):
contour = np.asarray(((0., 0.), (240., 0.), (240., 160.), (0., 160.)))
contour = contour @ np.asarray(((.763, -.646), (.646, .763)))
contour += np.asarray((420.123456, 540.234567)) + offset
contours, spine, edges = fine.subdivide_constraints(
[contour], np.empty((0, 2)), [], 16
)
free = g.uniform_interior_points_for_contours(contours, 16)
points, faces, _ = fine.triangulate(contours, spine, edges, free, 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(fine.ContourDomain([contour]).clearance(points[boundary]), 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_accepts_pixel_contacts_and_rejects_crossings():
crossing = np.asarray(((0, 0), (4, 4), (0, 4), (4, 0)))
assert not fine.valid_contours([crossing])
touching = np.asarray(
((0, 0), (2, 0), (2, 2), (4, 2), (4, 4), (2, 4), (2, 2), (0, 2))
)
assert fine.valid_contours([touching])
@pytest.mark.parametrize("offset", [0, 2048, 4096, 8192, 16384])
@pytest.mark.parametrize("spacing", [4, 0.5])
def test_cdt_preserves_short_constraints_at_large_pixel_coordinates(offset, spacing):
contour = np.asarray(((0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0))) + offset
spine = np.asarray(((5.1234567, 5.654321), (6.456789, 6.234567))) + offset
free = g.uniform_interior_points_for_contours([contour], spacing)
points, faces, constraints = fine.triangulate(
[contour], spine, [(0, 1)], free, spacing
)
assert len(constraints) >= 1
assert not g.boundary_vertices_from_faces(len(points), faces)[constraints].any()
@pytest.mark.parametrize(
"damage", ["missing_edge", "missing_faces", "missing_endpoint"]
)
def test_cdt_rejects_broken_constraint_coverage(monkeypatch, damage):
real_cdt = fine.delaunay_2d_cdt
def damaged_cdt(*args):
result = list(real_cdt(*args))
result[2] = list(result[2])
result[3] = [list(ids) for ids in result[3]]
result[4] = [list(ids) for ids in result[4]]
constraint = len(args[1]) - 1
edge_id = next(i for i, ids in enumerate(result[4]) if constraint in ids)
if damage == "missing_edge":
result[4][edge_id].remove(constraint)
elif damage == "missing_faces":
a, b = result[1][edge_id]
result[2] = [face for face in result[2] if not (a in face and b in face)]
else:
endpoint = args[1][-1][1]
for originals in result[3]:
if endpoint in originals:
originals.remove(endpoint)
return result
monkeypatch.setattr(fine, "delaunay_2d_cdt", damaged_cdt)
contour = np.asarray(((0.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0)))
spine = np.asarray(((2.0, 5.0), (18.0, 5.0)))
with pytest.raises(ValueError, match="lost an interior constraint"):
fine.triangulate([contour], spine, [(0, 1)], np.asarray(((10.0, 5.0),)), 8)

View File

@@ -0,0 +1,334 @@
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_mesh_limit_caps_actual_sampling_resolution():
assert cutout_geometry.limit_cutout_point_spacing(32, (4096, 1024), 1024) == 32
assert cutout_geometry.limit_cutout_point_spacing(32, (4096, 1024), 64) == 64
assert cutout_geometry.limit_cutout_point_spacing(4, (8192, 4096), 1024) == 8
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], asset_revision=11)
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_depth_surface_cutout_uses_default_alpha_boundary():
alpha = np.zeros((12, 16), dtype=np.float32)
alpha[2:10, 2:14] = 0.89
alpha[4:8, 5:11] = 0.91
selection_contours = cutout_geometry.extract_cutout_contours(
alpha,
64,
)
contour = np.asarray(selection_contours.components[0][0])
assert selection_contours.image_size == (16, 12)
np.testing.assert_allclose(contour.min(axis=0), (5.0, 4.0))
np.testing.assert_allclose(contour.max(axis=0), (11.0, 8.0))
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_blender_cutout_contour_resolution_tracks_mesh_detail():
expected = SelectionContours(
components=(((((0, 0), (1, 0), (0, 1))),),),
image_size=(1, 1),
)
values = SimpleNamespace(shape=(1024, 1024))
with patch.object(cutout_geometry, "extract_selection_contours", return_value=expected) as trace:
assert cutout_geometry.extract_cutout_contours(values, 32) == expected
assert cutout_geometry.extract_cutout_contours(values, 4) == expected
assert trace.call_args_list[0].kwargs["max_resolution"] == 512
assert trace.call_args_list[1].kwargs["max_resolution"] == 1024
def test_cutout_sampling_spacing_uses_the_complete_source_plane():
source = SimpleNamespace(
data=SimpleNamespace(size=(400, 200)),
matrix_world=object(),
)
with (
patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)),
patch.object(
cutout_geometry,
"world_plane_metric",
return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0),
),
):
spacing = cutout_geometry.cutout_sampling_spacing(source, 32)
assert spacing == 0.32
def test_cutout_sampling_spacing_tracks_upscaled_source_pixels():
original = SimpleNamespace(
data=SimpleNamespace(size=(2048, 1024)),
matrix_world=object(),
)
upscaled = SimpleNamespace(
data=SimpleNamespace(size=(8192, 4096)),
matrix_world=object(),
)
with (
patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)),
patch.object(
cutout_geometry,
"world_plane_metric",
return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0),
),
):
original_spacing = cutout_geometry.cutout_sampling_spacing(original, 32)
upscaled_spacing = cutout_geometry.cutout_sampling_spacing(upscaled, 32)
assert upscaled_spacing == original_spacing / 4
def test_cutout_cleanup_value_scales_the_ui_without_changing_node_area():
assert np.isclose(
cutout_geometry.cutout_cleanup_value(
0.25,
((1.0, 0.0), (0.0, 1.0)),
),
6.25,
)
assert np.isclose(
cutout_geometry.cutout_cleanup_value(
0.25,
((4.0, 0.0), (0.0, 9.0)),
),
6.25 / 6.0,
)
def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_selection():
components = ((((2046, 2046), (2050, 2046), (2050, 2050), (2046, 2050)),),)
domain = cutout_geometry.cutout_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 = cutout_geometry.cutout_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 = cutout_geometry.cutout_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[:, :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 = cutout_geometry.cutout_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)

View File

@@ -0,0 +1,222 @@
import json
from types import SimpleNamespace
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()
def popup_menu_pie(self, _event, draw, **_options):
menu = SimpleNamespace(layout=SimpleNamespace(menu_pie=lambda: self.pie))
draw(menu, None)
def cutout_context(
window_manager,
mesh_detail="LOW",
generate_normal=False,
inward_axis="NEGATIVE_Z",
alpha_threshold=0.9,
fine_outline=True,
):
settings = SimpleNamespace(
cutout_mesh_detail=mesh_detail,
cutout_generate_normal=generate_normal,
cutout_inward_axis=inward_axis,
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].point_spacing == 32 for call in window_manager.pie.calls)
assert all(call[2].inward_axis == "NEGATIVE_Z" 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,
mesh_detail="MEDIUM",
generate_normal=True,
inward_axis="POSITIVE_X",
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_BALLOON",
"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].point_spacing == 16 for call in window_manager.pie.calls)
assert all(call[2].inward_axis == "POSITIVE_X" for call in window_manager.pie.calls)
def test_cutout_normal_space_is_derived_from_shape():
assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", False) == "OBJECT"
assert cutout_main.normal_mode_for_shape("DEPTH_BALLOON", False) == "TANGENT"
assert cutout_main.normal_mode_for_shape("FLAT", True) == "TANGENT"
assert cutout_main.normal_mode_for_shape("SOLID", True) == "TANGENT"
assert cutout_main.normal_mode_for_shape("DEPTH_BALLOON", True) == "TANGENT"
assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", True) == "OBJECT"
def test_cutout_mesh_detail_maps_to_denser_sampling():
assert [
interaction.cutout_mesh_detail_spacing(detail)
for detail in ("LOW", "MEDIUM", "HIGH", "ULTRA")
] == [32, 16, 8, 4]
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())
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_only_binds_lasso_drag_and_native_selection():
keymap = cutout_main.CutoutTool.bl_keymap
assert keymap[0][1]["value"] == "CLICK_DRAG"
assert [entry[0] for entry in keymap[1:]] == ["view3d.select", "view3d.select"]
assert all(entry[1]["value"] != "DOUBLE_CLICK" for entry in keymap)
assert "full_image" not in cutout_main.SelectCutoutSelection.__annotations__
assert keymap[1][2] == {"properties": [("deselect_all", False)]}
assert keymap[2][2] == {"properties": [("toggle", True)]}
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_status_has_no_letter_shortcut():
status = []
operator = SimpleNamespace()
context = SimpleNamespace(
workspace=SimpleNamespace(status_text_set=status.append)
)
cutout_main.SelectCutoutSelection._set_status(operator, context)
assert status == [
"Cutout Lasso: drag and release for shapes; RMB or Esc cancels"
]
assert "F" not in status[0]

View File

@@ -0,0 +1,970 @@
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import numpy as np
import pytest
from mathutils import Matrix, Vector
from anyimage.common import depth as depth_data
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 object as cutout_object
from anyimage.operators.cutout_tool import shape as cutout_shape
from anyimage.server.geometry.depth_texture import (
write_depth_texture,
)
from anyimage.server.models.geometry import GeometryFrame
from tests.support.image_objects import _add_cutout_modifier, _create_cutout_shape, _depth_image, _depth_metadata, _depth_surface_step_object, _evaluated_positions, _evaluated_topology, _group_node, _inputs
def test_object_normal_image_follows_cutout_inward_axis():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new(
"Object Normal",
width=1,
height=1,
alpha=True,
float_buffer=True,
)
decoded = np.asarray((0.2, -0.4, 0.8), dtype=np.float32)
encoded = decoded * 0.5 + 0.5
image.pixels.foreach_set((*encoded, 0.3))
cutout_object.orient_object_normal_image(
image,
cutout_object.CutoutInwardAxis.NEGATIVE_Z,
)
actual = np.empty(4, dtype=np.float32)
image.pixels.foreach_get(actual)
assert np.allclose(actual[:3], encoded, atol=1e-6)
assert np.isclose(actual[3], 0.3)
cutout_object.orient_object_normal_image(
image,
cutout_object.CutoutInwardAxis.POSITIVE_X,
)
image.pixels.foreach_get(actual)
expected = np.asarray((-0.8, -0.2, -0.4), dtype=np.float32) * 0.5 + 0.5
assert np.allclose(actual[:3], expected, atol=1e-6)
assert np.isclose(actual[3], 0.3)
@pytest.mark.parametrize("shape", ("FLAT", "SOLID"))
def test_cutout_axes_produce_the_same_world_geometry(shape):
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Axis Source", width=32, height=16, alpha=True)
source = bpy.data.objects.new("Axis Source", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
source.matrix_world = (
Matrix.Translation((2.0, -3.0, 1.0))
@ Matrix.Rotation(0.35, 4, "Y")
@ Matrix.Diagonal((1.5, 0.75, 2.0, 1.0))
)
bpy.context.collection.objects.link(source)
positive_x = _create_cutout_shape(
source,
shape,
8,
inward_axis=cutout_object.CutoutInwardAxis.POSITIVE_X,
)
negative_z = _create_cutout_shape(
source,
shape,
8,
inward_axis=cutout_object.CutoutInwardAxis.NEGATIVE_Z,
)
for result in (positive_x, negative_z):
local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices])
assert np.allclose(local[:, 2], 0.0, atol=1e-8)
assert np.ptp(local[:, 0]) > 0.0
assert np.ptp(local[:, 1]) > 0.0
assert all(polygon.normal.z > 0.99 for polygon in result.data.polygons)
def world_positions(obj):
local = _evaluated_positions(obj)
return np.asarray([tuple(obj.matrix_world @ Vector(point)) for point in local])
expected = np.asarray(sorted(map(tuple, world_positions(positive_x))))
actual = np.asarray(sorted(map(tuple, world_positions(negative_z))))
from scipy.optimize import linear_sum_assignment
distances = np.linalg.norm(actual[:, None] - expected[None], axis=2)
rows, columns = linear_sum_assignment(distances)
assert distances[rows, columns].max() < 2e-6
assert np.allclose(negative_z.matrix_world, source.matrix_world)
def test_depth_result_image_loads_server_vector_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), 1.0)
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 > 0.5)
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), 2.0),
)
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_cutout_presets_load_three_direct_modifier_assets():
bpy.ops.wm.read_factory_settings(use_empty=True)
groups = {shape: cutout_object.load_cutout_node_group(shape) for shape in cutout_shape.CUTOUT_SHAPES}
assert groups["FLAT"] == groups["SOLID"]
assert len(set(groups.values())) == 3
for shape, group in groups.items():
assert group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape]
assert group.is_modifier
assert "Shape" not in _inputs(group)
assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes)
assert all(socket.is_linked or socket.hide for node in group.nodes for socket in node.outputs)
for shape in ("SOLID", "DEPTH_SOLID"):
group = groups[shape]
inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"}
assert next(s for s in group.interface.items_tree if s.name == "Thickness").default_value == 1
thickness = [item for item in group.interface.items_tree if item.name == "Thickness"]
assert [item.subtype for item in thickness] == ["NONE", "DISTANCE"]
assert inputs["Thickness"].subtype == "DISTANCE"
assert inputs["Mode"].socket_type == "NodeSocketMenu"
assert inputs["Mode"].default_value == "Balloon"
assert inputs["Smooth"].default_value == 0
assert inputs["Smooth Weight"].default_value == 1
assert "Edge Smooth" not in inputs
assert not any(node.bl_idname == "GeometryNodeRepeatInput" and node.inputs["Iterations"].default_value == 8 for node in group.nodes)
assert next(item for item in groups["DEPTH_BALLOON"].interface.items_tree if item.name == "Double Sided").default_value
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",
8,
)
assert all(abs(vertex.co.z) < 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
normal_scale_attribute = next(
node
for node in material_tree.nodes
if node.bl_idname == "ShaderNodeAttribute"
)
assert normal_scale_attribute.attribute_type == "GEOMETRY"
assert normal_scale_attribute.attribute_name == cutout_shape.NORMAL_SCALE_ATTRIBUTE_NAME
image_layer = _group_node(material_data.material_node_group(), material_tree)
normal_scale_link = next(
link
for link in material_tree.links
if link.to_node == image_layer
and link.to_socket == image_layer.inputs["Normal Scale"]
)
assert normal_scale_link.from_node == normal_scale_attribute
assert normal_scale_link.from_socket == normal_scale_attribute.outputs["Fac"]
assert result.modifiers[0].node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["SOLID"]
inward = result.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))
assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6)
def test_teddy_preference_writes_the_shared_balloon_attribute():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Teddy Sheet", width=64, height=64, alpha=True)
source = bpy.data.objects.new("Teddy Sheet", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
with patch.object(
cutout_geometry,
"addon_preferences",
return_value=SimpleNamespace(balloon_profile_algorithm="TEDDY"),
):
result = _create_cutout_shape(
source,
"FLAT",
8,
)
attribute = result.data.attributes[cutout_shape.BALLOON_ATTRIBUTE_NAME]
profile = np.empty(len(result.data.vertices), dtype=np.float32)
attribute.data.foreach_get("value", profile)
assert profile.min() == 0.0 and profile.max() > 0.0
assert any(
node.bl_idname == "ShaderNodeAttribute"
for node in result.data.materials[0].node_tree.nodes
)
def test_depth_surface_protected_smooth_evaluates():
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), 1.0)
result = _create_cutout_shape(
source,
"DEPTH_SOLID",
8,
depth_image=depth_image,
depth_metadata=depth_metadata,
)
assert all(abs(vertex.co.z) < 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"]
expected_cleanup = cutout_geometry.cutout_cleanup_value(
cutout_geometry.cutout_sampling_spacing(source, 8),
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")],
expected_cleanup,
)
assert np.isclose(modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")], 1.0)
assert (
modifier[
object_data.modifier_input_identifier(modifier.node_group, "Smooth")
]
== 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)
reference_id = object_data.modifier_input_identifier(
modifier.node_group,
"Reference Depth",
)
assert modifier[reference_id] == 8.0
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"),
0.0,
)
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
baseline_mesh = evaluated.to_mesh()
try:
baseline_z = np.asarray([vertex.co.z for vertex in baseline_mesh.vertices])
baseline_min = baseline_z.min()
baseline_max = baseline_z.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
z = np.asarray([vertex.co.z for vertex in evaluated_mesh.vertices])
assert np.isclose(z.min(), baseline_min - 1.0, atol=1e-5)
assert np.isclose(z.max(), baseline_max, atol=1e-5)
finally:
evaluated.to_mesh_clear()
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Smooth"),
4,
)
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"),
1.0,
)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
internal_mesh = evaluated.to_mesh()
try:
internal_positions = np.asarray(
[tuple(vertex.co) for vertex in internal_mesh.vertices]
)
finally:
evaluated.to_mesh_clear()
assert internal_positions.ndim == 2
assert internal_positions.shape[1] == 3
assert np.isfinite(internal_positions).all()
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",
8,
depth_image=_depth_image(points, validity),
depth_metadata=_depth_metadata(intrinsics, (size, size), 1.0),
)
modifier = result.modifiers[0]
for name, value in (("Thickness", 0.0), ("Smooth", 0)):
object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, name), value)
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[:, 1]) > 1.5
assert np.ptp(positions[:, 2]) < 1e-4
def test_depth_surface_split_preserves_original_faces():
bpy.ops.wm.read_factory_settings(use_empty=True)
obj, modifier = _depth_surface_step_object()
split_id = object_data.modifier_input_identifier(modifier.node_group, "Split Threshold")
for value in (0., .5, 1.):
object_data.set_modifier_input(modifier, split_id, value)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
vertices, faces, loose_edges, loose_points = _evaluated_topology(obj)
assert vertices >= 9 and faces == 8
assert not loose_edges and not loose_points
def test_cutout_thickness_keeps_one_connected_shell():
bpy.ops.wm.read_factory_settings(use_empty=True)
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
faces = []
for y in range(2):
for x in range(2):
a = y * 3 + x
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
data = bpy.data.meshes.new("Connected Cutout")
data.from_pydata(vertices, (), faces)
obj = bpy.data.objects.new("Connected Cutout", data)
bpy.context.collection.objects.link(obj)
modifier = _add_cutout_modifier(obj, "FLAT")
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold"),
0.0,
)
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
1.0,
)
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
assert len(result.vertices) == 18
assert len(result.polygons) == 24
z = np.asarray([vertex.co.z for vertex in result.vertices])
assert np.isclose(z.min(), -0.5, atol=1e-6)
assert np.isclose(z.max(), 0.5, atol=1e-6)
front_faces = [
polygon for polygon in result.polygons if polygon.normal.z > 0.9
]
back_faces = [polygon for polygon in result.polygons if polygon.normal.z < -0.9]
side_faces = [
polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1
]
assert len(front_faces) == 8
assert len(back_faces) == 8
assert len(side_faces) == 8
center = Vector((1.0, 1.0, 0.0))
for polygon in side_faces:
radial = polygon.center - center
assert polygon.normal.dot(radial) > 0.0
finally:
evaluated.to_mesh_clear()
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
2.4,
)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
assert len(result.vertices) == 34
assert len(result.polygons) == 40
side_faces = [
polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1
]
assert len(side_faces) == 24
for polygon in side_faces:
coordinates = [result.vertices[index].co for index in polygon.vertices]
edge_lengths = [
(coordinates[(index + 1) % len(coordinates)] - coordinate).length
for index, coordinate in enumerate(coordinates)
]
assert max(edge_lengths) / min(edge_lengths) <= 1.25 + 1e-6
finally:
evaluated.to_mesh_clear()
def test_balloon_uses_injected_profile_at_default_height():
bpy.ops.wm.read_factory_settings(use_empty=True)
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
faces = []
for y in range(2):
for x in range(2):
a = y * 3 + x
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
data = bpy.data.meshes.new("Balloon Profile")
data.from_pydata(vertices, (), faces)
cutout_object._assign_float_point_attribute(
data,
cutout_shape.BALLOON_ATTRIBUTE_NAME,
(0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0),
)
obj = bpy.data.objects.new("Balloon Profile", data)
bpy.context.collection.objects.link(obj)
modifier = _add_cutout_modifier(obj, "SOLID")
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold"),
0.0,
)
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
1.0,
)
bpy.context.view_layer.update()
positions = _evaluated_positions(obj)
assert np.isclose(positions[:, 2].min(), -1.0, atol=1e-6)
assert np.isclose(positions[:, 2].max(), 1.0, atol=1e-6)
assert np.isclose(
positions[:, 2].min(),
-positions[:, 2].max(),
atol=1e-5,
)
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
baseline_face_count = len(result.polygons)
finally:
evaluated.to_mesh_clear()
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
2.0,
)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
assert len(result.polygons) == baseline_face_count
z = np.asarray([vertex.co.z for vertex in result.vertices])
assert np.isclose(z.min(), -2.0, atol=1e-6)
assert np.isclose(z.max(), 2.0, atol=1e-6)
finally:
evaluated.to_mesh_clear()
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
0.0,
)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
zero_positions = _evaluated_positions(obj)
assert len(zero_positions) == len(vertices)
assert np.allclose(zero_positions[:, 2], 0.0, atol=1e-6)
def test_cutout_cleanup_uses_absolute_area_with_many_small_islands():
bpy.ops.wm.read_factory_settings(use_empty=True)
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
faces = []
for y in range(2):
for x in range(2):
a = y * 3 + x
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
for island in range(12):
start = len(vertices)
x = 5.0 + island * 0.1
vertices.extend(((x, 5.0, 0.0), (x + 0.01, 5.0, 0.0), (x, 5.01, 0.0)))
faces.append((start, start + 1, start + 2))
data = bpy.data.meshes.new("Cleanup Threshold")
data.from_pydata(vertices, (), faces)
obj = bpy.data.objects.new("Cleanup Threshold", data)
bpy.context.collection.objects.link(obj)
modifier = _add_cutout_modifier(obj, "FLAT")
cleanup_id = object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")
object_data.set_modifier_input(modifier, cleanup_id, 0.0)
bpy.context.view_layer.update()
assert len(_evaluated_positions(obj)) == 45
object_data.set_modifier_input(modifier, cleanup_id, 0.1)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
assert len(_evaluated_positions(obj)) == 9
def test_depth_balloon_uses_metric_plane_inputs_without_controller():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Depth Balloon", width=100, height=100, alpha=True)
source = bpy.data.objects.new("Depth Balloon", 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), 2.0)
selection_values = np.ones((60, 40), dtype=np.float32)
selection_values[:, :2] = 0.0
result = _create_cutout_shape(
source,
"DEPTH_BALLOON",
8,
depth_image=depth_image,
depth_metadata=depth_metadata,
bounds=crop_bounds,
selection_values=selection_values,
)
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
assert "o_balloon_depth" not in result.data.attributes
assert "o_depth_offset" not in result.data.attributes
assert not result.children
assert {obj.name for obj in bpy.context.scene.objects} == {
source.name,
result.name,
}
modifier = result.modifiers[0]
depth_image_id = object_data.modifier_input_identifier(
modifier.node_group,
"Depth Image",
)
assert isinstance(modifier[depth_image_id], bpy.types.Image)
expected_center = np.zeros(2, dtype=np.float64)
crop_center = Vector(
(
(local_bounds[0] + local_bounds[1]) * 0.5,
(local_bounds[2] + local_bounds[3]) * 0.5,
0.0,
)
)
expected_origin = source.matrix_world @ crop_center
assert np.allclose(
tuple(result.matrix_world.translation),
tuple(expected_origin),
atol=1e-5,
)
depth_reference_id = object_data.modifier_input_identifier(
modifier.node_group,
"Reference Depth",
)
assert not np.isclose(modifier[depth_reference_id], 8.0)
depth_direction_id = object_data.modifier_input_identifier(
modifier.node_group,
"Depth Direction",
)
direction = np.asarray(modifier[depth_direction_id], dtype=np.float64)
assert not np.allclose(direction, (0.0, 0.0, 1.0))
uniform_scale_id = object_data.modifier_input_identifier(
modifier.node_group,
"Uniform Scale",
)
assert modifier[uniform_scale_id] > 0.0
thickness_id = object_data.modifier_input_identifier(
modifier.node_group,
"Thickness",
)
smooth_id = object_data.modifier_input_identifier(
modifier.node_group,
"Smooth Weight",
)
assert modifier[smooth_id] == 1.0
object_data.set_modifier_input(modifier, smooth_id, 0.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
positions = _evaluated_positions(result)
center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1)
nearest_center = center_distance <= center_distance.min() + 1e-6
depth_at_thickness_one = positions[nearest_center, 2].max()
object_data.set_modifier_input(modifier, thickness_id, 2.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
positions = _evaluated_positions(result)
center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1)
nearest_center = center_distance <= center_distance.min() + 1e-6
depth_at_thickness_two = positions[nearest_center, 2].max()
assert np.isclose(
depth_at_thickness_one,
depth_at_thickness_two,
atol=1e-5,
)
object_data.set_modifier_input(modifier, thickness_id, 1.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
before_positions = _evaluated_positions(result)
object_data.set_modifier_input(
modifier,
depth_reference_id,
modifier[depth_reference_id] + 0.2,
)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
after_positions = _evaluated_positions(result)
assert np.isclose(
(after_positions[:, 2] - before_positions[:, 2]).max(),
0.2,
atol=1e-5,
)
depth_scale_id = object_data.modifier_input_identifier(
modifier.node_group,
"Depth Scale",
)
object_data.set_modifier_input(modifier, depth_scale_id, 0.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
zero_scale_before = _evaluated_positions(result)
object_data.set_modifier_input(
modifier,
depth_reference_id,
modifier[depth_reference_id] + 0.15,
)
object_data.set_modifier_input(modifier, depth_direction_id, (0.2, 0.1, 1.0))
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
zero_scale_after = _evaluated_positions(result)
assert np.allclose(zero_scale_after, zero_scale_before, atol=1e-5)
object_data.set_modifier_input(modifier, depth_scale_id, 1.0)
double_sided_id = object_data.modifier_input_identifier(
modifier.node_group,
"Double Sided",
)
object_data.set_modifier_input(modifier, double_sided_id, True)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
mirrored = _evaluated_positions(result)
layers = {}
for x, y, z in mirrored:
layers.setdefault((round(float(x), 6), round(float(y), 6)), []).append(z)
assert layers
for z_values in layers.values():
if len(z_values) == 1:
assert np.isclose(z_values[0], 0.0, atol=1e-5)
continue
assert np.isclose(
min(z_values) + max(z_values),
0.0,
atol=1e-5,
)
def test_bounds_input_preserves_rectangular_rgba_and_stays_packed_after_cleanup():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Bounds Source", width=5, height=4, alpha=True)
rgba = np.arange(4 * 5 * 4, dtype=np.float32).reshape((4, 5, 4)) / 100.0
rgba[:, :, 3] = np.asarray(
(
(0.0, 0.1, 0.2, 0.3, 0.4),
(0.5, 0.6, 0.7, 0.8, 0.9),
(1.0, 0.9, 0.8, 0.7, 0.6),
(0.5, 0.4, 0.3, 0.2, 0.1),
),
dtype=np.float32,
)
path = image_data.prepare_image_bounds_input(image, rgba, (1, 1, 5, 4))
result = None
try:
result = image_data.load_packed_rgba_image(
path,
image,
"Bounds Result",
)
loaded = image_data.image_rgba(result)
np.testing.assert_allclose(loaded, rgba[1:4, 1:5], atol=1.0 / 255.0)
assert result.packed_file is not None
image_data.cleanup_image_input(path, True)
assert not path.exists()
assert result.packed_file is not None
finally:
image_data.cleanup_image_input(path, True)
if result is not None:
bpy.data.images.remove(result, do_unlink=True)
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 = cutout_object.create_cropped_color_image(
source,
selection_mask.bounds,
rgba,
)
cutout_main.create_cutout_shape(
bpy.context,
source,
"FLAT",
16,
content_values,
selection_mask.bounds,
alpha_threshold=0.5,
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"]
expected_cleanup = cutout_geometry.cutout_cleanup_value(
cutout_geometry.cutout_sampling_spacing(source, 16),
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")],
expected_cleanup,
)
assert (
modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")]
== 1.0
)
assert (
modifier[
object_data.modifier_input_identifier(modifier.node_group, "Smooth")
]
== 0
)
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.z) < 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])
)

View File

@@ -0,0 +1,412 @@
import importlib
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from tests.support.blender import BlenderTestCase
class CutoutOperatorsTest(BlenderTestCase):
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,
point_spacing=32,
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, "video_total_frames", return_value=1
),
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,
"configured_max_cutout_mesh_resolution",
return_value=1024,
),
patch.object(
self.cutout_main,
"_cutout_content_values",
return_value=selection_mask.values,
),
patch.object(
self.cutout_main,
"create_cropped_color_image",
return_value="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,
32.0,
selection_mask.values,
selection_mask.bounds,
inward_axis="NEGATIVE_Z",
alpha_threshold=0.25,
fine_outline=False,
color_image="Color",
)
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.point_spacing = 32
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, "video_total_frames", return_value=1),
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,
"configured_max_cutout_mesh_resolution",
return_value=1024,
),
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_image_bounds_input",
return_value=Path(__file__),
) as prepare_input,
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,
source_rgba,
selection_mask.bounds,
)
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",
point_spacing=32,
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, "video_total_frames", return_value=1),
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.source_object_name = "Source"
operator.shape = "DEPTH_SOLID"
operator.model = self.anyimage.properties.DEFAULT_MOGE2_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, "production_device", return_value="CPU"
),
patch.object(self.cutout_main, "source_start_frame", return_value=1),
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.assertEqual(parameters["alpha_threshold"], 0.25)
self.assertEqual(parameters["normal_mode"], "OBJECT")
self.assertNotIn("polygon", parameters)
self.assertNotIn("shape", parameters)
self.assertNotIn("normal_analysis", parameters)
def test_cutout_geometry_settings_use_configured_moge(self):
preferences = SimpleNamespace(
geometry_model="MOGE2_VITB_NORMAL",
geometry_resolution_level=7,
)
with patch.object(
self.cutout_main,
"addon_preferences",
return_value=preferences,
):
settings = self.cutout_main.cutout_geometry_settings(None)
self.assertEqual(
settings,
("MOGE2_VITB_NORMAL", 7),
)
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,
[
setup,
("prop", "cutout_generate_normal", False),
("prop", "cutout_mesh_detail", True),
("prop", "cutout_fine_outline", True),
("prop", "cutout_alpha_threshold", True),
("prop", "cutout_inward_axis", True),
],
)
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_lasso(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.shape = "FLAT"
operator.point_spacing = 32
operator.generate_normal = True
operator._selection_mask = selection_mask
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,
):
message = operator.response(SimpleNamespace(), result)
color_result.assert_called_once_with(
__file__,
source,
selection_mask.bounds,
)
self.assertEqual(message, "Flat is ready")
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)

View File

@@ -0,0 +1,132 @@
from unittest.mock import patch
import numpy as np
import pytest
from anyimage.common.image import premultiplied_rgba
from anyimage.operators import frame_tool as frame
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.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)), "ADD"
).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.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_add_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)), "ADD"
).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 == "PREMUL"
finally:
bpy.data.images.remove(original)
bpy.data.images.remove(result)

View File

@@ -0,0 +1,36 @@
import numpy as np
import pytest
from anyimage.common.selection import clip_polygon_halfplanes
from anyimage.operators import frame_tool as frame
@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.frame_source_projection(
projection, np.eye(4), world, (-1, 1, -1, 1), (100, 100), perspective=True
)
source["image_size"] = (100, 100)
assert frame.frame_source_coordinates(source, np.array(((50, 50),)))[2][0]
overlap = frame.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)

View File

@@ -0,0 +1,910 @@
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_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(
self.image_frame.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(
self.image_frame.frame_output_size(frame, (1000, 800), 2048),
(1000, 500),
)
self.assertEqual(
self.image_frame.frame_output_size(frame, (4000, 2000), 500),
(500, 250),
)
def test_frame_requires_positive_valid_projection_overlap(self):
import numpy as np
source = self.image_frame.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(
self.image_frame.frame_source_overlap(
source,
((50.0, 150.0), (150.0, 150.0), (150.0, 50.0), (50.0, 50.0)),
)
)
self.assertFalse(
self.image_frame.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 = self.image_frame.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 = self.image_frame.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(
self.image_frame.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"):
self.image_frame.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 = self.image_frame.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 = (
self.image_frame.frame_source_coordinates(
source,
np.asarray(((75.0, 50.0),)),
)
)
_rear_points, rear_depth, rear_valid, _normalized = (
self.image_frame.frame_source_coordinates(
source,
np.asarray(((-50.0, 50.0),)),
)
)
overlap = self.image_frame.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 = (
self.image_frame.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 = self.image_frame.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 = self.image_frame.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 = self.image_frame.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(
self.image_frame.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 = self.image_frame.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 = self.image_frame.frame_source_overlap(source, frame)
depth_location = self.image_frame.frame_depth_location(source, overlap)
self.assertLess(depth_location[2], 0.0)
def test_frame_keeps_positive_active_origin_as_result_depth_location(self):
import numpy as np
marker = SimpleNamespace(copy=lambda: "active-origin")
source = {
"object": SimpleNamespace(
matrix_world=SimpleNamespace(translation=marker),
),
"origin_depth": 1e-14,
"perspective": True,
}
self.assertEqual(
self.image_frame.frame_depth_location(source, ((0.0, 0.0),)),
"active-origin",
)
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_geometry_faces_the_view_and_uses_frame_center(self):
import numpy as np
matrix, display_size = self.image_frame.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)
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": self.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 = self.image_frame.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 = self.image_frame.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 = self.image_frame.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 = self.image_selection.SelectionMask(np.ones((4, 4)), (0, 0, 4, 4))
removed = self.edit_mask.apply_alpha_mask(rgba, mask, "SUBTRACT")
source = self._frame_pixel_source(np.flipud(removed.reshape(rgba.shape)))
framed = self.image_frame.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 = self.edit_mask.apply_alpha_mask(result, mask, "ADD")
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 = self.image_frame.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 = self.image_frame.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(self.image_frame.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 = self.image_frame.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(self.image_frame.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(self.image_frame.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(self.image_frame.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 = self.image_frame.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(self.image_frame, "PROJECTIVE_CHUNK_ROWS", rows):
results.append(np.flipud(self.image_frame.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 = self.image_frame.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 = self.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 = self.image_frame.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 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)
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 = self.image_frame.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=self.image_data.premultiplied_rgba(
np.flipud(rgba).ravel(), (width, height)
),
)
return source

View File

@@ -0,0 +1,246 @@
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 import frame_tool as edit_frame
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 == "PREMUL"
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_add_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 = edit_frame.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 == "PREMUL"
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 == "PREMUL"
operator.mode = "ADD"
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 = edit_frame.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="ADD", 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
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"])
def test_mask_commit_is_one_undo_transaction(gesture):
source = bpy.data.images.new("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("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 == "PREMUL"
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
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,69 @@
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 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), "reference_depth": 2.0,
"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["Valid Only"].identifier] is True
assert modifier[inputs["Reference Depth"].identifier] == pytest.approx(
2 * 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), "reference_depth": 2,
"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,264 @@
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.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 tools.nodes.groups.image_plane import build_image_plane_group
from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group
from tools.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)
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[:, :2].min(axis=0), (-1.0, -0.5), atol=1e-6)
assert np.allclose(local[:, :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),
1.0,
),
"DEPTH",
)
assert_centered(depth_plane, depth_bounds, depth_matrix)
assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE"
depth_modifier = depth_plane.modifiers[0]
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),
1.0,
),
"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
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)
baseline = _evaluated_positions(result)
assert np.isclose(baseline[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(baseline[:, 2].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(z, 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[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(outward[:, 2].max(), 0.3, atol=1e-5)
outward_base = {
(round(float(x), 6), round(float(y), 6))
for x, y, z in outward
if np.isclose(z, 0.0, atol=1e-6)
}
assert outward_base == baseline_base
z_levels = np.unique(np.round(outward[:, 2], 6))
assert len(z_levels) >= 4
assert np.allclose(np.diff(z_levels), np.diff(z_levels)[0], atol=1e-5)
set_input("Reference Depth", 0.95)
inward = _evaluated_positions(result)
assert np.isclose(inward[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(inward[:, 2].max(), 0.05, atol=1e-5)
set_input("Reference Depth", 0.0)
limited = _evaluated_positions(result)
assert np.isclose(limited[:, 2].min(), 0.0, atol=1e-6)
assert limited[:, 2].max() > 0.0
assert limited[:, 2].max() < 0.001
set_input("Reference Depth", 1.3)
outward_beyond_thickness = _evaluated_positions(result)
assert np.isclose(outward_beyond_thickness[:, 2].max(), 0.4, atol=1e-5)
set_input("Thickness", 0.0)
set_input("Reference Depth", 1.2)
sheet = _evaluated_positions(result)
assert np.allclose(sheet[:, 2], 0.2, atol=1e-5)

View File

@@ -0,0 +1,202 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from tests.support.blender import BlenderTestCase
class PlaneOperatorsTest(BlenderTestCase):
def test_plane_depth_off_accepts_animated_image_without_server(self):
operator = SimpleNamespace(
depth_mode="OFF",
remove_background=False,
shadeless=False,
profile="SURFACE",
mesh_detail=64,
thickness=0.1,
inflation=0.1,
algorithm="POISSON",
)
source = SimpleNamespace(data=SimpleNamespace(name="Sequence"))
context = SimpleNamespace(object=source, scene=object())
with (
patch.object(self.convert_to_plane, "video_total_frames", return_value=24),
patch.object(
self.convert_to_plane,
"image_user_settings",
return_value=(1, 0, 24, True),
),
patch.object(self.convert_to_plane, "create_image_material", return_value="Material"),
patch.object(self.convert_to_plane, "create_color_image", return_value="Color"),
patch.object(self.convert_to_plane, "create_plane_object") as build_mesh,
):
result = self.convert_to_plane.ConvertToPlane.execute(operator, context)
self.assertEqual(result, {"FINISHED"})
build_mesh.assert_called_once()
def test_depth_plane_rejects_animation_and_requests_ai_setup(self):
operator = SimpleNamespace(mesh_detail=6, report=Mock())
context = SimpleNamespace(object=SimpleNamespace(data=object()))
with (
patch.object(self.convert_to_plane, "video_total_frames", return_value=2),
patch.object(self.convert_to_plane, "invoke_ai_setup_if_needed") as setup,
):
self.assertEqual(self.convert_to_plane.ConvertToDepthPlane.execute(operator, context), {"CANCELLED"})
setup.assert_not_called()
with (
patch.object(self.convert_to_plane, "video_total_frames", return_value=1),
patch.object(self.convert_to_plane, "invoke_ai_setup_if_needed", return_value={"FINISHED"}) as setup,
patch.object(self.convert_to_plane, "prepare_image_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", data=object())
context = SimpleNamespace(object=source, scene=object())
operator = SimpleNamespace(mesh_detail=4, report=Mock())
generate = Mock(return_value={"FINISHED"})
with (
patch.object(module, "video_total_frames", return_value=1),
patch.object(module, "invoke_ai_setup_if_needed", return_value=None),
patch.object(module, "prepare_image_input", return_value=(Path("input.png"), True)),
patch.object(module, "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.assertNotIn("depth_mode", generate.call_args.kwargs)
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 = {"reference_depth": 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", mesh_detail=6, plane_type=plane_type)
context = SimpleNamespace(scene=object())
ops = SimpleNamespace(
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
)
with (
patch.object(module, "require_image_empty", return_value=source),
patch.object(module, "load_color_result_image", return_value=color_image),
patch.object(module, "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_image_empty", return_value=source),
patch.object(module, "load_color_result_image", return_value=color_image),
patch.object(
module,
"load_depth_metadata",
return_value={"reference_depth": 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", mesh_detail=6, plane_type="DEPTH"),
)
remove_material.assert_called_once_with(material, do_unlink=True)
self.assertEqual(
[entry.args[0] for entry in remove_image.call_args_list],
[color_image, depth_image, normal_image],
)
self.assertTrue(
all(
entry.kwargs == {"do_unlink": True}
for entry in remove_image.call_args_list
)
)

View File

@@ -0,0 +1,237 @@
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.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.interactive_preview_polygon(
points, points[-1]
)
moving = self.rectify.interactive_preview_polygon(
points, (20.0, 70.0)
)
self.assertEqual(len(stationary), 3)
self.assertEqual(len(moving), 4)
def test_rectify_draws_scene_linear_texture_as_srgb_in_blender_5(self):
draw_options = self.rectify.preview_texture_draw_options
self.assertEqual(draw_options((4, 5, 3)), {})
self.assertEqual(
draw_options((5, 0, 0)),
{"is_scene_linear_with_rec709_srgb_target": True},
)
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.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.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.canonical_perspective_quad(corners)
for permutation in itertools.permutations(corners):
np.testing.assert_allclose(
self.rectify.canonical_perspective_quad(permutation),
ordered,
)
def test_rectify_detects_source_image_overlap(self):
overlaps = self.rectify.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.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.MAX_INTERACTIVE_ASPECT,
)
self.assertEqual(
self.rectify.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_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.perspective_quad_aspect(
((0, 0), (8, 0), (8, 4), (0, 4))
)
self.assertAlmostEqual(ratio, 2.0)
def test_rectify_ignores_an_already_removed_preview(self):
operator = self.rectify.RectifyImagePerspective()
operator._preview_image = object()
def remove(*_args, **_options):
raise ReferenceError("StructRNA of type Image has been removed")
self.fake_bpy.data = SimpleNamespace(images=SimpleNamespace(remove=remove))
operator._remove_preview()
self.assertIsNone(operator._preview_image)
def test_rectify_maps_crop_to_source_plane_bounds(self):
bounds = self.rectify.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,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
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.0001.png")
def execute(operator, context):
operator.source_object_name = owner_name
operator.source_image_name = source_name
operator.model = "PISA_SR"
operator.response(context, SimpleNamespace(directory=tmp_path))
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,65 @@
from tests.support.paths import PROJECT_ROOT
import importlib.util
import sys
import tempfile
import time
import unittest
from pathlib import Path
def load_server_module():
server_directory = PROJECT_ROOT / "src" / "anyimage" / "server"
specification = importlib.util.spec_from_file_location(
"server",
server_directory / "__init__.py",
submodule_search_locations=[str(server_directory)],
)
module = importlib.util.module_from_spec(specification)
sys.modules["server"] = module
specification.loader.exec_module(module)
return module
class ServerTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server_module = load_server_module()
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.server = cls.server_module.server
cls.app_module = sys.modules["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)
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,100 @@
from tests.support.paths import PROJECT_ROOT
import sys
from pathlib import Path
from types import SimpleNamespace
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))
def _make_fake_av(frame_count):
"""Build a fake av module yielding frame_count integer-valued frames."""
class FakeImage:
def __init__(self, value):
self.value = value
def save(self, path, format=None):
Path(path).write_text(str(self.value), encoding="utf-8")
class FakeFrame:
def __init__(self, value):
self.value = value
def to_image(self):
return FakeImage(self.value)
class FakeContainer:
def __init__(self):
self.position = 0
self.closed = False
self.streams = SimpleNamespace(video=[object()])
def decode(self, _stream):
while self.position < frame_count:
frame = FakeFrame(self.position)
self.position += 1
yield frame
def close(self):
self.closed = True
class FakeAv:
def __init__(self):
self.containers = []
def open(self, _path):
container = FakeContainer()
self.containers.append(container)
return container
return FakeAv()
class FakeModels:
def __init__(self, directory="model"):
self.model_directory = Path(directory)
def directory(self, _model):
return self.model_directory
def get_ben2(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,236 @@
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
import pytest
from PIL import Image
from server.geometry import prediction_artifacts as artifacts
from server.geometry.edge_depth import extend_edge_depth
from server.models.geometry import GeometryFrame
from server.models.onnx_moge2 import depth_to_points
def make_frame(depth):
depth = np.asarray(depth, dtype=np.float32)
height, width = depth.shape
normalized = np.array([[1.2, 0, 0.4], [0, 1.4, 0.6], [0, 0, 1]], np.float32)
intrinsics = normalized.copy()
intrinsics[0] *= width
intrinsics[1] *= height
return GeometryFrame(
depth=depth,
validity=np.ones_like(depth),
intrinsics=intrinsics,
points=depth_to_points(depth, normalized),
normal=np.zeros((*depth.shape, 3), np.float32),
)
def silhouette():
alpha = np.zeros((25, 35), np.float32)
alpha[4:21, 4:22] = 1
alpha[4:21, 22:25] = 0.2
depth = np.full(alpha.shape, 20, np.float32)
depth[alpha == 1] = 2
return make_frame(depth), alpha
def test_hair_extension_preserves_core_alpha_and_projection():
frame, alpha = silhouette()
original_alpha = alpha.copy()
result = extend_edge_depth(frame, alpha)
assert np.all(result.depth[alpha >= 0.1] == 2)
np.testing.assert_array_equal(result.points[9:16, 9:16], frame.points[9:16, 9:16])
np.testing.assert_array_equal(alpha, original_alpha)
np.testing.assert_array_equal(frame.depth[alpha == 0.2], 20)
np.testing.assert_allclose(
result.points[..., :2] / result.points[..., 2:],
frame.points[..., :2] / frame.points[..., 2:],
atol=1e-6,
)
assert result.validity is frame.validity
assert result.normal is frame.normal
assert np.isfinite(result.points).all()
def test_sampling_padding_is_bounded():
frame, alpha = silhouette()
result = extend_edge_depth(frame, alpha, alpha_threshold=0.1)
assert np.all(result.depth[12, 24:29] == 2)
assert result.depth[12, 29] == 20
assert (result.depth[12, 24] + result.depth[12, 25]) / 2 == 2
def test_edge_padding_tracks_selected_threshold():
frame, alpha = silhouette()
loose = extend_edge_depth(frame, alpha, alpha_threshold=0.1)
strict = extend_edge_depth(frame, alpha)
assert loose.depth[12, 28] == 2
assert strict.depth[12, 28] == 20
def test_strict_threshold_excludes_lower_alpha_donors():
alpha = np.zeros((10, 10), np.float32)
alpha[3:7, 3:7] = 0.96
alpha[5, 5] = 1
depth = np.full(alpha.shape, 10, np.float32)
depth[5, 5] = 2
result = extend_edge_depth(make_frame(depth), alpha, alpha_threshold=1)
assert result.depth[5, 6] == 2
def test_image_rectangle_does_not_erode_subject():
alpha = np.ones((15, 20), np.float32)
alpha[:, 15:] = 0
depth = np.full(alpha.shape, 2, np.float32)
depth[0, 0] = 3
frame = make_frame(depth)
result = extend_edge_depth(frame, alpha)
assert result.depth[0, 0] == 3
np.testing.assert_array_equal(result.points[0, 0], frame.points[0, 0])
def test_small_components_fall_back_without_borrowing_other_depths():
alpha = np.zeros((15, 30), np.float32)
alpha[5, 5:7] = [0.95, 0.1]
alpha[5, 10:12] = [1, 0.2]
alpha[5, 14] = 0.94
depth = np.full(alpha.shape, 30, np.float32)
depth[5, 5] = 2
depth[5, 10] = 8
result = extend_edge_depth(make_frame(depth), alpha)
assert result.depth[5, 6] == 2
assert result.depth[5, 11] == 8
assert result.depth[5, 14] == 30
assert result.depth[5, 7] == 2
assert result.depth[5, 9] == 8
def test_diagonal_pixels_share_a_component():
alpha = np.zeros((10, 10), np.float32)
alpha[4, 4] = 1
alpha[5, 5] = 0.2
depth = np.full(alpha.shape, 10, np.float32)
depth[4, 4] = 2
result = extend_edge_depth(make_frame(depth), alpha)
assert result.depth[5, 5] == 2
@pytest.mark.parametrize("validity", [0.0, 0.2, 0.5])
def test_invalid_donors_are_excluded(validity):
frame, alpha = silhouette()
frame.depth[10, 6] = 100
frame.validity[10, 6] = validity
result = extend_edge_depth(frame, alpha)
assert result.depth[10, 4] == 2
assert result.depth[10, 6] == 100
assert result.validity[10, 6] == np.float32(validity)
def test_internal_mask_hole_does_not_expand_alpha_repair():
frame, alpha = silhouette()
frame.depth[9:14, 10:15] = 100
frame.validity[9:14, 10:15] = 0
original = frame.depth.copy()
result = extend_edge_depth(frame, alpha)
np.testing.assert_array_equal(result.depth[6:19, 6:20], original[6:19, 6:20])
assert result.depth[12, 24] == 2
def test_opaque_image_keeps_low_mask_depth():
frame = make_frame(np.ones((10, 10)))
frame.depth[5, 5] = 100
frame.validity[5, 5] = 0
assert extend_edge_depth(frame, np.ones((10, 10))) is frame
def test_component_without_valid_donors_keeps_its_depth():
frame, alpha = silhouette()
frame.validity[alpha > 0] = 0
assert extend_edge_depth(frame, alpha) is frame
def test_local_smoothing_reduces_donor_step_and_preserves_core():
frame, alpha = silhouette()
frame.depth[12:, alpha.shape[1] // 4:22] = 4
result = extend_edge_depth(frame, alpha)
assert 2 < result.depth[11, 23] < 4
assert 2 < result.depth[12, 23] < 4
np.testing.assert_array_equal(result.depth[8:17, 8:17], frame.depth[8:17, 8:17])
@pytest.mark.parametrize("alpha_value", [0, 0.3, 1])
def test_images_without_repair_return_original_frame(alpha_value):
frame = make_frame(np.ones((5, 8)))
assert extend_edge_depth(frame, np.full((5, 8), alpha_value)) is frame
def test_alpha_resize_retains_threshold_values(tmp_path):
rgba = np.zeros((1, 4, 4), np.uint8)
rgba[0, :, 3] = [25, 26, 242, 243]
path = tmp_path / "alpha.png"
Image.fromarray(rgba).save(path)
alpha = artifacts._image_alpha(path, (2, 8))
np.testing.assert_array_equal(
alpha >= 0.1, [[False] * 2 + [True] * 6] * 2
)
np.testing.assert_array_equal(
alpha >= 0.95, [[False] * 6 + [True] * 2] * 2
)
@pytest.mark.parametrize(
"generate_depth,normal_mode",
[
(True, "OBJECT"),
(True, "TANGENT"),
(True, "NONE"),
(False, "OBJECT"),
],
)
def test_artifacts_use_corrected_depth_and_original_normals(
tmp_path, generate_depth, normal_mode
):
frame, alpha = silhouette()
rgba = np.full((*alpha.shape, 4), 255, np.uint8)
rgba[..., 3] = np.round(alpha * 255).astype(np.uint8)
path = tmp_path / "source.png"
Image.fromarray(rgba).save(path)
context = SimpleNamespace(
directory=tmp_path,
resource=lambda _: None,
progress=lambda *_: None,
check_cancelled=lambda: None,
)
with (
patch.object(artifacts.moge2, "infer", return_value=[frame]),
patch.object(
artifacts, "write_depth_texture",
return_value=tmp_path / "depth.exr",
) as depth,
patch.object(
artifacts, "write_depth_metadata", return_value=tmp_path / "depth.json"
) as metadata,
patch.object(
artifacts, "_write_normal", return_value=tmp_path / "normal.png"
) as normal,
patch.object(artifacts, "extend_edge_depth", wraps=extend_edge_depth) as repair,
):
artifacts.generate_moge2_artifacts(
context, {}, path, generate_depth=generate_depth, normal_mode=normal_mode,
)
if normal_mode != "NONE":
assert normal.call_args.args[0] is frame
else:
normal.assert_not_called()
assert repair.call_count == int(generate_depth)
if generate_depth:
written_frame = depth.call_args.args[0]
np.testing.assert_array_equal(depth.call_args.kwargs["alpha"], alpha)
assert metadata.call_args.args[0] is written_frame
assert written_frame.depth[12, 24] == 2
assert written_frame.validity is frame.validity
else:
metadata.assert_not_called()
depth.assert_not_called()

View File

@@ -0,0 +1,322 @@
import importlib.util
import json
import sys
from types import SimpleNamespace
from unittest.mock import patch
from tests.anyimage.server.runtime_support import ServerTestCase
class GeometryArtifactsTest(ServerTestCase):
def test_depth_alpha_multiplies_coverage_without_changing_geometry(self):
import numpy as np
depth = importlib.import_module("server.geometry.depth_texture")
GeometryFrame = importlib.import_module("server.models.geometry").GeometryFrame
validity = np.asarray([[0.75, 0, 1, 1]], dtype=np.float32)
alpha = np.asarray([[0.8, 1, 0, 1]], dtype=np.float32)
points = np.arange(12, dtype=np.float32).reshape(1, 4, 3)
frame = GeometryFrame(
depth=points[..., 2], points=points,
validity=validity.copy(), intrinsics=np.eye(3, dtype=np.float32),
)
output = self.storage_root / "depth.exr"
with patch.object(depth, "_write_float_exr") as write:
depth.write_depth_texture(frame, output, alpha=alpha)
packed = write.call_args.args[0]
np.testing.assert_allclose(packed[..., 3], [[0.6, 0, 0, 1]])
np.testing.assert_array_equal(packed[..., :3], points)
np.testing.assert_array_equal(frame.validity, validity)
self.assertEqual(packed.dtype, np.float32)
with self.assertRaisesRegex(ValueError, "Alpha.*dimensions"):
depth.write_depth_texture(frame, output, alpha=np.ones((4, 1)))
self.assertEqual(write.call_count, 1)
def test_depth_artifacts_store_texture_and_compact_metadata(self):
import numpy as np
depth = importlib.import_module("server.geometry.depth_texture")
GeometryFrame = importlib.import_module(
"server.models.geometry"
).GeometryFrame
texture_path = self.storage_root / "depth.exr"
metadata_path = self.storage_root / "depth.json"
validity = np.asarray(
((1.0, 1.0, 0.0), (1.0, 1.0, 1.0)),
dtype=np.float32,
)
calibration = np.asarray(((False, True, True), (False, False, True)))
frame = GeometryFrame(
depth=np.asarray(
((1.0, 2.0, 3.0), (4.0, 5.0, 6.0)),
dtype=np.float32,
),
validity=validity,
points=np.ones((2, 3, 3), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
)
depth.write_depth_texture(frame, texture_path, alpha=np.ones_like(validity))
depth.write_depth_metadata(frame, calibration, metadata_path)
self.assertTrue(texture_path.is_file())
self.assertEqual(texture_path.read_bytes()[:4], b"v/1\x01")
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
self.assertEqual(metadata["image_size"], [3, 2])
self.assertEqual(metadata["intrinsics"], np.eye(3).tolist())
self.assertAlmostEqual(metadata["reference_depth"], 4.0)
def test_object_normal_texture_matches_depth_surface_axes(self):
import numpy as np
from PIL import Image
normal_texture = importlib.import_module("server.geometry.normal_texture")
GeometryFrame = importlib.import_module(
"server.models.geometry"
).GeometryFrame
output_path = self.storage_root / "object-normal.png"
camera_normals = np.asarray(
[[
(0.0, 0.0, -1.0),
(1.0, 0.0, 0.0),
(0.0, 1.0, 0.0),
(0.0, 0.0, -1.0),
]],
dtype=np.float32,
)
valid = np.asarray([[True, True, True, False]], dtype=bool)
frame = GeometryFrame(
depth=np.ones((1, 4), dtype=np.float32),
validity=np.ones((1, 4), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
normal=camera_normals,
)
normal_texture.write_camera_normal_texture(
frame,
valid,
(0, 0),
(4, 1),
(4, 1),
output_path,
normal_mode="OBJECT",
)
with Image.open(output_path) as opened:
encoded = np.asarray(opened, dtype=np.uint8)
np.testing.assert_array_equal(
encoded,
np.asarray(
[[
(127, 127, 255),
(255, 127, 127),
(127, 0, 127),
(128, 128, 255),
]],
dtype=np.uint8,
),
)
def test_moge_model_input_preserves_rgb_independently_of_alpha(self):
import numpy as np
from PIL import Image
artifacts = sys.modules["server.geometry.prediction_artifacts"]
first_path = self.storage_root / "first.png"
second_path = self.storage_root / "second.png"
first = np.asarray(
[[(240, 120, 60, 0), (200, 100, 50, 128), (9, 8, 7, 255)]],
dtype=np.uint8,
)
second = first.copy()
second[..., 3] = 255
Image.fromarray(first, mode="RGBA").save(first_path)
Image.fromarray(second, mode="RGBA").save(second_path)
for max_size in (2048, 2):
with self.subTest(max_size=max_size):
outputs = []
for source in (first_path, second_path):
output = artifacts._limited_model_input(
source, source.with_name(f"model-{source.name}"), max_size,
)
with Image.open(output) as opened:
self.assertEqual(opened.mode, "RGB")
outputs.append(np.asarray(opened))
np.testing.assert_array_equal(*outputs)
self.assertEqual(outputs[0].shape[1], min(3, max_size))
if max_size == 2048:
np.testing.assert_array_equal(outputs[0], first[..., :3])
def test_geometry_writers_reject_missing_or_non_finite_fields(self):
import numpy as np
depth = importlib.import_module("server.geometry.depth_texture")
normal = importlib.import_module("server.geometry.normal_texture")
GeometryFrame = importlib.import_module(
"server.models.geometry"
).GeometryFrame
base = {
"depth": np.ones((1, 2), dtype=np.float32),
"validity": np.ones((1, 2), dtype=np.float32),
"intrinsics": np.eye(3, dtype=np.float32),
}
with self.assertRaisesRegex(ValueError, "point field"):
depth.write_depth_texture(
GeometryFrame(**base),
self.storage_root / "missing-points.exr",
alpha=base["validity"],
)
point_frame = GeometryFrame(
**base,
points=np.asarray([[(0.0, 0.0, 1.0), (np.nan, 0.0, 1.0)]], dtype=np.float32),
)
with self.assertRaisesRegex(ValueError, "Point field"):
depth.write_depth_texture(
point_frame,
self.storage_root / "nan-points.exr",
alpha=base["validity"],
)
depth_frame = GeometryFrame(
**base, points=np.asarray([[(0, 0, 1), (0, 0, np.inf)]], dtype=np.float32),
)
with self.assertRaisesRegex(ValueError, "Point field"):
depth.write_depth_texture(
depth_frame,
self.storage_root / "inf-depth.exr",
alpha=base["validity"],
)
with self.assertRaisesRegex(ValueError, "normal field"):
normal.write_camera_normal_texture(
GeometryFrame(**base),
np.ones((1, 2), dtype=bool),
(0, 0),
(2, 1),
(2, 1),
self.storage_root / "missing-normal.png",
)
normal_frame = GeometryFrame(
**base,
normal=np.asarray(
[[(0.0, 0.0, 1.0), (0.0, np.nan, 1.0)]],
dtype=np.float32,
),
)
with self.assertRaisesRegex(ValueError, "Normal field"):
normal.write_camera_normal_texture(
normal_frame,
np.ones((1, 2), dtype=bool),
(0, 0),
(2, 1),
(2, 1),
self.storage_root / "nan-normal.png",
)
self.assertFalse(any(self.storage_root.glob("*.exr")))
self.assertFalse(any(self.storage_root.glob("*-normal.png")))
def test_moge_artifact_orchestration_uses_one_geometry_frame(self):
import numpy as np
from PIL import Image
artifacts = sys.modules["server.geometry.prediction_artifacts"]
GeometryFrame = importlib.import_module(
"server.models.geometry"
).GeometryFrame
input_path = self.storage_root / "selection.png"
source = Image.new("RGBA", (4, 4), (255, 128, 64, 255))
source.paste((255, 128, 64, 0), (2, 0, 4, 2))
source.save(input_path)
output_directory = self.storage_root / "job"
output_directory.mkdir()
context = SimpleNamespace(
directory=output_directory,
resource=lambda _name: object(),
progress=lambda *_args: None,
check_cancelled=lambda: None,
)
frame = GeometryFrame(
depth=np.ones((2, 2), dtype=np.float32),
validity=np.ones((2, 2), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
normal=np.zeros((2, 2, 3), dtype=np.float32),
points=np.ones((2, 2, 3), dtype=np.float32),
)
texture = importlib.import_module("server.geometry.depth_texture")
with (
patch.object(artifacts.moge2, "infer", return_value=[frame]) as infer,
patch.object(texture, "_write_float_exr", wraps=texture._write_float_exr) as write,
):
result = artifacts.generate_moge2_artifacts(
context,
{"max_input_size": 2048},
input_path,
generate_depth=True,
normal_mode="OBJECT",
)
np.testing.assert_array_equal(write.call_args.args[0][..., 3], [[1, 0], [1, 1]])
np.testing.assert_array_equal(frame.validity, np.ones((2, 2)))
self.assertEqual(json.loads(result["depth_metadata"].read_text())["reference_depth"], 1)
infer.assert_called_once()
self.assertEqual(
set(result),
{"depth", "depth_metadata", "object_normal"},
)
self.assertFalse((output_directory / "model-input.png").exists())
self.assertTrue(all(path.is_file() for path in result.values()))
self.assertTrue(infer.call_args.kwargs["include_points"])
def test_generate_cutout_artifacts_only_returns_requested_outputs(self):
job = sys.modules["server.jobs.cutout"]
input_path = self.storage_root / "input.png"
input_path.write_bytes(b"selection")
context = SimpleNamespace(
directory=self.storage_root / "job",
progress=lambda *_args: None,
check_cancelled=lambda: None,
)
depth_texture = context.directory / "depth.exr"
metadata = context.directory / "depth.json"
normal = context.directory / "object-normal.png"
with patch.object(
job,
"generate_moge2_artifacts",
return_value={
"depth": depth_texture,
"depth_metadata": metadata,
"object_normal": normal,
},
) as generate:
result = job.run(
context,
{
"input": str(input_path),
"generate_depth": True,
"normal_mode": "OBJECT",
},
)
self.assertEqual(
result,
{
"depth": "depth.exr",
"depth_metadata": "depth.json",
"object_normal": "object-normal.png",
},
)
generate.assert_called_once_with(
context,
{
"input": str(input_path),
"generate_depth": True,
"normal_mode": "OBJECT",
},
input_path,
generate_depth=True,
normal_mode="OBJECT",
alpha_threshold=0.9,
)

View File

@@ -0,0 +1,170 @@
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)
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)
self.assertNotIn("clients", server.snapshot("instance"))
release_first.set()
self.wait_for_job(first)
self.wait_for_job(second)
self.assertEqual(order, ["first", "second"])
server.close()
def test_queued_job_can_be_cancelled_before_it_runs(self):
server = self.JobServer("Test", storage_root=self.storage_root)
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"])
server.close()
def test_cancelling_job_keeps_server_and_model_cache_alive(self):
fake_models = SimpleNamespace(
snapshot=lambda: {"loaded": {"ben2": "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"]["ben2"],
"BEN2_BASE",
)
def test_job_progress_is_kept_in_server_memory(self):
server = self.JobServer("Test", storage_root=self.storage_root)
@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")
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)
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-cutout-artifacts",
},
)

View File

@@ -0,0 +1,183 @@
import tempfile
import unittest
from pathlib import Path
from tests.anyimage.server.support import load_server_module, _make_fake_av
class MediaInputTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.media = load_server_module("media.input")
def test_collect_images_is_sorted_and_filters_suffixes(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "b.PNG").touch()
(root / "a.jpg").touch()
(root / "notes.txt").touch()
images = self.media.collect_images(root)
self.assertEqual([path.name for path in images], ["a.jpg", "b.PNG"])
def test_ai_tasks_support_cooperative_cancellation(self):
with self.assertRaises(self.media.TaskCancelled):
self.media.check_cancelled(lambda: True)
self.media.check_cancelled(lambda: False)
def test_prepares_a_single_image_file(self):
with tempfile.TemporaryDirectory() as directory:
image_path = Path(directory) / "input.png"
image_path.touch()
images, temporary_directory = self.media.prepare_input(
image_path,
16,
directory,
)
self.assertEqual(images, [image_path])
self.assertIsNone(temporary_directory)
def test_prepares_a_frame_directory(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "frame-0001.jpg").touch()
(root / "frame-0002.png").touch()
(root / "notes.txt").touch()
images, temporary_directory = self.media.prepare_input(
root,
16,
directory,
)
self.assertEqual(
[path.name for path in images],
["frame-0001.jpg", "frame-0002.png"],
)
self.assertIsNone(temporary_directory)
def test_prepares_first_frames_of_a_frame_directory(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for index in range(1, 6):
(root / f"frame-{index:04d}.jpg").touch()
images, temporary_directory = self.media.prepare_input(
root,
3,
directory,
)
self.assertEqual(
[path.name for path in images],
["frame-0001.jpg", "frame-0002.jpg", "frame-0003.jpg"],
)
self.assertIsNone(temporary_directory)
def test_prepares_first_frames_after_offset_in_a_frame_directory(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for index in range(1, 6):
(root / f"frame-{index:04d}.jpg").touch()
images, temporary_directory = self.media.prepare_input(
root,
3,
directory,
start_frame=2,
)
self.assertEqual(
[path.name for path in images],
["frame-0002.jpg", "frame-0003.jpg", "frame-0004.jpg"],
)
self.assertIsNone(temporary_directory)
def test_copy_color_images_numbers_a_still_image(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "source.png"
source.write_bytes(b"frame")
output = root / "result"
output.mkdir()
self.media.copy_color_images([source], output)
self.assertEqual(
(output / "image.0001.png").read_bytes(),
b"frame",
)
def test_extract_video_frames_reads_frames_sequentially(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
fake_av = _make_fake_av(100)
image_paths = self.media.extract_video_frames(
root / "clip.mkv",
root / "out",
10,
fake_av,
)
self.assertEqual(
[path.name for path in image_paths],
[f"frame.{index:04d}.jpg" for index in range(1, 11)],
)
self.assertEqual(len(fake_av.containers), 1)
self.assertEqual(fake_av.containers[0].position, 10)
def test_extract_video_frames_starts_after_offset(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
fake_av = _make_fake_av(100)
image_paths = self.media.extract_video_frames(
root / "clip.mkv",
root / "out",
10,
fake_av,
start_frame=71,
)
extracted = [
Path(path).read_text(encoding="utf-8")
for path in image_paths
]
self.assertEqual(extracted, [str(frame) for frame in range(70, 80)])
self.assertEqual(fake_av.containers[0].position, 80)
def test_extract_video_frames_tolerates_short_video_tail(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
image_paths = self.media.extract_video_frames(
root / "clip.mkv",
root / "out",
10,
_make_fake_av(99),
)
self.assertEqual(
[path.name for path in image_paths],
[f"frame.{index:04d}.jpg" for index in range(1, 11)],
)
def test_rejects_unsupported_input_file(self):
with tempfile.TemporaryDirectory() as directory:
input_path = Path(directory) / "input.txt"
input_path.touch()
with self.assertRaisesRegex(RuntimeError, "Unsupported input type"):
self.media.prepare_input(input_path, 16, directory)

View File

@@ -0,0 +1,70 @@
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
from PIL import Image
from server.models import onnx_ben2
class FakeSession:
def get_providers(self):
return ["CPUExecutionProvider"]
def get_inputs(self):
return [SimpleNamespace(name="input")]
def get_outputs(self):
return [SimpleNamespace(name="alpha")]
def run(self, output_names, feeds):
self.output_names = output_names
self.feeds = feeds
gradient = np.linspace(0.0, 1.0, 1024, dtype=np.float32)
return [np.tile(gradient, (1024, 1))[None, None]]
class Ben2OnnxTest(unittest.TestCase):
def test_infer_forwards_memory_release_boundary(self):
session = FakeSession()
with patch.object(
onnx_ben2,
"run_session",
side_effect=lambda model, names, feeds, **_options: model.run(
names, feeds
),
) as run:
result = onnx_ben2.infer(
session,
Image.new("RGB", (40, 20)),
release_memory=False,
)
self.assertEqual(result.size, (40, 20))
self.assertFalse(run.call_args.kwargs["release_memory"])
def test_preprocess_matches_model_layout(self):
image = Image.new("RGB", (40, 20), (64, 128, 192))
result = onnx_ben2.preprocess(image)
self.assertEqual(result.shape, (1, 3, 1024, 1024))
self.assertEqual(result.dtype, np.float32)
self.assertTrue(result.flags.c_contiguous)
def test_infer_restores_original_size_and_alpha(self):
session = FakeSession()
image = Image.new("RGB", (40, 20), (10, 20, 30))
result = onnx_ben2.infer(session, image)
self.assertEqual(result.mode, "RGBA")
self.assertEqual(result.size, image.size)
self.assertEqual(session.feeds["input"].shape, (1, 3, 1024, 1024))
alpha = np.asarray(result.getchannel("A"))
self.assertLess(int(alpha[:, 0].max()), int(alpha[:, -1].min()))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,96 @@
from tests.support.paths import PROJECT_ROOT
import importlib.util
import unittest
def load_module(name, relative_path):
specification = importlib.util.spec_from_file_location(
name,
PROJECT_ROOT / relative_path,
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
class ModelCatalogTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.models = load_module(
"anyimage_server_models",
"src/anyimage/server/model_catalog.py",
)
def test_unknown_model_is_rejected(self):
with self.assertRaisesRegex(ValueError, "Unknown model"):
self.models.get_downloadable_model("UNKNOWN")
def test_ben2_download_only_includes_required_hugging_face_files(self):
model = self.models.get_downloadable_model("BEN2_BASE")
self.assertEqual(model.repository, "onnx-community/BEN2-ONNX")
self.assertEqual(model.license_name, "Apache 2.0")
self.assertEqual(model.download_patterns, ("onnx/model_fp16.onnx",))
self.assertEqual(model.ready_patterns, ("onnx/model_fp16.onnx",))
self.assertEqual(model.r2_files[0][0], "onnx/model_fp16.onnx")
def test_ben2_model_directory_uses_repository_name(self):
self.assertEqual(
self.models.get_downloadable_model("BEN2_BASE").directory_name,
"BEN2-ONNX",
)
def test_upscale_models_use_verified_onnx_artifacts(self):
expected = {
"REALESRGAN_GENERAL_WDN_X4V3": ("realesr-general-wdn-x4v3.onnx", 4867419, "b021a837"),
"SAFMN_REAL_X4": ("safmn-real-x4.onnx", 22657477, "8f13867b"),
"REALESRGAN_X4PLUS": ("realesrgan-x4plus.onnx", 67051618, "4ed6a45a"),
}
self.assertEqual(set(self.models.UPSCALE_MODELS), set(expected) | {"PISA_SR"})
for key, (filename, size, checksum_prefix) in expected.items():
with self.subTest(model=key):
model = self.models.get_downloadable_model(key)
self.assertEqual(model.download_patterns, (filename,))
self.assertEqual(model.ready_patterns, (filename,))
self.assertEqual(model.r2_files[0][:2], (filename, size))
self.assertTrue(model.r2_files[0][2].startswith(checksum_prefix))
self.assertEqual(self.models.model_record(model)["family"], "upscale")
wdn = self.models.UPSCALE_MODELS["REALESRGAN_GENERAL_WDN_X4V3"]
self.assertEqual(wdn.repository, "xinntao/Real-ESRGAN")
self.assertEqual(wdn.huggingface_repository, "")
self.assertEqual(
self.models.UPSCALE_MODELS["SAFMN_REAL_X4"].huggingface_repository, ""
)
def test_moge2_models_use_official_onnx_models(self):
for key, variant in (("MOGE2_VITS_NORMAL", "vits"),
("MOGE2_VITB_NORMAL", "vitb"),
("MOGE2_VITL_NORMAL", "vitl")):
with self.subTest(key=key):
model = self.models.get_downloadable_model(key)
self.assertEqual(
model.repository,
f"Ruicheng/moge-2-{variant}-normal-onnx",
)
self.assertEqual(model.license_name, "MIT")
self.assertEqual(model.download_patterns, ("model.onnx",))
self.assertEqual(model.ready_patterns, ("model.onnx",))
self.assertEqual(model.r2_directory, model.repository.split("/")[-1])
self.assertEqual(model.r2_files[0][0], "model.onnx")
def test_moge2_models_match_uploaded_r2_variants(self):
self.assertEqual(
list(self.models.MOGE2_MODELS),
["MOGE2_VITS_NORMAL", "MOGE2_VITB_NORMAL", "MOGE2_VITL_NORMAL"],
)
self.assertEqual(
[model.r2_directory for model in self.models.MOGE2_MODELS.values()],
[
"moge-2-vits-normal-onnx",
"moge-2-vitb-normal-onnx",
"moge-2-vitl-normal-onnx",
],
)

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,65 @@
from unittest.mock import patch
import numpy as np
from anyimage.server.models import moge2 as moge2_model
from anyimage.server.models.geometry import GeometryFrame
def test_geometry_frame_requires_matching_float32_fields():
with np.testing.assert_raises_regex(ValueError, "validity"):
GeometryFrame(
depth=np.ones((2, 2), dtype=np.float32),
validity=np.ones((1, 2), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
)
with np.testing.assert_raises_regex(ValueError, "normal"):
GeometryFrame(
depth=np.ones((2, 2), dtype=np.float32),
validity=np.ones((2, 2), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
normal=np.ones((2, 2, 2), dtype=np.float32),
)
def test_moge_infer_returns_geometry_frames_with_model_validity():
mask = np.asarray(((1.0, 0.2), (0.7, 0.0)), dtype=np.float32)
prediction = {
"depth": np.ones((2, 2), dtype=np.float32),
"normal": np.zeros((2, 2, 3), dtype=np.float32),
"mask": mask,
"intrinsics": np.asarray(
((1.0, 0.0, 0.5), (0.0, 1.0, 0.5), (0.0, 0.0, 1.0)),
dtype=np.float32,
),
"points": np.ones((2, 2, 3), dtype=np.float32),
}
with patch.object(moge2_model, "infer_one", return_value=prediction):
frames = moge2_model.infer(None, {}, ["image"], include_points=True)
assert len(frames) == 1
frame = frames[0]
assert np.array_equal(frame.validity, mask.astype(np.float32))
assert np.array_equal(frame.normal, prediction["normal"])
assert np.array_equal(frame.points, prediction["points"])
assert np.array_equal(
frame.intrinsics,
np.asarray(((2.0, 0.0, 1.0), (0.0, 2.0, 1.0), (0.0, 0.0, 1.0))),
)
def test_moge_z_depth_frame_does_not_require_points():
prediction = {
"depth": np.ones((2, 2), dtype=np.float32),
"normal": np.zeros((2, 2, 3), dtype=np.float32),
"mask": np.ones((2, 2), dtype=bool),
"intrinsics": np.eye(3, dtype=np.float32),
"points": np.ones((2, 2, 3), dtype=np.float32),
}
with patch.object(moge2_model, "infer_one", return_value=prediction):
frame = moge2_model.infer(None, {}, ["image"], include_points=False)[0]
assert frame.points is None
assert frame.validity.all()

View File

@@ -0,0 +1,107 @@
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
import pytest
from PIL import Image
from tests.anyimage.server.support import load_server_module, FakeModels
class ModelInferenceTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.moge2_model = load_server_module("models.moge2")
cls.upscale_model = load_server_module("models.upscale")
cls.onnx_upscale = load_server_module("models.onnx_upscale")
def test_moge2_batch_releases_only_after_final_image(self):
prediction = {
"points": np.ones((2, 2, 3), dtype=np.float32),
"depth": np.ones((2, 2), dtype=np.float32),
"normal": np.ones((2, 2, 3), dtype=np.float32),
"mask": np.ones((2, 2), dtype=bool),
"intrinsics": np.eye(3, dtype=np.float32),
}
with patch.object(
self.moge2_model,
"infer_one",
return_value=prediction,
) as infer_one:
frames = self.moge2_model.infer(
FakeModels(),
{"model": "MOGE2_VITS_NORMAL", "device": "cpu"},
[Path("a.png"), Path("b.png")],
include_points=True,
)
self.assertEqual(len(frames), 2)
self.assertEqual(
[call.kwargs["release_memory"] for call in infer_one.call_args_list],
[False, True],
)
def test_upscale_resource_error_releases_session_and_is_readable(self):
from PIL import Image
class Models:
def __init__(self, directory):
self.model_directory = directory
self.released = False
def directory(self, _model):
return self.model_directory
def get_upscale(self, _directory, _device):
return object()
def release_upscale(self):
self.released = True
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "input.png"
Image.new("RGB", (8, 8)).save(path)
models = Models(Path(directory))
with patch.object(
self.onnx_upscale,
"infer",
side_effect=self.onnx_upscale.UpscaleResourceError(
"provider memory exhausted"
),
):
with self.assertRaisesRegex(
RuntimeError,
"Reduce Maximum AI Input Size or select CPU",
):
self.upscale_model.infer_one(
models,
{
"model": "REALESRGAN_GENERAL_WDN_X4V3",
"device": "directml",
"max_input_size": 2048,
},
path,
)
self.assertTrue(models.released)
upscale = load_server_module("models.upscale")
@pytest.mark.parametrize("key", ["REALESRGAN_GENERAL_WDN_X4V3", "SAFMN_REAL_X4", "REALESRGAN_X4PLUS"])
def test_upscale_models_resize_prediction_then_restore_alpha(tmp_path, key):
rng = np.random.default_rng(1)
source = Image.fromarray(rng.integers(0,256,(5,7,4),dtype=np.uint8))
path = tmp_path / "source.png"
source.save(path)
prediction = Image.fromarray(rng.integers(0,256,(20,28,3),dtype=np.uint8))
manager = SimpleNamespace(directory=lambda _: tmp_path, get_upscale=lambda *args: object())
with patch("server.models.onnx_upscale.infer", return_value=prediction):
output = upscale.infer_one(manager, {"model":key,"device":"cpu"}, path)
np.testing.assert_array_equal(output.convert("RGB"), prediction.resize((14,10), Image.Resampling.LANCZOS))
np.testing.assert_array_equal(output.getchannel("A"), source.getchannel("A").resize((14,10), Image.Resampling.LANCZOS))

View File

@@ -0,0 +1,182 @@
import sys
import tempfile
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
from tests.anyimage.server.runtime_support import ServerTestCase
class ModelManagerTest(ServerTestCase):
def test_required_model_download_skips_ready_model(self):
manager = self.ModelManager(Path("."))
downloads = []
context = SimpleNamespace(progress=lambda *_args: None)
with (
patch.object(
manager,
"ready",
side_effect=lambda key: key == "BEN2_BASE",
),
patch.object(
manager,
"_download",
side_effect=lambda _context, key, _progress: downloads.append(key),
),
):
result = manager.download_missing(
context,
("MOGE2_VITS_NORMAL", "BEN2_BASE"),
)
self.assertEqual(downloads, ["MOGE2_VITS_NORMAL"])
self.assertEqual(result, {"downloaded": ["MOGE2_VITS_NORMAL"]})
def test_models_are_owned_by_the_server_resource(self):
with tempfile.TemporaryDirectory() as directory:
manager = self.ModelManager(Path(directory))
snapshot = manager.snapshot()
self.assertEqual(
snapshot,
{
"loaded": {
"ben2": "",
"geometry": "",
"upscale": "",
}
},
)
self.assertIn("server.model_catalog", sys.modules)
self.assertNotIn("model_catalog", sys.modules)
def test_ben2_model_is_reused_for_matching_runtime_key(self):
loads = []
ben2 = ModuleType("server.models.onnx_ben2")
ben2.create_session = lambda model_dir, device: (
loads.append((model_dir, device)) or object()
)
cache = self.create_cache()
device = SimpleNamespace(type="cuda")
with patch.dict(sys.modules, {"server.models.onnx_ben2": ben2}):
first, _load_ms = cache.get_ben2("model", device)
second, cached_load_ms = cache.get_ben2("model", device)
self.assertIs(first, second)
self.assertEqual(loads, [("model", "cuda")])
self.assertEqual(cache.snapshot()["ben2"], "model")
self.assertEqual(cached_load_ms, 0.0)
def test_model_cache_keeps_three_categories(self):
cache = self.create_cache()
ben2 = object()
old_moge = object()
upscale = object()
cache.ben2_model = ben2
cache.ben2_key = ("ben2", "cuda")
cache.moge_model = old_moge
cache.moge_key = ("old-moge", "cuda")
cache.upscale_model = upscale
cache.upscale_key = ("upscale", "cuda")
new_moge = object()
fake_moge = ModuleType("server.models.onnx_moge2")
fake_moge.create_session = lambda _model_dir, _device: new_moge
with patch.dict(sys.modules, {"server.models.onnx_moge2": fake_moge}):
self.assertIs(cache.get_moge2("new-moge", "cuda"), new_moge)
self.assertIs(cache.ben2_model, ben2)
self.assertIs(cache.upscale_model, upscale)
self.assertEqual(cache.snapshot()["geometry"], "new-moge")
def test_upscale_model_is_reused_for_matching_runtime_key(self):
loads = []
upscale = ModuleType("server.models.onnx_upscale")
upscale.create_session = lambda model_dir, device: (
loads.append((model_dir, device)) or object()
)
cache = self.create_cache()
with patch.dict(
sys.modules,
{"server.models.onnx_upscale": upscale},
):
first = cache.get_upscale("model", "cpu")
second = cache.get_upscale("model", "cpu")
self.assertIs(first, second)
self.assertEqual(loads, [("model", "cpu")])
self.assertEqual(cache.snapshot()["upscale"], "model")
def test_inference_cleanup_keeps_every_cached_session(self):
class Session:
def get_providers(self):
return ["CUDAExecutionProvider"]
def run(self, _names, _feeds, run_options=None):
self.run_options = run_options
return [object()]
class RunOptions:
def add_run_config_entry(self, _key, _value):
return None
runtime = ModuleType("onnxruntime")
runtime.RunOptions = RunOptions
sessions = {
"ben2": Session(),
"moge2": Session(),
"upscale": Session(),
}
ben2 = ModuleType("server.models.onnx_ben2")
ben2.create_session = lambda _directory, _device: sessions["ben2"]
moge2 = ModuleType("server.models.onnx_moge2")
moge2.create_session = lambda _directory, _device: sessions["moge2"]
upscale = ModuleType("server.models.onnx_upscale")
upscale.create_session = lambda _directory, _device: sessions["upscale"]
cache = self.create_cache()
def assert_reused(first, getter, release_name):
with (
patch.dict(sys.modules, {"onnxruntime": runtime}),
patch.object(
cache,
release_name,
wraps=getattr(cache, release_name),
) as release,
):
self.onnx_runtime.run_session(first, ["output"], {"input": 1})
second = getter()
self.assertIs(first, second)
release.assert_not_called()
with patch.dict(
sys.modules,
{
"server.models.onnx_ben2": ben2,
"server.models.onnx_moge2": moge2,
"server.models.onnx_upscale": upscale,
},
):
first_ben2, _load_ms = cache.get_ben2("ben2", "cuda")
assert_reused(
first_ben2,
lambda: cache.get_ben2("ben2", "cuda")[0],
"release_ben2",
)
first_moge2 = cache.get_moge2("moge2", "cuda")
assert_reused(
first_moge2,
lambda: cache.get_moge2("moge2", "cuda"),
"release_geometry",
)
first_upscale = cache.get_upscale("upscale", "cuda")
assert_reused(
first_upscale,
lambda: cache.get_upscale("upscale", "cuda"),
"release_upscale",
)

View File

@@ -0,0 +1,208 @@
from tests.support.paths import PROJECT_ROOT
import importlib.util
import sys
import unittest
from pathlib import Path
from types import ModuleType
from unittest.mock import patch
import numpy as np
def load_module():
anyimage = ModuleType("anyimage")
anyimage.__path__ = [str(PROJECT_ROOT / "src" / "anyimage")]
server = ModuleType("anyimage.server")
server.__path__ = [str(PROJECT_ROOT / "src" / "anyimage" / "server")]
models = ModuleType("anyimage.server.models")
models.__path__ = [
str(PROJECT_ROOT / "src" / "anyimage" / "server" / "models")
]
specification = importlib.util.spec_from_file_location(
"anyimage.server.models.onnx_moge2",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "server"
/ "models"
/ "onnx_moge2.py",
)
module = importlib.util.module_from_spec(specification)
with patch.dict(
sys.modules,
{
"anyimage": anyimage,
"anyimage.server": server,
"anyimage.server.models": models,
},
):
specification.loader.exec_module(module)
return module
class FakeInput:
def __init__(self, name):
self.name = name
class FakeSession:
def __init__(self, outputs):
self.outputs = outputs
self.calls = []
def run(self, names, inputs):
self.calls.append((names, inputs))
return [self.outputs[name] for name in names]
def get_providers(self):
return ["CPUExecutionProvider"]
def get_outputs(self):
return [FakeInput(name) for name in self.outputs]
class Moge2OnnxTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.moge = load_module()
def test_coreml_session_requires_static_input_partitions(self):
calls = []
fake_runtime = ModuleType("onnxruntime")
fake_runtime.get_available_providers = lambda: [
"CoreMLExecutionProvider",
"CPUExecutionProvider",
]
fake_runtime.InferenceSession = lambda path, providers: calls.append(
(path, providers)
)
with patch.dict(sys.modules, {"onnxruntime": fake_runtime}):
self.moge.create_session("moge-model", "coreml")
self.assertEqual(
calls[0][0],
str(Path("moge-model") / "model.onnx"),
)
self.assertEqual(
calls[0][1],
[
(
"CoreMLExecutionProvider",
{"RequireStaticInputShapes": "1"},
),
"CPUExecutionProvider",
],
)
def test_infer_uses_dynamic_token_input_and_numpy_postprocess(self):
height, width = 8, 12
raw = {
"points": np.zeros((1, height, width, 3), dtype=np.float32),
"normal": np.zeros((1, height, width, 3), dtype=np.float32),
"mask": np.ones((1, height, width), dtype=np.float32),
"scale": np.ones((1,), dtype=np.float32),
}
raw["points"][..., 2] = 1.0
raw["normal"][..., 2] = 1.0
session = FakeSession(raw)
expected = {
"points": np.zeros((height, width, 3), dtype=np.float32),
"depth": np.ones((height, width), dtype=np.float32),
"normal": raw["normal"][0],
"mask": np.ones((height, width), dtype=bool),
"intrinsics": np.eye(3, dtype=np.float32),
}
with patch.object(self.moge, "postprocess", return_value=expected):
result = self.moge.infer(
session,
np.zeros((height, width, 3), dtype=np.float32),
resolution_level=9,
)
self.assertIs(result, expected)
names, inputs = session.calls[0]
self.assertEqual(names, ["points", "normal", "mask", "scale"])
self.assertEqual(inputs["image"].shape, (1, 3, height, width))
self.assertEqual(inputs["num_tokens"].dtype, np.int64)
self.assertEqual(inputs["num_tokens"], 3600)
def test_infer_forwards_memory_release_boundary(self):
height, width = 8, 12
raw = {
"points": np.zeros((1, height, width, 3), dtype=np.float32),
"normal": np.zeros((1, height, width, 3), dtype=np.float32),
"mask": np.ones((1, height, width), dtype=np.float32),
"scale": np.ones((1,), dtype=np.float32),
}
session = FakeSession(raw)
with (
patch.object(self.moge, "postprocess", return_value={}),
patch.object(
self.moge,
"run_session",
side_effect=lambda model, names, feeds, **_options: model.run(
names, feeds
),
) as run,
):
self.moge.infer(
session,
np.zeros((height, width, 3), dtype=np.float32),
0,
release_memory=False,
)
self.assertFalse(run.call_args.kwargs["release_memory"])
def test_postprocess_recovers_camera_projection_and_metric_scale(self):
height, width = 64, 80
focal = np.float32(0.9)
shift = np.float32(0.3)
metric_scale = np.float32(2.0)
uv = self.moge.normalized_view_plane_uv(width, height, np.float32)
depth = np.linspace(1.0, 2.4, height * width, dtype=np.float32).reshape(
height,
width,
)
raw_points = np.concatenate(
(uv * depth[..., None] / focal, (depth - shift)[..., None]),
axis=-1,
)
raw = {
"points": raw_points[None],
"normal": np.broadcast_to(
np.array((0.0, 0.0, 1.0), dtype=np.float32),
(1, height, width, 3),
),
"mask": np.ones((1, height, width), dtype=np.float32),
"metric_scale": np.array((metric_scale,), dtype=np.float32),
}
raw["mask"][0, 0, :4] = [0.0, 0.2, 0.7, 1.0]
result = self.moge.postprocess(raw)
np.testing.assert_allclose(result["depth"], depth * metric_scale, rtol=1e-3)
np.testing.assert_allclose(result["points"][..., 2], result["depth"], rtol=1e-5)
np.testing.assert_array_equal(result["mask"], raw["mask"][0])
def test_postprocess_preserves_mask_for_nonpositive_depth(self):
mask = np.array([[[0.2, 0.7], [0.0, 1.0]]], dtype=np.float32)
raw = {
"points": np.zeros((1, 2, 2, 3), dtype=np.float32),
"normal": np.zeros((1, 2, 2, 3), dtype=np.float32),
"mask": mask,
"metric_scale": np.ones(1, dtype=np.float32),
}
with patch.object(self.moge, "recover_focal_shift", return_value=(1.0, -1.0)) as recover:
result = self.moge.postprocess(raw)
np.testing.assert_array_equal(recover.call_args.args[1], mask[0] > 0.5)
np.testing.assert_array_equal(result["mask"], mask[0])
self.assertTrue((result["depth"] < 0).all())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,205 @@
import sys
import unittest
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
from server.models import onnx_runtime
class FakeSession:
def __init__(self, providers):
self.providers = providers
self.calls = []
def get_providers(self):
return self.providers
def run(self, output_names, input_feed, run_options=None):
self.calls.append((output_names, input_feed, run_options))
return ["prediction"]
class OnnxRuntimeTest(unittest.TestCase):
def test_directml_disables_parallel_execution_and_memory_pattern(self):
options = SimpleNamespace(enable_mem_pattern=True, execution_mode="parallel")
runtime = ModuleType("onnxruntime")
runtime.get_available_providers = lambda: ["DmlExecutionProvider"]
runtime.SessionOptions = lambda: options
runtime.ExecutionMode = SimpleNamespace(ORT_SEQUENTIAL="sequential")
calls = []
runtime.InferenceSession = lambda path, **kwargs: calls.append(kwargs)
with patch.dict(sys.modules, {"onnxruntime": runtime}):
onnx_runtime.create_session("model.onnx", "directml")
self.assertFalse(options.enable_mem_pattern)
self.assertEqual(options.execution_mode, "sequential")
self.assertIs(calls[0]["sess_options"], options)
def create_runtime(self, available):
calls = []
runtime = ModuleType("onnxruntime")
runtime.get_available_providers = lambda: available
runtime.InferenceSession = lambda path, providers: calls.append(
(path, providers)
)
return runtime, calls
def test_cuda_session_uses_shrinkable_arena(self):
runtime, calls = self.create_runtime(
["CUDAExecutionProvider", "CPUExecutionProvider"]
)
with patch.dict(sys.modules, {"onnxruntime": runtime}):
onnx_runtime.create_session("model.onnx", "cuda")
self.assertEqual(
calls[0],
(
"model.onnx",
[
(
"CUDAExecutionProvider",
{"arena_extend_strategy": "kSameAsRequested"},
)
],
),
)
def test_coreml_and_cpu_use_plain_session_providers(self):
runtime, calls = self.create_runtime(
["CoreMLExecutionProvider", "CPUExecutionProvider"]
)
with patch.dict(sys.modules, {"onnxruntime": runtime}):
onnx_runtime.create_session("model.onnx", "auto")
self.assertEqual(
calls[0][1],
[
"CoreMLExecutionProvider",
"CPUExecutionProvider",
],
)
def test_model_options_only_configure_coreml_provider(self):
runtime, calls = self.create_runtime(
["CoreMLExecutionProvider", "CPUExecutionProvider"]
)
with patch.dict(sys.modules, {"onnxruntime": runtime}):
onnx_runtime.create_session(
"model.onnx",
"coreml",
coreml_options={"RequireStaticInputShapes": "1"},
)
self.assertEqual(
calls[0][1],
[
(
"CoreMLExecutionProvider",
{"RequireStaticInputShapes": "1"},
),
"CPUExecutionProvider",
],
)
def test_coreml_device_selects_coreml_provider(self):
available = [
"CoreMLExecutionProvider",
"CPUExecutionProvider",
]
self.assertEqual(
onnx_runtime.select_providers("coreml", available),
["CoreMLExecutionProvider", "CPUExecutionProvider"],
)
self.assertEqual(
onnx_runtime.select_providers(
"coreml",
["CPUExecutionProvider"],
),
["CPUExecutionProvider"],
)
def test_old_mps_device_is_rejected(self):
with self.assertRaises(KeyError):
onnx_runtime.select_providers(
"mps",
["CoreMLExecutionProvider", "CPUExecutionProvider"],
)
def test_completing_cuda_run_shrinks_memory_arena(self):
configs = []
class FakeRunOptions:
def add_run_config_entry(self, key, value):
configs.append((key, value))
runtime = ModuleType("onnxruntime")
runtime.RunOptions = FakeRunOptions
session = FakeSession(["CUDAExecutionProvider", "CPUExecutionProvider"])
with patch.dict(sys.modules, {"onnxruntime": runtime}):
result = onnx_runtime.run_session(session, ["output"], {"input": 1})
self.assertEqual(result, ["prediction"])
self.assertIsInstance(session.calls[0][2], FakeRunOptions)
self.assertEqual(
configs,
[("memory.enable_memory_arena_shrinkage", "gpu:0")],
)
def test_intermediate_and_non_cuda_runs_use_plain_session_run(self):
cuda = FakeSession(["CUDAExecutionProvider"])
directml = FakeSession(["DmlExecutionProvider"])
onnx_runtime.run_session(
cuda,
["output"],
{"input": 1},
release_memory=False,
)
result = onnx_runtime.run_session(
directml,
["output"],
{"input": 2},
)
self.assertIsNone(cuda.calls[0][2])
self.assertIsNone(directml.calls[0][2])
self.assertEqual(result, ["prediction"])
def test_installed_runtime_accepts_memory_arena_shrinkage_option(self):
import onnxruntime
run_options = onnxruntime.RunOptions()
run_options.add_run_config_entry(
"memory.enable_memory_arena_shrinkage",
"gpu:0",
)
def test_auto_provider_prefers_accelerated_runtime(self):
providers = onnx_runtime.select_providers(
"auto",
["CPUExecutionProvider", "DmlExecutionProvider"],
)
self.assertEqual(
providers,
["DmlExecutionProvider", "CPUExecutionProvider"],
)
def test_explicit_unavailable_provider_falls_back_to_cpu(self):
providers = onnx_runtime.select_providers(
"cuda",
["CPUExecutionProvider"],
)
self.assertEqual(providers, ["CPUExecutionProvider"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,125 @@
import hashlib
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
import pytest
from PIL import Image
from server.models import onnx_pisa_sr as pisa
from server.model_manager import ModelManager, ModelSessionCache
from server import model_catalog
class FakePisa:
def __init__(self):
self.noise = np.zeros((1, 4, 64, 64), dtype=np.float16)
self.stages = []
self.closed = False
self.decoder_inputs = []
def activate(self, component):
self.component = component
self.stages.append(component)
def run(self, tensor, final=False):
if self.component == "encoder":
return np.zeros((1,8,64,64), dtype=np.float16)
if self.component == "unet-pixel":
return np.full_like(tensor, .1)
if self.component == "unet-full":
return np.full_like(tensor, .2)
self.decoder_inputs.append(tensor.copy())
return np.zeros((1,3,512,512), dtype=np.float16)
def close(self):
self.closed = True
@pytest.mark.parametrize("size", [(1,1), (7,5), (256,256), (513,257)])
def test_preset_output_size_and_prediction_combination(size):
model = FakePisa()
output = pisa.infer(model, Image.new("RGB", size, (32,64,96)))
assert output.size == (size[0]*2, size[1]*2)
assert model.stages == ["encoder", "unet-pixel", "unet-full", "decoder"]
for latent in model.decoder_inputs:
np.testing.assert_allclose(latent, -.17, atol=.0002)
assert np.isfinite(np.asarray(output)).all()
def test_cancel_cleans_intermediate_files_and_session(tmp_path):
model = FakePisa()
original = pisa.TemporaryDirectory
def temporary(**kwargs):
return original(dir=tmp_path, **kwargs)
checks = 0
def cancel():
nonlocal checks
checks += 1
return checks == 4
with patch.object(pisa, "TemporaryDirectory", side_effect=temporary):
with pytest.raises(RuntimeError, match="cancelled"):
pisa.infer(model, Image.new("RGB", (256,256)), cancel)
assert model.closed
assert not list(tmp_path.iterdir())
def test_stage_releases_previous_session_before_loading(tmp_path):
np.save(tmp_path / "noise.npy", np.zeros((1,4,64,64), dtype=np.float16))
model = pisa.PisaSession(tmp_path, "cpu")
loaded = []
def create(path, device):
assert model.session is None
loaded.append(path.stem)
return SimpleNamespace(get_providers=lambda: ["CPUExecutionProvider"], disable_fallback=lambda: None)
with patch.object(pisa, "create_session", side_effect=create):
model.activate("unet-pixel")
model.activate("unet-full")
assert loaded == ["unet-pixel", "unet-full"]
def test_resource_error_closes_pipeline():
model = FakePisa()
with patch.object(model, "run", side_effect=RuntimeError("out of memory")):
with pytest.raises(pisa.UpscaleResourceError):
pisa.infer(model, Image.new("RGB", (256,256)))
assert model.closed
def test_pisa_readiness_checks_every_asset_and_corruption(tmp_path):
content = b"verified"
digest = hashlib.sha256(content).hexdigest()
files = tuple((name, len(content), digest) for name in ("encoder.onnx", "noise.npy"))
spec = SimpleNamespace(directory_name="pisa-sr", r2_files=files)
directory = tmp_path / "pisa-sr"
directory.mkdir()
manager = ModelManager(tmp_path)
with patch.object(model_catalog, "get_downloadable_model", return_value=spec):
assert not manager.ready("PISA_SR")
for name, _, _ in files:
(directory / name).write_bytes(content)
assert manager.ready("PISA_SR")
(directory / "noise.npy").write_bytes(b"damaged!")
assert not manager.ready("PISA_SR")
def test_pisa_cache_reuse_and_clear(tmp_path):
cache = ModelSessionCache()
with patch.object(pisa, "PisaSession", return_value=object()) as create:
first = cache.get_upscale(tmp_path / "pisa-sr", "cpu")
assert cache.get_upscale(tmp_path / "pisa-sr", "cpu") is first
assert create.call_count == 1
cache.close()
assert cache.upscale_model is None
def test_coreml_returns_explicit_support_reason(tmp_path):
with pytest.raises(RuntimeError, match="select CPU"):
pisa.PisaSession(tmp_path, "coreml")
def test_clear_closes_pisa_components_even_with_external_reference():
cache = ModelSessionCache()
model = FakePisa()
cache.upscale_model = model
cache.close()
assert model.closed

View File

@@ -0,0 +1,166 @@
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
from PIL import Image
from server.models import onnx_upscale
class FakeSession:
def get_providers(self):
return ["CPUExecutionProvider"]
def get_inputs(self):
return [SimpleNamespace(name="input")]
def get_outputs(self):
return [SimpleNamespace(name="output")]
def run(self, output_names, feeds):
self.output_names = output_names
self.feeds = feeds
source = feeds["input"]
return [np.repeat(np.repeat(source, 4, axis=2), 4, axis=3)]
class UpscaleOnnxTest(unittest.TestCase):
def test_preprocess_matches_dynamic_rgb_layout(self):
image = Image.new("RGB", (7, 5), (64, 128, 192))
result = onnx_upscale.preprocess(image)
self.assertEqual(result.shape, (1, 3, 5, 7))
self.assertEqual(result.dtype, np.float32)
self.assertTrue(result.flags.c_contiguous)
np.testing.assert_allclose(
result[0, :, 0, 0],
np.array((64, 128, 192), dtype=np.float32) / 255.0,
)
def test_infer_returns_direct_four_x_rgb_prediction(self):
session = FakeSession()
image = Image.new("RGB", (7, 5), (10, 20, 30))
result = onnx_upscale.infer(session, image)
self.assertEqual(result.mode, "RGB")
self.assertEqual(result.size, (28, 20))
self.assertEqual(session.output_names, ["output"])
self.assertEqual(session.feeds["input"].shape, (1, 3, 256, 256))
self.assertEqual(result.getpixel((0, 0)), (10, 20, 30))
def test_infer_returns_raw_rgb_for_rgba_input(self):
session = FakeSession()
pixels = np.zeros((5, 7, 4), dtype=np.uint8)
pixels[..., :3] = (10, 20, 30)
pixels[..., 3] = np.arange(7, dtype=np.uint8) * 40
image = Image.fromarray(pixels, mode="RGBA")
result = onnx_upscale.infer(session, image)
self.assertEqual(result.mode, "RGB")
self.assertEqual(result.size, (28, 20))
self.assertEqual(result.getpixel((0, 0)), (10, 20, 30))
def test_tiles_use_reflected_context_at_outer_image_edges(self):
class RecordingSession(FakeSession):
def run(self, output_names, feeds):
self.input = feeds["input"].copy()
return super().run(output_names, feeds)
pixels = np.zeros((5, 7, 3), dtype=np.uint8)
pixels[..., 0] = np.arange(7, dtype=np.uint8) * 20
session = RecordingSession()
onnx_upscale.infer(
session,
Image.fromarray(pixels, mode="RGB"),
)
red = session.input[0, 0]
self.assertAlmostEqual(red[16, 22], 120 / 255.0)
self.assertAlmostEqual(red[16, 23], 100 / 255.0)
def test_infer_rejects_non_four_x_output(self):
class InvalidSession(FakeSession):
def run(self, _output_names, feeds):
return [feeds["input"]]
with self.assertRaisesRegex(RuntimeError, "unexpected output shape"):
onnx_upscale.infer(
InvalidSession(),
Image.new("RGB", (7, 5)),
)
def test_all_models_split_large_inputs_into_fixed_tiles(self):
class CountingSession(FakeSession):
def __init__(self):
self.calls = 0
def run(self, output_names, feeds):
self.calls += 1
return super().run(output_names, feeds)
session = CountingSession()
result = onnx_upscale.infer(
session,
Image.new("RGB", (449, 225), (1, 2, 3)),
)
self.assertEqual(result.size, (1796, 900))
self.assertEqual(session.calls, 6)
self.assertEqual(session.feeds["input"].shape, (1, 3, 256, 256))
def test_only_final_tile_releases_memory(self):
releases = []
session = FakeSession()
def run(model, output_names, feeds, *, release_memory):
releases.append(release_memory)
return model.run(output_names, feeds)
with patch.object(onnx_upscale, "run_session", side_effect=run):
result = onnx_upscale.infer(
session,
Image.new("RGB", (449, 225), (1, 2, 3)),
)
self.assertEqual(result.size, (1796, 900))
self.assertEqual(releases, [False, False, False, False, False, True])
def test_non_final_image_does_not_release_its_final_tile(self):
releases = []
session = FakeSession()
def run(model, output_names, feeds, *, release_memory):
releases.append(release_memory)
return model.run(output_names, feeds)
with patch.object(onnx_upscale, "run_session", side_effect=run):
onnx_upscale.infer(
session,
Image.new("RGB", (449, 225), (1, 2, 3)),
release_memory=False,
)
self.assertEqual(releases, [False] * 6)
def test_provider_unicode_error_is_presented_as_resource_error(self):
class InvalidProviderSession(FakeSession):
def run(self, _output_names, _feeds):
raise UnicodeDecodeError("utf-8", b"\xc4", 0, 1, "invalid")
with self.assertRaisesRegex(
onnx_upscale.UpscaleResourceError,
"provider failed",
):
onnx_upscale.infer(
InvalidProviderSession(),
Image.new("RGB", (7, 5)),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,439 @@
import sys
import tempfile
import unittest
from contextlib import nullcontext
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
import numpy as np
from tests.anyimage.server.support import load_server_module, FakeJobContext
class ServerJobsTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.resolution = load_server_module("media.resolution")
cls.background = load_server_module("jobs.remove_background")
cls.ben2 = load_server_module("models.ben2")
cls.onnx_upscale = load_server_module("models.onnx_upscale")
cls.depth_plane_job = load_server_module("jobs.depth_plane")
cls.upscale_job = load_server_module("jobs.upscale")
def test_upscale_progress_omits_single_frame_count(self):
self.assertEqual(
self.upscale_job.frame_progress_message("Upscaling image", 0, 1),
"Upscaling image",
)
self.assertEqual(
self.upscale_job.frame_progress_message("Upscaling image", 1, 3),
"Upscaling image (2/3)",
)
def test_plane_job_requests_depth_and_matching_normal_space(self):
for plane_type, normal_space in (("DEPTH", "OBJECT"), ("RELIEF", "TANGENT")):
normal_key = f"{normal_space.lower()}_normal"
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
input_path = root / "input.png"
input_path.touch()
context = FakeJobContext(root / "result", "generate-depth-plane-geometry")
depth_texture = context.directory / "depth.exr"
metadata = context.directory / "depth.json"
normal_texture = context.directory / f"{normal_space.lower()}-normal.png"
parameters = {"input": str(input_path), "plane_type": plane_type}
with (
patch.object(
self.depth_plane_job,
"input_frames",
return_value=nullcontext([input_path]),
),
patch.object(self.depth_plane_job, "copy_color_images"),
patch.object(
self.depth_plane_job,
"generate_moge2_artifacts",
return_value={
"depth": depth_texture,
"depth_metadata": metadata,
normal_key: normal_texture,
},
) as generate,
):
result = self.depth_plane_job.run(context, parameters)
self.assertEqual(
result,
{
"depth": "depth.exr",
"depth_metadata": "depth.json",
normal_key: f"{normal_space.lower()}-normal.png",
},
)
generate.assert_called_once_with(
context,
parameters,
input_path,
generate_depth=True,
normal_mode=normal_space,
)
def test_ben2_background_removal_writes_fast_transparent_png(self):
inference_options = []
save_options = []
class FakeImage:
size = (1, 1)
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def convert(self, mode):
self.mode = mode
return self
def getchannel(self, _name):
return self
def putalpha(self, _alpha):
return None
def paste(self, *_args):
return None
def save(self, path, **options):
save_options.append(options)
Path(path).touch()
class FakeModel:
@classmethod
def from_pretrained(cls, model_dir):
self.assertEqual(model_dir, "model")
return cls()
def to(model_self, device):
self.assertEqual(device.type, "cpu")
return model_self
def eval(self):
return None
def inference(self, image, **options):
inference_options.append((image, options))
return FakeImage()
fake_torch = ModuleType("torch")
fake_torch.cuda = SimpleNamespace(
is_available=lambda: False,
synchronize=lambda: None,
)
fake_torch.device = lambda device_type: SimpleNamespace(type=device_type)
fake_torch.inference_mode = nullcontext
fake_ben2 = ModuleType("server.models.onnx_ben2")
fake_ben2.create_session = lambda _model_dir, _device: object()
fake_ben2.infer = lambda _model, image, **options: (
inference_options.append((image, options)) or FakeImage()
)
fake_pil = ModuleType("PIL")
fake_pil.Image = SimpleNamespace(
open=lambda _path: FakeImage(),
new=lambda *_args, **_kwargs: FakeImage(),
)
fake_pil.ImageChops = SimpleNamespace(
multiply=lambda _source, _foreground: FakeImage()
)
with tempfile.TemporaryDirectory() as directory, patch.dict(
sys.modules,
{
"torch": fake_torch,
"server.models.onnx_ben2": fake_ben2,
"PIL": fake_pil,
},
):
root = Path(directory)
input_path = root / "input.png"
input_path.touch()
context = FakeJobContext(root / "result", "remove-background")
self.background.run(
context,
{"input": str(input_path), "model": "BEN2", "device": "cpu"},
)
self.assertTrue((root / "result" / "image.0001.png").is_file())
self.assertEqual(
inference_options[0][1],
{"release_memory": True},
)
self.assertEqual(
save_options[0],
{"format": "PNG", "compress_level": 1},
)
self.assertIn("Background removed in", context.status["message"])
def test_ben2_preserves_input_alpha_and_hides_transparent_rgb(self):
from PIL import Image
onnx_ben2 = load_server_module("models.onnx_ben2")
source_pixels = np.asarray(
(((255, 0, 0, 0), (0, 255, 0, 128)),),
dtype=np.uint8,
)
model_result = Image.new("RGBA", (2, 1), (10, 20, 30, 128))
received = []
def infer(_model, image, **_options):
received.append(np.asarray(image.convert("RGB"), dtype=np.uint8))
return model_result
with tempfile.TemporaryDirectory() as directory:
input_path = Path(directory) / "input.png"
Image.fromarray(source_pixels, mode="RGBA").save(input_path)
with (
patch.object(onnx_ben2, "create_session", return_value=object()),
patch.object(onnx_ben2, "infer", side_effect=infer),
):
result, _load_ms, _inference_ms = self.ben2.infer_foreground(
input_path,
"model",
"cpu",
preserve_input_alpha=True,
)
raw_result, _load_ms, _inference_ms = self.ben2.infer_foreground(
input_path,
"model",
"cpu",
)
inference_pixels = received[0]
result_pixels = np.asarray(result.convert("RGBA"), dtype=np.uint8)
self.assertEqual(tuple(inference_pixels[0, 0]), (0, 0, 0))
self.assertEqual(tuple(inference_pixels[0, 1]), (0, 128, 0))
self.assertEqual(tuple(received[1][0, 0]), (0, 0, 0))
self.assertEqual(tuple(received[1][0, 1]), (0, 128, 0))
self.assertEqual(tuple(result_pixels[0, 0]), (255, 0, 0, 0))
self.assertEqual(tuple(result_pixels[0, 1]), (0, 255, 0, 64))
self.assertEqual(
tuple(np.asarray(raw_result.convert("RGBA"))[0, 0]),
(10, 20, 30, 128),
)
def test_ben2_background_removal_processes_each_frame(self):
inference_options = []
class FakeImage:
size = (1, 1)
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def convert(self, mode):
self.mode = mode
return self
def getchannel(self, _name):
return self
def putalpha(self, _alpha):
return None
def paste(self, *_args):
return None
def save(self, path, **options):
Path(path).touch()
class FakeModel:
@classmethod
def from_pretrained(cls, model_dir):
return cls()
def to(model_self, device):
return model_self
def eval(self):
return None
def inference(self, image, **options):
inference_options.append((image, options))
return FakeImage()
fake_torch = ModuleType("torch")
fake_torch.cuda = SimpleNamespace(
is_available=lambda: False,
synchronize=lambda: None,
)
fake_torch.device = lambda device_type: SimpleNamespace(type=device_type)
fake_torch.inference_mode = nullcontext
fake_ben2 = ModuleType("server.models.onnx_ben2")
fake_ben2.create_session = lambda _model_dir, _device: object()
fake_ben2.infer = lambda _model, image, **options: (
inference_options.append((image, options)) or FakeImage()
)
fake_pil = ModuleType("PIL")
fake_pil.Image = SimpleNamespace(
open=lambda _path: FakeImage(),
new=lambda *_args, **_kwargs: FakeImage(),
)
fake_pil.ImageChops = SimpleNamespace(
multiply=lambda _source, _foreground: FakeImage()
)
with tempfile.TemporaryDirectory() as directory, patch.dict(
sys.modules,
{
"torch": fake_torch,
"server.models.onnx_ben2": fake_ben2,
"PIL": fake_pil,
},
):
root = Path(directory)
input_directory = root / "frames"
input_directory.mkdir()
(input_directory / "frame-0001.jpg").touch()
(input_directory / "frame-0002.jpg").touch()
context = FakeJobContext(root / "result", "remove-background")
self.background.run(
context,
{
"input": str(input_directory),
"model": "BEN2",
"device": "cpu",
"video_frames": 8,
},
)
result_directory = context.directory
self.assertTrue(
(result_directory / "image.0001.png").is_file()
)
self.assertTrue(
(result_directory / "image.0002.png").is_file()
)
self.assertEqual(len(inference_options), 2)
self.assertEqual(
[options["release_memory"] for _image, options in inference_options],
[False, True],
)
def test_upscale_job_saves_importable_upscale_sequence(self):
from PIL import Image
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
frames = root / "frames"
frames.mkdir()
(frames / "a.png").touch()
(frames / "b.png").touch()
context = FakeJobContext(root / "result", "upscale-image")
with patch.object(
self.upscale_job,
"infer_one",
return_value=Image.new("RGB", (8, 8), (10, 20, 30)),
) as infer_one:
result = self.upscale_job.run(
context,
{
"input": str(frames),
"model": "REALESRGAN_GENERAL_WDN_X4V3",
"video_frames": 2,
"device": "cpu",
},
)
self.assertEqual(infer_one.call_count, 2)
self.assertEqual(
[call.kwargs["release_memory"] for call in infer_one.call_args_list],
[False, True],
)
self.assertEqual(result, {"upscale": "."})
colors = sorted(context.directory.glob("upscale.*.png"))
self.assertEqual(
[path.name for path in colors],
["upscale.0001.png", "upscale.0002.png"],
)
self.assertIn(
(
"INFO",
"Upscale model: REALESRGAN_GENERAL_WDN_X4V3; "
"maximum AI input size: 2048 px",
),
context.logs,
)
def test_upscale_job_preserves_source_alpha(self):
from PIL import Image
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source_path = root / "source.png"
pixels = np.zeros((3, 5, 4), dtype=np.uint8)
pixels[..., :3] = (10, 20, 30)
pixels[..., 3] = np.arange(5, dtype=np.uint8) * 60
Image.fromarray(pixels, mode="RGBA").save(source_path)
context = FakeJobContext(root / "result", "upscale-image")
prediction = Image.new("RGB", (20, 12), (10, 20, 30))
with patch.object(
self.onnx_upscale,
"infer",
return_value=prediction,
):
self.upscale_job.run(
context,
{
"input": str(source_path),
"model": "REALESRGAN_GENERAL_WDN_X4V3",
"video_frames": 1,
"device": "cpu",
},
)
with Image.open(context.directory / "upscale.0001.png") as result:
self.assertEqual(result.mode, "RGBA")
self.assertEqual(result.size, (10, 6))
alpha = np.asarray(result.getchannel("A"))
expected_alpha = np.asarray(
Image.fromarray(pixels[:, :, 3], mode="L").resize(
result.size,
Image.Resampling.LANCZOS,
)
)
np.testing.assert_array_equal(alpha, expected_alpha)
self.assertLess(
int(alpha[:, 0].max()),
int(alpha[:, -1].min()),
)
def test_upscale_input_uses_the_global_long_edge_limit(self):
from PIL import Image
source = Image.new("RGB", (3000, 1500))
limited = self.resolution.limit_image(source, 2048)
self.assertEqual(limited.size, (2048, 1024))
self.assertEqual(
self.resolution.limit_image(Image.new("RGB", (800, 600)), 2048).size,
(800, 600),
)
with self.assertRaisesRegex(ValueError, "between 1 and 8192"):
self.resolution.limit_image(source, 9000)

View File

@@ -0,0 +1,365 @@
import importlib
import sys
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_semantic_mesh_operators_are_registered_without_dialog_options(self):
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.assertFalse(hasattr(self.convert_to_plane, "ConvertToDepthSurface"))
self.assertIn(self.cutout_main.CutoutSelectionToShape, self.anyimage.CLASSES)
self.assertEqual(operator.bl_idname, "anyimage.convert_to_plane")
self.assertNotIn("shadeless", operator.__annotations__)
self.assertNotIn("invoke", operator.__dict__)
self.assertNotIn("draw", operator.__dict__)
self.assertIn("REGISTER", operator.bl_options)
self.assertIn("UNDO", operator.bl_options)
self.assertEqual(operator.__annotations__["mesh_detail"]["default"], 0)
self.assertEqual(
self.convert_to_plane.ConvertToDepthPlane.__annotations__["mesh_detail"][
"default"
],
6,
)
self.assertEqual(
self.convert_to_plane.GenerateDepthPlane.__annotations__["mesh_detail"][
"default"
],
6,
)
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,
)
self.assertNotIn("selection_path_json", operator.__annotations__)
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"})
self.assertNotIn("draw", operator.__dict__)
self.assertEqual(mask_tool.bl_icon, "ops.sculpt.lasso_mask")
self.assertNotIn("bl_options", mask_tool.__dict__)
self.assertEqual(mask_tool.bl_cursor, "PAINT_CROSS")
self.assertEqual(
mask_tool.bl_keymap[0][1]["value"],
"PRESS",
)
self.assertEqual(
mask_tool.bl_keymap[1][1],
{"type": "LEFTMOUSE", "value": "CLICK", "shift": True},
)
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_tool.bl_icon, "ops.sculpt.border_mask")
self.assertEqual(frame_operator.bl_options, {"UNDO"})
self.assertNotIn("bl_options", frame_tool.__dict__)
self.assertEqual(frame_tool.bl_keymap[0][1]["value"], "CLICK_DRAG")
self.assertIsNone(frame_tool.bl_keymap[0][2])
self.assertEqual(
[entry[0] for entry in frame_tool.bl_keymap[1:]],
["view3d.select", "view3d.select"],
)
self.assertEqual(
frame_tool.bl_keymap[1][2],
{"properties": [("deselect_all", True)]},
)
self.assertEqual(
frame_tool.bl_keymap[2][2],
{"properties": [("toggle", True)]},
)
self.assertNotIn("draw_settings", frame_tool.__dict__)
self.assertIn("draw_settings", mask_tool.__dict__)
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"])
cutout_tool = importlib.import_module(
"anyimage.operators.cutout_tool"
).CutoutTool
self.assertEqual(
cutout_tool.bl_icon,
"ops.mesh.primitive_sphere_add_gizmo",
)
self.assertNotIn("bl_options", cutout_tool.__dict__)
self.assertEqual(cutout_tool.bl_keymap[0][1]["value"], "CLICK_DRAG")
self.assertEqual(
[entry[0] for entry in cutout_tool.bl_keymap[1:]],
["view3d.select", "view3d.select"],
)
self.assertEqual(
cutout_tool.bl_keymap[1][2],
{"properties": [("deselect_all", False)]},
)
self.assertEqual(
cutout_tool.bl_keymap[2][2],
{"properties": [("toggle", True)]},
)
cutout_module = sys.modules[cutout_tool.__module__]
self.assertNotIn(
"selection_path_json",
cutout_module.SelectCutoutSelection.__annotations__,
)
self.assertIn(
"selection_path_json",
cutout_module.CutoutSelectionToShape.__annotations__,
)
drawn_settings.clear()
with (
patch.object(
cutout_module,
"draw_ai_setup",
return_value={"ready": True},
) as setup,
patch.object(
cutout_module,
"draw_ai_property",
side_effect=lambda _layout, _settings, name, _status: (
drawn_settings.append(name)
),
) as draw,
):
cutout_tool.draw_settings(context, layout, None)
setup.assert_called_once_with(layout)
self.assertEqual(draw.call_count, 1)
self.assertEqual(
drawn_settings,
[
"cutout_generate_normal",
"cutout_mesh_detail",
"cutout_fine_outline",
"cutout_alpha_threshold",
"cutout_inward_axis",
],
)
self.assertEqual(operator.__annotations__["gesture"]["default"], "LASSO")
self.assertEqual(
[item[:2] for item in operator.__annotations__["gesture"]["items"]],
[
("LASSO", "Lasso"),
("BRUSH", "Brush"),
("POLYLINE", "Polyline"),
],
)
self.assertEqual(operator.__annotations__["mode"]["default"], "SUBTRACT")
self.assertEqual(operator.__annotations__["radius"]["default"], 25)
settings = self.anyimage.properties.AnyImageSettings.__annotations__
self.assertEqual(settings["mask_gesture"]["default"], "LASSO")
self.assertEqual(settings["mask_mode"]["default"], "SUBTRACT")
self.assertEqual(settings["mask_radius"]["default"], 25)
self.assertEqual(
{name for name in settings if name.startswith("mask_")},
{"mask_gesture", "mask_mode", "mask_radius"},
)
self.assertEqual(
set(operator.__annotations__),
{
"source_object_name",
"source_matrix_data",
"gesture",
"mode",
"radius",
},
)
self.assertEqual(settings["cutout_mesh_detail"]["default"], "LOW")
self.assertFalse(settings["cutout_fine_outline"]["default"])
self.assertFalse(cutout_module.CutoutSelectionToShape.__annotations__["fine_outline"]["default"])
self.assertEqual(settings["cutout_alpha_threshold"]["default"], 0.9)
self.assertEqual(settings["cutout_alpha_threshold"]["min"], 0.0)
self.assertEqual(settings["cutout_alpha_threshold"]["max"], 1.0)
self.assertEqual(settings["cutout_alpha_threshold"]["soft_min"], 0.1)
self.assertEqual(settings["cutout_alpha_threshold"]["soft_max"], 0.9)
self.assertEqual(settings["cutout_alpha_threshold"]["subtype"], "FACTOR")
self.assertEqual(
[item[:2] for item in settings["cutout_mesh_detail"]["items"]],
[
("LOW", "Low"),
("MEDIUM", "Medium"),
("HIGH", "High"),
("ULTRA", "Ultra"),
],
)
self.assertFalse(settings["cutout_generate_normal"]["default"])
self.assertEqual(settings["cutout_inward_axis"]["default"], "NEGATIVE_Z")
self.assertFalse(any("crop" in name for name in settings))
self.assertEqual(
settings["cutout_mesh_detail"]["name"], "Mesh Detail"
)
self.assertEqual(
settings["cutout_generate_normal"]["name"],
"Normal Map",
)
self.assertNotIn("image_extract_continuous", settings)
self.assertNotIn("image_extract_padding", settings)
self.assertNotIn("image_extract_remove_background", settings)
def test_rectify_tool_registration_and_properties(self):
operator = self.rectify.RectifyImagePerspective
tool = self.rectify.RectifyTool
settings = self.anyimage.properties.AnyImageSettings.__annotations__
self.assertIn(operator, self.anyimage.CLASSES)
self.assertNotIn(tool, self.anyimage.CLASSES)
self.assertEqual(tool.bl_operator, operator.bl_idname)
self.assertEqual(tool.bl_icon, "ops.sculpt.line_project")
self.assertNotIn("bl_options", tool.__dict__)
self.assertEqual(tool.bl_keymap[0][1]["value"], "PRESS")
self.assertEqual(
tool.bl_keymap[1][1],
{"type": "LEFTMOUSE", "value": "CLICK", "shift": True},
)
self.assertNotIn("bl_cursor", tool.__dict__)
self.assertNotIn("draw_settings", tool.__dict__)
self.assertNotIn("rectify_aspect_mode", settings)
self.assertNotIn("rectify_aspect_ratio", settings)
self.assertNotIn("aspect_mode", operator.__annotations__)
self.assertIn("aspect_ratio", operator.__annotations__)
self.assertNotIn("continuous", operator.__annotations__)
self.assertNotIn("padding", operator.__annotations__)
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()
self.context_menu_draws.append(existing_context_draw)
self.outliner_menu_draws.append(existing_outliner_draw)
self.assertFalse(any("debug" in cls.bl_idname.lower() for cls in self.anyimage.CLASSES if hasattr(cls, "bl_idname")))
settings = self.anyimage.properties.AnyImageSettings.__annotations__
self.assertEqual(set(settings), {
"cutout_alpha_threshold", "cutout_mesh_detail", "cutout_fine_outline",
"cutout_generate_normal", "cutout_inward_axis", "mask_gesture",
"mask_mode", "mask_radius", "video_frames",
})
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.assertFalse(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source"))
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.assertFalse(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source"))

View File

@@ -0,0 +1,130 @@
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 = {}
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={
"environment_ready": True,
"missing_models": ("BEN2_BASE",),
"ready": False,
},
):
menu.draw(
SimpleNamespace(
object=SimpleNamespace(
type="EMPTY",
empty_display_type="IMAGE",
data=SimpleNamespace(size=(640, 480)),
)
)
)
self.assertEqual(
calls,
[
("anyimage.convert_to_plane", True),
("anyimage.convert_to_depth_plane", True),
("anyimage.convert_to_relief_plane", 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)",
)
def test_image_menu_opens_environment_settings_when_ai_is_ready(self):
calls = []
class Layout:
operator_context = ""
enabled = True
def operator(self, identifier, **_options):
calls.append((identifier, self.enabled))
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={
"environment_ready": True,
"missing_models": (),
"ready": True,
},
):
menu.draw(None)
self.assertIn(("anyimage.open_ai_environment_settings", True), calls)
self.assertNotIn(("anyimage.setup_ai_environment", True), calls)
def test_image_operators_stay_clickable_until_server_is_busy(self):
context = SimpleNamespace(
object=SimpleNamespace(data=SimpleNamespace()),
)
operators = (
(self.convert_to_plane, self.convert_to_plane.ConvertToDepthPlane),
(self.convert_to_plane, self.convert_to_plane.ConvertToReliefPlane),
(self.remove_background, 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 module, operator in operators:
with (
self.subTest(operator=operator.__name__),
patch.object(
module,
"is_image_empty",
return_value=True,
),
patch.object(
module,
"video_total_frames",
return_value=1,
),
):
self.assertEqual(operator.poll(context), expected)

View File

@@ -0,0 +1,168 @@
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)
self.assertNotIn("--venv-dir", command)
self.assertNotIn("--models-dir", command)
self.assertNotIn("--stages", command)
def test_runtime_request_uses_request_response_shape(self):
result = object()
responses = []
with patch.object(
self.anyimage.runtime.server,
"request",
return_value=result,
) as request:
returned = self.anyimage.runtime.request(
"example",
{"value": 3},
response=responses.append,
)
self.assertIs(returned, result)
request.assert_called_once_with(
"example",
{"value": 3},
response=responses.append,
progress=None,
cancel_check=None,
)
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])
def test_runtime_keeps_server_as_an_entrypoint_string(self):
path, attribute = self.anyimage.runtime.server_entrypoint.rsplit(":", 1)
self.assertEqual(Path(path).name, "__init__.py")
self.assertEqual(Path(path).parent.name, "server")
self.assertEqual(attribute, "server")

View File

@@ -0,0 +1,474 @@
import importlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from tests.support.blender import BlenderTestCase
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_manifest_rejects_old_mps_device(self):
properties = self.anyimage.properties
with (
patch.object(
self.anyimage.runtime,
"environment_manifest",
return_value={"devices": ["mps", "cpu"]},
),
patch.object(properties.sys, "platform", "darwin"),
patch.object(
properties.platform,
"machine",
return_value="arm64",
),
):
self.assertEqual(properties.supported_devices(), ["cpu"])
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_geometry_model_choices_match_moge_catalog(self):
properties = self.anyimage.properties
expected = tuple(properties.shared_model_catalog.MOGE2_MODELS)
geometry = properties.geometry_model_enum_items(None, None)
self.assertEqual(tuple(item[0] for item in geometry), expected)
def test_global_ai_input_size_defaults_and_clamps(self):
preference_module = self.anyimage.preferences
preferences = self.fake_bpy.context.preferences.addons["anyimage"].preferences
self.assertEqual(
preference_module.configured_max_ai_input_size(),
2048,
)
preferences.max_ai_input_size = 10000
self.assertEqual(
preference_module.configured_max_ai_input_size(),
8192,
)
preferences.max_ai_input_size = 1
self.assertEqual(
preference_module.configured_max_ai_input_size(),
256,
)
def test_cutout_mesh_resolution_defaults_and_clamps_independently(self):
preference_module = self.anyimage.preferences
preferences = self.fake_bpy.context.preferences.addons["anyimage"].preferences
self.assertEqual(
preference_module.configured_max_cutout_mesh_resolution(),
1024,
)
preferences.max_cutout_mesh_resolution = 2048
self.assertEqual(
preference_module.configured_max_cutout_mesh_resolution(),
1024,
)
preferences.max_cutout_mesh_resolution = 1
self.assertEqual(
preference_module.configured_max_cutout_mesh_resolution(),
4,
)
def test_frame_resolution_defaults_and_clamps_independently(self):
preference_module = self.anyimage.preferences
preferences = self.fake_bpy.context.preferences.addons["anyimage"].preferences
self.assertEqual(preference_module.configured_max_frame_resolution(), 2048)
preferences.max_frame_resolution = 10000
self.assertEqual(preference_module.configured_max_frame_resolution(), 8192)
preferences.max_frame_resolution = 1
self.assertEqual(preference_module.configured_max_frame_resolution(), 64)
def test_preferences_draws_upscale_model_download(self):
preference_module = importlib.import_module("anyimage.preferences")
downloads = []
properties = []
labels = []
boxes = []
class Layout:
enabled = True
def prop(self, _owner, name, **_options):
properties.append(name)
def separator(self, **_options):
return None
def label(self, **_options):
labels.append(_options.get("text"))
def row(self, **_options):
return self
def split(self, **_options):
return self
def column(self, **_options):
return self
def box(self):
boxes.append(self)
return self
def operator(self, identifier, **_options):
operator = SimpleNamespace(model="")
if identifier == "anyimage.download_model":
downloads.append(operator)
return operator
preferences = preference_module.AnyImagePreferences()
preferences.layout = Layout()
preferences.geometry_model = "MOGE2_VITS_NORMAL"
preferences.background_model = "BEN2_BASE"
preferences.upscale_model = "SAFMN_REAL_X4"
catalog = tuple(
{
"key": key,
"label": key,
"family": family,
"enabled": True,
"ready": False,
}
for key, family in (
("MOGE2_VITS_NORMAL", "moge2"),
("BEN2_BASE", "ben2"),
("REALESRGAN_GENERAL_WDN_X4V3", "upscale"),
("SAFMN_REAL_X4", "upscale"),
)
)
with (
patch.object(
self.anyimage.properties,
"ai_status",
return_value={
"environment_ready": True,
"missing_models": (),
"ready": True,
},
),
patch.object(
self.anyimage.properties,
"model_catalog",
return_value=catalog,
),
patch.object(self.anyimage.runtime, "server_busy", return_value=False),
):
preferences.draw(None)
self.assertEqual(
[download.model for download in downloads],
["MOGE2_VITS_NORMAL", "BEN2_BASE", "SAFMN_REAL_X4"],
)
self.assertEqual(
[
label
for label in labels
if label
in {
"Server",
"AI",
"Tools",
"Frame Tool",
"Depth & Normal",
"Cutout Tool",
}
],
[
"Server",
"AI",
"Tools",
"Frame Tool",
"Depth & Normal",
"Cutout Tool",
],
)
self.assertEqual(len(boxes), 4)
self.assertIn("Shading", labels)
self.assertIn("adapt_material_to_view_transform", properties)
adaptation = preference_module.AnyImagePreferences.__annotations__[
"adapt_material_to_view_transform"
]
self.assertTrue(adaptation["default"])
self.assertNotIn("update", adaptation)
self.assertIn(preference_module.AnyImagePreferences, self.anyimage.CLASSES)
self.assertNotIn("Normal Model", labels)
self.assertIn("background_model", properties)
self.assertIn("upscale_model", properties)
self.assertIn("max_ai_input_size", properties)
self.assertIn("max_frame_resolution", properties)
self.assertIn("max_cutout_mesh_resolution", properties)
self.assertIn("balloon_profile_algorithm", properties)
balloon_profile = preference_module.AnyImagePreferences.__annotations__[
"balloon_profile_algorithm"
]
self.assertEqual(balloon_profile["default"], "POISSON")
self.assertEqual(
tuple(item[0] for item in balloon_profile["items"]),
("POISSON", "TEDDY"),
)
self.assertEqual(
preference_module.AnyImagePreferences.__annotations__["upscale_model"][
"name"
],
"Upscale Image Model",
)
self.assertEqual(
preference_module.AnyImagePreferences.__annotations__["geometry_model"][
"name"
],
"Generate Depth Map Model",
)
self.assertEqual(
preference_module.AnyImagePreferences.__annotations__["background_model"][
"name"
],
"Remove Background Model",
)
def test_ready_models_show_their_ai_feature(self):
preference_module = importlib.import_module("anyimage.preferences")
labels = []
class Layout:
def row(self, **_options):
return self
def label(self, **options):
labels.append((options.get("text"), options.get("icon")))
preferences = preference_module.AnyImagePreferences()
model = {"ready": True}
for ready_label in (
"AI Generate Depth Map",
"AI Remove Background",
"AI Upscale Image",
):
preferences._draw_model_status(Layout(), model, ready_label)
self.assertEqual(
labels,
[
("AI Generate Depth Map", "CHECKMARK"),
("AI Remove Background", "CHECKMARK"),
("AI Upscale Image", "CHECKMARK"),
],
)
def test_preferences_hide_ai_until_server_is_installed(self):
preference_module = importlib.import_module("anyimage.preferences")
labels = []
operators = []
class Layout:
enabled = True
def box(self):
return self
def label(self, **options):
labels.append(options.get("text"))
def prop(self, *_args, **_options):
return None
def row(self, **_options):
return self
def separator(self, **_options):
return None
def operator(self, identifier, **_options):
operators.append(identifier)
return SimpleNamespace(model="")
preferences = preference_module.AnyImagePreferences()
preferences.layout = Layout()
with (
patch.object(
self.anyimage.properties,
"ai_status",
return_value={
"environment_ready": False,
"missing_models": (),
"ready": False,
},
),
patch.object(self.anyimage.properties, "model_catalog", return_value=()),
patch.object(self.anyimage.runtime, "server_busy", return_value=False),
):
preferences.draw(None)
self.assertEqual(
labels,
["Server", "Shading", "Tools", "Frame Tool", "Depth & Normal", "Cutout Tool"],
)
self.assertEqual(
operators,
["anyimage.setup_ai_environment", "anyimage.open_server_log"],
)
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.properties.DEFAULT_MOGE2_MODEL_KEY
operator.resolution_level = 5
operator.max_input_size = 1536
context = SimpleNamespace(
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1))
)
with (
patch.object(self.convert_to_plane, "require_environment"),
patch.object(self.convert_to_plane, "require_model"),
patch.object(self.convert_to_plane, "production_device", return_value="CPU"),
patch.object(self.convert_to_plane, "source_start_frame", return_value=1),
):
parameters = operator.request(context)
self.assertEqual(parameters["max_input_size"], 1536)
self.assertNotIn("shape", parameters)
self.assertNotIn("polygon", parameters)
def test_upscale_execute_reads_model_and_size_from_preferences(self):
source_image = SimpleNamespace(
name="Original",
source="FILE",
anyimage_upscale_source=None,
)
source_object = SimpleNamespace(
name="Source",
type="EMPTY",
empty_display_type="IMAGE",
data=source_image,
)
context = SimpleNamespace(
object=source_object,
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1)),
)
operator = self.upscale.UpscaleImage()
with (
patch.object(
self.upscale,
"configured_upscale_model",
return_value="SAFMN_REAL_X4",
),
patch.object(
self.upscale,
"configured_max_ai_input_size",
return_value=1024,
),
patch.object(
self.upscale,
"prepare_image_input",
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, "SAFMN_REAL_X4")
self.assertEqual(operator.max_input_size, 1024)

View File

@@ -0,0 +1,134 @@
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)
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_server_panel_wraps_multiple_cached_models(self):
for cached_names, expected_labels in (
((), [("row", "Cached: None")]),
(("Model A",), [("row", "Cached: Model A")]),
(
("Model A", "Model B"),
[
("column", "Cached:"),
("column", "Model A"),
("column", "Model B"),
],
),
):
with self.subTest(cached_names=cached_names):
events = self._draw_server_panel("READY", cached_names)
cached_labels = [
(kind, text)
for kind, text, _icon in events.labels
if text != "Running"
]
self.assertEqual(cached_labels, expected_labels)
operators = [identifier for identifier, _enabled in events.operators]
self.assertIn("anyimage.clear_models", 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