Add new tests and functionality for image processing and geometry handling

- Introduced color_image.py for preparing standalone color images for tests.
- Added hdr_image.py to create HDR textures for testing.
- Implemented test_image_depth_panorama.py to evaluate panorama topology, UVs, and depth discontinuities.
- Created test_shadeless.py to validate the opacity and HDR color endpoints of the Shadeless group.
- Developed moge3_export.py for exporting dynamic MoGe-3 backbone and ONNX graphs.
- Added material.py to inherit material slots onto generated geometry.
- Built image_depth_panorama.py to construct a quad panorama with depth separation and sampling.
This commit is contained in:
2026-09-10 14:42:17 +08:00
parent 27ca25224a
commit 489003c2cc
136 changed files with 5758 additions and 1083 deletions

View File

@@ -7,41 +7,39 @@ 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
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)
copied = create_color_image(source)
cropped = create_cropped_color_image(source, (0, 0, 2, 2), expected)
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 == "STRAIGHT"
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 (copied, cropped):
assert image.alpha_mode == "PREMUL"
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 (copied, cropped, edited, original):
for image in (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_loader_uses_edit_or_material_alpha_policy(tmp_path, loader):
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")
original = bpy.data.images.new("Job source", width=1, height=1, alpha=True)
source = original if loader == images.load_image_edit_result else SimpleNamespace(data=original)
result = loader(source, tmp_path)
result = images.load_image_edit_result(original, tmp_path)
try:
assert result.alpha_mode == ("STRAIGHT" if loader == images.load_image_edit_result else "PREMUL")
assert result.alpha_mode == "STRAIGHT"
assert result.packed_file is not None
assert original.alpha_mode == "STRAIGHT"
finally:

View File

@@ -0,0 +1,233 @@
"""Verify isolated static color artifacts and source fidelity."""
from pathlib import Path
from types import SimpleNamespace
import bpy
import numpy as np
import pytest
from PIL import Image
from anyimage.common import color_image as colors, image as images, material
@pytest.fixture
def source():
bpy.ops.wm.read_factory_settings(use_empty=True)
source = bpy.data.images.new("Source.png", width=4, height=2, alpha=True)
values = np.arange(32, dtype=np.float32).reshape(2, 4, 4) / 32
values[0, 0, 3] = 0
source.pixels.foreach_set(values.ravel())
yield source
bpy.ops.wm.read_factory_settings(use_empty=True)
@pytest.mark.parametrize("floating,alpha", [(False, "STRAIGHT"), (False, "PREMUL"), (True, "STRAIGHT"), (True, "PREMUL"), (True, "CHANNEL_PACKED")])
@pytest.mark.parametrize("bounds", [None, (1, 0, 3, 2)])
def test_color_artifact_preserves_bounds_and_library_reload(source, tmp_path, monkeypatch, floating, alpha, bounds):
if floating:
source = bpy.data.images.new("Source.exr", width=4, height=2, alpha=True, float_buffer=True)
source.pixels.foreach_set(np.tile(np.array([4, 2, .5, .25, 8, 3, 1, 0], dtype=np.float32), 4))
source.alpha_mode = alpha
source["anyimage_clipboard_sha256"] = "original"
before = images.image_rgba(source)
state = (source.filepath_raw, source.colorspace_settings.name, source.alpha_mode)
path = colors.prepare_material_color_input(source, bounds)
color = colors.load_material_color_image(path)
try:
assert color != source and color.name.endswith("_color" + path.suffix)
assert "anyimage_clipboard_sha256" not in color
assert Path(color.filepath_raw) == path
expected = before if bounds is None else before[bounds[1]:bounds[3], bounds[0]:bounds[2]]
np.testing.assert_allclose(images.image_rgba(color), expected, atol=1e-6 if floating else 1/255)
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: True)
result = material.create_image_material(source, color)
assert next(n.image for n in result.node_tree.nodes if n.type == "TEX_IMAGE") == color
colors.cleanup_material_color_input(path)
assert not path.parent.exists()
library = tmp_path / "colors.blend"
bpy.data.libraries.write(str(library), {color})
with bpy.data.libraries.load(str(library)) as (saved, loaded):
loaded.images = saved.images
np.testing.assert_allclose(images.image_rgba(loaded.images[0]), expected, atol=1e-6 if floating else 1/255)
if floating:
assert color.colorspace_settings.name == "Linear Rec.709"
assert color.alpha_mode == ("PREMUL" if alpha == "STRAIGHT" else alpha)
layer = next(n for n in result.node_tree.nodes if n.type == "GROUP")
assert layer.inputs["Alpha Fix"].default_value == 0
assert (source.filepath_raw, source.colorspace_settings.name, source.alpha_mode) == state
np.testing.assert_array_equal(images.image_rgba(source), before)
finally:
colors.cleanup_material_color_input(path)
def test_original_path_never_matches_material_color(tmp_path, monkeypatch):
path = tmp_path / "original.png"
Image.new("RGBA", (2, 2), (100, 150, 200, 128)).save(path)
source = bpy.data.images.load(str(path), check_existing=False)
with colors.material_color_image(source) as color:
result = material.create_image_material(source, color)
color.pixels[0] = 1
bpy.data.images.remove(source)
imported = bpy.data.images.load(str(path), check_existing=True)
assert imported != color
assert imported.alpha_mode == "STRAIGHT"
assert images.image_pixels(imported)[0] == pytest.approx(100/255, abs=1/255)
bpy.data.materials.remove(result)
bpy.data.images.remove(imported)
@pytest.mark.parametrize("kind", ["MOVIE", "SEQUENCE"])
def test_static_input_rejects_single_frame_animation(kind):
with pytest.raises(ValueError, match="static images"):
colors.require_static_color_image(SimpleNamespace(source=kind, frame_duration=1))
@pytest.mark.parametrize("stage", ["export", "load", "material", "object"])
def test_failed_color_preparation_releases_owned_resources(source, tmp_path, monkeypatch, stage):
directory = tmp_path / "owned-color"
directory.mkdir()
monkeypatch.setattr(colors.tempfile, "mkdtemp", lambda **_: str(directory))
before = set(bpy.data.images)
original = images.image_pixels(source)
def fail(*_, **__):
raise RuntimeError("Color failure")
if stage == "export":
monkeypatch.setattr(colors, "_save_byte_color", fail)
elif stage == "load":
monkeypatch.setattr(colors, "load_material_color_image", fail)
elif stage == "material":
monkeypatch.setattr(material, "material_node_group", fail)
result = None
try:
with pytest.raises(RuntimeError, match="Color failure"):
with colors.material_color_image(source) as color:
result = material.create_image_material(source, color)
fail()
finally:
if result is not None:
bpy.data.materials.remove(result)
assert set(bpy.data.images) == before
assert not directory.exists()
np.testing.assert_array_equal(images.image_pixels(source), original)
def test_ai_color_uses_submitted_crop_after_source_changes(source):
from anyimage.operators.cutout_tool.operators import _generated_color_result
bounds = (1, 0, 3, 2)
expected = images.image_rgba(source)[:, 1:3].copy()
with colors.material_color_image(source, bounds=bounds) as direct:
path = colors.prepare_material_color_input(source, bounds)
try:
assert colors.material_analysis_input(path) == path
source.pixels.foreach_set(np.zeros(32, np.float32))
result, alpha, result_bounds = _generated_color_result(path, bounds)
try:
assert result_bounds == bounds and tuple(result.size) == (2, 2)
np.testing.assert_array_equal(images.image_rgba(result), images.image_rgba(direct))
np.testing.assert_allclose(images.image_rgba(result), expected, atol=1/255)
np.testing.assert_allclose(alpha, expected[..., 3], atol=1/255)
finally:
bpy.data.images.remove(result)
finally:
colors.cleanup_material_color_input(path)
@pytest.mark.parametrize("color_space", ["Linear Rec.709", "sRGB", "Non-Color"])
def test_float_straight_uses_exr_interpretation_without_rejecting_hidden_rgb(source, tmp_path, color_space):
source = bpy.data.images.new("Straight HDR", width=2, height=1, alpha=True, float_buffer=True)
source.colorspace_settings.name = color_space
rgba = np.array([4, 2, .5, .25, 8, 3, 1, 0], np.float32)
source.pixels.foreach_set(rgba)
with colors.material_color_image(source) as color:
assert color.alpha_mode == "PREMUL"
assert color.colorspace_settings.name == "Linear Rec.709"
library = tmp_path / "straight.blend"
bpy.data.libraries.write(str(library), {color})
with bpy.data.libraries.load(str(library)) as (saved, loaded):
loaded.images = saved.images
np.testing.assert_array_equal(loaded.images[0].pixels[:], rgba)
assert (source.colorspace_settings.name, source.alpha_mode) == (color_space, "STRAIGHT")
np.testing.assert_array_equal(source.pixels[:], rgba)
def test_hdr_analysis_preview_is_separate_from_full_precision_color(source):
source = bpy.data.images.new("HDR", width=2, height=1, alpha=True, float_buffer=True)
source.alpha_mode = "PREMUL"
source.pixels.foreach_set(np.array([4, 2, .5, .25, 8, 3, 1, 0], np.float32))
path = colors.prepare_material_color_input(source)
try:
preview = colors.material_analysis_input(path)
assert preview != path and preview.suffix == ".png" and path.suffix == ".exr"
with Image.open(preview) as image:
assert image.size == tuple(source.size)
color = colors.load_material_color_image(path)
try:
np.testing.assert_array_equal(images.image_rgba(color), images.image_rgba(source))
finally:
bpy.data.images.remove(color)
finally:
colors.cleanup_material_color_input(path)
assert not preview.exists()
@pytest.mark.parametrize("alpha_mode", ["STRAIGHT", "PREMUL", "CHANNEL_PACKED", "NONE"])
@pytest.mark.parametrize("source_kind", ["generated", "file", "dirty", "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
rgba = np.array([[[8, 3, 1, 0], [4, 2, .5, .25], [2, 1, .25, .5], [1, .5, .125, 1]]], np.float32)
if source_kind != "generated":
path = tmp_path / "source.exr"
save_hdr_result(rgba, path)
source = bpy.data.images.load(str(path), check_existing=False)
else:
source = bpy.data.images.new("Float Render", width=4, height=1, alpha=True, float_buffer=True)
source.pixels.foreach_set(rgba.ravel())
source.alpha_mode = alpha_mode
if source_kind == "dirty":
source.pixels.foreach_set(rgba.ravel())
elif source_kind == "packed":
source.pack()
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.device = "CPU"
scene.cycles.samples = 16
scene.cycles.use_denoising = False
scene.render.film_transparent = True
scene.render.resolution_x, scene.render.resolution_y = 64, 16
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "OPEN_EXR"
scene.render.image_settings.color_mode = "RGBA"
scene.render.image_settings.color_depth = "32"
bpy.ops.object.camera_add(location=(0, 0, 5))
scene.camera = bpy.context.object
scene.camera.data.type = "ORTHO"
scene.camera.data.ortho_scale = 4
bpy.ops.mesh.primitive_plane_add(size=2)
bpy.context.object.scale = (2, .5, 1)
result = bpy.data.materials.new("Float Interpretation")
result.use_nodes = True
nodes, links = result.node_tree.nodes, result.node_tree.links
nodes.clear()
texture = nodes.new("ShaderNodeTexImage")
texture.interpolation = "Closest"
emission = nodes.new("ShaderNodeEmission")
transparent = nodes.new("ShaderNodeBsdfTransparent")
mix = nodes.new("ShaderNodeMixShader")
output = nodes.new("ShaderNodeOutputMaterial")
links.new(texture.outputs["Color"], emission.inputs["Color"])
links.new(texture.outputs["Alpha"], mix.inputs[0])
links.new(transparent.outputs[0], mix.inputs[1])
links.new(emission.outputs[0], mix.inputs[2])
links.new(mix.outputs[0], output.inputs["Surface"])
bpy.context.object.data.materials.append(result)
with colors.material_color_image(source) as color:
rendered = []
for name, image in (("before", source), ("after", color)):
texture.image = image
scene.render.filepath = str(tmp_path / (name + ".exr"))
bpy.ops.render.render(write_still=True)
image = bpy.data.images.load(scene.render.filepath, check_existing=False)
rendered.append(images.image_rgba(image))
np.testing.assert_allclose(rendered[1], rendered[0], rtol=2e-5, atol=1e-6)

View File

@@ -0,0 +1,189 @@
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import numpy as np
import pytest
from PIL import Image
from anyimage.common.hdr_image import HdrBackgroundInput, recognition_rgba, save_hdr_result
from anyimage.common.image import cleanup_image_input, image_content_state, image_rgba, restore_image_content
from anyimage.common.image_target import ImageEditTarget
from anyimage.common import image_target as targets
from tests.support.image_texture import texture, texture_context
from tests.support.hdr_image import hdr_texture
@pytest.mark.parametrize("source_kind", ["generated", "file", "packed", "dirty"])
@pytest.mark.parametrize("owner_kind", ["texture", "empty"])
@pytest.mark.parametrize("shared", [False, True])
def test_hdr_commit_keeps_float_pixels_and_transaction(hdr_texture, tmp_path, source_kind, owner_kind, shared):
texture = hdr_texture
source = texture.image
rgba = image_rgba(source).copy()
if source_kind != "generated":
path = tmp_path / "source.exr"
save_hdr_result(rgba, path)
source = bpy.data.images.load(str(path), check_existing=False)
texture.image = source
if source_kind == "packed":
source.pack()
if source_kind == "dirty":
rgba[1, 1, 0] = 19
source.pixels.foreach_set(np.flipud(rgba).ravel())
source.use_fake_user = True
if owner_kind == "empty":
owner = bpy.data.objects.new("HDR Empty", None)
owner.empty_display_type = "IMAGE"
owner.data = source
bpy.context.scene.collection.objects.link(owner)
texture.image = None
context = SimpleNamespace(object=owner, space_data=None)
else:
owner = texture
context = texture_context(texture)
if shared:
other = texture.id_data.nodes.new("ShaderNodeTexImage")
other.image = source
target = ImageEditTarget.capture(context)
snapshot = HdrBackgroundInput.prepare(source)
original_state = image_content_state(source)
alpha = np.linspace(0.03, 0.91, 12, dtype=np.float32).reshape(3, 4)
alpha_path = tmp_path / "alpha.npy"
np.save(alpha_path, alpha)
try:
with Image.open(snapshot.path) as preview:
assert preview.size == (4, 3)
assert np.asarray(preview)[0, 0, :3].max() == 0
result = snapshot.apply(target, alpha_path)
expected = rgba.copy()
expected[..., 3] *= alpha
assert result.is_float and result.packed_file and result.alpha_mode == "CHANNEL_PACKED"
np.testing.assert_allclose(image_rgba(result), expected, atol=1e-7)
assert (result == source) == (not shared)
if shared:
np.testing.assert_array_equal(image_rgba(other.image), rgba)
else:
assert result.use_fake_user
# The encoded result restores the same ID and floats after a source-state restore.
result_state = image_content_state(result)
restore_image_content(result, original_state)
np.testing.assert_allclose(image_rgba(result), rgba, atol=1e-7)
restore_image_content(result, result_state)
np.testing.assert_allclose(image_rgba(result), expected, atol=1e-7)
blend_path = tmp_path / "roundtrip.blend"
result_name = result.name
bpy.ops.wm.save_as_mainfile(filepath=str(blend_path))
bpy.ops.wm.open_mainfile(filepath=str(blend_path))
restored = bpy.data.images[result_name]
assert restored.is_float and restored.packed_file
np.testing.assert_allclose(image_rgba(restored), expected, atol=1e-7)
finally:
cleanup_image_input(snapshot.path, True)
@pytest.mark.parametrize("invalid", ["shape", "dtype", "nan", "range", "corrupt", "source", "colorspace", "binding"])
def test_hdr_rejects_invalid_result_and_source_changes(hdr_texture, tmp_path, invalid):
target = ImageEditTarget.capture(texture_context(hdr_texture))
snapshot = HdrBackgroundInput.prepare(target.image)
alpha = np.ones((3, 4), np.float32)
if invalid == "shape": alpha = alpha[:1]
if invalid == "dtype": alpha = alpha.astype(np.float64)
if invalid == "nan": alpha[0, 0] = np.nan
if invalid == "range": alpha[0, 0] = 2
path = tmp_path / "alpha.npy"
np.save(path, alpha)
if invalid == "corrupt": path.write_bytes(b"broken")
if invalid == "source": target.image.pixels[4] = 20
if invalid == "colorspace": target.image.colorspace_settings.name = "ACEScg"
if invalid == "binding": hdr_texture.image = bpy.data.images.new("Other", width=1, height=1)
before = image_rgba(hdr_texture.image).copy()
count = len(bpy.data.images)
try:
with pytest.raises(RuntimeError):
snapshot.apply(target, path)
np.testing.assert_array_equal(image_rgba(hdr_texture.image), before)
assert len(bpy.data.images) == count
finally:
cleanup_image_input(snapshot.path, True)
def test_hdr_preview_is_fixed_and_preserves_source(hdr_texture):
image = hdr_texture.image
before = image_rgba(image).copy()
first = HdrBackgroundInput.prepare(image)
bpy.context.scene.view_settings.view_transform = "Standard"
bpy.context.scene.view_settings.exposure = 7
second = HdrBackgroundInput.prepare(image)
try:
with Image.open(first.path) as a, Image.open(second.path) as b:
np.testing.assert_array_equal(np.asarray(a), np.asarray(b))
np.testing.assert_array_equal(image_rgba(image), before)
np.testing.assert_array_equal(recognition_rgba(np.zeros((1, 1, 4), np.float32)), [[[0, 0, 0, 1]]])
finally:
cleanup_image_input(first.path, True)
cleanup_image_input(second.path, True)
def test_hdr_rejects_animation_before_export(hdr_texture):
hdr_texture.image.source = "SEQUENCE"
with pytest.raises(RuntimeError, match="still images"):
HdrBackgroundInput.prepare(hdr_texture.image)
def test_hdr_export_failure_releases_blender_resources(hdr_texture, tmp_path):
images, scenes = len(bpy.data.images), len(bpy.data.scenes)
blocked = tmp_path / "file"
blocked.write_bytes(b"not a directory")
with pytest.raises(RuntimeError):
save_hdr_result(image_rgba(hdr_texture.image), blocked / "output.exr")
assert (len(bpy.data.images), len(bpy.data.scenes)) == (images, scenes)
def test_straight_exr_only_changes_alpha(hdr_texture, tmp_path):
path = tmp_path / "straight.exr"
encoded = image_rgba(hdr_texture.image).copy()
save_hdr_result(encoded, path)
source = bpy.data.images.load(str(path), check_existing=False)
source.alpha_mode = "STRAIGHT"
hdr_texture.image = source
before = image_rgba(source).copy()
np.testing.assert_allclose(before[..., :3], encoded[..., :3] * encoded[..., 3:4], atol=1e-7)
snapshot = HdrBackgroundInput.prepare(source)
alpha_path = tmp_path / "alpha.npy"
np.save(alpha_path, np.full((3, 4), 0.3, np.float32))
try:
result = snapshot.apply(ImageEditTarget.capture(texture_context(hdr_texture)), alpha_path)
rgba = image_rgba(result)
np.testing.assert_array_equal(rgba[..., :3], before[..., :3])
np.testing.assert_allclose(rgba[..., 3], before[..., 3] * 0.3, atol=1e-7)
finally:
cleanup_image_input(snapshot.path, True)
def test_hdr_failed_commit_restores_unsaved_source(hdr_texture, tmp_path):
target = ImageEditTarget.capture(texture_context(hdr_texture))
snapshot = HdrBackgroundInput.prepare(target.image)
before = image_rgba(target.image).copy()
original_source = target.image.source
alpha_path = tmp_path / "alpha.npy"
np.save(alpha_path, np.full((3, 4), 0.3, np.float32))
calls = 0
def fail_first(image, state):
nonlocal calls
calls += 1
restore_image_content(image, state)
if calls == 1:
raise RuntimeError("Commit failed")
count = len(bpy.data.images)
try:
with patch.object(targets, "restore_image_content", side_effect=fail_first):
with pytest.raises(RuntimeError, match="Commit failed"):
snapshot.apply(target, alpha_path, prepare_undo=True)
np.testing.assert_array_equal(image_rgba(target.image), before)
assert target.image.source == original_source
assert len(bpy.data.images) == count
finally:
cleanup_image_input(snapshot.path, True)

View File

@@ -0,0 +1,80 @@
import bpy
import numpy as np
import pytest
from anyimage.common.hdr_image import HdrBackgroundInput
from anyimage.common.image import cleanup_image_input, image_rgba
from anyimage.common.image_target import ImageEditTarget
from tests.support.hdr_image import hdr_texture
from tests.support.image_texture import texture, texture_context
@pytest.mark.parametrize("use_alpha", [False, True])
def test_hdr_texture_rgb_remains_visible_independently_of_alpha(hdr_texture, tmp_path, use_alpha):
source = hdr_texture.image
source.pixels.foreach_set(np.tile([4, 2, 0.5, 1], 12).astype(np.float32))
alpha = np.tile([0, 0.25, 0.5, 1], (3, 1)).astype(np.float32)
path = tmp_path / "alpha.npy"
np.save(path, alpha)
snapshot = HdrBackgroundInput.prepare(source)
try:
result = snapshot.apply(ImageEditTarget.capture(texture_context(hdr_texture)), path)
finally:
cleanup_image_input(snapshot.path, True)
# A second removal must accept its own independent-channel result and keep RGB intact.
snapshot = HdrBackgroundInput.prepare(result)
try:
snapshot.apply(ImageEditTarget.capture(texture_context(hdr_texture)), path)
np.testing.assert_array_equal(image_rgba(result)[..., :3], np.tile([4, 2, 0.5], (3, 4, 1)))
finally:
cleanup_image_input(snapshot.path, True)
result.reload()
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.device = "CPU"
scene.cycles.samples = 32
scene.cycles.use_denoising = False
scene.render.film_transparent = True
scene.render.resolution_x, scene.render.resolution_y = 64, 16
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "OPEN_EXR"
scene.render.image_settings.color_mode = "RGBA"
scene.render.image_settings.color_depth = "32"
scene.render.filepath = str(tmp_path / "render.exr")
bpy.ops.object.camera_add(location=(0, 0, 5))
scene.camera = bpy.context.object
scene.camera.data.type = "ORTHO"
scene.camera.data.ortho_scale = 4
bpy.ops.mesh.primitive_plane_add(size=2)
bpy.context.object.scale = (2, 0.5, 1)
material = bpy.data.materials.new("HDR RGB validation")
material.use_nodes = True
nodes, links = material.node_tree.nodes, material.node_tree.links
nodes.clear()
image_node = nodes.new("ShaderNodeTexImage")
image_node.image = result
image_node.interpolation = "Closest"
emission = nodes.new("ShaderNodeEmission")
links.new(image_node.outputs["Color"], emission.inputs["Color"])
output = nodes.new("ShaderNodeOutputMaterial")
if use_alpha:
transparent = nodes.new("ShaderNodeBsdfTransparent")
mix = nodes.new("ShaderNodeMixShader")
links.new(image_node.outputs["Alpha"], mix.inputs[0])
links.new(transparent.outputs[0], mix.inputs[1])
links.new(emission.outputs[0], mix.inputs[2])
links.new(mix.outputs[0], output.inputs["Surface"])
else:
links.new(emission.outputs[0], output.inputs["Surface"])
bpy.context.object.data.materials.append(material)
bpy.ops.render.render(write_still=True)
rendered = bpy.data.images.load(scene.render.filepath, check_existing=False)
pixels = image_rgba(rendered)
for index, opacity in enumerate((0, 0.0625, 0.25, 1)):
patch = pixels[4:12, index * 16 + 4:index * 16 + 12]
expected_alpha = opacity if use_alpha else 1
np.testing.assert_allclose(patch[..., 3].mean(), expected_alpha, atol=0.035)
expected_rgb = patch[..., 3:4] * np.array([4, 2, 0.5])
np.testing.assert_allclose(patch[..., :3], expected_rgb, rtol=2e-5, atol=1e-6)

View File

@@ -217,13 +217,6 @@ class ImageDataTest(unittest.TestCase):
)
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):
@@ -322,20 +315,6 @@ class ImageDataTest(unittest.TestCase):
)
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):

View File

@@ -183,7 +183,7 @@ def test_loaded_normal_image_uses_normal_texture_name(tmp_path):
assert image.packed_file is not None
def test_converted_color_images_are_new_and_use_blender_duplicate_suffix():
def test_shared_conversion_images_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)
@@ -191,8 +191,11 @@ def test_converted_color_images_are_new_and_use_blender_duplicate_suffix():
source.data = image
bpy.context.collection.objects.link(source)
first = plane_object.create_color_image(source)
second = plane_object.create_color_image(source)
other = source.copy()
bpy.context.collection.objects.link(other)
from tests.support.color_image import color_image
first = color_image(source.data)
second = color_image(source.data)
assert first is not image
assert second is not image

View File

@@ -80,7 +80,7 @@ def test_invalid_node_context_does_not_fall_back_to_empty(texture, invalid):
targets.ImageEditTarget.capture(context)
@pytest.mark.parametrize("change", ["delete", "recreate", "rebind", "delete_material", "delete_image"])
@pytest.mark.parametrize("change", ["delete", "recreate", "rebind", "delete_material", "delete_image", "identity"])
def test_target_rejects_changed_identity_before_loading_result(texture, tmp_path, change):
target = targets.ImageEditTarget.capture(texture_context(texture))
tree = texture.id_data
@@ -95,6 +95,9 @@ def test_target_rejects_changed_identity_before_loading_result(texture, tmp_path
texture.image = bpy.data.images.new("Replacement", width=2, height=2)
elif change == "delete_material":
bpy.data.materials.remove(bpy.data.materials["Texture material"])
elif change == "identity":
# A reused RNA address must not make a replacement node the original target.
texture["anyimage_identity"] = "replacement"
else:
bpy.data.images.remove(texture.image)
count = len(bpy.data.images)

View File

@@ -0,0 +1,54 @@
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
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):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "export"
output.mkdir()
image = SimpleNamespace(file_format="OPEN_EXR", save=Mock(side_effect=failure))
with patch.object(self.image_data.tempfile, "mkdtemp", return_value=str(output)):
with self.assertRaises(RuntimeError):
self.image_data.save_packed_image(image, None)
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")

View File

@@ -14,11 +14,19 @@ def test_material_alpha_strength_follows_preference(image_layer, monkeypatch, en
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)
if shadeless:
shader = next(node for node in result.node_tree.nodes if node.type == "GROUP")
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)
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")
assert alpha_link.to_node.type == "BSDF_PRINCIPLED"
assert alpha_link.to_socket.name == "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)

View File

@@ -10,8 +10,7 @@ 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
from tests.support.color_image import color_image
@pytest.fixture
@@ -74,10 +73,7 @@ def test_color_space_and_alpha_follow_preference(color_data, enabled, view, spac
@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",
)),
"float",
])
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)
@@ -100,8 +96,7 @@ def test_material_pixels_survive_pack_and_library_reload(color_data, tmp_path, e
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"
assert result == source
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:
@@ -121,7 +116,7 @@ def test_material_pixels_survive_pack_and_library_reload(color_data, tmp_path, e
@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):
def test_material_configures_the_image_selected_by_its_caller(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)
@@ -144,10 +139,8 @@ def test_new_material_uses_current_preference_and_preserves_existing(color_data,
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))
if entry == "cutout":
color = color_image(owner.data, (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)
@@ -161,15 +154,15 @@ def test_new_material_uses_current_preference_and_preserves_existing(color_data,
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 == 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 original_color.colorspace_settings.name == "sRGB"
assert original_color.alpha_mode == "PREMUL"
assert not settings.adapt_material_to_view_transform
assert owner.data == source
assert source.colorspace_settings.name == "sRGB"
assert source.alpha_mode == "STRAIGHT"
assert source.alpha_mode == ("STRAIGHT" if entry == "cutout" else "PREMUL")
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():