feat: Implement upscale functionality with ONNX models
- Added upscale operator to handle image upscaling by 4x using AI models. - Introduced new jobs for upscale processing in the server, including debug and standard upscale jobs. - Created a new ONNX model handler for upscale operations, supporting tiled inference and resource management. - Updated tests to cover new upscale functionality, including input validation and output verification. - Added documentation for the upscale feature, detailing usage and model options.
This commit is contained in:
@@ -4,12 +4,7 @@ from .balloon import (
|
||||
ExtractBalloon,
|
||||
AnalyzeBalloon,
|
||||
)
|
||||
from .debug import (
|
||||
DebugBen2,
|
||||
DebugDa3,
|
||||
DebugMoge2,
|
||||
DebugRealesrganGeneralX4v3,
|
||||
)
|
||||
from .debug import DebugBen2, DebugDa3, DebugMoge2, DebugUpscale
|
||||
from .image_edit import CLASSES as IMAGE_EDIT_CLASSES
|
||||
from .depth_plane import (
|
||||
ConvertToDepthPlane,
|
||||
@@ -27,6 +22,7 @@ from .models import (
|
||||
)
|
||||
from .mesh_plane import ConvertToMeshPlane
|
||||
from .surface import ConvertToSurface, GenerateDa3Surface, GenerateMoge2Surface
|
||||
from .upscale import UpscaleImage
|
||||
|
||||
|
||||
CLASSES = (
|
||||
@@ -37,7 +33,7 @@ CLASSES = (
|
||||
DebugDa3,
|
||||
DebugMoge2,
|
||||
DebugBen2,
|
||||
DebugRealesrganGeneralX4v3,
|
||||
DebugUpscale,
|
||||
GenerateDa3Depth,
|
||||
GenerateMoge2Depth,
|
||||
GenerateDa3Surface,
|
||||
@@ -51,6 +47,7 @@ CLASSES = (
|
||||
AnalyzeBalloon,
|
||||
ActivateBalloonTool,
|
||||
RemoveImageBackground,
|
||||
UpscaleImage,
|
||||
ConvertToMeshPlane,
|
||||
ConvertToDepthPlane,
|
||||
ConvertToSurface,
|
||||
|
||||
@@ -79,7 +79,14 @@ def apply_background_response(result, source_object_name):
|
||||
source_object = bpy.data.objects.get(source_object_name)
|
||||
if not is_image_empty(source_object):
|
||||
raise RuntimeError("The source image empty is no longer available")
|
||||
create_color_image(source_object, result.directory)
|
||||
source_image = source_object.data
|
||||
source_name = source_image.name
|
||||
original_fake_user = source_image.use_fake_user
|
||||
result_image = create_color_image(source_object, result.directory)
|
||||
source_image.use_fake_user = original_fake_user
|
||||
if not original_fake_user and int(source_image.users) == 0:
|
||||
bpy.data.images.remove(source_image)
|
||||
result_image.name = source_name
|
||||
push_undo("Remove Background")
|
||||
|
||||
|
||||
|
||||
@@ -151,24 +151,6 @@ def data_name_matches(name, base_name):
|
||||
return suffix != name and suffix.isdigit()
|
||||
|
||||
|
||||
def material_uses_balloon_texture(material, color_name):
|
||||
node_tree = getattr(material, "node_tree", None)
|
||||
if node_tree is None:
|
||||
return False
|
||||
has_texture_layer = any(
|
||||
getattr(node, "type", None) == "GROUP"
|
||||
and getattr(getattr(node, "node_tree", None), "name", None)
|
||||
== TEXTURE_MATERIAL_NODE_GROUP_NAME
|
||||
for node in node_tree.nodes
|
||||
)
|
||||
has_color_image = any(
|
||||
getattr(node, "type", None) == "TEX_IMAGE"
|
||||
and getattr(node, "image", None) is not None
|
||||
and data_name_matches(node.image.name, color_name)
|
||||
for node in node_tree.nodes
|
||||
)
|
||||
return has_texture_layer and has_color_image
|
||||
|
||||
|
||||
def marked_balloon_material(source_image, normal_required=False):
|
||||
return next(
|
||||
@@ -194,30 +176,8 @@ def mark_balloon_material(material, source_image, normal_image=None):
|
||||
material[BALLOON_NORMAL_IMAGE_PROPERTY] = normal_image
|
||||
|
||||
|
||||
def reusable_balloon_material(
|
||||
material_name,
|
||||
color_name,
|
||||
source_image=None,
|
||||
normal_required=False,
|
||||
):
|
||||
marked = (
|
||||
marked_balloon_material(source_image, normal_required)
|
||||
if source_image is not None
|
||||
else None
|
||||
)
|
||||
if marked is not None:
|
||||
return marked
|
||||
if normal_required:
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
material
|
||||
for material in bpy.data.materials
|
||||
if data_name_matches(material.name, material_name)
|
||||
and material_uses_balloon_texture(material, color_name)
|
||||
),
|
||||
None,
|
||||
)
|
||||
def reusable_balloon_material(source_image, normal_required=False):
|
||||
return marked_balloon_material(source_image, normal_required)
|
||||
|
||||
|
||||
def load_balloon_normal_image(path, source_object):
|
||||
@@ -331,8 +291,6 @@ def extract_balloon_object(
|
||||
uses_normal = normal_path is not None
|
||||
material = (
|
||||
reusable_balloon_material(
|
||||
material_name,
|
||||
color_name,
|
||||
image,
|
||||
normal_required=uses_normal,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
import bpy
|
||||
|
||||
from .image import extract_frame_range
|
||||
from ..preferences import ADDON_PACKAGE, addon_preferences
|
||||
from ..runtime import runtime
|
||||
from ..properties import ai_ready, model_ready, model_record
|
||||
|
||||
@@ -13,7 +14,6 @@ BEN2_VIDEO_SUFFIXES = {
|
||||
".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".mts", ".mpg", ".mpeg"
|
||||
}
|
||||
HIDDEN = {"HIDDEN", "SKIP_SAVE"}
|
||||
ADDON_PACKAGE = __package__.partition(".")[0]
|
||||
|
||||
|
||||
def input_path(value):
|
||||
@@ -40,8 +40,7 @@ def source_start_frame(source_object_name):
|
||||
|
||||
|
||||
def production_device():
|
||||
addon = bpy.context.preferences.addons.get(ADDON_PACKAGE)
|
||||
return getattr(getattr(addon, "preferences", None), "device", "AUTO")
|
||||
return getattr(addon_preferences(), "device", "AUTO")
|
||||
|
||||
|
||||
def require_environment():
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import bpy
|
||||
|
||||
from ..runtime import JobOperatorBase
|
||||
from ..properties import (
|
||||
BEN2_MODEL_KEY,
|
||||
REALESRGAN_MODEL_KEY,
|
||||
)
|
||||
from ..properties import BEN2_MODEL_KEY, REALESRGAN_GENERAL_WDN_MODEL_KEY
|
||||
from .common import (
|
||||
background_input_path,
|
||||
da3_parameters,
|
||||
@@ -94,19 +91,24 @@ class DebugBen2(JobOperatorBase, bpy.types.Operator):
|
||||
return f"Removed background to {result.directory}"
|
||||
|
||||
|
||||
class DebugRealesrganGeneralX4v3(JobOperatorBase, bpy.types.Operator):
|
||||
bl_idname = "anyimage.debug_realesrgan_general_x4v3"
|
||||
bl_label = "Debug Real-ESRGAN General x4v3"
|
||||
job_type = "debug-realesrgan-general-x4v3"
|
||||
starting_message = "Starting Real-ESRGAN General x4v3 inference"
|
||||
class DebugUpscale(JobOperatorBase, bpy.types.Operator):
|
||||
bl_idname = "anyimage.debug_upscale"
|
||||
bl_label = "Debug Upscale"
|
||||
job_type = "debug-upscale"
|
||||
starting_message = "Starting Upscale inference"
|
||||
|
||||
model: bpy.props.StringProperty(
|
||||
default=REALESRGAN_GENERAL_WDN_MODEL_KEY,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
|
||||
def request(self, context):
|
||||
require_environment()
|
||||
settings = context.scene.anyimage_settings
|
||||
require_model(REALESRGAN_MODEL_KEY)
|
||||
require_model(self.model)
|
||||
return {
|
||||
"input": str(input_path(settings.input_path)),
|
||||
"model": REALESRGAN_MODEL_KEY,
|
||||
"model": self.model,
|
||||
"device": settings.device.lower(),
|
||||
"video_frames": settings.video_frames,
|
||||
"start_frame": 1,
|
||||
@@ -114,5 +116,4 @@ class DebugRealesrganGeneralX4v3(JobOperatorBase, bpy.types.Operator):
|
||||
|
||||
def response(self, _context, result):
|
||||
open_job_directory(result.directory)
|
||||
return f"Saved Real-ESRGAN API prediction to {result.directory}"
|
||||
|
||||
return f"Saved upscale API prediction to {result.directory}"
|
||||
|
||||
@@ -267,10 +267,10 @@ def build_image_depth_plane(
|
||||
frames_image = create_sequence_image(
|
||||
depth_data_name(source_object),
|
||||
depth_frames_directory,
|
||||
frame_prefix="color.",
|
||||
frame_prefix="image.",
|
||||
)
|
||||
frame_count = len(
|
||||
sorted_frame_paths(depth_frames_directory, "color.")
|
||||
sorted_frame_paths(depth_frames_directory, "image.")
|
||||
)
|
||||
(
|
||||
source_frame_start,
|
||||
|
||||
@@ -9,7 +9,7 @@ INTRINSICS_KEY_CANDIDATES = ("intrinsics", "ixts")
|
||||
def create_depth_sequence_image(
|
||||
name,
|
||||
depth_frames,
|
||||
scene,
|
||||
_scene,
|
||||
frame_directory,
|
||||
frame_prefix="depth",
|
||||
):
|
||||
@@ -27,11 +27,10 @@ def create_depth_sequence_image(
|
||||
float_buffer=True,
|
||||
)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
image_settings = scene.render.image_settings
|
||||
original_file_format = image_settings.file_format
|
||||
original_file_format = image.file_format
|
||||
frame_paths = []
|
||||
try:
|
||||
image_settings.file_format = "OPEN_EXR"
|
||||
image.file_format = "OPEN_EXR"
|
||||
for frame_index, depth_values in enumerate(depth_frames):
|
||||
# NPZ arrays use a top-left origin, while Blender image pixels start below.
|
||||
depth_pixels = np.flipud(depth_values)
|
||||
@@ -44,12 +43,12 @@ def create_depth_sequence_image(
|
||||
frame_path = (
|
||||
frame_directory / f"{frame_prefix}.{frame_index + 1:04d}.exr"
|
||||
)
|
||||
image.save_render(str(frame_path), scene=scene)
|
||||
image.save(filepath=str(frame_path), save_copy=True)
|
||||
if not frame_path.is_file():
|
||||
raise RuntimeError(f"Blender did not write: {frame_path}")
|
||||
frame_paths.append(frame_path)
|
||||
finally:
|
||||
image_settings.file_format = original_file_format
|
||||
image.file_format = original_file_format
|
||||
bpy.data.images.remove(image, do_unlink=True)
|
||||
if not frame_paths:
|
||||
raise RuntimeError("Unable to write the depth frame sequence")
|
||||
|
||||
@@ -155,8 +155,8 @@ def copy_depth_image(source_object):
|
||||
return depth_image
|
||||
|
||||
|
||||
def color_data_name(source_object):
|
||||
return f"{image_base_name(source_object.data)}.color"
|
||||
def color_data_name(source_object, suffix=".color"):
|
||||
return f"{image_base_name(source_object.data)}{suffix}"
|
||||
|
||||
|
||||
def material_data_name(source_object):
|
||||
@@ -204,18 +204,17 @@ def draw_process_resolution(layout, owner, image):
|
||||
)
|
||||
|
||||
|
||||
def save_packed_image(image, scene):
|
||||
def save_packed_image(image, _scene):
|
||||
output_path = Path(tempfile.mkdtemp(prefix="anyimage-input-")) / "input.png"
|
||||
image_settings = scene.render.image_settings
|
||||
original_file_format = image_settings.file_format
|
||||
original_file_format = image.file_format
|
||||
try:
|
||||
image_settings.file_format = "PNG"
|
||||
image.save_render(str(output_path), scene=scene)
|
||||
image.file_format = "PNG"
|
||||
image.save(filepath=str(output_path), save_copy=True)
|
||||
except (OSError, RuntimeError) as error:
|
||||
output_path.unlink(missing_ok=True)
|
||||
raise RuntimeError("Unable to export the packed image") from error
|
||||
finally:
|
||||
image_settings.file_format = original_file_format
|
||||
image.file_format = original_file_format
|
||||
if not output_path.is_file():
|
||||
raise RuntimeError("Blender did not export the packed image")
|
||||
return output_path
|
||||
@@ -377,14 +376,17 @@ def create_color_image(
|
||||
source_object,
|
||||
output_directory,
|
||||
assign_to_source=True,
|
||||
*,
|
||||
frame_prefix="image.",
|
||||
):
|
||||
output_directory = Path(output_directory)
|
||||
color_paths = sorted_frame_paths(output_directory, "color.")
|
||||
color_paths = sorted_frame_paths(output_directory, frame_prefix)
|
||||
if not color_paths:
|
||||
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:
|
||||
result_image = bpy.data.images.load(
|
||||
@@ -392,16 +394,17 @@ def create_color_image(
|
||||
check_existing=False,
|
||||
)
|
||||
result_image.alpha_mode = "CHANNEL_PACKED"
|
||||
result_image.colorspace_settings.name = source_color_space
|
||||
if animated:
|
||||
force_sequence_source(result_image)
|
||||
except (OSError, RuntimeError) as error:
|
||||
except (OSError, RuntimeError, TypeError, ValueError) as error:
|
||||
if "result_image" in locals():
|
||||
bpy.data.images.remove(result_image, do_unlink=True)
|
||||
raise RuntimeError("Unable to load the color result") from error
|
||||
|
||||
original_fake_user = source_image.use_fake_user
|
||||
try:
|
||||
result_image.name = color_data_name(source_object)
|
||||
result_name = color_data_name(source_object)
|
||||
if animated:
|
||||
pack_sequence_if_possible(result_image, len(color_paths))
|
||||
else:
|
||||
@@ -427,6 +430,7 @@ def create_color_image(
|
||||
source_image.use_fake_user = original_fake_user
|
||||
bpy.data.images.remove(result_image, do_unlink=True)
|
||||
raise RuntimeError("Unable to prepare the color result") from error
|
||||
result_image.name = result_name
|
||||
return result_image
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,15 @@ from ..common import production_device, require_environment, require_model
|
||||
from ..image import image_base_name, is_image_empty, plane_bounds
|
||||
|
||||
|
||||
class ImageEditWarning(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def report_image_edit_exception(operator, error):
|
||||
level = "WARNING" if isinstance(error, ImageEditWarning) else "ERROR"
|
||||
operator.report({level}, str(error))
|
||||
|
||||
|
||||
def image_edit_poll(context):
|
||||
return context.area is not None and context.area.type == "VIEW_3D"
|
||||
|
||||
@@ -94,7 +103,7 @@ def crop_pixels_to_alpha(pixels, bounds):
|
||||
)
|
||||
visible_y, visible_x = np.nonzero(rgba[:, :, 3] > 0.0)
|
||||
if not len(visible_x):
|
||||
raise ValueError("The edit contains no visible image pixels")
|
||||
raise ImageEditWarning("The edit contains no visible image pixels")
|
||||
content_left = int(visible_x.min())
|
||||
content_top = int(visible_y.min())
|
||||
content_right = int(visible_x.max()) + 1
|
||||
@@ -108,10 +117,10 @@ def crop_pixels_to_alpha(pixels, bounds):
|
||||
)
|
||||
|
||||
|
||||
def align_empty_display_to_bounds(
|
||||
def empty_display_alignment(
|
||||
source_object, source_size, output_size, placement_bounds
|
||||
):
|
||||
"""Place a new image canvas without changing the Empty transform."""
|
||||
"""Calculate image canvas placement without changing the Empty transform."""
|
||||
source_width, source_height = (float(value) for value in source_size)
|
||||
output_width, output_height = (float(value) for value in output_size)
|
||||
left, top, right, bottom = (float(value) for value in placement_bounds)
|
||||
@@ -134,21 +143,36 @@ def align_empty_display_to_bounds(
|
||||
display_height = display_size
|
||||
center_x = (desired_x_min + desired_x_max) * 0.5
|
||||
center_y = (desired_y_min + desired_y_max) * 0.5
|
||||
source_object.empty_display_size = display_size
|
||||
source_object.empty_image_offset = (
|
||||
return display_size, (
|
||||
center_x / display_width - 0.5,
|
||||
center_y / display_height - 0.5,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def write_image_pixels(image, pixels):
|
||||
image.alpha_mode = "STRAIGHT"
|
||||
image.pixels.foreach_set(pixels)
|
||||
image.pack()
|
||||
image.update()
|
||||
|
||||
|
||||
def replace_empty_image_pixels(
|
||||
source_object, pixels, output_size, placement_bounds, suffix=".edit"
|
||||
source_object, pixels, output_size, placement_bounds, suffix=""
|
||||
):
|
||||
source_image = source_object.data
|
||||
source_size = tuple(source_image.size)
|
||||
output_width, output_height = (int(value) for value in output_size)
|
||||
if output_width <= 0 or output_height <= 0:
|
||||
raise ValueError("The edited image dimensions must be positive")
|
||||
if len(pixels) != output_width * output_height * 4:
|
||||
raise ValueError("The edited pixels do not match the image dimensions")
|
||||
display_size, image_offset = empty_display_alignment(
|
||||
source_object,
|
||||
source_size,
|
||||
(output_width, output_height),
|
||||
placement_bounds,
|
||||
)
|
||||
image = bpy.data.images.new(
|
||||
f"{image_base_name(source_image)}{suffix}",
|
||||
width=output_width,
|
||||
@@ -157,20 +181,17 @@ def replace_empty_image_pixels(
|
||||
)
|
||||
try:
|
||||
image.colorspace_settings.name = source_image.colorspace_settings.name
|
||||
image.alpha_mode = "STRAIGHT"
|
||||
image.pixels.foreach_set(pixels)
|
||||
image.pack()
|
||||
image.update()
|
||||
align_empty_display_to_bounds(
|
||||
source_object,
|
||||
source_size,
|
||||
(output_width, output_height),
|
||||
placement_bounds,
|
||||
)
|
||||
source_object.data = image
|
||||
write_image_pixels(image, pixels)
|
||||
except Exception:
|
||||
bpy.data.images.remove(image, do_unlink=True)
|
||||
raise
|
||||
source_name = source_image.name
|
||||
source_object.data = image
|
||||
if not source_image.use_fake_user and int(source_image.users) == 0:
|
||||
bpy.data.images.remove(source_image)
|
||||
image.name = source_name
|
||||
source_object.empty_display_size = display_size
|
||||
source_object.empty_image_offset = image_offset
|
||||
return image
|
||||
|
||||
|
||||
|
||||
@@ -30,10 +30,12 @@ from .common import (
|
||||
draw_dashed_line,
|
||||
draw_image_mask_operator_settings,
|
||||
draw_polygon_fill,
|
||||
ImageEditWarning,
|
||||
image_edit_poll,
|
||||
image_mask_refine_request,
|
||||
polygon_bounds,
|
||||
replace_empty_image_pixels,
|
||||
report_image_edit_exception,
|
||||
resolve_image_edit_click,
|
||||
)
|
||||
|
||||
@@ -141,7 +143,6 @@ def edit_image_object(source_object, selection_mask, invert=False):
|
||||
pixels,
|
||||
(right - left, bottom - top),
|
||||
crop_bounds,
|
||||
suffix=".mask",
|
||||
)
|
||||
|
||||
|
||||
@@ -389,7 +390,8 @@ class EditImageMask(bpy.types.Operator):
|
||||
else:
|
||||
path = self._screen_polygon()
|
||||
if len(path) < 3:
|
||||
raise ValueError("Draw a larger image region")
|
||||
self._finish(context)
|
||||
return {"CANCELLED"}
|
||||
polygon = screen_path_to_image_pixels(
|
||||
path,
|
||||
context.region,
|
||||
@@ -444,7 +446,7 @@ class EditImageMask(bpy.types.Operator):
|
||||
try:
|
||||
return self._complete(context)
|
||||
except (RuntimeError, TypeError, ValueError) as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
report_image_edit_exception(self, error)
|
||||
self._finish(context)
|
||||
return {"CANCELLED"}
|
||||
return {"PASS_THROUGH"}
|
||||
@@ -482,7 +484,7 @@ class EditImageMask(bpy.types.Operator):
|
||||
try:
|
||||
return self._complete(context)
|
||||
except (RuntimeError, TypeError, ValueError) as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
report_image_edit_exception(self, error)
|
||||
self._finish(context)
|
||||
return {"CANCELLED"}
|
||||
|
||||
@@ -518,7 +520,7 @@ class EditImageMask(bpy.types.Operator):
|
||||
self.invert,
|
||||
)
|
||||
except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
report_image_edit_exception(self, error)
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -559,7 +561,11 @@ class RefineImageMask(JobOperatorBase, bpy.types.Operator):
|
||||
)
|
||||
finally:
|
||||
bpy.data.images.remove(refined_image, do_unlink=True)
|
||||
edit_image_object(source_object, mask, self.invert)
|
||||
try:
|
||||
edit_image_object(source_object, mask, self.invert)
|
||||
except ImageEditWarning as error:
|
||||
self.report({"WARNING"}, str(error))
|
||||
return str(error)
|
||||
push_undo("Edit Image Mask")
|
||||
return "Image selection refined"
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from .common import (
|
||||
image_edit_poll,
|
||||
polygon_bounds,
|
||||
replace_empty_image_pixels,
|
||||
report_image_edit_exception,
|
||||
resolve_image_edit_click,
|
||||
)
|
||||
|
||||
@@ -484,7 +485,6 @@ def create_perspective_result(
|
||||
pixels,
|
||||
output_size,
|
||||
placement_bounds,
|
||||
suffix=".perspective",
|
||||
)
|
||||
|
||||
|
||||
@@ -592,9 +592,14 @@ class EditImagePerspective(bpy.types.Operator):
|
||||
context.area.tag_redraw()
|
||||
|
||||
def _remove_preview(self):
|
||||
if self._preview_image is not None:
|
||||
bpy.data.images.remove(self._preview_image, do_unlink=True)
|
||||
self._preview_image = None
|
||||
preview_image = self._preview_image
|
||||
self._preview_image = None
|
||||
if preview_image is None:
|
||||
return
|
||||
try:
|
||||
bpy.data.images.remove(preview_image, do_unlink=True)
|
||||
except ReferenceError:
|
||||
pass
|
||||
|
||||
def _finish(self, context):
|
||||
if self._handle is not None:
|
||||
@@ -771,7 +776,7 @@ class EditImagePerspective(bpy.types.Operator):
|
||||
self._phase = "ASPECT"
|
||||
self._cursor = None
|
||||
except (RuntimeError, TypeError, ValueError) as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
report_image_edit_exception(self, error)
|
||||
self._finish(context)
|
||||
return {"CANCELLED"}
|
||||
self._set_aspect_status(context)
|
||||
@@ -825,7 +830,7 @@ class EditImagePerspective(bpy.types.Operator):
|
||||
TypeError,
|
||||
ValueError,
|
||||
) as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
report_image_edit_exception(self, error)
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ class SetupAIEnvironment(bpy.types.Operator):
|
||||
)
|
||||
layout.label(
|
||||
text=(
|
||||
"It also downloads MoGe-2 ViT-S Normal and BEN2 Base "
|
||||
"It also downloads MoGe-2 ViT-S Normal, BEN2 Base and "
|
||||
"Real-ESRGAN General WDN x4v3 "
|
||||
f"({format_download_size(required_size)})."
|
||||
)
|
||||
)
|
||||
|
||||
158
src/anyimage/operators/upscale.py
Normal file
158
src/anyimage/operators/upscale.py
Normal file
@@ -0,0 +1,158 @@
|
||||
import bpy
|
||||
|
||||
from ..preferences import (
|
||||
DEFAULT_UPSCALE_MAX_OUTPUT_SIZE,
|
||||
MAX_UPSCALE_OUTPUT_SIZE,
|
||||
)
|
||||
from ..properties import (
|
||||
configured_upscale_max_output_size,
|
||||
configured_upscale_model,
|
||||
upscale_model_label,
|
||||
)
|
||||
from ..runtime import JobOperatorBase
|
||||
from .common import (
|
||||
HIDDEN,
|
||||
cleanup_temporary_input,
|
||||
input_path,
|
||||
invoke_ai_setup_if_needed,
|
||||
production_device,
|
||||
require_environment,
|
||||
require_model,
|
||||
source_start_frame,
|
||||
)
|
||||
from .image import (
|
||||
create_color_image,
|
||||
default_extract_frames,
|
||||
image_base_name,
|
||||
is_image_empty,
|
||||
prepare_image_input,
|
||||
video_total_frames,
|
||||
)
|
||||
|
||||
|
||||
class UpscaleImage(JobOperatorBase, bpy.types.Operator):
|
||||
bl_idname = "anyimage.upscale_image"
|
||||
bl_label = "Upscale"
|
||||
bl_description = "Upscale this Image Empty by 4x with an AI model"
|
||||
bl_options = {"UNDO"}
|
||||
job_type = "upscale-image"
|
||||
starting_message = "Starting Image Empty upscale"
|
||||
|
||||
model: bpy.props.StringProperty(
|
||||
default="REALESRGAN_GENERAL_WDN_X4V3",
|
||||
options=HIDDEN,
|
||||
)
|
||||
max_output_size: bpy.props.IntProperty(
|
||||
name="Max Output Size",
|
||||
description=(
|
||||
"Maximum output long edge; larger inputs are resized before 4x "
|
||||
"upscaling to keep memory use bounded"
|
||||
),
|
||||
default=DEFAULT_UPSCALE_MAX_OUTPUT_SIZE,
|
||||
min=1024,
|
||||
max=MAX_UPSCALE_OUTPUT_SIZE,
|
||||
step=256,
|
||||
subtype="PIXEL",
|
||||
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
|
||||
|
||||
return is_image_empty(context.object) and not runtime.server_busy()
|
||||
|
||||
def invoke(self, context, _event):
|
||||
setup_result = invoke_ai_setup_if_needed()
|
||||
if setup_result is not None:
|
||||
return setup_result
|
||||
return self.execute(context)
|
||||
|
||||
def execute(self, context):
|
||||
self.model = configured_upscale_model(context)
|
||||
self.max_output_size = configured_upscale_max_output_size(context)
|
||||
source_object = context.object
|
||||
if not is_image_empty(source_object):
|
||||
self.report({"ERROR"}, "Select an Image Empty to upscale")
|
||||
return {"CANCELLED"}
|
||||
current_image = source_object.data
|
||||
try:
|
||||
original_image = getattr(
|
||||
current_image,
|
||||
"anyimage_upscale_source",
|
||||
None,
|
||||
)
|
||||
except ReferenceError:
|
||||
original_image = None
|
||||
source_image = original_image or current_image
|
||||
self.source_image_name = source_image.name
|
||||
self._rebuild_from_original = source_image is not current_image
|
||||
if self._rebuild_from_original:
|
||||
self.report(
|
||||
{"WARNING"},
|
||||
"This image was already upscaled; rebuilding from the original",
|
||||
)
|
||||
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,
|
||||
)
|
||||
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_temporary_input(self.input_path, self.delete_input)
|
||||
return result
|
||||
|
||||
def request(self, context):
|
||||
require_environment()
|
||||
require_model(self.model)
|
||||
settings = context.scene.anyimage_settings
|
||||
return {
|
||||
"input": str(input_path(self.input_path)),
|
||||
"model": self.model,
|
||||
"device": production_device().lower(),
|
||||
"video_frames": settings.video_frames,
|
||||
"start_frame": source_start_frame(self.source_object_name),
|
||||
"max_output_size": int(self.max_output_size),
|
||||
}
|
||||
|
||||
def response(self, _context, result):
|
||||
source_object = bpy.data.objects.get(self.source_object_name)
|
||||
if not is_image_empty(source_object):
|
||||
raise RuntimeError("The source image empty is no longer available")
|
||||
source_image = bpy.data.images.get(self.source_image_name)
|
||||
if source_image is None:
|
||||
raise RuntimeError("The original source image is no longer available")
|
||||
replaced_image = source_object.data
|
||||
result_image = create_color_image(
|
||||
source_object,
|
||||
result.directory,
|
||||
frame_prefix="upscale.",
|
||||
)
|
||||
if replaced_image is not source_image:
|
||||
replaced_image.use_fake_user = False
|
||||
if int(replaced_image.users) == 0:
|
||||
bpy.data.images.remove(replaced_image, do_unlink=True)
|
||||
result_image.name = f"{image_base_name(source_image)}.upscale"
|
||||
result_image.anyimage_upscale_source = source_image
|
||||
model_label = upscale_model_label(self.model)
|
||||
if getattr(self, "_rebuild_from_original", False):
|
||||
return f"Image is rebuilt from the original with {model_label}"
|
||||
return f"Image is upscaled with {model_label}"
|
||||
|
||||
def cleanup(self):
|
||||
cleanup_temporary_input(self.input_path, self.delete_input)
|
||||
Reference in New Issue
Block a user