diff --git a/examples/run_memory_test.py b/examples/run_memory_test.py index ee6ec2e9..b5fe800f 100644 --- a/examples/run_memory_test.py +++ b/examples/run_memory_test.py @@ -14,7 +14,6 @@ hordelib.initialise(setup_logging=False) -from hordelib.comfy_horde import cleanup from hordelib.horde import HordeLib from hordelib.settings import UserSettings from hordelib.shared_model_manager import SharedModelManager @@ -155,7 +154,7 @@ def main(): model_index += 1 how_far -= 1 # That would have pushed something to disk, force a memory cleanup - cleanup() + # cleanup() report_ram() logger.warning("Loaded all models") diff --git a/hordelib/comfy_horde.py b/hordelib/comfy_horde.py index 70fb8955..c03f6d5a 100644 --- a/hordelib/comfy_horde.py +++ b/hordelib/comfy_horde.py @@ -97,7 +97,11 @@ # isort: off -def do_comfy_import(force_normal_vram_mode: bool = False, extra_comfyui_args: list[str] | None = None) -> None: +def do_comfy_import( + force_normal_vram_mode: bool = False, + extra_comfyui_args: list[str] | None = None, + disable_smart_memory: bool = False, +) -> None: global _comfy_current_loaded_models global _comfy_load_models_gpu global _comfy_nodes, _comfy_PromptExecutor, _comfy_validate_prompt @@ -109,9 +113,9 @@ def do_comfy_import(force_normal_vram_mode: bool = False, extra_comfyui_args: li global _comfy_free_memory, _comfy_cleanup_models, _comfy_soft_empty_cache global _canny, _hed, _leres, _midas, _mlsd, _openpose, _pidinet, _uniformer - logger.info("Disabling smart memory") - - sys.argv.append("--disable-smart-memory") + if disable_smart_memory: + logger.info("Disabling smart memory") + sys.argv.append("--disable-smart-memory") if force_normal_vram_mode: logger.info("Forcing normal vram mode") @@ -222,8 +226,8 @@ def recursive_output_delete_if_changed_hijack(prompt: dict, old_prompt, outputs, return _comfy_recursive_output_delete_if_changed(prompt, old_prompt, outputs, current_item) -def cleanup(): - _comfy_soft_empty_cache() +# def cleanup(): +# _comfy_soft_empty_cache() def unload_all_models_vram(): @@ -271,17 +275,6 @@ def get_torch_free_vram_mb(): return round(_comfy_get_free_memory() / (1024 * 1024)) -def garbage_collect(): - logger.debug("Comfy_Horde garbage_collect called") - gc.collect() - if not torch.cuda.is_available(): - logger.debug("CUDA not available, skipping cuda empty cache") - return - if torch.version.cuda: - torch.cuda.empty_cache() - torch.cuda.ipc_collect() - - class Comfy_Horde: """Handles horde-specific behavior against ComfyUI.""" @@ -718,11 +711,11 @@ def _run_pipeline(self, pipeline: dict, params: dict) -> list[dict] | None: stdio.replay() - # Check if there are any resource to clean up - cleanup() - if time.time() - self._gc_timer > Comfy_Horde.GC_TIME: - self._gc_timer = time.time() - garbage_collect() + # # Check if there are any resource to clean up + # cleanup() + # if time.time() - self._gc_timer > Comfy_Horde.GC_TIME: + # self._gc_timer = time.time() + # garbage_collect() return self.images diff --git a/hordelib/horde.py b/hordelib/horde.py index 30c8fca1..adce18aa 100644 --- a/hordelib/horde.py +++ b/hordelib/horde.py @@ -319,55 +319,127 @@ def _validate_data_structure(self, data, schema_definition=PAYLOAD_SCHEMA): return data - def _apply_aihorde_compatibility_hacks(self, payload): + def _apply_aihorde_compatibility_hacks(self, payload: dict) -> tuple[dict, list[GenMetadataEntry]]: """For use by the AI Horde worker we require various counterintuitive hacks to the payload data. We encapsulate all of this implicit witchcraft in one function, here. """ + faults: list[GenMetadataEntry] = [] + + if SharedModelManager.manager.compvis is None: + raise RuntimeError("Cannot use AI Horde compatibility hacks without compvis loaded!") + payload = deepcopy(payload) - if payload.get("model"): - payload["model_name"] = payload["model"] - # Comfy expects the "model" key to be the filename - # But we are also sending the "generic" model name along in key "model_name" in order to be able - # To look it up in the model manager. - if SharedModelManager.manager.compvis.is_model_available(payload["model"]): - model_files = SharedModelManager.manager.compvis.get_model_filenames(payload["model"]) - payload["model"] = model_files[0]["file_path"] - for file_entry in model_files: - # If we have a file_type, we also add to the payload - # each file_path with the key being the file_type - # This is then defined in PAYLOAD_TO_PIPELINE_PARAMETER_MAPPING - # to be injected in the right part of the pipeline - if "file_type" in file_entry: - payload[file_entry["file_type"]] = file_entry["file_path"] - else: - post_processor_model_managers = SharedModelManager.manager.get_model_manager_instances( - [MODEL_CATEGORY_NAMES.codeformer, MODEL_CATEGORY_NAMES.esrgan, MODEL_CATEGORY_NAMES.gfpgan], - ) + model = payload.get("model") + + if model is None: + raise RuntimeError("No model specified in payload") + + # This is translated to "horde_model_name" later for compvis models and used as is for post processors + payload["model_name"] = model + + found_model_in_ref = False + found_model_on_disk = False + model_files: list[dict] = [{}] + + if model in SharedModelManager.manager.compvis.model_reference: + found_model_in_ref = True + + if SharedModelManager.manager.compvis.is_model_available(model): + model_files = SharedModelManager.manager.compvis.get_model_filenames(model) + found_model_on_disk = True + + if SharedModelManager.manager.compvis.model_reference[model].get("inpainting") is True: + if payload.get("source_processing") not in ["inpainting", "outpainting"]: + logger.warning( + "Inpainting model detected, but source processing not set to inpainting or outpainting.", + ) + + payload["source_processing"] = "inpainting" + + source_image = payload.get("source_image") + source_mask = payload.get("source_mask") + + if source_image is None or not isinstance(source_image, Image.Image): + logger.warning( + "Inpainting model detected, but source image is not a valid image. Using a noise image.", + ) + faults.append( + GenMetadataEntry( + type=METADATA_TYPE.source_image, + value=METADATA_VALUE.parse_failed, + ), + ) + payload["source_image"] = ImageUtils.create_noise_image( + payload["width"], + payload["height"], + ) - found_model = False + source_image = payload.get("source_image") - for post_processor_model_manager in post_processor_model_managers: - if post_processor_model_manager.is_model_available(payload["model"]): - model_files = post_processor_model_manager.get_model_filenames(payload["model"]) - payload["model"] = model_files[0]["file_path"] - found_model = True + if source_mask is None and ( + source_image is None + or (isinstance(source_image, Image.Image) and not ImageUtils.has_alpha_channel(source_image)) + ): + logger.warning( + "Inpainting model detected, but no source mask provided. Using an all white mask.", + ) + faults.append( + GenMetadataEntry( + type=METADATA_TYPE.source_mask, + value=METADATA_VALUE.parse_failed, + ), + ) + payload["source_mask"] = ImageUtils.create_white_image( + source_image.width if source_image else payload["width"], + source_image.height if source_image else payload["height"], + ) + + else: + # The node may be a post processor, so we check the other model managers + post_processor_model_managers = SharedModelManager.manager.get_model_manager_instances( + [MODEL_CATEGORY_NAMES.codeformer, MODEL_CATEGORY_NAMES.esrgan, MODEL_CATEGORY_NAMES.gfpgan], + ) + + for post_processor_model_manager in post_processor_model_managers: + if model in post_processor_model_manager.model_reference: + found_model_in_ref = True + if post_processor_model_manager.is_model_available(model): + model_files = post_processor_model_manager.get_model_filenames(model) + found_model_on_disk = True + break + + if not found_model_in_ref: + raise RuntimeError(f"Model {model} not found in model reference!") + + if not found_model_on_disk: + raise RuntimeError(f"Model {model} not found on disk!") + + if len(model_files) == 0 or (not isinstance(model_files[0], dict)) or "file_path" not in model_files[0]: + raise RuntimeError(f"Model {model} has no files in its reference entry!") + + payload["model"] = model_files[0]["file_path"] + for file_entry in model_files: + if "file_type" in file_entry: + payload[file_entry["file_type"]] = file_entry["file_path"] - if not found_model: - raise RuntimeError(f"Model {payload['model']} not found! Is it in a Model Reference?") # Rather than specify a scheduler, only karras or not karras is specified if payload.get("karras", False): payload["scheduler"] = "karras" else: payload["scheduler"] = "normal" + prompt = payload.get("prompt") + # Negative and positive prompts are merged together - if payload.get("prompt"): - if "###" in payload.get("prompt"): - split_prompts = payload.get("prompt").split("###") + if prompt is not None: + if "###" in prompt: + split_prompts = prompt.split("###") payload["prompt"] = split_prompts[0] payload["negative_prompt"] = split_prompts[1] + elif prompt == "": + logger.warning("Empty prompt detected, this is likely to produce poor results") # Turn off hires fix if we're not generating a hires image, or if the params are just confused try: @@ -397,9 +469,9 @@ def _apply_aihorde_compatibility_hacks(self, payload): # del payload["denoising_strength"] # else: # del payload["denoising_strength"] - return payload + return payload, faults - def _final_pipeline_adjustments(self, payload, pipeline_data): + def _final_pipeline_adjustments(self, payload, pipeline_data) -> tuple[dict, list[GenMetadataEntry]]: payload = deepcopy(payload) faults: list[GenMetadataEntry] = [] @@ -780,7 +852,7 @@ def _process_results( def _get_validated_payload_and_pipeline_data(self, payload: dict) -> tuple[dict, dict, list[GenMetadataEntry]]: # AIHorde hacks to payload - payload = self._apply_aihorde_compatibility_hacks(payload) + payload, compatibility_faults = self._apply_aihorde_compatibility_hacks(payload) # Check payload types/values and normalise it's format payload = self._validate_data_structure(payload) # Resize the source image and mask to actual final width/height requested @@ -789,8 +861,8 @@ def _get_validated_payload_and_pipeline_data(self, payload: dict) -> tuple[dict, pipeline = self._get_appropriate_pipeline(payload) # Final adjustments to the pipeline pipeline_data = self.generator.get_pipeline_data(pipeline) - payload, faults = self._final_pipeline_adjustments(payload, pipeline_data) - return payload, pipeline_data, faults + payload, finale_adjustment_faults = self._final_pipeline_adjustments(payload, pipeline_data) + return payload, pipeline_data, compatibility_faults + finale_adjustment_faults def _inference( self, @@ -985,7 +1057,7 @@ def basic_inference_rawpng(self, payload: dict) -> list[io.BytesIO]: def image_upscale(self, payload) -> ResultingImageReturn: logger.debug("image_upscale called") # AIHorde hacks to payload - payload = self._apply_aihorde_compatibility_hacks(payload) + payload, compatibility_faults = self._apply_aihorde_compatibility_hacks(payload) # Remember if we were passed width and height, we wouldn't normally be passed width and height # because the upscale models upscale to a fixed multiple of image size. However, if we *are* # passed a width and height we rescale the upscale output image to this size. @@ -996,7 +1068,7 @@ def image_upscale(self, payload) -> ResultingImageReturn: # Final adjustments to the pipeline pipeline_name = "image_upscale" pipeline_data = self.generator.get_pipeline_data(pipeline_name) - payload, faults = self._final_pipeline_adjustments(payload, pipeline_data) + payload, final_adjustment_faults = self._final_pipeline_adjustments(payload, pipeline_data) # Run the pipeline @@ -1007,7 +1079,7 @@ def image_upscale(self, payload) -> ResultingImageReturn: return ResultingImageReturn( ImageUtils.shrink_image(Image.open(images[0]["imagedata"]), width, height), rawpng=None, - faults=faults, + faults=final_adjustment_faults, ) result = self._process_results(images) if len(result) != 1: @@ -1017,18 +1089,18 @@ def image_upscale(self, payload) -> ResultingImageReturn: if not isinstance(image, Image.Image): raise RuntimeError(f"Expected a PIL.Image.Image but got {type(image)}") - return ResultingImageReturn(image=image, rawpng=rawpng, faults=faults) + return ResultingImageReturn(image=image, rawpng=rawpng, faults=compatibility_faults + final_adjustment_faults) def image_facefix(self, payload) -> ResultingImageReturn: logger.debug("image_facefix called") # AIHorde hacks to payload - payload = self._apply_aihorde_compatibility_hacks(payload) + payload, compatibility_faults = self._apply_aihorde_compatibility_hacks(payload) # Check payload types/values and normalise it's format payload = self._validate_data_structure(payload) # Final adjustments to the pipeline pipeline_name = "image_facefix" pipeline_data = self.generator.get_pipeline_data(pipeline_name) - payload, faults = self._final_pipeline_adjustments(payload, pipeline_data) + payload, final_adjustment_faults = self._final_pipeline_adjustments(payload, pipeline_data) # Run the pipeline @@ -1042,4 +1114,4 @@ def image_facefix(self, payload) -> ResultingImageReturn: if not isinstance(image, Image.Image): raise RuntimeError(f"Expected a PIL.Image.Image but got {type(image)}") - return ResultingImageReturn(image=image, rawpng=rawpng, faults=faults) + return ResultingImageReturn(image=image, rawpng=rawpng, faults=compatibility_faults + final_adjustment_faults) diff --git a/hordelib/nodes/node_image_loader.py b/hordelib/nodes/node_image_loader.py index dcf9d69b..9d6d83e9 100644 --- a/hordelib/nodes/node_image_loader.py +++ b/hordelib/nodes/node_image_loader.py @@ -1,7 +1,9 @@ # horde_image_loader.py # Load images into the pipeline from PIL, not disk import numpy as np +import PIL.Image import torch +from loguru import logger class HordeImageLoader: @@ -17,6 +19,14 @@ def INPUT_TYPES(s): FUNCTION = "load_image" def load_image(self, image): + if image is None: + logger.error("Input image is None in HordeImageLoader - this is a bug, please report it!") + raise ValueError("Input image is None in HordeImageLoader") + + if not isinstance(image, PIL.Image.Image): + logger.error(f"Input image is not a PIL Image, it is a {type(image)}") + raise ValueError(f"Input image is not a PIL Image, it is a {type(image)}") + new_image = image.convert("RGB") new_image = np.array(new_image).astype(np.float32) / 255.0 new_image = torch.from_numpy(new_image)[None,] diff --git a/hordelib/utils/image_utils.py b/hordelib/utils/image_utils.py index d5d5160b..7479ab47 100644 --- a/hordelib/utils/image_utils.py +++ b/hordelib/utils/image_utils.py @@ -1,3 +1,6 @@ +import base64 + +import numpy as np import rembg # type: ignore from loguru import logger from PIL import Image, ImageOps, PngImagePlugin, UnidentifiedImageError @@ -64,9 +67,9 @@ def resize_sources_to_request(cls, payload): ) except (UnidentifiedImageError, AttributeError): logger.warning( - "Source mask could not be parsed. Falling back to img2img without mask", + "Source mask could not be parsed. Falling back to img2img with an all alpha mask.", ) - del payload["source_mask"] + payload["source_mask"] = ImageUtils.create_alpha_image(payload["width"], payload["height"]) if payload.get("source_mask"): payload["source_image"] = cls.add_image_alpha_channel(payload["source_image"], payload["source_mask"]) @@ -123,3 +126,35 @@ def strip_background(cls, image: Image.Image): ) del session return image + + @classmethod + def create_alpha_image(cls, width: int = 512, height: int = 512): + return Image.new("L", (width, height), 255) + + @classmethod + def create_white_image(cls, width: int = 512, height: int = 512): + return Image.new("RGB", (width, height), (255, 255, 255)) + + @classmethod + def create_black_image(cls, width: int = 512, height: int = 512): + return Image.new("RGB", (width, height), (0, 0, 0)) + + @classmethod + def create_alpha_image_base64(cls, width: int = 512, height: int = 512): + alpha_image = cls.create_alpha_image(width, height) + alpha_image = alpha_image.tobytes() + return base64.b64encode(alpha_image).decode("utf-8") + + @classmethod + def has_alpha_channel(cls, image: Image.Image): + return image.mode == "RGBA" + + @classmethod + def create_noise_image(cls, width: int | None = 512, height: int | None = 512): + if width is None: + width = 512 + + if height is None: + height = 512 + + return Image.fromarray((np.random.rand(height, width, 3) * 255).astype(np.uint8)) diff --git a/images_expected/inpainting_img2img_no_image.png b/images_expected/inpainting_img2img_no_image.png new file mode 100644 index 00000000..5f03c8a6 Binary files /dev/null and b/images_expected/inpainting_img2img_no_image.png differ diff --git a/images_expected/inpainting_img2img_no_mask.png b/images_expected/inpainting_img2img_no_mask.png new file mode 100644 index 00000000..f5cb27ba Binary files /dev/null and b/images_expected/inpainting_img2img_no_mask.png differ diff --git a/images_expected/inpainting_mask_missing.png b/images_expected/inpainting_mask_missing.png new file mode 100644 index 00000000..f5cb27ba Binary files /dev/null and b/images_expected/inpainting_mask_missing.png differ diff --git a/images_expected/inpainting_no_source_image.png b/images_expected/inpainting_no_source_image.png new file mode 100644 index 00000000..2ef5b8d5 Binary files /dev/null and b/images_expected/inpainting_no_source_image.png differ diff --git a/images_expected/inpainting_txt2img.png b/images_expected/inpainting_txt2img.png new file mode 100644 index 00000000..f5cb27ba Binary files /dev/null and b/images_expected/inpainting_txt2img.png differ diff --git a/tests/test_horde_inference_painting.py b/tests/test_horde_inference_painting.py index e212cc83..ddf1d26a 100644 --- a/tests/test_horde_inference_painting.py +++ b/tests/test_horde_inference_painting.py @@ -1,6 +1,7 @@ from collections.abc import Generator import pytest +from horde_sdk.ai_horde_api.consts import METADATA_TYPE, METADATA_VALUE from PIL import Image from hordelib.horde import HordeLib @@ -231,3 +232,253 @@ def test_inpainting_n_iter( img_pairs_to_check.append((f"images_expected/{img_filename}", image_result.image)) assert check_list_inference_images_similarity(img_pairs_to_check) + + def test_inpainting_no_source_image( + self, + inpainting_model_for_testing: str, + hordelib_instance: HordeLib, + ): + data = { + "sampler_name": "euler", + "cfg_scale": 8, + "denoising_strength": 1, + "seed": 836138046008, + "height": 512, + "width": 512, + "karras": False, + "tiling": False, + "hires_fix": False, + "clip_skip": 1, + "control_type": None, + "image_is_control": False, + "return_control_map": False, + "prompt": "a dinosaur", + "ddim_steps": 20, + "n_iter": 1, + "model": inpainting_model_for_testing, + "source_processing": "inpainting", + } + result = hordelib_instance.basic_inference(data) + + assert len(result) == 1 + assert result[0].image is not None + assert len(result[0].faults) == 2 + + # Assert one of the faults is the source_image + assert any( + fault.type_ == METADATA_TYPE.source_image and fault.value == METADATA_VALUE.parse_failed + for fault in result[0].faults + ) + + # Assert one of the faults is the source_mask + assert any( + fault.type_ == METADATA_TYPE.source_mask and fault.value == METADATA_VALUE.parse_failed + for fault in result[0].faults + ) + + pil_image = result[0].image + assert pil_image is not None + assert isinstance(pil_image, Image.Image) + + img_filename = "inpainting_no_source_image.png" + pil_image.save(f"images/{img_filename}", quality=100) + + assert check_single_inference_image_similarity( + f"images_expected/{img_filename}", + pil_image, + ) + + def test_inpainting_no_mask( + self, + inpainting_model_for_testing: str, + hordelib_instance: HordeLib, + ): + data = { + "sampler_name": "euler", + "cfg_scale": 8, + "denoising_strength": 1, + "seed": 836138046008, + "height": 512, + "width": 512, + "karras": False, + "tiling": False, + "hires_fix": False, + "clip_skip": 1, + "control_type": None, + "image_is_control": False, + "return_control_map": False, + "prompt": "a dinosaur", + "ddim_steps": 20, + "n_iter": 1, + "model": inpainting_model_for_testing, + "source_image": Image.open("images/test_inpaint_original.png"), + "source_processing": "inpainting", + } + + result = hordelib_instance.basic_inference(data) + + assert len(result) == 1 + assert result[0].image is not None + assert len(result[0].faults) == 1 + + assert result[0].faults[0].type_ == METADATA_TYPE.source_mask + assert result[0].faults[0].value == METADATA_VALUE.parse_failed + + pil_image = result[0].image + assert pil_image is not None + assert isinstance(pil_image, Image.Image) + + img_filename = "inpainting_mask_missing.png" + pil_image.save(f"images/{img_filename}", quality=100) + + assert check_single_inference_image_similarity( + f"images_expected/{img_filename}", + pil_image, + ) + + def test_inpainting_set_as_txt2img( + self, + inpainting_model_for_testing: str, + hordelib_instance: HordeLib, + ): + data = { + "sampler_name": "euler", + "cfg_scale": 8, + "denoising_strength": 1, + "seed": 836138046008, + "height": 512, + "width": 512, + "karras": False, + "tiling": False, + "hires_fix": False, + "clip_skip": 1, + "control_type": None, + "image_is_control": False, + "return_control_map": False, + "prompt": "a dinosaur", + "ddim_steps": 20, + "n_iter": 1, + "model": inpainting_model_for_testing, + "source_processing": "txt2img", + } + + result = hordelib_instance.basic_inference(data) + + assert len(result) == 1 + assert result[0].image is not None + assert len(result[0].faults) == 2 + + # Assert one of the faults is the source_image + assert any( + fault.type_ == METADATA_TYPE.source_image and fault.value == METADATA_VALUE.parse_failed + for fault in result[0].faults + ) + + # Assert one of the faults is the source_mask + assert any( + fault.type_ == METADATA_TYPE.source_mask and fault.value == METADATA_VALUE.parse_failed + for fault in result[0].faults + ) + + pil_image = result[0].image + assert pil_image is not None + assert isinstance(pil_image, Image.Image) + + img_filename = "inpainting_txt2img.png" + pil_image.save(f"images/{img_filename}", quality=100) + + assert check_single_inference_image_similarity( + f"images_expected/{img_filename}", + pil_image, + ) + + def test_inpainting_set_as_img2img_no_mask( + self, + inpainting_model_for_testing: str, + hordelib_instance: HordeLib, + ): + data = { + "sampler_name": "euler", + "cfg_scale": 8, + "denoising_strength": 1, + "seed": 836138046008, + "height": 512, + "width": 512, + "karras": False, + "tiling": False, + "hires_fix": False, + "clip_skip": 1, + "control_type": None, + "image_is_control": False, + "return_control_map": False, + "prompt": "a dinosaur", + "ddim_steps": 20, + "n_iter": 1, + "model": inpainting_model_for_testing, + "source_image": Image.open("images/test_inpaint_original.png"), + "source_processing": "img2img", + } + + result = hordelib_instance.basic_inference(data) + + assert len(result) == 1 + assert result[0].image is not None + assert len(result[0].faults) == 1 + + assert result[0].faults[0].type_ == METADATA_TYPE.source_mask + assert result[0].faults[0].value == METADATA_VALUE.parse_failed + + pil_image = result[0].image + assert pil_image is not None + assert isinstance(pil_image, Image.Image) + + img_filename = "inpainting_img2img_no_mask.png" + pil_image.save(f"images/{img_filename}", quality=100) + + check_single_inference_image_similarity( + f"images_expected/{img_filename}", + pil_image, + ) + + def test_inpainting_set_as_img2img_no_image( + self, + inpainting_model_for_testing: str, + hordelib_instance: HordeLib, + ): + data = { + "sampler_name": "euler", + "cfg_scale": 8, + "denoising_strength": 1, + "seed": 836138046008, + "height": 512, + "width": 512, + "karras": False, + "tiling": False, + "hires_fix": False, + "clip_skip": 1, + "control_type": None, + "image_is_control": False, + "return_control_map": False, + "prompt": "a dinosaur", + "ddim_steps": 20, + "n_iter": 1, + "model": inpainting_model_for_testing, + "source_mask": Image.open("images/test_inpaint_mask.png"), + "source_processing": "img2img", + } + + result = hordelib_instance.basic_inference(data) + + assert len(result) == 1 + assert result[0].image is not None + assert len(result[0].faults) == 1 + + assert result[0].faults[0].type_ == METADATA_TYPE.source_image + assert result[0].faults[0].value == METADATA_VALUE.parse_failed + + pil_image = result[0].image + assert pil_image is not None + assert isinstance(pil_image, Image.Image) + + img_filename = "inpainting_img2img_no_image.png" + pil_image.save(f"images/{img_filename}", quality=100)