Files
AnyImage/src/anyimage/common/image.py
nan 489003c2cc 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.
2026-09-10 14:42:17 +08:00

879 lines
30 KiB
Python

"""Shared Blender Image and Image Empty lifecycle helpers."""
import math
import re
import shutil
import tempfile
from pathlib import Path
import bpy
from ..server.media.input import validate_input_path
VIEW_TRANSFORM_COLOR_SPACES = (
("ACES 1.3", "ACES 1.3 sRGB"),
("ACES 2.0", "ACES 2.0 sRGB"),
("AgX", "AgX Base sRGB"),
("Filmic", "Filmic sRGB"),
("Khronos PBR Neutral", "Khronos PBR Neutral sRGB"),
)
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 an image owner's playback 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(image, image_user):
"""Return the clamped (start_frame, frame_count) range extracted from the source."""
total_frames = video_total_frames(image)
if total_frames <= 1:
return 1, total_frames
frame_offset = int(getattr(image_user, "frame_offset", 0) or 0)
image_frames = int(getattr(image_user, "frame_duration", 0) or 0)
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 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 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, ValueError) as error:
cleanup_image_input(output_path, True)
raise RuntimeError("Unable to export the packed image") from error
finally:
image.file_format = original_file_format
if not output_path.is_file():
cleanup_image_input(output_path, True)
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(
f"Source video file does not exist: {source_path}"
)
return source_path, False
if getattr(image, "source", None) == "SEQUENCE":
validate_input_path(source_path, images_only=True)
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(
f"Source image file does not exist: {source_path}"
)
return source_path, False
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 color_alpha_mode(color_space):
"""Pair inverse view color spaces with straight alpha."""
return "STRAIGHT" if color_space in dict(VIEW_TRANSFORM_COLOR_SPACES).values() else "PREMUL"
def configure_color_alpha(image):
"""Apply the presentation mode to an owned Color image."""
alpha_mode = color_alpha_mode(image.colorspace_settings.name)
if image.alpha_mode != alpha_mode:
pixels = (
image_pixels(image)
if getattr(image, "source", None) == "GENERATED"
or getattr(image, "is_dirty", False)
else None
)
image.alpha_mode = alpha_mode
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
image.alpha_mode = source_image.alpha_mode
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 require_conversion_source(operator):
"""Validate the object and image captured when a conversion was submitted."""
source = require_image_empty(operator.source_object_name)
if (str(source.as_pointer()) != operator.source_identity
or str(source.data.as_pointer()) != operator.image_identity):
raise ValueError("The conversion source image has changed")
return source
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=(), isolate_image=False):
"""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 = isolate_image or 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_image,
output_directory,
*,
frame_prefix,
result_name,
alpha_mode,
):
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_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
result_image.alpha_mode = alpha_mode
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_image,
output_directory,
*,
frame_prefix="image.",
):
"""Load one packed Job result that directly edits the source Image."""
return _load_packed_result_image(
source_image,
output_directory,
frame_prefix=frame_prefix,
result_name=source_image.name,
alpha_mode=source_image.alpha_mode,
)
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,
)