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:
0
tests/anyimage/__init__.py
Normal file
0
tests/anyimage/__init__.py
Normal file
0
tests/anyimage/common/__init__.py
Normal file
0
tests/anyimage/common/__init__.py
Normal file
83
tests/anyimage/common/support.py
Normal file
83
tests/anyimage/common/support.py
Normal 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])
|
||||
225
tests/anyimage/common/test_alpha_presentation.py
Normal file
225
tests/anyimage/common/test_alpha_presentation.py
Normal 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)
|
||||
176
tests/anyimage/common/test_depth_calibration.py
Normal file
176
tests/anyimage/common/test_depth_calibration.py
Normal 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
|
||||
250
tests/anyimage/common/test_empty_image_edits.py
Normal file
250
tests/anyimage/common/test_empty_image_edits.py
Normal 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)
|
||||
350
tests/anyimage/common/test_image_data.py
Normal file
350
tests/anyimage/common/test_image_data.py
Normal 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))
|
||||
212
tests/anyimage/common/test_image_edit_tool.py
Normal file
212
tests/anyimage/common/test_image_edit_tool.py
Normal file
@@ -0,0 +1,212 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class ImageEditToolTest(BlenderTestCase):
|
||||
|
||||
|
||||
def test_packed_image_export_does_not_use_scene_render_transform(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output_directory = Path(directory)
|
||||
calls = []
|
||||
|
||||
class Image:
|
||||
file_format = "JPEG"
|
||||
|
||||
def save(self, **options):
|
||||
calls.append(options)
|
||||
Path(options["filepath"]).write_bytes(b"raw image")
|
||||
|
||||
image = Image()
|
||||
scene = SimpleNamespace(
|
||||
view_settings=SimpleNamespace(look="AgX - Medium High Contrast")
|
||||
)
|
||||
with patch.object(
|
||||
self.image_data.tempfile,
|
||||
"mkdtemp",
|
||||
return_value=str(output_directory),
|
||||
):
|
||||
output = self.image_data.save_packed_image(image, scene)
|
||||
|
||||
self.assertEqual(output, output_directory / "input.png")
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[{"filepath": str(output), "save_copy": True}],
|
||||
)
|
||||
self.assertEqual(image.file_format, "JPEG")
|
||||
|
||||
|
||||
def test_expected_empty_image_edits_are_reported_as_warnings(self):
|
||||
reports = []
|
||||
operator = SimpleNamespace(
|
||||
report=lambda level, message: reports.append((level, message))
|
||||
)
|
||||
|
||||
self.image_interaction.report_image_edit_exception(
|
||||
operator,
|
||||
self.image_interaction.ImageEditWarning(
|
||||
"The edit contains no visible image pixels"
|
||||
),
|
||||
)
|
||||
self.image_interaction.report_image_edit_exception(
|
||||
operator,
|
||||
ValueError("Unexpected invalid data"),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
reports,
|
||||
[
|
||||
(
|
||||
{"WARNING"},
|
||||
"The edit contains no visible image pixels",
|
||||
),
|
||||
({"ERROR"}, "Unexpected invalid data"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_image_edit_first_click_selects_another_image_without_editing(self):
|
||||
selected = []
|
||||
|
||||
def image(name):
|
||||
return SimpleNamespace(
|
||||
name=name,
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=object(),
|
||||
select_set=lambda value: selected.append((name, value)),
|
||||
)
|
||||
|
||||
active = image("Active")
|
||||
clicked = image("Clicked")
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=(active,),
|
||||
view_layer=SimpleNamespace(objects=SimpleNamespace(active=active)),
|
||||
)
|
||||
event = SimpleNamespace(mouse_region_x=10, mouse_region_y=20)
|
||||
previous_ops = getattr(self.fake_bpy, "ops", None)
|
||||
previous_objects = self.fake_bpy.data.objects
|
||||
|
||||
def select(**_kwargs):
|
||||
context.active_object = clicked
|
||||
|
||||
self.fake_bpy.ops = SimpleNamespace(
|
||||
view3d=SimpleNamespace(select=select),
|
||||
object=SimpleNamespace(select_all=lambda **_kwargs: None),
|
||||
)
|
||||
self.fake_bpy.data.objects = {
|
||||
active.name: active,
|
||||
clicked.name: clicked,
|
||||
}
|
||||
try:
|
||||
source, should_edit = self.image_interaction.resolve_image_edit_click(
|
||||
context, event, lambda *_args: None
|
||||
)
|
||||
finally:
|
||||
self.fake_bpy.data.objects = previous_objects
|
||||
if previous_ops is None:
|
||||
del self.fake_bpy.ops
|
||||
else:
|
||||
self.fake_bpy.ops = previous_ops
|
||||
|
||||
self.assertIs(source, clicked)
|
||||
self.assertFalse(should_edit)
|
||||
self.assertEqual(selected, [])
|
||||
|
||||
|
||||
def test_image_edit_first_click_selects_another_object_without_editing(self):
|
||||
active = SimpleNamespace(
|
||||
name="Active",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=object(),
|
||||
)
|
||||
clicked = SimpleNamespace(name="Mesh", type="MESH", data=object())
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=(active,),
|
||||
)
|
||||
event = SimpleNamespace(mouse_region_x=10, mouse_region_y=20)
|
||||
previous_ops = getattr(self.fake_bpy, "ops", None)
|
||||
select_options = []
|
||||
|
||||
def select(**options):
|
||||
select_options.append(options)
|
||||
context.active_object = clicked
|
||||
|
||||
self.fake_bpy.ops = SimpleNamespace(
|
||||
view3d=SimpleNamespace(select=select),
|
||||
)
|
||||
try:
|
||||
source, should_edit = self.image_interaction.resolve_image_edit_click(
|
||||
context, event, lambda *_args: None
|
||||
)
|
||||
finally:
|
||||
if previous_ops is None:
|
||||
del self.fake_bpy.ops
|
||||
else:
|
||||
self.fake_bpy.ops = previous_ops
|
||||
|
||||
self.assertIs(source, clicked)
|
||||
self.assertFalse(should_edit)
|
||||
self.assertEqual(select_options[0]["deselect_all"], False)
|
||||
|
||||
|
||||
def test_image_edit_empty_click_keeps_active_image_and_starts_editing(self):
|
||||
active = SimpleNamespace(
|
||||
name="Active",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=object(),
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=(active,),
|
||||
)
|
||||
event = SimpleNamespace(mouse_region_x=10, mouse_region_y=20)
|
||||
previous_ops = getattr(self.fake_bpy, "ops", None)
|
||||
select_options = []
|
||||
self.fake_bpy.ops = SimpleNamespace(
|
||||
view3d=SimpleNamespace(
|
||||
select=lambda **options: select_options.append(options)
|
||||
),
|
||||
)
|
||||
try:
|
||||
source, should_edit = self.image_interaction.resolve_image_edit_click(
|
||||
context, event, lambda *_args: None
|
||||
)
|
||||
finally:
|
||||
if previous_ops is None:
|
||||
del self.fake_bpy.ops
|
||||
else:
|
||||
self.fake_bpy.ops = previous_ops
|
||||
|
||||
self.assertIs(source, active)
|
||||
self.assertTrue(should_edit)
|
||||
self.assertIs(context.active_object, active)
|
||||
self.assertEqual(select_options[0]["deselect_all"], False)
|
||||
|
||||
|
||||
def test_image_edit_source_requires_active_image_to_be_selected(self):
|
||||
active = SimpleNamespace(
|
||||
name="Active",
|
||||
type="EMPTY",
|
||||
empty_display_type="IMAGE",
|
||||
data=object(),
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=(),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
self.image_interaction.ImageEditWarning,
|
||||
"Select an active Image Empty",
|
||||
):
|
||||
self.image_interaction.active_image_edit_source(context)
|
||||
226
tests/anyimage/common/test_image_materials.py
Normal file
226
tests/anyimage/common/test_image_materials.py
Normal 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"]
|
||||
27
tests/anyimage/common/test_material_alpha.py
Normal file
27
tests/anyimage/common/test_material_alpha.py
Normal 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
|
||||
72
tests/anyimage/common/test_material_settings.py
Normal file
72
tests/anyimage/common/test_material_settings.py
Normal 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",
|
||||
)
|
||||
232
tests/anyimage/common/test_material_view_adaptation.py
Normal file
232
tests/anyimage/common/test_material_view_adaptation.py
Normal 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"
|
||||
52
tests/anyimage/common/test_modifier_inputs.py
Normal file
52
tests/anyimage/common/test_modifier_inputs.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from tests.anyimage.common.support import load_common_modules
|
||||
|
||||
class ModifierInputsTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.modules = load_common_modules()
|
||||
|
||||
def test_runtime_modifier_enum_input_uses_string_identifier(self):
|
||||
enum_items = (
|
||||
SimpleNamespace(identifier="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)
|
||||
14
tests/anyimage/common/test_node_loading.py
Normal file
14
tests/anyimage/common/test_node_loading.py
Normal 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",
|
||||
)
|
||||
192
tests/anyimage/common/test_selection.py
Normal file
192
tests/anyimage/common/test_selection.py
Normal file
@@ -0,0 +1,192 @@
|
||||
import json
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
from anyimage.common import selection
|
||||
|
||||
|
||||
def reference_selection_mask(
|
||||
image_size,
|
||||
selection_path,
|
||||
*,
|
||||
antialias=False,
|
||||
):
|
||||
width, height = map(int, image_size)
|
||||
points = np.asarray(selection_path.points, dtype=np.float64)
|
||||
x = np.arange(width, dtype=np.float64) + 0.5
|
||||
y = np.arange(height, dtype=np.float64) + 0.5
|
||||
winding = np.zeros((height, width), dtype=np.int16)
|
||||
previous = points[-1]
|
||||
for current in points:
|
||||
crosses = (current[1] > y) != (previous[1] > y)
|
||||
edge_x = (previous[0] - current[0]) * (y - current[1]) / (
|
||||
previous[1] - current[1] + 1e-300
|
||||
) + current[0]
|
||||
selected = crosses[:, None] & (x[None, :] < edge_x[:, None])
|
||||
winding += selected * (1 if current[1] > previous[1] else -1)
|
||||
previous = current
|
||||
mask = winding != 0
|
||||
mask = (
|
||||
selection._antialias_mask(mask)
|
||||
if antialias
|
||||
else mask.astype(np.float32)
|
||||
)
|
||||
bounds = selection._selection_canvas_bounds(
|
||||
image_size,
|
||||
selection_path,
|
||||
2 if antialias else 0,
|
||||
)
|
||||
left, top, right, bottom = bounds
|
||||
mask = mask[top:bottom, left:right]
|
||||
if not np.any(mask > 0.0):
|
||||
raise selection.ImageEditWarning(
|
||||
"The selection contains no visible image pixels"
|
||||
)
|
||||
return selection.SelectionMask(mask, bounds)
|
||||
|
||||
|
||||
def assert_matches_reference(image_size, selection_path, **options):
|
||||
try:
|
||||
expected = reference_selection_mask(image_size, selection_path, **options)
|
||||
except selection.ImageEditWarning:
|
||||
with pytest.raises(selection.ImageEditWarning):
|
||||
selection.rasterize_selection_path(
|
||||
image_size,
|
||||
selection_path,
|
||||
**options,
|
||||
)
|
||||
return
|
||||
actual = selection.rasterize_selection_path(
|
||||
image_size,
|
||||
selection_path,
|
||||
**options,
|
||||
)
|
||||
assert actual.bounds == expected.bounds
|
||||
np.testing.assert_array_equal(actual.values, expected.values)
|
||||
|
||||
|
||||
def test_selection_path_json_contains_only_points():
|
||||
selection_path = selection.SelectionPath(
|
||||
points=((1, 2), (8, 2), (4, 9)),
|
||||
)
|
||||
value = selection_path.to_json()
|
||||
|
||||
assert set(json.loads(value)) == {"points"}
|
||||
assert selection.SelectionPath.from_json(value) == selection_path
|
||||
with pytest.raises(ValueError, match="Selection Path data is invalid"):
|
||||
selection.SelectionPath.from_json(
|
||||
json.dumps(
|
||||
{
|
||||
"points": selection_path.points,
|
||||
"fill_rule": "NONZERO",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_nonzero_winding_keeps_same_direction_overlap():
|
||||
points = (
|
||||
(1, 1),
|
||||
(7, 1),
|
||||
(7, 7),
|
||||
(1, 7),
|
||||
(1, 1),
|
||||
(4, 1),
|
||||
(10, 1),
|
||||
(10, 7),
|
||||
(4, 7),
|
||||
(4, 1),
|
||||
(1, 1),
|
||||
)
|
||||
|
||||
mask = selection.rasterize_selection_path(
|
||||
(12, 9),
|
||||
selection.SelectionPath(points),
|
||||
).full_values((12, 9))
|
||||
|
||||
assert mask[3, 5] == 1.0
|
||||
|
||||
|
||||
def test_scanline_matches_reference_for_edge_cases():
|
||||
paths = (
|
||||
((1, 1), (15, 1), (15, 10), (1, 10)),
|
||||
((0.2, 0.3), (15.8, 2.1), (8.4, 11.7)),
|
||||
((1, 1), (15, 1), (15, 5), (7, 5), (7, 11), (1, 11)),
|
||||
((1, 1), (15, 11), (1, 11), (15, 1)),
|
||||
((-8, -3), (10, 1), (23, 8), (7, 18), (-4, 10)),
|
||||
)
|
||||
for points in paths:
|
||||
for antialias in (False, True):
|
||||
assert_matches_reference(
|
||||
(18, 14),
|
||||
selection.SelectionPath(points),
|
||||
antialias=antialias,
|
||||
)
|
||||
|
||||
|
||||
def test_scanline_matches_reference_for_random_paths():
|
||||
random = np.random.default_rng(20260901)
|
||||
for _index in range(40):
|
||||
point_count = int(random.integers(3, 60))
|
||||
points = tuple(
|
||||
map(
|
||||
tuple,
|
||||
random.uniform((-8, -6), (40, 30), size=(point_count, 2)),
|
||||
)
|
||||
)
|
||||
for antialias in (False, True):
|
||||
assert_matches_reference(
|
||||
(32, 24),
|
||||
selection.SelectionPath(points),
|
||||
antialias=antialias,
|
||||
)
|
||||
|
||||
|
||||
def test_antialias_uses_only_padded_local_canvas(monkeypatch):
|
||||
selection_path = selection.SelectionPath(
|
||||
((100, 200), (500, 200), (500, 600), (100, 600))
|
||||
)
|
||||
calls = []
|
||||
rasterize = selection._rasterize_path
|
||||
|
||||
def record(bounds, points):
|
||||
calls.append(bounds)
|
||||
return rasterize(bounds, points)
|
||||
|
||||
monkeypatch.setattr(selection, "_rasterize_path", record)
|
||||
|
||||
selection.rasterize_selection_path(
|
||||
(5120, 2880),
|
||||
selection_path,
|
||||
antialias=True,
|
||||
)
|
||||
|
||||
assert calls == [(98, 198, 502, 602)]
|
||||
|
||||
|
||||
def test_high_resolution_lasso_allocates_one_local_scanline_table(monkeypatch):
|
||||
angles = np.arange(1000, dtype=np.float64) * (2.0 * np.pi / 1000.0)
|
||||
points = tuple(
|
||||
zip(
|
||||
2560.0 + 500.0 * np.cos(angles),
|
||||
1440.0 + 300.0 * np.sin(angles),
|
||||
)
|
||||
)
|
||||
bounds = selection.selection_path_bounds(points)
|
||||
allocations = []
|
||||
zeros = np.zeros
|
||||
|
||||
def record(shape, *args, **kwargs):
|
||||
allocations.append(shape)
|
||||
return zeros(shape, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(np, "zeros", record)
|
||||
|
||||
mask = selection._rasterize_path(bounds, points)
|
||||
|
||||
width = bounds[2] - bounds[0]
|
||||
height = bounds[3] - bounds[1]
|
||||
assert mask.shape == (height, width)
|
||||
assert allocations == [(height, width + 1)]
|
||||
assert (height, width) != (2880, 5120)
|
||||
0
tests/anyimage/operators/__init__.py
Normal file
0
tests/anyimage/operators/__init__.py
Normal file
151
tests/anyimage/operators/test_ai_image_operators.py
Normal file
151
tests/anyimage/operators/test_ai_image_operators.py
Normal 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")
|
||||
209
tests/anyimage/operators/test_ai_setup.py
Normal file
209
tests/anyimage/operators/test_ai_setup.py
Normal 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"], ())
|
||||
78
tests/anyimage/operators/test_balloon_profiles.py
Normal file
78
tests/anyimage/operators/test_balloon_profiles.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from unittest.mock import patch
|
||||
import numpy as np
|
||||
|
||||
|
||||
from anyimage.operators.cutout_tool import 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)
|
||||
129
tests/anyimage/operators/test_brush_strokes.py
Normal file
129
tests/anyimage/operators/test_brush_strokes.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
from anyimage.common import selection, viewport
|
||||
from anyimage.operators import mask_tool as mask
|
||||
|
||||
|
||||
def test_simplified_segments_bound_every_original_sample():
|
||||
raw = [(float(x), float(20 * np.sin(x / 15))) for x in np.arange(0, 160, 0.2)]
|
||||
path, samples = [], []
|
||||
for point in raw:
|
||||
viewport.append_lasso_point(path, point, samples)
|
||||
assert len(path) < len(raw) / 5
|
||||
for start, end in zip(path, path[1:]):
|
||||
segment = np.array(end) - start
|
||||
points = np.array([point for point in raw if start[0] <= point[0] <= end[0]])
|
||||
factor = np.clip((points - start) @ segment / (segment @ segment), 0, 1)
|
||||
distances = np.linalg.norm(points - (np.array(start) + factor[:, None] * segment), axis=1)
|
||||
assert distances.max() <= 0.75 + 1e-10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("radius", [2, 5, 25])
|
||||
@pytest.mark.parametrize("path", [
|
||||
((40, 70), (70, 70), (70, 100)),
|
||||
((35, 35), (100, 100), (35, 100), (100, 35)),
|
||||
])
|
||||
def test_brush_antialiases_the_hard_union_once(radius, path):
|
||||
size = (150, 150)
|
||||
hard = np.zeros(size[::-1], dtype=bool)
|
||||
for polygon in viewport.brush_footprint_polygons(path, radius):
|
||||
hard |= selection._rasterize_path((0, 0, *size), polygon)
|
||||
result = mask.rasterize_brush_path(size, path, radius, lambda points: points)
|
||||
np.testing.assert_allclose(result.full_values(size), selection._antialias_mask(hard))
|
||||
assert result.values.size < size[0] * size[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("radius", [2, 5, 25])
|
||||
def test_straight_stroke_is_independent_of_segmentation(radius):
|
||||
size = (160, 160)
|
||||
sparse = ((40, 70), (110, 70))
|
||||
dense = tuple((x, 70) for x in range(40, 111, 5))
|
||||
results = [mask.rasterize_brush_path(size, path, radius, lambda p: p).full_values(size)
|
||||
for path in (sparse, dense)]
|
||||
np.testing.assert_array_equal(*results)
|
||||
|
||||
|
||||
def test_incremental_preview_matches_full_union_when_tail_moves_and_crosses():
|
||||
cache = viewport.BrushPreview()
|
||||
path = [(10, 10)]
|
||||
operations = [(False, (100, 100)), (True, (110, 90)), (False, (10, 90)),
|
||||
(False, (110, 10)), (True, (105, 15)), (False, (10, 10))]
|
||||
for replace, point in [(False, None), *operations]:
|
||||
if point is not None:
|
||||
if replace:
|
||||
path[-1] = point
|
||||
else:
|
||||
path.append(point)
|
||||
cache.update(path, 8)
|
||||
reference = {round(bottom / cache.step): intervals
|
||||
for bottom, _, intervals in viewport.scanline_union_bands(
|
||||
viewport.brush_footprint_polygons(path, 8), cache.step)
|
||||
if intervals}
|
||||
assert cache.coverage.keys() == reference.keys()
|
||||
for row in reference:
|
||||
np.testing.assert_allclose(cache.coverage[row], reference[row], atol=1e-10)
|
||||
|
||||
|
||||
def test_preview_reuses_batches_and_limits_updates_to_changed_rows():
|
||||
cache = viewport.BrushPreview()
|
||||
path = [(10, y) for y in range(0, 800, 10)]
|
||||
cache.update(path, 5)
|
||||
gpu = SimpleNamespace(shader=Mock(), state=Mock())
|
||||
build = Mock(side_effect=lambda *args: Mock())
|
||||
with patch.dict(sys.modules, {"gpu": gpu, "gpu_extras.batch": SimpleNamespace(batch_for_shader=build)}):
|
||||
cache.draw((1, 1, 1, 0.16))
|
||||
initial = dict(cache.batches)
|
||||
builds = build.call_count
|
||||
with patch.object(cache, "_polygon_rows", wraps=cache._polygon_rows) as rasterize:
|
||||
cache.update(path, 5)
|
||||
cache.draw((1, 1, 1, 0.16))
|
||||
rasterize.assert_not_called()
|
||||
assert build.call_count == builds
|
||||
path.append((10, 800))
|
||||
cache.update(path, 5)
|
||||
assert rasterize.call_count == 2
|
||||
assert all(len(call.args[0]) <= 2 for call in rasterize.call_args_list)
|
||||
cache.draw((1, 1, 1, 0.16))
|
||||
changed = {chunk for chunk, batches in initial.items() if cache.batches[chunk] is not batches}
|
||||
assert len(changed) <= 2
|
||||
assert len(initial) > 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exit_kind", ["ESC", "SWITCH", "ERROR", "COMPLETE"])
|
||||
def test_gesture_releases_preview_and_samples_on_every_exit(exit_kind):
|
||||
gesture = viewport.ImageGesture()
|
||||
gesture.gesture = "BRUSH"
|
||||
gesture.active_tool_id = "anyimage.mask"
|
||||
gesture._path = [(0, 0), (10, 10)]
|
||||
gesture._path_samples = list(gesture._path)
|
||||
gesture._brush_preview = viewport.BrushPreview()
|
||||
handle = gesture._handle = object()
|
||||
gesture._brush_preview.update(gesture._path, 5)
|
||||
context = SimpleNamespace(workspace=Mock(), area=Mock())
|
||||
event = SimpleNamespace(type="ESC" if exit_kind == "ESC" else "LEFTMOUSE",
|
||||
value="PRESS" if exit_kind == "ESC" else "RELEASE",
|
||||
mouse_region_x=10, mouse_region_y=10)
|
||||
gesture.report = Mock()
|
||||
def complete(context):
|
||||
if exit_kind == "ERROR":
|
||||
raise ValueError("Failed projection")
|
||||
gesture._finish(context)
|
||||
return {"FINISHED"}
|
||||
gesture._complete = complete
|
||||
tool = "builtin.select" if exit_kind == "SWITCH" else gesture.active_tool_id
|
||||
remove = Mock()
|
||||
fake_bpy = SimpleNamespace(types=SimpleNamespace(SpaceView3D=SimpleNamespace(draw_handler_remove=remove)))
|
||||
with patch.object(viewport, "active_view3d_tool_id", return_value=tool), patch.object(viewport, "bpy", fake_bpy):
|
||||
result = gesture.modal(context, event)
|
||||
gesture._finish(context)
|
||||
remove.assert_called_once_with(handle, "WINDOW")
|
||||
assert gesture._handle is None
|
||||
assert result == ({"FINISHED"} if exit_kind == "COMPLETE" else {"CANCELLED"})
|
||||
assert gesture._path is gesture._path_samples is gesture._brush_preview is None
|
||||
assert gesture._fill_triangles == gesture._outline_segments == ()
|
||||
assert viewport.ImageGesture()._brush_preview is None
|
||||
933
tests/anyimage/operators/test_clipboard_image.py
Normal file
933
tests/anyimage/operators/test_clipboard_image.py
Normal 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()
|
||||
34
tests/anyimage/operators/test_cutout_depth.py
Normal file
34
tests/anyimage/operators/test_cutout_depth.py
Normal 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)
|
||||
299
tests/anyimage/operators/test_cutout_fine_mesh.py
Normal file
299
tests/anyimage/operators/test_cutout_fine_mesh.py
Normal 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)
|
||||
334
tests/anyimage/operators/test_cutout_geometry.py
Normal file
334
tests/anyimage/operators/test_cutout_geometry.py
Normal 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)
|
||||
222
tests/anyimage/operators/test_cutout_interaction.py
Normal file
222
tests/anyimage/operators/test_cutout_interaction.py
Normal 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]
|
||||
970
tests/anyimage/operators/test_cutout_objects.py
Normal file
970
tests/anyimage/operators/test_cutout_objects.py
Normal 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])
|
||||
)
|
||||
412
tests/anyimage/operators/test_cutout_operators.py
Normal file
412
tests/anyimage/operators/test_cutout_operators.py
Normal 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)
|
||||
132
tests/anyimage/operators/test_frame_antialiasing.py
Normal file
132
tests/anyimage/operators/test_frame_antialiasing.py
Normal 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)
|
||||
36
tests/anyimage/operators/test_frame_projection.py
Normal file
36
tests/anyimage/operators/test_frame_projection.py
Normal 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)
|
||||
910
tests/anyimage/operators/test_frame_tool.py
Normal file
910
tests/anyimage/operators/test_frame_tool.py
Normal 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
|
||||
246
tests/anyimage/operators/test_image_edit_integration.py
Normal file
246
tests/anyimage/operators/test_image_edit_integration.py
Normal 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
|
||||
1089
tests/anyimage/operators/test_mask_tool.py
Normal file
1089
tests/anyimage/operators/test_mask_tool.py
Normal file
File diff suppressed because it is too large
Load Diff
64
tests/anyimage/operators/test_mask_undo.py
Normal file
64
tests/anyimage/operators/test_mask_undo.py
Normal 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)
|
||||
69
tests/anyimage/operators/test_plane_conversion.py
Normal file
69
tests/anyimage/operators/test_plane_conversion.py
Normal 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
|
||||
264
tests/anyimage/operators/test_plane_objects.py
Normal file
264
tests/anyimage/operators/test_plane_objects.py
Normal 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)
|
||||
202
tests/anyimage/operators/test_plane_operators.py
Normal file
202
tests/anyimage/operators/test_plane_operators.py
Normal 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
|
||||
)
|
||||
)
|
||||
237
tests/anyimage/operators/test_rectify_tool.py
Normal file
237
tests/anyimage/operators/test_rectify_tool.py
Normal 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))
|
||||
46
tests/anyimage/operators/test_upscale_undo.py
Normal file
46
tests/anyimage/operators/test_upscale_undo.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import bpy
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from anyimage.operators.upscale import UpscaleImage
|
||||
from anyimage.common.image import image_pixels
|
||||
|
||||
|
||||
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])
|
||||
0
tests/anyimage/server/__init__.py
Normal file
0
tests/anyimage/server/__init__.py
Normal file
65
tests/anyimage/server/runtime_support.py
Normal file
65
tests/anyimage/server/runtime_support.py
Normal 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()}")
|
||||
100
tests/anyimage/server/support.py
Normal file
100
tests/anyimage/server/support.py
Normal 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
|
||||
236
tests/anyimage/server/test_edge_depth.py
Normal file
236
tests/anyimage/server/test_edge_depth.py
Normal 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()
|
||||
322
tests/anyimage/server/test_geometry_artifacts.py
Normal file
322
tests/anyimage/server/test_geometry_artifacts.py
Normal 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,
|
||||
)
|
||||
170
tests/anyimage/server/test_job_server.py
Normal file
170
tests/anyimage/server/test_job_server.py
Normal 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",
|
||||
},
|
||||
)
|
||||
183
tests/anyimage/server/test_media_input.py
Normal file
183
tests/anyimage/server/test_media_input.py
Normal 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)
|
||||
70
tests/anyimage/server/test_model_ben2_onnx.py
Normal file
70
tests/anyimage/server/test_model_ben2_onnx.py
Normal 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()
|
||||
96
tests/anyimage/server/test_model_catalog.py
Normal file
96
tests/anyimage/server/test_model_catalog.py
Normal 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",
|
||||
],
|
||||
)
|
||||
170
tests/anyimage/server/test_model_download.py
Normal file
170
tests/anyimage/server/test_model_download.py
Normal file
@@ -0,0 +1,170 @@
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def load_download_module():
|
||||
addon_directory = PROJECT_ROOT / "src" / "anyimage"
|
||||
sys.path.insert(0, str(addon_directory))
|
||||
try:
|
||||
return __import__(
|
||||
"server.model_download",
|
||||
fromlist=["model_download"],
|
||||
)
|
||||
finally:
|
||||
sys.path.remove(str(addon_directory))
|
||||
|
||||
|
||||
class FakeTqdm:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.n = kwargs.get("initial", 0)
|
||||
self.total = kwargs.get("total", 0)
|
||||
|
||||
def update(self, amount=1):
|
||||
self.n += amount
|
||||
return True
|
||||
|
||||
def refresh(self, *args, **kwargs):
|
||||
return True
|
||||
|
||||
|
||||
class ModelDownloadTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.download = load_download_module()
|
||||
|
||||
def create_progress(self, description="Downloading bytes", **options):
|
||||
reports = []
|
||||
progress_class = self.download.create_download_progress_class(
|
||||
lambda value, message: reports.append((value, message)),
|
||||
"owner/model",
|
||||
tqdm_type=options.pop("tqdm_type", FakeTqdm),
|
||||
clock=options.pop("clock", lambda: 1.0),
|
||||
**options,
|
||||
)
|
||||
progress = progress_class(
|
||||
desc=description,
|
||||
total=100,
|
||||
initial=0,
|
||||
unit="B",
|
||||
)
|
||||
return progress, reports
|
||||
|
||||
def test_https_connection_bounds_connect_and_idle_read_time(self):
|
||||
connect_timeouts = []
|
||||
read_timeouts = []
|
||||
|
||||
def connect(connection):
|
||||
connect_timeouts.append(connection.timeout)
|
||||
connection.sock = SimpleNamespace(
|
||||
settimeout=read_timeouts.append,
|
||||
)
|
||||
|
||||
connection = self.download._ShortConnectHTTPSConnection("example.com")
|
||||
with patch.object(
|
||||
self.download.http.client.HTTPSConnection,
|
||||
"connect",
|
||||
new=connect,
|
||||
):
|
||||
connection.connect()
|
||||
|
||||
self.assertEqual(connect_timeouts, [10.0])
|
||||
self.assertEqual(read_timeouts, [60.0])
|
||||
self.assertIsNone(connection.timeout)
|
||||
|
||||
def test_reports_aggregated_download_bytes(self):
|
||||
progress, reports = self.create_progress()
|
||||
|
||||
progress.update(25)
|
||||
|
||||
self.assertAlmostEqual(reports[-1][0], 0.275)
|
||||
self.assertEqual(reports[-1][1], "Downloading owner/model")
|
||||
|
||||
def test_reports_hugging_face_aggregated_bar(self):
|
||||
progress, reports = self.create_progress(
|
||||
"Downloading (incomplete total...)"
|
||||
)
|
||||
|
||||
progress.update(25)
|
||||
|
||||
self.assertAlmostEqual(reports[-1][0], 0.275)
|
||||
|
||||
def test_reports_download_speed(self):
|
||||
class FakeTqdmWithRate(FakeTqdm):
|
||||
@property
|
||||
def format_dict(self):
|
||||
return {"rate": 14_000_000}
|
||||
|
||||
progress, reports = self.create_progress(
|
||||
tqdm_type=FakeTqdmWithRate,
|
||||
label="MoGe-2",
|
||||
)
|
||||
|
||||
progress.update(50)
|
||||
|
||||
self.assertEqual(
|
||||
reports[-1][1],
|
||||
"Downloading MoGe-2 13.4 MB/s",
|
||||
)
|
||||
|
||||
def test_progress_callback_has_no_eta_or_state_protocol(self):
|
||||
times = iter((0.0, 10.0))
|
||||
progress, reports = self.create_progress(clock=lambda: next(times))
|
||||
|
||||
progress.update(50)
|
||||
|
||||
self.assertEqual(len(reports[-1]), 2)
|
||||
|
||||
def test_ignores_unrelated_progress_bars(self):
|
||||
progress, reports = self.create_progress("Reconstructing")
|
||||
|
||||
progress.update(25)
|
||||
|
||||
self.assertEqual(reports, [])
|
||||
|
||||
def test_download_endpoints_uses_direct_endpoint(self):
|
||||
with patch.dict(os.environ, {"HF_ENDPOINT": ""}):
|
||||
endpoints = self.download.download_endpoints()
|
||||
|
||||
self.assertEqual(endpoints, ["https://huggingface.co"])
|
||||
|
||||
def test_download_endpoints_respects_environment_override(self):
|
||||
with patch.dict(os.environ, {"HF_ENDPOINT": "https://hf.example"}):
|
||||
endpoints = self.download.download_endpoints()
|
||||
|
||||
self.assertEqual(endpoints, ["https://hf.example"])
|
||||
|
||||
def test_download_endpoints_strips_trailing_slash(self):
|
||||
with patch.dict(os.environ, {"HF_ENDPOINT": "https://hf.example/"}):
|
||||
endpoints = self.download.download_endpoints()
|
||||
|
||||
self.assertEqual(endpoints, ["https://hf.example"])
|
||||
|
||||
def test_declared_download_skips_hf_when_no_public_onnx_exists(self):
|
||||
args = SimpleNamespace(
|
||||
repository="",
|
||||
progress=lambda *_args: None,
|
||||
cancel_check=lambda: None,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(
|
||||
self.download, "download_from_hf"
|
||||
) as hugging_face, patch.object(
|
||||
self.download, "download_from_r2"
|
||||
) as mirror, patch.object(
|
||||
self.download, "_verify_model_complete"
|
||||
):
|
||||
self.download._download_declared_or_fail(
|
||||
args, Path(directory), "Model", 0.05, 0.90
|
||||
)
|
||||
|
||||
hugging_face.assert_not_called()
|
||||
mirror.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
154
tests/anyimage/server/test_model_download_jobs.py
Normal file
154
tests/anyimage/server/test_model_download_jobs.py
Normal file
@@ -0,0 +1,154 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from tests.anyimage.server.support import load_server_module
|
||||
|
||||
|
||||
class ModelDownloadJobsTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.download = load_server_module("model_download")
|
||||
|
||||
|
||||
def test_download_model_uses_actual_request_without_preflight(self):
|
||||
calls = []
|
||||
fake_hugging_face = ModuleType("huggingface_hub")
|
||||
fake_hugging_face.snapshot_download = lambda **options: calls.append(
|
||||
options
|
||||
)
|
||||
args = SimpleNamespace(
|
||||
repository="Ruicheng/moge-2-vits-normal-onnx",
|
||||
model_dir="model",
|
||||
progress=lambda *_args, **_kwargs: None,
|
||||
allow_pattern=[],
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(
|
||||
sys.modules,
|
||||
{"huggingface_hub": fake_hugging_face},
|
||||
), patch.object(
|
||||
self.download,
|
||||
"create_download_progress_class",
|
||||
return_value=object,
|
||||
), patch.dict(os.environ, {"HF_ENDPOINT": ""}), patch.object(
|
||||
self.download,
|
||||
"_verify_model_complete",
|
||||
return_value=None,
|
||||
):
|
||||
args.model_dir = str(Path(directory) / "models" / "MoGe2")
|
||||
self.download.download_model(args)
|
||||
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
|
||||
def test_download_model_refuses_success_when_files_missing(self):
|
||||
fake_hugging_face = ModuleType("huggingface_hub")
|
||||
fake_hugging_face.snapshot_download = lambda **options: None
|
||||
args = SimpleNamespace(
|
||||
repository="PramaLLC/BEN2",
|
||||
model_dir="model",
|
||||
progress=lambda *_args, **_kwargs: None,
|
||||
allow_pattern=[],
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(
|
||||
sys.modules,
|
||||
{"huggingface_hub": fake_hugging_face},
|
||||
), patch.object(
|
||||
self.download,
|
||||
"create_download_progress_class",
|
||||
return_value=object,
|
||||
), patch.dict(os.environ, {"HF_ENDPOINT": ""}):
|
||||
args.model_dir = str(Path(directory) / "model")
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"All download sources failed",
|
||||
):
|
||||
self.download.download_model(args)
|
||||
|
||||
|
||||
def test_download_model_reports_all_failed_sources(self):
|
||||
args = SimpleNamespace(
|
||||
repository="Ruicheng/moge-2-vits-normal-onnx",
|
||||
model_dir="model",
|
||||
progress=lambda *_args, **_kwargs: None,
|
||||
allow_pattern=[],
|
||||
r2_directory="moge-2-vits-normal-onnx",
|
||||
r2_file=["config.json|100|abc"],
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(
|
||||
self.download,
|
||||
"download_from_hf",
|
||||
side_effect=OSError("direct down"),
|
||||
), patch.object(
|
||||
self.download,
|
||||
"download_from_r2",
|
||||
side_effect=OSError("mirror down"),
|
||||
):
|
||||
args.model_dir = str(Path(directory) / "model")
|
||||
with self.assertRaisesRegex(RuntimeError, "models.omoolab.xyz"):
|
||||
self.download.download_model(args)
|
||||
|
||||
|
||||
def test_download_model_prefers_direct_hf_then_r2(self):
|
||||
args = SimpleNamespace(
|
||||
repository="Ruicheng/moge-2-vits-normal-onnx",
|
||||
model_dir="model",
|
||||
progress=lambda *_args, **_kwargs: None,
|
||||
allow_pattern=["config.json"],
|
||||
r2_directory="moge-2-vits-normal-onnx",
|
||||
r2_file=["config.json|100|abc"],
|
||||
)
|
||||
order = []
|
||||
|
||||
def fake_download_from_hf(*_args, **_kwargs):
|
||||
order.append("hf")
|
||||
raise OSError("hf down")
|
||||
|
||||
def fake_download_from_r2(*_args, **_kwargs):
|
||||
order.append("r2")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(
|
||||
self.download,
|
||||
"download_from_hf",
|
||||
side_effect=fake_download_from_hf,
|
||||
), patch.object(
|
||||
self.download,
|
||||
"download_from_r2",
|
||||
side_effect=fake_download_from_r2,
|
||||
), patch.object(
|
||||
self.download,
|
||||
"_verify_model_complete",
|
||||
return_value=None,
|
||||
):
|
||||
args.model_dir = str(Path(directory) / "models" / "MoGe2")
|
||||
self.download.download_model(args)
|
||||
|
||||
self.assertEqual(order, ["hf", "r2"])
|
||||
|
||||
|
||||
def test_download_from_r2_downloads_declared_files(self):
|
||||
args = SimpleNamespace(
|
||||
repository="Ruicheng/moge-2-vits-normal-onnx",
|
||||
model_dir="model",
|
||||
progress=lambda *_args, **_kwargs: None,
|
||||
allow_pattern=["config.json"],
|
||||
r2_directory="moge-2-vits-normal-onnx",
|
||||
r2_file=["config.json|100|abc"],
|
||||
)
|
||||
downloaded = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(
|
||||
self.download,
|
||||
"_download_file_from_url",
|
||||
side_effect=lambda *args, **kwargs: downloaded.append(args[1].name),
|
||||
):
|
||||
args.model_dir = str(Path(directory) / "model")
|
||||
self.download.download_from_r2(args, args.model_dir)
|
||||
|
||||
self.assertEqual(downloaded, ["config.json"])
|
||||
65
tests/anyimage/server/test_model_geometry.py
Normal file
65
tests/anyimage/server/test_model_geometry.py
Normal 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()
|
||||
107
tests/anyimage/server/test_model_inference.py
Normal file
107
tests/anyimage/server/test_model_inference.py
Normal 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))
|
||||
182
tests/anyimage/server/test_model_manager.py
Normal file
182
tests/anyimage/server/test_model_manager.py
Normal 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",
|
||||
)
|
||||
208
tests/anyimage/server/test_model_moge2_onnx.py
Normal file
208
tests/anyimage/server/test_model_moge2_onnx.py
Normal 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()
|
||||
205
tests/anyimage/server/test_model_onnx_runtime.py
Normal file
205
tests/anyimage/server/test_model_onnx_runtime.py
Normal 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()
|
||||
125
tests/anyimage/server/test_model_pisa_sr.py
Normal file
125
tests/anyimage/server/test_model_pisa_sr.py
Normal 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
|
||||
166
tests/anyimage/server/test_model_upscale_onnx.py
Normal file
166
tests/anyimage/server/test_model_upscale_onnx.py
Normal 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()
|
||||
439
tests/anyimage/server/test_server_jobs.py
Normal file
439
tests/anyimage/server/test_server_jobs.py
Normal 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)
|
||||
365
tests/anyimage/test_addon_registration.py
Normal file
365
tests/anyimage/test_addon_registration.py
Normal 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"))
|
||||
130
tests/anyimage/test_image_menu.py
Normal file
130
tests/anyimage/test_image_menu.py
Normal 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)
|
||||
168
tests/anyimage/test_job_operator.py
Normal file
168
tests/anyimage/test_job_operator.py
Normal 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")
|
||||
474
tests/anyimage/test_preferences.py
Normal file
474
tests/anyimage/test_preferences.py
Normal 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)
|
||||
134
tests/anyimage/test_server_panel.py
Normal file
134
tests/anyimage/test_server_panel.py
Normal 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
|
||||
Reference in New Issue
Block a user