Add comprehensive tests for image processing and mask functionalities

- Introduced tests for color image handling, ensuring source preservation and premultiplied alpha usage.
- Implemented tests for brush stroke operations, validating segment boundaries and brush anti-aliasing.
- Added tests for frame anti-aliasing, confirming correct rendering behavior with various alpha values and depth settings.
- Created tests for frame projection, ensuring accurate overlap calculations and half-plane clipping.
- Developed tests for image premultiplied retention, verifying behavior during loading and packing processes.
- Added undo functionality tests for mask operations, ensuring single transaction behavior during edits.
This commit is contained in:
2026-09-05 21:33:11 +08:00
parent bbaa4bff46
commit 1994d38ccd
111 changed files with 7691 additions and 2341 deletions

View File

@@ -1,5 +1,6 @@
"""Shared Blender Image and Image Empty lifecycle helpers."""
import math
import re
import shutil
import tempfile
@@ -20,6 +21,10 @@ IMAGE_NAME_SUFFIXES = (
".tga",
)
MAX_PROJECTIVE_OUTPUT_DIMENSION = 8192
MAX_PROJECTIVE_OUTPUT_PIXELS = 16_777_216
PROJECTIVE_CHUNK_ROWS = 256
VIDEO_NAME_SUFFIXES = (
".mp4",
@@ -52,11 +57,25 @@ def require_image_empty(object_name):
def image_pixels(image):
"""Read one Blender Image into a flat float32 pixel array."""
"""Read straight-alpha business pixels without changing presentation mode."""
import numpy as np
pixels = np.empty(len(image.pixels), dtype=np.float32)
image.pixels.foreach_get(pixels)
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
@@ -70,6 +89,197 @@ def image_rgba(image):
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"}
@@ -405,6 +615,21 @@ def load_normal_result_image(path, source_object):
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)
@@ -420,7 +645,7 @@ def create_image_edit_result(source_image, pixels, output_size):
)
try:
image.colorspace_settings.name = source_image.colorspace_settings.name
image.alpha_mode = "STRAIGHT"
configure_color_alpha(image)
image.pixels.foreach_set(pixels)
image.pack()
image.update()
@@ -435,7 +660,9 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None):
source_image = source_object.data
source_name = source_image.name
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
frame_count = (
video_total_frames(result_image)
if is_animated_image(result_image)
@@ -460,8 +687,13 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None):
) = 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
)
try:
source_object.data = result_image
source_object.use_empty_image_alpha = True
if frame_count is not None:
configure_image_user(
image_user,
@@ -474,6 +706,12 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None):
source_object.empty_image_offset = alignment[1]
except Exception:
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
source_image.use_fake_user = original_fake_user
raise
@@ -484,47 +722,6 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None):
return result_image
def place_empty_image_result(
context,
source_object,
result_image,
*,
placement_bounds=None,
keep_original=False,
):
"""Place one crop result and optionally preserve its source."""
if not keep_original:
replace_empty_image(
source_object,
result_image,
placement_bounds=placement_bounds,
)
return source_object
result_object = source_object.copy()
collections = tuple(source_object.users_collection)
if not collections:
collections = (context.collection,)
try:
for collection in collections:
collection.objects.link(result_object)
replace_empty_image(
result_object,
result_image,
placement_bounds=placement_bounds,
)
except Exception:
bpy.data.objects.remove(result_object, do_unlink=True)
raise
for selected in context.selected_objects:
selected.select_set(False)
result_object.select_set(True)
context.view_layer.objects.active = result_object
return result_object
def _load_packed_result_image(
source_object,
output_directory,
@@ -546,8 +743,8 @@ def _load_packed_result_image(
str(color_paths[0]),
check_existing=False,
)
result_image.alpha_mode = "CHANNEL_PACKED"
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: