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

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

View File

View File

@@ -0,0 +1,83 @@
from tests.support.paths import PROJECT_ROOT
import importlib
import importlib.util
import sys
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
from tests.support.blender import mock_bpy_imports
def load_common_modules():
fake_bpy = ModuleType("bpy")
fake_bpy.props = SimpleNamespace(
BoolProperty=lambda **options: options,
EnumProperty=lambda **options: options,
FloatProperty=lambda **options: options,
IntProperty=lambda **options: options,
StringProperty=lambda **options: options,
)
fake_bpy.types = SimpleNamespace(Menu=object, Operator=object)
fake_bpy.path = SimpleNamespace(abspath=lambda path: path)
fake_bpy.context = SimpleNamespace(scene=None)
module_paths = (
(
"anyimage.common.image",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "common"
/ "image.py",
),
(
"anyimage.common.node",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "common"
/ "node.py",
),
(
"anyimage.common.material",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "common"
/ "material.py",
),
(
"anyimage.common.object",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "common"
/ "object.py",
),
)
packages = {
name: ModuleType(name)
for name in (
"anyimage",
"anyimage.operators",
"anyimage.common",
"anyimage.operators.convert_to_plane",
)
}
for package in packages.values():
package.__path__ = []
loaded = []
preferences = ModuleType("anyimage.preferences")
preferences.configured_material_view_adaptation = lambda: True
with mock_bpy_imports(fake_bpy), patch.dict(sys.modules, {"anyimage.preferences": preferences, **packages}):
for module_name, module_path in module_paths:
specification = importlib.util.spec_from_file_location(
module_name,
module_path,
)
module = importlib.util.module_from_spec(specification)
sys.modules[module_name] = module
specification.loader.exec_module(module)
loaded.append(module)
return SimpleNamespace(image=loaded[0], node=loaded[1], material=loaded[2], object=loaded[3])

View File

@@ -0,0 +1,225 @@
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import numpy as np
import pytest
from PIL import Image
from anyimage.common import image as images
from anyimage.operators.convert_to_plane.object import create_color_image
from anyimage.operators.cutout_tool.object import create_cropped_color_image
@pytest.mark.parametrize("floating", [False, True])
def test_owned_color_images_preserve_source_and_use_premul(floating):
original = bpy.data.images.new("Alpha source", width=2, height=2, alpha=True, float_buffer=floating)
rgba = np.tile(np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32), (2, 2, 1))
original.pixels.foreach_set(rgba.ravel())
source = SimpleNamespace(data=original)
expected = images.image_rgba(original)
copied = create_color_image(source)
cropped = create_cropped_color_image(source, (0, 0, 2, 2), expected)
edited = images.create_image_edit_result(original, rgba.ravel(), (2, 2))
try:
assert original.alpha_mode == "STRAIGHT"
np.testing.assert_allclose(images.image_rgba(original), expected)
for image in (copied, cropped, edited):
assert image.alpha_mode == "PREMUL"
np.testing.assert_allclose(images.image_rgba(image), expected, atol=1 / 255)
finally:
for image in (copied, cropped, edited, original):
bpy.data.images.remove(image)
@pytest.mark.parametrize("loader", [images.load_image_edit_result, images.load_color_result_image])
def test_job_color_loader_defaults_to_premul(tmp_path, loader):
rgba = np.array([[[51, 102, 204, 64]]], dtype=np.uint8)
Image.fromarray(rgba).save(tmp_path / "image.0001.png")
original = bpy.data.images.new("Job source", width=1, height=1, alpha=True)
result = loader(SimpleNamespace(data=original), tmp_path)
try:
assert result.alpha_mode == "PREMUL"
assert result.packed_file is not None
assert original.alpha_mode == "STRAIGHT"
finally:
bpy.data.images.remove(result)
bpy.data.images.remove(original)
def test_failed_empty_replacement_restores_blending_and_timing():
original = SimpleNamespace(name="Original", use_fake_user=False, users=1)
timing = SimpleNamespace(frame_start=3, frame_offset=2, frame_duration=7, use_cyclic=True)
source = SimpleNamespace(data=original, use_empty_image_alpha=False, image_user=timing)
result = SimpleNamespace(source="SEQUENCE", users=1)
def fail_configuration(*args, **kwargs):
timing.frame_start = 99
raise RuntimeError("Failed configuration")
with patch.object(images, "has_other_image_empty", return_value=True), patch.object(images, "video_total_frames", return_value=3), patch.object(
images, "configure_image_user", side_effect=fail_configuration
):
with pytest.raises(RuntimeError, match="Failed configuration"):
images.replace_empty_image(source, result)
assert source.data is original
assert not source.use_empty_image_alpha
assert not original.use_fake_user
assert images.image_user_settings(source) == (3, 2, 7, True)
def test_business_pixels_ignore_premul_read_interpretation():
original = bpy.data.images.new("Premul read source", width=1, height=1, alpha=True)
expected = np.array([0.4, 0.25, 0.1, 0.5], dtype=np.float32)
result = images.create_image_edit_result(original, expected, (1, 1))
try:
result.reload()
interpreted = np.empty(4, dtype=np.float32)
result.pixels.foreach_get(interpreted)
assert interpreted[0] > expected[0]
np.testing.assert_allclose(images.image_pixels(result), expected, atol=1 / 255)
assert result.alpha_mode == "PREMUL"
finally:
bpy.data.images.remove(result)
bpy.data.images.remove(original)
@pytest.mark.parametrize("dirty", [False, True])
def test_business_read_preserves_shared_packed_source(dirty):
original = bpy.data.images.new("Read integrity", width=1, height=1, alpha=True)
result = images.create_image_edit_result(original, np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32), (1, 1))
owner = bpy.data.objects.new("Read owner", None)
owner.empty_display_type = "IMAGE"
owner.data = result
try:
result.reload()
if dirty:
result.pixels.foreach_set([0.9, 0.1, 0.3, 0.5])
result.update()
before = np.array(result.pixels[:])
packed = bytes(result.packed_file.data)
state = (result.alpha_mode, result.is_dirty, result.users)
count = len(bpy.data.images)
for _ in range(3):
pixels = images.image_pixels(result)
np.testing.assert_allclose(pixels, before if dirty else [0.2, 0.4, 0.8, 0.25], atol=1 / 255)
np.testing.assert_array_equal(result.pixels[:], before)
assert (result.alpha_mode, result.is_dirty, result.users) == state
assert bytes(result.packed_file.data) == packed
assert owner.data == result
assert len(bpy.data.images) == count
finally:
bpy.data.objects.remove(owner)
bpy.data.images.remove(result)
bpy.data.images.remove(original)
@pytest.mark.parametrize("floating", [False, True])
def test_generated_premul_reads_current_pixels(floating):
source = bpy.data.images.new("Generated read", width=1, height=1, alpha=True, float_buffer=floating)
try:
source.alpha_mode = "PREMUL"
source.pixels.foreach_set([0.2, 0.4, 0.8, 0.25])
before = np.array(source.pixels[:])
np.testing.assert_array_equal(images.image_pixels(source), before)
np.testing.assert_array_equal(source.pixels[:], before)
assert source.alpha_mode == "PREMUL"
finally:
bpy.data.images.remove(source)
@pytest.mark.parametrize("packed", [False, True])
@pytest.mark.parametrize("floating", [False, True])
@pytest.mark.parametrize("dirty", [False, True])
def test_file_read_preserves_byte_and_native_float_cache(tmp_path, packed, floating, dirty):
path = tmp_path / ("source.exr" if floating else "source.png")
if floating:
generated = bpy.data.images.new("EXR source", width=1, height=1, alpha=True, float_buffer=True)
try:
generated.pixels.foreach_set([0.2, 0.4, 0.8, 0.25])
generated.file_format = "OPEN_EXR"
generated.filepath_raw = str(path)
generated.save()
finally:
bpy.data.images.remove(generated)
else:
Image.fromarray(np.array([[[51, 102, 204, 64]]], dtype=np.uint8)).save(path)
source = bpy.data.images.load(str(path), check_existing=False)
try:
if packed:
source.pack()
source.alpha_mode = "PREMUL"
if dirty:
source.pixels.foreach_set([0.9, 0.1, 0.3, 0.5])
source.update()
before = np.array(source.pixels[:])
assert source.is_dirty == dirty
assert source.is_float == floating
expected = before if floating or dirty else np.array([51, 102, 204, 64]) / 255
count = len(bpy.data.images)
np.testing.assert_allclose(images.image_pixels(source), expected, atol=1e-7)
np.testing.assert_array_equal(source.pixels[:], before)
assert source.is_dirty == dirty and source.alpha_mode == "PREMUL"
assert len(bpy.data.images) == count
finally:
bpy.data.images.remove(source)
def test_isolated_decode_failure_releases_copy():
class FailedReader:
@property
def alpha_mode(self):
return "PREMUL"
@alpha_mode.setter
def alpha_mode(self, value):
raise RuntimeError("Decode failed")
reader = FailedReader()
source = SimpleNamespace(alpha_mode="PREMUL", is_dirty=False, is_float=False,
source="FILE", copy=lambda: reader)
with patch.object(images, "bpy", SimpleNamespace(data=SimpleNamespace(images=SimpleNamespace(remove=lambda value: removed.append(value))))):
removed = []
with pytest.raises(RuntimeError, match="Decode failed"):
images.image_pixels(source)
assert removed == [reader]
assert source.alpha_mode == "PREMUL"
def test_mask_pack_reopen_add_and_repeat_frame_preserve_hidden_color(tmp_path):
from anyimage.common.selection import SelectionMask
from anyimage.operators.mask_tool import apply_alpha_mask
from anyimage.operators import frame_tool as frame
source = bpy.data.images.new("Roundtrip source", width=2, height=2, alpha=True)
rgba = np.tile(np.array([0.2, 0.4, 0.8, 1], dtype=np.float32), (2, 2, 1))
full = SelectionMask(np.ones((2, 2)), (0, 0, 2, 2))
result = images.create_image_edit_result(source, apply_alpha_mask(rgba, full, "SUBTRACT"), (2, 2))
result.use_fake_user = True
name = result.name
bpy.data.images.remove(source)
path = str(tmp_path / "mask.blend")
bpy.ops.wm.save_as_mainfile(filepath=path)
bpy.ops.wm.open_mainfile(filepath=path)
result = bpy.data.images[name]
try:
restored = images.image_rgba(result)
np.testing.assert_allclose(restored[..., :3], rgba[..., :3], atol=1 / 255)
np.testing.assert_array_equal(restored[..., 3], 0)
added = images.create_image_edit_result(result, apply_alpha_mask(restored, full, "ADD"), (2, 2))
bpy.data.images.remove(result)
result = added
for _ in range(2):
source = frame.frame_source_projection(np.eye(4), np.eye(4), np.eye(4), (-1, 1, -1, 1), (2, 2), perspective=False)
rgba_read = images.image_rgba(result)
source.update(rgba=rgba_read, pixels=images.premultiplied_rgba(images.image_pixels(result), (2, 2)),
image_size=(2, 2), active=True, name=result.name)
pixels = frame.composite_frame_pixels((source,), ((0, 2), (2, 2), (2, 0), (0, 0)), (2, 2))
next_result = images.create_image_edit_result(result, pixels, (2, 2))
bpy.data.images.remove(result)
result = next_result
result.reload()
assert result.alpha_mode == "PREMUL" and result.packed_file is not None
np.testing.assert_allclose(images.image_rgba(result), rgba, atol=1 / 255)
finally:
bpy.data.images.remove(result)

View File

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

View File

@@ -0,0 +1,250 @@
from unittest.mock import patch
import bpy
import numpy as np
import pytest
from PIL import Image
from anyimage.common import image as images
@pytest.fixture
def source():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Source.png", width=2, height=2, alpha=True)
image.pixels.foreach_set(np.tile([0.2, 0.4, 0.6, 0.25], 4).astype(np.float32))
image.pack()
obj = create_empty("Source", image)
yield obj
bpy.ops.wm.read_factory_settings(use_empty=True)
def create_empty(name, image):
obj = bpy.data.objects.new(name, None)
obj.empty_display_type = "IMAGE"
obj.data = image
bpy.context.collection.objects.link(obj)
return obj
def edit_result(source):
return images.create_image_edit_result(
source.data, np.tile([0.8, 0.3, 0.1, 0.0], 12).astype(np.float32), (4, 3),
)
@pytest.mark.parametrize("sharing", ["visible", "hidden", "other_scene"])
def test_shared_edit_preserves_other_empty(source, sharing):
original = source.data
packed = bytes(original.packed_file.data)
rgba = images.image_rgba(original)
result = edit_result(source)
# Create the second user after preparing the result, as during an async Job.
other = create_empty("Other", original)
other.hide_viewport = sharing == "hidden"
if sharing == "other_scene":
bpy.context.collection.objects.unlink(other)
scene = bpy.data.scenes.new("Other scene")
scene.collection.objects.link(other)
other.empty_display_size = 7
other.image_user.frame_start = 9
assert images.has_other_image_empty(source)
assert images.replace_empty_image(source, result) == result
assert source.data == result
assert result.name == "Source.png.001"
assert other.data == original
assert other.empty_display_size == 7
assert other.image_user.frame_start == 9
assert original.name == "Source.png"
assert bytes(original.packed_file.data) == packed
np.testing.assert_array_equal(images.image_rgba(original), rgba)
@pytest.mark.parametrize("reference", ["none", "fake_user", "material", "collections"])
def test_sole_edit_preserves_id_and_releases_result(source, reference, tmp_path):
original = source.data
pointer = original.as_pointer()
original.use_fake_user = reference == "fake_user"
if reference == "material":
material = bpy.data.materials.new("Manual reference")
material.use_nodes = True
node = material.node_tree.nodes.new("ShaderNodeTexImage")
node.image = original
if reference == "collections":
collection = bpy.data.collections.new("Another collection")
bpy.context.scene.collection.children.link(collection)
collection.objects.link(source)
count = len(bpy.data.images)
result = edit_result(source)
expected = images.image_rgba(result)
assert not images.has_other_image_empty(source)
final = images.replace_empty_image(source, result)
assert final.as_pointer() == pointer == source.data.as_pointer()
assert final.name == "Source.png"
assert final.use_fake_user == (reference == "fake_user")
assert len(bpy.data.images) == count
if reference == "material":
assert node.image == final
np.testing.assert_allclose(images.image_rgba(final), expected, atol=1 / 255)
path = str(tmp_path / "edited.blend")
bpy.ops.wm.save_as_mainfile(filepath=path)
bpy.ops.wm.open_mainfile(filepath=path)
reopened = bpy.data.objects["Source"].data
assert reopened.name == "Source.png"
np.testing.assert_allclose(images.image_rgba(reopened), expected, atol=1 / 255)
@pytest.mark.parametrize("outside", [False, True])
def test_frame_sharing_uses_surviving_objects(source, outside):
original = source.data
merged = create_empty("Merged", original)
if outside:
other = create_empty("Outside", original)
assert images.has_other_image_empty(source, removed_objects=(merged,)) == outside
result = edit_result(source)
final = images.replace_empty_image(source, result, removed_objects=(merged,))
bpy.data.objects.remove(merged, do_unlink=True)
assert (final == original) == (not outside)
if outside:
assert other.data == original
assert tuple(original.size) == (2, 2)
@pytest.mark.parametrize("shared", [False, True])
def test_failed_display_configuration_restores_content(source, tmp_path, shared):
original = source.data
if shared:
create_empty("Other", original)
# Preserve unsaved pixels as well as the previous packed representation.
original.pixels.foreach_set(np.tile([0.1, 0.9, 0.2, 0.5], 4).astype(np.float32))
packed = bytes(original.packed_file.data)
pixels = images.image_pixels(original)
alpha = original.alpha_mode
source.image_user.frame_start = 5
source.image_user.use_auto_refresh = False
for index in (1, 2):
Image.new("RGBA", (4, 3), (100, 150, 200, 255)).save(tmp_path / f"image.{index:04d}.png")
result = bpy.data.images.load(str(tmp_path / "image.0001.png"))
result.source = "SEQUENCE"
count = len(bpy.data.images) - 1
def fail_configuration(user, *args, **kwargs):
user.frame_start = 99
user.use_auto_refresh = True
raise RuntimeError("Display failed")
with patch.object(images, "configure_image_user", side_effect=fail_configuration):
with pytest.raises(RuntimeError, match="Display failed"):
images.replace_empty_image(source, result)
assert source.data == original
assert tuple(original.size) == (2, 2)
assert original.alpha_mode == alpha
assert original.is_dirty
assert bytes(original.packed_file.data) == packed
np.testing.assert_array_equal(images.image_pixels(original), pixels)
assert source.image_user.frame_start == 5
assert not source.image_user.use_auto_refresh
assert not source.use_empty_image_alpha
assert not original.use_fake_user
assert len(bpy.data.images) == count
def test_failed_content_write_restores_source(source):
original = source.data
packed = bytes(original.packed_file.data)
result = edit_result(source)
restore = images.restore_image_content
calls = 0
def fail_once(image, state):
nonlocal calls
calls += 1
restore(image, state)
if calls == 1:
raise RuntimeError("Write failed")
with patch.object(images, "restore_image_content", side_effect=fail_once):
with pytest.raises(RuntimeError, match="Write failed"):
images.replace_empty_image(source, result)
assert source.data == original
assert tuple(original.size) == (2, 2)
assert bytes(original.packed_file.data) == packed
assert len(bpy.data.images) == 1
def test_animated_result_preserves_id_and_reopens(source, tmp_path):
original = source.data
source.image_user.frame_start = 4
source.image_user.use_cyclic = True
for index in (1, 2):
Image.new("RGBA", (4, 3), (index * 80, 100, 50, 255)).save(tmp_path / f"image.{index:04d}.png")
original.source = "SEQUENCE"
result = images.load_image_edit_result(source, tmp_path)
expected_path = result.filepath_raw
expected_packed = bytes(result.packed_file.data) if result.packed_file else None
final = images.replace_empty_image(source, result)
assert final == original
assert final.source == "SEQUENCE"
assert final.filepath_raw == expected_path
assert (bytes(final.packed_file.data) if final.packed_file else None) == expected_packed
assert len(images.collect_sequence_frames(final)) == 2
assert source.image_user.frame_start == 4
assert source.image_user.frame_duration == 2
assert source.image_user.use_cyclic
assert source.image_user.use_auto_refresh
path = str(tmp_path / "animation.blend")
bpy.ops.wm.save_as_mainfile(filepath=path)
bpy.ops.wm.open_mainfile(filepath=path)
reopened = bpy.data.objects["Source"].data
assert reopened.name == "Source.png"
assert reopened.source == "SEQUENCE"
frames = images.collect_sequence_frames(reopened)
assert len(frames) == 2
for index, frame in enumerate(frames, 1):
loaded = bpy.data.images.load(str(frame), check_existing=False)
try:
assert tuple(loaded.size) == (4, 3)
np.testing.assert_allclose(
images.image_pixels(loaded)[:4],
np.array([index * 80, 100, 50, 255]) / 255,
atol=1 / 255,
)
finally:
bpy.data.images.remove(loaded)
@pytest.mark.parametrize("floating", [False, True])
def test_failed_generated_commit_restores_unsaved_pixels(source, floating):
original = bpy.data.images.new("Generated", width=3, height=2, alpha=True, float_buffer=floating)
source.data = original
original.pixels.foreach_set(np.tile([0.12, 0.24, 0.48, 0.5], 6).astype(np.float32))
expected = images.image_pixels(original)
result = edit_result(source)
restore = images.restore_image_content
calls = 0
def fail_once(image, state):
nonlocal calls
calls += 1
restore(image, state)
if calls == 1:
raise RuntimeError("Write failed")
with patch.object(images, "restore_image_content", side_effect=fail_once):
with pytest.raises(RuntimeError, match="Write failed"):
images.replace_empty_image(source, result)
assert original.source == "GENERATED"
assert original.is_float == floating
assert original.packed_file is None
assert tuple(original.size) == (3, 2)
np.testing.assert_array_equal(images.image_pixels(original), expected)
def test_failed_preparation_releases_result(source):
result = edit_result(source)
count = len(bpy.data.images) - 1
with patch.object(images, "empty_display_alignment", side_effect=ValueError("Invalid bounds")):
with pytest.raises(ValueError, match="Invalid bounds"):
images.replace_empty_image(source, result, placement_bounds=(0, 0, 1, 1))
assert len(bpy.data.images) == count
assert tuple(source.data.size) == (2, 2)

View File

@@ -0,0 +1,350 @@
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from tests.anyimage.common.support import load_common_modules
class ImageDataTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.modules = load_common_modules()
def test_is_animated_image_accepts_movie_and_sequence(self):
self.assertTrue(
self.modules.image.is_animated_image(SimpleNamespace(source="MOVIE"))
)
self.assertTrue(
self.modules.image.is_animated_image(SimpleNamespace(source="SEQUENCE"))
)
self.assertFalse(
self.modules.image.is_animated_image(SimpleNamespace(source="FILE"))
)
def test_video_total_frames_reads_movie_duration(self):
image = SimpleNamespace(source="MOVIE", frame_duration=97)
self.assertEqual(self.modules.image.video_total_frames(image), 97)
def test_video_total_frames_counts_sequence_files(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "foreground-0001.png").touch()
(root / "foreground-0002.png").touch()
image = SimpleNamespace(
source="SEQUENCE",
filepath=str(root / "foreground-0001.png"),
)
self.assertEqual(self.modules.image.video_total_frames(image), 2)
def test_video_total_frames_returns_one_for_still_image(self):
image = SimpleNamespace(source="FILE", frame_duration=1)
self.assertEqual(self.modules.image.video_total_frames(image), 1)
def test_default_extract_frames_uses_empty_image_frames(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(frame_duration=24),
)
self.assertEqual(self.modules.image.default_extract_frames(source_object), 24)
def test_default_extract_frames_caps_at_video_total(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(frame_duration=200),
)
self.assertEqual(self.modules.image.default_extract_frames(source_object), 97)
def test_default_extract_frames_falls_back_to_video_total(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
)
self.assertEqual(self.modules.image.default_extract_frames(source_object), 97)
def test_extract_frame_range_honors_offset_within_clip(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(
frame_start=4,
frame_offset=70,
frame_duration=24,
),
)
self.assertEqual(
self.modules.image.extract_frame_range(source_object),
(71, 24),
)
def test_extract_frame_range_clamps_large_offset_to_clip_tail(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(
frame_start=1,
frame_offset=90,
frame_duration=24,
),
)
self.assertEqual(
self.modules.image.extract_frame_range(source_object),
(74, 24),
)
def test_extract_frame_range_clamps_negative_offset_to_start(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(
frame_start=1,
frame_offset=-5,
frame_duration=24,
),
)
self.assertEqual(
self.modules.image.extract_frame_range(source_object),
(1, 24),
)
def test_extract_frame_range_returns_full_clip_for_still(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="FILE", frame_duration=1),
image_user=SimpleNamespace(
frame_start=1,
frame_offset=50,
frame_duration=24,
),
)
self.assertEqual(
self.modules.image.extract_frame_range(source_object),
(1, 1),
)
def test_extract_frame_range_honors_single_frame_setting(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(
frame_start=1,
frame_offset=70,
frame_duration=1,
),
)
self.assertEqual(
self.modules.image.extract_frame_range(source_object),
(71, 1),
)
def test_configure_image_user_preserves_cyclic_setting(self):
image_user = SimpleNamespace(
frame_start=0,
frame_offset=5,
use_auto_refresh=False,
use_cyclic=False,
frame_duration=1,
)
self.modules.image.configure_image_user(
image_user,
97,
frame_start=4,
frame_offset=70,
)
self.assertFalse(image_user.use_cyclic)
self.assertTrue(image_user.use_auto_refresh)
self.assertEqual(image_user.frame_start, 4)
self.assertEqual(image_user.frame_offset, 70)
self.assertEqual(image_user.frame_duration, 97)
self.modules.image.configure_image_user(
image_user,
use_cyclic=True,
)
self.assertTrue(image_user.use_cyclic)
def test_image_user_settings_reads_empty_values(self):
source_object = SimpleNamespace(
image_user=SimpleNamespace(
frame_start=4,
frame_offset=70,
frame_duration=12,
)
)
self.assertEqual(
self.modules.image.image_user_settings(source_object),
(4, 70, 12, False),
)
source_object.image_user.use_cyclic = True
self.assertEqual(
self.modules.image.image_user_settings(source_object),
(4, 70, 12, True),
)
def test_image_user_settings_defaults_without_user(self):
self.assertEqual(
self.modules.image.image_user_settings(SimpleNamespace(image_user=None)),
(1, 0, 0, False),
)
def test_image_base_name_strips_video_extension(self):
image = SimpleNamespace(name="微信视频2026-07-23_192756_414.mp4")
self.assertEqual(
self.modules.image.image_base_name(image),
"微信视频2026-07-23_192756_414",
)
def test_color_name_strips_video_extension(self):
source_object = SimpleNamespace(data=SimpleNamespace(name="clip.mp4"))
self.assertEqual(
self.modules.image.color_data_name(source_object),
"clip_color.png",
)
def test_force_sequence_source_keeps_loaded_sequence(self):
image = SimpleNamespace(source="SEQUENCE")
result = self.modules.image.force_sequence_source(image)
self.assertIs(result, image)
self.assertEqual(image.source, "SEQUENCE")
def test_force_sequence_source_switches_single_image(self):
image = SimpleNamespace(source="FILE")
result = self.modules.image.force_sequence_source(image)
self.assertIs(result, image)
self.assertEqual(image.source, "SEQUENCE")
def test_force_sequence_source_reports_unloadable_sequence(self):
class BlockedImage:
@property
def source(self):
return "FILE"
@source.setter
def source(self, value):
raise RuntimeError("blocked")
with self.assertRaisesRegex(RuntimeError, "cannot load"):
self.modules.image.force_sequence_source(BlockedImage())
def test_collect_sequence_frames_returns_numbered_files_in_order(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "frame-0001.jpg").touch()
(root / "frame-0002.jpg").touch()
(root / "frame-0003.jpg").touch()
(root / "notes.txt").touch()
image = SimpleNamespace(
source="SEQUENCE",
filepath=str(root / "frame-0002.jpg"),
)
frames = self.modules.image.collect_sequence_frames(image)
self.assertEqual(
[path.name for path in frames],
["frame-0001.jpg", "frame-0002.jpg", "frame-0003.jpg"],
)
def test_sorted_frame_paths_uses_numeric_frame_order(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "frame-2.jpg").touch()
(root / "frame-10.jpg").touch()
(root / "frame-1.jpg").touch()
frames = self.modules.image.sorted_frame_paths(root)
self.assertEqual(
[path.name for path in frames],
["frame-1.jpg", "frame-2.jpg", "frame-10.jpg"],
)
def test_depth_data_uses_source_image_name(self):
source_object = SimpleNamespace(
name="obj_img",
data=SimpleNamespace(name="img.png"),
)
self.assertEqual(
self.modules.image.depth_data_name(source_object),
"img_depth.exr",
)
source_object.data.name = "img.png.001"
self.assertEqual(
self.modules.image.depth_data_name(source_object),
"img.001_depth.exr",
)
def test_moge_normal_name_uses_source_image_name(self):
source_object = SimpleNamespace(
name="obj_img",
data=SimpleNamespace(name="img.png"),
)
self.assertEqual(
self.modules.image.normal_data_name(source_object),
"img_normal.png",
)
def test_color_name_uses_source_image_base_name(self):
source_object = SimpleNamespace(
data=SimpleNamespace(name="img.png"),
)
self.assertEqual(
self.modules.image.color_data_name(source_object),
"img_color.png",
)
source_object.data.name = "img.png.001"
self.assertEqual(
self.modules.image.color_data_name(source_object),
"img.001_color.png",
)
def test_image_empty_bounds_keep_image_aspect_and_empty_offset(self):
source = SimpleNamespace(
data=SimpleNamespace(size=(800, 400)),
empty_display_size=2.0,
empty_image_offset=(-0.5, -0.5),
)
bounds = self.modules.image.image_empty_bounds(source)
self.assertEqual(bounds, (-1.0, 1.0, -0.5, 0.5))

View File

@@ -0,0 +1,212 @@
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from tests.support.blender import BlenderTestCase
class ImageEditToolTest(BlenderTestCase):
def test_packed_image_export_does_not_use_scene_render_transform(self):
with tempfile.TemporaryDirectory() as directory:
output_directory = Path(directory)
calls = []
class Image:
file_format = "JPEG"
def save(self, **options):
calls.append(options)
Path(options["filepath"]).write_bytes(b"raw image")
image = Image()
scene = SimpleNamespace(
view_settings=SimpleNamespace(look="AgX - Medium High Contrast")
)
with patch.object(
self.image_data.tempfile,
"mkdtemp",
return_value=str(output_directory),
):
output = self.image_data.save_packed_image(image, scene)
self.assertEqual(output, output_directory / "input.png")
self.assertEqual(
calls,
[{"filepath": str(output), "save_copy": True}],
)
self.assertEqual(image.file_format, "JPEG")
def test_expected_empty_image_edits_are_reported_as_warnings(self):
reports = []
operator = SimpleNamespace(
report=lambda level, message: reports.append((level, message))
)
self.image_interaction.report_image_edit_exception(
operator,
self.image_interaction.ImageEditWarning(
"The edit contains no visible image pixels"
),
)
self.image_interaction.report_image_edit_exception(
operator,
ValueError("Unexpected invalid data"),
)
self.assertEqual(
reports,
[
(
{"WARNING"},
"The edit contains no visible image pixels",
),
({"ERROR"}, "Unexpected invalid data"),
],
)
def test_image_edit_first_click_selects_another_image_without_editing(self):
selected = []
def image(name):
return SimpleNamespace(
name=name,
type="EMPTY",
empty_display_type="IMAGE",
data=object(),
select_set=lambda value: selected.append((name, value)),
)
active = image("Active")
clicked = image("Clicked")
context = SimpleNamespace(
active_object=active,
selected_objects=(active,),
view_layer=SimpleNamespace(objects=SimpleNamespace(active=active)),
)
event = SimpleNamespace(mouse_region_x=10, mouse_region_y=20)
previous_ops = getattr(self.fake_bpy, "ops", None)
previous_objects = self.fake_bpy.data.objects
def select(**_kwargs):
context.active_object = clicked
self.fake_bpy.ops = SimpleNamespace(
view3d=SimpleNamespace(select=select),
object=SimpleNamespace(select_all=lambda **_kwargs: None),
)
self.fake_bpy.data.objects = {
active.name: active,
clicked.name: clicked,
}
try:
source, should_edit = self.image_interaction.resolve_image_edit_click(
context, event, lambda *_args: None
)
finally:
self.fake_bpy.data.objects = previous_objects
if previous_ops is None:
del self.fake_bpy.ops
else:
self.fake_bpy.ops = previous_ops
self.assertIs(source, clicked)
self.assertFalse(should_edit)
self.assertEqual(selected, [])
def test_image_edit_first_click_selects_another_object_without_editing(self):
active = SimpleNamespace(
name="Active",
type="EMPTY",
empty_display_type="IMAGE",
data=object(),
)
clicked = SimpleNamespace(name="Mesh", type="MESH", data=object())
context = SimpleNamespace(
active_object=active,
selected_objects=(active,),
)
event = SimpleNamespace(mouse_region_x=10, mouse_region_y=20)
previous_ops = getattr(self.fake_bpy, "ops", None)
select_options = []
def select(**options):
select_options.append(options)
context.active_object = clicked
self.fake_bpy.ops = SimpleNamespace(
view3d=SimpleNamespace(select=select),
)
try:
source, should_edit = self.image_interaction.resolve_image_edit_click(
context, event, lambda *_args: None
)
finally:
if previous_ops is None:
del self.fake_bpy.ops
else:
self.fake_bpy.ops = previous_ops
self.assertIs(source, clicked)
self.assertFalse(should_edit)
self.assertEqual(select_options[0]["deselect_all"], False)
def test_image_edit_empty_click_keeps_active_image_and_starts_editing(self):
active = SimpleNamespace(
name="Active",
type="EMPTY",
empty_display_type="IMAGE",
data=object(),
)
context = SimpleNamespace(
active_object=active,
selected_objects=(active,),
)
event = SimpleNamespace(mouse_region_x=10, mouse_region_y=20)
previous_ops = getattr(self.fake_bpy, "ops", None)
select_options = []
self.fake_bpy.ops = SimpleNamespace(
view3d=SimpleNamespace(
select=lambda **options: select_options.append(options)
),
)
try:
source, should_edit = self.image_interaction.resolve_image_edit_click(
context, event, lambda *_args: None
)
finally:
if previous_ops is None:
del self.fake_bpy.ops
else:
self.fake_bpy.ops = previous_ops
self.assertIs(source, active)
self.assertTrue(should_edit)
self.assertIs(context.active_object, active)
self.assertEqual(select_options[0]["deselect_all"], False)
def test_image_edit_source_requires_active_image_to_be_selected(self):
active = SimpleNamespace(
name="Active",
type="EMPTY",
empty_display_type="IMAGE",
data=object(),
)
context = SimpleNamespace(
active_object=active,
selected_objects=(),
)
with self.assertRaisesRegex(
self.image_interaction.ImageEditWarning,
"Select an active Image Empty",
):
self.image_interaction.active_image_edit_source(context)

View File

@@ -0,0 +1,226 @@
from types import SimpleNamespace
import bpy
import numpy as np
from anyimage.common import image as image_data
from anyimage.common import material as material_data
from anyimage.operators.convert_to_plane import object as plane_object
from anyimage.operators.cutout_tool import object as cutout_object
from anyimage.operators.cutout_tool import shape as cutout_shape
from tests.support.image_objects import _create_cutout_shape, _group_node, _inputs
def test_image_layer_switches_between_tangent_and_object_space_normal():
bpy.ops.wm.read_factory_settings(use_empty=True)
assert material_data.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer"
group = material_data.material_node_group()
normal = next(
item
for item in group.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Normal"
)
assert tuple(normal.default_value) == (0.5, 0.5, 1.0, 1.0)
bump_scale = next(
item
for item in group.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Bump Scale"
)
assert bump_scale.default_value == 0.0
normal_scale = next(
item
for item in group.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Normal Scale"
)
assert normal_scale.default_value == 1.0
object_space = next(
item
for item in group.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Object Space"
)
assert object_space.default_value is False
normal_maps = {
node.space for node in group.nodes if node.bl_idname == "ShaderNodeNormalMap"
}
assert normal_maps == {"TANGENT", "OBJECT"}
assert all(
not node.label
for node in group.nodes
if node.bl_idname in {"ShaderNodeNormalMap", "ShaderNodeMix"}
)
color = bpy.data.images.new("Color", width=2, height=2)
normal_image = bpy.data.images.new("Normal", width=2, height=2)
object_material = material_data.create_image_material(
SimpleNamespace(name="Object Normal"),
color,
1.0,
normal_image=normal_image,
normal_space="OBJECT",
)
layer = _group_node(group, object_material.node_tree)
assert layer.inputs["Object Space"].default_value is True
tangent_material = material_data.create_image_material(
SimpleNamespace(name="Tangent Normal"),
color,
1.0,
normal_image=normal_image,
)
layer = _group_node(group, tangent_material.node_tree)
assert layer.inputs["Object Space"].default_value is False
reused = material_data.material_node_group()
assert {
node.space for node in reused.nodes if node.bl_idname == "ShaderNodeNormalMap"
} == {"TANGENT", "OBJECT"}
def test_cutout_material_uses_ior_1_2():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Cutout", width=16, height=16, alpha=True)
source = bpy.data.objects.new("Cutout", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
result = _create_cutout_shape(source, "FLAT", 8)
shader = next(
node
for node in result.data.materials[0].node_tree.nodes
if node.bl_idname == "ShaderNodeBsdfPrincipled"
)
assert np.isclose(shader.inputs["IOR"].default_value, 1.2)
image_layer = _group_node(
material_data.material_node_group(),
result.data.materials[0].node_tree,
)
ior_level_link = next(
link
for link in result.data.materials[0].node_tree.links
if link.to_node == shader
and link.to_socket == shader.inputs["Specular IOR Level"]
)
assert ior_level_link.from_node == image_layer
assert ior_level_link.from_socket == image_layer.outputs["Color"]
def test_plane_material_color_drives_ior_level_but_shadeless_does_not():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Plane", width=2, height=2, alpha=True)
source = SimpleNamespace(data=SimpleNamespace(name="Plane"))
material = material_data.create_image_material(source.data, image)
shader = next(
node
for node in material.node_tree.nodes
if node.bl_idname == "ShaderNodeBsdfPrincipled"
)
assert np.isclose(shader.inputs["IOR"].default_value, 1.2)
image_layer = _group_node(material_data.material_node_group(), material.node_tree)
assert image_layer.inputs["Bump Scale"].default_value == 0.0
assert any(
link.from_node == image_layer
and link.from_socket == image_layer.outputs["Color"]
and link.to_node == shader
and link.to_socket == shader.inputs["Specular IOR Level"]
for link in material.node_tree.links
)
shadeless = material_data.create_image_material(
source.data,
image,
0.0,
shadeless=True,
)
assert not any(
node.bl_idname == "ShaderNodeBsdfPrincipled"
for node in shadeless.node_tree.nodes
)
def test_cutout_material_uses_flat_tangent_normal():
bpy.ops.wm.read_factory_settings(use_empty=True)
source = bpy.data.objects.new("Source", None)
source.empty_display_type = "IMAGE"
source.data = bpy.data.images.new("Source", width=2, height=2)
color = bpy.data.images.new("Color", width=2, height=2)
normal = bpy.data.images.new("Normal", width=1, height=1)
result = material_data.create_image_material(
source.data,
color,
0.0,
normal_image=normal,
scene=bpy.context.scene,
)
layer = _group_node(material_data.material_node_group(), result.node_tree)
assert layer.inputs["Object Space"].default_value is False
def test_loaded_normal_image_uses_normal_texture_name(tmp_path):
from PIL import Image
path = tmp_path / "normal.png"
Image.new("RGB", (1, 1), (128, 128, 255)).save(path)
source = SimpleNamespace(data=SimpleNamespace(name="Poster.png"))
image = image_data.load_normal_result_image(path, source)
assert image.name == "Poster_normal.png"
assert image.colorspace_settings.name == "Non-Color"
assert image.packed_file is not None
def test_converted_color_images_are_new_and_use_blender_duplicate_suffix():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Poster", width=16, height=16)
source = bpy.data.objects.new("Poster", None)
source.empty_display_type = "IMAGE"
source.data = image
bpy.context.collection.objects.link(source)
first = plane_object.create_color_image(source)
second = plane_object.create_color_image(source)
assert first is not image
assert second is not image
assert first is not second
assert first.name == "Poster_color.png"
assert second.name == "Poster_color.png.001"
def test_balloon_normal_scale_uses_existing_image_layer_input():
bpy.ops.wm.read_factory_settings(use_empty=True)
group = material_data.material_node_group()
original_inputs = _inputs(group)
image_material = bpy.data.materials.new("Balloon")
image_material.use_nodes = True
image_material.node_tree.nodes.clear()
image_layer = image_material.node_tree.nodes.new("ShaderNodeGroup")
image_layer.node_tree = group
cutout_object._connect_cutout_normal_scale(image_material)
assert _inputs(group) == original_inputs
attribute = next(
node
for node in image_material.node_tree.nodes
if node != image_layer
)
assert attribute.bl_idname == "ShaderNodeAttribute"
assert attribute.attribute_name == cutout_shape.NORMAL_SCALE_ATTRIBUTE_NAME
link = next(iter(image_material.node_tree.links))
assert link.from_socket == attribute.outputs["Fac"]
assert link.to_socket == image_layer.inputs["Normal Scale"]

View File

@@ -0,0 +1,27 @@
import bpy
import pytest
from types import SimpleNamespace
from anyimage.common import material
from tests.support.materials import image_layer
@pytest.mark.parametrize("enabled", [False, True])
@pytest.mark.parametrize("shadeless", [False, True])
@pytest.mark.parametrize("view", ["AgX", "Unknown"])
def test_material_alpha_strength_follows_preference(image_layer, monkeypatch, enabled, shadeless, view):
monkeypatch.setattr(material, "material_node_group", lambda depth_plane=False: image_layer)
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: enabled)
color = bpy.data.images.new("Alpha edge", width=2, height=2, alpha=True)
scene = SimpleNamespace(view_settings=SimpleNamespace(view_transform=view))
result = material.create_image_material(color, color, shadeless=shadeless, scene=scene)
layer = next(node for node in result.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer)
assert layer.inputs["Alpha Fix"].default_value == float(enabled)
alpha_link = layer.outputs["Alpha"].links[0]
assert alpha_link.to_node.type == ("MIX_SHADER" if shadeless else "BSDF_PRINCIPLED")
assert alpha_link.to_socket.name == ("Fac" if shadeless else "Alpha")
layer.inputs["Alpha Fix"].default_value = 0.5
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: not enabled)
second = material.create_image_material(color, color, shadeless=shadeless, scene=scene)
second_layer = next(node for node in second.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer)
assert second_layer.inputs["Alpha Fix"].default_value == float(not enabled)
assert layer.inputs["Alpha Fix"].default_value == 0.5

View File

@@ -0,0 +1,72 @@
import unittest
from types import SimpleNamespace
from tests.anyimage.common.support import load_common_modules
class MaterialSettingsTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.modules = load_common_modules()
def test_material_color_space_matches_scene_view_transform(self):
mappings = {
"ACES 1.3": "ACES 1.3 sRGB",
"ACES 2.0": "ACES 2.0 sRGB",
"AgX": "AgX Base sRGB",
"Filmic": "Filmic sRGB",
"Khronos PBR Neutral": "Khronos PBR Neutral sRGB",
"Standard": "sRGB",
}
for view_transform, expected in mappings.items():
with self.subTest(view_transform=view_transform):
scene = SimpleNamespace(
view_settings=SimpleNamespace(
view_transform=view_transform,
)
)
self.assertEqual(
self.modules.material.material_color_space_name(scene),
expected,
)
def test_material_color_space_falls_back_when_unsupported(self):
class ColorSpaceSettings:
def __init__(self):
self._name = "sRGB"
@property
def name(self):
return self._name
@name.setter
def name(self, value):
if value != "sRGB":
raise TypeError("unsupported color space")
self._name = value
image = SimpleNamespace(
colorspace_settings=ColorSpaceSettings(),
users=0, use_fake_user=False, alpha_mode="STRAIGHT",
)
scene = SimpleNamespace(
view_settings=SimpleNamespace(view_transform="ACES 2.0")
)
result = self.modules.material.configure_material_color_image(image, scene)
self.assertIs(result, image)
self.assertEqual(image.alpha_mode, "PREMUL")
self.assertEqual(image.colorspace_settings.name, "sRGB")
def test_surface_displacement_is_set_to_displacement_only(self):
class Material:
surface_displacement_method = "BUMP"
material = Material()
self.modules.material.set_displacement_only(material)
self.assertEqual(
material.surface_displacement_method,
"DISPLACEMENT",
)

View File

@@ -0,0 +1,232 @@
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import numpy as np
import pytest
from PIL import Image
from anyimage import preferences
from anyimage.common import image as images
from anyimage.common import material
from anyimage.operators.clipboard_image import actions as clipboard
from anyimage.operators.convert_to_plane.object import create_color_image
from anyimage.operators.cutout_tool.object import create_cropped_color_image
@pytest.fixture
def color_data():
original_images = set(bpy.data.images)
original_materials = set(bpy.data.materials)
original_objects = set(bpy.data.objects)
yield
for obj in set(bpy.data.objects) - original_objects:
bpy.data.objects.remove(obj, do_unlink=True)
for item in set(bpy.data.materials) - original_materials:
bpy.data.materials.remove(item, do_unlink=True)
for item in set(bpy.data.images) - original_images:
bpy.data.images.remove(item, do_unlink=True)
def view_scene(view):
return SimpleNamespace(view_settings=SimpleNamespace(view_transform=view))
def color_texture(result):
return next(node.image for node in result.node_tree.nodes if node.type == "TEX_IMAGE")
def test_adaptation_preference_defaults_and_reads_current_value():
with patch.object(preferences, "addon_preferences", return_value=None):
assert preferences.configured_material_view_adaptation()
with patch.object(preferences, "addon_preferences", return_value=SimpleNamespace()):
assert preferences.configured_material_view_adaptation()
settings = SimpleNamespace(adapt_material_to_view_transform=False)
with patch.object(preferences, "addon_preferences", return_value=settings):
assert not preferences.configured_material_view_adaptation()
settings.adapt_material_to_view_transform = True
assert preferences.configured_material_view_adaptation()
@pytest.mark.parametrize("enabled", [False, True])
@pytest.mark.parametrize("view,space", [*material.VIEW_TRANSFORM_COLOR_SPACES, ("Standard", "sRGB"), ("Unknown", "sRGB")])
def test_color_space_and_alpha_follow_preference(color_data, enabled, view, space):
image = bpy.data.images.new("Material policy", width=2, height=1, alpha=True)
expected = np.array([0.2, 0.4, 0.8, 0.25, 0.6, 0.3, 0.1, 0], dtype=np.float32)
image.pixels.foreach_set(expected)
settings = SimpleNamespace(adapt_material_to_view_transform=enabled)
# The installed bpy may not include newer view transforms such as ACES 2.0.
try:
image.colorspace_settings.name = space
except TypeError:
space = "sRGB"
image.colorspace_settings.name = "sRGB"
image.pixels.foreach_set(expected)
with patch.object(preferences, "addon_preferences", return_value=settings):
result = material.configure_material_color_image(image, view_scene(view))
selected = space if enabled else "sRGB"
assert result.colorspace_settings.name == selected
assert result.alpha_mode == ("PREMUL" if selected == "sRGB" else "STRAIGHT")
assert settings.adapt_material_to_view_transform == enabled
np.testing.assert_allclose(images.image_pixels(result), expected, atol=1 / 255)
@pytest.mark.parametrize("enabled", [False, True])
@pytest.mark.parametrize("kind", [
"byte", "packed", "file", "dirty",
pytest.param("float", marks=pytest.mark.xfail(
strict=True,
reason="Native float RGB is reinterpreted by Blender after packing and reloading with a display color space",
)),
])
def test_material_pixels_survive_pack_and_library_reload(color_data, tmp_path, enabled, kind):
expected = np.array([[[51, 102, 204, 64], [153, 76, 25, 0]]], dtype=np.uint8)
if kind in {"file", "dirty"}:
path = tmp_path / "source.png"
Image.fromarray(expected).save(path)
source = bpy.data.images.load(str(path), check_existing=False)
if kind == "dirty":
source.pixels.foreach_set((expected.astype(np.float32) / 255).ravel())
else:
source = bpy.data.images.new("Material pixels", width=2, height=1, alpha=True, float_buffer=kind == "float")
source.pixels.foreach_set((expected.astype(np.float32) / 255).ravel())
if kind == "packed":
source.pack()
source.reload()
images.configure_color_alpha(source)
before = images.image_pixels(source)
owner = bpy.data.objects.new("Color source owner", None)
owner.empty_display_type = "IMAGE"
owner.data = source
with patch.object(material, "configured_material_view_adaptation", return_value=enabled):
result = material.configure_material_color_image(source, view_scene("AgX"))
assert result != source
assert source.alpha_mode == "PREMUL"
np.testing.assert_allclose(images.image_pixels(source), before, atol=1 / 255)
np.testing.assert_allclose(images.image_pixels(result), before, atol=1 / 255)
if result.packed_file is None or result.is_dirty:
result.pack()
selected = (result.colorspace_settings.name, result.alpha_mode)
path = tmp_path / "material.blend"
bpy.data.libraries.write(str(path), {result})
with bpy.data.libraries.load(str(path)) as (available, loaded):
loaded.images = available.images
restored = loaded.images[0]
assert (restored.colorspace_settings.name, restored.alpha_mode) == selected
np.testing.assert_allclose(images.image_pixels(restored), before, atol=1 / 255)
edited = images.create_image_edit_result(restored, images.image_pixels(restored), (2, 1))
assert edited.alpha_mode == "PREMUL"
np.testing.assert_allclose(images.image_pixels(edited), before, atol=1 / 255)
@pytest.mark.parametrize("shadeless", [False, True])
@pytest.mark.parametrize("entry", ["plane", "cutout", "depth", "clipboard"])
def test_new_material_uses_current_preference_and_preserves_existing(color_data, entry, shadeless):
source = bpy.data.images.new("Shared source", width=2, height=2, alpha=True)
source.pixels.foreach_set(np.tile(np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32), 4))
owner = bpy.data.objects.new("Shared Empty", None)
owner.empty_display_type = "IMAGE"
owner.data = source
before = images.image_pixels(source)
scene = view_scene("AgX")
settings = SimpleNamespace(adapt_material_to_view_transform=True)
data_textures = {}
if entry == "depth":
for key in ("displacement_image", "normal_image"):
image = bpy.data.images.new(key, width=2, height=2, float_buffer=True)
image.colorspace_settings.name = "Non-Color"
data_textures[key] = image
data_states = {
key: (image.colorspace_settings.name, image.alpha_mode, images.image_pixels(image))
for key, image in data_textures.items()
}
with patch.object(preferences, "addon_preferences", return_value=settings):
if entry == "clipboard":
first = clipboard.create_image_material(source, source, shadeless=shadeless, scene=scene)
else:
if entry == "plane":
color = create_color_image(owner)
elif entry == "cutout":
color = create_cropped_color_image(owner, (0, 0, 2, 2), images.image_rgba(source))
else:
color = source
first = material.create_image_material(owner.data, color, 0, shadeless=shadeless, scene=scene, **data_textures)
original_color = color_texture(first)
assert original_color.colorspace_settings.name == "AgX Base sRGB"
assert original_color.alpha_mode == "STRAIGHT"
settings.adapt_material_to_view_transform = False
assert original_color.alpha_mode == "STRAIGHT"
if entry == "clipboard":
second = clipboard.create_image_material(original_color, original_color, shadeless=shadeless, scene=scene)
else:
second = material.create_image_material(owner.data, original_color, 0, shadeless=shadeless, scene=scene)
new_color = color_texture(second)
assert new_color != original_color
assert new_color.colorspace_settings.name == "sRGB"
assert new_color.alpha_mode == "PREMUL"
assert original_color.colorspace_settings.name == "AgX Base sRGB"
assert original_color.alpha_mode == "STRAIGHT"
assert not settings.adapt_material_to_view_transform
assert owner.data == source
assert source.colorspace_settings.name == "sRGB"
assert source.alpha_mode == "STRAIGHT"
for image in (source, original_color, new_color):
np.testing.assert_allclose(images.image_pixels(image), before, atol=1 / 255)
for key, image in data_textures.items():
space, alpha, pixels = data_states[key]
assert image.colorspace_settings.name == space
assert image.alpha_mode == alpha
np.testing.assert_array_equal(images.image_pixels(image), pixels)
def test_edit_results_keep_their_policy_with_adaptation_enabled(color_data, tmp_path):
source = bpy.data.images.new("Editing source", width=1, height=1, alpha=True)
rgba = np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32)
Image.fromarray(np.array([[[51, 102, 204, 64]]], dtype=np.uint8)).save(tmp_path / "image.0001.png")
with patch.object(preferences, "addon_preferences", return_value=SimpleNamespace(adapt_material_to_view_transform=True)):
edited = images.create_image_edit_result(source, rgba, (1, 1))
loaded = images.load_image_edit_result(SimpleNamespace(data=source), tmp_path)
for result in (edited, loaded):
assert result.colorspace_settings.name == "sRGB"
assert result.alpha_mode == "PREMUL"
np.testing.assert_allclose(images.image_pixels(result), rgba, atol=1 / 255)
assert source.colorspace_settings.name == "sRGB"
assert source.alpha_mode == "STRAIGHT"
@pytest.mark.parametrize("supported", [{"sRGB", "Non-Color"}, {"Non-Color"}])
def test_unavailable_color_space_falls_back_or_preserves_original(supported):
class ColorSpace:
value = "Non-Color"
@property
def name(self):
return self.value
@name.setter
def name(self, value):
if value not in supported:
raise TypeError("Unsupported color space")
self.value = value
image = SimpleNamespace(colorspace_settings=ColorSpace(), users=0, use_fake_user=False, alpha_mode="STRAIGHT")
with patch.object(material, "configured_material_view_adaptation", return_value=True):
if "sRGB" in supported:
result = material.configure_material_color_image(image, view_scene("AgX"))
assert result.colorspace_settings.name == "sRGB"
assert result.alpha_mode == "PREMUL"
else:
with pytest.raises(RuntimeError, match="material color space"):
material.configure_material_color_image(image, view_scene("AgX"))
assert image.colorspace_settings.name == "Non-Color"
assert image.alpha_mode == "STRAIGHT"
def test_missing_scene_uses_srgb(color_data):
image = bpy.data.images.new("Missing scene", width=1, height=1)
with patch.object(material, "bpy", SimpleNamespace(context=SimpleNamespace(scene=None))), patch.object(
material, "configured_material_view_adaptation", return_value=True
):
result = material.configure_material_color_image(image)
assert result.colorspace_settings.name == "sRGB"
assert result.alpha_mode == "PREMUL"

View File

@@ -0,0 +1,52 @@
import unittest
from types import SimpleNamespace
from tests.anyimage.common.support import load_common_modules
class ModifierInputsTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.modules = load_common_modules()
def test_runtime_modifier_enum_input_uses_string_identifier(self):
enum_items = (
SimpleNamespace(identifier="SURFACE"),
SimpleNamespace(identifier="BALLOON"),
)
value_property = SimpleNamespace(type="ENUM", enum_items=enum_items)
input_slot = SimpleNamespace(
value=None,
bl_rna=SimpleNamespace(properties={"value": value_property}),
)
modifier = SimpleNamespace(
properties=SimpleNamespace(
inputs=SimpleNamespace(Shape=input_slot),
)
)
self.modules.object.set_modifier_input(modifier, "Shape", 1)
self.assertEqual(input_slot.value, "BALLOON")
def test_modifier_inputs_support_rna_and_dictionary_values(self):
value_property = SimpleNamespace(type="FLOAT")
input_slot = SimpleNamespace(
value=None,
bl_rna=SimpleNamespace(properties={"value": value_property}),
)
runtime_modifier = SimpleNamespace(
properties=SimpleNamespace(
inputs=SimpleNamespace(Thickness=input_slot),
)
)
self.modules.object.set_modifier_input(runtime_modifier, "Thickness", 2.0)
self.assertEqual(input_slot.value, 2.0)
class LegacyModifier(dict):
properties = None
legacy_modifier = LegacyModifier()
self.modules.object.set_modifier_input(legacy_modifier, "Shape", 1)
self.assertEqual(legacy_modifier["Shape"], 1)

View File

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

View File

@@ -0,0 +1,192 @@
import json
import numpy as np
import pytest
from anyimage.common import selection
def reference_selection_mask(
image_size,
selection_path,
*,
antialias=False,
):
width, height = map(int, image_size)
points = np.asarray(selection_path.points, dtype=np.float64)
x = np.arange(width, dtype=np.float64) + 0.5
y = np.arange(height, dtype=np.float64) + 0.5
winding = np.zeros((height, width), dtype=np.int16)
previous = points[-1]
for current in points:
crosses = (current[1] > y) != (previous[1] > y)
edge_x = (previous[0] - current[0]) * (y - current[1]) / (
previous[1] - current[1] + 1e-300
) + current[0]
selected = crosses[:, None] & (x[None, :] < edge_x[:, None])
winding += selected * (1 if current[1] > previous[1] else -1)
previous = current
mask = winding != 0
mask = (
selection._antialias_mask(mask)
if antialias
else mask.astype(np.float32)
)
bounds = selection._selection_canvas_bounds(
image_size,
selection_path,
2 if antialias else 0,
)
left, top, right, bottom = bounds
mask = mask[top:bottom, left:right]
if not np.any(mask > 0.0):
raise selection.ImageEditWarning(
"The selection contains no visible image pixels"
)
return selection.SelectionMask(mask, bounds)
def assert_matches_reference(image_size, selection_path, **options):
try:
expected = reference_selection_mask(image_size, selection_path, **options)
except selection.ImageEditWarning:
with pytest.raises(selection.ImageEditWarning):
selection.rasterize_selection_path(
image_size,
selection_path,
**options,
)
return
actual = selection.rasterize_selection_path(
image_size,
selection_path,
**options,
)
assert actual.bounds == expected.bounds
np.testing.assert_array_equal(actual.values, expected.values)
def test_selection_path_json_contains_only_points():
selection_path = selection.SelectionPath(
points=((1, 2), (8, 2), (4, 9)),
)
value = selection_path.to_json()
assert set(json.loads(value)) == {"points"}
assert selection.SelectionPath.from_json(value) == selection_path
with pytest.raises(ValueError, match="Selection Path data is invalid"):
selection.SelectionPath.from_json(
json.dumps(
{
"points": selection_path.points,
"fill_rule": "NONZERO",
}
)
)
def test_nonzero_winding_keeps_same_direction_overlap():
points = (
(1, 1),
(7, 1),
(7, 7),
(1, 7),
(1, 1),
(4, 1),
(10, 1),
(10, 7),
(4, 7),
(4, 1),
(1, 1),
)
mask = selection.rasterize_selection_path(
(12, 9),
selection.SelectionPath(points),
).full_values((12, 9))
assert mask[3, 5] == 1.0
def test_scanline_matches_reference_for_edge_cases():
paths = (
((1, 1), (15, 1), (15, 10), (1, 10)),
((0.2, 0.3), (15.8, 2.1), (8.4, 11.7)),
((1, 1), (15, 1), (15, 5), (7, 5), (7, 11), (1, 11)),
((1, 1), (15, 11), (1, 11), (15, 1)),
((-8, -3), (10, 1), (23, 8), (7, 18), (-4, 10)),
)
for points in paths:
for antialias in (False, True):
assert_matches_reference(
(18, 14),
selection.SelectionPath(points),
antialias=antialias,
)
def test_scanline_matches_reference_for_random_paths():
random = np.random.default_rng(20260901)
for _index in range(40):
point_count = int(random.integers(3, 60))
points = tuple(
map(
tuple,
random.uniform((-8, -6), (40, 30), size=(point_count, 2)),
)
)
for antialias in (False, True):
assert_matches_reference(
(32, 24),
selection.SelectionPath(points),
antialias=antialias,
)
def test_antialias_uses_only_padded_local_canvas(monkeypatch):
selection_path = selection.SelectionPath(
((100, 200), (500, 200), (500, 600), (100, 600))
)
calls = []
rasterize = selection._rasterize_path
def record(bounds, points):
calls.append(bounds)
return rasterize(bounds, points)
monkeypatch.setattr(selection, "_rasterize_path", record)
selection.rasterize_selection_path(
(5120, 2880),
selection_path,
antialias=True,
)
assert calls == [(98, 198, 502, 602)]
def test_high_resolution_lasso_allocates_one_local_scanline_table(monkeypatch):
angles = np.arange(1000, dtype=np.float64) * (2.0 * np.pi / 1000.0)
points = tuple(
zip(
2560.0 + 500.0 * np.cos(angles),
1440.0 + 300.0 * np.sin(angles),
)
)
bounds = selection.selection_path_bounds(points)
allocations = []
zeros = np.zeros
def record(shape, *args, **kwargs):
allocations.append(shape)
return zeros(shape, *args, **kwargs)
monkeypatch.setattr(np, "zeros", record)
mask = selection._rasterize_path(bounds, points)
width = bounds[2] - bounds[0]
height = bounds[3] - bounds[1]
assert mask.shape == (height, width)
assert allocations == [(height, width + 1)]
assert (height, width) != (2880, 5120)