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:
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -50,7 +50,7 @@ class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator):
|
||||
|
||||
input_path: bpy.props.StringProperty(options=_HIDDEN)
|
||||
source_object_name: bpy.props.StringProperty(options=_HIDDEN)
|
||||
mesh_detail: bpy.props.IntProperty(default=0, min=0, max=10, options=_HIDDEN)
|
||||
mesh_detail: bpy.props.IntProperty(default=6, min=0, max=10, options=_HIDDEN)
|
||||
model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=_HIDDEN)
|
||||
resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=_HIDDEN)
|
||||
max_input_size: bpy.props.IntProperty(
|
||||
@@ -160,7 +160,7 @@ class ConvertToDepthPlane(bpy.types.Operator):
|
||||
bl_description = "Convert the complete Image Empty into a front-projected relief"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
mesh_detail: bpy.props.IntProperty(name="Subdivide", default=0, min=0, max=10)
|
||||
mesh_detail: bpy.props.IntProperty(name="Subdivide", default=6, min=0, max=10)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
|
||||
@@ -9,18 +9,29 @@ from ...common.node import load_node_group
|
||||
MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane"
|
||||
|
||||
|
||||
def build_mesh_plane_matrix(matrix_world, bounds):
|
||||
from mathutils import Matrix
|
||||
|
||||
x_min, x_max, y_min, y_max = bounds
|
||||
center = ((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, 0.0)
|
||||
return matrix_world @ Matrix.Translation(center)
|
||||
|
||||
|
||||
def create_mesh_plane_mesh(name, bounds):
|
||||
x_min, x_max, y_min, y_max = bounds
|
||||
if x_max <= x_min or y_max <= y_min:
|
||||
raise ValueError("The plane has invalid dimensions")
|
||||
|
||||
half_width = (x_max - x_min) * 0.5
|
||||
half_height = (y_max - y_min) * 0.5
|
||||
|
||||
mesh = bpy.data.meshes.new(name)
|
||||
mesh.from_pydata(
|
||||
(
|
||||
(x_min, y_min, 0.0),
|
||||
(x_max, y_min, 0.0),
|
||||
(x_max, y_max, 0.0),
|
||||
(x_min, y_max, 0.0),
|
||||
(-half_width, -half_height, 0.0),
|
||||
(half_width, -half_height, 0.0),
|
||||
(half_width, half_height, 0.0),
|
||||
(-half_width, half_height, 0.0),
|
||||
),
|
||||
(),
|
||||
((0, 1, 2, 3),),
|
||||
@@ -37,7 +48,6 @@ def create_mesh_plane_mesh(name, bounds):
|
||||
|
||||
def add_mesh_plane_processing(
|
||||
plane_object,
|
||||
material,
|
||||
subdivisions=0,
|
||||
thickness=0.0,
|
||||
):
|
||||
@@ -52,7 +62,6 @@ def add_mesh_plane_processing(
|
||||
for name, value in (
|
||||
("Subdivide", max(0, int(subdivisions))),
|
||||
("Thickness", max(0.0, float(thickness))),
|
||||
("Material", material),
|
||||
):
|
||||
identifier = modifier_input_identifier(node_group, name)
|
||||
set_modifier_input(modifier, identifier, value)
|
||||
@@ -72,11 +81,13 @@ def create_mesh_plane_object(
|
||||
mesh.materials.append(material)
|
||||
plane = bpy.data.objects.new(name, mesh)
|
||||
context.collection.objects.link(plane)
|
||||
if matrix_world is not None:
|
||||
plane.matrix_world = matrix_world.copy()
|
||||
if matrix_world is None:
|
||||
from mathutils import Matrix
|
||||
|
||||
matrix_world = Matrix.Identity(4)
|
||||
plane.matrix_world = build_mesh_plane_matrix(matrix_world, bounds)
|
||||
add_mesh_plane_processing(
|
||||
plane,
|
||||
material,
|
||||
subdivisions=subdivisions,
|
||||
thickness=thickness,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from ...common.image import (
|
||||
image_empty_bounds,
|
||||
image_pixels,
|
||||
load_color_result_image,
|
||||
load_normal_result_image,
|
||||
require_image_empty,
|
||||
)
|
||||
from ...common.depth import (
|
||||
@@ -21,7 +22,11 @@ from ...common.object import (
|
||||
set_modifier_input,
|
||||
)
|
||||
from ...common.node import load_node_group
|
||||
from .mesh_plane import create_mesh_plane_mesh, create_mesh_plane_object
|
||||
from .mesh_plane import (
|
||||
build_mesh_plane_matrix,
|
||||
create_mesh_plane_mesh,
|
||||
create_mesh_plane_object,
|
||||
)
|
||||
|
||||
|
||||
DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane"
|
||||
@@ -65,26 +70,27 @@ def create_depth_plane_object(
|
||||
depth_metadata,
|
||||
):
|
||||
source_name = source_object.name
|
||||
mesh = create_mesh_plane_mesh(source_name, image_empty_bounds(source_object))
|
||||
bounds = image_empty_bounds(source_object)
|
||||
mesh = create_mesh_plane_mesh(source_name, bounds)
|
||||
for polygon in mesh.polygons:
|
||||
polygon.use_smooth = True
|
||||
mesh.materials.append(material)
|
||||
plane = bpy.data.objects.new(source_name, mesh)
|
||||
context.collection.objects.link(plane)
|
||||
plane.matrix_world = source_object.matrix_world.copy()
|
||||
plane.matrix_world = build_mesh_plane_matrix(source_object.matrix_world, bounds)
|
||||
plane["anyimage_mesh_shape"] = "DEPTH_PLANE"
|
||||
|
||||
node_group = load_node_group(DEPTH_PLANE_NODE_GROUP_NAME)
|
||||
modifier = plane.modifiers.new(name=node_group.name, type="NODES")
|
||||
modifier.node_group = node_group
|
||||
modifier.show_group_selector = False
|
||||
uniform_scale = depth_uniform_scale(source_object, depth_metadata)
|
||||
for name, value in (
|
||||
("Subdivide", max(0, min(10, int(subdivisions)))),
|
||||
("Material", material),
|
||||
("Thickness", 0.1),
|
||||
("Depth Amount", 1.0),
|
||||
("Reference Depth", depth_metadata["reference_depth"]),
|
||||
("Uniform Scale", depth_uniform_scale(source_object, depth_metadata)),
|
||||
("Depth Scale", 1.0),
|
||||
("Base Plane Depth", depth_metadata["reference_depth"] * uniform_scale),
|
||||
("Uniform Scale", uniform_scale),
|
||||
("Depth Image", depth_image),
|
||||
):
|
||||
set_modifier_input(
|
||||
@@ -99,19 +105,29 @@ def create_depth_plane_from_result(context, result, operator):
|
||||
source_object = require_image_empty(operator.source_object_name)
|
||||
color_image = load_color_result_image(source_object, result.directory)
|
||||
color_image.name = color_data_name(source_object)
|
||||
material = create_image_material(
|
||||
source_object,
|
||||
color_image,
|
||||
0.0,
|
||||
scene=context.scene,
|
||||
)
|
||||
depth_metadata = load_depth_metadata(result.file("depth_metadata"))
|
||||
depth_image = load_depth_result_image(
|
||||
result.file("z_depth"),
|
||||
source_object,
|
||||
depth_metadata,
|
||||
)
|
||||
material = None
|
||||
depth_image = None
|
||||
normal_image = None
|
||||
try:
|
||||
depth_metadata = load_depth_metadata(result.file("depth_metadata"))
|
||||
depth_image = load_depth_result_image(
|
||||
result.file("z_depth"),
|
||||
source_object,
|
||||
depth_metadata,
|
||||
)
|
||||
normal_image = load_normal_result_image(
|
||||
result.file("tangent_normal"),
|
||||
source_object,
|
||||
)
|
||||
material = create_image_material(
|
||||
source_object,
|
||||
color_image,
|
||||
0.0,
|
||||
normal_image=normal_image,
|
||||
normal_space="TANGENT",
|
||||
ior=1.2,
|
||||
scene=context.scene,
|
||||
)
|
||||
create_depth_plane_object(
|
||||
context,
|
||||
source_object,
|
||||
@@ -121,8 +137,11 @@ def create_depth_plane_from_result(context, result, operator):
|
||||
depth_metadata,
|
||||
)
|
||||
except Exception:
|
||||
if depth_image.users == 0:
|
||||
bpy.data.images.remove(depth_image, do_unlink=True)
|
||||
if material is not None and material.users == 0:
|
||||
bpy.data.materials.remove(material, do_unlink=True)
|
||||
for image in (depth_image, normal_image):
|
||||
if image is not None and image.users == 0:
|
||||
bpy.data.images.remove(image, do_unlink=True)
|
||||
raise
|
||||
if "FINISHED" not in bpy.ops.ed.undo_push(message="Convert to Depth Plane"):
|
||||
raise RuntimeError("Unable to create the Convert to Depth Plane Undo step")
|
||||
|
||||
@@ -202,10 +202,10 @@ def _resize_values(values, size):
|
||||
)
|
||||
|
||||
|
||||
def extract_selection_contours(selection_mask, max_resolution=512, threshold=0.5):
|
||||
def extract_selection_contours(selection_values, max_resolution=512, threshold=0.5):
|
||||
import numpy as np
|
||||
|
||||
values = np.asarray(selection_mask.values)
|
||||
values = np.asarray(selection_values)
|
||||
if values.ndim != 2:
|
||||
raise ValueError("Cutout Alpha must be two-dimensional")
|
||||
if np.issubdtype(values.dtype, np.floating):
|
||||
@@ -1341,13 +1341,13 @@ def crop_plane_bounds(source_object, crop_bounds, source_size):
|
||||
|
||||
|
||||
def extract_cutout_contours(
|
||||
selection_mask,
|
||||
content_values,
|
||||
mesh_resolution,
|
||||
threshold=CUTOUT_ALPHA_THRESHOLD,
|
||||
):
|
||||
contour_resolution = max(512, min(int(mesh_resolution) * 8, 1024))
|
||||
return extract_selection_contours(
|
||||
selection_mask,
|
||||
content_values,
|
||||
max_resolution=contour_resolution,
|
||||
threshold=threshold,
|
||||
)
|
||||
@@ -1373,11 +1373,11 @@ def cutout_sampling_spacing(source_object, mesh_resolution):
|
||||
return max(width, height) / max(int(mesh_resolution), 8)
|
||||
|
||||
|
||||
def selection_calibration(selection_mask, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD):
|
||||
"""Return the final Selection mask and transparency ratio at geometry resolution."""
|
||||
def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD):
|
||||
"""Return Cutout validity and transparency ratio at geometry resolution."""
|
||||
import numpy as np
|
||||
|
||||
values = np.asarray(selection_mask.values, dtype=np.float32)
|
||||
values = np.asarray(content_values, dtype=np.float32)
|
||||
target_height, target_width = (int(value) for value in target_shape)
|
||||
source_height, source_width = values.shape
|
||||
y = np.minimum(
|
||||
@@ -1394,16 +1394,22 @@ def selection_calibration(selection_mask, target_shape, threshold=CUTOUT_ALPHA_T
|
||||
return valid, transparent_fraction
|
||||
|
||||
|
||||
def build_base_shape(context, source_object, mesh_resolution, selection_mask):
|
||||
def build_base_shape(
|
||||
context,
|
||||
source_object,
|
||||
mesh_resolution,
|
||||
content_values,
|
||||
content_bounds,
|
||||
):
|
||||
import numpy as np
|
||||
|
||||
bounds = crop_plane_bounds(
|
||||
source_object,
|
||||
selection_mask.bounds,
|
||||
content_bounds,
|
||||
tuple(source_object.data.size),
|
||||
)
|
||||
selection_contours = extract_cutout_contours(
|
||||
selection_mask,
|
||||
content_values,
|
||||
mesh_resolution,
|
||||
threshold=CUTOUT_ALPHA_THRESHOLD,
|
||||
)
|
||||
|
||||
@@ -5,9 +5,6 @@ from ...preferences import (
|
||||
MIN_CUTOUT_MESH_RESOLUTION,
|
||||
)
|
||||
from ...properties import ai_ready
|
||||
from ...common.image import image_rgba
|
||||
from ...common.selection import rasterize_selection_path
|
||||
from .geometry import CUTOUT_ALPHA_THRESHOLD
|
||||
from .shape import CUTOUT_SHAPE_LABELS
|
||||
|
||||
CUTOUT_SHAPE_BUTTONS = (
|
||||
@@ -25,21 +22,6 @@ CUTOUT_MESH_DETAIL_SPACING = {
|
||||
}
|
||||
|
||||
|
||||
def validate_cutout_selection(
|
||||
source_object,
|
||||
selection_path,
|
||||
):
|
||||
image = source_object.data
|
||||
rgba = image_rgba(image)
|
||||
rasterize_selection_path(
|
||||
tuple(image.size),
|
||||
selection_path,
|
||||
source_alpha=rgba[:, :, 3],
|
||||
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
|
||||
antialias=True,
|
||||
)
|
||||
|
||||
|
||||
def cutout_mesh_resolution(image_size, point_spacing):
|
||||
longest_edge = max(int(value) for value in image_size)
|
||||
detail = round(longest_edge / max(int(point_spacing), 1))
|
||||
|
||||
@@ -34,9 +34,11 @@ from ...common.ai import (
|
||||
)
|
||||
from ...common.image import (
|
||||
cleanup_image_input,
|
||||
color_data_name,
|
||||
image_rgba,
|
||||
load_normal_result_image,
|
||||
prepare_selection_input,
|
||||
load_packed_rgba_image,
|
||||
prepare_image_bounds_input,
|
||||
require_image_empty,
|
||||
source_start_frame,
|
||||
video_total_frames,
|
||||
@@ -52,7 +54,6 @@ from ...common.viewport import (
|
||||
)
|
||||
from ...common.selection import (
|
||||
ImageEditWarning,
|
||||
SelectionMask,
|
||||
SelectionPath,
|
||||
rasterize_selection_path,
|
||||
)
|
||||
@@ -61,8 +62,12 @@ from .geometry import (
|
||||
build_base_shape,
|
||||
limit_cutout_mesh_resolution,
|
||||
)
|
||||
from .interaction import open_shape_pie, validate_cutout_selection
|
||||
from .object import NormalMode, create_shape_object
|
||||
from .interaction import open_shape_pie
|
||||
from .object import (
|
||||
NormalMode,
|
||||
create_cropped_color_image,
|
||||
create_shape_object,
|
||||
)
|
||||
from .shape import CUTOUT_SHAPES, DEPTH_CUTOUT_SHAPES
|
||||
|
||||
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
|
||||
@@ -89,66 +94,99 @@ def create_cutout_shape(
|
||||
source_object,
|
||||
shape,
|
||||
mesh_resolution,
|
||||
selection_mask,
|
||||
content_values,
|
||||
content_bounds,
|
||||
*,
|
||||
depth_image=None,
|
||||
depth_metadata=None,
|
||||
normal_image=None,
|
||||
normal_mode=NormalMode.NONE,
|
||||
source_rgba=None,
|
||||
color_image,
|
||||
):
|
||||
base_shape = build_base_shape(
|
||||
context,
|
||||
source_object,
|
||||
mesh_resolution,
|
||||
selection_mask,
|
||||
)
|
||||
result = create_shape_object(
|
||||
context,
|
||||
source_object,
|
||||
shape,
|
||||
mesh_resolution,
|
||||
selection_mask,
|
||||
base_shape,
|
||||
depth_image=depth_image,
|
||||
depth_metadata=depth_metadata,
|
||||
normal_image=normal_image,
|
||||
normal_mode=normal_mode,
|
||||
source_rgba=source_rgba,
|
||||
)
|
||||
try:
|
||||
base_shape = build_base_shape(
|
||||
context,
|
||||
source_object,
|
||||
mesh_resolution,
|
||||
content_values,
|
||||
content_bounds,
|
||||
)
|
||||
result = create_shape_object(
|
||||
context,
|
||||
source_object,
|
||||
shape,
|
||||
mesh_resolution,
|
||||
content_values,
|
||||
content_bounds,
|
||||
base_shape,
|
||||
depth_image=depth_image,
|
||||
depth_metadata=depth_metadata,
|
||||
normal_image=normal_image,
|
||||
normal_mode=normal_mode,
|
||||
color_image=color_image,
|
||||
)
|
||||
except Exception:
|
||||
if color_image.users == 0:
|
||||
bpy.data.images.remove(color_image, do_unlink=True)
|
||||
raise
|
||||
undo_message = f"Convert to {shape.replace('_', ' ').title()}"
|
||||
if "FINISHED" not in bpy.ops.ed.undo_push(message=undo_message):
|
||||
raise RuntimeError(f"Unable to create the {undo_message} Undo step")
|
||||
return result
|
||||
|
||||
|
||||
def _selection_mask_from_image(path, bounds):
|
||||
image = bpy.data.images.load(str(path), check_existing=False)
|
||||
def _cutout_content_values(source_alpha, selection_values=None):
|
||||
"""Combine local image Alpha with optional pure Selection geometry."""
|
||||
import numpy as np
|
||||
|
||||
alpha = np.asarray(source_alpha, dtype=np.float32)
|
||||
if alpha.ndim != 2:
|
||||
raise ValueError("The Cutout source Alpha must be two-dimensional")
|
||||
if selection_values is None:
|
||||
values = alpha.copy()
|
||||
else:
|
||||
selection = np.asarray(selection_values, dtype=np.float32)
|
||||
if alpha.shape != selection.shape:
|
||||
raise ValueError("The Cutout source Alpha does not match the Selection")
|
||||
values = alpha * selection
|
||||
if not np.any(values > CUTOUT_ALPHA_THRESHOLD):
|
||||
raise ImageEditWarning(
|
||||
"The Cutout selection contains no visible image pixels"
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
def _generated_color_result(result, input_path, source_object, source_bounds):
|
||||
value = result.value if isinstance(result.value, dict) else {}
|
||||
if "selection_image" not in value:
|
||||
path = input_path
|
||||
bounds = source_bounds
|
||||
else:
|
||||
path = result.file("selection_image")
|
||||
local_bounds = tuple(
|
||||
int(value)
|
||||
for value in json.loads(
|
||||
result.file("selection_bounds").read_text(encoding="utf-8")
|
||||
)
|
||||
)
|
||||
bounds = (
|
||||
source_bounds[0] + local_bounds[0],
|
||||
source_bounds[1] + local_bounds[1],
|
||||
source_bounds[0] + local_bounds[2],
|
||||
source_bounds[1] + local_bounds[3],
|
||||
)
|
||||
image = load_packed_rgba_image(
|
||||
path,
|
||||
source_object.data,
|
||||
color_data_name(source_object),
|
||||
)
|
||||
try:
|
||||
return SelectionMask(
|
||||
image_rgba(image)[:, :, 3],
|
||||
tuple(int(value) for value in bounds),
|
||||
)
|
||||
finally:
|
||||
bpy.data.images.remove(image, do_unlink=True)
|
||||
|
||||
|
||||
def _generated_selection_mask(result, input_path, source_bounds):
|
||||
if not isinstance(result.value, dict) or "selection_image" not in result.value:
|
||||
return _selection_mask_from_image(input_path, source_bounds)
|
||||
local_bounds = tuple(
|
||||
int(value)
|
||||
for value in json.loads(
|
||||
result.file("selection_bounds").read_text(encoding="utf-8")
|
||||
)
|
||||
)
|
||||
bounds = (
|
||||
source_bounds[0] + local_bounds[0],
|
||||
source_bounds[1] + local_bounds[1],
|
||||
source_bounds[0] + local_bounds[2],
|
||||
source_bounds[1] + local_bounds[3],
|
||||
)
|
||||
return _selection_mask_from_image(result.file("selection_image"), bounds)
|
||||
alpha = image_rgba(image)[:, :, 3]
|
||||
except Exception:
|
||||
if image.users == 0:
|
||||
bpy.data.images.remove(image, do_unlink=True)
|
||||
raise
|
||||
return image, alpha, bounds
|
||||
|
||||
|
||||
def cutout_geometry_settings(context):
|
||||
@@ -213,14 +251,12 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
|
||||
if self.shape not in CUTOUT_SHAPES:
|
||||
raise ValueError(f"Unsupported Cutout shape: {self.shape}")
|
||||
selection_path = SelectionPath.from_json(self.selection_path_json)
|
||||
source_rgba = image_rgba(source_object.data)
|
||||
selection_mask = rasterize_selection_path(
|
||||
tuple(source_object.data.size),
|
||||
selection_path,
|
||||
source_alpha=source_rgba[:, :, 3],
|
||||
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
|
||||
antialias=True,
|
||||
)
|
||||
source_rgba = image_rgba(source_object.data)
|
||||
context.view_layer.objects.active = source_object
|
||||
source_object.select_set(True)
|
||||
crop_width = selection_mask.bounds[2] - selection_mask.bounds[0]
|
||||
@@ -232,13 +268,24 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
|
||||
)
|
||||
self.max_ai_input_size = configured_max_ai_input_size(context)
|
||||
if not self._needs_generation():
|
||||
left, top, right, bottom = selection_mask.bounds
|
||||
content_values = _cutout_content_values(
|
||||
source_rgba[top:bottom, left:right, 3],
|
||||
selection_mask.values,
|
||||
)
|
||||
color_image = create_cropped_color_image(
|
||||
source_object,
|
||||
selection_mask.bounds,
|
||||
source_rgba,
|
||||
)
|
||||
create_cutout_shape(
|
||||
context,
|
||||
source_object,
|
||||
self.shape,
|
||||
self.mesh_resolution,
|
||||
selection_mask,
|
||||
source_rgba=source_rgba,
|
||||
content_values,
|
||||
selection_mask.bounds,
|
||||
color_image=color_image,
|
||||
)
|
||||
return {"FINISHED"}
|
||||
setup_result = invoke_ai_setup_if_needed()
|
||||
@@ -249,7 +296,14 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
|
||||
selection_mask.bounds,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
self.input_path = str(prepare_selection_input(source_object.data, selection_mask))
|
||||
self._selection_mask = selection_mask
|
||||
self.input_path = str(
|
||||
prepare_image_bounds_input(
|
||||
source_object.data,
|
||||
source_rgba,
|
||||
selection_mask.bounds,
|
||||
)
|
||||
)
|
||||
except (
|
||||
ImageEditWarning,
|
||||
OSError,
|
||||
@@ -303,15 +357,31 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
|
||||
def response(self, context, result):
|
||||
source_object = require_image_empty(self.source_object_name)
|
||||
source_bounds = tuple(json.loads(self.selection_bounds_json))
|
||||
selection_mask = _generated_selection_mask(
|
||||
color_image, source_alpha, color_bounds = _generated_color_result(
|
||||
result,
|
||||
self.input_path,
|
||||
source_object,
|
||||
source_bounds,
|
||||
)
|
||||
value = result.value if isinstance(result.value, dict) else {}
|
||||
normal_image = None
|
||||
depth_image = None
|
||||
shape_started = False
|
||||
try:
|
||||
if "selection_image" in value:
|
||||
content_values = _cutout_content_values(
|
||||
source_alpha,
|
||||
)
|
||||
content_bounds = color_bounds
|
||||
else:
|
||||
selection_mask = getattr(self, "_selection_mask", None)
|
||||
if selection_mask is None:
|
||||
raise RuntimeError("The Cutout Selection is no longer available")
|
||||
content_values = _cutout_content_values(
|
||||
source_alpha,
|
||||
selection_mask.values,
|
||||
)
|
||||
content_bounds = selection_mask.bounds
|
||||
normal_mode = normal_mode_for_shape(self.shape, self.generate_normal)
|
||||
normal_key = f"{normal_mode.value.lower()}_normal"
|
||||
if normal_key in value:
|
||||
@@ -328,22 +398,27 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
|
||||
source_object,
|
||||
depth_metadata,
|
||||
)
|
||||
shape_started = True
|
||||
create_cutout_shape(
|
||||
context,
|
||||
source_object,
|
||||
self.shape,
|
||||
self.mesh_resolution,
|
||||
selection_mask,
|
||||
content_values,
|
||||
content_bounds,
|
||||
depth_image=depth_image,
|
||||
depth_metadata=depth_metadata,
|
||||
normal_image=normal_image,
|
||||
normal_mode=normal_mode,
|
||||
color_image=color_image,
|
||||
)
|
||||
except Exception:
|
||||
if normal_image is not None and normal_image.users == 0:
|
||||
bpy.data.images.remove(normal_image, do_unlink=True)
|
||||
if depth_image is not None and depth_image.users == 0:
|
||||
bpy.data.images.remove(depth_image, do_unlink=True)
|
||||
if not shape_started and color_image.users == 0:
|
||||
bpy.data.images.remove(color_image, do_unlink=True)
|
||||
raise
|
||||
return f"{self.shape.replace('_', ' ').title()} is ready"
|
||||
|
||||
@@ -361,13 +436,11 @@ class SelectCutoutSelection(ImageSelectionGesture, bpy.types.Operator):
|
||||
|
||||
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
|
||||
source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
|
||||
selection_path_json: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
|
||||
refine_selection: refine_selection_property()
|
||||
invert: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
gesture: gesture_property()
|
||||
full_image: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
preview_fill_rule = "NONZERO"
|
||||
_pie_event = None
|
||||
|
||||
def _set_status(self, context):
|
||||
@@ -396,28 +469,21 @@ class SelectCutoutSelection(ImageSelectionGesture, bpy.types.Operator):
|
||||
self.source_object_name = source_object.name
|
||||
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
|
||||
width, height = (int(value) for value in source_object.data.size)
|
||||
self.selection_path_json = SelectionPath(
|
||||
selection_path = SelectionPath(
|
||||
points=((0, 0), (width, 0), (width, height), (0, height)),
|
||||
fill_rule="NONZERO",
|
||||
).to_json()
|
||||
return self.execute(context)
|
||||
)
|
||||
return self._submit_selection_path(context, selection_path)
|
||||
|
||||
def modal(self, context, event):
|
||||
self._pie_event = event
|
||||
return super().modal(context, event)
|
||||
|
||||
def execute(self, context):
|
||||
def _submit_selection_path(self, context, selection_path):
|
||||
try:
|
||||
source_object = require_image_empty(self.source_object_name)
|
||||
except RuntimeError as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
selection_path = SelectionPath.from_json(self.selection_path_json)
|
||||
validate_cutout_selection(source_object, selection_path)
|
||||
except (ImageEditWarning, TypeError, ValueError) as error:
|
||||
report_image_edit_exception(self, error)
|
||||
return {"CANCELLED"}
|
||||
open_shape_pie(
|
||||
context,
|
||||
self._pie_event,
|
||||
|
||||
@@ -4,10 +4,7 @@ from enum import StrEnum
|
||||
|
||||
import bpy
|
||||
|
||||
from ...common.image import (
|
||||
color_data_name,
|
||||
image_rgba,
|
||||
)
|
||||
from ...common.image import color_data_name
|
||||
from ...common.depth import camera_depth_values, depth_uniform_scale
|
||||
from ...common.material import (
|
||||
IMAGE_MATERIAL_NODE_GROUP_NAME,
|
||||
@@ -84,8 +81,8 @@ def _assign_float_point_attribute(mesh, name, values):
|
||||
attribute.data.foreach_set("value", np.asarray(values, dtype=np.float32))
|
||||
|
||||
|
||||
def _cropped_color_image(source_object, selection_mask, source_rgba):
|
||||
crop_bounds = selection_mask.bounds
|
||||
def create_cropped_color_image(source_object, crop_bounds, source_rgba):
|
||||
"""Create a packed color image for one local Cutout bounds rectangle."""
|
||||
import numpy as np
|
||||
|
||||
source = source_object.data
|
||||
@@ -95,17 +92,10 @@ def _cropped_color_image(source_object, selection_mask, source_rgba):
|
||||
height = bottom - top
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("The Cutout crop dimensions must be positive")
|
||||
cropped = np.zeros((height, width, 4), dtype=np.float32)
|
||||
source_height, source_width = rgba.shape[:2]
|
||||
source_left = max(0, left)
|
||||
source_top = max(0, top)
|
||||
source_right = min(source_width, right)
|
||||
source_bottom = min(source_height, bottom)
|
||||
if source_right > source_left and source_bottom > source_top:
|
||||
cropped[
|
||||
source_top - top : source_bottom - top,
|
||||
source_left - left : source_right - left,
|
||||
] = rgba[source_top:source_bottom, source_left:source_right]
|
||||
if left < 0 or top < 0 or right > source_width or bottom > source_height:
|
||||
raise ValueError("The Cutout crop is outside the source RGBA")
|
||||
cropped = rgba[top:bottom, left:right]
|
||||
image = bpy.data.images.new(
|
||||
color_data_name(source_object),
|
||||
width=width,
|
||||
@@ -151,21 +141,22 @@ def create_shape_object(
|
||||
source_object,
|
||||
shape,
|
||||
mesh_resolution,
|
||||
selection_mask,
|
||||
content_values,
|
||||
content_bounds,
|
||||
base_shape,
|
||||
*,
|
||||
depth_image=None,
|
||||
depth_metadata=None,
|
||||
normal_image=None,
|
||||
normal_mode=NormalMode.NONE,
|
||||
source_rgba=None,
|
||||
color_image,
|
||||
):
|
||||
import numpy as np
|
||||
|
||||
normal_mode = NormalMode(normal_mode)
|
||||
bounds = crop_plane_bounds(
|
||||
source_object,
|
||||
selection_mask.bounds,
|
||||
content_bounds,
|
||||
tuple(source_object.data.size),
|
||||
)
|
||||
crop_center = (
|
||||
@@ -177,11 +168,6 @@ def create_shape_object(
|
||||
cutout_sampling_spacing(source_object, mesh_resolution),
|
||||
world_metric,
|
||||
)
|
||||
color_image = _cropped_color_image(
|
||||
source_object,
|
||||
selection_mask,
|
||||
image_rgba(source_object.data) if source_rgba is None else source_rgba,
|
||||
)
|
||||
material = None
|
||||
mesh = None
|
||||
mesh_object = None
|
||||
@@ -209,7 +195,7 @@ def create_shape_object(
|
||||
raise ValueError("Depth Shape requires a Depth texture and metadata")
|
||||
camera_depth, valid = camera_depth_values(depth_image)
|
||||
calibration_valid, transparent_fraction = selection_calibration(
|
||||
selection_mask, valid.shape
|
||||
content_values, valid.shape
|
||||
)
|
||||
calibration_valid &= valid
|
||||
uniform_scale = depth_uniform_scale(
|
||||
@@ -311,6 +297,4 @@ def create_shape_object(
|
||||
bpy.data.meshes.remove(mesh)
|
||||
if material is not None and material.users == 0:
|
||||
bpy.data.materials.remove(material)
|
||||
if color_image.users == 0:
|
||||
bpy.data.images.remove(color_image, do_unlink=True)
|
||||
raise
|
||||
|
||||
@@ -12,9 +12,10 @@ from ...common.ai import (
|
||||
from ...common.image import (
|
||||
cleanup_image_input,
|
||||
create_pixel_result_image,
|
||||
image_pixels,
|
||||
image_base_name,
|
||||
image_rgba,
|
||||
prepare_selection_input,
|
||||
load_packed_rgba_image,
|
||||
prepare_image_bounds_input,
|
||||
replace_empty_image,
|
||||
require_image_empty,
|
||||
video_total_frames,
|
||||
@@ -27,69 +28,47 @@ from ...common.viewport import (
|
||||
)
|
||||
from ...common.selection import (
|
||||
ImageEditWarning,
|
||||
SelectionPath,
|
||||
crop_pixels_to_alpha,
|
||||
rasterize_selection_path,
|
||||
)
|
||||
|
||||
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
|
||||
|
||||
|
||||
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,
|
||||
source_rgba,
|
||||
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))
|
||||
)
|
||||
mask = np.asarray(selection_mask, dtype=np.float32)
|
||||
if invert:
|
||||
mask = 1.0 - mask
|
||||
mask = np.where(rgba[:, :, 3] > 0.0, mask, 0.0)
|
||||
rgba[:, :, 3] *= mask
|
||||
return crop_pixels_to_alpha(
|
||||
np.flipud(rgba).ravel(),
|
||||
(0, 0, width, height),
|
||||
left, top, right, bottom = selection_mask.bounds
|
||||
rgba = np.asarray(source_rgba, dtype=np.float32)[top:bottom, left:right].copy()
|
||||
if rgba.shape[:2] != selection_mask.values.shape:
|
||||
raise ValueError("The Selection does not match the source RGBA")
|
||||
rgba[:, :, 3] *= selection_mask.values
|
||||
visible = rgba[:, :, 3] > 0.0
|
||||
visible_y = np.flatnonzero(np.any(visible, axis=1))
|
||||
visible_x = np.flatnonzero(np.any(visible, axis=0))
|
||||
if not len(visible_x):
|
||||
raise ImageEditWarning("The edit contains no visible image pixels")
|
||||
content_left = int(visible_x[0])
|
||||
content_top = int(visible_y[0])
|
||||
content_right = int(visible_x[-1]) + 1
|
||||
content_bottom = int(visible_y[-1]) + 1
|
||||
output = rgba[content_top:content_bottom, content_left:content_right]
|
||||
return np.flipud(output).ravel(), (
|
||||
left + content_left,
|
||||
top + content_top,
|
||||
left + content_right,
|
||||
top + content_bottom,
|
||||
)
|
||||
|
||||
|
||||
def _edit_image_object(source_object, selection_mask, invert=False):
|
||||
def _edit_image_object(source_object, source_rgba, selection_mask):
|
||||
image = source_object.data
|
||||
source_size = tuple(image.size)
|
||||
pixels, crop_bounds = _edit_image_mask_pixels(
|
||||
image_pixels(image),
|
||||
source_size,
|
||||
source_rgba,
|
||||
selection_mask,
|
||||
invert,
|
||||
)
|
||||
left, top, right, bottom = crop_bounds
|
||||
result_image = create_pixel_result_image(
|
||||
@@ -116,7 +95,6 @@ class EditImageMask(ImageSelectionGesture, bpy.types.Operator):
|
||||
|
||||
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
|
||||
source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
|
||||
selection_path_json: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
|
||||
refine_selection: refine_selection_property()
|
||||
invert: bpy.props.BoolProperty(
|
||||
name="Invert",
|
||||
@@ -126,25 +104,25 @@ class EditImageMask(ImageSelectionGesture, bpy.types.Operator):
|
||||
)
|
||||
gesture: gesture_property()
|
||||
|
||||
def execute(self, context):
|
||||
def _submit_selection_path(self, context, selection_path):
|
||||
try:
|
||||
source_object = require_image_empty(self.source_object_name)
|
||||
except RuntimeError as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
return {"CANCELLED"}
|
||||
try:
|
||||
selection_path = SelectionPath.from_json(self.selection_path_json)
|
||||
if video_total_frames(source_object.data) > 1:
|
||||
raise RuntimeError("Image tools only support a still image")
|
||||
selection_mask = rasterize_selection_path(
|
||||
tuple(source_object.data.size),
|
||||
selection_path,
|
||||
source_alpha=image_rgba(source_object.data)[:, :, 3],
|
||||
)
|
||||
source_rgba = image_rgba(source_object.data)
|
||||
if self.refine_selection:
|
||||
source_path = prepare_selection_input(
|
||||
source_path = prepare_image_bounds_input(
|
||||
source_object.data,
|
||||
selection_mask,
|
||||
source_rgba,
|
||||
selection_mask.bounds,
|
||||
)
|
||||
result = bpy.ops.anyimage.refine_image_selection(
|
||||
input_path=str(source_path),
|
||||
@@ -157,9 +135,10 @@ class EditImageMask(ImageSelectionGesture, bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
_edit_image_object(
|
||||
source_object,
|
||||
selection_mask.full_values(tuple(source_object.data.size)),
|
||||
source_rgba,
|
||||
selection_mask,
|
||||
)
|
||||
except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error:
|
||||
except (RuntimeError, TypeError, ValueError) as error:
|
||||
report_image_edit_exception(self, error)
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
@@ -197,24 +176,22 @@ class RefineImageSelection(JobOperatorBase, bpy.types.Operator):
|
||||
source_bounds[0] + local_bounds[2],
|
||||
source_bounds[1] + local_bounds[3],
|
||||
)
|
||||
refined_image = bpy.data.images.load(
|
||||
str(result.file("selection_image")), check_existing=False
|
||||
source_image = source_object.data
|
||||
refined_image = load_packed_rgba_image(
|
||||
result.file("selection_image"),
|
||||
source_image,
|
||||
image_base_name(source_image),
|
||||
)
|
||||
try:
|
||||
refined_pixels = image_pixels(refined_image)
|
||||
mask = _refined_mask_from_pixels(
|
||||
refined_pixels,
|
||||
tuple(refined_image.size),
|
||||
bounds,
|
||||
tuple(source_object.data.size),
|
||||
replace_empty_image(
|
||||
source_object,
|
||||
refined_image,
|
||||
placement_bounds=bounds,
|
||||
)
|
||||
finally:
|
||||
bpy.data.images.remove(refined_image, do_unlink=True)
|
||||
try:
|
||||
_edit_image_object(source_object, mask)
|
||||
except ImageEditWarning as error:
|
||||
self.report({"WARNING"}, str(error))
|
||||
return str(error)
|
||||
except Exception:
|
||||
if refined_image.users == 0:
|
||||
bpy.data.images.remove(refined_image, do_unlink=True)
|
||||
raise
|
||||
if "FINISHED" not in bpy.ops.ed.undo_push(message="Edit Image Mask"):
|
||||
raise RuntimeError("Unable to create the Edit Image Mask Undo step")
|
||||
return "Image Selection refined"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Generate optional AI files for one Blender-rasterized Cutout Selection."""
|
||||
"""Generate optional AI files from one rectangular Cutout image input."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -16,6 +16,7 @@ def run(context, parameters):
|
||||
parameters,
|
||||
images[0],
|
||||
depth_mode="Z",
|
||||
normal_mode="TANGENT",
|
||||
)
|
||||
context.check_cancelled()
|
||||
context.progress(1.0, "Depth Plane data is ready")
|
||||
|
||||
@@ -5,14 +5,16 @@ def _crop_to_alpha(image):
|
||||
import numpy as np
|
||||
|
||||
pixels = np.asarray(image.convert("RGBA"), dtype=np.uint8)
|
||||
visible_y, visible_x = np.nonzero(pixels[:, :, 3] > 0)
|
||||
visible = pixels[:, :, 3] > 0
|
||||
visible_y = np.flatnonzero(np.any(visible, axis=1))
|
||||
visible_x = np.flatnonzero(np.any(visible, axis=0))
|
||||
if not len(visible_x):
|
||||
raise ValueError("The generated Selection contains no visible pixels")
|
||||
bounds = (
|
||||
int(visible_x.min()),
|
||||
int(visible_y.min()),
|
||||
int(visible_x.max()) + 1,
|
||||
int(visible_y.max()) + 1,
|
||||
int(visible_x[0]),
|
||||
int(visible_y[0]),
|
||||
int(visible_x[-1]) + 1,
|
||||
int(visible_y[-1]) + 1,
|
||||
)
|
||||
return image.crop(bounds), bounds
|
||||
|
||||
@@ -24,7 +26,7 @@ def refine_selection(
|
||||
device,
|
||||
output_path,
|
||||
):
|
||||
"""Refine an image Selection from an already rasterized Selection."""
|
||||
"""Refine foreground Alpha within one rectangular Selection context."""
|
||||
model_manager = context.resource("model_manager")
|
||||
foreground, _load_ms, _inference_ms = infer_foreground(
|
||||
input_path,
|
||||
|
||||
Reference in New Issue
Block a user