Files
AnyImage/src/anyimage/common/selection.py
nan 1994d38ccd 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.
2026-09-05 21:33:11 +08:00

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