Refactor image processing and cutout functionalities

- Updated the naming convention for upscaled images to include underscores and file extensions.
- Renamed properties related to cutout pixel size to point spacing for clarity.
- Adjusted alpha region calculations to use a half-alpha threshold for better accuracy.
- Enhanced the handling of foreground mesh masks to consider half-alpha values.
- Introduced new functions for unbounded selections and cutout images, ensuring proper handling of source and selection images.
- Improved test coverage for cutout functionalities, including bounds mapping and alpha preservation.
- Updated tests to reflect changes in naming conventions and functionality adjustments.
This commit is contained in:
2026-08-27 12:01:47 +08:00
parent 2b79a342b0
commit 6c8358f92f
27 changed files with 1058 additions and 223 deletions

View File

@@ -267,11 +267,12 @@ def extract_balloon_object(
depth_data=None,
normal_path=None,
material_image=None,
local_bounds=None,
bake_normal_to_tangent=False,
):
image = material_image or source_object.data
image_size = tuple(image.size)
local_bounds = plane_bounds(source_object)
local_bounds = local_bounds or plane_bounds(source_object)
world_metric, world_z_scale = world_plane_metric(matrix_world)
point_spacing, inflation = balloon_geometry_parameters(
thickness,

View File

@@ -26,9 +26,9 @@ CUTOUT_DETAIL_MIN = 4
CUTOUT_DETAIL_MAX = 1024
def cutout_mesh_detail(image_size, pixel_size):
def cutout_mesh_detail(image_size, point_spacing):
longest_edge = max(int(value) for value in image_size)
detail = round(longest_edge / max(int(pixel_size), 1))
detail = round(longest_edge / max(int(point_spacing), 1))
return max(CUTOUT_DETAIL_MIN, min(CUTOUT_DETAIL_MAX, detail))
@@ -69,9 +69,9 @@ def _open_shape_pie(
source_name = source_object.name
selection_data = json.dumps(polygon, separators=(",", ":"))
settings = context.scene.anyimage_settings
pixel_size = settings.cutout_pixel_size
point_spacing = settings.cutout_point_spacing
normal_analysis = bool(getattr(settings, "cutout_normal_analysis", False))
mesh_detail = cutout_mesh_detail(source_object.data.size, pixel_size)
mesh_detail = cutout_mesh_detail(source_object.data.size, point_spacing)
def draw(menu, _context):
pie = menu.layout.menu_pie()
@@ -147,6 +147,7 @@ class SelectCutoutRegion(ImageMaskGesture, bpy.types.Operator):
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):
@@ -250,6 +251,6 @@ class CutoutTool(bpy.types.WorkSpaceTool):
def draw_settings(context, layout, _tool):
settings = context.scene.anyimage_settings
layout.prop(settings, "cutout_pixel_size")
layout.prop(settings, "cutout_point_spacing")
layout.prop(settings, "cutout_normal_analysis")
draw_image_mask_settings(context, layout)

View File

@@ -141,11 +141,11 @@ def image_base_name(image):
def depth_data_name(source_object):
return f"{image_base_name(source_object.data)}.depth"
return f"{image_base_name(source_object.data)}_depth.exr"
def normal_data_name(source_object):
return f"{image_base_name(source_object.data)}.normal"
return f"{image_base_name(source_object.data)}_normal.png"
def copy_depth_image(source_object):
@@ -155,8 +155,8 @@ def copy_depth_image(source_object):
return depth_image
def color_data_name(source_object, suffix=".color"):
return f"{image_base_name(source_object.data)}{suffix}"
def color_data_name(source_object):
return f"{image_base_name(source_object.data)}_color.png"
def material_data_name(source_object):

View File

@@ -286,11 +286,16 @@ def dashed_line_segments(vertices, dash_length=4.0, gap_length=4.0):
return tuple(segments)
def draw_dashed_line(vertices, color=(1.0, 1.0, 1.0, 0.9)):
def draw_dashed_line(
vertices,
color=(1.0, 1.0, 1.0, 0.9),
segments=None,
):
import gpu
from gpu_extras.batch import batch_for_shader
segments = dashed_line_segments(vertices)
if segments is None:
segments = dashed_line_segments(vertices)
if not segments:
return
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
@@ -303,19 +308,244 @@ def draw_dashed_line(vertices, color=(1.0, 1.0, 1.0, 0.9)):
gpu.state.blend_set("NONE")
def triangle_fan_vertices(vertices):
def _signed_area(vertices):
return sum(
start[0] * end[1] - end[0] * start[1]
for start, end in zip(vertices, (*vertices[1:], vertices[0]))
) * 0.5
def _triangle_cross(start, middle, end):
return (middle[0] - start[0]) * (end[1] - start[1]) - (
middle[1] - start[1]
) * (end[0] - start[0])
def _point_in_triangle(point, start, middle, end, orientation, epsilon):
return all(
orientation * cross >= -epsilon
for cross in (
_triangle_cross(start, middle, point),
_triangle_cross(middle, end, point),
_triangle_cross(end, start, point),
)
)
def _triangulated_polygon_vertices_python(vertices):
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
if len(vertices) < 3:
return ()
area = _signed_area(vertices)
if abs(area) <= 1e-8:
return ()
orientation = 1.0 if area > 0.0 else -1.0
remaining = list(range(len(vertices)))
triangles = []
for index in range(1, len(vertices) - 1):
triangles.extend((vertices[0], vertices[index], vertices[index + 1]))
epsilon = 1e-8
while len(remaining) > 3:
for position, current in enumerate(remaining):
previous = remaining[position - 1]
following = remaining[(position + 1) % len(remaining)]
start = vertices[previous]
middle = vertices[current]
end = vertices[following]
if orientation * _triangle_cross(start, middle, end) <= epsilon:
continue
if any(
_point_in_triangle(
vertices[index],
start,
middle,
end,
orientation,
epsilon,
)
for index in remaining
if index not in {previous, current, following}
):
continue
triangles.extend((start, middle, end))
del remaining[position]
break
else:
return ()
triangles.extend(vertices[index] for index in remaining)
return tuple(triangles)
def draw_polygon_fill(vertices, color=(1.0, 1.0, 1.0, 0.16)):
def triangulated_polygon_vertices(vertices):
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
try:
import mathutils
points = [
mathutils.Vector((vertex[0], vertex[1], 0.0)) for vertex in vertices
]
triangles = mathutils.geometry.tessellate_polygon([points])
return tuple(
(float(point[0]), float(point[1]))
for triangle in triangles
for point in triangle
)
except (AttributeError, ImportError, RuntimeError, TypeError, ValueError):
return _triangulated_polygon_vertices_python(vertices)
def scanline_fill_triangles(vertices, fill_rule='EVEN_ODD', 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
triangles = []
edges = tuple(zip(vertices, (*vertices[1:], vertices[0])))
while band_bottom < maximum_y:
band_top = min(band_bottom + step, maximum_y)
sample_y = (band_bottom + band_top) * 0.5
events = []
for start, end in edges:
if (start[1] <= sample_y < end[1]) or (
end[1] <= sample_y < start[1]
):
factor = (sample_y - start[1]) / (end[1] - start[1])
events.append(
(
start[0] + (end[0] - start[0]) * factor,
1 if end[1] > start[1] else -1,
)
)
events.sort(key=lambda event: event[0])
intervals = []
winding = 0
interval_start = None
index = 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
is_inside = winding != 0
if not was_inside and is_inside:
interval_start = x
elif was_inside and not is_inside and interval_start is not None:
intervals.append((interval_start, x))
interval_start = None
for left, right in intervals:
if right - left <= 1e-8:
continue
triangles.extend(
(
(left, band_bottom),
(right, band_bottom),
(right, band_top),
(left, band_bottom),
(right, band_top),
(left, band_top),
)
)
band_bottom = band_top
return tuple(triangles)
def _point_on_segment(point, start, end, epsilon=1e-8):
return (
min(start[0], end[0]) - epsilon
<= point[0]
<= max(start[0], end[0]) + epsilon
and min(start[1], end[1]) - epsilon
<= point[1]
<= max(start[1], end[1]) + epsilon
)
def _segments_intersect(first_start, first_end, second_start, second_end):
crosses = (
_triangle_cross(first_start, first_end, second_start),
_triangle_cross(first_start, first_end, second_end),
_triangle_cross(second_start, second_end, first_start),
_triangle_cross(second_start, second_end, first_end),
)
if (crosses[0] > 1e-8) != (crosses[1] > 1e-8) and (
crosses[2] > 1e-8
) != (crosses[3] > 1e-8):
return True
return any(
abs(cross) <= 1e-8 and _point_on_segment(point, start, end)
for cross, point, start, end in (
(crosses[0], second_start, first_start, first_end),
(crosses[1], second_end, first_start, first_end),
(crosses[2], first_start, second_start, second_end),
(crosses[3], first_end, second_start, second_end),
)
)
def polygon_self_intersects(vertices, cell_size=32.0):
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
if len(vertices) < 4:
return False
edges = tuple(zip(vertices, (*vertices[1:], vertices[0])))
cells = {}
last_edge = len(edges) - 1
for edge_index, (start, end) in enumerate(edges):
left = math.floor(min(start[0], end[0]) / cell_size)
right = math.floor(max(start[0], end[0]) / cell_size)
bottom = math.floor(min(start[1], end[1]) / cell_size)
top = math.floor(max(start[1], end[1]) / cell_size)
edge_cells = tuple(
(x, y)
for x in range(left, right + 1)
for y in range(bottom, top + 1)
)
candidates = {
candidate
for cell in edge_cells
for candidate in cells.get(cell, ())
}
for candidate in candidates:
if abs(edge_index - candidate) <= 1 or {
edge_index,
candidate,
} == {0, last_edge}:
continue
if _segments_intersect(start, end, *edges[candidate]):
return True
for cell in edge_cells:
cells.setdefault(cell, []).append(edge_index)
return False
def preview_fill_triangles(vertices, fill_rule='EVEN_ODD'):
if not polygon_self_intersects(vertices):
triangles = triangulated_polygon_vertices(vertices)
if triangles:
return triangles
return scanline_fill_triangles(vertices, fill_rule)
def draw_polygon_fill(
vertices,
triangles=None,
color=(1.0, 1.0, 1.0, 0.16),
):
import gpu
from gpu_extras.batch import batch_for_shader
triangles = triangle_fan_vertices(vertices)
if triangles is None:
triangles = triangulated_polygon_vertices(vertices)
if not triangles:
return
shader = gpu.shader.from_builtin("UNIFORM_COLOR")

View File

@@ -13,7 +13,6 @@ from ..balloon.operator import (
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 (
@@ -28,12 +27,14 @@ from .common import (
active_view3d_tool_id,
apply_image_mask_settings,
crop_pixels_to_alpha,
dashed_line_segments,
draw_dashed_line,
draw_image_mask_operator_settings,
draw_polygon_fill,
image_edit_poll,
image_mask_refine_request,
polygon_bounds,
preview_fill_triangles,
replace_empty_image_pixels,
report_image_edit_exception,
resolve_image_edit_click,
@@ -47,6 +48,45 @@ GESTURE_TOOL_IDS = {
}
LINE_SNAP_RADIANS = math.radians(5.0)
POLYLINE_CLOSE_RADIUS = 8.0
LASSO_POINT_DISTANCE = 2.0
LASSO_SIMPLIFY_TOLERANCE = 0.75
def append_lasso_point(path, point):
point = (float(point[0]), float(point[1]))
if not path:
path.append(point)
return True
if math.dist(point, path[-1]) < LASSO_POINT_DISTANCE:
return False
if len(path) >= 2:
anchor = path[-2]
previous = path[-1]
segment_x = point[0] - anchor[0]
segment_y = point[1] - anchor[1]
segment_length_squared = segment_x * segment_x + segment_y * segment_y
if segment_length_squared > 0.0:
factor = (
(previous[0] - anchor[0]) * segment_x
+ (previous[1] - anchor[1]) * segment_y
) / segment_length_squared
projected = (
anchor[0] + segment_x * factor,
anchor[1] + segment_y * factor,
)
forward = (
(previous[0] - anchor[0]) * (point[0] - previous[0])
+ (previous[1] - anchor[1]) * (point[1] - previous[1])
)
if (
0.0 <= factor <= 1.0
and forward >= 0.0
and math.dist(previous, projected) <= LASSO_SIMPLIFY_TOLERANCE
):
path[-1] = point
return True
path.append(point)
return True
def points_inside_polygon(points, polygon):
@@ -265,9 +305,14 @@ def image_line_half_plane_polygon(line, image_size):
class ImageMaskGesture:
active_tool_id = None
preview_fill_rule = 'EVEN_ODD'
_path = None
_cursor = None
_handle = None
_fill_polygon = None
_fill_triangles = ()
_outline_polygon = None
_outline_segments = ()
_area_pointer = 0
_region_pointer = 0
_region_size = (0, 0)
@@ -283,7 +328,7 @@ class ImageMaskGesture:
if self.gesture == "BOX":
return box_screen_path(self._path[0], self._cursor)
if self.gesture == "LASSO":
return tuple(simplify_screen_path(self._path))
return tuple(self._path)
if self.gesture == "LINE":
return line_half_plane_polygon(
self._path[0], self._cursor, self._region_size
@@ -299,16 +344,29 @@ class ImageMaskGesture:
):
return
polygon = self._screen_polygon()
if polygon != self._outline_polygon:
self._outline_polygon = polygon
if self.gesture == 'LINE':
outline = (self._path[0], self._cursor)
else:
outline = (*polygon, polygon[0]) if len(polygon) > 1 else ()
self._outline_segments = dashed_line_segments(outline)
if len(polygon) >= 3:
if polygon != self._fill_polygon:
self._fill_polygon = polygon
self._fill_triangles = preview_fill_triangles(
polygon, self.preview_fill_rule
)
draw_polygon_fill(
polygon,
self._fill_triangles,
(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))
draw_dashed_line((), segments=self._outline_segments)
return
if len(polygon) > 1:
draw_dashed_line((*polygon, polygon[0]))
draw_dashed_line((), segments=self._outline_segments)
if (
self.gesture == "POLYLINE"
and len(self._path) >= 3
@@ -382,6 +440,10 @@ class ImageMaskGesture:
point = (float(event.mouse_region_x), float(event.mouse_region_y))
self._path = [point]
self._cursor = point
self._fill_polygon = None
self._fill_triangles = ()
self._outline_polygon = None
self._outline_segments = ()
self._area_pointer = context.area.as_pointer()
self._region_pointer = context.region.as_pointer()
self._region_size = (context.region.width, context.region.height)
@@ -447,8 +509,7 @@ class ImageMaskGesture:
if event.type == "MOUSEMOVE":
point = (float(event.mouse_region_x), float(event.mouse_region_y))
if self.gesture == "LASSO":
if math.dist(point, self._path[-1]) >= 2.0:
self._path.append(point)
append_lasso_point(self._path, point)
self._cursor = point
elif self.gesture == "LINE":
self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl)
@@ -459,8 +520,7 @@ class ImageMaskGesture:
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
point = (float(event.mouse_region_x), float(event.mouse_region_y))
if self.gesture == "LASSO":
if math.dist(point, self._path[-1]) >= 2.0:
self._path.append(point)
append_lasso_point(self._path, point)
self._cursor = point
elif self.gesture == "LINE":
self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl)

View File

@@ -27,13 +27,16 @@ from .common import (
)
from .image import (
analysis_source_image,
color_data_name,
create_color_image,
image_base_name,
image_user_settings,
is_image_empty,
material_data_name,
normal_data_name,
plane_bounds,
prepare_image_analysis_inputs,
prepare_image_input,
remove_temporary_input,
scaled_process_resolution,
video_total_frames,
@@ -114,11 +117,11 @@ def _depth_field_image(source_object, anchored):
return image
def _cutout_domain(regions_pixels, image_size, bounds, detail, world_metric):
def _cutout_domain(regions_pixels, image_size, bounds, spacing, world_metric):
import numpy as np
x_min, x_max, y_min, y_max = bounds
spacing = max(x_max - x_min, y_max - y_min) / max(int(detail), 1)
regions, spacing = prepare_planar_regions(
regions_pixels,
image_size,
@@ -168,12 +171,12 @@ def _load_field(path):
return points, intrinsics, valid, image_size, reference_depth
def _anchored_point_field(source_object, field):
def _anchored_point_field(source_object, field, bounds=None):
import numpy as np
points, intrinsics, valid, image_size, reference_depth = field
width, height = image_size
x_min, x_max, y_min, y_max = plane_bounds(source_object)
x_min, x_max, y_min, y_max = bounds or plane_bounds(source_object)
plane_width = x_max - x_min
plane_height = y_max - y_min
fx, fy = float(intrinsics[0, 0]), float(intrinsics[1, 1])
@@ -950,17 +953,6 @@ def _mesh_node_group(shape):
return group
def _unique_color_name(source_object, current=None):
base = image_base_name(source_object.data)
index = 0
while True:
number = "" if index == 0 else f".{index:03d}"
candidate = f"{base}{number}.color"
existing = bpy.data.images.get(candidate)
if existing is None or existing is current:
return candidate
index += 1
def _copy_color_image(source_object):
source = source_object.data
@@ -976,7 +968,7 @@ def _copy_color_image(source_object):
source.pixels.foreach_get(pixels)
image.pixels.foreach_set(pixels)
image.update()
image.name = _unique_color_name(source_object, image)
image.name = color_data_name(source_object)
return image
@@ -989,9 +981,81 @@ def _image_rgba(image):
return np.flipud(pixels.reshape((height, width, 4)))
def _cutout_regions(alpha, mesh_detail):
def _mask_crop_bounds(alpha):
import numpy as np
values = np.asarray(alpha)
visible_y, visible_x = np.nonzero(values > 1.0 / 255.0)
if not len(visible_x):
raise ValueError("The Cutout selection contains no visible pixels")
return (
int(visible_x.min()),
int(visible_y.min()),
int(visible_x.max()) + 1,
int(visible_y.max()) + 1,
)
def _crop_plane_bounds(source_object, crop_bounds, source_size):
source_width, source_height = (float(value) for value in source_size)
left, top, right, bottom = (float(value) for value in crop_bounds)
x_min, x_max, y_min, y_max = plane_bounds(source_object)
width = x_max - x_min
height = y_max - y_min
return (
x_min + left * width / source_width,
x_min + right * width / source_width,
y_max - bottom * height / source_height,
y_max - top * height / source_height,
)
def _cropped_color_image(source_object, crop_bounds):
import numpy as np
source = source_object.data
rgba = _image_rgba(source)
left, top, right, bottom = crop_bounds
width = right - left
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]
image = bpy.data.images.new(
color_data_name(source_object),
width=width,
height=height,
alpha=True,
)
try:
image.colorspace_settings.name = source.colorspace_settings.name
image.alpha_mode = source.alpha_mode
image.pixels.foreach_set(np.flipud(cropped).ravel())
image.pack()
image.update()
except Exception:
bpy.data.images.remove(image, do_unlink=True)
raise
return image
def _cutout_regions(alpha, mesh_detail, threshold=0.5):
contour_resolution = max(512, min(int(mesh_detail) * 8, 1024))
return alpha_regions(alpha, max_resolution=contour_resolution)
return alpha_regions(
alpha,
max_resolution=contour_resolution,
threshold=threshold,
)
def _antialias_selection(selection):
@@ -1089,7 +1153,7 @@ def _local_cutout_rgba(source_object, polygon, invert=False):
def _rename_color_image(image, source_object):
image.name = _unique_color_name(source_object, image)
image.name = color_data_name(source_object)
return image
@@ -1120,18 +1184,29 @@ def _build_mesh_object(
field=None,
regions=None,
image_size=None,
bounds=None,
keep_original=False,
):
import numpy as np
bounds = plane_bounds(source_object)
bounds = bounds or plane_bounds(source_object)
world_metric, _world_z_scale = _world_plane_metric(source_object.matrix_world)
domain = (
_plane_domain(bounds, detail, world_metric)
if shape in {PLANE, DEPTH_PLANE}
else _cutout_domain(regions, image_size, bounds, detail, world_metric)
else _cutout_domain(
regions,
image_size,
bounds,
_cutout_point_spacing(source_object, detail),
world_metric,
)
)
anchored = (
_anchored_point_field(source_object, field, bounds)
if field
else None
)
anchored = _anchored_point_field(source_object, field) if field else None
depth_image = None
face_discontinuity = None
if shape in {PLANE, DEPTH_PLANE}:
@@ -1223,13 +1298,13 @@ def _build_mesh_object(
def _load_normal_image(path, source_object):
image = bpy.data.images.load(str(path), check_existing=False)
image.name = f"{image_base_name(source_object.data)}.normal"
image.name = normal_data_name(source_object)
image.colorspace_settings.name = "Non-Color"
image.pack()
return image
def _balloon_resolution(source_object, detail):
def _cutout_point_spacing(source_object, detail):
import math
x_min, x_max, y_min, y_max = plane_bounds(source_object)
@@ -1252,19 +1327,40 @@ def _build_local_cutout(
polygon = json.loads(selection_data) if selection_data else None
if not isinstance(polygon, list) or len(polygon) < 3:
raise ValueError("The Cutout selection is invalid")
rgba, selection = _local_cutout_rgba(source_object, polygon, invert)
regions, image_size = _geometry_cutout_regions(
rgba[:, :, 3],
polygon,
_rgba, selection = _local_cutout_rgba(source_object, polygon, invert)
source_size = tuple(source_object.data.size)
if invert:
crop_bounds = _mask_crop_bounds(selection)
left, top, right, bottom = crop_bounds
cropped_selection = selection[top:bottom, left:right]
else:
crop_bounds = tuple(int(value) for value in polygon_bounds(polygon))
left, top, right, bottom = crop_bounds
relative_polygon = [
(x - left, y - top)
for x, y in polygon
]
cropped_selection = _antialias_selection(
polygon_mask(
(right - left, bottom - top),
relative_polygon,
"NONZERO",
)
)
regions, image_size = _cutout_regions(
cropped_selection,
operator.mesh_detail,
invert,
refine_selection=False,
selection=selection,
threshold=0.5,
)
local_bounds = _crop_plane_bounds(
source_object,
crop_bounds,
source_size,
)
color_image = None
material = None
try:
color_image = _copy_color_image(source_object)
color_image = _cropped_color_image(source_object, crop_bounds)
if shape == BALLOON:
result_object = extract_balloon_object(
context,
@@ -1273,12 +1369,15 @@ def _build_local_cutout(
source_object.matrix_world.copy(),
"POISSON",
thickness=1.0,
resolution=_balloon_resolution(source_object, operator.mesh_detail),
resolution=_cutout_point_spacing(
source_object, operator.mesh_detail
),
select_result=True,
reuse_material=False,
depth_data=None,
normal_path=None,
material_image=color_image,
local_bounds=local_bounds,
)
result_object["anyimage_mesh_shape"] = shape
else:
@@ -1291,6 +1390,7 @@ def _build_local_cutout(
material,
regions=regions,
image_size=image_size,
bounds=local_bounds,
keep_original=True,
)
except Exception:
@@ -1305,27 +1405,38 @@ def _build_response(context, result, operator):
source_object = bpy.data.objects.get(operator.source_object_name)
if not is_image_empty(source_object):
raise RuntimeError("The source Image Empty no longer exists")
regions = image_size = None
regions = image_size = local_bounds = None
if operator.shape in CUTOUT_SHAPES:
geometry_image = create_color_image(
source_size = tuple(source_object.data.size)
crop_bounds = tuple(
int(value)
for value in json.loads(
result.file("bounds").read_text(encoding="utf-8")
)
)
local_bounds = _crop_plane_bounds(
source_object,
crop_bounds,
source_size,
)
selection_image = bpy.data.images.load(
str(result.file("selection")),
check_existing=False,
)
try:
selection_alpha = _image_rgba(selection_image)[:, :, 3]
regions, image_size = _cutout_regions(
selection_alpha,
operator.mesh_detail,
threshold=0.5,
)
finally:
bpy.data.images.remove(selection_image)
color_image = create_color_image(
source_object,
result.directory,
assign_to_source=False,
)
try:
rgba = _image_rgba(geometry_image)
finally:
if geometry_image is not source_object.data:
bpy.data.images.remove(geometry_image)
polygon = json.loads(operator.selection_data)
regions, image_size = _geometry_cutout_regions(
rgba[:, :, 3],
polygon,
operator.mesh_detail,
operator.invert,
refine_selection=operator.refine_selection,
)
color_image = _copy_color_image(source_object)
else:
color_image = _rename_color_image(
create_color_image(
@@ -1350,12 +1461,13 @@ def _build_response(context, result, operator):
source_object.matrix_world.copy(),
"POISSON",
thickness=1.0,
resolution=_balloon_resolution(source_object, operator.mesh_detail),
resolution=_cutout_point_spacing(source_object, operator.mesh_detail),
select_result=True,
reuse_material=False,
depth_data=depth_data,
normal_path=normal_path if normal_path.is_file() else None,
material_image=color_image,
local_bounds=local_bounds,
bake_normal_to_tangent=normal_path.is_file(),
)
balloon["anyimage_mesh_shape"] = operator.shape
@@ -1390,6 +1502,7 @@ def _build_response(context, result, operator):
field=field,
regions=regions,
image_size=image_size,
bounds=local_bounds,
keep_original=operator.shape in CUTOUT_SHAPES,
)
if normal_image is not None and operator.shape == CUTOUT:
@@ -1401,7 +1514,6 @@ def _build_response(context, result, operator):
)
push_undo(f"Convert to {operator.shape.replace('_', ' ').title()}")
def _depth_settings(context, shape):
preferences = addon_preferences(context)
model = getattr(preferences, "mesh_depth_model", DEFAULT_MOGE2_MODEL_KEY)
@@ -1528,16 +1640,25 @@ def _start_job(
if setup_result is not None:
return setup_result
try:
(
source_path,
delete_input,
analysis_path,
delete_analysis_input,
) = prepare_image_analysis_inputs(
source_object.data, context.scene
)
if shape in CUTOUT_SHAPES:
source_path, delete_input = prepare_image_input(
source_object.data,
context.scene,
)
analysis_path = source_path
delete_analysis_input = False
analysis_image = source_object.data
else:
(
source_path,
delete_input,
analysis_path,
delete_analysis_input,
) = prepare_image_analysis_inputs(
source_object.data, context.scene
)
analysis_image = analysis_source_image(source_object.data)
model, process_factor, resolution_level = _depth_settings(context, shape)
analysis_image = analysis_source_image(source_object.data)
process_res = (
scaled_process_resolution(analysis_image, process_factor)
if shape in DEPTH_SHAPES and not model.startswith("MOGE2_")

View File

@@ -147,7 +147,7 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator):
replaced_image.use_fake_user = False
if int(replaced_image.users) == 0:
bpy.data.images.remove(replaced_image, do_unlink=True)
result_image.name = f"{image_base_name(source_image)}.upscale"
result_image.name = f"{image_base_name(source_image)}_upscale.png"
result_image.anyimage_upscale_source = source_image
model_label = upscale_model_label(self.model)
if getattr(self, "_rebuild_from_original", False):

View File

@@ -332,11 +332,11 @@ def cutout_normal_analysis_property():
default=False,
)
def cutout_pixel_size_property():
def cutout_point_spacing_property():
return bpy.props.IntProperty(
name="Pixel Size",
name="Point Spacing",
description=(
"Approximate source image pixels per mesh step; smaller values "
"Source image pixels between neighboring mesh points; smaller values "
"create denser geometry"
),
default=32,
@@ -372,7 +372,7 @@ def balloon_radial_zoom_property(scale):
class AnyImageSettings(bpy.types.PropertyGroup):
cutout_pixel_size: cutout_pixel_size_property()
cutout_point_spacing: cutout_point_spacing_property()
cutout_normal_analysis: cutout_normal_analysis_property()
image_edit_refine_selection: image_edit_refine_selection_property()
balloon_mode: balloon_mode_property()

View File

@@ -186,7 +186,7 @@ def alpha_regions(alpha, max_resolution=512, threshold=0.5):
sampled_size = tuple(max(1, round(value * scale)) for value in source_size)
if sampled_size != source_size:
values = _resize_alpha(values, sampled_size)
mask = values > round(255.0 * threshold)
mask = values >= 255.0 * threshold
regions = mask_regions(mask)
scale_x = source_size[0] / sampled_size[0]
scale_y = source_size[1] / sampled_size[1]

View File

@@ -116,7 +116,7 @@ def foreground_mesh_mask(foreground_path, depth_shape):
resample=Image.Resampling.BILINEAR,
)
alpha = alpha.filter(ImageFilter.GaussianBlur(radius=0.75))
return np.asarray(alpha) > 0
return np.asarray(alpha) >= round(255.0 * 0.5)
def confidence_percentile_threshold(depth, confidence, percentile):

View File

@@ -1,3 +1,5 @@
import json
from ..media import copy_color_images
from ..models import geometry_adapter, moge2
from .background import remove_image_background
@@ -45,7 +47,7 @@ def _write_field(frame, output_path):
def _write_masked_color(input_path, polygon, output_path, invert=False):
import numpy as np
from PIL import Image, ImageChops, ImageFilter, ImageOps
from PIL import Image, ImageFilter, ImageOps
with Image.open(input_path) as opened:
source = opened.convert("RGBA")
@@ -55,9 +57,8 @@ def _write_masked_color(input_path, polygon, output_path, invert=False):
).filter(ImageFilter.GaussianBlur(radius=0.75))
if invert:
selection = ImageOps.invert(selection)
alpha = ImageChops.multiply(source.getchannel("A"), selection)
pixels = np.asarray(source, dtype=np.uint8).copy()
pixels[:, :, 3] = np.asarray(alpha, dtype=np.uint8)
pixels[:, :, 3] = np.asarray(selection, dtype=np.uint8)
pixels[pixels[:, :, 3] == 0, :3] = 0
Image.fromarray(pixels, mode="RGBA").save(
output_path,
@@ -67,6 +68,37 @@ def _write_masked_color(input_path, polygon, output_path, invert=False):
return output_path
def _write_unbounded_selection(
input_path,
polygon,
output_path,
invert=False,
):
import numpy as np
from PIL import Image, ImageFilter
if invert:
_write_masked_color(input_path, polygon, output_path, invert=True)
with Image.open(input_path) as source:
return output_path, (0, 0, source.width, source.height)
canvas, selection_mask, bounds = create_selection_canvas(
input_path, polygon, 'NONZERO'
)
selection = Image.fromarray(
selection_mask.astype(np.uint8) * 255,
mode='L',
).filter(ImageFilter.GaussianBlur(radius=0.75))
pixels = np.asarray(canvas, dtype=np.uint8).copy()
pixels[:, :, 3] = np.asarray(selection, dtype=np.uint8)
pixels[pixels[:, :, 3] == 0, :3] = 0
Image.fromarray(pixels, mode='RGBA').save(
output_path,
format='PNG',
compress_level=1,
)
return output_path, bounds
def _write_refined_color(context, parameters, input_path, polygon, output_path):
from PIL import Image, ImageChops
@@ -100,6 +132,77 @@ def _write_refined_color(context, parameters, input_path, polygon, output_path):
return output_path
def _write_cutout_images(
input_path,
selection_path,
output_directory,
selection_bounds=None,
):
import numpy as np
from PIL import Image
with Image.open(input_path) as opened:
source = opened.convert("RGBA")
with Image.open(selection_path) as opened:
selection = opened.convert("RGBA")
if selection_bounds is None and selection.size != source.size:
raise ValueError("The Cutout selection does not match the source image")
if selection_bounds is None:
selection_bounds = (0, 0, source.width, source.height)
selection_pixels = np.asarray(selection, dtype=np.uint8).copy()
alpha = selection_pixels[:, :, 3]
alpha[alpha <= 1] = 0
selection_pixels[alpha == 0, :3] = 0
selection = Image.fromarray(selection_pixels, mode="RGBA")
visible_y, visible_x = np.nonzero(alpha >= 128)
if not len(visible_x):
raise ValueError("The Cutout selection contains no visible pixels")
bounds = (
int(visible_x.min()),
int(visible_y.min()),
int(visible_x.max()) + 1,
int(visible_y.max()) + 1,
)
content_bounds = bounds
selection_left, selection_top, _selection_right, _selection_bottom = (
selection_bounds
)
bounds = (
selection_left + content_bounds[0],
selection_top + content_bounds[1],
selection_left + content_bounds[2],
selection_top + content_bounds[3],
)
color_canvas = Image.new('RGBA', selection.size, (0, 0, 0, 0))
color_canvas.paste(source, (-selection_left, -selection_top))
color = color_canvas.crop(content_bounds)
color_path = output_directory / "image.0001.png"
color.save(color_path, format="PNG", compress_level=1)
selection_crop = selection.crop(content_bounds)
selection_path = output_directory / "selection.png"
selection_crop.save(selection_path, format="PNG", compress_level=1)
analysis_pixels = np.asarray(color, dtype=np.uint8).copy()
mask_alpha = np.asarray(selection_crop, dtype=np.uint8)[:, :, 3]
analysis_pixels[:, :, 3] = np.minimum(
analysis_pixels[:, :, 3],
mask_alpha,
)
analysis_pixels[mask_alpha < 128] = 0
analysis_path = output_directory / "analysis.png"
Image.fromarray(analysis_pixels, mode="RGBA").save(
analysis_path,
format="PNG",
compress_level=1,
)
bounds_path = output_directory / "bounds.json"
bounds_path.write_text(
json.dumps(bounds, separators=(",", ":")),
encoding="utf-8",
)
return color_path, selection_path, analysis_path, bounds_path
def _write_camera_normal_from_prediction(prediction, color_path, output_path):
import numpy as np
from PIL import Image
@@ -148,25 +251,15 @@ def _write_balloon_depth(
context,
parameters,
input_path,
polygon,
selection_size=None,
include_normal=False,
):
import numpy as np
from PIL import Image
with Image.open(input_path) as source:
rgba = np.asarray(source.convert("RGBA"), dtype=np.uint8)
source_size = source.size
selection_size = selection_size or source_size
scale_x = source_size[0] / selection_size[0]
scale_y = source_size[1] / selection_size[1]
analysis_polygon = tuple(
(point[0] * scale_x, point[1] * scale_y) for point in polygon
)
selection_mask = polygon_selection_mask(
source_size,
analysis_polygon,
"NONZERO",
)
selection_mask = rgba[:, :, 3] >= 128
prediction = moge2.infer(
context.resource("model_manager"),
parameters,
@@ -190,7 +283,6 @@ def _write_balloon_depth(
)
return depth_path, normal_path
def _write_surface_depth(
context, parameters, color_path, include_normal=False
):
@@ -230,49 +322,59 @@ def run(context, parameters):
raise ValueError("Empty Image to Mesh currently supports one image")
shape = parameters["shape"]
polygon = parameters.get("polygon")
analysis_path = None
result = {}
if polygon:
selection_bounds = None
full_selection_path = context.directory / "selection-full.png"
if parameters.get("refine_selection"):
color_path = _write_refined_color(
_write_refined_color(
context,
parameters,
images[0],
polygon,
context.directory / "image.0001.png",
full_selection_path,
)
else:
color_path = _write_masked_color(
_selection_path, selection_bounds = _write_unbounded_selection(
images[0],
polygon,
context.directory / "image.0001.png",
full_selection_path,
parameters.get("invert", False),
)
color_path, selection_path, analysis_path, bounds_path = (
_write_cutout_images(
images[0],
full_selection_path,
context.directory,
selection_bounds,
)
)
result.update(
selection=selection_path.name,
bounds=bounds_path.name,
)
else:
copy_color_images(images, context.directory)
color_path = min(context.directory.glob("image.0001.*"))
result = {}
normal_analysis = bool(parameters.get("normal_analysis"))
with analysis_frames(context, parameters, images) as model_images:
if len(model_images) != 1:
raise ValueError("Empty Image to Mesh currently supports one image")
analysis_path = model_images[0]
model_path = analysis_path or model_images[0]
if shape in {"CUTOUT", "BALLOON"} and normal_analysis:
context.progress(0.4, "Estimating surface normal")
result["normal"] = _write_camera_normal(
context, parameters, analysis_path
context, parameters, model_path
).name
elif shape == "DEPTH_BALLOON":
from PIL import Image
with Image.open(images[0]) as selection_source:
selection_size = selection_source.size
context.progress(0.4, "Estimating Balloon depth")
depth_path, normal_path = _write_balloon_depth(
context,
parameters,
analysis_path,
polygon,
selection_size,
model_path,
include_normal=normal_analysis,
)
result["depth"] = depth_path.name
@@ -283,7 +385,7 @@ def run(context, parameters):
field_path, normal_path = _write_surface_depth(
context,
parameters,
analysis_path,
model_path,
include_normal=normal_analysis,
)
result["field"] = field_path.name
@@ -293,7 +395,7 @@ def run(context, parameters):
and shape == "DEPTH_SURFACE"
):
normal_path = _write_camera_normal(
context, parameters, analysis_path
context, parameters, model_path
)
if normal_path is not None:
result["normal"] = normal_path.name