- 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.
879 lines
30 KiB
Python
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,
|
|
)
|