- Introduced a new `Boundary Padding` preference in the Cutout Tool with a default of 2 px and a configurable range of 1-4 px. - Updated the Cutout client to establish inward mapping from the final Mask, sampling in a fixed direction and processing independent material RGBA and Depth. - Enhanced the handling of RGBA and Depth during cutout operations, ensuring proper mapping and preservation of source data. - Added tests to validate the new boundary padding functionality and its integration with existing cutout processes. feat(geometry): remove overall mesh smoothing from geometry nodes - Removed the `Smooth` input from several geometry node groups to eliminate unnecessary overall mesh smoothing. - Retained `Smooth Weight` for depth nodes while removing it from Cutout and Relief Plane nodes. - Ensured that boundary, normal, and fill smoothing behaviors remain intact and consistent. - Updated tests and documentation to reflect the changes in node interfaces and behaviors. feat(normals): scale tangent normals with depth - Reused the existing `o_normal_reduction` attribute to adjust Tangent Normal strength based on the geometry's `Depth Scale`. - Ensured that Tangent Normal strength is consistent with the geometry depth, preserving full strength for materials that do not provide the attribute. - Updated tests to verify the correct behavior of normal strength adjustments in both Tangent and Object Space.
227 lines
9.8 KiB
Python
227 lines
9.8 KiB
Python
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 tests.support.color_image import 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)
|
|
if floating:
|
|
original.alpha_mode = "PREMUL"
|
|
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)
|
|
cropped = color_image(source.data, (0, 0, 2, 2), expected)
|
|
edited = images.create_image_edit_result(original, rgba.ravel(), (2, 2))
|
|
try:
|
|
assert original.alpha_mode == ("PREMUL" if floating else "STRAIGHT")
|
|
np.testing.assert_allclose(images.image_rgba(original), expected)
|
|
assert edited.alpha_mode == original.alpha_mode
|
|
for image in (cropped,):
|
|
assert image.alpha_mode == original.alpha_mode
|
|
np.testing.assert_allclose(images.image_rgba(image), expected, atol=1 / 255)
|
|
finally:
|
|
for image in (cropped, edited, original):
|
|
bpy.data.images.remove(image)
|
|
|
|
|
|
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 / "foreground.png")
|
|
original = bpy.data.images.new("Job source", width=1, height=1, alpha=True)
|
|
result = images.load_image_edit_result(original, tmp_path / "foreground.png")
|
|
try:
|
|
assert result.alpha_mode == "STRAIGHT"
|
|
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_business_pixels_ignore_premul_read_interpretation():
|
|
original = bpy.data.images.new("Premul read source", width=1, height=1, alpha=True)
|
|
original.alpha_mode = "PREMUL"
|
|
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)
|
|
original.alpha_mode = "PREMUL"
|
|
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,floating,dirty", [
|
|
(False, False, False),
|
|
(True, False, True),
|
|
(False, True, True),
|
|
(True, True, False),
|
|
])
|
|
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_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.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))
|
|
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, "EXTEND"), (2, 2))
|
|
bpy.data.images.remove(result)
|
|
result = added
|
|
for _ in range(2):
|
|
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 = 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 == "STRAIGHT" 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)
|
|
|
|
|
|
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)
|