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/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)
|
||||
Reference in New Issue
Block a user