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:
@@ -262,21 +262,29 @@ def prepare_image_input(image, scene):
|
||||
return source_path, False
|
||||
|
||||
|
||||
def prepare_selection_input(image, selection_mask):
|
||||
"""Write one cropped RGBA Selection input owned by its temporary directory."""
|
||||
def prepare_image_bounds_input(image, source_rgba, bounds):
|
||||
"""Write one rectangular RGBA input owned by its temporary directory."""
|
||||
import numpy as np
|
||||
|
||||
left, top, right, bottom = selection_mask.bounds
|
||||
rgba = image_rgba(image)
|
||||
cropped = rgba[top:bottom, left:right].copy()
|
||||
if cropped.shape[:2] != selection_mask.values.shape:
|
||||
raise ValueError("The Selection does not match the source image")
|
||||
cropped[:, :, 3] *= selection_mask.values
|
||||
cropped[cropped[:, :, 3] <= 0.0, :3] = 0.0
|
||||
output_directory = Path(tempfile.mkdtemp(prefix="anyimage-selection-"))
|
||||
left, top, right, bottom = (int(value) for value in bounds)
|
||||
rgba = np.asarray(source_rgba, dtype=np.float32)
|
||||
expected_shape = (int(image.size[1]), int(image.size[0]), 4)
|
||||
if rgba.shape != expected_shape:
|
||||
raise ValueError("The source RGBA does not match the Blender Image")
|
||||
if (
|
||||
left < 0
|
||||
or top < 0
|
||||
or right > expected_shape[1]
|
||||
or bottom > expected_shape[0]
|
||||
):
|
||||
raise ValueError("The image bounds are outside the Blender Image")
|
||||
if right <= left or bottom <= top:
|
||||
raise ValueError("The image bounds are empty")
|
||||
cropped = rgba[top:bottom, left:right]
|
||||
output_directory = Path(tempfile.mkdtemp(prefix="anyimage-bounds-"))
|
||||
output_path = output_directory / "input.png"
|
||||
temporary_image = bpy.data.images.new(
|
||||
"AnyImage Selection Input",
|
||||
"AnyImage Bounds Input",
|
||||
width=right - left,
|
||||
height=bottom - top,
|
||||
alpha=True,
|
||||
@@ -288,7 +296,7 @@ def prepare_selection_input(image, selection_mask):
|
||||
temporary_image.pixels.foreach_set(np.flipud(cropped).ravel())
|
||||
temporary_image.file_format = "PNG"
|
||||
temporary_image.save(filepath=str(output_path), save_copy=True)
|
||||
except (OSError, RuntimeError):
|
||||
except Exception:
|
||||
cleanup_image_input(output_path, True)
|
||||
raise
|
||||
finally:
|
||||
@@ -296,10 +304,26 @@ def prepare_selection_input(image, selection_mask):
|
||||
bpy.data.images.remove(temporary_image, do_unlink=True)
|
||||
if not output_path.is_file():
|
||||
cleanup_image_input(output_path, True)
|
||||
raise RuntimeError("Blender did not export the Selection input")
|
||||
raise RuntimeError("Blender did not export the bounds image input")
|
||||
return output_path
|
||||
|
||||
|
||||
def load_packed_rgba_image(path, source_image, name):
|
||||
"""Load an encoded local RGBA file and make its Blender data self-contained."""
|
||||
result_image = None
|
||||
try:
|
||||
result_image = bpy.data.images.load(str(path), check_existing=False)
|
||||
result_image.colorspace_settings.name = source_image.colorspace_settings.name
|
||||
result_image.alpha_mode = source_image.alpha_mode
|
||||
result_image.name = name
|
||||
result_image.pack()
|
||||
except (OSError, RuntimeError, TypeError, ValueError) as error:
|
||||
if result_image is not None:
|
||||
bpy.data.images.remove(result_image, do_unlink=True)
|
||||
raise RuntimeError("Unable to prepare the RGBA image result") from error
|
||||
return result_image
|
||||
|
||||
|
||||
def configure_image_user(
|
||||
image_user,
|
||||
frame_count=None,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -326,12 +326,10 @@ def triangulated_polygon_vertices(vertices):
|
||||
return _triangulated_polygon_vertices_python(vertices)
|
||||
|
||||
|
||||
def scanline_fill_triangles(vertices, fill_rule="EVEN_ODD", step=2.0):
|
||||
def scanline_fill_triangles(vertices, step=2.0):
|
||||
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
|
||||
if len(vertices) < 3:
|
||||
return ()
|
||||
if fill_rule not in {"EVEN_ODD", "NONZERO"}:
|
||||
raise ValueError(f"Unknown polygon fill rule: {fill_rule}")
|
||||
minimum_y = min(vertex[1] for vertex in vertices)
|
||||
maximum_y = max(vertex[1] for vertex in vertices)
|
||||
band_bottom = minimum_y
|
||||
@@ -358,17 +356,11 @@ def scanline_fill_triangles(vertices, fill_rule="EVEN_ODD", step=2.0):
|
||||
while index < len(events):
|
||||
x = events[index][0]
|
||||
delta = 0
|
||||
count = 0
|
||||
while index < len(events) and abs(events[index][0] - x) <= 1e-8:
|
||||
delta += events[index][1]
|
||||
count += 1
|
||||
index += 1
|
||||
was_inside = winding != 0
|
||||
if fill_rule == "EVEN_ODD":
|
||||
if count % 2:
|
||||
winding = 0 if was_inside else 1
|
||||
else:
|
||||
winding += delta
|
||||
winding += delta
|
||||
is_inside = winding != 0
|
||||
if not was_inside and is_inside:
|
||||
interval_start = x
|
||||
@@ -454,12 +446,12 @@ def polygon_self_intersects(vertices, cell_size=32.0):
|
||||
return False
|
||||
|
||||
|
||||
def preview_fill_triangles(vertices, fill_rule="EVEN_ODD"):
|
||||
def preview_fill_triangles(vertices):
|
||||
if not polygon_self_intersects(vertices):
|
||||
triangles = triangulated_polygon_vertices(vertices)
|
||||
if triangles:
|
||||
return triangles
|
||||
return scanline_fill_triangles(vertices, fill_rule)
|
||||
return scanline_fill_triangles(vertices)
|
||||
|
||||
|
||||
def draw_polygon_fill(
|
||||
@@ -609,7 +601,6 @@ def image_line_half_plane_polygon(line, image_size):
|
||||
|
||||
class ImageSelectionGesture:
|
||||
active_tool_id = None
|
||||
preview_fill_rule = "EVEN_ODD"
|
||||
_path = None
|
||||
_cursor = None
|
||||
_handle = None
|
||||
@@ -658,9 +649,7 @@ class ImageSelectionGesture:
|
||||
if len(polygon) >= 3:
|
||||
if polygon != self._fill_polygon:
|
||||
self._fill_polygon = polygon
|
||||
self._fill_triangles = preview_fill_triangles(
|
||||
polygon, self.preview_fill_rule
|
||||
)
|
||||
self._fill_triangles = preview_fill_triangles(polygon)
|
||||
draw_polygon_fill(
|
||||
polygon,
|
||||
self._fill_triangles,
|
||||
@@ -725,6 +714,9 @@ class ImageSelectionGesture:
|
||||
def cancel(self, context):
|
||||
self._finish(context)
|
||||
|
||||
def _submit_selection_path(self, _context, _selection_path):
|
||||
raise NotImplementedError
|
||||
|
||||
def invoke(self, context, event):
|
||||
source_object, should_edit = resolve_image_edit_click(
|
||||
context, event, self.report
|
||||
@@ -740,7 +732,6 @@ class ImageSelectionGesture:
|
||||
self.invert = False
|
||||
self.source_object_name = source_object.name
|
||||
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
|
||||
self.selection_path_json = ""
|
||||
point = (float(event.mouse_region_x), float(event.mouse_region_y))
|
||||
self._path = [point]
|
||||
self._cursor = point
|
||||
@@ -787,13 +778,12 @@ class ImageSelectionGesture:
|
||||
image_empty_bounds(source_object),
|
||||
tuple(source_object.data.size),
|
||||
)
|
||||
self.selection_path_json = SelectionPath(
|
||||
selection_path = SelectionPath(
|
||||
points=selection_path,
|
||||
invert=self.invert,
|
||||
fill_rule=self.preview_fill_rule,
|
||||
).to_json()
|
||||
)
|
||||
self._finish(context)
|
||||
return self.execute(context)
|
||||
return self._submit_selection_path(context, selection_path)
|
||||
|
||||
def modal(self, context, event):
|
||||
active_tool = active_view3d_tool_id(context)
|
||||
|
||||
Reference in New Issue
Block a user