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:
@@ -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:
|
||||
|
||||
233
tests/anyimage/common/test_color_image.py
Normal file
233
tests/anyimage/common/test_color_image.py
Normal 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)
|
||||
189
tests/anyimage/common/test_hdr_image.py
Normal file
189
tests/anyimage/common/test_hdr_image.py
Normal 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)
|
||||
80
tests/anyimage/common/test_hdr_rgb_render.py
Normal file
80
tests/anyimage/common/test_hdr_rgb_render.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
54
tests/anyimage/common/test_input_preparation.py
Normal file
54
tests/anyimage/common/test_input_preparation.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user