Add tests for clipboard image functionality and mesh plane asset creation

- Implemented a comprehensive test suite for the clipboard image module, covering various functionalities including operator registration, context handling, and image processing.
- Created a new mesh plane asset with customizable parameters and integrated it into the geometry node system.
- Developed a validation script to ensure the correct behavior of the mesh plane asset, including input handling and output verification.
This commit is contained in:
2026-08-22 09:49:00 +08:00
parent af5603045c
commit 988332548e
49 changed files with 4419 additions and 291 deletions

View File

@@ -5,6 +5,11 @@ from .balloon import (
AnalyzeBalloon,
)
from .debug import DebugBen2, DebugDa3, DebugMoge2
from .image_lasso import (
ActivateImageLassoTool,
AnalyzeImageLasso,
ExtractImageLasso,
)
from .depth_plane import (
ConvertToDepthPlane,
ExtractOriginalFrames,
@@ -12,11 +17,20 @@ from .depth_plane import (
GenerateMoge2Depth,
GenerateOriginalBackground,
)
from .models import ClearModels, DownloadModel, DownloadRequiredModels
from .models import (
ClearModels,
DownloadModel,
DownloadRequiredModels,
OpenAIEnvironmentSettings,
SetupAIEnvironment,
)
from .mesh_plane import ConvertToMeshPlane
from .surface import ConvertToSurface, GenerateDa3Surface, GenerateMoge2Surface
CLASSES = (
SetupAIEnvironment,
OpenAIEnvironmentSettings,
DownloadModel,
DownloadRequiredModels,
DebugDa3,
@@ -30,10 +44,14 @@ CLASSES = (
GenerateOriginalBackground,
ExtractOriginalFrames,
ClearModels,
ExtractImageLasso,
AnalyzeImageLasso,
ActivateImageLassoTool,
ExtractBalloon,
AnalyzeBalloon,
ActivateBalloonTool,
RemoveImageBackground,
ConvertToMeshPlane,
ConvertToDepthPlane,
ConvertToSurface,
)

View File

@@ -6,6 +6,7 @@ from .common import (
HIDDEN,
background_input_path,
cleanup_temporary_input,
invoke_ai_setup_if_needed,
production_device,
push_undo,
require_environment,
@@ -31,14 +32,14 @@ class RemoveImageBackground(bpy.types.Operator):
@classmethod
def poll(cls, context):
from ..runtime import runtime
from ..properties import required_models_ready
return (
is_image_empty(context.object)
and runtime.environment_ready()
and not runtime.server_busy()
and required_models_ready()
)
return is_image_empty(context.object) and not runtime.server_busy()
def invoke(self, _context, _event):
setup_result = invoke_ai_setup_if_needed()
if setup_result is not None:
return setup_result
return self.execute(_context)
def execute(self, context):
source_object = context.object

View File

@@ -13,6 +13,9 @@ from .depth import (
from .geometry import INFLATION, POINT_SPACING, triangulate_balloon
from ...properties import (
BEN2_MODEL_KEY,
ai_ready,
ai_setup_label,
ai_status,
balloon_contour_analysis_property as contour_analysis_property,
balloon_depth_analysis_property as depth_analysis_property,
balloon_depth_model_property as depth_model_property,
@@ -500,10 +503,24 @@ class ExtractBalloon(bpy.types.Operator):
layout.prop(self, "thickness")
layout.prop(self, "resolution", text="Resolution (m)")
layout.prop(self, "reuse_material")
layout.prop(self, "contour_analysis")
layout.prop(self, "depth_analysis")
status = ai_status()
ready = status["ready"]
contour_row = layout.row()
contour_row.enabled = ready
contour_row.prop(self, "contour_analysis")
depth_row = layout.row()
depth_row.enabled = ready
depth_row.prop(self, "depth_analysis")
if self.depth_analysis:
layout.prop(self, "depth_model")
model_row = layout.row()
model_row.enabled = ready
model_row.prop(self, "depth_model")
if not ready:
layout.operator(
"anyimage.setup_ai_environment",
text=ai_setup_label(status),
icon="IMPORT",
)
@classmethod
def poll(cls, context):
@@ -541,8 +558,9 @@ class ExtractBalloon(bpy.types.Operator):
self.thickness = settings.balloon_thickness
self.resolution = settings.balloon_resolution
self.reuse_material = settings.balloon_reuse_material
self.contour_analysis = settings.balloon_contour_analysis
self.depth_analysis = settings.balloon_depth_analysis
ready = ai_ready()
self.contour_analysis = ready and settings.balloon_contour_analysis
self.depth_analysis = ready and settings.balloon_depth_analysis
self.depth_model = settings.balloon_depth_model
self.source_object_name = source_object.name
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
@@ -835,7 +853,21 @@ class BalloonTool(bpy.types.WorkSpaceTool):
layout.prop(settings, "balloon_thickness")
layout.prop(settings, "balloon_resolution", text="Resolution (m)")
layout.prop(settings, "balloon_reuse_material")
layout.prop(settings, "balloon_contour_analysis")
layout.prop(settings, "balloon_depth_analysis")
status = ai_status()
ready = status["ready"]
contour_row = layout.row()
contour_row.enabled = ready
contour_row.prop(settings, "balloon_contour_analysis")
depth_row = layout.row()
depth_row.enabled = ready
depth_row.prop(settings, "balloon_depth_analysis")
if settings.balloon_depth_analysis:
layout.prop(settings, "balloon_depth_model", text="Depth Model")
model_row = layout.row()
model_row.enabled = ready
model_row.prop(settings, "balloon_depth_model", text="Depth Model")
if not ready:
layout.operator(
"anyimage.setup_ai_environment",
text=ai_setup_label(status),
icon="IMPORT",
)

View File

@@ -5,7 +5,7 @@ import bpy
from .image import extract_frame_range
from ..runtime import runtime
from ..properties import model_ready, model_record
from ..properties import ai_ready, model_ready, model_record
BEN2_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
@@ -49,6 +49,12 @@ def require_environment():
raise RuntimeError("Install the AnyImage Environment first")
def invoke_ai_setup_if_needed():
if ai_ready():
return None
return bpy.ops.anyimage.setup_ai_environment("INVOKE_DEFAULT")
def require_model(model_key):
model = model_record(model_key)
if model is None:

View File

@@ -8,6 +8,7 @@ from ..common import (
cleanup_temporary_input,
da3_parameters,
input_path,
invoke_ai_setup_if_needed,
moge2_parameters,
push_undo,
require_environment,
@@ -74,16 +75,13 @@ class ConvertToDepthPlane(bpy.types.Operator):
@classmethod
def poll(cls, context):
from ...runtime import runtime
from ...properties import required_models_ready
return (
is_image_empty(context.object)
and runtime.environment_ready()
and not runtime.server_busy()
and required_models_ready()
)
return is_image_empty(context.object) and not runtime.server_busy()
def invoke(self, context, _event):
setup_result = invoke_ai_setup_if_needed()
if setup_result is not None:
return setup_result
return context.window_manager.invoke_props_dialog(self, width=340)
def draw(self, context):

View File

@@ -0,0 +1,635 @@
import json
import math
import bpy
from ..properties import (
BEN2_MODEL_KEY,
ai_ready,
ai_setup_label,
ai_status,
image_lasso_contour_analysis_property as contour_analysis_property,
image_lasso_keep_original_property as keep_original_property,
image_lasso_padding_property as padding_property,
)
from ..runtime import JobOperatorBase
from .balloon.operator import (
deserialize_matrix,
drawing_in_region,
hidden_string_property,
screen_path_to_image_pixels,
serialize_matrix,
simplify_screen_path,
)
from .common import (
HIDDEN,
cleanup_temporary_input,
production_device,
push_undo,
require_environment,
require_model,
)
from .image import (
image_base_name,
is_image_empty,
plane_bounds,
prepare_image_input,
remove_temporary_input,
video_total_frames,
)
def lasso_bounds(polygon):
import numpy as np
points = np.asarray(polygon, dtype=np.float64)
if points.ndim != 2 or points.shape[0] < 3 or points.shape[1] != 2:
raise ValueError("The lasso does not define a valid image region")
if not np.isfinite(points).all():
raise ValueError("The lasso contains invalid image coordinates")
left = int(math.floor(float(points[:, 0].min())))
top = int(math.floor(float(points[:, 1].min())))
right = int(math.ceil(float(points[:, 0].max())))
bottom = int(math.ceil(float(points[:, 1].max())))
if right <= left or bottom <= top:
raise ValueError("The lasso does not define a valid image region")
return left, top, right, bottom
def points_inside_polygon(points, polygon):
import numpy as np
points = np.asarray(points, dtype=np.float64)
polygon = np.asarray(polygon, dtype=np.float64)
inside = np.zeros(len(points), dtype=bool)
previous = polygon[-1]
for current in polygon:
crosses = (current[1] > points[:, 1]) != (previous[1] > points[:, 1])
edge_x = (
(previous[0] - current[0])
* (points[:, 1] - current[1])
/ (previous[1] - current[1] + 1e-300)
+ current[0]
)
inside ^= crosses & (points[:, 0] < edge_x)
previous = current
return inside
def extract_lasso_pixels(source_pixels, source_size, polygon):
"""Return Blender-order RGBA pixels and the unbounded lasso rectangle."""
import numpy as np
source_width, source_height = (int(value) for value in source_size)
if source_width <= 0 or source_height <= 0:
raise ValueError("The source image has no readable dimensions")
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)))
left, top, right, bottom = lasso_bounds(polygon)
width = right - left
height = bottom - top
result = np.zeros((height, width, 4), dtype=np.float32)
source_left = max(left, 0)
source_top = max(top, 0)
source_right = min(right, source_width)
source_bottom = min(bottom, source_height)
if source_right <= source_left or source_bottom <= source_top:
raise ValueError("The lasso does not overlap the source image")
result[
source_top - top : source_bottom - top,
source_left - left : source_right - left,
] = source[source_top:source_bottom, source_left:source_right]
y, x = np.indices((height, width), dtype=np.float64)
centers = np.column_stack(
((x.ravel() + left + 0.5), (y.ravel() + top + 0.5))
)
selected = points_inside_polygon(centers, polygon).reshape((height, width))
result[~selected] = 0.0
result[result[:, :, 3] <= 0.0] = 0.0
return np.flipud(result).ravel(), (left, top, right, bottom)
def crop_pixels_to_alpha(pixels, bounds, padding=0):
"""Crop Blender-order RGBA pixels to nonzero Alpha plus pixel padding."""
import numpy as np
if padding < 0:
raise ValueError("Padding must not be negative")
left, top, right, bottom = bounds
width = right - left
height = bottom - top
rgba = np.flipud(
np.asarray(pixels, dtype=np.float32).reshape((height, width, 4))
)
visible_y, visible_x = np.nonzero(rgba[:, :, 3] > 0.0)
if not len(visible_x):
raise ValueError("The lasso contains no visible image pixels")
content_left = int(visible_x.min())
content_top = int(visible_y.min())
content_right = int(visible_x.max()) + 1
content_bottom = int(visible_y.max()) + 1
output_width = content_right - content_left + padding * 2
output_height = content_bottom - content_top + padding * 2
output = np.zeros((output_height, output_width, 4), dtype=np.float32)
output[
padding : padding + content_bottom - content_top,
padding : padding + content_right - content_left,
] = rgba[content_top:content_bottom, content_left:content_right]
output[output[:, :, 3] <= 0.0] = 0.0
return np.flipud(output).ravel(), (
left + content_left - padding,
top + content_top - padding,
left + content_right + padding,
top + content_bottom + padding,
)
def align_empty_to_crop(source_object, matrix_world, source_size, crop_bounds):
from mathutils import Matrix
source_width, source_height = source_size
left, top, right, bottom = crop_bounds
x_min, x_max, y_min, y_max = plane_bounds(source_object)
local_width = x_max - x_min
local_height = y_max - y_min
crop_x_min = x_min + left * local_width / source_width
crop_x_max = x_min + right * local_width / source_width
crop_y_max = y_max - top * local_height / source_height
crop_y_min = y_max - bottom * local_height / source_height
center = (
(crop_x_min + crop_x_max) * 0.5,
(crop_y_min + crop_y_max) * 0.5,
0.0,
)
source_object.matrix_world = matrix_world @ Matrix.Translation(center)
source_object.empty_display_size = max(
crop_x_max - crop_x_min,
crop_y_max - crop_y_min,
)
source_object.empty_image_offset = (-0.5, -0.5)
def replace_empty_image(source_object, matrix_world, pixels, crop_bounds):
source_image = source_object.data
source_size = tuple(source_image.size)
left, top, right, bottom = crop_bounds
image = bpy.data.images.new(
f"{image_base_name(source_image)}.extract",
width=right - left,
height=bottom - top,
alpha=True,
)
try:
image.colorspace_settings.name = source_image.colorspace_settings.name
image.alpha_mode = "STRAIGHT"
image.pixels.foreach_set(pixels)
image.pack()
image.update()
align_empty_to_crop(source_object, matrix_world, source_size, crop_bounds)
source_object.data = image
except Exception:
bpy.data.images.remove(image, do_unlink=True)
raise
return image
def replace_empty_image_from_path(source_object, matrix_world, path, crop_bounds):
source_size = tuple(source_object.data.size)
colorspace = source_object.data.colorspace_settings.name
try:
image = bpy.data.images.load(str(path), check_existing=False)
except (OSError, RuntimeError) as error:
raise RuntimeError("Unable to load the extracted image") from error
try:
image.name = f"{image_base_name(source_object.data)}.extract"
image.colorspace_settings.name = colorspace
image.alpha_mode = "STRAIGHT"
image.pack()
align_empty_to_crop(source_object, matrix_world, source_size, crop_bounds)
source_object.data = image
except Exception:
bpy.data.images.remove(image, do_unlink=True)
raise
return image
def image_lasso_target(context, source_object, keep_original):
if not keep_original:
return source_object
target = source_object.copy()
collection = (
source_object.users_collection[0]
if source_object.users_collection
else context.collection
)
collection.objects.link(target)
return target
def select_image_lasso_result(context, target):
bpy.ops.object.select_all(action="DESELECT")
target.select_set(True)
context.view_layer.objects.active = target
def selection_shape_property():
return bpy.props.EnumProperty(
items=(
("LASSO", "Lasso", "Draw a freehand image extraction region"),
("BOX", "Box", "Draw a rectangular image extraction region"),
),
default="BOX",
options={"HIDDEN", "SKIP_SAVE"},
)
def box_screen_path(path):
if len(path) < 2:
return ()
x0, y0 = path[0]
x1, y1 = path[-1]
if abs(x1 - x0) < 2.0 or abs(y1 - y0) < 2.0:
return ()
return ((x0, y0), (x1, y0), (x1, y1), (x0, y1))
def dashed_line_segments(vertices, dash_length=4.0, gap_length=4.0):
if dash_length <= 0.0 or gap_length < 0.0:
raise ValueError("Dash length must be positive and gap length cannot be negative")
pattern_length = dash_length + gap_length
segments = []
distance_offset = 0.0
for start, end in zip(vertices, vertices[1:]):
start_x, start_y = (float(value) for value in start)
end_x, end_y = (float(value) for value in end)
segment_length = math.hypot(end_x - start_x, end_y - start_y)
if segment_length <= 0.0:
continue
direction_x = (end_x - start_x) / segment_length
direction_y = (end_y - start_y) / segment_length
position = 0.0
while position < segment_length:
phase = (distance_offset + position) % pattern_length
if phase < dash_length:
run_length = min(dash_length - phase, segment_length - position)
segments.extend(
(
(
start_x + direction_x * position,
start_y + direction_y * position,
),
(
start_x + direction_x * (position + run_length),
start_y + direction_y * (position + run_length),
),
)
)
else:
run_length = min(pattern_length - phase, segment_length - position)
position += run_length
distance_offset += segment_length
return tuple(segments)
def draw_dashed_line(vertices):
import gpu
from gpu_extras.batch import batch_for_shader
segments = dashed_line_segments(vertices)
if not segments:
return
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
batch = batch_for_shader(shader, "LINES", {"pos": segments})
gpu.state.blend_set("ALPHA")
gpu.state.line_width_set(1.0)
shader.bind()
shader.uniform_float("color", (1.0, 1.0, 1.0, 0.9))
batch.draw(shader)
gpu.state.blend_set("NONE")
class ExtractImageLasso(bpy.types.Operator):
bl_idname = "anyimage.extract_image_lasso"
bl_label = "Extract Image"
bl_description = "Replace an Image Empty with its lassoed image region"
bl_options = {"REGISTER", "UNDO"}
source_object_name: hidden_string_property()
source_matrix_data: hidden_string_property()
selection_data: hidden_string_property()
contour_analysis: contour_analysis_property()
keep_original: keep_original_property()
padding: padding_property()
selection_shape: selection_shape_property()
_path = None
_handle = None
_area_pointer = 0
_region_pointer = 0
def draw(self, _context):
status = ai_status()
row = self.layout.row()
row.enabled = status["ready"]
row.prop(self, "contour_analysis")
self.layout.prop(self, "keep_original")
self.layout.prop(self, "padding")
if not status["ready"]:
self.layout.operator(
"anyimage.setup_ai_environment",
text=ai_setup_label(status),
icon="IMPORT",
)
@classmethod
def poll(cls, context):
return (
context.area is not None
and context.area.type == "VIEW_3D"
and is_image_empty(context.active_object)
)
def _draw_selection(self):
if not self._path or not drawing_in_region(
self._area_pointer, self._region_pointer
):
return
vertices = (
list(box_screen_path(self._path))
if self.selection_shape == "BOX"
else list(self._path)
)
if len(vertices) > 2:
vertices.append(vertices[0])
draw_dashed_line(vertices)
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):
source_object = context.active_object
self.contour_analysis = (
ai_ready() and context.scene.anyimage_settings.image_lasso_contour_analysis
)
self.keep_original = context.scene.anyimage_settings.image_lasso_keep_original
self.padding = context.scene.anyimage_settings.image_lasso_padding
self.source_object_name = source_object.name
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
self.selection_data = ""
self._path = [(event.mouse_region_x, event.mouse_region_y)]
self._area_pointer = context.area.as_pointer()
self._region_pointer = context.region.as_pointer()
self._handle = bpy.types.SpaceView3D.draw_handler_add(
self._draw_selection, (), "WINDOW", "POST_PIXEL"
)
context.workspace.status_text_set(
f"Image {self.selection_shape.title()}: release LMB to extract, "
"RMB or Esc to cancel"
)
context.window_manager.modal_handler_add(self)
context.area.tag_redraw()
return {"RUNNING_MODAL"}
def modal(self, context, event):
if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS":
self._finish(context)
return {"CANCELLED"}
if event.type == "MOUSEMOVE":
self._path.append((event.mouse_region_x, event.mouse_region_y))
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
self._path.append((event.mouse_region_x, event.mouse_region_y))
path = (
list(box_screen_path(self._path))
if self.selection_shape == "BOX"
else simplify_screen_path(self._path)
)
if len(path) < 3:
self.report({"WARNING"}, "Draw a larger image region")
self._finish(context)
return {"CANCELLED"}
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
self.report({"ERROR"}, "The source Image Empty is no longer available")
self._finish(context)
return {"CANCELLED"}
try:
matrix_world = deserialize_matrix(self.source_matrix_data)
polygon = screen_path_to_image_pixels(
path,
context.region,
context.region_data,
matrix_world,
plane_bounds(source_object),
tuple(source_object.data.size),
)
self.selection_data = json.dumps(polygon, separators=(",", ":"))
except (RuntimeError, TypeError, ValueError) as error:
self.report({"ERROR"}, str(error))
self._finish(context)
return {"CANCELLED"}
self._finish(context)
return self.execute(context)
return {"PASS_THROUGH"}
def execute(self, context):
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
self.report({"ERROR"}, "The source Image Empty is no longer available")
return {"CANCELLED"}
try:
polygon = json.loads(self.selection_data)
crop_bounds = lasso_bounds(polygon)
if video_total_frames(source_object.data) > 1:
raise RuntimeError("Image Lasso only supports a still image")
if self.contour_analysis:
source_path, delete_input = prepare_image_input(
source_object.data, context.scene
)
result = bpy.ops.anyimage.analyze_image_lasso(
input_path=str(source_path),
delete_input=delete_input,
source_object_name=self.source_object_name,
source_matrix_data=self.source_matrix_data,
selection_data=self.selection_data,
keep_original=self.keep_original,
padding=self.padding,
)
if "CANCELLED" in result:
if delete_input:
remove_temporary_input(source_path)
return {"CANCELLED"}
return {"FINISHED"}
import numpy as np
image = source_object.data
source_pixels = np.empty(len(image.pixels), dtype=np.float32)
image.pixels.foreach_get(source_pixels)
pixels, crop_bounds = extract_lasso_pixels(
source_pixels, tuple(image.size), polygon
)
pixels, crop_bounds = crop_pixels_to_alpha(
pixels, crop_bounds, self.padding
)
target = image_lasso_target(context, source_object, self.keep_original)
try:
replace_empty_image(
target,
deserialize_matrix(self.source_matrix_data),
pixels,
crop_bounds,
)
except Exception:
if target is not source_object:
bpy.data.objects.remove(target, do_unlink=True)
raise
select_image_lasso_result(
context,
source_object if self.keep_original else target,
)
except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
return {"FINISHED"}
class AnalyzeImageLasso(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.analyze_image_lasso"
bl_label = "Analyze Image Lasso"
job_type = "analyze-image-lasso"
starting_message = "Analyzing image contour"
input_path: bpy.props.StringProperty(options=HIDDEN)
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
source_object_name: hidden_string_property()
source_matrix_data: hidden_string_property()
selection_data: hidden_string_property()
keep_original: keep_original_property()
padding: padding_property()
def request(self, _context):
require_environment()
require_model(BEN2_MODEL_KEY)
polygon = json.loads(self.selection_data)
lasso_bounds(polygon)
return {
"input": str(self.input_path),
"polygon": polygon,
"padding": self.padding,
"model": BEN2_MODEL_KEY,
"device": production_device().lower(),
}
def response(self, context, result):
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
raise RuntimeError("The source Image Empty is no longer available")
crop_bounds = tuple(
json.loads(result.file("bounds").read_text(encoding="utf-8"))
)
target = image_lasso_target(context, source_object, self.keep_original)
try:
replace_empty_image_from_path(
target,
deserialize_matrix(self.source_matrix_data),
result.file("image"),
crop_bounds,
)
except Exception:
if target is not source_object:
bpy.data.objects.remove(target, do_unlink=True)
raise
select_image_lasso_result(
context,
source_object if self.keep_original else target,
)
push_undo("Extract Image")
return "Contoured image extraction is ready"
def cleanup(self):
cleanup_temporary_input(self.input_path, self.delete_input)
class ActivateImageLassoTool(bpy.types.Operator):
bl_idname = "anyimage.activate_image_lasso_tool"
bl_label = "Image Extract Tool"
bl_description = "Activate the Image Box tool in the 3D Viewport"
bl_options = {"INTERNAL"}
@classmethod
def poll(cls, context):
return ExtractImageLasso.poll(context)
def execute(self, _context):
return bpy.ops.wm.tool_set_by_id(
name=ImageBoxTool.bl_idname, space_type="VIEW_3D"
)
class ImageBoxTool(bpy.types.WorkSpaceTool):
bl_space_type = "VIEW_3D"
bl_context_mode = "OBJECT"
bl_idname = "anyimage.image_box"
bl_label = "Image Box"
bl_description = "Replace an Image Empty with one boxed image region"
bl_icon = "ops.sculpt.border_mask"
bl_operator = ExtractImageLasso.bl_idname
bl_keymap = (
(
ExtractImageLasso.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
{"properties": [("selection_shape", "BOX")]},
),
)
def draw_settings(context, layout, _tool):
draw_image_extract_settings(context, layout)
class ImageLassoTool(bpy.types.WorkSpaceTool):
bl_space_type = "VIEW_3D"
bl_context_mode = "OBJECT"
bl_idname = "anyimage.image_lasso"
bl_label = "Image Lasso"
bl_description = "Replace an Image Empty with one lassoed image region"
bl_icon = "ops.sculpt.lasso_mask"
bl_operator = ExtractImageLasso.bl_idname
bl_keymap = (
(
ExtractImageLasso.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
{"properties": [("selection_shape", "LASSO")]},
),
)
def draw_settings(context, layout, _tool):
draw_image_extract_settings(context, layout)
def draw_image_extract_settings(context, layout):
settings = context.scene.anyimage_settings
status = ai_status()
row = layout.row()
row.enabled = status["ready"]
row.prop(settings, "image_lasso_contour_analysis")
layout.prop(settings, "image_lasso_keep_original")
layout.prop(settings, "image_lasso_padding")
if not status["ready"]:
layout.operator(
"anyimage.setup_ai_environment",
text=ai_setup_label(status),
icon="IMPORT",
)

View File

@@ -10,6 +10,7 @@ ORIGINAL_DISPLACEMENT_MIDLEVEL = 0.0
BUMP_BASE_DISTANCE = 0.001
DEPTH_PLANE_NODE_GROUP_NAME = "O DA3 Depth Plane"
MOGE_DEPTH_PLANE_NODE_GROUP_NAME = "O MoGe2 Depth Plane"
MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane"
NODE_ASSET_NAME = "O_AnyImage.blend"
MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement"
BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth"
@@ -647,6 +648,9 @@ def load_shadeless_node_group():
def load_depth_plane_node_group(group_name=DEPTH_PLANE_NODE_GROUP_NAME):
return load_asset_node_group(group_name)
def load_mesh_plane_node_group():
return load_asset_node_group(MESH_PLANE_NODE_GROUP_NAME)
def modifier_input_identifier(node_group, name):
for item in node_group.interface.items_tree:

View File

@@ -0,0 +1,228 @@
import bpy
from .image import (
image_user_settings,
is_image_empty,
material_data_name,
plane_bounds,
)
from .material import (
create_material,
load_mesh_plane_node_group,
modifier_input_identifier,
set_modifier_input,
)
MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane"
def is_mesh_plane_object(candidate):
if candidate is None or getattr(candidate, "type", None) != "MESH":
return False
return any(
getattr(getattr(modifier, "node_group", None), "name", None)
== MESH_PLANE_NODE_GROUP_NAME
for modifier in getattr(candidate, "modifiers", ())
)
def flatten_matrix(matrix):
return tuple(
float(matrix[row][column]) for row in range(4) for column in range(4)
)
def restore_matrix(values):
from mathutils import Matrix
return Matrix(
tuple(
tuple(values[row * 4 + column] for column in range(4))
for row in range(4)
)
)
def create_mesh_plane_mesh(name, bounds):
x_min, x_max, y_min, y_max = bounds
if x_max <= x_min or y_max <= y_min:
raise ValueError("The plane has invalid dimensions")
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(
(
(x_min, y_min, 0.0),
(x_max, y_min, 0.0),
(x_max, y_max, 0.0),
(x_min, y_max, 0.0),
),
(),
((0, 1, 2, 3),),
)
mesh.update()
uv_layer = mesh.uv_layers.new(name="UVMap")
for loop, uv in zip(
mesh.loops,
((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)),
):
uv_layer.data[loop.index].uv = uv
return mesh
def add_mesh_plane_processing(
plane_object,
material,
subdivisions=0,
thickness=0.0,
):
node_group = load_mesh_plane_node_group()
modifier = plane_object.modifiers.new(
name=MESH_PLANE_NODE_GROUP_NAME,
type="NODES",
)
modifier.node_group = node_group
if hasattr(modifier, "show_group_selector"):
modifier.show_group_selector = False
for name, value in (
("Subdivide", max(0, int(subdivisions))),
("Thickness", max(0.0, float(thickness))),
("Material", material),
):
identifier = modifier_input_identifier(node_group, name)
set_modifier_input(modifier, identifier, value)
return modifier
def create_mesh_plane_object(
context,
name,
bounds,
material,
matrix_world=None,
subdivisions=0,
thickness=0.0,
):
mesh = create_mesh_plane_mesh(name, bounds)
mesh.materials.append(material)
plane = bpy.data.objects.new(name, mesh)
context.collection.objects.link(plane)
if matrix_world is not None:
plane.matrix_world = matrix_world.copy()
add_mesh_plane_processing(
plane,
material,
subdivisions=subdivisions,
thickness=thickness,
)
return plane
def finish_mesh_plane(context, source_object, plane_object, keep_original):
source_name = source_object.name
for selected in context.selected_objects:
selected.select_set(False)
plane_object.select_set(True)
context.view_layer.objects.active = plane_object
if not keep_original:
bpy.data.objects.remove(source_object, do_unlink=True)
plane_object.name = source_name
return plane_object
def build_mesh_plane(
context,
source_object,
keep_original,
shadeless=False,
matrix_world=None,
):
frame_start, frame_offset, frame_duration, use_cyclic = image_user_settings(
source_object
)
material = create_material(
material_data_name(source_object),
source_object.data,
abs(float(source_object.scale.z)),
shadeless=shadeless,
frame_start=frame_start,
color_frame_offset=frame_offset,
color_frame_duration=frame_duration,
use_cyclic=use_cyclic,
scene=context.scene,
)
plane = create_mesh_plane_object(
context,
source_object.name,
plane_bounds(source_object),
material,
matrix_world=(
source_object.matrix_world if matrix_world is None else matrix_world
),
)
return finish_mesh_plane(context, source_object, plane, keep_original)
class ConvertToMeshPlane(bpy.types.Operator):
bl_idname = "anyimage.convert_to_mesh_plane"
bl_label = "Convert to Mesh Plane"
bl_description = "Convert an image to an editable mesh plane without AI depth"
bl_options = {"REGISTER", "UNDO"}
shadeless: bpy.props.BoolProperty(
name="Shadeless",
description="Display the source color without scene lighting",
default=False,
)
keep_original: bpy.props.BoolProperty(
name="Keep Original",
description="Keep the source Image Empty after conversion",
default=False,
)
source_object_name: bpy.props.StringProperty(options={"HIDDEN"})
source_matrix_world: bpy.props.FloatVectorProperty(
size=16,
default=(
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0,
),
options={"HIDDEN"},
)
@classmethod
def poll(cls, context):
return is_image_empty(context.object) or is_mesh_plane_object(context.object)
def capture_source_state(self, source_object):
self.source_object_name = source_object.name
self.source_matrix_world = flatten_matrix(source_object.matrix_world)
def invoke(self, context, _event):
self.capture_source_state(context.object)
return self.execute(context)
def execute(self, context):
try:
source_object = context.object
if not self.source_object_name:
if not is_image_empty(source_object):
raise ValueError("Select an Image Empty to convert")
self.capture_source_state(source_object)
stored_source = bpy.data.objects.get(self.source_object_name)
if is_image_empty(stored_source):
source_object = stored_source
elif not is_image_empty(source_object):
raise ValueError("The source Image Empty is no longer available")
build_mesh_plane(
context,
source_object,
keep_original=self.keep_original,
shadeless=self.shadeless,
matrix_world=restore_matrix(self.source_matrix_world),
)
except (RuntimeError, ValueError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
return {"FINISHED"}

View File

@@ -1,8 +1,124 @@
import bpy
from ..runtime import JobOperatorBase, runtime
from ..properties import REQUIRED_MODEL_KEYS, model_record
from .common import HIDDEN, require_environment
from ..properties import (
REQUIRED_MODEL_KEYS,
ai_status,
model_download_size,
model_record,
)
from .common import ADDON_PACKAGE, HIDDEN, require_environment
def format_download_size(size):
return f"{size / 1_000_000:.0f} MB"
class SetupAIEnvironment(bpy.types.Operator):
bl_idname = "anyimage.setup_ai_environment"
bl_label = "Set Up AI Environment"
bl_description = "Install the environment and required AI models"
use_mirror: bpy.props.BoolProperty(
name="Use Mirror",
description=(
"Use the China mirrors for uv, Python and Python packages; "
"model download sources are unchanged"
),
default=False,
)
@classmethod
def poll(cls, _context):
return not runtime.server_busy()
def invoke(self, context, _event):
return context.window_manager.invoke_props_dialog(self, width=440)
def draw(self, _context):
layout = self.layout
status = ai_status()
if not status["environment_ready"]:
required_size = sum(
model_download_size(model_key)
for model_key in REQUIRED_MODEL_KEYS
)
layout.label(
text="AnyImage AI features run in an isolated Python environment."
)
layout.label(
text="This installs Python, ONNX Runtime and supporting packages."
)
layout.label(
text=(
"It also downloads MoGe-2 ViT-S Normal and BEN2 Base "
f"({format_download_size(required_size)})."
)
)
layout.label(
text="Allow about 600 MB of downloads and 1 GB of disk space."
)
layout.label(
text=(
"Installation usually takes 5–20 minutes, "
"depending on network speed."
)
)
layout.separator(type="LINE")
layout.prop(self, "use_mirror")
return
if status["ready"]:
layout.label(text="The AI Environment is ready.", icon="CHECKMARK")
return
labels = []
for model_key in status["missing_models"]:
model = model_record(model_key)
labels.append(model["label"] if model else model_key)
missing_size = sum(
model_download_size(model_key)
for model_key in status["missing_models"]
)
layout.label(
text="The AI Environment is already installed.",
icon="CHECKMARK",
)
layout.label(text=f"Required models to download: {', '.join(labels)}")
layout.label(text=f"Download size: {format_download_size(missing_size)}")
layout.label(text="The download usually takes 2–10 minutes.")
def execute(self, _context):
status = ai_status()
if status["ready"]:
return {"FINISHED"}
if status["environment_ready"]:
return bpy.ops.anyimage.download_required_models("INVOKE_DEFAULT")
installer = (
bpy.ops.anyimage.install_environment_mirror
if self.use_mirror
else bpy.ops.anyimage.install_environment
)
return installer("INVOKE_DEFAULT")
class OpenAIEnvironmentSettings(bpy.types.Operator):
bl_idname = "anyimage.open_ai_environment_settings"
bl_label = "AI Environment Settings"
bl_description = "Open the AnyImage add-on preferences"
def execute(self, context):
result = bpy.ops.screen.userpref_show("INVOKE_DEFAULT", section="ADDONS")
if "FINISHED" not in result:
return {"CANCELLED"}
for window in context.window_manager.windows:
for area in window.screen.areas:
if area.type != "PREFERENCES":
continue
with context.temp_override(window=window, area=area):
bpy.ops.preferences.addon_show(module=ADDON_PACKAGE)
return {"FINISHED"}
return {"FINISHED"}
class DownloadModel(JobOperatorBase, bpy.types.Operator):

View File

@@ -9,6 +9,7 @@ from .common import (
cleanup_temporary_input,
da3_parameters,
input_path,
invoke_ai_setup_if_needed,
moge2_parameters,
push_undo,
require_environment,
@@ -548,17 +549,17 @@ class ConvertToSurface(bpy.types.Operator):
@classmethod
def poll(cls, context):
from ..runtime import runtime
from ..properties import required_models_ready
return (
is_image_empty(context.object)
and video_total_frames(context.object.data) == 1
and runtime.environment_ready()
and not runtime.server_busy()
and required_models_ready()
)
def invoke(self, context, _event):
setup_result = invoke_ai_setup_if_needed()
if setup_result is not None:
return setup_result
return context.window_manager.invoke_props_dialog(self, width=360)
def draw(self, context):