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:
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
6
src/anyimage/operators/convert_to_panorama/__init__.py
Normal file
6
src/anyimage/operators/convert_to_panorama/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Image Empty panorama conversion operators."""
|
||||
|
||||
from .operators import ConvertToPanorama, GeneratePanorama
|
||||
|
||||
CLASSES = (GeneratePanorama, ConvertToPanorama)
|
||||
__all__ = ("ConvertToPanorama", "GeneratePanorama", "CLASSES")
|
||||
71
src/anyimage/operators/convert_to_panorama/object.py
Normal file
71
src/anyimage/operators/convert_to_panorama/object.py
Normal 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
|
||||
96
src/anyimage/operators/convert_to_panorama/operators.py
Normal file
96
src/anyimage/operators/convert_to_panorama/operators.py
Normal 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"}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user