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,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))