Add new functionality for image rectification and depth metadata handling
- Implemented a preview tool for rectifying images with interactive aspect controls in `preview.py`. - Added tests for depth metadata loading to ensure consistent schema usage in `test_depth_metadata.py`. - Developed comprehensive tests for cutout calibration processes in `test_cutout_calibration.py`, including stability checks and depth calibration. - Created tests for frame compositing operations in `test_frame_compositing.py`, ensuring correct handling of depth and transparency. - Introduced material slot inheritance functionality in `material_slots.py` to manage geometry material assignments. - Built shared shader nodes for material processing in `shader.py`, including roughness and bump mapping setups.
This commit is contained in:
@@ -9,14 +9,6 @@ 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)
|
||||
|
||||
@@ -58,9 +50,7 @@ def load_common_modules():
|
||||
name: ModuleType(name)
|
||||
for name in (
|
||||
"anyimage",
|
||||
"anyimage.operators",
|
||||
"anyimage.common",
|
||||
"anyimage.operators.convert_to_plane",
|
||||
)
|
||||
}
|
||||
for package in packages.values():
|
||||
@@ -80,4 +70,4 @@ def load_common_modules():
|
||||
specification.loader.exec_module(module)
|
||||
loaded.append(module)
|
||||
|
||||
return SimpleNamespace(image=loaded[0], node=loaded[1], material=loaded[2], object=loaded[3])
|
||||
return SimpleNamespace(image=loaded[0], material=loaded[2], object=loaded[3])
|
||||
|
||||
@@ -35,9 +35,9 @@ def test_owned_color_images_preserve_source_and_use_premul(floating):
|
||||
|
||||
def test_job_loader_preserves_edit_alpha_policy(tmp_path):
|
||||
rgba = np.array([[[51, 102, 204, 64]]], dtype=np.uint8)
|
||||
Image.fromarray(rgba).save(tmp_path / "image.0001.png")
|
||||
Image.fromarray(rgba).save(tmp_path / "foreground.png")
|
||||
original = bpy.data.images.new("Job source", width=1, height=1, alpha=True)
|
||||
result = images.load_image_edit_result(original, tmp_path)
|
||||
result = images.load_image_edit_result(original, tmp_path / "foreground.png")
|
||||
try:
|
||||
assert result.alpha_mode == "STRAIGHT"
|
||||
assert result.packed_file is not None
|
||||
@@ -47,27 +47,6 @@ def test_job_loader_preserves_edit_alpha_policy(tmp_path):
|
||||
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)
|
||||
original.alpha_mode = "PREMUL"
|
||||
@@ -188,10 +167,11 @@ def test_isolated_decode_failure_releases_copy():
|
||||
assert source.alpha_mode == "PREMUL"
|
||||
|
||||
|
||||
def test_mask_pack_reopen_add_and_repeat_frame_preserve_hidden_color(tmp_path):
|
||||
def test_mask_pack_reopen_extend_and_repeat_frame_preserve_hidden_color(tmp_path):
|
||||
from anyimage.common.selection import SelectionMask
|
||||
from anyimage.operators.mask_tool import apply_alpha_mask
|
||||
from anyimage.operators import frame_tool as frame
|
||||
from anyimage.operators.frame_tool.projection import frame_source_projection
|
||||
from anyimage.operators.frame_tool.compositing import composite_frame_pixels
|
||||
|
||||
source = bpy.data.images.new("Roundtrip source", width=2, height=2, alpha=True)
|
||||
rgba = np.tile(np.array([0.2, 0.4, 0.8, 1], dtype=np.float32), (2, 2, 1))
|
||||
@@ -208,15 +188,15 @@ def test_mask_pack_reopen_add_and_repeat_frame_preserve_hidden_color(tmp_path):
|
||||
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))
|
||||
added = images.create_image_edit_result(result, apply_alpha_mask(restored, full, "EXTEND"), (2, 2))
|
||||
bpy.data.images.remove(result)
|
||||
result = added
|
||||
for _ in range(2):
|
||||
source = frame.frame_source_projection(np.eye(4), np.eye(4), np.eye(4), (-1, 1, -1, 1), (2, 2), perspective=False)
|
||||
source = frame_source_projection(np.eye(4), np.eye(4), np.eye(4), (-1, 1, -1, 1), (2, 2), perspective=False)
|
||||
rgba_read = images.image_rgba(result)
|
||||
source.update(rgba=rgba_read, pixels=images.premultiplied_rgba(images.image_pixels(result), (2, 2)),
|
||||
image_size=(2, 2), active=True, name=result.name)
|
||||
pixels = frame.composite_frame_pixels((source,), ((0, 2), (2, 2), (2, 0), (0, 0)), (2, 2))
|
||||
pixels = composite_frame_pixels((source,), ((0, 2), (2, 2), (2, 0), (0, 0)), (2, 2))
|
||||
next_result = images.create_image_edit_result(result, pixels, (2, 2))
|
||||
bpy.data.images.remove(result)
|
||||
result = next_result
|
||||
@@ -225,3 +205,19 @@ def test_mask_pack_reopen_add_and_repeat_frame_preserve_hidden_color(tmp_path):
|
||||
np.testing.assert_allclose(images.image_rgba(result), rgba, atol=1 / 255)
|
||||
finally:
|
||||
bpy.data.images.remove(result)
|
||||
|
||||
|
||||
def test_job_loader_reads_only_the_returned_file(tmp_path):
|
||||
Image.new("RGBA", (2, 2), (255, 0, 0, 255)).save(tmp_path / "foreground.png")
|
||||
Image.new("RGBA", (4, 4), (0, 0, 255, 255)).save(tmp_path / "upscale.png")
|
||||
original = bpy.data.images.new("Source", width=1, height=1, alpha=True)
|
||||
result = images.load_image_edit_result(original, tmp_path / "upscale.png")
|
||||
try:
|
||||
assert tuple(result.size) == (4, 4)
|
||||
assert result.source == "FILE"
|
||||
assert result.packed_file is not None
|
||||
with pytest.raises(RuntimeError, match="Color result image does not exist"):
|
||||
images.load_image_edit_result(original, tmp_path / "missing.png")
|
||||
finally:
|
||||
bpy.data.images.remove(result)
|
||||
bpy.data.images.remove(original)
|
||||
|
||||
@@ -171,8 +171,10 @@ def test_hdr_analysis_preview_is_separate_from_full_precision_color(source):
|
||||
assert not preview.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha_mode", ["STRAIGHT", "PREMUL", "CHANNEL_PACKED", "NONE"])
|
||||
@pytest.mark.parametrize("source_kind", ["generated", "file", "dirty", "packed"])
|
||||
@pytest.mark.parametrize("alpha_mode,source_kind", [
|
||||
("STRAIGHT", "generated"), ("PREMUL", "file"),
|
||||
("CHANNEL_PACKED", "dirty"), ("NONE", "packed"),
|
||||
])
|
||||
def test_float_color_conversion_preserves_rendered_color_and_opacity(source, tmp_path, alpha_mode, source_kind):
|
||||
from anyimage.common.hdr_image import save_hdr_result
|
||||
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
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
|
||||
25
tests/anyimage/common/test_depth_metadata.py
Normal file
25
tests/anyimage/common/test_depth_metadata.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
|
||||
from anyimage.common.depth import load_depth_metadata
|
||||
|
||||
|
||||
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
|
||||
@@ -33,9 +33,12 @@ def edit_result(source):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha_mode", ["STRAIGHT", "PREMUL", "CHANNEL_PACKED", "NONE"])
|
||||
@pytest.mark.parametrize("shared", [False, True])
|
||||
@pytest.mark.parametrize("entry", ["pixels", "image.", "upscale."])
|
||||
@pytest.mark.parametrize("alpha_mode,shared,entry", [
|
||||
("STRAIGHT", False, "pixels"),
|
||||
("PREMUL", True, "foreground.png"),
|
||||
("CHANNEL_PACKED", False, "upscale.png"),
|
||||
("NONE", True, "pixels"),
|
||||
])
|
||||
def test_image_edit_replacement_preserves_alpha_mode(source, tmp_path, alpha_mode, shared, entry):
|
||||
original = source.data
|
||||
original.alpha_mode = alpha_mode
|
||||
@@ -44,8 +47,8 @@ def test_image_edit_replacement_preserves_alpha_mode(source, tmp_path, alpha_mod
|
||||
if entry == "pixels":
|
||||
result = edit_result(source)
|
||||
else:
|
||||
Image.new("RGBA", (4, 3), (100, 150, 200, 128)).save(tmp_path / f"{entry}0001.png")
|
||||
result = images.load_image_edit_result(original, tmp_path, frame_prefix=entry)
|
||||
Image.new("RGBA", (4, 3), (100, 150, 200, 128)).save(tmp_path / entry)
|
||||
result = images.load_image_edit_result(original, tmp_path / entry)
|
||||
assert result.alpha_mode == alpha_mode
|
||||
images.replace_empty_image(source, result)
|
||||
assert source.data.alpha_mode == alpha_mode
|
||||
@@ -131,45 +134,6 @@ def test_frame_sharing_uses_surviving_objects(source, outside):
|
||||
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)
|
||||
@@ -193,47 +157,6 @@ def test_failed_content_write_restores_source(source):
|
||||
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.data, 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)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from tests.anyimage.common.support import load_common_modules
|
||||
|
||||
@@ -21,165 +19,6 @@ class ImageDataTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
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_extract_frame_count_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.extract_frame_range(source_object.data, getattr(source_object, "image_user", None))[1], 24)
|
||||
|
||||
|
||||
def test_extract_frame_count_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.extract_frame_range(source_object.data, getattr(source_object, "image_user", None))[1], 97)
|
||||
|
||||
|
||||
def test_extract_frame_count_falls_back_to_video_total(self):
|
||||
source_object = SimpleNamespace(
|
||||
data=SimpleNamespace(source="MOVIE", frame_duration=97),
|
||||
)
|
||||
|
||||
self.assertEqual(self.modules.image.extract_frame_range(source_object.data, getattr(source_object, "image_user", None))[1], 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.data, source_object.image_user),
|
||||
(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.data, source_object.image_user),
|
||||
(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.data, source_object.image_user),
|
||||
(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.data, source_object.image_user),
|
||||
(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.data, source_object.image_user),
|
||||
(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(
|
||||
@@ -208,84 +47,6 @@ class ImageDataTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
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_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",
|
||||
@@ -315,8 +76,6 @@ class ImageDataTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def test_image_empty_bounds_keep_image_aspect_and_empty_offset(self):
|
||||
source = SimpleNamespace(
|
||||
data=SimpleNamespace(size=(800, 400)),
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
|
||||
@@ -18,21 +18,19 @@ def test_target_uses_edit_tree_and_survives_renaming(texture, tmp_path):
|
||||
texture.id_data.nodes.active = other
|
||||
context.space_data.edit_tree = None
|
||||
write_result(tmp_path)
|
||||
target.apply(tmp_path)
|
||||
target.apply(tmp_path / "foreground.png")
|
||||
assert tuple(texture.image.size) == (8, 6)
|
||||
assert other.image is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("color_space,alpha_mode", [
|
||||
("sRGB", "PREMUL"),
|
||||
("AgX Base sRGB", "STRAIGHT"),
|
||||
("Filmic sRGB", "STRAIGHT"),
|
||||
("sRGB", "STRAIGHT"),
|
||||
("AgX Base sRGB", "PREMUL"),
|
||||
@pytest.mark.parametrize("color_space,alpha_mode,filename,shared", [
|
||||
("sRGB", "PREMUL", "foreground.png", True),
|
||||
("AgX Base sRGB", "STRAIGHT", "upscale.png", False),
|
||||
("Filmic sRGB", "STRAIGHT", "foreground.png", False),
|
||||
("sRGB", "STRAIGHT", "upscale.png", True),
|
||||
("AgX Base sRGB", "PREMUL", "foreground.png", True),
|
||||
])
|
||||
@pytest.mark.parametrize("prefix", ["image.", "upscale."])
|
||||
@pytest.mark.parametrize("shared", [False, True])
|
||||
def test_image_actions_preserve_source_color_space_and_alpha(texture, tmp_path, color_space, alpha_mode, prefix, shared):
|
||||
def test_image_actions_preserve_source_color_space_and_alpha(texture, tmp_path, color_space, alpha_mode, filename, shared):
|
||||
texture.image.colorspace_settings.name = color_space
|
||||
texture.image.alpha_mode = alpha_mode
|
||||
original = texture.image
|
||||
@@ -41,8 +39,8 @@ def test_image_actions_preserve_source_color_space_and_alpha(texture, tmp_path,
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
other.image = original
|
||||
target = targets.ImageEditTarget.capture(texture_context(texture))
|
||||
write_result(tmp_path, prefix)
|
||||
target.apply(tmp_path, frame_prefix=prefix)
|
||||
write_result(tmp_path, filename)
|
||||
target.apply(tmp_path / filename)
|
||||
result = texture.image
|
||||
assert result.colorspace_settings.name == color_space
|
||||
assert result.alpha_mode == alpha_mode
|
||||
@@ -102,7 +100,7 @@ def test_target_rejects_changed_identity_before_loading_result(texture, tmp_path
|
||||
bpy.data.images.remove(texture.image)
|
||||
count = len(bpy.data.images)
|
||||
with pytest.raises(RuntimeError, match="no longer available"):
|
||||
target.apply(tmp_path)
|
||||
target.apply(tmp_path / "foreground.png")
|
||||
assert len(bpy.data.images) == count
|
||||
|
||||
|
||||
@@ -137,7 +135,7 @@ def test_texture_commit_preserves_structure_and_isolates_users(texture, tmp_path
|
||||
shared = reference in {"same_tree", "other_material", "empty", "brush"}
|
||||
links = [(link.from_socket, link.to_socket) for link in texture.id_data.links]
|
||||
write_result(tmp_path)
|
||||
target.apply(tmp_path)
|
||||
target.apply(tmp_path / "foreground.png")
|
||||
assert (texture.image == original) == (not shared)
|
||||
assert tuple(texture.image.size) == (8, 6)
|
||||
assert texture.image.colorspace_settings.name == "Non-Color"
|
||||
@@ -163,63 +161,46 @@ def test_texture_commit_preserves_structure_and_isolates_users(texture, tmp_path
|
||||
assert len(reopened.id_data.links) == len(links)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shared", [False, True])
|
||||
def test_animated_texture_preserves_playback_and_reopens(texture, tmp_path, shared):
|
||||
write_result(tmp_path, count=4)
|
||||
texture.image.source = "SEQUENCE"
|
||||
texture.image.filepath_raw = str(tmp_path / "image.0001.png")
|
||||
texture.image_user.frame_start = 9
|
||||
texture.image_user.frame_offset = 1
|
||||
texture.image_user.frame_duration = 2
|
||||
texture.image_user.use_cyclic = True
|
||||
context = texture_context(texture)
|
||||
target = targets.ImageEditTarget.capture(context)
|
||||
assert (target.start_frame, target.frame_count) == (2, 2)
|
||||
if shared:
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
other.image = texture.image
|
||||
with patch.object(targets, "prepare_image_input", return_value=(tmp_path, False)):
|
||||
target.prepare(context)
|
||||
assert context.scene.anyimage_settings.video_frames == 2
|
||||
target.apply(tmp_path)
|
||||
assert images.image_user_settings(texture) == (9, 0, 4, True)
|
||||
assert texture.image_user.use_auto_refresh
|
||||
assert texture.image.source == "SEQUENCE"
|
||||
path = str(tmp_path / "animated.blend")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=path)
|
||||
bpy.ops.wm.open_mainfile(filepath=path)
|
||||
reopened = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"]
|
||||
assert images.image_user_settings(reopened) == (9, 0, 4, True)
|
||||
assert len(images.collect_sequence_frames(reopened.image)) == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["MOVIE", "SEQUENCE"])
|
||||
def test_target_rejects_animated_image(texture, source):
|
||||
texture.image.source = source
|
||||
with pytest.raises(RuntimeError, match="Only still images"):
|
||||
targets.ImageEditTarget.capture(texture_context(texture))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shared", [False, True])
|
||||
def test_texture_failed_commit_restores_dirty_pixels_and_playback(texture, tmp_path, shared):
|
||||
def test_failed_texture_assignment_restores_source(texture, tmp_path, shared):
|
||||
original = texture.image
|
||||
if shared:
|
||||
other = texture.id_data.nodes.new("ShaderNodeTexImage")
|
||||
other.image = original
|
||||
original.pixels.foreach_set(np.tile([0.1, 0.3, 0.7, 0.5], 12).astype(np.float32))
|
||||
packed = bytes(original.packed_file.data)
|
||||
pixels = images.image_pixels(original)
|
||||
timing = images.image_user_settings(texture)
|
||||
write_result(tmp_path, count=2)
|
||||
result = bpy.data.images.load(str(tmp_path / "image.0001.png"))
|
||||
result.source = "SEQUENCE"
|
||||
result = images.load_image_edit_result(original, write_result(tmp_path))
|
||||
count = len(bpy.data.images) - 1
|
||||
|
||||
def fail_configuration(user, *args, **kwargs):
|
||||
user.frame_start = 80
|
||||
user.use_auto_refresh = True
|
||||
raise RuntimeError("Playback failed")
|
||||
class FailingTarget:
|
||||
failed = False
|
||||
|
||||
with patch.object(targets, "configure_image_user", side_effect=fail_configuration):
|
||||
with pytest.raises(RuntimeError, match="Playback failed"):
|
||||
targets.replace_texture_image(texture, result)
|
||||
def __getattr__(self, name):
|
||||
return getattr(texture, name)
|
||||
|
||||
@property
|
||||
def image(self):
|
||||
return texture.image
|
||||
|
||||
@image.setter
|
||||
def image(self, value):
|
||||
texture.image = value
|
||||
if not self.failed:
|
||||
self.failed = True
|
||||
raise RuntimeError("Assignment failed")
|
||||
|
||||
with patch.object(targets, "has_other_image_user", return_value=shared):
|
||||
with pytest.raises(RuntimeError, match="Assignment failed"):
|
||||
targets.replace_texture_image(FailingTarget(), result)
|
||||
assert texture.image == original
|
||||
assert tuple(original.size) == (4, 3)
|
||||
assert bytes(original.packed_file.data) == packed
|
||||
np.testing.assert_array_equal(images.image_pixels(original), pixels)
|
||||
assert images.image_user_settings(texture) == timing
|
||||
assert not texture.image_user.use_auto_refresh
|
||||
assert len(bpy.data.images) == count
|
||||
|
||||
@@ -7,20 +7,6 @@ from tests.support.blender import BlenderTestCase
|
||||
|
||||
|
||||
class InputPreparationTest(BlenderTestCase):
|
||||
def test_sequence_format_is_checked_before_copy(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "frame.0001.exr"
|
||||
path.touch()
|
||||
image = SimpleNamespace(source="SEQUENCE")
|
||||
with (
|
||||
patch.object(self.image_data, "image_path", return_value=path),
|
||||
patch.object(self.image_data, "image_is_packed", return_value=False),
|
||||
patch.object(self.image_data.tempfile, "mkdtemp") as create,
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "Unsupported input format: .exr"):
|
||||
self.image_data.prepare_image_input(image, None)
|
||||
create.assert_not_called()
|
||||
self.assertTrue(path.exists())
|
||||
|
||||
def test_packed_export_failure_removes_temporary_directory(self):
|
||||
for failure in (OSError("disk full"), None):
|
||||
@@ -34,21 +20,11 @@ class InputPreparationTest(BlenderTestCase):
|
||||
self.assertEqual(image.file_format, "OPEN_EXR")
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_sequence_copy_failure_preserves_originals(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / "frame.0001.png"
|
||||
source.write_bytes(b"source")
|
||||
output = root / "prepared"
|
||||
output.mkdir()
|
||||
with (
|
||||
patch.object(self.image_data, "image_path", return_value=source),
|
||||
patch.object(self.image_data, "image_is_packed", return_value=False),
|
||||
patch.object(self.image_data, "collect_sequence_frames", return_value=[source]),
|
||||
patch.object(self.image_data.tempfile, "mkdtemp", return_value=str(output)),
|
||||
patch.object(self.image_data.shutil, "copy2", side_effect=OSError("disk full")),
|
||||
):
|
||||
with self.assertRaises(OSError):
|
||||
self.image_data.prepare_image_input(SimpleNamespace(source="SEQUENCE"), None)
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(source.read_bytes(), b"source")
|
||||
|
||||
def test_animated_inputs_are_rejected_before_export(self):
|
||||
for source in ("MOVIE", "SEQUENCE"):
|
||||
with self.subTest(source=source), patch.object(self.image_data, "save_packed_image") as export:
|
||||
image = SimpleNamespace(source=source, packed_file=object())
|
||||
with self.assertRaisesRegex(RuntimeError, "Only still images"):
|
||||
self.image_data.prepare_image_input(image, None)
|
||||
export.assert_not_called()
|
||||
|
||||
@@ -19,7 +19,6 @@ def test_material_alpha_strength_follows_preference(image_layer, monkeypatch, en
|
||||
assert shader.node_tree.name == "O Shadeless"
|
||||
assert shader.inputs["Color"].links[0].from_node.type == "TEX_IMAGE"
|
||||
assert shader.inputs["Alpha"].links[0].from_socket.name == "Alpha"
|
||||
assert not any(node.type in {"MIX_SHADER", "BSDF_TRANSPARENT"} for node in result.node_tree.nodes)
|
||||
assert len([node for node in result.node_tree.nodes if node.type == "GROUP"]) == 1
|
||||
return
|
||||
layer = next(node for node in result.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer)
|
||||
|
||||
@@ -29,36 +29,6 @@ class MaterialSettingsTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -35,18 +35,6 @@ 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):
|
||||
@@ -85,11 +73,11 @@ def test_material_pixels_survive_pack_and_library_reload(color_data, tmp_path, e
|
||||
source.pixels.foreach_set((expected.astype(np.float32) / 255).ravel())
|
||||
else:
|
||||
source = bpy.data.images.new("Material pixels", width=2, height=1, alpha=True, float_buffer=kind == "float")
|
||||
source.alpha_mode = "PREMUL"
|
||||
source.pixels.foreach_set((expected.astype(np.float32) / 255).ravel())
|
||||
if kind == "packed":
|
||||
source.pack()
|
||||
source.reload()
|
||||
images.configure_color_alpha(source)
|
||||
before = images.image_pixels(source)
|
||||
owner = bpy.data.objects.new("Color source owner", None)
|
||||
owner.empty_display_type = "IMAGE"
|
||||
@@ -175,51 +163,13 @@ def test_material_configures_the_image_selected_by_its_caller(color_data, entry,
|
||||
def test_edit_results_preserve_source_alpha_with_adaptation_enabled(color_data, tmp_path):
|
||||
source = bpy.data.images.new("Editing source", width=1, height=1, alpha=True)
|
||||
rgba = np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32)
|
||||
Image.fromarray(np.array([[[51, 102, 204, 64]]], dtype=np.uint8)).save(tmp_path / "image.0001.png")
|
||||
Image.fromarray(np.array([[[51, 102, 204, 64]]], dtype=np.uint8)).save(tmp_path / "foreground.png")
|
||||
with patch.object(preferences, "addon_preferences", return_value=SimpleNamespace(adapt_material_to_view_transform=True)):
|
||||
edited = images.create_image_edit_result(source, rgba, (1, 1))
|
||||
loaded = images.load_image_edit_result(source, tmp_path)
|
||||
loaded = images.load_image_edit_result(source, tmp_path / "foreground.png")
|
||||
for result in (edited, loaded):
|
||||
assert result.colorspace_settings.name == "sRGB"
|
||||
assert result.alpha_mode == source.alpha_mode
|
||||
np.testing.assert_allclose(images.image_pixels(result), rgba, atol=1 / 255)
|
||||
assert source.colorspace_settings.name == "sRGB"
|
||||
assert source.alpha_mode == "STRAIGHT"
|
||||
|
||||
|
||||
@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"
|
||||
|
||||
@@ -9,8 +9,8 @@ class ModifierInputsTest(unittest.TestCase):
|
||||
|
||||
def test_runtime_modifier_enum_input_uses_string_identifier(self):
|
||||
enum_items = (
|
||||
SimpleNamespace(identifier="SURFACE"),
|
||||
SimpleNamespace(identifier="BALLOON"),
|
||||
SimpleNamespace(identifier="Balloon"),
|
||||
SimpleNamespace(identifier="Shell"),
|
||||
)
|
||||
value_property = SimpleNamespace(type="ENUM", enum_items=enum_items)
|
||||
input_slot = SimpleNamespace(
|
||||
@@ -19,13 +19,13 @@ class ModifierInputsTest(unittest.TestCase):
|
||||
)
|
||||
modifier = SimpleNamespace(
|
||||
properties=SimpleNamespace(
|
||||
inputs=SimpleNamespace(Shape=input_slot),
|
||||
inputs=SimpleNamespace(Mode=input_slot),
|
||||
)
|
||||
)
|
||||
|
||||
self.modules.object.set_modifier_input(modifier, "Shape", 1)
|
||||
self.modules.object.set_modifier_input(modifier, "Mode", 1)
|
||||
|
||||
self.assertEqual(input_slot.value, "BALLOON")
|
||||
self.assertEqual(input_slot.value, "Shell")
|
||||
|
||||
|
||||
def test_modifier_inputs_support_rna_and_dictionary_values(self):
|
||||
@@ -44,9 +44,9 @@ class ModifierInputsTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(input_slot.value, 2.0)
|
||||
|
||||
class LegacyModifier(dict):
|
||||
class DictionaryModifier(dict):
|
||||
properties = None
|
||||
|
||||
legacy_modifier = LegacyModifier()
|
||||
self.modules.object.set_modifier_input(legacy_modifier, "Shape", 1)
|
||||
self.assertEqual(legacy_modifier["Shape"], 1)
|
||||
dictionary_modifier = DictionaryModifier()
|
||||
self.modules.object.set_modifier_input(dictionary_modifier, "Mode", 1)
|
||||
self.assertEqual(dictionary_modifier["Mode"], 1)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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",
|
||||
)
|
||||
Reference in New Issue
Block a user