Add comprehensive tests for edge depth extension, empty image edits, and material view adaptation

- Introduced `test_edge_depth.py` to validate the functionality of edge depth extension, ensuring core alpha preservation, proper depth handling, and edge padding behavior.
- Created `test_empty_image_edits.py` to verify the behavior of image edits in Blender, focusing on shared edits, preservation of image properties, and handling of failed operations.
- Developed `test_material_view_adaptation.py` to test material adaptation preferences, ensuring color space and alpha settings are correctly applied and preserved across various scenarios.
This commit is contained in:
2026-09-06 21:43:47 +08:00
parent 1994d38ccd
commit 7e55af6722
58 changed files with 3607 additions and 200 deletions

View File

@@ -8,9 +8,9 @@ maintainer = "OmooLab"
type = "add-on"
blender_version_min = "4.5.0"
platforms = ["windows-x64", "macos-arm64", "linux-x64"]
platforms = ["windows-x64"]
wheels = ["./wheels/blendjob-0.1.16-py3-none-any.whl", "./wheels/scipy-1.15.3-cp311-cp311-win_amd64.whl", "./wheels/scipy-1.15.3-cp313-cp313-win_amd64.whl"]
license = ["SPDX:GPL-3.0-or-later"]
wheels = ["./wheels/blendjob-0.1.16-py3-none-any.whl"]
[permissions]
network = "Access the local Job Server and download environment dependencies and model weights"

View File

@@ -340,7 +340,7 @@ def image_path(image):
filepath_from_user = getattr(image, "filepath_from_user", None)
filepath = (
filepath_from_user()
if callable(filepath_from_user)
if callable(filepath_from_user) and getattr(image, "source", None) != "SEQUENCE"
else getattr(image, "filepath", "")
)
if not filepath:
@@ -558,7 +558,7 @@ def configure_image_user(
def pack_sequence_if_possible(image, expected_frames):
"""Pack a frame sequence when Blender supports it; otherwise keep external files."""
if expected_frames <= 1:
if expected_frames <= 1 or not image.has_data:
return False
try:
image.pack()
@@ -655,71 +655,149 @@ def create_image_edit_result(source_image, pixels, output_size):
return image
def replace_empty_image(source_object, result_image, *, placement_bounds=None):
def has_other_image_empty(source_object, *, removed_objects=()):
"""Check surviving image Empty objects for the same Image datablock."""
return any(
candidate != source_object
and candidate not in removed_objects
and is_image_empty(candidate)
and candidate.data == source_object.data
for candidate in bpy.data.objects
)
def image_content_state(image):
"""Capture encoded content and unsaved pixels for an Image transaction."""
import numpy as np
pixels = None
if image.is_dirty:
pixels = np.empty(len(image.pixels), dtype=np.float32)
image.pixels.foreach_get(pixels)
return {
"source": image.source,
"filepath": image.filepath_raw,
"colorspace": image.colorspace_settings.name,
"alpha_mode": image.alpha_mode,
"file_format": image.file_format,
"packed": bytes(image.packed_file.data) if image.packed_file else None,
"pixels": pixels,
"size": tuple(image.size),
"generated": {
name: getattr(image, name)
for name in ("generated_width", "generated_height", "generated_type",
"use_generated_float")
},
"generated_color": tuple(image.generated_color),
}
def restore_image_content(image, state):
"""Apply captured image content while retaining the destination ID."""
if image.packed_file:
image.source = "FILE"
image.unpack(method="REMOVE")
image.filepath_raw = state["filepath"]
image.source = state["source"]
if state["source"] == "GENERATED":
for name, value in state["generated"].items():
setattr(image, name, value)
image.generated_color = state["generated_color"]
image.colorspace_settings.name = state["colorspace"]
image.alpha_mode = state["alpha_mode"]
image.file_format = state["file_format"]
if state["packed"] is not None:
data = state["packed"]
image.source = "FILE"
image.pack(data=data, data_len=len(data))
image.source = state["source"]
image.reload()
if state["pixels"] is not None:
if tuple(image.size) != state["size"]:
image.scale(*state["size"])
image.pixels.foreach_set(state["pixels"])
image.update()
def replace_empty_image(source_object, result_image, *, placement_bounds=None,
removed_objects=()):
"""Replace one Empty image, retaining shared source images."""
source_image = source_object.data
source_name = source_image.name
original_fake_user = source_image.use_fake_user
original_blending = getattr(source_object, "use_empty_image_alpha", False)
image_user = getattr(source_object, "image_user", None)
original_timing = image_user_settings(source_object) if image_user is not None else None
frame_count = (
video_total_frames(result_image)
if is_animated_image(result_image)
else None
)
alignment = (
empty_display_alignment(
source_object,
tuple(source_image.size),
tuple(result_image.size),
placement_bounds,
)
if placement_bounds is not None
else None
)
if frame_count is not None:
(
original_frame_start,
_original_frame_offset,
_original_frame_duration,
original_use_cyclic,
) = image_user_settings(source_object)
source_image.use_fake_user = True
original_alignment = (
(source_object.empty_display_size, tuple(source_object.empty_image_offset))
if alignment is not None else None
)
try:
source_object.data = result_image
source_object.use_empty_image_alpha = True
if frame_count is not None:
configure_image_user(
image_user,
frame_count,
frame_start=original_frame_start,
use_cyclic=original_use_cyclic,
source_image = source_object.data
original_fake_user = source_image.use_fake_user
original_blending = getattr(source_object, "use_empty_image_alpha", False)
image_user = getattr(source_object, "image_user", None)
original_timing = image_user_settings(source_object) if image_user is not None else None
original_auto_refresh = getattr(image_user, "use_auto_refresh", False)
frame_count = (
video_total_frames(result_image)
if is_animated_image(result_image)
else None
)
alignment = (
empty_display_alignment(
source_object,
tuple(source_image.size),
tuple(result_image.size),
placement_bounds,
)
if alignment is not None:
source_object.empty_display_size = alignment[0]
source_object.empty_image_offset = alignment[1]
except Exception:
source_object.data = source_image
source_object.use_empty_image_alpha = original_blending
if original_alignment is not None:
source_object.empty_display_size, source_object.empty_image_offset = original_alignment
if original_timing is not None:
(image_user.frame_start, image_user.frame_offset,
image_user.frame_duration, image_user.use_cyclic) = original_timing
source_image.use_fake_user = original_fake_user
raise
if placement_bounds is not None
else None
)
if frame_count is not None:
(
original_frame_start,
_original_frame_offset,
_original_frame_duration,
original_use_cyclic,
) = image_user_settings(source_object)
source_image.use_fake_user = original_fake_user
if not original_fake_user and int(source_image.users) == 0:
bpy.data.images.remove(source_image, do_unlink=True)
result_image.name = source_name
return result_image
source_image.use_fake_user = True
original_alignment = (
(source_object.empty_display_size, tuple(source_object.empty_image_offset))
if alignment is not None else None
)
original_content = None
try:
shared = has_other_image_empty(source_object, removed_objects=removed_objects)
if shared:
final_image = result_image
else:
result_content = image_content_state(result_image)
original_content = image_content_state(source_image)
restore_image_content(source_image, result_content)
final_image = source_image
source_object.data = final_image
source_object.use_empty_image_alpha = True
if frame_count is not None:
configure_image_user(
image_user,
frame_count,
frame_start=original_frame_start,
use_cyclic=original_use_cyclic,
)
if alignment is not None:
source_object.empty_display_size = alignment[0]
source_object.empty_image_offset = alignment[1]
except Exception:
if original_content is not None:
restore_image_content(source_image, original_content)
source_object.data = source_image
source_object.use_empty_image_alpha = original_blending
if original_alignment is not None:
source_object.empty_display_size, source_object.empty_image_offset = original_alignment
if original_timing is not None:
(image_user.frame_start, image_user.frame_offset,
image_user.frame_duration, image_user.use_cyclic) = original_timing
image_user.use_auto_refresh = original_auto_refresh
source_image.use_fake_user = original_fake_user
raise
source_image.use_fake_user = original_fake_user
return final_image
finally:
if result_image.users == 0:
bpy.data.images.remove(result_image)
def _load_packed_result_image(

View File

@@ -2,8 +2,8 @@
import bpy
from ..preferences import configured_material_view_adaptation
from .image import (
configure_color_alpha,
configure_image_user,
image_base_name,
image_pixels,
@@ -36,31 +36,51 @@ def material_color_space_name(scene):
return "sRGB"
def set_material_color_space(image, scene=None):
def configure_material_color_image(image, scene=None):
"""Configure an owned material Color image using the current shading preference."""
if scene is None:
scene = getattr(bpy.context, "scene", None)
settings = image.colorspace_settings
preferred = material_color_space_name(scene)
original = settings.name
pixels = None
if preferred != original and (
getattr(image, "source", None) == "GENERATED"
preferred = (
material_color_space_name(scene)
if configured_material_view_adaptation()
else "sRGB"
)
pixels = (
image_pixels(image)
if getattr(image, "source", None) == "GENERATED"
or getattr(image, "is_dirty", False)
):
pixels = image_pixels(image)
for color_space in dict.fromkeys((preferred, "sRGB", original)):
if not color_space:
continue
try:
settings.name = color_space
except (TypeError, ValueError):
continue
if settings.name == color_space:
else None
)
shared = image.users > int(image.use_fake_user)
result = image.copy() if shared else image
settings = result.colorspace_settings
original_space = settings.name
original_alpha = result.alpha_mode
try:
for color_space in dict.fromkeys((preferred, "sRGB")):
try:
settings.name = color_space
except (TypeError, ValueError):
continue
if settings.name == color_space:
break
else:
raise RuntimeError("Unable to configure the material color space")
result.alpha_mode = "PREMUL" if settings.name == "sRGB" else "STRAIGHT"
if pixels is not None:
result.pixels.foreach_set(pixels)
result.update()
except Exception:
if shared:
bpy.data.images.remove(result)
else:
settings.name = original_space
result.alpha_mode = original_alpha
if pixels is not None:
image.pixels.foreach_set(pixels)
image.update()
return color_space
return settings.name
result.pixels.foreach_set(pixels)
result.update()
raise
return result
def material_node_group(depth_plane=False):
@@ -92,6 +112,7 @@ def create_image_material(
ior=None,
scene=None,
):
material_image = configure_material_color_image(color_image, scene)
material = bpy.data.materials.new(image_base_name(source_object.data))
material.use_nodes = True
if hasattr(material, "surface_render_method"):
@@ -105,9 +126,6 @@ def create_image_material(
uv_map.uv_map = "UVMap"
uv_map.location = (-1250.0, 350.0)
material_image = color_image
set_material_color_space(material_image, scene)
configure_color_alpha(material_image)
color_texture.image = material_image
color_texture.interpolation = "Linear"
color_texture.extension = "EXTEND"

View File

@@ -0,0 +1,29 @@
approximate_polygon from scikit-image 0.25.2, skimage/measure/_polygon.py
Copyright: 2009-2022 the scikit-image team
License: BSD-3-Clause
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the University nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE HOLDERS OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -4,6 +4,7 @@ import tempfile
import bpy
from ...common.material import configure_material_color_image
CLIPBOARD_HASH_PROPERTY = "anyimage_clipboard_sha256"
CLIPBOARD_TEXTURE_PROPERTY = "anyimage_clipboard_texture"
@@ -149,7 +150,7 @@ def add_image_plane(
from ..convert_to_plane.mesh_plane import create_mesh_plane_object
half_width, half_height = _plane_half_dimensions(width, height)
material = _create_image_material(image, shadeless)
material = _create_image_material(image, shadeless, scene=context.scene)
plane = create_mesh_plane_object(
context,
image.name,
@@ -197,7 +198,8 @@ def _plane_half_dimensions(width, height):
return width / height, 1.0
def _create_image_material(image, shadeless=False):
def _create_image_material(image, shadeless=False, *, scene=None):
material_image = configure_material_color_image(image, scene)
material = bpy.data.materials.new(name=f"{image.name} Material")
material.use_nodes = True
if hasattr(material, "surface_render_method"):
@@ -206,7 +208,7 @@ def _create_image_material(image, shadeless=False):
nodes = material.node_tree.nodes
nodes.clear()
image_node = nodes.new("ShaderNodeTexImage")
image_node.image = image
image_node.image = material_image
image_node.location = (-620.0, 80.0)
output_node = nodes.new("ShaderNodeOutputMaterial")

View File

@@ -0,0 +1,621 @@
"""Fine Cutout contours, sparse constrained support and interior sampling."""
import heapq
import numpy as np
from mathutils import Vector
from mathutils.geometry import delaunay_2d_cdt
from scipy import ndimage
from scipy.spatial import cKDTree
from .polygon import approximate_polygon
from . import geometry as g
def valid_contours(contours):
"""Reject crossing rings and holes moved outside the outer ring."""
domain = ContourDomain(contours)
count = len(domain.starts)
following = np.concatenate(
[
np.roll(np.arange(len(contour)) + sum(map(len, contours[:index])), -1)
for index, contour in enumerate(contours)
]
)
for first in range(0, count, 32):
ids = np.arange(first, min(first + 32, count))[:, None]
other = np.arange(count)[None, :]
a = domain.starts[first : first + 32, None]
v = domain.delta[first : first + 32, None]
w = domain.delta[None]
d = domain.starts[None] - a
determinant = v[..., 0] * w[..., 1] - v[..., 1] * w[..., 0]
divisor = np.where(np.abs(determinant) > 1e-12, determinant, 1.0)
t = (d[..., 0] * w[..., 1] - d[..., 1] * w[..., 0]) / divisor
u = (d[..., 0] * v[..., 1] - d[..., 1] * v[..., 0]) / divisor
adjacent = (other == following[ids]) | (ids == following[other])
if np.any(
(other > ids)
& ~adjacent
& (np.abs(determinant) > 1e-12)
& (t > 1e-9)
& (t < 1 - 1e-9)
& (u > 1e-9)
& (u < 1 - 1e-9)
):
return False
return all(
g.points_inside_contours(hole[:1], contours[:1])[0] for hole in contours[1:]
)
def support_flat_regions(points, faces):
"""Split interior boundary chords so inflated faces remain connected."""
boundary = g.boundary_vertices_from_faces(len(points), faces)
edges = np.sort(np.concatenate((faces[:, :2], faces[:, 1:], faces[:, ::2])), axis=1)
unique, counts = np.unique(edges, axis=0, return_counts=True)
chords = unique[(counts == 2) & boundary[unique].all(axis=1)]
midpoints = {tuple(edge): len(points) + index for index, edge in enumerate(chords)}
vertices = points.tolist() + points[chords].mean(axis=1).tolist()
triangles = []
for face in faces:
ring = []
for index, start in enumerate(face):
end = face[(index + 1) % 3]
ring.append(int(start))
midpoint = midpoints.get(tuple(sorted((int(start), int(end)))))
if midpoint is not None:
ring.append(midpoint)
if len(ring) > 3 or boundary[face].all():
center = len(vertices)
vertices.append(points[face].mean(axis=0).tolist())
triangles.extend(
(center, start, ring[(index + 1) % len(ring)])
for index, start in enumerate(ring)
)
else:
triangles.append(tuple(face))
return np.asarray(vertices), np.asarray(triangles, dtype=np.int32)
def build_fine_mesh(alpha, spacing, threshold):
"""Return pixel-space triangles and component ranges for height solving."""
alpha = np.asarray(alpha, dtype=float)
if alpha.ndim != 2 or not alpha.size or not np.isfinite(alpha).all():
raise ValueError("The Cutout Alpha must be a finite two-dimensional array")
if not np.isfinite(spacing) or spacing <= 0:
raise ValueError("Cutout point spacing must be greater than zero")
filtered, _statistics = filter_alpha(alpha, threshold)
mask = (filtered > 0) & (filtered >= threshold)
if not mask.any():
raise ValueError("The Cutout selection contains no visible image pixels")
reference = g.extract_selection_contours(
filtered, max_resolution=max(mask.shape), threshold=threshold
)
width_sizes = WidthSamples(mask)
points, faces, components = [], [], []
offset = 0
for component in reference.components:
contours = contours_for_component(component, spacing)
spine, path_edges = interior_paths(
contours, min(32, max(mask.shape) / 6), width_sizes
)
contours, spine, path_edges = subdivide_constraints(
contours, spine, path_edges, spacing
)
free = relax_points(contours, spine, mask.shape, spacing)
vertices, triangles, constraints = triangulate(
contours, spine, path_edges, free, spacing
)
if not len(triangles):
raise ValueError(
"Fine Outline could not triangulate a foreground component"
)
vertices, triangles = support_flat_regions(vertices, triangles)
boundary = g.boundary_vertices_from_faces(len(vertices), triangles)
if boundary[constraints].any():
raise ValueError(
"Fine Outline placed an interior constraint on the boundary"
)
points.append(vertices)
faces.append(triangles + offset)
components.append((offset, offset + len(vertices), triangles, contours[0]))
offset += len(vertices)
points, faces = np.concatenate(points), np.concatenate(faces)
return points, faces, g.boundary_vertices_from_faces(len(points), faces), components
def clean_mesh(points, faces):
faces = np.asarray(faces, dtype=np.int32).reshape(-1, 3)
triangles = points[faces]
area = np.abs(
np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])
)
faces = faces[area > 1e-8]
used = np.unique(faces)
remap = np.full(len(points), -1, dtype=np.int32)
remap[used] = np.arange(len(used))
return points[used], remap[faces]
def filter_alpha(alpha, threshold, min_area=16):
mask = (alpha > 0) & (alpha >= threshold)
labels, count = ndimage.label(mask)
areas = np.bincount(labels.ravel(), minlength=count + 1)
keep = areas >= min_area
keep[0] = False
if count:
keep[1 + np.argmax(areas[1:])] = True
removed = mask & ~keep[labels]
result = alpha.copy()
result[removed] = 0
return (
result,
{
"components_before": count,
"components_after": int(keep.sum()),
"removed_pixels": int(removed.sum()),
"foreground_pixels": int(mask.sum()),
},
)
def width_ridge_samples(image):
"""Supply ridge disks for width estimation, not a connected skeleton."""
distance = ndimage.distance_transform_edt(image)
ridge = np.zeros(image.shape, dtype=bool)
dominated = np.zeros(image.shape, dtype=bool)
for dy, dx in ((0, 1), (1, 0), (1, 1), (1, -1)):
before = np.roll(distance, (dy, dx), axis=(0, 1))
after = np.roll(distance, (-dy, -dx), axis=(0, 1))
ridge |= (
(distance >= before)
& (distance >= after)
& ((distance > before) | (distance > after))
)
dominated |= np.maximum(before, after) >= distance + np.hypot(dx, dy) - 1e-9
ridge &= image & ~dominated
return ridge, distance
class WidthSamples:
"""Estimate local width from indexed distance-ridge disks."""
def __init__(self, mask):
ridge, distance = width_ridge_samples(np.pad(mask, 1))
ridge = ridge[1:-1, 1:-1]
self.distance = np.maximum(distance[1:-1, 1:-1] - 0.5, 0.3)
yy, xx = np.nonzero(ridge)
self.centers = np.column_stack((xx + 0.5, yy + 0.5))
self.radius = self.distance[yy, xx]
self.reach_squared = (self.radius + 0.75) ** 2
buckets = np.floor(np.log2(self.radius)).astype(int)
self.groups = []
for bucket in np.unique(buckets)[::-1]:
ids = np.flatnonzero(buckets == bucket)
self.groups.append(
(ids, cKDTree(self.centers[ids]), float(self.radius[ids].max() + 0.75))
)
def __call__(self, points):
radius = np.full(len(points), 0.5)
pending = np.arange(len(points))
for ids, tree, reach in self.groups:
if not len(pending):
break
neighbors = tree.query_ball_point(points[pending], reach)
solved = np.zeros(len(pending), dtype=bool)
for index, local in enumerate(neighbors):
if not len(local):
continue
candidates = ids[np.asarray(local)]
delta = self.centers[candidates] - points[pending[index]]
covered = (
np.sum(delta * delta, axis=1) <= self.reach_squared[candidates]
)
if covered.any():
radius[pending[index]] = max(
0.5, float(self.radius[candidates[covered]].max())
)
solved[index] = True
pending = pending[~solved]
distance = ndimage.map_coordinates(
self.distance,
[points[:, 1] - 0.5, points[:, 0] - 0.5],
order=1,
mode="nearest",
)
return np.maximum(
0.7, np.minimum(32, np.minimum(16, radius * 0.9) + 0.4 * distance)
)
class ContourDomain:
"""Reuse boundary segments for exact internal segment validation."""
def __init__(self, contours):
self.contours = contours
self.starts = np.concatenate(contours)
self.ends = np.concatenate([np.roll(c, -1, axis=0) for c in contours])
self.delta = self.ends - self.starts
self.lengths = np.maximum(np.sum(self.delta**2, axis=1), 1e-20)
def clearance(self, points):
result = np.empty(len(points))
for first in range(0, len(points), 64):
d = points[first : first + 64, None, :] - self.starts[None, :, :]
t = np.clip(np.sum(d * self.delta, axis=2) / self.lengths, 0, 1)
result[first : first + 64] = np.sqrt(
np.min(np.sum((d - t[:, :, None] * self.delta) ** 2, axis=2), axis=1)
)
return result
def contains(self, points):
result = np.empty(len(points), dtype=bool)
a = self.starts
b = self.ends
dy = b[:, 1] - a[:, 1]
divisor = np.where(dy != 0, dy, 1.0)
for first in range(0, len(points), 64):
p = points[first : first + 64]
y = p[:, 1, None]
x = p[:, 0, None]
crosses = (a[:, 1] > y) != (b[:, 1] > y)
hit = a[:, 0] + (y - a[:, 1]) * (b[:, 0] - a[:, 0]) / divisor
result[first : first + 64] = (
np.count_nonzero(crosses & (x < hit), axis=1) % 2 == 1
)
return result
def valid_segments(self, starts, ends):
starts = np.asarray(starts)
ends = np.asarray(ends)
valid = self.contains(np.concatenate((starts, ends))).reshape(2, -1).all(axis=0)
valid &= np.minimum(self.clearance(starts), self.clearance(ends)) > 1e-05
for first in range(0, len(starts), 32):
a = starts[first : first + 32, None, :]
v = (ends - starts)[first : first + 32, None, :]
d = self.starts[None, :, :] - a
w = self.delta[None, :, :]
det = v[:, :, 0] * w[:, :, 1] - v[:, :, 1] * w[:, :, 0]
nonparallel = np.abs(det) > 1e-12
divisor = np.where(nonparallel, det, 1.0)
t = (d[:, :, 0] * w[:, :, 1] - d[:, :, 1] * w[:, :, 0]) / divisor
u = (d[:, :, 0] * v[:, :, 1] - d[:, :, 1] * v[:, :, 0]) / divisor
crossing = (nonparallel & (t >= 0) & (t <= 1) & (u >= 0) & (u <= 1)).any(
axis=1
)
factors = np.clip(
np.sum(d * v, axis=2) / np.maximum(np.sum(v * v, axis=2), 1e-20), 0, 1
)
distance = np.min(
np.sum((d - factors[:, :, None] * v) ** 2, axis=2), axis=1
)
valid[first : first + 32] &= ~crossing & (distance > 1e-10)
return valid
def contours_for_component(component, spacing, strength=1.0):
result = []
tolerance = (
2.5 if spacing > 16 else 1.25 if spacing > 8 else 0.625 if spacing > 4 else 0.5
)
for contour in component:
original = np.asarray(contour, dtype=float)
if spacing <= 16 and strength > 0:
original = ndimage.gaussian_filter1d(
g.resample_closed_contour(original, 1.0),
0.8 * strength,
axis=0,
mode="wrap",
)
for error in (tolerance * strength, tolerance * strength * 0.5, 0):
simplified = approximate_polygon(np.vstack((original, original[0])), error)[
:-1
]
if len(simplified) >= 3 and abs(g.contour_area(simplified)) > 0.05:
break
points = []
for a, b in zip(simplified, np.roll(simplified, -1, axis=0)):
count = max(1, int(np.ceil(np.linalg.norm(b - a) / 32)))
points.extend(a + np.arange(count)[:, None] / count * (b - a))
result.append(np.asarray(points))
if strength > 0 and not valid_contours(result):
return contours_for_component(
component, spacing, strength * 0.5 if strength > 0.125 else 0
)
return result
def interior_paths(contours, spacing, width_sizes):
p, f, _ = g._triangulate_contour_component(contours, np.empty((0, 2)), spacing)
p, f = clean_mesh(p, f)
users = {}
for ti, face in enumerate(f):
for i in range(3):
edge = tuple(sorted((int(face[i]), int(face[(i + 1) % 3]))))
users.setdefault(edge, []).append(ti)
nodes = []
connections = []
midpoints = {}
for edge, owners in users.items():
if len(owners) == 2:
midpoints[edge] = len(nodes)
nodes.append(p[list(edge)].mean(axis=0))
for face in f:
mids = [
midpoints[e]
for i in range(3)
if (e := tuple(sorted((int(face[i]), int(face[(i + 1) % 3]))))) in midpoints
]
if len(mids) == 2:
connections.append(tuple(mids))
else:
center = len(nodes)
nodes.append(p[face].mean(axis=0))
connections.extend(((m, center) for m in mids))
if not nodes:
return (np.empty((0, 2)), [])
nodes = np.asarray(nodes)
domain = ContourDomain(contours)
radius = domain.clearance(nodes)
local_sizes = width_sizes(nodes)
connections = [(a, b) for a, b in connections if min(radius[a], radius[b]) > 0.0001]
if connections:
pairs = np.asarray(connections)
valid = domain.valid_segments(nodes[pairs[:, 0]], nodes[pairs[:, 1]])
connections = [edge for edge, ok in zip(connections, valid) if ok]
full_graph = {}
for a, b in connections:
length = float(np.linalg.norm(nodes[a] - nodes[b]))
full_graph.setdefault(a, []).append((b, length))
full_graph.setdefault(b, []).append((a, length))
selected = {
tuple(sorted((a, b)))
for a, b in connections
if min(radius[a], radius[b]) < spacing * 0.4
and min(local_sizes[a], local_sizes[b]) < min(16, spacing * 0.6)
}
if selected:
parents = {i: i for edge in selected for i in edge}
def root(i):
while parents[i] != i:
parents[i] = parents[parents[i]]
i = parents[i]
return i
for a, b in selected:
parents[root(a)] = root(b)
owners = {i: root(i) for i in parents}
distances = {i: 0.0 for i in parents}
previous = {}
queue = [(0.0, i) for i in parents]
heapq.heapify(queue)
while queue:
distance, node = heapq.heappop(queue)
if distance != distances[node]:
continue
for neighbor, length in full_graph.get(node, []):
if max(radius[node], radius[neighbor]) > spacing * 0.75:
continue
value = distance + length
if value < distances.get(neighbor, np.inf):
distances[neighbor] = value
owners[neighbor] = owners[node]
previous[neighbor] = node
heapq.heappush(queue, (value, neighbor))
candidates = []
for a, b in connections:
if (
a in owners
and b in owners
and (owners[a] != owners[b])
and (max(radius[a], radius[b]) <= spacing * 0.75)
):
candidates.append(
(
distances[a]
+ np.linalg.norm(nodes[a] - nodes[b])
+ distances[b],
a,
b,
)
)
for _, a, b in sorted(candidates):
ra, rb = (root(owners[a]), root(owners[b]))
if ra == rb:
continue
parents[ra] = rb
selected.add(tuple(sorted((a, b))))
for cursor in (a, b):
while cursor in previous:
other = previous[cursor]
selected.add(tuple(sorted((cursor, other))))
cursor = other
connections = sorted(selected)
graph = {}
for a, b in connections:
graph.setdefault(a, set()).add(b)
graph.setdefault(b, set()).add(a)
used = set()
paths = []
starts = [i for i in graph if len(graph[i]) != 2] + list(graph)
for start in starts:
for neighbor in graph[start]:
edge = tuple(sorted((start, neighbor)))
if edge in used:
continue
used.add(edge)
ids = [start, neighbor]
while len(graph[ids[-1]]) == 2:
nxt = next((x for x in graph[ids[-1]] if x != ids[-2]))
edge = tuple(sorted((ids[-1], nxt)))
if edge in used:
break
used.add(edge)
ids.append(nxt)
path = nodes[ids]
length = np.linalg.norm(np.diff(path, axis=0), axis=1).sum()
if length < 5 and (len(graph[ids[0]]) == 1 or len(graph[ids[-1]]) == 1):
continue
simplified = path
for tol in (2.0, 1.0, 0.25, 0):
candidate = approximate_polygon(path, tol)
if domain.valid_segments(candidate[:-1], candidate[1:]).all():
simplified = candidate
break
expanded = []
for a, b in zip(simplified, simplified[1:]):
n = max(1, int(np.ceil(np.linalg.norm(b - a) / 24)))
expanded.extend(a + np.arange(n)[:, None] / n * (b - a))
expanded.append(simplified[-1])
paths.append(np.asarray(expanded))
points = []
edges = []
lookup = {}
for path in paths:
ids = []
for p in path:
key = tuple(np.round(p, 7))
if key not in lookup:
lookup[key] = len(points)
points.append(p)
ids.append(lookup[key])
edges.extend(zip(ids, ids[1:]))
return (np.asarray(points).reshape(-1, 2), edges)
def relax_points(contours, spine, shape, spacing):
free = g.uniform_interior_points_for_contours(contours, spacing)
if len(free) and len(spine):
free = free[cKDTree(spine).query(free)[0] > spacing * 0.55]
step = max(1, int(spacing / 4))
phase = step // 2
points = np.concatenate(contours)
minimum = points.min(axis=0)
maximum = points.max(axis=0)
xx = np.arange(phase, shape[1], step) + 0.5
yy = np.arange(phase, shape[0], step) + 0.5
xx = xx[(xx >= minimum[0]) & (xx <= maximum[0])]
yy = yy[(yy >= minimum[1]) & (yy <= maximum[1])]
xx, yy = np.meshgrid(xx, yy)
samples = np.column_stack((xx.ravel(), yy.ravel()))
samples = samples[g.points_inside_contours(samples, contours)]
if not len(samples):
return free
fixed = np.concatenate((*contours, spine))
for iteration in range(12):
if not len(free):
break
sites = np.concatenate((fixed, free))
labels = cKDTree(sites).query(samples)[1] - len(fixed)
next_free = free.copy()
valid = labels >= 0
owners = labels[valid]
owned = samples[valid]
counts = np.bincount(owners, minlength=len(free))
used = counts > 0
for axis in range(2):
sums = np.bincount(owners, weights=owned[:, axis], minlength=len(free))
next_free[used, axis] = sums[used] / counts[used]
proposed = free * 0.3 + next_free * 0.7
usable = g.points_inside_contours(proposed, contours)
movement = np.linalg.norm(proposed[usable] - free[usable], axis=1)
free[usable] = proposed[usable]
if iteration >= 3 and (
not len(movement) or np.sqrt(np.mean(movement**2)) < spacing * 0.01
):
break
return free
def subdivide_constraints(contours, spine, path_edges, spacing):
dense = []
for c in contours:
points = []
for a, b in zip(c, np.roll(c, -1, axis=0)):
count = max(1, int(np.ceil(np.linalg.norm(b - a) / spacing)))
points.extend(a + np.arange(count)[:, None] / count * (b - a))
dense.append(np.asarray(points))
points = spine.tolist()
edges = []
for a, b in path_edges:
count = max(1, int(np.ceil(np.linalg.norm(spine[b] - spine[a]) / spacing)))
ids = [a]
for i in range(1, count):
ids.append(len(points))
points.append((spine[a] + i / count * (spine[b] - spine[a])).tolist())
ids.append(b)
edges.extend(zip(ids, ids[1:]))
return (dense, np.asarray(points).reshape(-1, 2), edges)
def triangulate(contours, spine, path_edges, free, spacing):
boundary_count = sum(map(len, contours))
p = np.concatenate((*contours, spine, free))
edges = []
offset = 0
for c in contours:
edges.extend(((offset + i, offset + (i + 1) % len(c)) for i in range(len(c))))
offset += len(c)
constraint_start = len(edges)
edges.extend(((boundary_count + a, boundary_count + b) for a, b in path_edges))
result = delaunay_2d_cdt([Vector(v) for v in p], edges, [], 0, 1e-07, True)
points = np.asarray([tuple(v) for v in result[0]])
faces = np.asarray(result[2], dtype=np.int32)
faces = faces[g.points_inside_contours(points[faces].mean(axis=1), contours)]
area = np.abs(
np.cross(
points[faces[:, 1]] - points[faces[:, 0]],
points[faces[:, 2]] - points[faces[:, 0]],
)
)
faces = faces[area > 1e-08]
used = np.unique(faces)
remap = np.full(len(points), -1, dtype=np.int32)
remap[used] = np.arange(len(used))
face_edges = {
tuple(sorted((int(face[i]), int(face[(i + 1) % 3]))))
for face in faces
for i in range(3)
}
path_result = []
constraint_graphs = [{} for _ in path_edges]
for edge, originals in zip(result[1], result[4]):
if tuple(sorted(edge)) not in face_edges:
continue
for original in originals:
if constraint_start <= original < len(edges):
graph = constraint_graphs[original - constraint_start]
a, b = edge
graph.setdefault(a, set()).add(b)
graph.setdefault(b, set()).add(a)
path_result.append(tuple(remap[list(edge)]))
# CDT coordinates are float32. Validate endpoint connectivity via provenance,
# so short constraints at large pixel coordinates survive rounding.
original_vertices = {
original: vertex
for vertex, originals in enumerate(result[3])
for original in originals
}
for (a, b), graph in zip(edges[constraint_start:], constraint_graphs):
start = original_vertices.get(a)
end = original_vertices.get(b)
seen = set()
pending = [start] if start in graph else []
while pending:
vertex = pending.pop()
if vertex in seen:
continue
seen.add(vertex)
pending.extend(graph[vertex] - seen)
if end not in seen:
raise ValueError(
"Fine Outline lost an interior constraint during triangulation"
)
return (
points[used],
remap[faces],
np.asarray(path_result, dtype=np.int32).reshape(-1, 2),
)

View File

@@ -216,7 +216,7 @@ def extract_selection_contours(selection_values, max_resolution=512, threshold=0
sampled_size = tuple(max(1, round(value * scale)) for value in source_size)
if sampled_size != source_size:
values = _resize_values(values, sampled_size)
mask = values >= 255.0 * threshold
mask = (values > 0.0) & (values >= 255.0 * threshold)
components = _group_selection_contours(mask)
scale_x = source_size[0] / sampled_size[0]
scale_y = source_size[1] / sampled_size[1]
@@ -1226,7 +1226,7 @@ def depth_balloon_projection_parameters(
return reference, tuple(float(value) for value in direction)
CUTOUT_ALPHA_THRESHOLD = 0.1
CUTOUT_ALPHA_THRESHOLD = 0.9
CUTOUT_CLEANUP_UI_SCALE = 100.0
@@ -1244,18 +1244,38 @@ def cutout_domain(
spacing,
world_metric,
balloon_algorithm="POISSON",
*,
fine_mesh=None,
):
import numpy as np
x_min, x_max, y_min, y_max = bounds
planar, planar_faces, boundary, components = triangulate_selection_contours(
selection_contours,
bounds,
spacing=spacing,
world_metric=world_metric,
return_components=True,
)
if fine_mesh is None:
planar, planar_faces, boundary, components = triangulate_selection_contours(
selection_contours,
bounds,
spacing=spacing,
world_metric=world_metric,
return_components=True,
)
else:
pixels, planar_faces, boundary, pixel_components, image_size = fine_mesh
image_width, image_height = image_size
transform = metric_transform(world_metric)
def pixel_to_planar(points):
local = np.asarray(points, dtype=np.float64).copy()
local[:, 0] = x_min + local[:, 0] * (x_max - x_min) / image_width
local[:, 1] = y_max - local[:, 1] * (y_max - y_min) / image_height
return local @ transform
planar = pixel_to_planar(pixels)
planar_faces = planar_faces.tolist()
components = [
(start, end, faces.tolist(), pixel_to_planar(contour))
for start, end, faces, contour in pixel_components
]
profile = balloon_profile(
balloon_algorithm, planar, planar_faces, boundary, components
)
@@ -1381,7 +1401,7 @@ def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_T
source_width - 1,
)
resized = values[y[:, None], x[None, :]]
valid = resized > float(threshold)
valid = (resized > 0.0) & (resized >= float(threshold))
transparent_fraction = float(np.count_nonzero(~valid) / valid.size)
return valid, transparent_fraction
@@ -1392,6 +1412,9 @@ def build_base_shape(
point_spacing,
content_values,
content_bounds,
*,
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
fine_outline=False,
):
import numpy as np
@@ -1400,11 +1423,21 @@ def build_base_shape(
content_bounds,
tuple(source_object.data.size),
)
selection_contours = extract_cutout_contours(
content_values,
point_spacing,
threshold=CUTOUT_ALPHA_THRESHOLD,
)
fine_mesh = None
selection_contours = None
if fine_outline:
from .fine_mesh import build_fine_mesh
fine_mesh = (
*build_fine_mesh(content_values, point_spacing, alpha_threshold),
(content_values.shape[1], content_values.shape[0]),
)
else:
selection_contours = extract_cutout_contours(
content_values,
point_spacing,
threshold=alpha_threshold,
)
world_metric, world_z_scale = world_plane_metric(source_object.matrix_world)
if world_z_scale <= 1e-10:
raise ValueError("The Image Empty has a degenerate world transform")
@@ -1417,6 +1450,7 @@ def build_base_shape(
point_spacing,
world_metric,
balloon_algorithm,
fine_mesh=fine_mesh,
)
source_vertices, faces, uv, _boundary, planar, balloon = domain
return BaseShape(

View File

@@ -32,6 +32,8 @@ def _pie_button(
generate_normal,
point_spacing,
inward_axis,
alpha_threshold,
fine_outline,
):
operator = pie.operator(
"anyimage.cutout_selection_to_shape",
@@ -44,6 +46,8 @@ def _pie_button(
operator.generate_normal = generate_normal
operator.point_spacing = point_spacing
operator.inward_axis = inward_axis
operator.alpha_threshold = alpha_threshold
operator.fine_outline = fine_outline
def open_shape_pie(
@@ -61,6 +65,8 @@ def open_shape_pie(
getattr(settings, "cutout_generate_normal", False)
)
inward_axis = str(getattr(settings, "cutout_inward_axis", "NEGATIVE_Z"))
alpha_threshold = settings.cutout_alpha_threshold
fine_outline = settings.cutout_fine_outline
def draw(menu, _context):
pie = menu.layout.menu_pie()
@@ -76,6 +82,8 @@ def open_shape_pie(
generate_normal,
point_spacing,
inward_axis,
alpha_threshold,
fine_outline,
)
context.window_manager.popup_menu_pie(

View File

@@ -9,7 +9,11 @@ from ...preferences import (
configured_max_ai_input_size,
configured_max_cutout_mesh_resolution,
)
from ...properties import DEFAULT_MOGE2_MODEL_KEY
from ...properties import (
DEFAULT_MOGE2_MODEL_KEY,
cutout_alpha_threshold_property,
cutout_fine_outline_property,
)
from ...runtime import JobOperatorBase
from ...server.media.resolution import (
DEFAULT_MAX_AI_INPUT_SIZE,
@@ -92,6 +96,8 @@ def create_cutout_shape(
content_values,
content_bounds,
*,
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
fine_outline=False,
depth_image=None,
depth_metadata=None,
normal_image=None,
@@ -106,6 +112,8 @@ def create_cutout_shape(
point_spacing,
content_values,
content_bounds,
alpha_threshold=alpha_threshold,
fine_outline=fine_outline,
)
result = create_shape_object(
context,
@@ -115,6 +123,7 @@ def create_cutout_shape(
content_values,
content_bounds,
base_shape,
alpha_threshold=alpha_threshold,
depth_image=depth_image,
depth_metadata=depth_metadata,
normal_image=normal_image,
@@ -132,7 +141,9 @@ def create_cutout_shape(
return result
def _cutout_content_values(source_alpha, selection_values=None):
def _cutout_content_values(
source_alpha, selection_values=None, *, alpha_threshold=CUTOUT_ALPHA_THRESHOLD
):
"""Combine local image Alpha with optional pure Selection geometry."""
import numpy as np
@@ -146,7 +157,7 @@ def _cutout_content_values(source_alpha, selection_values=None):
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):
if not np.any((values > 0.0) & (values >= alpha_threshold)):
raise ImageEditWarning(
"The Cutout selection contains no visible image pixels"
)
@@ -212,6 +223,8 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
options=_HIDDEN,
)
generate_normal: bpy.props.BoolProperty(default=False, options=_HIDDEN)
alpha_threshold: cutout_alpha_threshold_property()
fine_outline: cutout_fine_outline_property()
inward_axis: bpy.props.StringProperty(
default=CutoutInwardAxis.NEGATIVE_Z.value,
options=_HIDDEN,
@@ -254,6 +267,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
content_values = _cutout_content_values(
source_rgba[top:bottom, left:right, 3],
selection_mask.values,
alpha_threshold=self.alpha_threshold,
)
color_image = create_cropped_color_image(
source_object,
@@ -268,6 +282,8 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
content_values,
selection_mask.bounds,
inward_axis=self.inward_axis,
alpha_threshold=self.alpha_threshold,
fine_outline=self.fine_outline,
color_image=color_image,
)
return {"FINISHED"}
@@ -314,6 +330,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
"depth_mode": depth_mode,
"normal_mode": normal_mode.value,
"max_input_size": int(self.max_ai_input_size),
"alpha_threshold": float(self.alpha_threshold),
}
if depth_mode != "NONE" or normal_mode != NormalMode.NONE:
start_frame = source_start_frame(self.source_object_name)
@@ -356,6 +373,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
content_values = _cutout_content_values(
source_alpha,
selection_mask.values,
alpha_threshold=self.alpha_threshold,
)
content_bounds = color_bounds
normal_mode = normal_mode_for_shape(self.shape, self.generate_normal)
@@ -389,6 +407,8 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
normal_image=normal_image,
normal_mode=normal_mode,
inward_axis=inward_axis,
alpha_threshold=self.alpha_threshold,
fine_outline=self.fine_outline,
color_image=color_image,
)
except Exception:
@@ -490,4 +510,6 @@ class CutoutTool(bpy.types.WorkSpaceTool):
status = draw_ai_setup(layout)
draw_ai_property(layout, settings, "cutout_generate_normal", status)
layout.prop(settings, "cutout_mesh_detail")
layout.prop(settings, "cutout_fine_outline")
layout.prop(settings, "cutout_alpha_threshold")
layout.prop(settings, "cutout_inward_axis")

View File

@@ -17,6 +17,7 @@ from ...common.object import (
)
from ...common.node import load_node_group
from .geometry import (
CUTOUT_ALPHA_THRESHOLD,
DEFAULT_DEPTH_BASELINE,
DEFAULT_DEPTH_DIRECTION,
crop_plane_bounds,
@@ -161,6 +162,7 @@ def create_shape_object(
content_bounds,
base_shape,
*,
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
depth_image=None,
depth_metadata=None,
normal_image=None,
@@ -213,7 +215,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(
content_values, valid.shape
content_values, valid.shape, threshold=alpha_threshold
)
calibration_valid &= valid
uniform_scale = depth_uniform_scale(

View File

@@ -0,0 +1,94 @@
"""Polygonal chain simplification using NumPy."""
import numpy as np
# approximate_polygon: scikit-image 0.25.2, copyright 2009-2022 the scikit-image team.
# BSD-3-Clause; see ../../licenses/scikit-image.txt.
def approximate_polygon(coords, tolerance):
"""Approximate a polygonal chain with the specified tolerance.
It is based on the Douglas-Peucker algorithm.
Note that the approximated polygon is always within the convex hull of the
original polygon.
Parameters
----------
coords : (K, 2) array
Coordinate array.
tolerance : float
Maximum distance from original points of polygon to approximated
polygonal chain. If tolerance is 0, the original coordinate array
is returned.
Returns
-------
coords : (L, 2) array
Approximated polygonal chain where L <= K.
References
----------
.. [1] https://en.wikipedia.org/wiki/Ramer-Douglas-Peucker_algorithm
"""
if tolerance <= 0:
return coords
chain = np.zeros(coords.shape[0], "bool")
# pre-allocate distance array for all points
dists = np.zeros(coords.shape[0])
chain[0] = True
chain[-1] = True
pos_stack = [(0, chain.shape[0] - 1)]
end_of_chain = False
while not end_of_chain:
start, end = pos_stack.pop()
# determine properties of current line segment
r0, c0 = coords[start, :]
r1, c1 = coords[end, :]
dr = r1 - r0
dc = c1 - c0
segment_angle = -np.arctan2(dr, dc)
segment_dist = c0 * np.sin(segment_angle) + r0 * np.cos(segment_angle)
# select points in-between line segment
segment_coords = coords[start + 1 : end, :]
segment_dists = dists[start + 1 : end]
# check whether to take perpendicular or euclidean distance with
# inner product of vectors
# vectors from points -> start and end
dr0 = segment_coords[:, 0] - r0
dc0 = segment_coords[:, 1] - c0
dr1 = segment_coords[:, 0] - r1
dc1 = segment_coords[:, 1] - c1
# vectors points -> start and end projected on start -> end vector
projected_lengths0 = dr0 * dr + dc0 * dc
projected_lengths1 = -dr1 * dr - dc1 * dc
perp = np.logical_and(projected_lengths0 > 0, projected_lengths1 > 0)
eucl = np.logical_not(perp)
segment_dists[perp] = np.abs(
segment_coords[perp, 0] * np.cos(segment_angle)
+ segment_coords[perp, 1] * np.sin(segment_angle)
- segment_dist
)
segment_dists[eucl] = np.minimum(
# distance to start point
np.sqrt(dc0[eucl] ** 2 + dr0[eucl] ** 2),
# distance to end point
np.sqrt(dc1[eucl] ** 2 + dr1[eucl] ** 2),
)
if np.any(segment_dists > tolerance):
# select point with maximum distance to line
new_end = start + np.argmax(segment_dists) + 1
pos_stack.append((new_end, end))
pos_stack.append((start, new_end))
chain[new_end] = True
if len(pos_stack) == 0:
end_of_chain = True
return coords[chain, :]

View File

@@ -498,8 +498,6 @@ def apply_frame(context, source_objects, frame_quad):
output_size,
)
result_image = create_image_edit_result(active.data, pixels, output_size)
active_source_image = active.data
active_source_name = active_source_image.name
old_matrix = active.matrix_world.copy()
old_display_size = active.empty_display_size
old_offset = tuple(active.empty_image_offset)
@@ -511,13 +509,14 @@ def apply_frame(context, source_objects, frame_quad):
active.matrix_world = matrix
active.empty_display_size = display_size
active.empty_image_offset = (-0.5, -0.5)
replace_empty_image(active, result_image)
replace_empty_image(
active, result_image,
removed_objects=tuple(source for source in source_objects if source != active),
)
except Exception:
active.matrix_world = old_matrix
active.empty_display_size = old_display_size
active.empty_image_offset = old_offset
if result_image.users == 0:
bpy.data.images.remove(result_image, do_unlink=True)
raise
for source in source_objects:
if source is not active:
@@ -526,8 +525,6 @@ def apply_frame(context, source_objects, frame_quad):
try:
if image.users == 0 and not image.use_fake_user:
bpy.data.images.remove(image, do_unlink=True)
if image is active_source_image:
result_image.name = active_source_name
except (ReferenceError, RuntimeError):
pass
return active

View File

@@ -145,12 +145,7 @@ class EditImageAlpha(ImageGesture, bpy.types.Operator):
pixels,
tuple(source_image.size),
)
try:
replace_empty_image(source_object, result_image)
except Exception:
if result_image.users == 0:
bpy.data.images.remove(result_image, do_unlink=True)
raise
replace_empty_image(source_object, result_image)
except (RuntimeError, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
return {"CANCELLED"}

View File

@@ -61,6 +61,12 @@ def configured_storage_root():
return DEFAULT_STORAGE_ROOT
def configured_material_view_adaptation(context=None):
return bool(
getattr(addon_preferences(context), "adapt_material_to_view_transform", True)
)
def configured_max_cutout_mesh_resolution(context=None):
preferences = addon_preferences(context)
value = int(
@@ -109,6 +115,14 @@ def geometry_model_items(owner, context):
class AnyImagePreferences(bpy.types.AddonPreferences):
bl_idname = ADDON_PACKAGE
adapt_material_to_view_transform: bpy.props.BoolProperty(
name="Adapt to Scene View Transform",
description=(
"Use the scene view transform's inverse color space with Straight alpha "
"for new material color textures; otherwise use sRGB with Premultiplied alpha"
),
default=True,
)
storage_root: bpy.props.StringProperty(
name="Storage Root",
description=(
@@ -243,6 +257,9 @@ class AnyImagePreferences(bpy.types.AddonPreferences):
"AI Upscale Image",
)
shading = self._draw_box(layout, "Shading")
shading.prop(self, "adapt_material_to_view_transform")
tools = self._draw_box(layout, "Tools")
tools.label(text="Frame Tool")
tools.prop(self, "max_frame_resolution")

View File

@@ -258,6 +258,27 @@ def cutout_generate_normal_property():
)
def cutout_alpha_threshold_property():
return bpy.props.FloatProperty(
name="Alpha Threshold",
description="Minimum Alpha kept in the Cutout contour",
default=0.9,
min=0.0,
max=1.0,
soft_min=0.1,
soft_max=0.9,
subtype="FACTOR",
)
def cutout_fine_outline_property():
return bpy.props.BoolProperty(
name="Fine Outline",
description="Fit detailed outlines and support thin parts; disable for simpler, faster meshes",
default=False,
)
def cutout_mesh_detail_property():
return bpy.props.EnumProperty(
name="Mesh Detail",
@@ -273,7 +294,9 @@ def cutout_mesh_detail_property():
class AnyImageSettings(bpy.types.PropertyGroup):
cutout_alpha_threshold: cutout_alpha_threshold_property()
cutout_mesh_detail: cutout_mesh_detail_property()
cutout_fine_outline: cutout_fine_outline_property()
cutout_generate_normal: cutout_generate_normal_property()
cutout_inward_axis: cutout_inward_axis_property()
mask_gesture: mask_gesture_property()

View File

@@ -9,6 +9,7 @@ ENVIRONMENT = {
"python": "3.12",
"packages": [
"numpy==2.4.2",
"scipy==1.15.3",
"pillow==12.1.1",
"av==17.0.0",
"trimesh==4.11.2",

View File

@@ -0,0 +1,78 @@
"""Extend reliable Cutout depth along each visible component's silhouette."""
from dataclasses import replace
def extend_edge_depth(frame, alpha, alpha_threshold=0.9):
"""Return edge-corrected geometry while preserving reliable camera points."""
import numpy as np
from scipy import ndimage
from ..models.onnx_moge2 import depth_to_points
depth = frame.depth
alpha = np.asarray(alpha, dtype=np.float32)
if alpha.shape != depth.shape:
raise ValueError("Edge Alpha must match the depth dimensions")
if np.all(alpha == 1.0):
return frame
visible = (alpha > 0.0) & (alpha >= alpha_threshold)
candidates = (
visible
& (alpha >= 0.95)
& (frame.validity > 0.0)
& np.isfinite(depth)
& (depth > 0.0)
)
if not candidates.any():
return frame
interior_distance = ndimage.distance_transform_edt(
np.pad(candidates, 3, mode="edge")
)[3:-3, 3:-3]
core = candidates & (interior_distance > 2.0)
del interior_distance
labels, _ = ndimage.label(visible, structure=np.ones((3, 3), dtype=bool))
corrected = depth.copy()
background_distance = np.full(depth.shape, np.inf, dtype=np.float32)
for component_id, bounds in enumerate(ndimage.find_objects(labels), start=1):
region = tuple(
slice(max(0, bound.start - 7), min(size, bound.stop + 7))
for bound, size in zip(bounds, depth.shape)
)
component = labels[region] == component_id
donors = core[region] & component
if not donors.any():
donors = candidates[region] & component
if not donors.any():
continue
indices = ndimage.distance_transform_edt(
~donors, return_distances=False, return_indices=True
)
extended = depth[region][tuple(indices)]
smoothed = ndimage.gaussian_filter(extended, sigma=0.8, radius=3)
distance = ndimage.distance_transform_edt(~component)
background = (
~visible[region]
& (distance <= 4.0)
& (distance < background_distance[region])
)
target = (component & ~donors) | background
corrected[region][target] = smoothed[target]
background_distance[region][background] = distance[background]
changed = corrected != depth
if not changed.any():
return frame
points = frame.points
if points is not None:
height, width = depth.shape
intrinsics = frame.intrinsics.copy()
intrinsics[0] /= width
intrinsics[1] /= height
projected = depth_to_points(corrected, intrinsics)
points = points.copy()
points[changed] = projected[changed]
return replace(frame, depth=corrected, points=points)

View File

@@ -8,6 +8,7 @@ from .depth_texture import (
write_z_depth_texture,
)
from .normal_texture import write_camera_normal_texture
from .edge_depth import extend_edge_depth
def _limited_model_input(source_path, output_path, max_input_size):
@@ -21,7 +22,7 @@ def _limited_model_input(source_path, output_path, max_input_size):
return output_path
def _alpha_mask(image_path, shape):
def _image_alpha(image_path, shape):
import numpy as np
from PIL import Image
@@ -31,13 +32,13 @@ def _alpha_mask(image_path, shape):
(int(shape[1]), int(shape[0])),
Image.Resampling.NEAREST,
)
return np.asarray(alpha, dtype=np.uint8) >= 26
return np.asarray(alpha, dtype=np.float32) / 255.0
def _write_normal(frame, color_path, output_path, normal_mode):
from PIL import Image
valid = _alpha_mask(color_path, frame.depth.shape)
valid = _image_alpha(color_path, frame.depth.shape) >= 0.1
with Image.open(color_path) as source:
output_size = source.size
return write_camera_normal_texture(
@@ -58,6 +59,8 @@ def generate_moge2_artifacts(
*,
depth_mode="NONE",
normal_mode="NONE",
correct_edge_depth=False,
alpha_threshold=0.9,
):
"""Run MoGe-2 once and write only the requested file artifacts."""
depth_mode = str(depth_mode).upper()
@@ -85,21 +88,27 @@ def generate_moge2_artifacts(
result = {}
if depth_mode != "NONE":
context.progress(0.45, "Generating depth texture")
alpha = _image_alpha(input_path, frame.depth.shape)
depth_frame = (
extend_edge_depth(frame, alpha, alpha_threshold)
if correct_edge_depth else frame
)
if depth_mode == "VECTOR":
depth_path = write_vector_depth_texture(
frame,
depth_frame,
context.directory / "vector-depth.exr",
)
result["vector_depth"] = depth_path
else:
depth_path = write_z_depth_texture(
frame,
depth_frame,
context.directory / "z-depth.exr",
)
result["z_depth"] = depth_path
result["depth_metadata"] = write_depth_metadata(
frame,
_alpha_mask(input_path, frame.depth.shape),
depth_frame,
(alpha > 0.0) & (alpha >= alpha_threshold)
if correct_edge_depth else alpha >= 0.1,
context.directory / "depth.json",
)

View File

@@ -19,6 +19,8 @@ def run(context, parameters):
input_path,
depth_mode=depth_mode,
normal_mode=normal_mode,
correct_edge_depth=depth_mode.upper() != "NONE",
alpha_threshold=float(parameters.get("alpha_threshold", 0.9)),
)
result.update({key: path.name for key, path in artifacts.items()})
context.check_cancelled()