Add comprehensive tests for image processing and mask functionalities

- Introduced tests for color image handling, ensuring source preservation and premultiplied alpha usage.
- Implemented tests for brush stroke operations, validating segment boundaries and brush anti-aliasing.
- Added tests for frame anti-aliasing, confirming correct rendering behavior with various alpha values and depth settings.
- Created tests for frame projection, ensuring accurate overlap calculations and half-plane clipping.
- Developed tests for image premultiplied retention, verifying behavior during loading and packing processes.
- Added undo functionality tests for mask operations, ensuring single transaction behavior during edits.
This commit is contained in:
2026-09-05 21:33:11 +08:00
parent bbaa4bff46
commit 1994d38ccd
111 changed files with 7691 additions and 2341 deletions

View File

@@ -5,11 +5,10 @@ from .operators.clipboard_image import CLASSES as CLIPBOARD_CLASSES
from .menu import AnyImageImageMenu, draw_image_context_menu
from .operators import CLASSES as OPERATOR_CLASSES
from .operators.cutout_tool import CutoutTool
from .operators.crop_tool import (
CropBoxTool,
CropLassoTool,
CropPerspectiveTool,
CropPolylineTool,
from .operators.image_edit_tool import (
FrameTool,
MaskTool,
RectifyTool,
)
from .panel import (
Ben2RemovalDebugPanel,
@@ -42,20 +41,15 @@ CLASSES = (
def register():
for class_type in CLASSES:
bpy.utils.register_class(class_type)
bpy.utils.register_tool(CropBoxTool, separator=True, group=True)
bpy.utils.register_tool(FrameTool, separator=True, group=True)
bpy.utils.register_tool(
CropLassoTool,
after={CropBoxTool.bl_idname},
MaskTool,
after={FrameTool.bl_idname},
separator=False,
)
bpy.utils.register_tool(
CropPolylineTool,
after={CropLassoTool.bl_idname},
separator=False,
)
bpy.utils.register_tool(
CropPerspectiveTool,
after={CropPolylineTool.bl_idname},
RectifyTool,
after={MaskTool.bl_idname},
separator=False,
)
bpy.utils.register_tool(CutoutTool)
@@ -70,10 +64,9 @@ def unregister():
runtime.unregister()
clipboard_image.unregister_keymaps()
bpy.utils.unregister_tool(CutoutTool)
bpy.utils.unregister_tool(CropPerspectiveTool)
bpy.utils.unregister_tool(CropPolylineTool)
bpy.utils.unregister_tool(CropLassoTool)
bpy.utils.unregister_tool(CropBoxTool)
bpy.utils.unregister_tool(RectifyTool)
bpy.utils.unregister_tool(MaskTool)
bpy.utils.unregister_tool(FrameTool)
bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu)
bpy.types.OUTLINER_MT_object.remove(draw_image_context_menu)
if hasattr(bpy.types.Scene, "anyimage_settings"):

Binary file not shown.

View File

@@ -1,5 +1,6 @@
"""Shared Blender Image and Image Empty lifecycle helpers."""
import math
import re
import shutil
import tempfile
@@ -20,6 +21,10 @@ IMAGE_NAME_SUFFIXES = (
".tga",
)
MAX_PROJECTIVE_OUTPUT_DIMENSION = 8192
MAX_PROJECTIVE_OUTPUT_PIXELS = 16_777_216
PROJECTIVE_CHUNK_ROWS = 256
VIDEO_NAME_SUFFIXES = (
".mp4",
@@ -52,11 +57,25 @@ def require_image_empty(object_name):
def image_pixels(image):
"""Read one Blender Image into a flat float32 pixel array."""
"""Read straight-alpha business pixels without changing presentation mode."""
import numpy as np
pixels = np.empty(len(image.pixels), dtype=np.float32)
image.pixels.foreach_get(pixels)
reader = image
if (
getattr(image, "alpha_mode", None) == "PREMUL"
and not getattr(image, "is_dirty", False)
and not getattr(image, "is_float", False)
and getattr(image, "source", None) != "GENERATED"
):
reader = image.copy()
try:
if reader is not image:
reader.alpha_mode = "STRAIGHT"
pixels = np.empty(len(reader.pixels), dtype=np.float32)
reader.pixels.foreach_get(pixels)
finally:
if reader is not image:
bpy.data.images.remove(reader)
return pixels
@@ -70,6 +89,197 @@ def image_rgba(image):
return np.flipud(image_pixels(image).reshape((height, width, 4)))
def homography_from_points(source, target):
"""Return a projective transform mapping four source points to target."""
import numpy as np
source = np.asarray(source, dtype=np.float64)
target = np.asarray(target, dtype=np.float64)
if source.shape != (4, 2) or target.shape != (4, 2):
raise ValueError("A homography requires four source and target points")
rows = []
values = []
for (x, y), (u, v) in zip(source, target):
rows.extend(
(
(x, y, 1.0, 0.0, 0.0, 0.0, -u * x, -u * y),
(0.0, 0.0, 0.0, x, y, 1.0, -v * x, -v * y),
)
)
values.extend((u, v))
matrix = np.asarray(rows, dtype=np.float64)
if np.linalg.cond(matrix) > 1e12:
raise ValueError("The projective transform is too degenerate")
coefficients = np.linalg.solve(matrix, np.asarray(values, dtype=np.float64))
return np.append(coefficients, 1.0).reshape((3, 3))
def transform_homography(points, matrix):
"""Transform 2D points by one homography."""
import numpy as np
points = np.asarray(points, dtype=np.float64)
if points.ndim < 2 or points.shape[-1] != 2:
raise ValueError("Projective points must contain X and Y")
flat = points.reshape((-1, 2))
homogeneous = np.column_stack((flat, np.ones(len(flat), dtype=np.float64)))
mapped = homogeneous @ np.asarray(matrix, dtype=np.float64).T
denominator = mapped[:, 2]
if np.any(np.abs(denominator) <= 1e-12):
raise ValueError("The projective transform reaches infinity")
mapped = mapped[:, :2] / denominator[:, None]
if not np.isfinite(mapped).all():
raise ValueError("The projective transform is not finite")
return mapped.reshape(points.shape)
def projective_output_size(
quad,
aspect_ratio,
max_dimension=MAX_PROJECTIVE_OUTPUT_DIMENSION,
max_pixels=MAX_PROJECTIVE_OUTPUT_PIXELS,
):
"""Choose a detail-preserving output size for a source-space quad."""
import numpy as np
quad = np.asarray(quad, dtype=np.float64)
if quad.shape != (4, 2) or not np.isfinite(quad).all():
raise ValueError("Projective output requires four valid corners")
if not math.isfinite(aspect_ratio) or aspect_ratio <= 0.0:
raise ValueError("Projective output aspect ratio must be greater than zero")
width_pixels = 0.5 * (
np.linalg.norm(quad[1] - quad[0]) + np.linalg.norm(quad[2] - quad[3])
)
height_pixels = 0.5 * (
np.linalg.norm(quad[2] - quad[1]) + np.linalg.norm(quad[3] - quad[0])
)
if min(width_pixels, height_pixels) <= 1e-8:
raise ValueError("Projective output corners are too close together")
height = (aspect_ratio * width_pixels + height_pixels) / (
aspect_ratio * aspect_ratio + 1.0
)
width = aspect_ratio * height
dimension_scale = float(max_dimension) / max(width, height)
pixel_scale = math.sqrt(float(max_pixels) / max(width * height, 1.0))
scale = min(1.0, dimension_scale, pixel_scale)
return max(2, int(round(width * scale))), max(2, int(round(height * scale)))
def premultiplied_rgba(source_pixels, source_size):
"""Return top-down premultiplied RGBA pixels."""
import numpy as np
width, height = (int(value) for value in source_size)
if min(width, height) <= 0:
raise ValueError("Image dimensions must be positive")
source = np.asarray(source_pixels, dtype=np.float32)
if source.size != width * height * 4:
raise ValueError("The image pixels do not match its dimensions")
source = np.flipud(source.reshape((height, width, 4))).copy()
source[:, :, :3] *= source[:, :, 3:4]
return source
def sample_rgba(source, source_points):
"""Bilinearly sample top-down RGBA at image-boundary coordinates."""
import numpy as np
source = np.asarray(source, dtype=np.float32)
points = np.asarray(source_points, dtype=np.float64)
if source.ndim != 3 or source.shape[2] != 4:
raise ValueError("The source must contain RGBA pixels")
if points.shape[-1] != 2:
raise ValueError("Sample points must contain X and Y")
height, width = source.shape[:2]
flat = points.reshape((-1, 2))
mapped_x = flat[:, 0]
mapped_y = flat[:, 1]
valid = (
np.isfinite(mapped_x)
& np.isfinite(mapped_y)
& (mapped_x >= 0.0)
& (mapped_x <= width)
& (mapped_y >= 0.0)
& (mapped_y <= height)
)
pixel_x = np.clip(mapped_x - 0.5, 0.0, width - 1.0)
pixel_y = np.clip(mapped_y - 0.5, 0.0, height - 1.0)
x0 = np.floor(pixel_x).astype(np.int64)
y0 = np.floor(pixel_y).astype(np.int64)
x1 = np.minimum(x0 + 1, width - 1)
y1 = np.minimum(y0 + 1, height - 1)
fx = (pixel_x - x0)[:, None]
fy = (pixel_y - y0)[:, None]
sampled = (
source[y0, x0] * (1.0 - fx) * (1.0 - fy)
+ source[y0, x1] * fx * (1.0 - fy)
+ source[y1, x0] * (1.0 - fx) * fy
+ source[y1, x1] * fx * fy
)
sampled[~valid] = 0.0
return sampled.reshape((*points.shape[:-1], 4))
def straight_rgba(premultiplied):
"""Convert premultiplied RGBA and bleed edge RGB into transparent pixels."""
import numpy as np
rgba = np.asarray(premultiplied, dtype=np.float32).copy()
alpha = rgba[..., 3]
visible = alpha > 1e-8
rgb = rgba[..., :3]
rgb[visible] /= alpha[visible, None]
rgba[~visible] = 0.0
if rgba.ndim == 3:
unfilled = ~visible
def fill_edge(target, source):
candidates = unfilled[target] & visible[source]
target_rgb = rgb[target]
target_rgb[candidates] = rgb[source][candidates]
unfilled[target][candidates] = False
fill_edge((slice(1, None), slice(None)), (slice(None, -1), slice(None)))
fill_edge((slice(None, -1), slice(None)), (slice(1, None), slice(None)))
fill_edge((slice(None), slice(1, None)), (slice(None), slice(None, -1)))
fill_edge((slice(None), slice(None, -1)), (slice(None), slice(1, None)))
fill_edge((slice(1, None), slice(1, None)), (slice(None, -1), slice(None, -1)))
fill_edge((slice(1, None), slice(None, -1)), (slice(None, -1), slice(1, None)))
fill_edge((slice(None, -1), slice(1, None)), (slice(1, None), slice(None, -1)))
fill_edge((slice(None, -1), slice(None, -1)), (slice(1, None), slice(1, None)))
return rgba
def warp_projective_pixels(source_pixels, source_size, quad, output_size):
"""Warp one source quad into Blender-order straight RGBA pixels."""
import numpy as np
output_width, output_height = (int(value) for value in output_size)
if min(output_width, output_height) <= 0:
raise ValueError("Projective output dimensions must be positive")
source = premultiplied_rgba(source_pixels, source_size)
target = np.asarray(
(
(0.0, 0.0),
(float(output_width), 0.0),
(float(output_width), float(output_height)),
(0.0, float(output_height)),
),
dtype=np.float64,
)
output_to_source = homography_from_points(target, quad)
output = np.zeros((output_height, output_width, 4), dtype=np.float32)
target_x = np.arange(output_width, dtype=np.float64) + 0.5
for row_start in range(0, output_height, PROJECTIVE_CHUNK_ROWS):
row_end = min(row_start + PROJECTIVE_CHUNK_ROWS, output_height)
target_y = np.arange(row_start, row_end, dtype=np.float64) + 0.5
x, y = np.meshgrid(target_x, target_y)
points = np.stack((x, y), axis=-1)
mapped = transform_homography(points, output_to_source)
output[row_start:row_end] = sample_rgba(source, mapped)
return np.flipud(straight_rgba(output)).ravel()
def is_animated_image(image):
return getattr(image, "source", None) in {"MOVIE", "SEQUENCE"}
@@ -405,6 +615,21 @@ def load_normal_result_image(path, source_object):
return image
def configure_color_alpha(image):
"""Apply the presentation mode to an owned Color image."""
if image.alpha_mode != "PREMUL":
pixels = (
image_pixels(image)
if getattr(image, "source", None) == "GENERATED"
or getattr(image, "is_dirty", False)
else None
)
image.alpha_mode = "PREMUL"
if pixels is not None:
image.pixels.foreach_set(pixels)
image.update()
def create_image_edit_result(source_image, pixels, output_size):
"""Create a packed Image datablock from edited RGBA pixels."""
output_width, output_height = (int(value) for value in output_size)
@@ -420,7 +645,7 @@ def create_image_edit_result(source_image, pixels, output_size):
)
try:
image.colorspace_settings.name = source_image.colorspace_settings.name
image.alpha_mode = "STRAIGHT"
configure_color_alpha(image)
image.pixels.foreach_set(pixels)
image.pack()
image.update()
@@ -435,7 +660,9 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None):
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)
@@ -460,8 +687,13 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None):
) = 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,
@@ -474,6 +706,12 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None):
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
@@ -484,47 +722,6 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None):
return result_image
def place_empty_image_result(
context,
source_object,
result_image,
*,
placement_bounds=None,
keep_original=False,
):
"""Place one crop result and optionally preserve its source."""
if not keep_original:
replace_empty_image(
source_object,
result_image,
placement_bounds=placement_bounds,
)
return source_object
result_object = source_object.copy()
collections = tuple(source_object.users_collection)
if not collections:
collections = (context.collection,)
try:
for collection in collections:
collection.objects.link(result_object)
replace_empty_image(
result_object,
result_image,
placement_bounds=placement_bounds,
)
except Exception:
bpy.data.objects.remove(result_object, do_unlink=True)
raise
for selected in context.selected_objects:
selected.select_set(False)
result_object.select_set(True)
context.view_layer.objects.active = result_object
return result_object
def _load_packed_result_image(
source_object,
output_directory,
@@ -546,8 +743,8 @@ def _load_packed_result_image(
str(color_paths[0]),
check_existing=False,
)
result_image.alpha_mode = "CHANNEL_PACKED"
result_image.colorspace_settings.name = source_color_space
configure_color_alpha(result_image)
if animated:
force_sequence_source(result_image)
except (OSError, RuntimeError, TypeError, ValueError) as error:

View File

@@ -3,6 +3,7 @@
import bpy
from .image import (
configure_color_alpha,
configure_image_user,
image_base_name,
image_pixels,
@@ -106,6 +107,7 @@ def create_image_material(
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

@@ -1,19 +1,45 @@
"""Selection values, serialization, rasterization, and Alpha cropping."""
"""Selection values, serialization, rasterization, and composition."""
import json
import math
from dataclasses import dataclass
from pathlib import Path
class ImageEditWarning(ValueError):
pass
def clip_polygon_halfplanes(points, boundaries):
"""Clip a 2D polygon to linear half-planes ax + by + c >= 0."""
polygon = tuple(tuple(float(value) for value in point) for point in points)
for coefficients in boundaries:
scale = max(abs(float(value)) for value in coefficients)
if scale == 0.0:
continue
a, b, c = (float(value) / scale for value in coefficients)
result = []
if not polygon:
break
previous = polygon[-1]
previous_value = math.fsum((a * previous[0], b * previous[1], c))
for current in polygon:
value = math.fsum((a * current[0], b * current[1], c))
if (value >= 0.0) != (previous_value >= 0.0):
weight = previous_value / (previous_value - value)
result.append(tuple(
previous[axis] + weight * (current[axis] - previous[axis])
for axis in range(2)
))
if value >= 0.0:
result.append(current)
previous, previous_value = current, value
polygon = tuple(result)
return polygon
@dataclass(frozen=True)
class SelectionPath:
points: tuple[tuple[float, float], ...]
invert: bool = False
def __post_init__(self):
points = tuple(
@@ -22,26 +48,19 @@ class SelectionPath:
)
selection_path_bounds(points)
object.__setattr__(self, "points", points)
object.__setattr__(self, "invert", bool(self.invert))
def to_json(self):
return json.dumps(
{
"points": self.points,
"invert": self.invert,
},
{"points": self.points},
separators=(",", ":"),
)
@classmethod
def from_json(cls, value):
data = json.loads(value)
if not isinstance(data, dict) or set(data) != {"points", "invert"}:
if not isinstance(data, dict) or set(data) != {"points"}:
raise ValueError("The Selection Path data is invalid")
return cls(
points=tuple(data["points"]),
invert=bool(data["invert"]),
)
return cls(points=tuple(data["points"]))
@dataclass(frozen=True)
@@ -87,29 +106,6 @@ class SelectionMask:
return result
def save_selection_mask(path, selection_mask):
"""Save one local Selection Mask beside a prepared image input."""
import numpy as np
path = Path(path).parent / "selection-mask.npy"
np.save(path, selection_mask.values, allow_pickle=False)
return path
def load_selection_mask(path, bounds):
"""Load a local Selection Mask sidecar without allowing object data."""
import numpy as np
values = np.load(Path(path), allow_pickle=False)
return SelectionMask(values, bounds)
def cleanup_selection_mask(path):
"""Remove a local Selection Mask sidecar."""
if path:
Path(path).unlink(missing_ok=True)
def selection_path_bounds(points):
import numpy as np
@@ -158,8 +154,6 @@ 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")
if selection_path.invert:
return 0, 0, width, height
left, top, right, bottom = selection_path_bounds(selection_path.points)
return (
max(left - padding, 0),
@@ -208,15 +202,13 @@ def rasterize_selection_path(
mask = _antialias_mask(mask)
else:
mask = mask.astype(np.float32)
if selection_path.invert:
mask = 1.0 - mask
if not np.any(mask > 0.0):
raise ImageEditWarning("The selection contains no visible image pixels")
return SelectionMask(mask, canvas_bounds)
def crop_pixels_to_alpha(pixels, bounds):
"""Crop Blender-order RGBA pixels to their nonzero Alpha bounds."""
def trim_pixels_to_alpha(pixels, bounds):
"""Trim Blender-order RGBA pixels to their nonzero Alpha bounds."""
import numpy as np
left, top, right, bottom = bounds
@@ -239,3 +231,30 @@ def crop_pixels_to_alpha(pixels, bounds):
left + content_right,
top + content_bottom,
)
def rasterize_selection_union(image_size, paths, *, antialias=False):
"""Rasterize a geometric union on one local canvas before antialiasing."""
import numpy as np
paths = tuple(paths)
bounds = [
_selection_canvas_bounds(image_size, path, 2 if antialias else 0)
for path in paths
]
entries = [(path, box) for path, box in zip(paths, bounds)
if box[2] > box[0] and box[3] > box[1]]
if not entries:
raise ImageEditWarning("The selection contains no visible image pixels")
left = min(box[0] for _, box in entries)
top = min(box[1] for _, box in entries)
right = max(box[2] for _, box in entries)
bottom = max(box[3] for _, box in entries)
coverage = np.zeros((bottom - top, right - left), dtype=bool)
for path, box in entries:
x0, y0, x1, y1 = box
coverage[y0 - top:y1 - top, x0 - left:x1 - left] |= _rasterize_path(box, path.points)
if not coverage.any():
raise ImageEditWarning("The selection contains no visible image pixels")
values = _antialias_mask(coverage) if antialias else coverage.astype(np.float32)
return SelectionMask(values, (left, top, right, bottom))

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ from .cutout_tool import (
SelectCutoutSelection,
)
from .debug import DebugBen2, DebugDa3, DebugMoge2, DebugUpscale
from .crop_tool import CLASSES as CROP_TOOL_CLASSES
from .image_edit_tool import CLASSES as IMAGE_EDIT_TOOL_CLASSES
from .convert_to_plane import (
ConvertToDepthPlane,
ConvertToPlane,
@@ -34,7 +34,7 @@ CLASSES = (
SelectCutoutSelection,
RunBackgroundRemoval,
ClearModels,
*CROP_TOOL_CLASSES,
*IMAGE_EDIT_TOOL_CLASSES,
RemoveImageBackground,
UpscaleImage,
ConvertToPlane,

View File

@@ -173,6 +173,7 @@ def add_reference_image(context, image, location=None):
reference = bpy.data.objects.new(image.name, None)
reference.empty_display_type = "IMAGE"
reference.use_empty_image_alpha = True
reference.data = image
reference.empty_display_size = REFERENCE_IMAGE_SIZE
context.collection.objects.link(reference)

View File

@@ -4,6 +4,7 @@ import bpy
from ...common.image import (
color_data_name,
configure_color_alpha,
image_empty_bounds,
image_pixels,
load_color_result_image,
@@ -42,6 +43,7 @@ def create_color_image(source_object):
image.pixels.foreach_set(pixels)
image.update()
image.name = color_data_name(source_object)
configure_color_alpha(image)
return image

View File

@@ -1,21 +0,0 @@
from .box import CropBoxTool
from .lasso import CropLassoTool
from .perspective import CropImagePerspective, CropPerspectiveTool
from .polyline import CropPolylineTool
from .selection import ActivateCropTool, CropImageSelection, RefineCropSelection
CLASSES = (
CropImageSelection,
RefineCropSelection,
ActivateCropTool,
CropImagePerspective,
)
__all__ = (
"CLASSES",
"CropBoxTool",
"CropLassoTool",
"CropPerspectiveTool",
"CropPolylineTool",
)

View File

@@ -1,30 +0,0 @@
import bpy
from ...common.viewport import draw_crop_settings
from .selection import CropImageSelection
class CropBoxTool(bpy.types.WorkSpaceTool):
bl_space_type = "VIEW_3D"
bl_context_mode = "OBJECT"
bl_idname = "anyimage.crop_box"
bl_label = "Crop Box"
bl_description = "Crop an Image Empty with a rectangular selection"
bl_icon = "ops.sculpt.border_mask"
bl_operator = CropImageSelection.bl_idname
bl_keymap = (
(
CropImageSelection.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
{"properties": [("gesture", "BOX")]},
),
)
def draw_settings(context, layout, _tool):
draw_crop_settings(
context,
layout,
refine_selection="crop_box_refine_selection",
invert="crop_box_invert",
keep_original="crop_box_keep_original",
)

View File

@@ -1,30 +0,0 @@
import bpy
from ...common.viewport import draw_crop_settings
from .selection import CropImageSelection
class CropLassoTool(bpy.types.WorkSpaceTool):
bl_space_type = "VIEW_3D"
bl_context_mode = "OBJECT"
bl_idname = "anyimage.crop_lasso"
bl_label = "Crop Lasso"
bl_description = "Crop an Image Empty with a freehand selection"
bl_icon = "ops.sculpt.lasso_mask"
bl_operator = CropImageSelection.bl_idname
bl_keymap = (
(
CropImageSelection.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
{"properties": [("gesture", "LASSO")]},
),
)
def draw_settings(context, layout, _tool):
draw_crop_settings(
context,
layout,
refine_selection="crop_lasso_refine_selection",
invert="crop_lasso_invert",
keep_original="crop_lasso_keep_original",
)

View File

@@ -1,29 +0,0 @@
import bpy
from ...common.viewport import draw_crop_settings
from .selection import CropImageSelection
class CropPolylineTool(bpy.types.WorkSpaceTool):
bl_space_type = "VIEW_3D"
bl_context_mode = "OBJECT"
bl_idname = "anyimage.crop_polyline"
bl_label = "Crop Polyline"
bl_description = "Crop an Image Empty with a clicked polygon selection"
bl_icon = "ops.sculpt.polyline_mask"
bl_operator = CropImageSelection.bl_idname
bl_keymap = (
(
CropImageSelection.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
{"properties": [("gesture", "POLYLINE")]},
),
)
def draw_settings(context, layout, _tool):
draw_crop_settings(
context,
layout,
invert="crop_polyline_invert",
keep_original="crop_polyline_keep_original",
)

View File

@@ -1,275 +0,0 @@
import json
import bpy
from ...properties import BEN2_MODEL_KEY, refine_selection_property
from ...runtime import JobOperatorBase
from ...common.ai import (
production_device,
require_environment,
require_model,
)
from ...common.image import (
cleanup_image_input,
create_image_edit_result,
image_rgba,
load_packed_rgba_image,
place_empty_image_result,
prepare_image_bounds_input,
require_image_empty,
video_total_frames,
)
from ...common.viewport import (
CropSelectionGesture,
gesture_property,
image_edit_poll,
report_image_edit_exception,
)
from ...common.selection import (
ImageEditWarning,
cleanup_selection_mask,
load_selection_mask,
rasterize_selection_path,
save_selection_mask,
)
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
def _edit_image_mask_pixels(
source_rgba,
selection_mask,
):
"""Apply a mask while preserving source RGB, then tightly crop the canvas."""
import numpy as np
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 _apply_local_selection_mask(source_rgba, selection_mask):
"""Apply a same-sized Selection Mask without changing the image canvas."""
import numpy as np
rgba = np.asarray(source_rgba, dtype=np.float32).copy()
if rgba.ndim != 3 or rgba.shape[2] != 4:
raise ValueError("The Refine result must contain RGBA pixels")
if rgba.shape[:2] != selection_mask.values.shape:
raise ValueError("The Refine result does not match the Selection")
rgba[:, :, 3] *= selection_mask.values
if not np.any(rgba[:, :, 3] > 0.0):
raise ImageEditWarning("The edit contains no visible image pixels")
return np.flipud(rgba).ravel()
def _edit_image_object(
context,
source_object,
source_rgba,
selection_mask,
*,
keep_original=False,
):
image = source_object.data
pixels, crop_bounds = _edit_image_mask_pixels(
source_rgba,
selection_mask,
)
left, top, right, bottom = crop_bounds
result_image = create_image_edit_result(
image,
pixels,
(right - left, bottom - top),
)
return place_empty_image_result(
context,
source_object,
result_image,
placement_bounds=crop_bounds,
keep_original=keep_original,
)
class CropImageSelection(CropSelectionGesture, bpy.types.Operator):
refine_selection_settings = {
"BOX": "crop_box_refine_selection",
"LASSO": "crop_lasso_refine_selection",
}
invert_settings = {
"BOX": "crop_box_invert",
"LASSO": "crop_lasso_invert",
"POLYLINE": "crop_polyline_invert",
}
keep_original_settings = {
"BOX": "crop_box_keep_original",
"LASSO": "crop_lasso_keep_original",
"POLYLINE": "crop_polyline_keep_original",
}
bl_idname = "anyimage.crop_image_selection"
bl_label = "Crop Image Selection"
bl_description = "Crop an Image Empty with a selection"
bl_options = {"REGISTER", "UNDO"}
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
refine_selection: refine_selection_property()
invert: bpy.props.BoolProperty(
name="Invert",
description="Keep the opposite side of the refined gesture mask",
default=False,
options=_HIDDEN,
)
keep_original: bpy.props.BoolProperty(default=False, options=_HIDDEN)
gesture: gesture_property()
def _submit_selection_path(self, context, selection_path):
keep_original = bool(getattr(self, "keep_original", False))
try:
source_object = require_image_empty(self.source_object_name)
except RuntimeError as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
try:
if video_total_frames(source_object.data) > 1:
raise RuntimeError("Crop tools only support a still image")
selection_mask = rasterize_selection_path(
tuple(source_object.data.size),
selection_path,
)
source_rgba = image_rgba(source_object.data)
if self.refine_selection:
source_path = prepare_image_bounds_input(
source_object.data,
source_rgba,
selection_mask.bounds,
)
try:
selection_mask_path = save_selection_mask(
source_path,
selection_mask,
)
except Exception:
cleanup_image_input(source_path, True)
raise
result = bpy.ops.anyimage.refine_crop_selection(
input_path=str(source_path),
selection_mask_path=str(selection_mask_path),
source_object_name=self.source_object_name,
selection_bounds_json=json.dumps(selection_mask.bounds),
keep_original=keep_original,
)
if "CANCELLED" in result:
cleanup_selection_mask(selection_mask_path)
cleanup_image_input(source_path, True)
return {"CANCELLED"}
return {"FINISHED"}
_edit_image_object(
context,
source_object,
source_rgba,
selection_mask,
keep_original=keep_original,
)
except (RuntimeError, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
return {"CANCELLED"}
return {"FINISHED"}
class RefineCropSelection(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.refine_crop_selection"
bl_label = "Refine Crop Selection"
job_type = "refine-image-selection"
starting_message = "Refining image selection"
input_path: bpy.props.StringProperty(options=_HIDDEN)
selection_mask_path: bpy.props.StringProperty(options=_HIDDEN)
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
selection_bounds_json: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
keep_original: bpy.props.BoolProperty(default=False, options=_HIDDEN)
def request(self, _context):
require_environment()
require_model(BEN2_MODEL_KEY)
return {
"input": str(self.input_path),
"model": BEN2_MODEL_KEY,
"device": production_device().lower(),
}
def response(self, context, result):
source_object = require_image_empty(self.source_object_name)
bounds = tuple(json.loads(self.selection_bounds_json))
selection_mask = load_selection_mask(self.selection_mask_path, bounds)
source_image = source_object.data
encoded_image = load_packed_rgba_image(
result.file("selection_image"),
source_image,
"AnyImage Refine Result",
)
refined_image = None
try:
rgba = image_rgba(encoded_image)
pixels = _apply_local_selection_mask(rgba, selection_mask)
refined_image = create_image_edit_result(
source_image,
pixels,
selection_mask.values.shape[::-1],
)
place_empty_image_result(
context,
source_object,
refined_image,
placement_bounds=bounds,
keep_original=bool(getattr(self, "keep_original", False)),
)
except Exception:
if refined_image is not None and refined_image.users == 0:
bpy.data.images.remove(refined_image, do_unlink=True)
raise
finally:
if encoded_image.users == 0:
bpy.data.images.remove(encoded_image, do_unlink=True)
if "FINISHED" not in bpy.ops.ed.undo_push(message="Crop Image Selection"):
raise RuntimeError("Unable to create the Crop Image Selection Undo step")
return "Crop Selection refined"
def cleanup(self):
cleanup_selection_mask(getattr(self, "selection_mask_path", ""))
cleanup_image_input(self.input_path, True)
class ActivateCropTool(bpy.types.Operator):
bl_idname = "anyimage.activate_crop_tool"
bl_label = "Crop Tool"
bl_description = "Activate Crop Box in the 3D Viewport"
bl_options = {"INTERNAL"}
@classmethod
def poll(cls, context):
return image_edit_poll(context)
def execute(self, _context):
return bpy.ops.wm.tool_set_by_id(
name="anyimage.crop_box", space_type="VIEW_3D"
)

View File

@@ -1213,7 +1213,7 @@ def depth_balloon_projection_parameters(
)
if not usable.any():
raise ValueError("MoGe-2 found no valid Depth Balloon samples")
local_coordinates = vertices[:, 1:]
local_coordinates = vertices[:, :2]
metric_depth = sampled[usable] * float(uniform_scale)
plane = _stable_plane(local_coordinates[usable], metric_depth)
reference = metric_depth_reference(
@@ -1221,7 +1221,7 @@ def depth_balloon_projection_parameters(
calibration_valid,
uniform_scale,
)
direction = np.asarray((-plane[0], plane[1], 1.0), dtype=np.float64)
direction = np.asarray((plane[0], plane[1], 1.0), dtype=np.float64)
direction /= np.linalg.norm(direction)
return reference, tuple(float(value) for value in direction)
@@ -1238,19 +1238,6 @@ class BaseShape:
balloon: object
def orient_cutout_vertices(vertices):
import numpy as np
vertices = np.asarray(vertices, dtype=np.float64)
return np.column_stack(
(
-vertices[:, 2],
-vertices[:, 0],
vertices[:, 1],
)
)
def cutout_domain(
selection_contours,
bounds,
@@ -1433,7 +1420,7 @@ def build_base_shape(
)
source_vertices, faces, uv, _boundary, planar, balloon = domain
return BaseShape(
vertices=orient_cutout_vertices(source_vertices),
vertices=np.asarray(source_vertices, dtype=np.float64),
faces=counter_clockwise_faces(planar, faces),
uv=np.asarray(uv, dtype=np.float32),
balloon=np.asarray(balloon, dtype=np.float64) / world_z_scale,

View File

@@ -29,7 +29,6 @@ def _pie_button(
icon,
source_name,
selection_path_json,
refine_selection,
generate_normal,
point_spacing,
inward_axis,
@@ -42,7 +41,6 @@ def _pie_button(
operator.source_object_name = source_name
operator.selection_path_json = selection_path_json
operator.shape = shape
operator.refine_selection = refine_selection
operator.generate_normal = generate_normal
operator.point_spacing = point_spacing
operator.inward_axis = inward_axis
@@ -53,14 +51,12 @@ def open_shape_pie(
event,
source_object,
selection_path,
refine_selection=False,
):
source_name = source_object.name
selection_path_json = selection_path.to_json()
settings = context.scene.anyimage_settings
point_spacing = cutout_mesh_detail_spacing(settings.cutout_mesh_detail)
ready = ai_ready()
refine_selection = ready and refine_selection
generate_normal = ready and bool(
getattr(settings, "cutout_generate_normal", False)
)
@@ -77,7 +73,6 @@ def open_shape_pie(
icon,
source_name,
selection_path_json,
refine_selection,
generate_normal,
point_spacing,
inward_axis,

View File

@@ -9,11 +9,7 @@ from ...preferences import (
configured_max_ai_input_size,
configured_max_cutout_mesh_resolution,
)
from ...properties import (
BEN2_MODEL_KEY,
DEFAULT_MOGE2_MODEL_KEY,
refine_selection_property,
)
from ...properties import DEFAULT_MOGE2_MODEL_KEY
from ...runtime import JobOperatorBase
from ...server.media.resolution import (
DEFAULT_MAX_AI_INPUT_SIZE,
@@ -43,9 +39,8 @@ from ...common.image import (
)
from ...common.depth import load_depth_metadata, load_depth_result_image
from ...common.viewport import (
CropSelectionGesture,
apply_crop_settings,
gesture_property,
ImageGesture,
image_edit_drag_keymap,
report_image_edit_exception,
resolve_image_edit_click,
serialize_matrix,
@@ -158,14 +153,9 @@ def _cutout_content_values(source_alpha, selection_values=None):
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
else:
path = result.file("selection_image")
def _generated_color_result(input_path, source_object, source_bounds):
image = load_packed_rgba_image(
path,
input_path,
source_object.data,
color_data_name(source_object),
)
@@ -222,18 +212,13 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
options=_HIDDEN,
)
generate_normal: bpy.props.BoolProperty(default=False, options=_HIDDEN)
refine_selection: bpy.props.BoolProperty(default=False, options=_HIDDEN)
inward_axis: bpy.props.StringProperty(
default=CutoutInwardAxis.NEGATIVE_Z.value,
options=_HIDDEN,
)
def _needs_generation(self):
return (
self.refine_selection
or self.shape in DEPTH_CUTOUT_SHAPES
or self.generate_normal
)
return self.shape in DEPTH_CUTOUT_SHAPES or self.generate_normal
def execute(self, context):
try:
@@ -321,20 +306,15 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
source_path = require_input_path(self.input_path)
depth_mode = depth_mode_for_shape(self.shape)
normal_mode = normal_mode_for_shape(self.shape, self.generate_normal)
if self.refine_selection:
require_model(BEN2_MODEL_KEY)
if depth_mode != "NONE" or normal_mode != NormalMode.NONE:
require_model(self.model)
parameters = {
"input": str(source_path),
"device": production_device().lower(),
"refine_selection": self.refine_selection,
"depth_mode": depth_mode,
"normal_mode": normal_mode.value,
"max_input_size": int(self.max_ai_input_size),
}
if self.refine_selection:
parameters["refine_model"] = BEN2_MODEL_KEY
if depth_mode != "NONE" or normal_mode != NormalMode.NONE:
start_frame = source_start_frame(self.source_object_name)
parameters.update(
@@ -361,7 +341,6 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
source_object = require_image_empty(self.source_object_name)
source_bounds = tuple(json.loads(self.selection_bounds_json))
color_image, source_alpha, color_bounds = _generated_color_result(
result,
self.input_path,
source_object,
source_bounds,
@@ -426,19 +405,16 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
cleanup_image_input(self.input_path, True)
class SelectCutoutSelection(CropSelectionGesture, bpy.types.Operator):
class SelectCutoutSelection(ImageGesture, bpy.types.Operator):
bl_idname = "anyimage.select_cutout_selection"
bl_label = "Start Cutout Selection"
bl_description = "Lasso an Image Empty selection, then choose its mesh shape"
bl_options = {"INTERNAL"}
active_tool_id = "anyimage.cutout_lasso"
refine_selection_settings = {"LASSO": "cutout_refine_selection"}
gesture = "LASSO"
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
source_matrix_data: 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"})
_pie_event = None
@@ -462,8 +438,6 @@ class SelectCutoutSelection(CropSelectionGesture, bpy.types.Operator):
if video_total_frames(source_object.data) > 1:
self.report({"WARNING"}, "Cutout only supports a still image")
return {"CANCELLED"}
apply_crop_settings(self, context)
self.invert = False
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)
@@ -474,7 +448,7 @@ class SelectCutoutSelection(CropSelectionGesture, bpy.types.Operator):
def modal(self, context, event):
self._pie_event = event
return CropSelectionGesture.modal(self, context, event)
return ImageGesture.modal(self, context, event)
def _submit_selection_path(self, context, selection_path):
try:
@@ -487,7 +461,6 @@ class SelectCutoutSelection(CropSelectionGesture, bpy.types.Operator):
self._pie_event,
source_object,
selection_path,
self.refine_selection,
)
return {"FINISHED"}
@@ -504,19 +477,17 @@ class CutoutTool(bpy.types.WorkSpaceTool):
(
SelectCutoutSelection.bl_idname,
{"type": "LEFTMOUSE", "value": "DOUBLE_CLICK"},
{"properties": [("gesture", "LASSO"), ("full_image", True)]},
{"properties": [("full_image", True)]},
),
(
*image_edit_drag_keymap(
SelectCutoutSelection.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
{"properties": [("gesture", "LASSO")]},
deselect_on_empty=False,
),
)
def draw_settings(context, layout, _tool):
settings = context.scene.anyimage_settings
status = draw_ai_setup(layout)
draw_ai_property(layout, settings, "cutout_refine_selection", status)
draw_ai_property(layout, settings, "cutout_generate_normal", status)
layout.prop(settings, "cutout_mesh_detail")
layout.prop(settings, "cutout_inward_axis")

View File

@@ -4,7 +4,7 @@ from enum import StrEnum
import bpy
from ...common.image import color_data_name
from ...common.image import color_data_name, configure_color_alpha
from ...common.depth import camera_depth_values, depth_uniform_scale
from ...common.material import (
IMAGE_MATERIAL_NODE_GROUP_NAME,
@@ -31,7 +31,7 @@ from .geometry import (
from .shape import (
BALLOON_ATTRIBUTE_NAME,
BALLOON_CUTOUT_SHAPES,
CUTOUT_AXIS_TO_SOURCE,
CUTOUT_X_TO_Z,
CUTOUT_NODE_GROUP_NAME,
CUTOUT_SHAPE_INDICES,
DEPTH_CUTOUT_SHAPES,
@@ -55,13 +55,13 @@ def _cutout_world_matrix(source_matrix, crop_center, inward_axis):
center_x, center_y = (float(value) for value in crop_center)
matrix = source_matrix @ Matrix.Translation((center_x, center_y, 0.0))
if CutoutInwardAxis(inward_axis) == CutoutInwardAxis.POSITIVE_X:
matrix @= Matrix(CUTOUT_AXIS_TO_SOURCE)
matrix @= Matrix(CUTOUT_X_TO_Z)
return matrix
def orient_object_normal_image(image, inward_axis):
"""Convert internal +X Cutout normals to the selected object axes."""
if CutoutInwardAxis(inward_axis) == CutoutInwardAxis.POSITIVE_X:
"""Convert native Z-axis Cutout normals to the selected object axes."""
if CutoutInwardAxis(inward_axis) == CutoutInwardAxis.NEGATIVE_Z:
return image
import numpy as np
@@ -70,7 +70,7 @@ def orient_object_normal_image(image, inward_axis):
rgba = pixels.reshape((-1, 4))
normal = rgba[:, :3] * 2.0 - 1.0
rgba[:, :3] = np.column_stack(
(-normal[:, 1], normal[:, 2], -normal[:, 0])
(-normal[:, 2], -normal[:, 0], normal[:, 1])
) * 0.5 + 0.5
image.pixels.foreach_set(pixels)
image.update()
@@ -120,7 +120,7 @@ def create_cropped_color_image(source_object, crop_bounds, source_rgba):
)
try:
image.colorspace_settings.name = source.colorspace_settings.name
image.alpha_mode = source.alpha_mode
configure_color_alpha(image)
image.pixels.foreach_set(np.flipud(cropped).ravel())
image.pack()
image.update()

View File

@@ -2,12 +2,18 @@
BALLOON_ATTRIBUTE_NAME = "o_balloon"
CUTOUT_NODE_GROUP_NAME = "O Image Cutout"
CUTOUT_AXIS_TO_SOURCE = (
CUTOUT_X_TO_Z = (
(0.0, -1.0, 0.0, 0.0),
(0.0, 0.0, 1.0, 0.0),
(-1.0, 0.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 1.0),
)
CUTOUT_Z_TO_X = (
(0.0, 0.0, -1.0, 0.0),
(-1.0, 0.0, 0.0, 0.0),
(0.0, 1.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 1.0),
)
CUTOUT_NODE_GROUP_NAMES = {
"SURFACE": "O Image Surface",
"BALLOON": "O Image Balloon",

View File

@@ -0,0 +1,18 @@
from .frame import ActivateImageEditTool, FrameImages, FrameTool
from .mask import EditImageAlpha, MaskTool
from .rectify import RectifyImagePerspective, RectifyTool
CLASSES = (
FrameImages,
EditImageAlpha,
ActivateImageEditTool,
RectifyImagePerspective,
)
__all__ = (
"CLASSES",
"FrameTool",
"MaskTool",
"RectifyTool",
)

View File

@@ -0,0 +1,665 @@
import math
import bpy
from ...common.image import (
MAX_PROJECTIVE_OUTPUT_PIXELS,
PROJECTIVE_CHUNK_ROWS,
create_image_edit_result,
homography_from_points,
image_empty_bounds,
image_pixels,
is_image_empty,
premultiplied_rgba,
replace_empty_image,
sample_rgba,
transform_homography,
video_total_frames,
)
from ...preferences import configured_max_frame_resolution
from ...common.viewport import (
active_image_edit_source,
active_view3d_tool_id,
draw_dashed_line,
draw_polygon_fill,
drawing_in_region,
image_edit_drag_keymap,
image_edit_poll,
rectangle_screen_path,
report_image_edit_exception,
)
from ...common.selection import ImageEditWarning, clip_polygon_halfplanes
FRAME_TOOL_ID = "anyimage.frame"
PROJECTIVE_RELATIVE_EPSILON = 1e-12
def _matrix_array(matrix):
import numpy as np
return np.asarray(
tuple(tuple(float(value) for value in row) for row in matrix),
dtype=np.float64,
)
def frame_source_projection(
projection_matrix,
view_matrix,
matrix_world,
bounds,
region_size,
*,
perspective,
):
"""Return normalized image-plane transforms for one frozen view."""
import numpy as np
x_min, x_max, y_min, y_max = (float(value) for value in bounds)
region_width, region_height = (float(value) for value in region_size)
if min(x_max - x_min, y_max - y_min, region_width, region_height) <= 0.0:
raise ValueError("The Image Empty projection is invalid")
source_plane = np.asarray(
(
(x_max - x_min, 0.0, x_min),
(0.0, y_min - y_max, y_max),
(0.0, 0.0, 0.0),
(0.0, 0.0, 1.0),
),
dtype=np.float64,
)
world = _matrix_array(matrix_world)
source_to_world = world @ source_plane
source_to_view = _matrix_array(view_matrix) @ source_to_world
source_to_clip = _matrix_array(projection_matrix) @ source_to_world
source_to_screen = np.stack(
(
0.5 * (source_to_clip[0] + source_to_clip[3]),
0.5 * (source_to_clip[1] + source_to_clip[3]),
source_to_clip[3],
)
)
scale = float(np.max(np.abs(source_to_screen)))
if not math.isfinite(scale) or scale <= 0.0:
raise ValueError("The Image Empty projection is invalid")
source_to_screen /= scale
if not np.isfinite(source_to_screen).all():
raise ValueError("The Image Empty projection is invalid")
condition = float(np.linalg.cond(source_to_screen))
if not math.isfinite(condition) or condition > 1.0 / np.finfo(np.float64).eps:
raise ValueError("The Image Empty is edge-on to the current view")
return {
"bounds": (x_min, x_max, y_min, y_max),
"origin_depth": -float((_matrix_array(view_matrix) @ world)[2, 3]),
"perspective": bool(perspective),
"region_size": (region_width, region_height),
"screen_to_source": np.linalg.inv(source_to_screen),
"source_to_screen": source_to_screen,
"source_to_view": source_to_view,
"source_to_world": source_to_world,
}
def _projective_points(points, matrix):
"""Map points and return finite results plus per-point validity."""
import numpy as np
points = np.asarray(points, dtype=np.float64)
flat = points.reshape((-1, 2))
homogeneous = np.column_stack((flat, np.ones(len(flat), dtype=np.float64)))
mapped = homogeneous @ np.asarray(matrix, dtype=np.float64).T
magnitude = np.max(np.abs(mapped), axis=1)
valid = (
np.isfinite(mapped).all(axis=1)
& (np.abs(mapped[:, 2]) > PROJECTIVE_RELATIVE_EPSILON * magnitude)
)
result = np.full((len(flat), 2), np.nan, dtype=np.float64)
result[valid] = mapped[valid, :2] / mapped[valid, 2, None]
return result.reshape(points.shape), valid.reshape(points.shape[:-1])
def frame_source_coordinates(source, screen_points):
"""Return source pixels, view depth, validity, and normalized coordinates."""
import numpy as np
screen_points = np.asarray(screen_points, dtype=np.float64)
region_width, region_height = source["region_size"]
normalized_screen = screen_points / (region_width, region_height)
normalized_source, valid = _projective_points(
normalized_screen,
source["screen_to_source"],
)
flat_source = normalized_source.reshape((-1, 2))
homogeneous = np.column_stack(
(flat_source, np.ones(len(flat_source), dtype=np.float64))
)
depth = -(homogeneous @ source["source_to_view"][2])
depth = depth.reshape(valid.shape)
valid &= np.isfinite(depth)
if source["perspective"]:
valid &= depth > 0.0
valid &= (
(normalized_source[..., 0] >= 0.0)
& (normalized_source[..., 0] <= 1.0)
& (normalized_source[..., 1] >= 0.0)
& (normalized_source[..., 1] <= 1.0)
)
image_width, image_height = source["image_size"]
source_points = normalized_source * (image_width, image_height)
source_points[~valid] = -1.0
return source_points, depth, valid, normalized_source
def frame_source_overlap(source, frame_quad):
"""Clip the source plane before dividing its homogeneous projection."""
import numpy as np
frame = np.asarray(frame_quad, dtype=np.float64) / source["region_size"]
left, bottom = frame.min(axis=0)
right, top = frame.max(axis=0)
x, y, w = source["source_to_screen"]
boundaries = [x - left * w, right * w - x, y - bottom * w, top * w - y]
if source["perspective"]:
boundaries.insert(0, -source["source_to_view"][2])
polygon = clip_polygon_halfplanes(
((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)), boundaries
)
if len(polygon) < 3:
return ()
screen, valid = _projective_points(polygon, source["source_to_screen"])
if not np.all(valid):
return ()
screen *= source["region_size"]
# Center the coordinates to avoid cancellation in a distant small polygon.
centered = screen - screen[0]
area = abs(np.sum(centered[:, 0] * np.roll(centered[:, 1], -1)
- centered[:, 1] * np.roll(centered[:, 0], -1))) * 0.5
return tuple(map(tuple, screen)) if area > 1e-8 else ()
def polygon_centroid(polygon):
"""Return the area centroid of one non-degenerate screen polygon."""
cross_sum = 0.0
x_sum = 0.0
y_sum = 0.0
for first, second in zip(polygon, (*polygon[1:], polygon[0])):
cross = first[0] * second[1] - second[0] * first[1]
cross_sum += cross
x_sum += (first[0] + second[0]) * cross
y_sum += (first[1] + second[1]) * cross
if abs(cross_sum) <= 1e-12:
raise ValueError("Frame does not overlap the active Image Empty")
return x_sum / (3.0 * cross_sum), y_sum / (3.0 * cross_sum)
def _pixel_quad(image_size):
width, height = (float(value) for value in image_size)
if min(width, height) <= 0.0:
raise ValueError("The source image has no readable dimensions")
return ((0.0, 0.0), (width, 0.0), (width, height), (0.0, height))
def frame_output_size(frame_quad, region_size, max_resolution):
"""Choose output resolution from Frame pixels within the current Region."""
frame_width = abs(float(frame_quad[1][0] - frame_quad[0][0]))
frame_height = abs(float(frame_quad[0][1] - frame_quad[3][1]))
region_width, region_height = (float(value) for value in region_size)
maximum = float(max_resolution)
if min(frame_width, frame_height, region_width, region_height, maximum) <= 0.0:
raise ValueError("The Frame output dimensions must be positive")
scale = min(
1.0,
region_width / frame_width,
region_height / frame_height,
maximum / max(frame_width, frame_height),
math.sqrt(
MAX_PROJECTIVE_OUTPUT_PIXELS / max(frame_width * frame_height, 1.0)
),
)
return (
max(2, int(round(frame_width * scale))),
max(2, int(round(frame_height * scale))),
)
def _frame_source(source_object, region, view3d, view_matrix, *, active):
import numpy as np
image_size = tuple(source_object.data.size)
pixels = image_pixels(source_object.data)
width, height = image_size
bounds = image_empty_bounds(source_object)
source = frame_source_projection(
view3d.perspective_matrix,
view_matrix,
source_object.matrix_world,
bounds,
(region.width, region.height),
perspective=view3d.is_perspective,
)
source.update(
{
"active": bool(active),
"image_size": image_size,
"name": source_object.name,
"object": source_object,
"pixels": premultiplied_rgba(pixels, image_size),
"rgba": np.flipud(pixels.reshape((height, width, 4))),
}
)
return source
def _composite_frame_samples(sources, screen_points):
"""Resolve independently sorted samples with an unweighted bottom RGB base."""
import numpy as np
samples, depths, coordinates = [], [], []
for source in sources:
points, depth, valid, _ = frame_source_coordinates(source, screen_points)
sample = sample_rgba(source["pixels"], points)
sample[~valid] = 0
depths.append(np.where(valid, depth, -math.inf))
samples.append(sample)
coordinates.append(points)
samples = np.stack(samples)
depths = np.stack(depths)
order = np.argsort(-depths, axis=0, kind="stable")
bottom = order[0]
covered = np.isfinite(np.take_along_axis(depths, bottom[None], axis=0)[0])
result = np.zeros_like(samples[0])
for index, source in enumerate(sources):
selected = covered & (bottom == index)
if np.any(selected):
result[..., :3][selected] = sample_rgba(
source["rgba"], coordinates[index][selected]
)[..., :3]
for index in range(len(sources)):
layer = np.take_along_axis(samples, order[index][None, ..., None], axis=0)[0]
alpha = layer[..., 3:4]
result[..., 3:4] = alpha + result[..., 3:4] * (1 - alpha)
if index:
result[..., :3] = layer[..., :3] + result[..., :3] * (1 - alpha)
return result, covered
def _frame_boundary_pixels(sources, points, output_to_screen):
"""Conservatively detect projected contour and depth-order crossings."""
import numpy as np
boundary = np.zeros(points.shape[:-1], dtype=bool)
for source in sources:
width, height = source["region_size"]
mapping = source["screen_to_source"] @ np.diag((1 / width, 1 / height, 1)) @ output_to_screen
u, v, w = mapping
lines = [u, v, u - w, v - w, w]
positive = np.ones_like(boundary)
negative = np.ones_like(boundary)
for line in (u, v, w - u, w - v, w):
radius = 0.5 * (abs(line[0]) + abs(line[1]))
value = points[..., 0] * line[0] + points[..., 1] * line[1] + line[2]
positive &= value + radius >= 0
negative &= value - radius <= 0
source_boundary = np.zeros_like(boundary)
if source["perspective"]:
lines.append(source["source_to_view"][2] @ mapping)
for line in lines:
radius = 0.5 * (abs(line[0]) + abs(line[1]))
value = points[..., 0] * line[0] + points[..., 1] * line[1] + line[2]
source_boundary |= np.abs(value) < radius - 1e-10 * max(radius, 1e-20)
boundary |= source_boundary & (positive | negative)
if len(sources) > 1:
first_order = None
for offset in ((-0.499999, -0.499999), (0.499999, -0.499999),
(-0.499999, 0.499999), (0.499999, 0.499999)):
screen = transform_homography(points + offset, output_to_screen)
depths = []
for source in sources:
_, depth, valid, _ = frame_source_coordinates(source, screen)
depths.append(np.where(valid, depth, -math.inf))
order = np.argsort(-np.stack(depths), axis=0, kind="stable")
if first_order is None:
first_order = order
else:
boundary |= np.any(order != first_order, axis=0)
return boundary
def composite_frame_pixels(sources, frame_quad, output_size):
"""Composite projected Image Empty sources into one Blender-order RGBA image."""
import numpy as np
output_width, output_height = (int(value) for value in output_size)
if min(output_width, output_height) <= 0:
raise ValueError("The Frame output dimensions must be positive")
sources = tuple(
sorted(sources, key=lambda source: (source["active"], source["name"]))
)
if not sources or not sources[-1]["active"]:
raise ValueError("Frame requires one active Image Empty")
output_quad = _pixel_quad(output_size)
output_to_screen = homography_from_points(output_quad, frame_quad)
output = np.zeros((output_height, output_width, 4), dtype=np.float32)
covered = np.zeros((output_height, output_width), dtype=bool)
target_x = np.arange(output_width, dtype=np.float64) + 0.5
for row_start in range(0, output_height, PROJECTIVE_CHUNK_ROWS):
row_end = min(row_start + PROJECTIVE_CHUNK_ROWS, output_height)
target_y = np.arange(row_start, row_end, dtype=np.float64) + 0.5
x, y = np.meshgrid(target_x, target_y)
points = np.stack((x, y), axis=-1)
composite, coverage = _composite_frame_samples(
sources, transform_homography(points, output_to_screen)
)
boundary = _frame_boundary_pixels(sources, points, output_to_screen)
if np.any(boundary):
edge_points = points[boundary]
accumulated = np.zeros((len(edge_points), 4), dtype=np.float32)
counts = np.zeros(len(edge_points), dtype=np.int32)
for dy in (-0.375, -0.125, 0.125, 0.375):
for dx in (-0.375, -0.125, 0.125, 0.375):
sample, hit = _composite_frame_samples(
sources,
transform_homography(edge_points + (dx, dy), output_to_screen),
)
accumulated += sample
counts += hit
accumulated[..., 3] /= 16
accumulated[..., :3] /= np.maximum(counts, 1)[..., None]
composite[boundary] = accumulated
coverage[boundary] = counts > 0
covered[row_start:row_end] = coverage
output[row_start:row_end] = composite
output[..., 3][output[..., 3] <= 1e-8] = 0.0
output[~covered] = 0.0
return np.flipud(output).ravel()
def view_facing_frame_geometry(corners, output_size):
"""Return a world matrix array and display size for frame-plane corners."""
import numpy as np
corners = np.asarray(corners, dtype=np.float64)
if corners.shape != (4, 3) or not np.isfinite(corners).all():
raise ValueError("The Frame view plane is invalid")
top_left, top_right, _bottom_right, bottom_left = corners
right = top_right - top_left
up = top_left - bottom_left
width = float(np.linalg.norm(right))
height = float(np.linalg.norm(up))
if min(width, height) <= 1e-10:
raise ValueError("Draw a larger Frame")
right /= width
up /= height
forward = np.cross(right, up)
forward_length = float(np.linalg.norm(forward))
if forward_length <= 1e-10:
raise ValueError("The Frame view plane is invalid")
forward /= forward_length
matrix = np.identity(4, dtype=np.float64)
matrix[:3, 0] = right
matrix[:3, 1] = up
matrix[:3, 2] = forward
matrix[:3, 3] = corners.mean(axis=0)
display_size = width if output_size[0] >= output_size[1] else height
return matrix, display_size
def frame_result_transform(frame_quad, source_origin, region, view3d, output_size):
"""Return the view-facing object matrix and Image Empty display size."""
from mathutils import Matrix
from bpy_extras import view3d_utils
corners = tuple(
tuple(
float(value)
for value in view3d_utils.region_2d_to_location_3d(
region,
view3d,
point,
source_origin,
)
)
for point in frame_quad
)
matrix, display_size = view_facing_frame_geometry(corners, output_size)
return Matrix(tuple(tuple(row) for row in matrix)), display_size
def frame_depth_location(active_source, overlap_polygon):
"""Return the world point whose view depth places the Frame result."""
import numpy as np
source_object = active_source["object"]
if not active_source["perspective"] or active_source["origin_depth"] > 0.0:
return source_object.matrix_world.translation.copy()
centroid = np.asarray((polygon_centroid(overlap_polygon),), dtype=np.float64)
_points, depth, valid, normalized_source = frame_source_coordinates(
active_source,
centroid,
)
if not bool(valid[0]) or depth[0] <= 0.0:
raise ValueError("The Frame result depth is invalid")
source_point = np.asarray(
(normalized_source[0, 0], normalized_source[0, 1], 1.0),
dtype=np.float64,
)
world = active_source["source_to_world"] @ source_point
if not np.isfinite(world).all() or abs(world[3]) <= 1e-12:
raise ValueError("The Frame result depth is invalid")
return tuple(float(value) for value in world[:3] / world[3])
def selected_frame_sources(context):
"""Return selected still Image Empties with the active object last."""
active = active_image_edit_source(context)
sources = [
source for source in context.selected_objects if is_image_empty(source)
]
for source in sources:
if video_total_frames(source.data) > 1:
raise ValueError("Frame only supports still images")
return tuple(
sorted(sources, key=lambda source: (source is active, source.name))
)
def apply_frame(context, source_objects, frame_quad):
"""Bake selected projections into the active Image Empty."""
active = context.active_object
if active not in source_objects:
raise ValueError("The active Image Empty is not a Frame source")
view3d = context.region_data
sources = tuple(
_frame_source(
source,
context.region,
view3d,
view3d.view_matrix,
active=source is active,
)
for source in source_objects
)
active_source = next(source for source in sources if source["active"])
active_overlap = frame_source_overlap(active_source, frame_quad)
if not active_overlap:
raise ImageEditWarning("Frame does not overlap the active Image Empty")
output_size = frame_output_size(
frame_quad,
(context.region.width, context.region.height),
configured_max_frame_resolution(context),
)
pixels = composite_frame_pixels(sources, frame_quad, output_size)
depth_location = frame_depth_location(active_source, active_overlap)
matrix, display_size = frame_result_transform(
frame_quad,
depth_location,
context.region,
view3d,
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)
source_images = []
for source in source_objects:
if all(image is not source.data for image in source_images):
source_images.append(source.data)
try:
active.matrix_world = matrix
active.empty_display_size = display_size
active.empty_image_offset = (-0.5, -0.5)
replace_empty_image(active, result_image)
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:
bpy.data.objects.remove(source, do_unlink=True)
for image in source_images:
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
class FrameImages(bpy.types.Operator):
bl_idname = "anyimage.frame_images"
bl_label = "Frame Images"
bl_description = "Bake selected Image Empties into a view-facing frame"
bl_options = {"UNDO"}
_active_name = ""
_source_names = ()
_start = None
_cursor = None
_handle = None
_area_pointer = 0
_region_pointer = 0
@classmethod
def poll(cls, context):
return image_edit_poll(context)
def _draw_overlay(self):
if not drawing_in_region(self._area_pointer, self._region_pointer):
return
frame = rectangle_screen_path(self._start, self._cursor)
if not frame:
return
draw_polygon_fill(frame)
draw_dashed_line((*frame, frame[0]))
def _finish(self, context):
if self._handle is not None:
bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW")
self._handle = None
context.workspace.status_text_set(None)
if context.area is not None:
context.area.tag_redraw()
def cancel(self, context):
self._finish(context)
def invoke(self, context, event):
try:
sources = selected_frame_sources(context)
except (TypeError, ValueError) as error:
report_image_edit_exception(self, error)
return {"CANCELLED"}
self._active_name = context.active_object.name
self._source_names = tuple(source.name for source in sources)
point = (float(event.mouse_region_x), float(event.mouse_region_y))
self._start = point
self._cursor = point
self._area_pointer = context.area.as_pointer()
self._region_pointer = context.region.as_pointer()
self._handle = bpy.types.SpaceView3D.draw_handler_add(
self._draw_overlay, (), "WINDOW", "POST_PIXEL"
)
context.workspace.status_text_set(
"Frame: drag a rectangle to merge selected Image Empties; "
"RMB or Esc cancels"
)
context.window_manager.modal_handler_add(self)
context.area.tag_redraw()
return {"RUNNING_MODAL"}
def modal(self, context, event):
active_tool = active_view3d_tool_id(context)
if active_tool is not None and active_tool != FRAME_TOOL_ID:
self._finish(context)
return {"CANCELLED"}
if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS":
self._finish(context)
return {"CANCELLED"}
if event.type == "MOUSEMOVE":
self._cursor = (
float(event.mouse_region_x),
float(event.mouse_region_y),
)
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
self._cursor = (
float(event.mouse_region_x),
float(event.mouse_region_y),
)
frame = rectangle_screen_path(self._start, self._cursor)
if not frame:
self._finish(context)
return {"CANCELLED"}
try:
active = bpy.data.objects.get(self._active_name)
sources = tuple(
bpy.data.objects.get(name) for name in self._source_names
)
if (
not is_image_empty(active)
or any(not is_image_empty(source) for source in sources)
or context.active_object is not active
):
raise ValueError("The selected Image Empties changed during Frame")
apply_frame(context, sources, frame)
except (RuntimeError, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
self._finish(context)
return {"CANCELLED"}
self._finish(context)
return {"FINISHED"}
return {"PASS_THROUGH"}
class FrameTool(bpy.types.WorkSpaceTool):
bl_space_type = "VIEW_3D"
bl_context_mode = "OBJECT"
bl_idname = FRAME_TOOL_ID
bl_label = "Frame"
bl_description = "Bake selected Image Empties into a view-facing frame"
bl_icon = "ops.sculpt.border_mask"
bl_operator = FrameImages.bl_idname
bl_keymap = image_edit_drag_keymap(FrameImages.bl_idname)
class ActivateImageEditTool(bpy.types.Operator):
bl_idname = "anyimage.activate_image_edit_tool"
bl_label = "Image Edit"
bl_description = "Activate Frame in the 3D Viewport"
bl_options = {"INTERNAL"}
@classmethod
def poll(cls, context):
return image_edit_poll(context)
def execute(self, _context):
return bpy.ops.wm.tool_set_by_id(name=FRAME_TOOL_ID, space_type="VIEW_3D")

View File

@@ -0,0 +1,176 @@
import bpy
from ...common.image import (
create_image_edit_result,
image_empty_bounds,
image_rgba,
replace_empty_image,
require_image_empty,
)
from ...common.selection import (
rasterize_selection_path,
rasterize_selection_union,
SelectionPath,
)
from ...common.viewport import (
brush_footprint_polygons,
ImageGesture,
image_edit_point_keymap,
report_image_edit_exception,
screen_path_to_image_pixels,
)
from ...properties import (
mask_gesture_property,
mask_mode_property,
mask_radius_property,
)
MASK_TOOL_ID = "anyimage.mask"
def compose_alpha(source_alpha, mask_values, mode):
"""Compose source Alpha with one same-shaped Mask."""
import numpy as np
alpha = np.asarray(source_alpha, dtype=np.float32)
mask = np.asarray(mask_values, dtype=np.float32)
if alpha.shape != mask.shape:
raise ValueError("The source Alpha and Mask must have the same shape")
mode = str(mode)
if mode == "SET":
result = alpha * mask
elif mode == "ADD":
result = np.maximum(alpha, mask)
elif mode == "SUBTRACT":
result = alpha * (1.0 - mask)
else:
raise ValueError(f"Unsupported Mask mode: {mode}")
return np.clip(result, 0.0, 1.0)
def apply_alpha_mask(source_rgba, selection_mask, mode):
"""Apply one Selection Mask to current Alpha without changing the canvas."""
import numpy as np
rgba = np.asarray(source_rgba, dtype=np.float32).copy()
if rgba.ndim != 3 or rgba.shape[2] != 4:
raise ValueError("The source image must contain RGBA pixels")
values = selection_mask.full_values((rgba.shape[1], rgba.shape[0]))
rgba[:, :, 3] = compose_alpha(rgba[:, :, 3], values, mode)
return np.flipud(rgba).ravel()
def rasterize_brush_path(image_size, path, radius, project_path):
"""Project the shared Brush footprint and antialias its complete union."""
paths = tuple(
SelectionPath(points=tuple(project_path(polygon)))
for polygon in brush_footprint_polygons(path, radius)
)
return rasterize_selection_union(image_size, paths, antialias=True)
class EditImageAlpha(ImageGesture, bpy.types.Operator):
bl_idname = "anyimage.edit_image_alpha"
bl_label = "Edit Image Alpha"
bl_description = "Edit Image Empty Alpha with a lasso, brush, or polyline"
bl_options = {"UNDO"}
active_tool_id = MASK_TOOL_ID
resolve_on_press = True
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
gesture: mask_gesture_property()
mode: mask_mode_property()
radius: mask_radius_property()
def _read_gesture_settings(self, context):
settings = context.scene.anyimage_settings
self.gesture = str(settings.mask_gesture)
self.mode = str(settings.mask_mode)
self.radius = int(settings.mask_radius)
def _set_status(self, context):
if self.gesture == "BRUSH":
action = f"paint with radius {self.radius} px"
elif self.gesture == "POLYLINE":
action = (
"click points; click start, double-click, or Enter to finish; "
"Backspace removes"
)
else:
action = "draw and release"
context.workspace.status_text_set(
f"Mask {self.gesture.title()}: {action}; {self.mode.title()}; "
"RMB or Esc cancels"
)
def _submit_selection_path(self, context, selection_path):
source_object = require_image_empty(self.source_object_name)
selection_mask = rasterize_selection_path(
tuple(source_object.data.size),
selection_path,
antialias=True,
)
return self._apply_mask(context, source_object, selection_mask)
def _complete_brush(self, context, source_object, matrix):
image_size = tuple(source_object.data.size)
local_bounds = image_empty_bounds(source_object)
selection_mask = rasterize_brush_path(
image_size,
self._path,
self.radius,
lambda path: screen_path_to_image_pixels(
path,
context.region,
context.region_data,
matrix,
local_bounds,
image_size,
simplify=False,
),
)
return self._apply_mask(context, source_object, selection_mask)
def _apply_mask(self, context, source_object, selection_mask):
try:
source_image = source_object.data
pixels = apply_alpha_mask(
image_rgba(source_image),
selection_mask,
self.mode,
)
result_image = create_image_edit_result(
source_image,
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
except (RuntimeError, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
return {"CANCELLED"}
return {"FINISHED"}
class MaskTool(bpy.types.WorkSpaceTool):
bl_space_type = "VIEW_3D"
bl_context_mode = "OBJECT"
bl_idname = MASK_TOOL_ID
bl_label = "Mask"
bl_description = "Edit Image Empty Alpha with a lasso, brush, or polyline"
bl_icon = "ops.sculpt.lasso_mask"
bl_operator = EditImageAlpha.bl_idname
bl_cursor = "PAINT_CROSS"
bl_keymap = image_edit_point_keymap(EditImageAlpha.bl_idname)
def draw_settings(context, layout, _tool):
settings = context.scene.anyimage_settings
layout.prop(settings, "mask_gesture")
layout.prop(settings, "mask_mode", expand=True, icon_only=True)
if settings.mask_gesture == "BRUSH":
layout.prop(settings, "mask_radius")

View File

@@ -6,31 +6,30 @@ import bpy
from ...common.viewport import (
active_view3d_tool_id,
deserialize_matrix,
draw_crop_settings,
draw_dashed_line,
draw_polygon_fill,
drawing_in_region,
image_edit_poll,
image_edit_point_keymap,
report_image_edit_exception,
resolve_image_edit_click,
screen_path_to_image_pixels,
serialize_matrix,
)
from ...common.selection import crop_pixels_to_alpha
from ...common.selection import clip_polygon_halfplanes, trim_pixels_to_alpha
from ...common.image import (
create_image_edit_result,
image_base_name,
image_pixels,
image_empty_bounds,
place_empty_image_result,
projective_output_size,
replace_empty_image,
require_image_empty,
video_total_frames,
warp_projective_pixels,
)
MIN_QUAD_AREA = 16.0
MAX_OUTPUT_DIMENSION = 8192
MAX_OUTPUT_PIXELS = 16_777_216
WARP_CHUNK_ROWS = 256
CROP_PERSPECTIVE_TOOL_ID = "anyimage.crop_perspective"
RECTIFY_TOOL_ID = "anyimage.rectify"
PREVIEW_TEXTURE_SIZE = 512
ASPECT_PIXELS_PER_DOUBLE = 240.0
MIN_INTERACTIVE_ASPECT = 0.05
@@ -50,15 +49,15 @@ def validate_perspective_quad(points, min_area=1e-6):
quad = np.asarray(points, dtype=np.float64)
if quad.shape != (4, 2) or not np.isfinite(quad).all():
raise ValueError("Perspective requires four valid image corners")
raise ValueError("Rectify requires four valid image corners")
edges = np.roll(quad, -1, axis=0) - quad
if np.any(np.linalg.norm(edges, axis=1) < 2.0):
raise ValueError("Perspective corners are too close together")
raise ValueError("Rectify corners are too close together")
crosses = np.cross(edges, np.roll(edges, -1, axis=0))
if np.any(np.abs(crosses) <= 1e-8) or not (
np.all(crosses > 0.0) or np.all(crosses < 0.0)
):
raise ValueError("Perspective corners must define one convex quadrilateral")
raise ValueError("Rectify corners must define one convex quadrilateral")
area = 0.5 * abs(
float(
np.dot(quad[:, 0], np.roll(quad[:, 1], -1))
@@ -66,7 +65,7 @@ def validate_perspective_quad(points, min_area=1e-6):
)
)
if area < min_area:
raise ValueError("Draw a larger perspective quad")
raise ValueError("Draw a larger Rectify quad")
return quad
@@ -76,11 +75,11 @@ def canonical_perspective_quad(points, min_area=1e-6):
points = np.asarray(points, dtype=np.float64)
if points.shape != (4, 2) or not np.isfinite(points).all():
raise ValueError("Perspective requires four valid image corners")
raise ValueError("Rectify requires four valid image corners")
distances = np.linalg.norm(points[:, None] - points[None, :], axis=2)
distances += np.eye(4) * 1e9
if float(distances.min()) < 2.0:
raise ValueError("Perspective corners are too close together")
raise ValueError("Rectify corners are too close together")
center = points.mean(axis=0)
ring = points[np.argsort(np.arctan2(points[:, 1] - center[1], points[:, 0] - center[0]))]
ring = validate_perspective_quad(ring, min_area)
@@ -119,63 +118,15 @@ def interactive_preview_polygon(points, cursor):
return preview_polygon(vertices)
def clip_polygon_to_image(points, image_size):
"""Clip one polygon to the source image rectangle."""
polygon = [tuple(float(value) for value in point) for point in points]
width, height = (float(value) for value in image_size)
if width <= 0.0 or height <= 0.0:
raise ValueError("The source image has no readable dimensions")
def clip(vertices, inside, intersection):
if not vertices:
return []
result = []
previous = vertices[-1]
previous_inside = inside(previous)
for current in vertices:
current_inside = inside(current)
if current_inside != previous_inside:
result.append(intersection(previous, current))
if current_inside:
result.append(current)
previous = current
previous_inside = current_inside
return result
def vertical_intersection(start, end, x):
factor = (x - start[0]) / (end[0] - start[0])
return x, start[1] + factor * (end[1] - start[1])
def horizontal_intersection(start, end, y):
factor = (y - start[1]) / (end[1] - start[1])
return start[0] + factor * (end[0] - start[0]), y
polygon = clip(
polygon,
lambda point: point[0] >= 0.0,
lambda start, end: vertical_intersection(start, end, 0.0),
)
polygon = clip(
polygon,
lambda point: point[0] <= width,
lambda start, end: vertical_intersection(start, end, width),
)
polygon = clip(
polygon,
lambda point: point[1] >= 0.0,
lambda start, end: horizontal_intersection(start, end, 0.0),
)
return clip(
polygon,
lambda point: point[1] <= height,
lambda start, end: horizontal_intersection(start, end, height),
)
def perspective_quad_overlaps_image(points, image_size):
import numpy as np
clipped = np.asarray(clip_polygon_to_image(points, image_size))
width, height = image_size
if min(width, height) <= 0:
raise ValueError("The source image has no readable dimensions")
clipped = np.asarray(clip_polygon_halfplanes(
points, ((1, 0, 0), (-1, 0, width), (0, 1, 0), (0, -1, height))
))
if clipped.ndim != 2 or len(clipped) < 3:
return False
area = 0.5 * abs(
@@ -287,7 +238,7 @@ def draw_aspect_hud(bounds, aspect_ratio, preset_label=None):
def create_perspective_preview_image(source_image, pixels):
image = bpy.data.images.new(
f"{image_base_name(source_image)}.perspective-preview",
f"{image_base_name(source_image)}.rectify-preview",
width=PREVIEW_TEXTURE_SIZE,
height=PREVIEW_TEXTURE_SIZE,
alpha=True,
@@ -309,134 +260,6 @@ def preview_texture_draw_options(blender_version):
return {}
def homography_from_points(source, target):
"""Return a projective transform mapping four source points to target."""
import numpy as np
source = np.asarray(source, dtype=np.float64)
target = np.asarray(target, dtype=np.float64)
if source.shape != (4, 2) or target.shape != (4, 2):
raise ValueError("A homography requires four source and target points")
rows = []
values = []
for (x, y), (u, v) in zip(source, target):
rows.extend(
(
(x, y, 1.0, 0.0, 0.0, 0.0, -u * x, -u * y),
(0.0, 0.0, 0.0, x, y, 1.0, -v * x, -v * y),
)
)
values.extend((u, v))
matrix = np.asarray(rows, dtype=np.float64)
if np.linalg.cond(matrix) > 1e12:
raise ValueError("The perspective quad is too degenerate to transform")
coefficients = np.linalg.solve(matrix, np.asarray(values, dtype=np.float64))
return np.append(coefficients, 1.0).reshape((3, 3))
def perspective_output_size(
quad,
aspect_ratio,
max_dimension=MAX_OUTPUT_DIMENSION,
max_pixels=MAX_OUTPUT_PIXELS,
):
"""Choose a detail-preserving output size for one requested aspect ratio."""
import numpy as np
if not math.isfinite(aspect_ratio) or aspect_ratio <= 0.0:
raise ValueError("Perspective aspect ratio must be greater than zero")
quad = validate_perspective_quad(quad)
width_pixels = 0.5 * (
np.linalg.norm(quad[1] - quad[0]) + np.linalg.norm(quad[2] - quad[3])
)
height_pixels = 0.5 * (
np.linalg.norm(quad[2] - quad[1]) + np.linalg.norm(quad[3] - quad[0])
)
height = (aspect_ratio * width_pixels + height_pixels) / (
aspect_ratio * aspect_ratio + 1.0
)
width = aspect_ratio * height
dimension_scale = float(max_dimension) / max(width, height)
pixel_scale = math.sqrt(float(max_pixels) / max(width * height, 1.0))
scale = min(1.0, dimension_scale, pixel_scale)
return max(2, int(round(width * scale))), max(2, int(round(height * scale)))
def warp_perspective_pixels(source_pixels, source_size, quad, output_size):
"""Rectify one source quadrilateral into Blender-order straight RGBA pixels."""
import numpy as np
source_width, source_height = (int(value) for value in source_size)
output_width, output_height = (int(value) for value in output_size)
if min(source_width, source_height, output_width, output_height) <= 0:
raise ValueError("Perspective image dimensions must be positive")
source = np.asarray(source_pixels, dtype=np.float32)
if source.size != source_width * source_height * 4:
raise ValueError("The source image pixels do not match its dimensions")
source = np.flipud(source.reshape((source_height, source_width, 4)))
source[:, :, :3] *= source[:, :, 3:4]
quad = validate_perspective_quad(quad)
target = np.asarray(
(
(0.0, 0.0),
(float(output_width), 0.0),
(float(output_width), float(output_height)),
(0.0, float(output_height)),
),
dtype=np.float64,
)
inverse = homography_from_points(target, quad)
output = np.zeros((output_height, output_width, 4), dtype=np.float32)
target_x = np.arange(output_width, dtype=np.float64) + 0.5
for row_start in range(0, output_height, WARP_CHUNK_ROWS):
row_end = min(row_start + WARP_CHUNK_ROWS, output_height)
target_y = np.arange(row_start, row_end, dtype=np.float64) + 0.5
x, y = np.meshgrid(target_x, target_y)
target_centers = np.stack(
(x.ravel(), y.ravel(), np.ones(x.size)), axis=0
)
mapped = inverse @ target_centers
denominator = mapped[2]
valid_denominator = np.abs(denominator) > 1e-12
mapped[:, valid_denominator] /= denominator[valid_denominator]
mapped[:, ~valid_denominator] = -1.0
pixel_x = mapped[0] - 0.5
pixel_y = mapped[1] - 0.5
valid = (
valid_denominator
& np.isfinite(mapped[0])
& np.isfinite(mapped[1])
& (mapped[0] >= 0.0)
& (mapped[0] <= source_width)
& (mapped[1] >= 0.0)
& (mapped[1] <= source_height)
)
pixel_x = np.clip(pixel_x, 0.0, source_width - 1.0)
pixel_y = np.clip(pixel_y, 0.0, source_height - 1.0)
x0 = np.floor(pixel_x).astype(np.int64)
y0 = np.floor(pixel_y).astype(np.int64)
x1 = np.minimum(x0 + 1, source_width - 1)
y1 = np.minimum(y0 + 1, source_height - 1)
fx = (pixel_x - x0)[:, None]
fy = (pixel_y - y0)[:, None]
sampled = (
source[y0, x0] * (1.0 - fx) * (1.0 - fy)
+ source[y0, x1] * fx * (1.0 - fy)
+ source[y1, x0] * (1.0 - fx) * fy
+ source[y1, x1] * fx * fy
)
sampled[~valid] = 0.0
alpha = sampled[:, 3:4]
visible = alpha[:, 0] > 1e-8
sampled[visible, :3] /= alpha[visible]
sampled[~visible] = 0.0
output[row_start:row_end] = sampled.reshape(
(row_end - row_start, output_width, 4)
)
return np.flipud(output).ravel()
def perspective_quad_aspect(quad):
"""Estimate an initial output aspect from the visible quad edge lengths."""
import numpy as np
@@ -454,22 +277,22 @@ def perspective_quad_aspect(quad):
)
def perspective_placement_bounds(quad, output_size, crop_bounds):
"""Map a rectified Alpha crop back to an approximate source-image rectangle."""
def perspective_placement_bounds(quad, output_size, trim_bounds):
"""Map a rectified Alpha trim back to an approximate source-image rectangle."""
quad = validate_perspective_quad(quad)
source_left = math.floor(float(quad[:, 0].min()))
source_top = math.floor(float(quad[:, 1].min()))
source_right = math.ceil(float(quad[:, 0].max()))
source_bottom = math.ceil(float(quad[:, 1].max()))
output_width, output_height = (float(value) for value in output_size)
crop_left, crop_top, crop_right, crop_bottom = crop_bounds
trim_left, trim_top, trim_right, trim_bottom = trim_bounds
source_width = source_right - source_left
source_height = source_bottom - source_top
return (
source_left + crop_left * source_width / output_width,
source_top + crop_top * source_height / output_height,
source_left + crop_right * source_width / output_width,
source_top + crop_bottom * source_height / output_height,
source_left + trim_left * source_width / output_width,
source_top + trim_top * source_height / output_height,
source_left + trim_right * source_width / output_width,
source_top + trim_bottom * source_height / output_height,
)
@@ -479,33 +302,29 @@ def extract_perspective_pixels(
quad,
aspect_ratio,
):
output_size = perspective_output_size(quad, aspect_ratio)
pixels = warp_perspective_pixels(
output_size = projective_output_size(quad, aspect_ratio)
pixels = warp_projective_pixels(
source_pixels, source_size, quad, output_size
)
pixels, crop_bounds = crop_pixels_to_alpha(pixels, (0, 0, *output_size))
cropped_size = (
crop_bounds[2] - crop_bounds[0],
crop_bounds[3] - crop_bounds[1],
pixels, trim_bounds = trim_pixels_to_alpha(pixels, (0, 0, *output_size))
trimmed_size = (
trim_bounds[2] - trim_bounds[0],
trim_bounds[3] - trim_bounds[1],
)
placement_bounds = perspective_placement_bounds(
quad, output_size, crop_bounds
quad, output_size, trim_bounds
)
return pixels, cropped_size, placement_bounds
return pixels, trimmed_size, placement_bounds
class CropImagePerspective(bpy.types.Operator):
bl_idname = "anyimage.crop_image_perspective"
bl_label = "Crop Image Perspective"
bl_description = "Crop four image corners into a rectangular image"
class RectifyImagePerspective(bpy.types.Operator):
bl_idname = "anyimage.rectify_image_perspective"
bl_label = "Rectify Image Perspective"
bl_description = "Rectify four image corners into a rectangular image"
bl_options = {"REGISTER", "UNDO"}
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
keep_original: bpy.props.BoolProperty(
default=False,
options={"HIDDEN", "SKIP_SAVE"},
)
quad_json: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
aspect_ratio: bpy.props.FloatProperty(
default=1.0,
@@ -577,14 +396,14 @@ class CropImagePerspective(bpy.types.Operator):
def _set_point_status(self, context):
remaining = 4 - len(self._points)
context.workspace.status_text_set(
f"Crop Perspective: click {remaining} more corner"
f"Rectify: click {remaining} more corner"
f"{'s' if remaining != 1 else ''}; click order does not matter; "
"Backspace removes, RMB or Esc cancels"
)
def _set_aspect_status(self, context):
context.workspace.status_text_set(
f"Crop Perspective Aspect {self.aspect_ratio:.3f}: "
f"Rectify Aspect {self.aspect_ratio:.3f}: "
"move mouse horizontally, LMB confirms, Backspace returns, "
"RMB or Esc cancels"
)
@@ -640,14 +459,11 @@ class CropImagePerspective(bpy.types.Operator):
if video_total_frames(source_object.data) > 1:
self.report(
{"WARNING"},
"Crop Perspective only supports a still image",
"Rectify only supports a still image",
)
return {"CANCELLED"}
self.source_object_name = source_object.name
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
self.keep_original = (
context.scene.anyimage_settings.crop_perspective_keep_original
)
self.quad_json = ""
self._phase = "POINTS"
self._preview_image = None
@@ -669,7 +485,7 @@ class CropImagePerspective(bpy.types.Operator):
def modal(self, context, event):
active_tool = active_view3d_tool_id(context)
if active_tool is not None and active_tool != CROP_PERSPECTIVE_TOOL_ID:
if active_tool is not None and active_tool != RECTIFY_TOOL_ID:
self._finish(context)
return {"CANCELLED"}
if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS":
@@ -743,7 +559,7 @@ class CropImagePerspective(bpy.types.Operator):
):
self.report(
{"WARNING"},
"Perspective quad does not overlap the source image",
"Rectify quad does not overlap the source image",
)
self._finish(context)
return {"CANCELLED"}
@@ -769,7 +585,7 @@ class CropImagePerspective(bpy.types.Operator):
import numpy as np
self._source_pixels = image_pixels(source_object.data)
preview_pixels = warp_perspective_pixels(
preview_pixels = warp_projective_pixels(
self._source_pixels,
tuple(source_object.data.size),
quad,
@@ -780,7 +596,7 @@ class CropImagePerspective(bpy.types.Operator):
):
self.report(
{"WARNING"},
"Perspective quad contains no visible image pixels",
"Rectify quad contains no visible image pixels",
)
self._finish(context)
return {"CANCELLED"}
@@ -806,14 +622,14 @@ class CropImagePerspective(bpy.types.Operator):
return {"CANCELLED"}
try:
if video_total_frames(source_object.data) > 1:
raise RuntimeError("Crop Perspective only supports a still image")
raise RuntimeError("Rectify only supports a still image")
quad = validate_perspective_quad(json.loads(self.quad_json))
if not perspective_quad_overlaps_image(
quad, tuple(source_object.data.size)
):
self.report(
{"WARNING"},
"Perspective quad does not overlap the source image",
"Rectify quad does not overlap the source image",
)
return {"CANCELLED"}
import numpy as np
@@ -834,12 +650,10 @@ class CropImagePerspective(bpy.types.Operator):
pixels,
output_size,
)
place_empty_image_result(
context,
replace_empty_image(
source_object,
result_image,
placement_bounds=placement_bounds,
keep_original=bool(getattr(self, "keep_original", False)),
)
except (
json.JSONDecodeError,
@@ -853,25 +667,12 @@ class CropImagePerspective(bpy.types.Operator):
return {"FINISHED"}
class CropPerspectiveTool(bpy.types.WorkSpaceTool):
class RectifyTool(bpy.types.WorkSpaceTool):
bl_space_type = "VIEW_3D"
bl_context_mode = "OBJECT"
bl_idname = CROP_PERSPECTIVE_TOOL_ID
bl_label = "Crop Perspective"
bl_description = "Crop four image corners to a rectangular perspective"
bl_idname = RECTIFY_TOOL_ID
bl_label = "Rectify"
bl_description = "Rectify four image corners into a rectangular image"
bl_icon = "ops.sculpt.line_project"
bl_operator = CropImagePerspective.bl_idname
bl_keymap = (
(
CropImagePerspective.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
None,
),
)
def draw_settings(context, layout, _tool):
draw_crop_settings(
context,
layout,
keep_original="crop_perspective_keep_original",
)
bl_operator = RectifyImagePerspective.bl_idname
bl_keymap = image_edit_point_keymap(RectifyImagePerspective.bl_idname)

View File

@@ -17,6 +17,9 @@ DEFAULT_BACKGROUND_MODEL_KEY = "BEN2_BASE"
DEFAULT_MAX_CUTOUT_MESH_RESOLUTION = 1024
MIN_CUTOUT_MESH_RESOLUTION = 4
MAX_CUTOUT_MESH_RESOLUTION = 1024
DEFAULT_MAX_FRAME_RESOLUTION = 2048
MIN_FRAME_RESOLUTION = 64
MAX_FRAME_RESOLUTION = 8192
ADDON_PACKAGE = __package__
DEFAULT_STORAGE_ROOT = Path.home() / ".anyimage"
@@ -73,6 +76,18 @@ def configured_max_cutout_mesh_resolution(context=None):
)
def configured_max_frame_resolution(context=None):
preferences = addon_preferences(context)
value = int(
getattr(
preferences,
"max_frame_resolution",
DEFAULT_MAX_FRAME_RESOLUTION,
)
)
return min(max(value, MIN_FRAME_RESOLUTION), MAX_FRAME_RESOLUTION)
def configured_max_ai_input_size(context=None):
preferences = addon_preferences(context)
value = int(getattr(preferences, "max_ai_input_size", DEFAULT_MAX_AI_INPUT_SIZE))
@@ -139,6 +154,15 @@ class AnyImagePreferences(bpy.types.AddonPreferences):
step=64,
subtype="PIXEL",
)
max_frame_resolution: bpy.props.IntProperty(
name="Maximum Frame Resolution",
description="Maximum output resolution along the longest Frame edge",
default=DEFAULT_MAX_FRAME_RESOLUTION,
min=MIN_FRAME_RESOLUTION,
max=MAX_FRAME_RESOLUTION,
step=128,
subtype="PIXEL",
)
balloon_profile_algorithm: bpy.props.EnumProperty(
name="Balloon Profile",
description="Algorithm used to generate o_balloon for every Cutout shape",
@@ -220,6 +244,9 @@ class AnyImagePreferences(bpy.types.AddonPreferences):
)
tools = self._draw_box(layout, "Tools")
tools.label(text="Frame Tool")
tools.prop(self, "max_frame_resolution")
tools.separator(type="LINE")
tools.label(text="Depth & Normal")
tools.prop(self, "geometry_resolution_level")
tools.separator(type="LINE")

View File

@@ -192,27 +192,46 @@ def upscale_model_label(model_key):
return model.label if model is not None else model_key
def refine_selection_property():
return bpy.props.BoolProperty(
name="Refine Selection",
description="Use BEN2 to refine the selection to foreground content",
default=False,
def mask_gesture_property():
return bpy.props.EnumProperty(
name="Gesture",
description="Shape used to edit image Alpha",
items=(
("LASSO", "Lasso", "Draw a closed freehand area"),
("BRUSH", "Brush", "Paint with a circular brush"),
("POLYLINE", "Polyline", "Click points to define a closed area"),
),
default="LASSO",
)
def crop_invert_property():
return bpy.props.BoolProperty(
name="Invert",
description="Keep the area outside the crop selection",
default=False,
def mask_mode_property():
return bpy.props.EnumProperty(
name="Mode",
description="How the gesture changes image Alpha",
items=(
("SET", "Set", "Keep current Alpha inside the gesture", "SELECT_SET", 0),
("ADD", "Add", "Extend Alpha with the gesture", "SELECT_EXTEND", 1),
(
"SUBTRACT",
"Subtract",
"Remove Alpha inside the gesture",
"SELECT_SUBTRACT",
2,
),
),
default="SUBTRACT",
)
def keep_original_property():
return bpy.props.BoolProperty(
name="Keep Original",
description="Create a crop result while preserving the source Image Empty",
default=False,
def mask_radius_property():
return bpy.props.IntProperty(
name="Radius",
description="Brush radius in Viewport pixels",
default=25,
min=1,
soft_max=500,
subtype="PIXEL",
)
@@ -256,17 +275,10 @@ def cutout_mesh_detail_property():
class AnyImageSettings(bpy.types.PropertyGroup):
cutout_mesh_detail: cutout_mesh_detail_property()
cutout_generate_normal: cutout_generate_normal_property()
cutout_refine_selection: refine_selection_property()
cutout_inward_axis: cutout_inward_axis_property()
crop_box_refine_selection: refine_selection_property()
crop_box_invert: crop_invert_property()
crop_box_keep_original: keep_original_property()
crop_lasso_refine_selection: refine_selection_property()
crop_lasso_invert: crop_invert_property()
crop_lasso_keep_original: keep_original_property()
crop_polyline_invert: crop_invert_property()
crop_polyline_keep_original: keep_original_property()
crop_perspective_keep_original: keep_original_property()
mask_gesture: mask_gesture_property()
mask_mode: mask_mode_property()
mask_radius: mask_radius_property()
input_path: bpy.props.StringProperty(
name="Image or Video",
description="A single image or video file used for reconstruction",

View File

@@ -16,7 +16,6 @@ from .jobs.debug_predictions import (
)
from .jobs.cutout import run as _generate_cutout_artifacts
from .jobs.depth_plane import run as _generate_depth_plane_geometry
from .jobs.refine_image_selection import run as _refine_image_selection
from .jobs.remove_background import run as _remove_background
from .jobs.upscale import run as _upscale_image
from .model_manager import ModelManager
@@ -41,11 +40,6 @@ def download_required_models(context, parameters):
return manager.download_missing(context, parameters["models"])
@server.job("refine-image-selection")
def refine_image_selection(context, parameters):
return _refine_image_selection(context, parameters)
@server.job("remove-background")
def remove_background(context, parameters):
return _remove_background(context, parameters)

View File

@@ -40,15 +40,9 @@ def write_camera_normal_texture(
if visibility.shape != normal.shape[:2]:
visibility = resized_visibility(visibility, normal.shape[:2])
normal_mode = str(normal_mode).upper()
if normal_mode == "TANGENT":
if normal_mode in {"TANGENT", "OBJECT"}:
converted = normal * np.asarray((1.0, -1.0, -1.0), dtype=np.float32)
empty_normal = (128, 128, 255)
elif normal_mode == "OBJECT":
converted = normal[..., (2, 0, 1)] * np.asarray(
(1.0, -1.0, -1.0),
dtype=np.float32,
)
empty_normal = (0, 128, 128)
else:
raise ValueError(f"Unsupported normal mode: {normal_mode}")
encoded = np.clip(

View File

@@ -3,33 +3,20 @@
from pathlib import Path
from ..geometry.prediction_artifacts import generate_moge2_artifacts
from ..selection import refine_selection
def run(context, parameters):
context.progress(0.05, "Preparing Cutout Selection")
context.directory.mkdir(parents=True, exist_ok=True)
input_path = Path(parameters["input"])
selection_image_path = input_path
result = {}
if parameters.get("refine_selection"):
context.progress(0.2, "Refining Selection")
selection_image_path = refine_selection(
context,
input_path,
parameters["refine_model"],
parameters["device"],
context.directory / "selection.png",
)
result["selection_image"] = selection_image_path.name
depth_mode = str(parameters.get("depth_mode", "NONE"))
normal_mode = str(parameters.get("normal_mode", "NONE"))
if depth_mode != "NONE" or normal_mode != "NONE":
artifacts = generate_moge2_artifacts(
context,
parameters,
selection_image_path,
input_path,
depth_mode=depth_mode,
normal_mode=normal_mode,
)

View File

@@ -1,24 +0,0 @@
import time
from ..selection import refine_selection
def run(context, parameters):
"""Refine foreground Alpha while preserving the submitted bounds canvas."""
started = time.perf_counter()
context.progress(0.05, "Preparing image Selection")
context.check_cancelled()
output_directory = context.directory
output_directory.mkdir(parents=True, exist_ok=True)
context.progress(0.2, f"Loading BEN2 for {parameters['device'].upper()}")
output_path = refine_selection(
context,
parameters["input"],
parameters["model"],
parameters["device"],
output_directory / "image.png",
)
elapsed_ms = (time.perf_counter() - started) * 1000.0
message = f"Image Selection refined in {elapsed_ms:.0f} ms"
context.progress(1.0, message)
return {"selection_image": output_path.name}

View File

@@ -1,5 +0,0 @@
"""AI generation for rasterized image Selections."""
from .refine_selection import refine_selection
__all__ = ("refine_selection",)

View File

@@ -1,23 +0,0 @@
from ..models.ben2 import infer_foreground
def refine_selection(
context,
input_path,
model,
device,
output_path,
):
"""Refine foreground Alpha within one rectangular Selection context."""
model_manager = context.resource("model_manager")
foreground, _load_ms, _inference_ms = infer_foreground(
input_path,
model_manager.directory(model),
device,
model_cache=model_manager,
cancel_check=context.check_cancelled,
preserve_input_alpha=True,
)
context.check_cancelled()
foreground.convert("RGBA").save(output_path, format="PNG", compress_level=1)
return output_path