"""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.*?)(?P\d+)(?P\.[^.]+)$", 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.*?)(?P\d+)(?P\.[^.]+)$", 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, )