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,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))
|
||||
|
||||
Reference in New Issue
Block a user