Add new tests and functionality for image processing and geometry handling

- Introduced color_image.py for preparing standalone color images for tests.
- Added hdr_image.py to create HDR textures for testing.
- Implemented test_image_depth_panorama.py to evaluate panorama topology, UVs, and depth discontinuities.
- Created test_shadeless.py to validate the opacity and HDR color endpoints of the Shadeless group.
- Developed moge3_export.py for exporting dynamic MoGe-3 backbone and ONNX graphs.
- Added material.py to inherit material slots onto generated geometry.
- Built image_depth_panorama.py to construct a quad panorama with depth separation and sampling.
This commit is contained in:
2026-09-10 14:42:17 +08:00
parent 27ca25224a
commit 489003c2cc
136 changed files with 5758 additions and 1083 deletions

View File

@@ -20,9 +20,11 @@ from .ai_setup import (
SetupAIEnvironment,
)
from .upscale import UpscaleImage
from .convert_to_panorama import CLASSES as PANORAMA_CLASSES
CLASSES = (
*PANORAMA_CLASSES,
SetupAIEnvironment,
OpenAIEnvironmentSettings,
DownloadModel,

View File

@@ -8,7 +8,7 @@ from ..properties import (
model_download_size,
model_record,
)
from ..common.ai import require_environment
from ..common.ai import report_ai_error, require_environment
def format_download_size(size):
@@ -94,14 +94,18 @@ class SetupAIEnvironment(bpy.types.Operator):
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")
try:
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")
except RuntimeError as error:
report_ai_error(self, error)
return {"CANCELLED"}
class OpenAIEnvironmentSettings(bpy.types.Operator):

View File

@@ -5,8 +5,11 @@ import tempfile
import bpy
from ...common.material import create_image_material
from ...common.color_image import material_color_image
from ...common.image import image_pixels
CLIPBOARD_HASH_PROPERTY = "anyimage_clipboard_sha256"
CLIPBOARD_CONTENT_PROPERTY = "anyimage_clipboard_content_sha256"
CLIPBOARD_TEXTURE_PROPERTY = "anyimage_clipboard_texture"
DEFAULT_STENCIL_SIZE = 256.0
REFERENCE_IMAGE_SIZE = 5.0
@@ -80,8 +83,9 @@ def acquire_packed_image(image_data, suffix):
image.name = "Clipboard"
image.pack()
image[CLIPBOARD_HASH_PROPERTY] = image_hash
image[CLIPBOARD_CONTENT_PROPERTY] = _clipboard_content_hash(image)
return image, False
except (OSError, RuntimeError):
except Exception:
if image is not None:
bpy.data.images.remove(image)
raise
@@ -96,11 +100,27 @@ def _find_packed_clipboard_image(image_hash):
for image in bpy.data.images:
if image.get(CLIPBOARD_HASH_PROPERTY) != image_hash:
continue
if _is_image_packed(image):
return image
if not _is_image_packed(image):
continue
baseline = image.get(CLIPBOARD_CONTENT_PROPERTY)
if not baseline:
continue
try:
if _clipboard_content_hash(image) == baseline:
return image
except (RuntimeError, ValueError, ReferenceError, OSError):
continue
return None
def _clipboard_content_hash(image):
"""Fingerprint current pixels and interpretation without changing the image."""
digest = hashlib.sha256()
digest.update(repr((tuple(image.size), image.colorspace_settings.name, image.alpha_mode)).encode())
digest.update(image_pixels(image).tobytes())
return digest.hexdigest()
def _is_image_packed(image):
if getattr(image, "packed_file", None) is not None:
return True
@@ -150,17 +170,24 @@ def add_image_plane(
from ..convert_to_plane.image_plane import create_image_plane_object
half_width, half_height = _plane_half_dimensions(width, height)
material = create_image_material(
image, image, shadeless=shadeless, scene=context.scene
)
plane = create_image_plane_object(
context,
image.name,
(-half_width, half_width, -half_height, half_height),
material,
)
_place_in_view(context, plane, location)
_select_only(context, plane)
material = plane = None
try:
with material_color_image(image) as color:
material = create_image_material(image, color, shadeless=shadeless, scene=context.scene)
plane = create_image_plane_object(
context, image.name, (-half_width, half_width, -half_height, half_height), material,
)
_place_in_view(context, plane, location)
_select_only(context, plane)
except Exception:
if plane is not None:
mesh = plane.data
bpy.data.objects.remove(plane, do_unlink=True)
if mesh.users == 0:
bpy.data.meshes.remove(mesh)
if material is not None and material.users == 0:
bpy.data.materials.remove(material)
raise
return plane

View File

@@ -0,0 +1,6 @@
"""Image Empty panorama conversion operators."""
from .operators import ConvertToPanorama, GeneratePanorama
CLASSES = (GeneratePanorama, ConvertToPanorama)
__all__ = ("ConvertToPanorama", "GeneratePanorama", "CLASSES")

View File

@@ -0,0 +1,71 @@
"""Apply packed panorama artifacts around the source Empty's camera origin."""
import json
import math
from pathlib import Path
import bpy
from ...common.depth import load_depth_result_image
from ...common.image import require_conversion_source
from ...common.color_image import material_color_image
from ...common.material import create_image_material
from ...common.node import load_node_group
from ...common.object import finalize_object_result, modifier_input_identifier, set_modifier_input
def load_panorama_metadata(path):
metadata = json.loads(Path(path).read_text(encoding="utf-8"))
try:
size = tuple(int(value) for value in metadata["image_size"])
distance = float(metadata["reference_distance"])
valid = (metadata["projection"] == "equirectangular" and len(size) == 2
and size[1] > 0 and size[0] == 2 * size[1]
and math.isfinite(distance) and distance > 0)
except (KeyError, TypeError, ValueError) as error:
raise ValueError("Panorama metadata is invalid") from error
if not valid:
raise ValueError("Panorama metadata is invalid")
return {"image_size": size, "reference_distance": distance}
def create_panorama_from_result(context, result, operator):
source = require_conversion_source(operator)
depth = material = mesh = obj = None
try:
with material_color_image(input_path=operator.color_path) as color:
metadata = load_panorama_metadata(result.file("depth_metadata"))
group = load_node_group("O Image Depth Panorama")
depth = load_depth_result_image(result.file("depth"), source, metadata)
material = create_image_material(source.data, color, shadeless=True, scene=context.scene,
texture_extension="REPEAT")
mesh = bpy.data.meshes.new(source.name)
mesh.from_pydata([(0, 0, 0)], [], [])
mesh.materials.append(material)
obj = bpy.data.objects.new(source.name, mesh)
context.collection.objects.link(obj)
obj.location = source.matrix_world.translation
obj.scale = source.matrix_world.to_scale()
obj["anyimage_mesh_shape"] = "PANORAMA"
modifier = obj.modifiers.new(group.name, "NODES")
modifier.node_group = group
modifier.show_group_selector = False
for name, value in (
("Subdivide", operator.mesh_detail), ("Depth Image", depth),
("Reference Distance", metadata["reference_distance"]),
):
set_modifier_input(modifier, modifier_input_identifier(group, name), value)
finalize_object_result(context, source, obj)
except Exception:
if obj is not None:
bpy.data.objects.remove(obj, do_unlink=True)
if mesh is not None and mesh.users == 0:
bpy.data.meshes.remove(mesh)
if material is not None and material.users == 0:
bpy.data.materials.remove(material, do_unlink=True)
if depth is not None and depth.users == 0:
bpy.data.images.remove(depth, do_unlink=True)
raise
if "FINISHED" not in bpy.ops.ed.undo_push(message="Convert to Panorama"):
raise RuntimeError("Unable to create the Convert to Panorama Undo step")
return obj

View File

@@ -0,0 +1,96 @@
"""Submit the current static panorama through the managed geometry Job."""
import bpy
from ...common.ai import invoke_ai_setup_if_needed, production_device, report_ai_error, require_environment, require_input_path, require_model
from ...common.image import is_image_empty, video_total_frames
from ...common.color_image import prepare_material_color_input, material_analysis_input, cleanup_material_color_input
from ...preferences import addon_preferences, configured_max_ai_input_size
from ...properties import DEFAULT_MOGE2_MODEL_KEY
from ...runtime import JobOperatorBase
from ...server.media.resolution import DEFAULT_MAX_AI_INPUT_SIZE, MAX_MAX_AI_INPUT_SIZE, MIN_MAX_AI_INPUT_SIZE
from .object import create_panorama_from_result
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
class GeneratePanorama(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.generate_panorama"
bl_label = "Generate Panorama"
job_type = "generate-panorama-geometry"
starting_message = "Preparing panorama conversion"
input_path: bpy.props.StringProperty(options=_HIDDEN)
color_path: bpy.props.StringProperty(options=_HIDDEN)
source_object_name: bpy.props.StringProperty(options=_HIDDEN)
source_identity: bpy.props.StringProperty(options=_HIDDEN)
image_identity: bpy.props.StringProperty(options=_HIDDEN)
mesh_detail: bpy.props.IntProperty(default=7, min=0, max=8, options=_HIDDEN)
model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=_HIDDEN)
resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=_HIDDEN)
max_input_size: bpy.props.IntProperty(default=DEFAULT_MAX_AI_INPUT_SIZE,
min=MIN_MAX_AI_INPUT_SIZE, max=MAX_MAX_AI_INPUT_SIZE, options=_HIDDEN)
def request(self, context):
require_environment()
require_model(self.model)
return {
"input": str(require_input_path(self.input_path)),
"device": production_device().lower(), "model": self.model,
"resolution_level": int(self.resolution_level),
"max_input_size": int(self.max_input_size), "video_frames": 1, "start_frame": 1,
}
def response(self, context, result):
create_panorama_from_result(context, result, self)
return "Panorama is ready"
def cleanup(self):
cleanup_material_color_input(self.color_path)
class ConvertToPanorama(bpy.types.Operator):
bl_idname = "anyimage.convert_to_panorama"
bl_label = "Convert to Panorama"
bl_description = "Convert a static 2:1 equirectangular Image Empty into a radial depth surface"
bl_options = {"REGISTER", "UNDO"}
mesh_detail: bpy.props.IntProperty(name="Subdivide", default=7, min=0, max=8)
@classmethod
def poll(cls, context):
from ...runtime import runtime
return is_image_empty(context.object) and not runtime.server_busy()
def execute(self, context):
source = context.object
image = source.data
width, height = image.size
if image.source in {"MOVIE", "SEQUENCE"} or video_total_frames(image) != 1 or height <= 0 or width != height * 2:
self.report({"ERROR"}, "Panorama conversion requires a static 2:1 equirectangular image")
return {"CANCELLED"}
setup = invoke_ai_setup_if_needed(self)
if setup is not None:
return setup
color_path = None
try:
preferences = addon_preferences(context)
color_path = prepare_material_color_input(image)
path = material_analysis_input(color_path)
result = bpy.ops.anyimage.generate_panorama(
input_path=str(path), color_path=str(color_path), source_object_name=source.name,
source_identity=str(source.as_pointer()),
image_identity=str(image.as_pointer()), mesh_detail=self.mesh_detail,
model=getattr(preferences, "geometry_model", DEFAULT_MOGE2_MODEL_KEY),
resolution_level=int(getattr(preferences, "geometry_resolution_level", 5)),
max_input_size=configured_max_ai_input_size(context),
)
except (OSError, RuntimeError, ValueError) as error:
cleanup_material_color_input(color_path)
report_ai_error(self, error)
return {"CANCELLED"}
if "CANCELLED" in result:
cleanup_material_color_input(color_path)
return {"CANCELLED"}
return {"FINISHED"}

View File

@@ -78,17 +78,25 @@ def create_image_plane_object(
thickness=0.0,
):
mesh = create_image_plane_mesh(name, bounds)
mesh.materials.append(material)
plane = bpy.data.objects.new(name, mesh)
context.collection.objects.link(plane)
if matrix_world is None:
from mathutils import Matrix
plane = None
try:
mesh.materials.append(material)
plane = bpy.data.objects.new(name, mesh)
context.collection.objects.link(plane)
if matrix_world is None:
from mathutils import Matrix
matrix_world = Matrix.Identity(4)
plane.matrix_world = build_image_plane_matrix(matrix_world, bounds)
add_image_plane_processing(
plane,
subdivisions=subdivisions,
thickness=thickness,
)
matrix_world = Matrix.Identity(4)
plane.matrix_world = build_image_plane_matrix(matrix_world, bounds)
add_image_plane_processing(
plane,
subdivisions=subdivisions,
thickness=thickness,
)
except Exception:
if plane is not None:
bpy.data.objects.remove(plane, do_unlink=True)
if mesh.users == 0:
bpy.data.meshes.remove(mesh)
raise
return plane

View File

@@ -3,13 +3,9 @@
import bpy
from ...common.image import (
color_data_name,
configure_color_alpha,
image_empty_bounds,
image_pixels,
load_color_result_image,
load_normal_result_image,
require_image_empty,
require_conversion_source,
)
from ...common.depth import (
depth_uniform_scale,
@@ -17,6 +13,7 @@ from ...common.depth import (
load_depth_result_image,
)
from ...common.material import create_image_material
from ...common.color_image import material_color_image
from ...common.object import (
finalize_object_result,
modifier_input_identifier,
@@ -30,20 +27,6 @@ from .image_plane import (
)
def create_color_image(source_object):
source = source_object.data
image = source.copy()
if getattr(source, "source", None) == "GENERATED" or getattr(
source, "is_dirty", False
):
pixels = image_pixels(source)
image.pixels.foreach_set(pixels)
image.update()
image.name = color_data_name(source_object)
configure_color_alpha(image)
return image
def create_plane_object(context, source_object, subdivisions, material):
source_name = source_object.name
plane = create_image_plane_object(
@@ -54,10 +37,17 @@ def create_plane_object(context, source_object, subdivisions, material):
matrix_world=source_object.matrix_world,
subdivisions=subdivisions,
)
for polygon in plane.data.polygons:
polygon.use_smooth = True
plane["anyimage_mesh_shape"] = "PLANE"
return finalize_object_result(context, source_object, plane)
try:
for polygon in plane.data.polygons:
polygon.use_smooth = True
plane["anyimage_mesh_shape"] = "PLANE"
return finalize_object_result(context, source_object, plane)
except Exception:
mesh = plane.data
bpy.data.objects.remove(plane, do_unlink=True)
if mesh.users == 0:
bpy.data.meshes.remove(mesh)
raise
def create_depth_plane_object(
@@ -102,54 +92,43 @@ def create_depth_plane_object(
modifier_input_identifier(node_group, name),
value,
)
return finalize_object_result(context, source_object, plane)
except Exception:
if plane is not None:
bpy.data.objects.remove(plane, do_unlink=True)
if mesh.users == 0:
bpy.data.meshes.remove(mesh)
raise
return finalize_object_result(context, source_object, plane)
def create_depth_plane_from_result(context, result, operator):
normal_space = {"DEPTH": "OBJECT", "RELIEF": "TANGENT"}[operator.plane_type]
source_object = require_image_empty(operator.source_object_name)
color_image = load_color_result_image(source_object, result.directory)
color_image.name = color_data_name(source_object)
source_object = require_conversion_source(operator)
material = None
depth_image = None
normal_image = None
try:
depth_metadata = load_depth_metadata(result.file("depth_metadata"))
depth_image = load_depth_result_image(
result.file("depth"),
source_object,
depth_metadata,
)
normal_image = load_normal_result_image(
result.file(f"{normal_space.lower()}_normal"),
source_object,
)
material = create_image_material(
source_object.data,
color_image,
normal_image=normal_image,
normal_space=normal_space,
scene=context.scene,
)
create_depth_plane_object(
context,
source_object,
operator.mesh_detail,
material,
depth_image,
depth_metadata,
operator.plane_type,
)
with material_color_image(input_path=operator.color_path) as color_image:
depth_metadata = load_depth_metadata(result.file("depth_metadata"))
depth_image = load_depth_result_image(
result.file("depth"), source_object, depth_metadata,
)
normal_image = load_normal_result_image(
result.file(f"{normal_space.lower()}_normal"), source_object,
)
material = create_image_material(
source_object.data, color_image,
normal_image=normal_image, normal_space=normal_space,
scene=context.scene,
)
create_depth_plane_object(
context, source_object, operator.mesh_detail, material,
depth_image, depth_metadata, operator.plane_type,
)
except Exception:
if material is not None and material.users == 0:
bpy.data.materials.remove(material, do_unlink=True)
for image in (color_image, depth_image, normal_image):
for image in (depth_image, normal_image):
if image is not None and image.users == 0:
bpy.data.images.remove(image, do_unlink=True)
raise

View File

@@ -15,18 +15,18 @@ from ...common.ai import (
require_environment,
require_input_path,
require_model,
report_ai_error,
)
from ...common.image import (
cleanup_image_input,
image_user_settings,
is_image_empty,
prepare_image_input,
source_start_frame,
video_total_frames,
)
from ...common.image import is_image_empty
from ...common.material import create_image_material
from ...common.color_image import (
cleanup_material_color_input,
material_analysis_input,
material_color_image,
prepare_material_color_input,
require_static_color_image,
)
from .object import (
create_color_image,
create_depth_plane_from_result,
create_plane_object,
)
@@ -49,7 +49,10 @@ class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator):
starting_message = "Preparing depth conversion"
input_path: bpy.props.StringProperty(options=_HIDDEN)
color_path: bpy.props.StringProperty(options=_HIDDEN)
source_object_name: bpy.props.StringProperty(options=_HIDDEN)
source_identity: bpy.props.StringProperty(options=_HIDDEN)
image_identity: bpy.props.StringProperty(options=_HIDDEN)
plane_type: bpy.props.EnumProperty(
items=(("DEPTH", "Depth Plane", ""), ("RELIEF", "Relief Plane", "")),
default="DEPTH",
@@ -64,7 +67,6 @@ class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator):
max=MAX_MAX_AI_INPUT_SIZE,
options=_HIDDEN,
)
delete_input: bpy.props.BoolProperty(default=False, options=_HIDDEN)
def request(self, context):
require_environment()
@@ -74,7 +76,7 @@ class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator):
"input": str(input_path),
"device": production_device().lower(),
"video_frames": 1,
"start_frame": source_start_frame(self.source_object_name),
"start_frame": 1,
"max_input_size": int(self.max_input_size),
"plane_type": self.plane_type,
}
@@ -84,7 +86,7 @@ class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator):
input_path,
self.model,
self.resolution_level,
source_start_frame(self.source_object_name),
1,
)
)
return parameters
@@ -94,38 +96,42 @@ class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator):
return f"{self.plane_type.title()} Plane is ready"
def cleanup(self):
cleanup_image_input(self.input_path, self.delete_input)
cleanup_material_color_input(self.color_path)
def start_depth_plane_job(operator, context, plane_type):
source_object = context.object
if video_total_frames(source_object.data) != 1:
operator.report({"ERROR"}, "Depth conversion currently supports still images")
return {"CANCELLED"}
setup_result = invoke_ai_setup_if_needed()
if setup_result is not None:
return setup_result
try:
source_path, delete_input = prepare_image_input(
source_object.data,
context.scene,
)
model, resolution_level = depth_plane_settings(context)
except (RuntimeError, ValueError) as error:
require_static_color_image(source_object.data)
except ValueError as error:
operator.report({"ERROR"}, str(error))
return {"CANCELLED"}
result = bpy.ops.anyimage.generate_depth_plane(
input_path=str(source_path),
source_object_name=source_object.name,
mesh_detail=operator.mesh_detail,
plane_type=plane_type,
model=model,
resolution_level=resolution_level,
max_input_size=configured_max_ai_input_size(context),
delete_input=delete_input,
)
if "CANCELLED" in result and delete_input:
cleanup_image_input(source_path, True)
setup_result = invoke_ai_setup_if_needed(operator)
if setup_result is not None:
return setup_result
color_path = None
try:
color_path = prepare_material_color_input(source_object.data)
source_path = material_analysis_input(color_path)
model, resolution_level = depth_plane_settings(context)
result = bpy.ops.anyimage.generate_depth_plane(
input_path=str(source_path),
color_path=str(color_path),
source_object_name=source_object.name,
source_identity=str(source_object.as_pointer()),
image_identity=str(source_object.data.as_pointer()),
mesh_detail=operator.mesh_detail,
plane_type=plane_type,
model=model,
resolution_level=resolution_level,
max_input_size=configured_max_ai_input_size(context),
)
except (OSError, RuntimeError, ValueError) as error:
cleanup_material_color_input(color_path)
report_ai_error(operator, error)
return {"CANCELLED"}
if "CANCELLED" in result:
cleanup_material_color_input(color_path)
return {"CANCELLED"} if "CANCELLED" in result else {"FINISHED"}
@@ -143,20 +149,26 @@ class ConvertToPlane(bpy.types.Operator):
def execute(self, context):
source_object = context.object
color_image = create_color_image(source_object)
frame_start, frame_offset, frame_duration, use_cyclic = image_user_settings(
source_object
)
material = create_image_material(
source_object.data,
color_image,
frame_start=frame_start,
color_frame_offset=frame_offset,
color_frame_duration=frame_duration,
use_cyclic=use_cyclic,
scene=context.scene,
)
create_plane_object(context, source_object, self.mesh_detail, material)
try:
require_static_color_image(source_object.data)
except ValueError as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
material = None
try:
with material_color_image(source_object.data) as color_image:
material = create_image_material(
source_object.data, color_image,
scene=context.scene,
)
create_plane_object(context, source_object, self.mesh_detail, material)
except Exception as error:
if material is not None and material.users == 0:
bpy.data.materials.remove(material, do_unlink=True)
if isinstance(error, ValueError):
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
raise
return {"FINISHED"}

View File

@@ -4,7 +4,6 @@ from enum import StrEnum
import bpy
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,
@@ -97,37 +96,6 @@ def _assign_float_point_attribute(mesh, name, values):
attribute.data.foreach_set("value", np.asarray(values, dtype=np.float32))
def create_cropped_color_image(source_object, crop_bounds, source_rgba):
"""Create a packed color image for one local Cutout bounds rectangle."""
import numpy as np
source = source_object.data
rgba = np.asarray(source_rgba)
left, top, right, bottom = crop_bounds
width = right - left
height = bottom - top
if width <= 0 or height <= 0:
raise ValueError("The Cutout crop dimensions must be positive")
source_height, source_width = rgba.shape[:2]
if left < 0 or top < 0 or right > source_width or bottom > source_height:
raise ValueError("The Cutout crop is outside the source RGBA")
cropped = rgba[top:bottom, left:right]
image = bpy.data.images.new(
color_data_name(source_object),
width=width,
height=height,
alpha=True,
)
try:
image.colorspace_settings.name = source.colorspace_settings.name
configure_color_alpha(image)
image.pixels.foreach_set(np.flipud(cropped).ravel())
image.pack()
image.update()
except Exception:
bpy.data.images.remove(image, do_unlink=True)
raise
return image
def load_cutout_node_group(shape):

View File

@@ -31,17 +31,19 @@ from ...common.ai import (
require_model,
)
from ...common.image import (
cleanup_image_input,
color_data_name,
image_rgba,
load_normal_result_image,
load_packed_rgba_image,
prepare_image_bounds_input,
require_image_empty,
source_start_frame,
video_total_frames,
)
from ...common.depth import load_depth_metadata, load_depth_result_image
from ...common.color_image import (
cleanup_material_color_input,
load_material_color_image,
material_analysis_input,
material_color_image,
prepare_material_color_input,
)
from ...common.viewport import (
ImageGesture,
image_edit_drag_keymap,
@@ -61,7 +63,6 @@ from .interaction import open_shape_pie
from .object import (
CutoutInwardAxis,
NormalMode,
create_cropped_color_image,
create_shape_object,
orient_object_normal_image,
)
@@ -101,36 +102,31 @@ def create_cutout_shape(
inward_axis=CutoutInwardAxis.NEGATIVE_Z,
color_image,
):
try:
base_shape = build_base_shape(
context,
source_object,
point_spacing,
content_values,
content_bounds,
alpha_threshold=alpha_threshold,
fine_outline=fine_outline,
)
result = create_shape_object(
context,
source_object,
shape,
point_spacing,
content_values,
content_bounds,
base_shape,
alpha_threshold=alpha_threshold,
depth_image=depth_image,
depth_metadata=depth_metadata,
normal_image=normal_image,
normal_mode=normal_mode,
inward_axis=inward_axis,
color_image=color_image,
)
except Exception:
if color_image.users == 0:
bpy.data.images.remove(color_image, do_unlink=True)
raise
base_shape = build_base_shape(
context,
source_object,
point_spacing,
content_values,
content_bounds,
alpha_threshold=alpha_threshold,
fine_outline=fine_outline,
)
result = create_shape_object(
context,
source_object,
shape,
point_spacing,
content_values,
content_bounds,
base_shape,
alpha_threshold=alpha_threshold,
depth_image=depth_image,
depth_metadata=depth_metadata,
normal_image=normal_image,
normal_mode=normal_mode,
inward_axis=inward_axis,
color_image=color_image,
)
undo_message = f"Convert to {shape.replace('_', ' ').title()}"
if "FINISHED" not in bpy.ops.ed.undo_push(message=undo_message):
raise RuntimeError(f"Unable to create the {undo_message} Undo step")
@@ -160,12 +156,8 @@ def _cutout_content_values(
return values
def _generated_color_result(input_path, source_object, source_bounds):
image = load_packed_rgba_image(
input_path,
source_object.data,
color_data_name(source_object),
)
def _generated_color_result(input_path, source_bounds):
image = load_material_color_image(input_path)
try:
alpha = image_rgba(image)[:, :, 3]
except Exception:
@@ -193,6 +185,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
starting_message = "Generating Cutout artifacts"
input_path: bpy.props.StringProperty(options=_HIDDEN)
color_path: bpy.props.StringProperty(options=_HIDDEN)
source_object_name: bpy.props.StringProperty(options=_HIDDEN)
selection_path_json: bpy.props.StringProperty(options=_HIDDEN)
selection_bounds_json: bpy.props.StringProperty(options=_HIDDEN)
@@ -265,25 +258,15 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
selection_mask.values,
alpha_threshold=self.alpha_threshold,
)
color_image = create_cropped_color_image(
source_object,
selection_mask.bounds,
source_rgba,
)
create_cutout_shape(
context,
source_object,
self.shape,
self.point_spacing,
content_values,
selection_mask.bounds,
inward_axis=self.inward_axis,
alpha_threshold=self.alpha_threshold,
fine_outline=self.fine_outline,
color_image=color_image,
)
with material_color_image(source_object.data, bounds=selection_mask.bounds, rgba=source_rgba) as color_image:
create_cutout_shape(
context, source_object, self.shape, self.point_spacing,
content_values, selection_mask.bounds,
inward_axis=self.inward_axis, alpha_threshold=self.alpha_threshold,
fine_outline=self.fine_outline, color_image=color_image,
)
return {"FINISHED"}
setup_result = invoke_ai_setup_if_needed()
setup_result = invoke_ai_setup_if_needed(self)
if setup_result is not None:
return setup_result
self.model, self.resolution_level = cutout_geometry_settings(context)
@@ -292,13 +275,10 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
separators=(",", ":"),
)
self._selection_mask = selection_mask
self.input_path = str(
prepare_image_bounds_input(
source_object.data,
source_rgba,
selection_mask.bounds,
)
)
self.color_path = str(prepare_material_color_input(
source_object.data, selection_mask.bounds, source_rgba,
))
self.input_path = str(material_analysis_input(self.color_path))
except (
ImageEditWarning,
OSError,
@@ -306,11 +286,12 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
TypeError,
ValueError,
) as error:
cleanup_material_color_input(getattr(self, "color_path", None))
report_image_edit_exception(self, error)
return {"CANCELLED"}
result = super().execute(context)
if "CANCELLED" in result:
cleanup_image_input(self.input_path, True)
cleanup_material_color_input(self.color_path)
return result
def request(self, context):
@@ -329,7 +310,6 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
"alpha_threshold": float(self.alpha_threshold),
}
if generate_depth or normal_mode != NormalMode.NONE:
start_frame = source_start_frame(self.source_object_name)
parameters.update(
model=self.model,
resolution_level=self.resolution_level,
@@ -340,7 +320,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
source_path,
self.model,
self.resolution_level,
start_frame,
1,
)
)
return parameters
@@ -354,14 +334,12 @@ 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(
self.input_path,
source_object,
self.color_path,
source_bounds,
)
value = result.value if isinstance(result.value, dict) else {}
normal_image = None
depth_image = None
shape_started = False
try:
selection_mask = getattr(self, "_selection_mask", None)
if selection_mask is None:
@@ -389,7 +367,6 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
source_object,
depth_metadata,
)
shape_started = True
create_cutout_shape(
context,
source_object,
@@ -411,13 +388,13 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
bpy.data.images.remove(normal_image, do_unlink=True)
if depth_image is not None and depth_image.users == 0:
bpy.data.images.remove(depth_image, do_unlink=True)
if not shape_started and color_image.users == 0:
if color_image.users == 0:
bpy.data.images.remove(color_image, do_unlink=True)
raise
return f"{self.shape.replace('_', ' ').title()} is ready"
def cleanup(self):
cleanup_image_input(self.input_path, True)
cleanup_material_color_input(self.color_path)
class SelectCutoutSelection(ImageGesture, bpy.types.Operator):

View File

@@ -3,7 +3,8 @@ import bpy
from ..properties import BEN2_MODEL_KEY
from ..runtime import JobOperatorBase
from ..common.ai import (
require_background_input_path,
require_input_path,
report_ai_error,
invoke_ai_setup_if_needed,
production_device,
require_environment,
@@ -12,6 +13,7 @@ from ..common.ai import (
from ..common.image import cleanup_image_input
from ..common.image_target import ImageEditTarget, image_edit_owner
from ..common.hdr_image import HdrBackgroundInput
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
@@ -30,13 +32,17 @@ class RemoveImageBackground(bpy.types.Operator):
return image_edit_owner(context) is not None and not runtime.server_busy()
def invoke(self, _context, _event):
setup_result = invoke_ai_setup_if_needed()
setup_result = invoke_ai_setup_if_needed(self)
if setup_result is not None:
return setup_result
return self.execute(_context)
def execute(self, _context):
result = bpy.ops.anyimage.run_background_removal()
try:
result = bpy.ops.anyimage.run_background_removal()
except RuntimeError as error:
report_ai_error(self, error)
return {"CANCELLED"}
if "CANCELLED" not in result:
return {"FINISHED"}
return {"CANCELLED"}
@@ -52,14 +58,26 @@ class RunBackgroundRemoval(JobOperatorBase, bpy.types.Operator):
delete_input: bpy.props.BoolProperty(default=False, options=_HIDDEN)
def execute(self, context):
self._hdr_input = None
try:
self._image_target = ImageEditTarget.capture(context)
source_path, self.delete_input = self._image_target.prepare(context)
except RuntimeError as error:
self.report({"ERROR"}, str(error))
if getattr(self._image_target.image, "is_float", False):
self._hdr_input = HdrBackgroundInput.prepare(self._image_target.image)
source_path, self.delete_input = self._hdr_input.path, True
else:
source_path, self.delete_input = self._image_target.prepare(context)
except (OSError, RuntimeError, ValueError, MemoryError) as error:
self._hdr_input = None
self._image_target = None
message = "Not enough memory to prepare the HDR image" if isinstance(error, MemoryError) else str(error)
self.report({"ERROR"}, message)
return {"CANCELLED"}
self.input_path = str(source_path)
result = super().execute(context)
try:
result = super().execute(context)
except Exception:
self.cleanup()
raise
if "CANCELLED" in result:
self.cleanup()
return result
@@ -68,20 +86,29 @@ class RunBackgroundRemoval(JobOperatorBase, bpy.types.Operator):
require_environment()
require_model(BEN2_MODEL_KEY)
settings = context.scene.anyimage_settings
return {
"input": str(require_background_input_path(self.input_path)),
parameters = {
"input": str(require_input_path(self.input_path)),
"model": BEN2_MODEL_KEY,
"device": production_device().lower(),
"video_frames": settings.video_frames,
"start_frame": self._image_target.start_frame,
}
if getattr(self, "_hdr_input", None) is not None:
parameters.update(output_kind="alpha", video_frames=1, start_frame=1)
return parameters
def response(self, _context, result):
self._image_target.apply(result.directory)
if getattr(self, "_hdr_input", None) is not None:
self._hdr_input.apply(self._image_target, result.file("alpha"), prepare_undo=True)
else:
self._image_target.apply(result.directory)
if "FINISHED" not in bpy.ops.ed.undo_push(message="Remove Background"):
raise RuntimeError("Unable to create the Remove Background Undo step")
return "Image background is removed"
def cleanup(self):
cleanup_image_input(self.input_path, self.delete_input)
self._image_target = None
try:
cleanup_image_input(self.input_path, self.delete_input)
finally:
self._image_target = None
self._hdr_input = None

View File

@@ -77,7 +77,7 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator):
)
def invoke(self, context, _event):
setup_result = invoke_ai_setup_if_needed()
setup_result = invoke_ai_setup_if_needed(self)
if setup_result is not None:
return setup_result
return self.execute(context)
@@ -87,7 +87,7 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator):
self.max_input_size = configured_max_ai_input_size(context)
try:
self._image_target = ImageEditTarget.capture(context)
except RuntimeError as error:
except (OSError, RuntimeError, ValueError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
source_image = self._image_target.image
@@ -100,7 +100,7 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator):
return {"CANCELLED"}
try:
source_path, delete_input = self._image_target.prepare(context)
except RuntimeError as error:
except (OSError, RuntimeError, ValueError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}