Files
AnyImage/src/anyimage/common/image.py
nan 7e55af6722 Add comprehensive tests for edge depth extension, empty image edits, and material view adaptation
- Introduced `test_edge_depth.py` to validate the functionality of edge depth extension, ensuring core alpha preservation, proper depth handling, and edge padding behavior.
- Created `test_empty_image_edits.py` to verify the behavior of image edits in Blender, focusing on shared edits, preservation of image properties, and handling of failed operations.
- Developed `test_material_view_adaptation.py` to test material adaptation preferences, ensuring color space and alpha settings are correctly applied and preserved across various scenarios.
2026-09-06 21:43:47 +08:00

942 lines
32 KiB
Python

"""Shared Blender Image and Image Empty lifecycle helpers."""
import math
import re
import shutil
import tempfile
from pathlib import Path
import bpy
IMAGE_NAME_SUFFIXES = (
".jpeg",
".jpg",
".png",
".webp",
".tiff",
".tif",
".exr",
".hdr",
".bmp",
".tga",
)
MAX_PROJECTIVE_OUTPUT_DIMENSION = 8192
MAX_PROJECTIVE_OUTPUT_PIXELS = 16_777_216
PROJECTIVE_CHUNK_ROWS = 256
VIDEO_NAME_SUFFIXES = (
".mp4",
".mov",
".avi",
".mkv",
".webm",
".m4v",
".mts",
".mpg",
".mpeg",
)
def is_image_empty(candidate):
return bool(
candidate
and getattr(candidate, "type", None) == "EMPTY"
and getattr(candidate, "empty_display_type", None) == "IMAGE"
and getattr(candidate, "data", None) is not None
)
def require_image_empty(object_name):
"""Return a named Image Empty or raise when it is no longer available."""
source_object = bpy.data.objects.get(object_name)
if not is_image_empty(source_object):
raise RuntimeError("The source Image Empty is no longer available")
return source_object
def image_pixels(image):
"""Read straight-alpha business pixels without changing presentation mode."""
import numpy as np
reader = image
if (
getattr(image, "alpha_mode", None) == "PREMUL"
and not getattr(image, "is_dirty", False)
and not getattr(image, "is_float", False)
and getattr(image, "source", None) != "GENERATED"
):
reader = image.copy()
try:
if reader is not image:
reader.alpha_mode = "STRAIGHT"
pixels = np.empty(len(reader.pixels), dtype=np.float32)
reader.pixels.foreach_get(pixels)
finally:
if reader is not image:
bpy.data.images.remove(reader)
return pixels
def image_rgba(image):
"""Read one Blender Image into a top-down RGBA float32 array."""
import numpy as np
width, height = (int(value) for value in image.size)
if width <= 0 or height <= 0:
raise ValueError("The source image has no readable dimensions")
return np.flipud(image_pixels(image).reshape((height, width, 4)))
def homography_from_points(source, target):
"""Return a projective transform mapping four source points to target."""
import numpy as np
source = np.asarray(source, dtype=np.float64)
target = np.asarray(target, dtype=np.float64)
if source.shape != (4, 2) or target.shape != (4, 2):
raise ValueError("A homography requires four source and target points")
rows = []
values = []
for (x, y), (u, v) in zip(source, target):
rows.extend(
(
(x, y, 1.0, 0.0, 0.0, 0.0, -u * x, -u * y),
(0.0, 0.0, 0.0, x, y, 1.0, -v * x, -v * y),
)
)
values.extend((u, v))
matrix = np.asarray(rows, dtype=np.float64)
if np.linalg.cond(matrix) > 1e12:
raise ValueError("The projective transform is too degenerate")
coefficients = np.linalg.solve(matrix, np.asarray(values, dtype=np.float64))
return np.append(coefficients, 1.0).reshape((3, 3))
def transform_homography(points, matrix):
"""Transform 2D points by one homography."""
import numpy as np
points = np.asarray(points, dtype=np.float64)
if points.ndim < 2 or points.shape[-1] != 2:
raise ValueError("Projective points must contain X and Y")
flat = points.reshape((-1, 2))
homogeneous = np.column_stack((flat, np.ones(len(flat), dtype=np.float64)))
mapped = homogeneous @ np.asarray(matrix, dtype=np.float64).T
denominator = mapped[:, 2]
if np.any(np.abs(denominator) <= 1e-12):
raise ValueError("The projective transform reaches infinity")
mapped = mapped[:, :2] / denominator[:, None]
if not np.isfinite(mapped).all():
raise ValueError("The projective transform is not finite")
return mapped.reshape(points.shape)
def projective_output_size(
quad,
aspect_ratio,
max_dimension=MAX_PROJECTIVE_OUTPUT_DIMENSION,
max_pixels=MAX_PROJECTIVE_OUTPUT_PIXELS,
):
"""Choose a detail-preserving output size for a source-space quad."""
import numpy as np
quad = np.asarray(quad, dtype=np.float64)
if quad.shape != (4, 2) or not np.isfinite(quad).all():
raise ValueError("Projective output requires four valid corners")
if not math.isfinite(aspect_ratio) or aspect_ratio <= 0.0:
raise ValueError("Projective output aspect ratio must be greater than zero")
width_pixels = 0.5 * (
np.linalg.norm(quad[1] - quad[0]) + np.linalg.norm(quad[2] - quad[3])
)
height_pixels = 0.5 * (
np.linalg.norm(quad[2] - quad[1]) + np.linalg.norm(quad[3] - quad[0])
)
if min(width_pixels, height_pixels) <= 1e-8:
raise ValueError("Projective output corners are too close together")
height = (aspect_ratio * width_pixels + height_pixels) / (
aspect_ratio * aspect_ratio + 1.0
)
width = aspect_ratio * height
dimension_scale = float(max_dimension) / max(width, height)
pixel_scale = math.sqrt(float(max_pixels) / max(width * height, 1.0))
scale = min(1.0, dimension_scale, pixel_scale)
return max(2, int(round(width * scale))), max(2, int(round(height * scale)))
def premultiplied_rgba(source_pixels, source_size):
"""Return top-down premultiplied RGBA pixels."""
import numpy as np
width, height = (int(value) for value in source_size)
if min(width, height) <= 0:
raise ValueError("Image dimensions must be positive")
source = np.asarray(source_pixels, dtype=np.float32)
if source.size != width * height * 4:
raise ValueError("The image pixels do not match its dimensions")
source = np.flipud(source.reshape((height, width, 4))).copy()
source[:, :, :3] *= source[:, :, 3:4]
return source
def sample_rgba(source, source_points):
"""Bilinearly sample top-down RGBA at image-boundary coordinates."""
import numpy as np
source = np.asarray(source, dtype=np.float32)
points = np.asarray(source_points, dtype=np.float64)
if source.ndim != 3 or source.shape[2] != 4:
raise ValueError("The source must contain RGBA pixels")
if points.shape[-1] != 2:
raise ValueError("Sample points must contain X and Y")
height, width = source.shape[:2]
flat = points.reshape((-1, 2))
mapped_x = flat[:, 0]
mapped_y = flat[:, 1]
valid = (
np.isfinite(mapped_x)
& np.isfinite(mapped_y)
& (mapped_x >= 0.0)
& (mapped_x <= width)
& (mapped_y >= 0.0)
& (mapped_y <= height)
)
pixel_x = np.clip(mapped_x - 0.5, 0.0, width - 1.0)
pixel_y = np.clip(mapped_y - 0.5, 0.0, height - 1.0)
x0 = np.floor(pixel_x).astype(np.int64)
y0 = np.floor(pixel_y).astype(np.int64)
x1 = np.minimum(x0 + 1, width - 1)
y1 = np.minimum(y0 + 1, height - 1)
fx = (pixel_x - x0)[:, None]
fy = (pixel_y - y0)[:, None]
sampled = (
source[y0, x0] * (1.0 - fx) * (1.0 - fy)
+ source[y0, x1] * fx * (1.0 - fy)
+ source[y1, x0] * (1.0 - fx) * fy
+ source[y1, x1] * fx * fy
)
sampled[~valid] = 0.0
return sampled.reshape((*points.shape[:-1], 4))
def straight_rgba(premultiplied):
"""Convert premultiplied RGBA and bleed edge RGB into transparent pixels."""
import numpy as np
rgba = np.asarray(premultiplied, dtype=np.float32).copy()
alpha = rgba[..., 3]
visible = alpha > 1e-8
rgb = rgba[..., :3]
rgb[visible] /= alpha[visible, None]
rgba[~visible] = 0.0
if rgba.ndim == 3:
unfilled = ~visible
def fill_edge(target, source):
candidates = unfilled[target] & visible[source]
target_rgb = rgb[target]
target_rgb[candidates] = rgb[source][candidates]
unfilled[target][candidates] = False
fill_edge((slice(1, None), slice(None)), (slice(None, -1), slice(None)))
fill_edge((slice(None, -1), slice(None)), (slice(1, None), slice(None)))
fill_edge((slice(None), slice(1, None)), (slice(None), slice(None, -1)))
fill_edge((slice(None), slice(None, -1)), (slice(None), slice(1, None)))
fill_edge((slice(1, None), slice(1, None)), (slice(None, -1), slice(None, -1)))
fill_edge((slice(1, None), slice(None, -1)), (slice(None, -1), slice(1, None)))
fill_edge((slice(None, -1), slice(1, None)), (slice(1, None), slice(None, -1)))
fill_edge((slice(None, -1), slice(None, -1)), (slice(1, None), slice(1, None)))
return rgba
def warp_projective_pixels(source_pixels, source_size, quad, output_size):
"""Warp one source quad into Blender-order straight RGBA pixels."""
import numpy as np
output_width, output_height = (int(value) for value in output_size)
if min(output_width, output_height) <= 0:
raise ValueError("Projective output dimensions must be positive")
source = premultiplied_rgba(source_pixels, source_size)
target = np.asarray(
(
(0.0, 0.0),
(float(output_width), 0.0),
(float(output_width), float(output_height)),
(0.0, float(output_height)),
),
dtype=np.float64,
)
output_to_source = homography_from_points(target, quad)
output = np.zeros((output_height, output_width, 4), dtype=np.float32)
target_x = np.arange(output_width, dtype=np.float64) + 0.5
for row_start in range(0, output_height, PROJECTIVE_CHUNK_ROWS):
row_end = min(row_start + PROJECTIVE_CHUNK_ROWS, output_height)
target_y = np.arange(row_start, row_end, dtype=np.float64) + 0.5
x, y = np.meshgrid(target_x, target_y)
points = np.stack((x, y), axis=-1)
mapped = transform_homography(points, output_to_source)
output[row_start:row_end] = sample_rgba(source, mapped)
return np.flipud(straight_rgba(output)).ravel()
def is_animated_image(image):
return getattr(image, "source", None) in {"MOVIE", "SEQUENCE"}
def video_total_frames(image):
"""Return the total animated frame count, or 1 for a still image."""
source = getattr(image, "source", None)
if source == "MOVIE":
return int(getattr(image, "frame_duration", 1) or 1)
if source == "SEQUENCE":
return len(collect_sequence_frames(image)) or 1
return 1
def image_user_settings(source_object):
"""Return the Empty's image user settings as a four-item tuple."""
image_user = getattr(source_object, "image_user", None)
if image_user is None:
return 1, 0, 0, False
return (
int(getattr(image_user, "frame_start", 1) or 1),
int(getattr(image_user, "frame_offset", 0) or 0),
int(getattr(image_user, "frame_duration", 0) or 0),
bool(getattr(image_user, "use_cyclic", False)),
)
def extract_frame_range(source_object):
"""Return the clamped (start_frame, frame_count) range extracted from the source."""
total_frames = video_total_frames(getattr(source_object, "data", None))
if total_frames <= 1:
return 1, total_frames
_frame_start, frame_offset, image_frames, _use_cyclic = (
image_user_settings(source_object)
)
frame_count = (
min(image_frames, total_frames)
if image_frames >= 1
else total_frames
)
start_frame = min(1 + frame_offset, total_frames - frame_count + 1)
start_frame = max(start_frame, 1)
return start_frame, frame_count
def default_extract_frames(source_object):
"""Return the Empty's Image Frames value, or the full frame count when unset."""
return extract_frame_range(source_object)[1]
def source_start_frame(source_object_name):
source_object = bpy.data.objects.get(source_object_name)
return extract_frame_range(source_object)[0] if source_object else 1
def image_path(image):
filepath_from_user = getattr(image, "filepath_from_user", None)
filepath = (
filepath_from_user()
if callable(filepath_from_user) and getattr(image, "source", None) != "SEQUENCE"
else getattr(image, "filepath", "")
)
if not filepath:
return Path()
return Path(bpy.path.abspath(filepath)).expanduser()
def image_is_packed(image):
if getattr(image, "packed_file", None) is not None:
return True
return bool(getattr(image, "packed_files", ()))
def cleanup_image_input(path, temporary):
"""Remove a prepared input and its temporary container when requested."""
if not temporary or not path:
return
path = Path(path)
try:
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
else:
path.unlink(missing_ok=True)
path.parent.rmdir()
except OSError:
pass
def image_base_name(image):
name = image.name
lower_name = name.lower()
for suffix in IMAGE_NAME_SUFFIXES + VIDEO_NAME_SUFFIXES:
suffix_index = lower_name.rfind(suffix)
if suffix_index <= 0:
continue
trailing = name[suffix_index + len(suffix) :]
if trailing and not (
trailing.startswith(".") and trailing[1:].isdigit()
):
continue
return f"{name[:suffix_index]}{trailing}"
return name
def depth_data_name(source_object):
return f"{image_base_name(source_object.data)}_depth.exr"
def normal_data_name(source_object):
return f"{image_base_name(source_object.data)}_normal.png"
def color_data_name(source_object):
return f"{image_base_name(source_object.data)}_color.png"
def save_packed_image(image, _scene):
output_path = Path(tempfile.mkdtemp(prefix="anyimage-input-")) / "input.png"
original_file_format = image.file_format
try:
image.file_format = "PNG"
image.save(filepath=str(output_path), save_copy=True)
except (OSError, RuntimeError) as error:
output_path.unlink(missing_ok=True)
raise RuntimeError("Unable to export the packed image") from error
finally:
image.file_format = original_file_format
if not output_path.is_file():
raise RuntimeError("Blender did not export the packed image")
return output_path
def collect_sequence_frames(image):
"""Return the numbered frame files of a SEQUENCE image, or [] when absent."""
source_path = image_path(image)
if not is_animated_image(image) or not source_path.is_file():
return []
match = re.match(
r"^(?P<prefix>.*?)(?P<digits>\d+)(?P<suffix>\.[^.]+)$",
source_path.name,
)
if match is None:
return []
prefix = match.group("prefix")
suffix = match.group("suffix").lower()
frames = []
for path in source_path.parent.iterdir():
frame_match = re.match(
r"^(?P<prefix>.*?)(?P<digits>\d+)(?P<suffix>\.[^.]+)$",
path.name,
)
if (
frame_match is not None
and frame_match.group("prefix") == prefix
and frame_match.group("suffix").lower() == suffix
):
frames.append((int(frame_match.group("digits")), path))
return [path for _digits, path in sorted(frames)]
def prepare_image_input(image, scene):
source_path = image_path(image)
if image_is_packed(image):
return save_packed_image(image, scene), True
if getattr(image, "source", None) == "MOVIE":
if not source_path.is_file():
raise RuntimeError(
"The image empty must reference an existing video file"
)
return source_path, False
sequence_frames = collect_sequence_frames(image)
if sequence_frames:
temporary_directory = Path(tempfile.mkdtemp(prefix="anyimage-input-"))
try:
for index, frame_path in enumerate(sequence_frames):
suffix = frame_path.suffix.lower() or ".jpg"
shutil.copy2(
frame_path,
temporary_directory / f"frame.{index + 1:04d}{suffix}",
)
except Exception:
shutil.rmtree(temporary_directory, ignore_errors=True)
raise
return temporary_directory, True
if not source_path.is_file():
raise RuntimeError(
"The image empty must reference an existing image file"
)
return source_path, False
def prepare_image_bounds_input(image, source_rgba, bounds):
"""Write one rectangular RGBA input owned by its temporary directory."""
import numpy as np
left, top, right, bottom = (int(value) for value in bounds)
rgba = np.asarray(source_rgba, dtype=np.float32)
expected_shape = (int(image.size[1]), int(image.size[0]), 4)
if rgba.shape != expected_shape:
raise ValueError("The source RGBA does not match the Blender Image")
if (
left < 0
or top < 0
or right > expected_shape[1]
or bottom > expected_shape[0]
):
raise ValueError("The image bounds are outside the Blender Image")
if right <= left or bottom <= top:
raise ValueError("The image bounds are empty")
cropped = rgba[top:bottom, left:right]
output_directory = Path(tempfile.mkdtemp(prefix="anyimage-bounds-"))
output_path = output_directory / "input.png"
temporary_image = bpy.data.images.new(
"AnyImage Bounds Input",
width=right - left,
height=bottom - top,
alpha=True,
)
original_format = temporary_image.file_format
try:
temporary_image.colorspace_settings.name = image.colorspace_settings.name
temporary_image.alpha_mode = "STRAIGHT"
temporary_image.pixels.foreach_set(np.flipud(cropped).ravel())
temporary_image.file_format = "PNG"
temporary_image.save(filepath=str(output_path), save_copy=True)
except Exception:
cleanup_image_input(output_path, True)
raise
finally:
temporary_image.file_format = original_format
bpy.data.images.remove(temporary_image, do_unlink=True)
if not output_path.is_file():
cleanup_image_input(output_path, True)
raise RuntimeError("Blender did not export the bounds image input")
return output_path
def load_packed_rgba_image(path, source_image, name):
"""Load an encoded local RGBA file and make its Blender data self-contained."""
result_image = None
try:
result_image = bpy.data.images.load(str(path), check_existing=False)
result_image.colorspace_settings.name = source_image.colorspace_settings.name
result_image.alpha_mode = source_image.alpha_mode
result_image.name = name
result_image.pack()
except (OSError, RuntimeError, TypeError, ValueError) as error:
if result_image is not None:
bpy.data.images.remove(result_image, do_unlink=True)
raise RuntimeError("Unable to prepare the RGBA image result") from error
return result_image
def configure_image_user(
image_user,
frame_count=None,
frame_start=1,
frame_offset=0,
use_cyclic=None,
):
"""Map the image to scene frames and enable auto refresh."""
if image_user is None:
return
if hasattr(image_user, "frame_start"):
image_user.frame_start = frame_start
if hasattr(image_user, "frame_offset"):
image_user.frame_offset = frame_offset
if hasattr(image_user, "use_auto_refresh"):
image_user.use_auto_refresh = True
if use_cyclic is not None and hasattr(image_user, "use_cyclic"):
image_user.use_cyclic = use_cyclic
if frame_count is not None and hasattr(image_user, "frame_duration"):
image_user.frame_duration = frame_count
def pack_sequence_if_possible(image, expected_frames):
"""Pack a frame sequence when Blender supports it; otherwise keep external files."""
if expected_frames <= 1 or not image.has_data:
return False
try:
image.pack()
except (OSError, RuntimeError):
return False
if len(getattr(image, "packed_files", ())) >= expected_frames:
return True
try:
image.unpack(method="REMOVE")
except (OSError, RuntimeError):
pass
return False
def sorted_frame_paths(directory, prefix=None):
"""Return image files in a directory sorted by their trailing frame number."""
def frame_key(path):
match = re.search(r"(\d+)(?=\.[^.]+$)", path.name)
return (int(match.group(1)) if match is not None else 0, path.name)
return sorted(
(
path
for path in Path(directory).iterdir()
if path.is_file()
and path.suffix.lower() in IMAGE_NAME_SUFFIXES
and (prefix is None or path.stem.startswith(prefix))
),
key=frame_key,
)
def force_sequence_source(image):
"""Ensure a loaded image is a SEQUENCE datablock for its numbered frames."""
if getattr(image, "source", None) == "SEQUENCE":
return image
try:
image.source = "SEQUENCE"
except (OSError, RuntimeError, TypeError) as error:
raise RuntimeError(
"Blender cannot load the frame sequence"
) from error
if getattr(image, "source", None) != "SEQUENCE":
raise RuntimeError("Blender cannot load the frame sequence")
return image
def load_normal_result_image(path, source_object):
"""Load, name, configure, and pack a generated normal image."""
image = bpy.data.images.load(str(path), check_existing=False)
image.name = normal_data_name(source_object)
image.colorspace_settings.name = "Non-Color"
image.pack()
return image
def configure_color_alpha(image):
"""Apply the presentation mode to an owned Color image."""
if image.alpha_mode != "PREMUL":
pixels = (
image_pixels(image)
if getattr(image, "source", None) == "GENERATED"
or getattr(image, "is_dirty", False)
else None
)
image.alpha_mode = "PREMUL"
if pixels is not None:
image.pixels.foreach_set(pixels)
image.update()
def create_image_edit_result(source_image, pixels, output_size):
"""Create a packed Image datablock from edited RGBA pixels."""
output_width, output_height = (int(value) for value in output_size)
if output_width <= 0 or output_height <= 0:
raise ValueError("The edited image dimensions must be positive")
if len(pixels) != output_width * output_height * 4:
raise ValueError("The edited pixels do not match the image dimensions")
image = bpy.data.images.new(
source_image.name,
width=output_width,
height=output_height,
alpha=True,
)
try:
image.colorspace_settings.name = source_image.colorspace_settings.name
configure_color_alpha(image)
image.pixels.foreach_set(pixels)
image.pack()
image.update()
except Exception:
bpy.data.images.remove(image, do_unlink=True)
raise
return image
def has_other_image_empty(source_object, *, removed_objects=()):
"""Check surviving image Empty objects for the same Image datablock."""
return any(
candidate != source_object
and candidate not in removed_objects
and is_image_empty(candidate)
and candidate.data == source_object.data
for candidate in bpy.data.objects
)
def image_content_state(image):
"""Capture encoded content and unsaved pixels for an Image transaction."""
import numpy as np
pixels = None
if image.is_dirty:
pixels = np.empty(len(image.pixels), dtype=np.float32)
image.pixels.foreach_get(pixels)
return {
"source": image.source,
"filepath": image.filepath_raw,
"colorspace": image.colorspace_settings.name,
"alpha_mode": image.alpha_mode,
"file_format": image.file_format,
"packed": bytes(image.packed_file.data) if image.packed_file else None,
"pixels": pixels,
"size": tuple(image.size),
"generated": {
name: getattr(image, name)
for name in ("generated_width", "generated_height", "generated_type",
"use_generated_float")
},
"generated_color": tuple(image.generated_color),
}
def restore_image_content(image, state):
"""Apply captured image content while retaining the destination ID."""
if image.packed_file:
image.source = "FILE"
image.unpack(method="REMOVE")
image.filepath_raw = state["filepath"]
image.source = state["source"]
if state["source"] == "GENERATED":
for name, value in state["generated"].items():
setattr(image, name, value)
image.generated_color = state["generated_color"]
image.colorspace_settings.name = state["colorspace"]
image.alpha_mode = state["alpha_mode"]
image.file_format = state["file_format"]
if state["packed"] is not None:
data = state["packed"]
image.source = "FILE"
image.pack(data=data, data_len=len(data))
image.source = state["source"]
image.reload()
if state["pixels"] is not None:
if tuple(image.size) != state["size"]:
image.scale(*state["size"])
image.pixels.foreach_set(state["pixels"])
image.update()
def replace_empty_image(source_object, result_image, *, placement_bounds=None,
removed_objects=()):
"""Replace one Empty image, retaining shared source images."""
try:
source_image = source_object.data
original_fake_user = source_image.use_fake_user
original_blending = getattr(source_object, "use_empty_image_alpha", False)
image_user = getattr(source_object, "image_user", None)
original_timing = image_user_settings(source_object) if image_user is not None else None
original_auto_refresh = getattr(image_user, "use_auto_refresh", False)
frame_count = (
video_total_frames(result_image)
if is_animated_image(result_image)
else None
)
alignment = (
empty_display_alignment(
source_object,
tuple(source_image.size),
tuple(result_image.size),
placement_bounds,
)
if placement_bounds is not None
else None
)
if frame_count is not None:
(
original_frame_start,
_original_frame_offset,
_original_frame_duration,
original_use_cyclic,
) = image_user_settings(source_object)
source_image.use_fake_user = True
original_alignment = (
(source_object.empty_display_size, tuple(source_object.empty_image_offset))
if alignment is not None else None
)
original_content = None
try:
shared = has_other_image_empty(source_object, removed_objects=removed_objects)
if shared:
final_image = result_image
else:
result_content = image_content_state(result_image)
original_content = image_content_state(source_image)
restore_image_content(source_image, result_content)
final_image = source_image
source_object.data = final_image
source_object.use_empty_image_alpha = True
if frame_count is not None:
configure_image_user(
image_user,
frame_count,
frame_start=original_frame_start,
use_cyclic=original_use_cyclic,
)
if alignment is not None:
source_object.empty_display_size = alignment[0]
source_object.empty_image_offset = alignment[1]
except Exception:
if original_content is not None:
restore_image_content(source_image, original_content)
source_object.data = source_image
source_object.use_empty_image_alpha = original_blending
if original_alignment is not None:
source_object.empty_display_size, source_object.empty_image_offset = original_alignment
if original_timing is not None:
(image_user.frame_start, image_user.frame_offset,
image_user.frame_duration, image_user.use_cyclic) = original_timing
image_user.use_auto_refresh = original_auto_refresh
source_image.use_fake_user = original_fake_user
raise
source_image.use_fake_user = original_fake_user
return final_image
finally:
if result_image.users == 0:
bpy.data.images.remove(result_image)
def _load_packed_result_image(
source_object,
output_directory,
*,
frame_prefix,
result_name,
):
output_directory = Path(output_directory)
color_paths = sorted_frame_paths(output_directory, frame_prefix)
if not color_paths:
raise RuntimeError(
f"Color result has no images: {output_directory}"
)
source_image = source_object.data
source_color_space = source_image.colorspace_settings.name
animated = is_animated_image(source_image)
try:
result_image = bpy.data.images.load(
str(color_paths[0]),
check_existing=False,
)
result_image.colorspace_settings.name = source_color_space
configure_color_alpha(result_image)
if animated:
force_sequence_source(result_image)
except (OSError, RuntimeError, TypeError, ValueError) as error:
if "result_image" in locals():
bpy.data.images.remove(result_image, do_unlink=True)
raise RuntimeError("Unable to load the color result") from error
try:
if animated:
pack_sequence_if_possible(result_image, len(color_paths))
else:
result_image.pack()
result_image.name = result_name
except (OSError, RuntimeError, TypeError, ValueError) as error:
bpy.data.images.remove(result_image, do_unlink=True)
raise RuntimeError("Unable to prepare the color result") from error
return result_image
def load_image_edit_result(
source_object,
output_directory,
*,
frame_prefix="image.",
):
"""Load one packed Job result that directly edits the source Image."""
return _load_packed_result_image(
source_object,
output_directory,
frame_prefix=frame_prefix,
result_name=source_object.data.name,
)
def load_color_result_image(
source_object,
output_directory,
*,
frame_prefix="image.",
):
"""Load one packed Color texture result for a generated material."""
return _load_packed_result_image(
source_object,
output_directory,
frame_prefix=frame_prefix,
result_name=color_data_name(source_object),
)
def image_empty_bounds(source_object):
image_width, image_height = source_object.data.size
if image_width <= 0 or image_height <= 0:
raise ValueError("The source image has no readable dimensions")
aspect = image_width / image_height
display_size = float(source_object.empty_display_size)
if aspect >= 1.0:
width = display_size
height = display_size / aspect
else:
width = display_size * aspect
height = display_size
offset = source_object.empty_image_offset
x_min = float(offset[0]) * width
y_min = float(offset[1]) * height
return (
x_min,
x_min + width,
y_min,
y_min + height,
)
def empty_display_alignment(
source_object,
source_size,
output_size,
placement_bounds,
):
"""Return the display size and offset for an edited Image Empty."""
source_width, source_height = source_size
output_width, output_height = output_size
left, top, right, bottom = placement_bounds
if (
source_width <= 0
or source_height <= 0
or output_width <= 0
or output_height <= 0
or right <= left
or bottom <= top
):
raise ValueError("The edited image placement is invalid")
x_min, x_max, y_min, y_max = image_empty_bounds(source_object)
local_width = x_max - x_min
local_height = y_max - y_min
desired_x_min = x_min + left * local_width / source_width
desired_x_max = x_min + right * local_width / source_width
desired_y_max = y_max - top * local_height / source_height
desired_y_min = y_max - bottom * local_height / source_height
desired_width = desired_x_max - desired_x_min
desired_height = desired_y_max - desired_y_min
display_size = max(desired_width, desired_height)
aspect = output_width / output_height
if aspect >= 1.0:
display_width = display_size
display_height = display_size / aspect
else:
display_width = display_size * aspect
display_height = display_size
center_x = (desired_x_min + desired_x_max) * 0.5
center_y = (desired_y_min + desired_y_max) * 0.5
return display_size, (
center_x / display_width - 0.5,
center_y / display_height - 0.5,
)