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:

View File

@@ -3,6 +3,7 @@
import bpy
from .image import (
configure_color_alpha,
configure_image_user,
image_base_name,
image_pixels,
@@ -106,6 +107,7 @@ def create_image_material(
uv_map.location = (-1250.0, 350.0)
material_image = color_image
set_material_color_space(material_image, scene)
configure_color_alpha(material_image)
color_texture.image = material_image
color_texture.interpolation = "Linear"
color_texture.extension = "EXTEND"

View File

@@ -1,19 +1,45 @@
"""Selection values, serialization, rasterization, and Alpha cropping."""
"""Selection values, serialization, rasterization, and composition."""
import json
import math
from dataclasses import dataclass
from pathlib import Path
class ImageEditWarning(ValueError):
pass
def clip_polygon_halfplanes(points, boundaries):
"""Clip a 2D polygon to linear half-planes ax + by + c >= 0."""
polygon = tuple(tuple(float(value) for value in point) for point in points)
for coefficients in boundaries:
scale = max(abs(float(value)) for value in coefficients)
if scale == 0.0:
continue
a, b, c = (float(value) / scale for value in coefficients)
result = []
if not polygon:
break
previous = polygon[-1]
previous_value = math.fsum((a * previous[0], b * previous[1], c))
for current in polygon:
value = math.fsum((a * current[0], b * current[1], c))
if (value >= 0.0) != (previous_value >= 0.0):
weight = previous_value / (previous_value - value)
result.append(tuple(
previous[axis] + weight * (current[axis] - previous[axis])
for axis in range(2)
))
if value >= 0.0:
result.append(current)
previous, previous_value = current, value
polygon = tuple(result)
return polygon
@dataclass(frozen=True)
class SelectionPath:
points: tuple[tuple[float, float], ...]
invert: bool = False
def __post_init__(self):
points = tuple(
@@ -22,26 +48,19 @@ class SelectionPath:
)
selection_path_bounds(points)
object.__setattr__(self, "points", points)
object.__setattr__(self, "invert", bool(self.invert))
def to_json(self):
return json.dumps(
{
"points": self.points,
"invert": self.invert,
},
{"points": self.points},
separators=(",", ":"),
)
@classmethod
def from_json(cls, value):
data = json.loads(value)
if not isinstance(data, dict) or set(data) != {"points", "invert"}:
if not isinstance(data, dict) or set(data) != {"points"}:
raise ValueError("The Selection Path data is invalid")
return cls(
points=tuple(data["points"]),
invert=bool(data["invert"]),
)
return cls(points=tuple(data["points"]))
@dataclass(frozen=True)
@@ -87,29 +106,6 @@ class SelectionMask:
return result
def save_selection_mask(path, selection_mask):
"""Save one local Selection Mask beside a prepared image input."""
import numpy as np
path = Path(path).parent / "selection-mask.npy"
np.save(path, selection_mask.values, allow_pickle=False)
return path
def load_selection_mask(path, bounds):
"""Load a local Selection Mask sidecar without allowing object data."""
import numpy as np
values = np.load(Path(path), allow_pickle=False)
return SelectionMask(values, bounds)
def cleanup_selection_mask(path):
"""Remove a local Selection Mask sidecar."""
if path:
Path(path).unlink(missing_ok=True)
def selection_path_bounds(points):
import numpy as np
@@ -158,8 +154,6 @@ def _selection_canvas_bounds(image_size, selection_path, padding):
width, height = (int(value) for value in image_size)
if width <= 0 or height <= 0:
raise ValueError("The source image has no readable dimensions")
if selection_path.invert:
return 0, 0, width, height
left, top, right, bottom = selection_path_bounds(selection_path.points)
return (
max(left - padding, 0),
@@ -208,15 +202,13 @@ def rasterize_selection_path(
mask = _antialias_mask(mask)
else:
mask = mask.astype(np.float32)
if selection_path.invert:
mask = 1.0 - mask
if not np.any(mask > 0.0):
raise ImageEditWarning("The selection contains no visible image pixels")
return SelectionMask(mask, canvas_bounds)
def crop_pixels_to_alpha(pixels, bounds):
"""Crop Blender-order RGBA pixels to their nonzero Alpha bounds."""
def trim_pixels_to_alpha(pixels, bounds):
"""Trim Blender-order RGBA pixels to their nonzero Alpha bounds."""
import numpy as np
left, top, right, bottom = bounds
@@ -239,3 +231,30 @@ def crop_pixels_to_alpha(pixels, bounds):
left + content_right,
top + content_bottom,
)
def rasterize_selection_union(image_size, paths, *, antialias=False):
"""Rasterize a geometric union on one local canvas before antialiasing."""
import numpy as np
paths = tuple(paths)
bounds = [
_selection_canvas_bounds(image_size, path, 2 if antialias else 0)
for path in paths
]
entries = [(path, box) for path, box in zip(paths, bounds)
if box[2] > box[0] and box[3] > box[1]]
if not entries:
raise ImageEditWarning("The selection contains no visible image pixels")
left = min(box[0] for _, box in entries)
top = min(box[1] for _, box in entries)
right = max(box[2] for _, box in entries)
bottom = max(box[3] for _, box in entries)
coverage = np.zeros((bottom - top, right - left), dtype=bool)
for path, box in entries:
x0, y0, x1, y1 = box
coverage[y0 - top:y1 - top, x0 - left:x1 - left] |= _rasterize_path(box, path.points)
if not coverage.any():
raise ImageEditWarning("The selection contains no visible image pixels")
values = _antialias_mask(coverage) if antialias else coverage.astype(np.float32)
return SelectionMask(values, (left, top, right, bottom))

File diff suppressed because it is too large Load Diff