feat(selection): separate selection geometry from image content processing

- Introduced a new design for selection rasterization that decouples the selection geometry from the image content, ensuring that the SelectionMask is independent of the source image's alpha and color.
- Updated the `rasterize_selection_path` function to only accept selection paths and image dimensions, removing parameters related to source alpha and alpha thresholds.
- Implemented a new approach for handling selection paths using non-zero winding fill, ensuring overlapping areas are preserved.
- Enhanced performance by utilizing a scanline algorithm for rasterization, significantly reducing processing time for high-resolution images.
- Updated internal documentation and added comprehensive tests to validate the new behavior and performance improvements.
- Ensured backward compatibility by maintaining existing interfaces where necessary while introducing breaking changes in the selection processing logic.
This commit is contained in:
2026-09-01 22:36:20 +08:00
parent 2ff7b04f56
commit 7f6e47432e
54 changed files with 2831 additions and 634 deletions

View File

@@ -13,7 +13,6 @@ class ImageEditWarning(ValueError):
class SelectionPath:
points: tuple[tuple[float, float], ...]
invert: bool = False
fill_rule: str = "EVEN_ODD"
def __post_init__(self):
points = tuple(
@@ -21,8 +20,6 @@ class SelectionPath:
for point in self.points
)
selection_path_bounds(points)
if self.fill_rule not in {"EVEN_ODD", "NONZERO"}:
raise ValueError(f"Unknown Selection Path fill rule: {self.fill_rule}")
object.__setattr__(self, "points", points)
object.__setattr__(self, "invert", bool(self.invert))
@@ -31,7 +28,6 @@ class SelectionPath:
{
"points": self.points,
"invert": self.invert,
"fill_rule": self.fill_rule,
},
separators=(",", ":"),
)
@@ -39,12 +35,11 @@ class SelectionPath:
@classmethod
def from_json(cls, value):
data = json.loads(value)
if not isinstance(data, dict):
if not isinstance(data, dict) or set(data) != {"points", "invert"}:
raise ValueError("The Selection Path data is invalid")
return cls(
points=tuple(data.get("points", ())),
invert=bool(data.get("invert", False)),
fill_rule=str(data.get("fill_rule", "EVEN_ODD")),
points=tuple(data["points"]),
invert=bool(data["invert"]),
)
@@ -108,45 +103,46 @@ def selection_path_bounds(points):
return left, top, right, bottom
def _rasterize_path(image_size, points, fill_rule):
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(points)
left = max(left, 0)
top = max(top, 0)
right = min(right, width)
bottom = min(bottom, height)
mask = np.zeros((height, width), dtype=bool)
if right <= left or bottom <= top:
return mask
points = np.asarray(points, dtype=np.float64)
x = np.arange(left, right, dtype=np.float64) + 0.5
for row_start in range(top, bottom, 128):
row_end = min(row_start + 128, bottom)
y = np.arange(row_start, row_end, dtype=np.float64) + 0.5
state = np.zeros(
(len(y), len(x)),
dtype=bool if fill_rule == "EVEN_ODD" else np.int16,
)
previous = points[-1]
for current in points:
crosses = (current[1] > y) != (previous[1] > y)
edge_x = (previous[0] - current[0]) * (y - current[1]) / (
previous[1] - current[1] + 1e-300
) + current[0]
selected = crosses[:, None] & (x[None, :] < edge_x[:, None])
if fill_rule == "EVEN_ODD":
state ^= selected
else:
winding = 1 if current[1] > previous[1] else -1
state += selected * winding
previous = current
mask[row_start:row_end, left:right] = state != 0
return mask
if selection_path.invert:
return 0, 0, width, height
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):
@@ -170,40 +166,29 @@ def rasterize_selection_path(
image_size,
selection_path,
*,
source_alpha=None,
alpha_threshold=0.0,
antialias=False,
):
import numpy as np
width, height = (int(value) for value in image_size)
mask = _rasterize_path(
canvas_bounds = _selection_canvas_bounds(
(width, height),
selection_path.points,
selection_path.fill_rule,
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 selection_path.invert:
mask = 1.0 - mask
if source_alpha is not None:
alpha = np.asarray(source_alpha)
if alpha.shape != (height, width):
raise ValueError("The source Alpha does not match the image selection")
mask = np.where(alpha > float(alpha_threshold), mask, 0.0)
visible_y, visible_x = np.nonzero(mask > float(alpha_threshold))
if not len(visible_x):
if not np.any(mask > 0.0):
raise ImageEditWarning("The selection contains no visible image pixels")
bounds = (
int(visible_x.min()),
int(visible_y.min()),
int(visible_x.max()) + 1,
int(visible_y.max()) + 1,
)
left, top, right, bottom = bounds
return SelectionMask(mask[top:bottom, left:right], bounds)
return SelectionMask(mask, canvas_bounds)
def crop_pixels_to_alpha(pixels, bounds):