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:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user