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:
@@ -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.
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
18
src/anyimage/operators/image_edit_tool/__init__.py
Normal file
18
src/anyimage/operators/image_edit_tool/__init__.py
Normal 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",
|
||||
)
|
||||
665
src/anyimage/operators/image_edit_tool/frame.py
Normal file
665
src/anyimage/operators/image_edit_tool/frame.py
Normal 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")
|
||||
176
src/anyimage/operators/image_edit_tool/mask.py
Normal file
176
src/anyimage/operators/image_edit_tool/mask.py
Normal 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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
@@ -1,5 +0,0 @@
|
||||
"""AI generation for rasterized image Selections."""
|
||||
|
||||
from .refine_selection import refine_selection
|
||||
|
||||
__all__ = ("refine_selection",)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user