- 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.
261 lines
9.4 KiB
Python
261 lines
9.4 KiB
Python
"""Selection values, serialization, rasterization, and composition."""
|
|
|
|
import json
|
|
import math
|
|
from dataclasses import dataclass
|
|
|
|
|
|
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], ...]
|
|
|
|
def __post_init__(self):
|
|
points = tuple(
|
|
(float(point[0]), float(point[1]))
|
|
for point in self.points
|
|
)
|
|
selection_path_bounds(points)
|
|
object.__setattr__(self, "points", points)
|
|
|
|
def to_json(self):
|
|
return json.dumps(
|
|
{"points": self.points},
|
|
separators=(",", ":"),
|
|
)
|
|
|
|
@classmethod
|
|
def from_json(cls, value):
|
|
data = json.loads(value)
|
|
if not isinstance(data, dict) or set(data) != {"points"}:
|
|
raise ValueError("The Selection Path data is invalid")
|
|
return cls(points=tuple(data["points"]))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SelectionMask:
|
|
values: object
|
|
bounds: tuple[int, int, int, int]
|
|
|
|
def __post_init__(self):
|
|
import numpy as np
|
|
|
|
values = np.asarray(self.values, dtype=np.float32)
|
|
bounds = tuple(int(value) for value in self.bounds)
|
|
if values.ndim != 2:
|
|
raise ValueError("The selection mask must be two-dimensional")
|
|
if len(bounds) != 4:
|
|
raise ValueError("The selection bounds are invalid")
|
|
left, top, right, bottom = bounds
|
|
if right <= left or bottom <= top:
|
|
raise ValueError("The selection bounds are empty")
|
|
if values.shape != (bottom - top, right - left):
|
|
raise ValueError("The selection mask does not match its bounds")
|
|
if not np.isfinite(values).all():
|
|
raise ValueError("The selection mask contains invalid values")
|
|
object.__setattr__(self, "values", np.clip(values, 0.0, 1.0))
|
|
object.__setattr__(self, "bounds", bounds)
|
|
|
|
def full_values(self, image_size):
|
|
import numpy as np
|
|
|
|
width, height = (int(value) for value in image_size)
|
|
result = np.zeros((height, width), dtype=np.float32)
|
|
left, top, right, bottom = self.bounds
|
|
source_left = max(left, 0)
|
|
source_top = max(top, 0)
|
|
source_right = min(right, width)
|
|
source_bottom = min(bottom, height)
|
|
if source_right <= source_left or source_bottom <= source_top:
|
|
return result
|
|
result[source_top:source_bottom, source_left:source_right] = self.values[
|
|
source_top - top : source_bottom - top,
|
|
source_left - left : source_right - left,
|
|
]
|
|
return result
|
|
|
|
|
|
def selection_path_bounds(points):
|
|
import numpy as np
|
|
|
|
points = np.asarray(points, dtype=np.float64)
|
|
if points.ndim != 2 or points.shape[0] < 3 or points.shape[1] != 2:
|
|
raise ValueError("The selection does not define a valid image path")
|
|
if not np.isfinite(points).all():
|
|
raise ValueError("The selection contains invalid image coordinates")
|
|
left = int(math.floor(float(points[:, 0].min())))
|
|
top = int(math.floor(float(points[:, 1].min())))
|
|
right = int(math.ceil(float(points[:, 0].max())))
|
|
bottom = int(math.ceil(float(points[:, 1].max())))
|
|
if right <= left or bottom <= top:
|
|
raise ValueError("The selection does not define a valid image area")
|
|
return left, top, right, bottom
|
|
|
|
|
|
def _rasterize_path(bounds, points):
|
|
import numpy as np
|
|
|
|
left, top, right, bottom = bounds
|
|
width = right - left
|
|
height = bottom - top
|
|
points = np.asarray(points, dtype=np.float64)
|
|
y = np.arange(top, bottom, dtype=np.float64) + 0.5
|
|
changes = np.zeros((height, width + 1), dtype=np.int16)
|
|
previous = points[-1]
|
|
for current in points:
|
|
rows = np.flatnonzero((current[1] > y) != (previous[1] > y))
|
|
if len(rows):
|
|
sample_y = y[rows]
|
|
edge_x = (previous[0] - current[0]) * (sample_y - current[1]) / (
|
|
previous[1] - current[1] + 1e-300
|
|
) + current[0]
|
|
ends = np.ceil(edge_x - left - 0.5).astype(np.int64)
|
|
ends = np.clip(ends, 0, width)
|
|
starts = np.zeros_like(rows)
|
|
winding = 1 if current[1] > previous[1] else -1
|
|
np.add.at(changes, (rows, starts), winding)
|
|
np.add.at(changes, (rows, ends), -winding)
|
|
previous = current
|
|
return np.cumsum(changes[:, :width], axis=1, dtype=np.int16) != 0
|
|
|
|
|
|
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")
|
|
left, top, right, bottom = selection_path_bounds(selection_path.points)
|
|
return (
|
|
max(left - padding, 0),
|
|
max(top - padding, 0),
|
|
min(right + padding, width),
|
|
min(bottom + padding, height),
|
|
)
|
|
|
|
|
|
def _antialias_mask(mask):
|
|
import numpy as np
|
|
|
|
values = np.asarray(mask, dtype=np.float32)
|
|
kernel = np.asarray((0.029, 0.235, 0.472, 0.235, 0.029), dtype=np.float32)
|
|
radius = len(kernel) // 2
|
|
horizontal = np.zeros_like(values)
|
|
padded = np.pad(values, ((0, 0), (radius, radius)), mode="edge")
|
|
for offset, weight in enumerate(kernel):
|
|
horizontal += padded[:, offset : offset + values.shape[1]] * weight
|
|
result = np.zeros_like(values)
|
|
padded = np.pad(horizontal, ((radius, radius), (0, 0)), mode="edge")
|
|
for offset, weight in enumerate(kernel):
|
|
result += padded[offset : offset + values.shape[0]] * weight
|
|
return result
|
|
|
|
|
|
def rasterize_selection_path(
|
|
image_size,
|
|
selection_path,
|
|
*,
|
|
antialias=False,
|
|
):
|
|
import numpy as np
|
|
|
|
width, height = (int(value) for value in image_size)
|
|
canvas_bounds = _selection_canvas_bounds(
|
|
(width, height),
|
|
selection_path,
|
|
2 if antialias else 0,
|
|
)
|
|
left, top, right, bottom = canvas_bounds
|
|
if right <= left or bottom <= top:
|
|
raise ImageEditWarning("The selection contains no visible image pixels")
|
|
mask = _rasterize_path(canvas_bounds, selection_path.points)
|
|
if antialias:
|
|
mask = _antialias_mask(mask)
|
|
else:
|
|
mask = mask.astype(np.float32)
|
|
if not np.any(mask > 0.0):
|
|
raise ImageEditWarning("The selection contains no visible image pixels")
|
|
return SelectionMask(mask, canvas_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
|
|
width = right - left
|
|
height = bottom - top
|
|
rgba = np.flipud(
|
|
np.asarray(pixels, dtype=np.float32).reshape((height, width, 4))
|
|
)
|
|
visible_y, visible_x = np.nonzero(rgba[:, :, 3] > 0.0)
|
|
if not len(visible_x):
|
|
raise ImageEditWarning("The edit contains no visible image pixels")
|
|
content_left = int(visible_x.min())
|
|
content_top = int(visible_y.min())
|
|
content_right = int(visible_x.max()) + 1
|
|
content_bottom = int(visible_y.max()) + 1
|
|
output = rgba[content_top:content_bottom, content_left:content_right].copy()
|
|
return np.flipud(output).ravel(), (
|
|
left + content_left,
|
|
top + content_top,
|
|
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))
|