Files
AnyImage/src/anyimage/operators/image_edit/mask.py
nan 0cf04a13c6 feat: Implement upscale functionality with ONNX models
- Added upscale operator to handle image upscaling by 4x using AI models.
- Introduced new jobs for upscale processing in the server, including debug and standard upscale jobs.
- Created a new ONNX model handler for upscale operations, supporting tiled inference and resource management.
- Updated tests to cover new upscale functionality, including input validation and output verification.
- Added documentation for the upscale feature, detailing usage and model options.
2026-08-23 20:34:35 +08:00

590 lines
21 KiB
Python

import json
import math
import bpy
from ...properties import (
image_edit_refine_selection_property as refine_selection_property,
)
from ...runtime import JobOperatorBase
from ..balloon.operator import (
deserialize_matrix,
drawing_in_region,
hidden_string_property,
screen_path_to_image_pixels,
serialize_matrix,
simplify_screen_path,
)
from ..common import HIDDEN, cleanup_temporary_input, push_undo
from ..image import (
is_image_empty,
plane_bounds,
prepare_image_input,
remove_temporary_input,
video_total_frames,
)
from .common import (
active_view3d_tool_id,
apply_image_mask_settings,
crop_pixels_to_alpha,
draw_dashed_line,
draw_image_mask_operator_settings,
draw_polygon_fill,
ImageEditWarning,
image_edit_poll,
image_mask_refine_request,
polygon_bounds,
replace_empty_image_pixels,
report_image_edit_exception,
resolve_image_edit_click,
)
GESTURE_TOOL_IDS = {
"BOX": "anyimage.image_box",
"LASSO": "anyimage.image_lasso",
"LINE": "anyimage.image_line",
"POLYLINE": "anyimage.image_polyline",
}
LINE_SNAP_RADIANS = math.radians(5.0)
POLYLINE_CLOSE_RADIUS = 8.0
def points_inside_polygon(points, polygon):
import numpy as np
points = np.asarray(points, dtype=np.float64)
polygon = np.asarray(polygon, dtype=np.float64)
inside = np.zeros(len(points), dtype=bool)
previous = polygon[-1]
for current in polygon:
crosses = (current[1] > points[:, 1]) != (previous[1] > points[:, 1])
edge_x = (
(previous[0] - current[0])
* (points[:, 1] - current[1])
/ (previous[1] - current[1] + 1e-300)
+ current[0]
)
inside ^= crosses & (points[:, 0] < edge_x)
previous = current
return inside
def polygon_mask(image_size, polygon):
import numpy as np
width, height = (int(value) for value in image_size)
if width <= 0 or height <= 0:
raise ValueError("The source image has no readable dimensions")
polygon_bounds(polygon)
y, x = np.indices((height, width), dtype=np.float64)
centers = np.column_stack((x.ravel() + 0.5, y.ravel() + 0.5))
return points_inside_polygon(centers, polygon).reshape((height, width)).astype(
np.float32
)
def refined_mask_from_pixels(mask_pixels, mask_size, bounds, source_size):
import numpy as np
mask_width, mask_height = (int(value) for value in mask_size)
source_width, source_height = (int(value) for value in source_size)
local = np.flipud(
np.asarray(mask_pixels, dtype=np.float32).reshape(
(mask_height, mask_width, 4)
)
)[:, :, 3]
result = np.zeros((source_height, source_width), dtype=np.float32)
left, top, right, bottom = (int(value) for value in bounds)
source_left = max(left, 0)
source_top = max(top, 0)
source_right = min(right, source_width)
source_bottom = min(bottom, source_height)
if source_right <= source_left or source_bottom <= source_top:
return result
result[source_top:source_bottom, source_left:source_right] = local[
source_top - top : source_bottom - top,
source_left - left : source_right - left,
]
return result
def edit_image_mask_pixels(source_pixels, source_size, selection_mask, invert=False):
"""Apply a mask while preserving source RGB, then tightly crop the canvas."""
import numpy as np
width, height = (int(value) for value in source_size)
rgba = np.flipud(
np.asarray(source_pixels, dtype=np.float32).reshape((height, width, 4))
).copy()
mask = np.asarray(selection_mask, dtype=np.float32)
if mask.shape != (height, width):
raise ValueError("The refined selection does not match the source image")
mask = np.clip(mask, 0.0, 1.0)
if invert:
mask = 1.0 - mask
rgba[:, :, 3] *= mask
return crop_pixels_to_alpha(np.flipud(rgba).ravel(), (0, 0, width, height))
def edit_image_object(source_object, selection_mask, invert=False):
import numpy as np
image = source_object.data
source_size = tuple(image.size)
source_pixels = np.empty(len(image.pixels), dtype=np.float32)
image.pixels.foreach_get(source_pixels)
pixels, crop_bounds = edit_image_mask_pixels(
source_pixels, source_size, selection_mask, invert
)
left, top, right, bottom = crop_bounds
return replace_empty_image_pixels(
source_object,
pixels,
(right - left, bottom - top),
crop_bounds,
)
def gesture_property():
return bpy.props.EnumProperty(
items=(
("BOX", "Box", "Edit with a rectangular gesture"),
("LASSO", "Lasso", "Edit with a freehand gesture"),
("LINE", "Line", "Edit one side of a line"),
("POLYLINE", "Polyline", "Edit with a clicked polygon"),
),
default="BOX",
options={"HIDDEN", "SKIP_SAVE"},
)
def box_screen_path(start, end):
x0, y0 = start
x1, y1 = end
if abs(x1 - x0) < 2.0 or abs(y1 - y0) < 2.0:
return ()
return ((x0, y0), (x1, y0), (x1, y1), (x0, y1))
def snapped_line_endpoint(start, end, snap):
if not snap:
return tuple(float(value) for value in end)
delta_x = float(end[0]) - float(start[0])
delta_y = float(end[1]) - float(start[1])
length = math.hypot(delta_x, delta_y)
if length <= 0.0:
return tuple(float(value) for value in end)
angle = round(math.atan2(delta_y, delta_x) / LINE_SNAP_RADIANS)
angle *= LINE_SNAP_RADIANS
return (
float(start[0]) + math.cos(angle) * length,
float(start[1]) + math.sin(angle) * length,
)
def line_half_plane_polygon(start, end, rectangle_size):
"""Clip a rectangle to the left side of one directed line."""
width, height = (float(value) for value in rectangle_size)
if math.dist(start, end) < 2.0:
return ()
vertices = [(0.0, 0.0), (width, 0.0), (width, height), (0.0, height)]
def side(point):
return (end[0] - start[0]) * (point[1] - start[1]) - (
end[1] - start[1]
) * (point[0] - start[0])
result = []
previous = vertices[-1]
previous_side = side(previous)
for current in vertices:
current_side = side(current)
if (current_side >= 0.0) != (previous_side >= 0.0):
factor = previous_side / (previous_side - current_side)
result.append(
(
previous[0] + (current[0] - previous[0]) * factor,
previous[1] + (current[1] - previous[1]) * factor,
)
)
if current_side >= 0.0:
result.append(current)
previous = current
previous_side = current_side
return tuple(result)
def image_line_half_plane_polygon(line, image_size):
"""Keep the image-space half-plane shown by the screen-space preview."""
if len(line) != 2:
raise ValueError("Drag a longer line across the image plane")
return line_half_plane_polygon(line[1], line[0], image_size)
class EditImageMask(bpy.types.Operator):
bl_idname = "anyimage.edit_image_mask"
bl_label = "Edit Image Mask"
bl_description = "Edit and tightly crop an Image Empty with a gesture"
bl_options = {"REGISTER", "UNDO"}
source_object_name: hidden_string_property()
source_matrix_data: hidden_string_property()
selection_data: hidden_string_property()
refine_selection: refine_selection_property()
invert: bpy.props.BoolProperty(
name="Invert",
description="Keep the opposite side of the refined gesture mask",
default=False,
options={"SKIP_SAVE"},
)
gesture: gesture_property()
_path = None
_cursor = None
_handle = None
_area_pointer = 0
_region_pointer = 0
_region_size = (0, 0)
def draw(self, _context):
draw_image_mask_operator_settings(self.layout, self)
@classmethod
def poll(cls, context):
return image_edit_poll(context)
def _screen_polygon(self):
if self.gesture == "BOX":
return box_screen_path(self._path[0], self._cursor)
if self.gesture == "LASSO":
return tuple(simplify_screen_path(self._path))
if self.gesture == "LINE":
return line_half_plane_polygon(
self._path[0], self._cursor, self._region_size
)
vertices = list(self._path)
if self._cursor is not None:
vertices.append(self._cursor)
return tuple(vertices)
def _draw_selection(self):
if not self._path or not drawing_in_region(
self._area_pointer, self._region_pointer
):
return
polygon = self._screen_polygon()
if len(polygon) >= 3:
draw_polygon_fill(
polygon,
(1.0, 0.25, 0.2, 0.16)
if self.invert
else (1.0, 1.0, 1.0, 0.16),
)
if self.gesture == "LINE":
draw_dashed_line((self._path[0], self._cursor))
return
if len(polygon) > 1:
draw_dashed_line((*polygon, polygon[0]))
if (
self.gesture == "POLYLINE"
and len(self._path) >= 3
and self._cursor is not None
and math.dist(self._cursor, self._path[0]) <= POLYLINE_CLOSE_RADIUS
):
self._draw_polyline_close_indicator()
def _draw_polyline_close_indicator(self):
import gpu
from gpu_extras.batch import batch_for_shader
center_x, center_y = self._path[0]
vertices = tuple(
(
center_x + math.cos(index * math.tau / 24.0) * POLYLINE_CLOSE_RADIUS,
center_y + math.sin(index * math.tau / 24.0) * POLYLINE_CLOSE_RADIUS,
)
for index in range(25)
)
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
batch = batch_for_shader(shader, "LINE_STRIP", {"pos": vertices})
gpu.state.blend_set("ALPHA")
gpu.state.line_width_set(2.0)
shader.bind()
shader.uniform_float("color", (1.0, 1.0, 1.0, 0.95))
batch.draw(shader)
gpu.state.line_width_set(1.0)
gpu.state.blend_set("NONE")
def _set_status(self, context):
invert = "On" if self.invert else "Off"
if self.gesture == "POLYLINE":
action = "click points; click start or Enter to finish; Backspace removes"
elif self.gesture == "LINE":
action = "drag line; Ctrl snaps 5°; release to edit"
else:
action = "drag and release to edit"
context.workspace.status_text_set(
f"Image {self.gesture.title()}: {action}; F toggles Invert ({invert}); "
"RMB or Esc cancels"
)
def _finish(self, context):
if self._handle is not None:
bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW")
self._handle = None
context.workspace.status_text_set(None)
if context.area is not None:
context.area.tag_redraw()
def cancel(self, context):
self._finish(context)
def invoke(self, context, event):
source_object, should_edit = resolve_image_edit_click(
context, event, self.report
)
if not should_edit:
return {"FINISHED"} if source_object is not None else {"CANCELLED"}
if source_object is None:
return {"CANCELLED"}
if video_total_frames(source_object.data) > 1:
self.report({"WARNING"}, "Image tools only support a still image")
return {"CANCELLED"}
apply_image_mask_settings(self, context)
self.invert = False
self.source_object_name = source_object.name
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
self.selection_data = ""
point = (float(event.mouse_region_x), float(event.mouse_region_y))
self._path = [point]
self._cursor = point
self._area_pointer = context.area.as_pointer()
self._region_pointer = context.region.as_pointer()
self._region_size = (context.region.width, context.region.height)
self._handle = bpy.types.SpaceView3D.draw_handler_add(
self._draw_selection, (), "WINDOW", "POST_PIXEL"
)
self._set_status(context)
context.window_manager.modal_handler_add(self)
context.area.tag_redraw()
return {"RUNNING_MODAL"}
def _complete(self, context):
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
raise RuntimeError("The source Image Empty is no longer available")
matrix = deserialize_matrix(self.source_matrix_data)
if self.gesture == "LINE":
line = screen_path_to_image_pixels(
(self._path[0], self._cursor),
context.region,
context.region_data,
matrix,
plane_bounds(source_object),
tuple(source_object.data.size),
)
polygon = image_line_half_plane_polygon(
line, tuple(source_object.data.size)
)
else:
path = self._screen_polygon()
if len(path) < 3:
self._finish(context)
return {"CANCELLED"}
polygon = screen_path_to_image_pixels(
path,
context.region,
context.region_data,
matrix,
plane_bounds(source_object),
tuple(source_object.data.size),
)
polygon_bounds(polygon)
self.selection_data = json.dumps(polygon, separators=(",", ":"))
self._finish(context)
return self.execute(context)
def modal(self, context, event):
active_tool = active_view3d_tool_id(context)
if active_tool is not None and active_tool != GESTURE_TOOL_IDS[self.gesture]:
self._finish(context)
return {"CANCELLED"}
if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS":
self._finish(context)
return {"CANCELLED"}
if event.type == "F" and event.value == "PRESS":
self.invert = not self.invert
self._set_status(context)
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if self.gesture == "POLYLINE":
return self._modal_polyline(context, event)
if event.type == "MOUSEMOVE":
point = (float(event.mouse_region_x), float(event.mouse_region_y))
if self.gesture == "LASSO":
self._path.append(point)
self._cursor = point
elif self.gesture == "LINE":
self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl)
else:
self._cursor = point
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
point = (float(event.mouse_region_x), float(event.mouse_region_y))
if self.gesture == "LASSO":
self._path.append(point)
self._cursor = point
elif self.gesture == "LINE":
self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl)
if math.dist(self._path[0], self._cursor) < 2.0:
self._finish(context)
return {"CANCELLED"}
else:
self._cursor = point
try:
return self._complete(context)
except (RuntimeError, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
self._finish(context)
return {"CANCELLED"}
return {"PASS_THROUGH"}
def _modal_polyline(self, context, event):
if event.type == "BACK_SPACE" and event.value == "PRESS":
if len(self._path) > 1:
self._path.pop()
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if event.type in {"RET", "NUMPAD_ENTER"} and event.value == "PRESS":
return self._confirm_polyline(context)
if event.type == "MOUSEMOVE":
self._cursor = (
float(event.mouse_region_x),
float(event.mouse_region_y),
)
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if event.type == "LEFTMOUSE" and event.value == "PRESS":
point = (float(event.mouse_region_x), float(event.mouse_region_y))
if (
len(self._path) >= 3
and math.dist(point, self._path[0]) <= POLYLINE_CLOSE_RADIUS
):
return self._confirm_polyline(context)
if math.dist(point, self._path[-1]) >= 2.0:
self._path.append(point)
context.area.tag_redraw()
return {"RUNNING_MODAL"}
return {"PASS_THROUGH"}
def _confirm_polyline(self, context):
self._cursor = self._path[0]
try:
return self._complete(context)
except (RuntimeError, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
self._finish(context)
return {"CANCELLED"}
def execute(self, context):
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
self.report({"ERROR"}, "The source Image Empty is no longer available")
return {"CANCELLED"}
try:
polygon = json.loads(self.selection_data)
polygon_bounds(polygon)
if video_total_frames(source_object.data) > 1:
raise RuntimeError("Image tools only support a still image")
if self.refine_selection:
source_path, delete_input = prepare_image_input(
source_object.data, context.scene
)
result = bpy.ops.anyimage.refine_image_mask(
input_path=str(source_path),
delete_input=delete_input,
source_object_name=self.source_object_name,
selection_data=self.selection_data,
invert=self.invert,
)
if "CANCELLED" in result:
if delete_input:
remove_temporary_input(source_path)
return {"CANCELLED"}
return {"FINISHED"}
edit_image_object(
source_object,
polygon_mask(tuple(source_object.data.size), polygon),
self.invert,
)
except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
return {"CANCELLED"}
return {"FINISHED"}
class RefineImageMask(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.refine_image_mask"
bl_label = "Refine Image Mask"
job_type = "refine-image-mask"
starting_message = "Refining image selection"
input_path: bpy.props.StringProperty(options=HIDDEN)
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
source_object_name: hidden_string_property()
selection_data: hidden_string_property()
invert: bpy.props.BoolProperty(default=False, options=HIDDEN)
def request(self, _context):
return image_mask_refine_request(self.input_path, self.selection_data)
def response(self, _context, result):
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
raise RuntimeError("The source Image Empty is no longer available")
import numpy as np
bounds = tuple(json.loads(result.file("bounds").read_text(encoding="utf-8")))
refined_image = bpy.data.images.load(
str(result.file("image")), check_existing=False
)
try:
refined_pixels = np.empty(len(refined_image.pixels), dtype=np.float32)
refined_image.pixels.foreach_get(refined_pixels)
mask = refined_mask_from_pixels(
refined_pixels,
tuple(refined_image.size),
bounds,
tuple(source_object.data.size),
)
finally:
bpy.data.images.remove(refined_image, do_unlink=True)
try:
edit_image_object(source_object, mask, self.invert)
except ImageEditWarning as error:
self.report({"WARNING"}, str(error))
return str(error)
push_undo("Edit Image Mask")
return "Image selection refined"
def cleanup(self):
cleanup_temporary_input(self.input_path, self.delete_input)
class ActivateImageEditTool(bpy.types.Operator):
bl_idname = "anyimage.activate_image_edit_tool"
bl_label = "Image Edit Tool"
bl_description = "Activate the Image Box tool in the 3D Viewport"
bl_options = {"INTERNAL"}
@classmethod
def poll(cls, context):
return image_edit_poll(context)
def execute(self, _context):
return bpy.ops.wm.tool_set_by_id(
name="anyimage.image_box", space_type="VIEW_3D"
)