Enhance image editing tests and functionality
- Added parameterized tests for image edit replacement to ensure alpha mode preservation. - Refactored existing tests to utilize new image editing methods and improve clarity. - Introduced new tests for image target functionality, ensuring proper handling of shared textures and context. - Updated various tests to validate alpha mode handling across different scenarios. - Improved undo/redo functionality in image operators to maintain state consistency. - Added support for texture node actions, enhancing integration with Blender's node system. - Ensured that image properties are preserved during operations and that invalid contexts are handled gracefully.
This commit is contained in:
@@ -2,7 +2,12 @@ import bpy
|
||||
|
||||
from .operators import clipboard_image
|
||||
from .operators.clipboard_image import CLASSES as CLIPBOARD_CLASSES
|
||||
from .menu import AnyImageImageMenu, draw_image_context_menu
|
||||
from .menu import (
|
||||
AnyImageImageMenu,
|
||||
AnyImageTextureNodeMenu,
|
||||
draw_image_context_menu,
|
||||
draw_texture_node_context_menu,
|
||||
)
|
||||
from .operators import CLASSES as OPERATOR_CLASSES
|
||||
from .operators.cutout_tool import CutoutTool
|
||||
from .operators.frame_tool import FrameTool
|
||||
@@ -20,6 +25,7 @@ CLASSES = (
|
||||
*CLIPBOARD_CLASSES,
|
||||
*OPERATOR_CLASSES,
|
||||
AnyImageImageMenu,
|
||||
AnyImageTextureNodeMenu,
|
||||
ServerPanel,
|
||||
)
|
||||
|
||||
@@ -42,6 +48,7 @@ def register():
|
||||
bpy.types.Scene.anyimage_settings = bpy.props.PointerProperty(type=AnyImageSettings)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.prepend(draw_image_context_menu)
|
||||
bpy.types.OUTLINER_MT_object.prepend(draw_image_context_menu)
|
||||
bpy.types.NODE_MT_context_menu.prepend(draw_texture_node_context_menu)
|
||||
clipboard_image.register_keymaps()
|
||||
runtime.register()
|
||||
|
||||
@@ -53,8 +60,9 @@ def unregister():
|
||||
bpy.utils.unregister_tool(RectifyTool)
|
||||
bpy.utils.unregister_tool(MaskTool)
|
||||
bpy.utils.unregister_tool(FrameTool)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu)
|
||||
bpy.types.NODE_MT_context_menu.remove(draw_texture_node_context_menu)
|
||||
bpy.types.OUTLINER_MT_object.remove(draw_image_context_menu)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu)
|
||||
if hasattr(bpy.types.Scene, "anyimage_settings"):
|
||||
del bpy.types.Scene.anyimage_settings
|
||||
for class_type in reversed(CLASSES):
|
||||
|
||||
@@ -8,6 +8,14 @@ from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
VIEW_TRANSFORM_COLOR_SPACES = (
|
||||
("ACES 1.3", "ACES 1.3 sRGB"),
|
||||
("ACES 2.0", "ACES 2.0 sRGB"),
|
||||
("AgX", "AgX Base sRGB"),
|
||||
("Filmic", "Filmic sRGB"),
|
||||
("Khronos PBR Neutral", "Khronos PBR Neutral sRGB"),
|
||||
)
|
||||
|
||||
IMAGE_NAME_SUFFIXES = (
|
||||
".jpeg",
|
||||
".jpg",
|
||||
@@ -296,7 +304,7 @@ def video_total_frames(image):
|
||||
|
||||
|
||||
def image_user_settings(source_object):
|
||||
"""Return the Empty's image user settings as a four-item tuple."""
|
||||
"""Return an image owner's playback settings as a four-item tuple."""
|
||||
image_user = getattr(source_object, "image_user", None)
|
||||
if image_user is None:
|
||||
return 1, 0, 0, False
|
||||
@@ -308,14 +316,13 @@ def image_user_settings(source_object):
|
||||
)
|
||||
|
||||
|
||||
def extract_frame_range(source_object):
|
||||
def extract_frame_range(image, image_user):
|
||||
"""Return the clamped (start_frame, frame_count) range extracted from the source."""
|
||||
total_frames = video_total_frames(getattr(source_object, "data", None))
|
||||
total_frames = video_total_frames(image)
|
||||
if total_frames <= 1:
|
||||
return 1, total_frames
|
||||
_frame_start, frame_offset, image_frames, _use_cyclic = (
|
||||
image_user_settings(source_object)
|
||||
)
|
||||
frame_offset = int(getattr(image_user, "frame_offset", 0) or 0)
|
||||
image_frames = int(getattr(image_user, "frame_duration", 0) or 0)
|
||||
frame_count = (
|
||||
min(image_frames, total_frames)
|
||||
if image_frames >= 1
|
||||
@@ -326,14 +333,9 @@ def extract_frame_range(source_object):
|
||||
return start_frame, frame_count
|
||||
|
||||
|
||||
def default_extract_frames(source_object):
|
||||
"""Return the Empty's Image Frames value, or the full frame count when unset."""
|
||||
return extract_frame_range(source_object)[1]
|
||||
|
||||
|
||||
def source_start_frame(source_object_name):
|
||||
source_object = bpy.data.objects.get(source_object_name)
|
||||
return extract_frame_range(source_object)[0] if source_object else 1
|
||||
return extract_frame_range(source_object.data, source_object.image_user)[0] if source_object else 1
|
||||
|
||||
|
||||
def image_path(image):
|
||||
@@ -615,16 +617,22 @@ def load_normal_result_image(path, source_object):
|
||||
return image
|
||||
|
||||
|
||||
def color_alpha_mode(color_space):
|
||||
"""Pair inverse view color spaces with straight alpha."""
|
||||
return "STRAIGHT" if color_space in dict(VIEW_TRANSFORM_COLOR_SPACES).values() else "PREMUL"
|
||||
|
||||
|
||||
def configure_color_alpha(image):
|
||||
"""Apply the presentation mode to an owned Color image."""
|
||||
if image.alpha_mode != "PREMUL":
|
||||
alpha_mode = color_alpha_mode(image.colorspace_settings.name)
|
||||
if image.alpha_mode != alpha_mode:
|
||||
pixels = (
|
||||
image_pixels(image)
|
||||
if getattr(image, "source", None) == "GENERATED"
|
||||
or getattr(image, "is_dirty", False)
|
||||
else None
|
||||
)
|
||||
image.alpha_mode = "PREMUL"
|
||||
image.alpha_mode = alpha_mode
|
||||
if pixels is not None:
|
||||
image.pixels.foreach_set(pixels)
|
||||
image.update()
|
||||
@@ -645,7 +653,7 @@ def create_image_edit_result(source_image, pixels, output_size):
|
||||
)
|
||||
try:
|
||||
image.colorspace_settings.name = source_image.colorspace_settings.name
|
||||
configure_color_alpha(image)
|
||||
image.alpha_mode = source_image.alpha_mode
|
||||
image.pixels.foreach_set(pixels)
|
||||
image.pack()
|
||||
image.update()
|
||||
@@ -801,11 +809,12 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None,
|
||||
|
||||
|
||||
def _load_packed_result_image(
|
||||
source_object,
|
||||
source_image,
|
||||
output_directory,
|
||||
*,
|
||||
frame_prefix,
|
||||
result_name,
|
||||
alpha_mode,
|
||||
):
|
||||
output_directory = Path(output_directory)
|
||||
color_paths = sorted_frame_paths(output_directory, frame_prefix)
|
||||
@@ -813,7 +822,6 @@ def _load_packed_result_image(
|
||||
raise RuntimeError(
|
||||
f"Color result has no images: {output_directory}"
|
||||
)
|
||||
source_image = source_object.data
|
||||
source_color_space = source_image.colorspace_settings.name
|
||||
animated = is_animated_image(source_image)
|
||||
try:
|
||||
@@ -822,7 +830,7 @@ def _load_packed_result_image(
|
||||
check_existing=False,
|
||||
)
|
||||
result_image.colorspace_settings.name = source_color_space
|
||||
configure_color_alpha(result_image)
|
||||
result_image.alpha_mode = alpha_mode
|
||||
if animated:
|
||||
force_sequence_source(result_image)
|
||||
except (OSError, RuntimeError, TypeError, ValueError) as error:
|
||||
@@ -843,17 +851,18 @@ def _load_packed_result_image(
|
||||
|
||||
|
||||
def load_image_edit_result(
|
||||
source_object,
|
||||
source_image,
|
||||
output_directory,
|
||||
*,
|
||||
frame_prefix="image.",
|
||||
):
|
||||
"""Load one packed Job result that directly edits the source Image."""
|
||||
return _load_packed_result_image(
|
||||
source_object,
|
||||
source_image,
|
||||
output_directory,
|
||||
frame_prefix=frame_prefix,
|
||||
result_name=source_object.data.name,
|
||||
result_name=source_image.name,
|
||||
alpha_mode=source_image.alpha_mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -865,10 +874,11 @@ def load_color_result_image(
|
||||
):
|
||||
"""Load one packed Color texture result for a generated material."""
|
||||
return _load_packed_result_image(
|
||||
source_object,
|
||||
source_object.data,
|
||||
output_directory,
|
||||
frame_prefix=frame_prefix,
|
||||
result_name=color_data_name(source_object),
|
||||
alpha_mode=color_alpha_mode(source_object.data.colorspace_settings.name),
|
||||
)
|
||||
|
||||
|
||||
|
||||
167
src/anyimage/common/image_target.py
Normal file
167
src/anyimage/common/image_target.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Resolve and commit AI image edits for Empty objects and texture nodes."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import bpy
|
||||
|
||||
from .image import (
|
||||
configure_image_user,
|
||||
extract_frame_range,
|
||||
image_content_state,
|
||||
image_user_settings,
|
||||
is_animated_image,
|
||||
is_image_empty,
|
||||
load_image_edit_result,
|
||||
prepare_image_input,
|
||||
replace_empty_image,
|
||||
restore_image_content,
|
||||
video_total_frames,
|
||||
)
|
||||
|
||||
|
||||
def active_texture_node(context):
|
||||
"""Return the editable image texture in the current material edit tree."""
|
||||
space = getattr(context, "space_data", None)
|
||||
if (
|
||||
getattr(space, "type", None) != "NODE_EDITOR"
|
||||
or getattr(space, "tree_type", None) != "ShaderNodeTree"
|
||||
or getattr(space, "shader_type", None) != "OBJECT"
|
||||
or not isinstance(getattr(space, "id", None), bpy.types.Material)
|
||||
):
|
||||
return None
|
||||
tree = space.edit_tree
|
||||
if tree is None or not tree.is_editable:
|
||||
return None
|
||||
node = tree.nodes.active
|
||||
if (
|
||||
node is None
|
||||
or node.bl_idname != "ShaderNodeTexImage"
|
||||
or node.image is None
|
||||
or not node.image.is_editable
|
||||
):
|
||||
return None
|
||||
return node
|
||||
|
||||
|
||||
def image_edit_owner(context):
|
||||
"""Resolve the editor's image owner without falling back across editors."""
|
||||
if getattr(getattr(context, "space_data", None), "type", None) == "NODE_EDITOR":
|
||||
return active_texture_node(context)
|
||||
owner = getattr(context, "object", None)
|
||||
return owner if is_image_empty(owner) else None
|
||||
|
||||
|
||||
def owner_image(owner):
|
||||
"""Return the Image assigned to an image Empty or texture node."""
|
||||
return owner.data if is_image_empty(owner) else owner.image
|
||||
|
||||
|
||||
def has_other_texture_image_user(node):
|
||||
"""Check actual Image references, excluding editor display and Fake User."""
|
||||
tree = node.id_data
|
||||
image = node.image
|
||||
for user in bpy.data.user_map(subset={image})[image]:
|
||||
if isinstance(user, bpy.types.Screen):
|
||||
continue
|
||||
if user == tree or getattr(user, "node_tree", None) == tree:
|
||||
if any(
|
||||
other != node and getattr(other, "image", None) == image
|
||||
for other in tree.nodes
|
||||
):
|
||||
return True
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def replace_texture_image(node, result_image):
|
||||
"""Commit one texture image transaction while preserving other users."""
|
||||
try:
|
||||
source_image = node.image
|
||||
original_timing = image_user_settings(node)
|
||||
original_auto_refresh = node.image_user.use_auto_refresh
|
||||
original_content = None
|
||||
frame_count = video_total_frames(result_image) if is_animated_image(result_image) else None
|
||||
try:
|
||||
if has_other_texture_image_user(node):
|
||||
final_image = result_image
|
||||
else:
|
||||
result_content = image_content_state(result_image)
|
||||
original_content = image_content_state(source_image)
|
||||
restore_image_content(source_image, result_content)
|
||||
final_image = source_image
|
||||
node.image = final_image
|
||||
if frame_count is not None:
|
||||
configure_image_user(
|
||||
node.image_user,
|
||||
frame_count,
|
||||
frame_start=original_timing[0],
|
||||
use_cyclic=original_timing[3],
|
||||
)
|
||||
except Exception:
|
||||
if original_content is not None:
|
||||
restore_image_content(source_image, original_content)
|
||||
node.image = source_image
|
||||
user = node.image_user
|
||||
(user.frame_start, user.frame_offset, user.frame_duration, user.use_cyclic) = original_timing
|
||||
user.use_auto_refresh = original_auto_refresh
|
||||
raise
|
||||
return final_image
|
||||
finally:
|
||||
if result_image.users == 0:
|
||||
bpy.data.images.remove(result_image)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageEditTarget:
|
||||
"""Hold main-thread RNA identities for a single asynchronous image edit."""
|
||||
|
||||
owner: object
|
||||
image: object
|
||||
tree: object = None
|
||||
start_frame: int = 1
|
||||
frame_count: int = 1
|
||||
|
||||
@classmethod
|
||||
def capture(cls, context):
|
||||
owner = image_edit_owner(context)
|
||||
if owner is None:
|
||||
raise RuntimeError("Select an Image Empty or a material Image Texture")
|
||||
image = owner_image(owner)
|
||||
start_frame, frame_count = extract_frame_range(image, owner.image_user)
|
||||
return cls(
|
||||
owner, image,
|
||||
None if is_image_empty(owner) else owner.id_data,
|
||||
start_frame, frame_count,
|
||||
)
|
||||
|
||||
def validate(self):
|
||||
"""Reject a removed owner or a changed image assignment."""
|
||||
try:
|
||||
if self.tree is None:
|
||||
valid = is_image_empty(self.owner) and self.owner.data == self.image
|
||||
else:
|
||||
valid = (
|
||||
self.tree.is_editable
|
||||
and self.image.is_editable
|
||||
and self.tree.nodes.get(self.owner.name) == self.owner
|
||||
and self.owner.image == self.image
|
||||
)
|
||||
if valid:
|
||||
return
|
||||
except ReferenceError:
|
||||
pass
|
||||
raise RuntimeError("The source image target is no longer available or has changed")
|
||||
|
||||
def prepare(self, context):
|
||||
self.validate()
|
||||
if video_total_frames(self.image) > 1:
|
||||
context.scene.anyimage_settings.video_frames = self.frame_count
|
||||
return prepare_image_input(self.image, context.scene)
|
||||
|
||||
def apply(self, directory, *, frame_prefix="image."):
|
||||
self.validate()
|
||||
result_image = load_image_edit_result(self.image, directory, frame_prefix=frame_prefix)
|
||||
if self.tree is None:
|
||||
return replace_empty_image(self.owner, result_image)
|
||||
return replace_texture_image(self.owner, result_image)
|
||||
@@ -4,6 +4,8 @@ import bpy
|
||||
|
||||
from ..preferences import configured_material_view_adaptation
|
||||
from .image import (
|
||||
VIEW_TRANSFORM_COLOR_SPACES,
|
||||
color_alpha_mode,
|
||||
configure_image_user,
|
||||
image_base_name,
|
||||
image_pixels,
|
||||
@@ -15,13 +17,6 @@ from .node import load_node_group
|
||||
IMAGE_MATERIAL_NODE_GROUP_NAME = "O Image Layer"
|
||||
DEPTH_MATERIAL_NODE_GROUP_NAME = "O Image Depth Layer"
|
||||
SHADELESS_NODE_GROUP_NAME = "O Shadeless"
|
||||
VIEW_TRANSFORM_COLOR_SPACES = (
|
||||
("ACES 1.3", "ACES 1.3 sRGB"),
|
||||
("ACES 2.0", "ACES 2.0 sRGB"),
|
||||
("AgX", "AgX Base sRGB"),
|
||||
("Filmic", "Filmic sRGB"),
|
||||
("Khronos PBR Neutral", "Khronos PBR Neutral sRGB"),
|
||||
)
|
||||
|
||||
|
||||
def material_color_space_name(scene):
|
||||
@@ -64,7 +59,7 @@ def configure_material_color_image(image, scene=None):
|
||||
break
|
||||
else:
|
||||
raise RuntimeError("Unable to configure the material color space")
|
||||
result.alpha_mode = "PREMUL" if settings.name == "sRGB" else "STRAIGHT"
|
||||
result.alpha_mode = color_alpha_mode(settings.name)
|
||||
if pixels is not None:
|
||||
result.pixels.foreach_set(pixels)
|
||||
result.update()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import bpy
|
||||
|
||||
from .common.image import is_image_empty
|
||||
from .common.image_target import active_texture_node, image_edit_owner, owner_image
|
||||
from .operators.upscale import upscale_output_size
|
||||
|
||||
|
||||
@@ -9,8 +10,6 @@ class AnyImageImageMenu(bpy.types.Menu):
|
||||
bl_label = "AnyImage"
|
||||
|
||||
def draw(self, context):
|
||||
from .properties import ai_setup_label, ai_status
|
||||
|
||||
layout = self.layout
|
||||
layout.operator_context = "INVOKE_DEFAULT"
|
||||
layout.operator(
|
||||
@@ -29,37 +28,52 @@ class AnyImageImageMenu(bpy.types.Menu):
|
||||
icon="MOD_DISPLACE",
|
||||
)
|
||||
layout.separator()
|
||||
draw_image_actions(layout, context)
|
||||
|
||||
|
||||
class AnyImageTextureNodeMenu(bpy.types.Menu):
|
||||
bl_idname = "ANYIMAGE_MT_texture_node_context"
|
||||
bl_label = "AnyImage"
|
||||
|
||||
def draw(self, context):
|
||||
draw_image_actions(self.layout, context)
|
||||
|
||||
|
||||
def draw_image_actions(layout, context):
|
||||
from .properties import ai_setup_label, ai_status
|
||||
|
||||
layout.operator_context = "INVOKE_DEFAULT"
|
||||
layout.operator(
|
||||
"anyimage.remove_image_background",
|
||||
text="Remove Background",
|
||||
icon="IMAGE_ALPHA",
|
||||
)
|
||||
layout.operator(
|
||||
"anyimage.upscale_image",
|
||||
text=upscale_menu_label(context),
|
||||
icon="FULLSCREEN_ENTER",
|
||||
)
|
||||
layout.separator()
|
||||
status = ai_status()
|
||||
if status["ready"]:
|
||||
layout.operator(
|
||||
"anyimage.remove_image_background",
|
||||
text="Remove Background",
|
||||
icon="IMAGE_ALPHA",
|
||||
"anyimage.open_ai_environment_settings",
|
||||
text="Settings",
|
||||
icon="PREFERENCES",
|
||||
)
|
||||
else:
|
||||
layout.operator(
|
||||
"anyimage.upscale_image",
|
||||
text=upscale_menu_label(context),
|
||||
icon="FULLSCREEN_ENTER",
|
||||
"anyimage.setup_ai_environment",
|
||||
text=ai_setup_label(status),
|
||||
icon="IMPORT",
|
||||
)
|
||||
layout.separator()
|
||||
status = ai_status()
|
||||
if status["ready"]:
|
||||
layout.operator(
|
||||
"anyimage.open_ai_environment_settings",
|
||||
text="Settings",
|
||||
icon="PREFERENCES",
|
||||
)
|
||||
else:
|
||||
layout.operator(
|
||||
"anyimage.setup_ai_environment",
|
||||
text=ai_setup_label(status),
|
||||
icon="IMPORT",
|
||||
)
|
||||
|
||||
|
||||
def upscale_menu_label(context):
|
||||
source_object = getattr(context, "object", None)
|
||||
if not is_image_empty(source_object):
|
||||
owner = image_edit_owner(context)
|
||||
if owner is None:
|
||||
return "Upscale"
|
||||
output_size = upscale_output_size(source_object.data)
|
||||
output_size = upscale_output_size(owner_image(owner))
|
||||
if output_size is None:
|
||||
return "Upscale"
|
||||
width, height = output_size
|
||||
@@ -71,3 +85,10 @@ def draw_image_context_menu(self, context):
|
||||
return
|
||||
self.layout.menu(AnyImageImageMenu.bl_idname, text="AnyImage", icon="PLUGIN")
|
||||
self.layout.separator()
|
||||
|
||||
|
||||
def draw_texture_node_context_menu(self, context):
|
||||
if active_texture_node(context) is None:
|
||||
return
|
||||
self.layout.menu(AnyImageTextureNodeMenu.bl_idname, text="AnyImage", icon="PLUGIN")
|
||||
self.layout.separator()
|
||||
|
||||
@@ -10,14 +10,8 @@ from ..common.ai import (
|
||||
require_model,
|
||||
)
|
||||
|
||||
from ..common.image import (
|
||||
cleanup_image_input,
|
||||
default_extract_frames,
|
||||
is_image_empty,
|
||||
prepare_image_input,
|
||||
source_start_frame,
|
||||
video_total_frames,
|
||||
)
|
||||
from ..common.image import cleanup_image_input
|
||||
from ..common.image_target import ImageEditTarget, image_edit_owner
|
||||
|
||||
|
||||
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
|
||||
@@ -26,14 +20,14 @@ _HIDDEN = {"HIDDEN", "SKIP_SAVE"}
|
||||
class RemoveImageBackground(bpy.types.Operator):
|
||||
bl_idname = "anyimage.remove_image_background"
|
||||
bl_label = "Remove Background"
|
||||
bl_description = "Remove the background from this Image Empty with BEN2"
|
||||
bl_description = "Remove the image background with BEN2"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
from ..runtime import runtime
|
||||
|
||||
return is_image_empty(context.object) and not runtime.server_busy()
|
||||
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()
|
||||
@@ -41,58 +35,35 @@ class RemoveImageBackground(bpy.types.Operator):
|
||||
return setup_result
|
||||
return self.execute(_context)
|
||||
|
||||
def execute(self, context):
|
||||
source_object = context.object
|
||||
total_frames = video_total_frames(source_object.data)
|
||||
if total_frames > 1:
|
||||
context.scene.anyimage_settings.video_frames = (
|
||||
default_extract_frames(source_object)
|
||||
)
|
||||
try:
|
||||
source_path, delete_input = prepare_image_input(
|
||||
source_object.data,
|
||||
context.scene,
|
||||
)
|
||||
except RuntimeError as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
return {"CANCELLED"}
|
||||
|
||||
result = bpy.ops.anyimage.run_background_removal(
|
||||
input_path=str(source_path),
|
||||
source_object_name=source_object.name,
|
||||
delete_input=delete_input,
|
||||
)
|
||||
def execute(self, _context):
|
||||
result = bpy.ops.anyimage.run_background_removal()
|
||||
if "CANCELLED" not in result:
|
||||
return {"FINISHED"}
|
||||
if delete_input:
|
||||
cleanup_image_input(source_path, True)
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
def apply_background_response(result, source_object_name):
|
||||
from ..common.image import (
|
||||
load_image_edit_result,
|
||||
replace_empty_image,
|
||||
require_image_empty,
|
||||
)
|
||||
|
||||
source_object = require_image_empty(source_object_name)
|
||||
result_image = load_image_edit_result(source_object, result.directory)
|
||||
replace_empty_image(source_object, result_image)
|
||||
if "FINISHED" not in bpy.ops.ed.undo_push(message="Remove Background"):
|
||||
raise RuntimeError("Unable to create the Remove Background Undo step")
|
||||
|
||||
|
||||
class RunBackgroundRemoval(JobOperatorBase, bpy.types.Operator):
|
||||
bl_idname = "anyimage.run_background_removal"
|
||||
bl_label = "Run Background Removal"
|
||||
job_type = "remove-background"
|
||||
starting_message = "Starting Image Empty background removal"
|
||||
starting_message = "Starting image background removal"
|
||||
|
||||
input_path: bpy.props.StringProperty(options=_HIDDEN)
|
||||
source_object_name: bpy.props.StringProperty(options=_HIDDEN)
|
||||
delete_input: bpy.props.BoolProperty(default=False, options=_HIDDEN)
|
||||
|
||||
def execute(self, context):
|
||||
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))
|
||||
return {"CANCELLED"}
|
||||
self.input_path = str(source_path)
|
||||
result = super().execute(context)
|
||||
if "CANCELLED" in result:
|
||||
self.cleanup()
|
||||
return result
|
||||
|
||||
def request(self, context):
|
||||
require_environment()
|
||||
require_model(BEN2_MODEL_KEY)
|
||||
@@ -102,12 +73,15 @@ class RunBackgroundRemoval(JobOperatorBase, bpy.types.Operator):
|
||||
"model": BEN2_MODEL_KEY,
|
||||
"device": production_device().lower(),
|
||||
"video_frames": settings.video_frames,
|
||||
"start_frame": source_start_frame(self.source_object_name),
|
||||
"start_frame": self._image_target.start_frame,
|
||||
}
|
||||
|
||||
def response(self, _context, result):
|
||||
apply_background_response(result, self.source_object_name)
|
||||
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
|
||||
|
||||
@@ -15,17 +15,8 @@ from ..common.ai import (
|
||||
require_environment,
|
||||
require_model,
|
||||
)
|
||||
from ..common.image import (
|
||||
cleanup_image_input,
|
||||
default_extract_frames,
|
||||
is_image_empty,
|
||||
load_image_edit_result,
|
||||
prepare_image_input,
|
||||
source_start_frame,
|
||||
replace_empty_image,
|
||||
require_image_empty,
|
||||
video_total_frames,
|
||||
)
|
||||
from ..common.image import cleanup_image_input
|
||||
from ..common.image_target import ImageEditTarget, image_edit_owner, owner_image
|
||||
|
||||
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
|
||||
|
||||
@@ -49,10 +40,10 @@ def upscale_input_size_allowed(image, max_input_size):
|
||||
class UpscaleImage(JobOperatorBase, bpy.types.Operator):
|
||||
bl_idname = "anyimage.upscale_image"
|
||||
bl_label = "Upscale"
|
||||
bl_description = "Upscale this Image Empty by 2x with an AI model"
|
||||
bl_description = "Upscale the image by 2x with an AI model"
|
||||
bl_options = {"UNDO"}
|
||||
job_type = "upscale-image"
|
||||
starting_message = "Starting Image Empty upscale"
|
||||
starting_message = "Starting image upscale"
|
||||
|
||||
model: bpy.props.StringProperty(
|
||||
default="REALESRGAN_GENERAL_WDN_X4V3",
|
||||
@@ -69,19 +60,17 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator):
|
||||
options=_HIDDEN,
|
||||
)
|
||||
input_path: bpy.props.StringProperty(options=_HIDDEN)
|
||||
source_object_name: bpy.props.StringProperty(options=_HIDDEN)
|
||||
source_image_name: bpy.props.StringProperty(options=_HIDDEN)
|
||||
delete_input: bpy.props.BoolProperty(default=False, options=_HIDDEN)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
from ..runtime import runtime
|
||||
|
||||
source_object = getattr(context, "object", None)
|
||||
owner = image_edit_owner(context)
|
||||
return (
|
||||
is_image_empty(source_object)
|
||||
owner is not None
|
||||
and upscale_input_size_allowed(
|
||||
source_object.data,
|
||||
owner_image(owner),
|
||||
configured_max_ai_input_size(context),
|
||||
)
|
||||
and not runtime.server_busy()
|
||||
@@ -96,11 +85,12 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator):
|
||||
def execute(self, context):
|
||||
self.model = configured_upscale_model(context)
|
||||
self.max_input_size = configured_max_ai_input_size(context)
|
||||
source_object = context.object
|
||||
if not is_image_empty(source_object):
|
||||
self.report({"ERROR"}, "Select an Image Empty to upscale")
|
||||
try:
|
||||
self._image_target = ImageEditTarget.capture(context)
|
||||
except RuntimeError as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
return {"CANCELLED"}
|
||||
source_image = source_object.data
|
||||
source_image = self._image_target.image
|
||||
if not upscale_input_size_allowed(source_image, self.max_input_size):
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
@@ -108,26 +98,17 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator):
|
||||
f"({self.max_input_size} px)",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
self.source_image_name = source_image.name
|
||||
if video_total_frames(source_image) > 1:
|
||||
context.scene.anyimage_settings.video_frames = (
|
||||
default_extract_frames(source_object)
|
||||
)
|
||||
try:
|
||||
source_path, delete_input = prepare_image_input(
|
||||
source_image,
|
||||
context.scene,
|
||||
)
|
||||
source_path, delete_input = self._image_target.prepare(context)
|
||||
except RuntimeError as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
return {"CANCELLED"}
|
||||
|
||||
self.input_path = str(source_path)
|
||||
self.source_object_name = source_object.name
|
||||
self.delete_input = delete_input
|
||||
result = super().execute(context)
|
||||
if "CANCELLED" in result:
|
||||
cleanup_image_input(self.input_path, self.delete_input)
|
||||
self.cleanup()
|
||||
return result
|
||||
|
||||
def request(self, context):
|
||||
@@ -139,23 +120,15 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator):
|
||||
"model": self.model,
|
||||
"device": production_device().lower(),
|
||||
"video_frames": settings.video_frames,
|
||||
"start_frame": source_start_frame(self.source_object_name),
|
||||
"start_frame": self._image_target.start_frame,
|
||||
"max_input_size": int(self.max_input_size),
|
||||
}
|
||||
|
||||
def response(self, _context, result):
|
||||
source_object = require_image_empty(self.source_object_name)
|
||||
source_image = bpy.data.images.get(self.source_image_name)
|
||||
if source_image is None:
|
||||
raise RuntimeError("The source image is no longer available")
|
||||
result_image = load_image_edit_result(
|
||||
source_object,
|
||||
result.directory,
|
||||
frame_prefix="upscale.",
|
||||
)
|
||||
replace_empty_image(source_object, result_image)
|
||||
self._image_target.apply(result.directory, frame_prefix="upscale.")
|
||||
model_label = upscale_model_label(self.model)
|
||||
return f"Image is upscaled with {model_label}"
|
||||
|
||||
def cleanup(self):
|
||||
cleanup_image_input(self.input_path, self.delete_input)
|
||||
self._image_target = None
|
||||
|
||||
Reference in New Issue
Block a user