diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 8093934..70a7b32 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -26,8 +26,6 @@ body: label: Steps to reproduce description: | Description of how we can reproduce this issue. - validations: - required: true - type: textarea attributes: @@ -38,7 +36,7 @@ body: attributes: label: Console logs, from start to end. description: | - The full console log of your terminal. + The FULL console log of your terminal. placeholder: | Python ... Version: ... diff --git a/.github/workflows/lgtm.yml b/.github/workflows/lgtm.yml deleted file mode 100644 index 08c0fad..0000000 --- a/.github/workflows/lgtm.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Empirical Implementation of JDD - -on: - pull_request: - types: - - opened - -jobs: - lint: - permissions: - issues: write - pull-requests: write - runs-on: ubuntu-latest - - steps: - - uses: peter-evans/create-or-update-comment@v4 - with: - issue-number: ${{ github.event.pull_request.number }} - body: | - ![Imgur](https://i.imgur.com/ESow3BL.png) - - LGTM - reactions: hooray diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index fee205c..a8d10cf 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -7,7 +7,7 @@ on: jobs: test: name: test - runs-on: macos-14 + runs-on: macos-latest strategy: matrix: python-version: @@ -27,7 +27,7 @@ jobs: - name: Install dependencies run: | - uv pip install --system . pytest + uv pip install --system ".[test]" - name: Run tests run: pytest -v diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f345788 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Test on PR + +on: + pull_request: + paths: + - "adetailer/**.py" + +jobs: + test: + name: Test on python ${{ matrix.python-version }} + runs-on: macos-latest + strategy: + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - uses: yezz123/setup-uv@v4 + + - name: Install dependencies + run: | + uv pip install --system ".[test]" + + - name: Run tests + run: pytest -v diff --git a/.gitignore b/.gitignore index aefb818..009cc5d 100644 --- a/.gitignore +++ b/.gitignore @@ -195,3 +195,4 @@ pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode *.ipynb node_modules +modules diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 667ab5d..6d11532 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,8 @@ ci: autoupdate_branch: "dev" +exclude: ^modules/ + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 @@ -22,7 +24,7 @@ repos: - id: prettier - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.6 + rev: v0.5.7 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/CHANGELOG.md b/CHANGELOG.md index 2095da5..b70e4e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2024-09-02 + +- v24.9.0 +- Dynamic Denoising, Inpaint bbox sizing 기능 (PR #678) +- `ad_save_images_dir` 옵션 추가 - ad 이미지를 저장하는 장소 지정 (PR #689) + +- forge와 관련된 버그 몇 개 수정 +- pydantic validation에 실패해도 에러를 일으키지 않고 넘어가도록 수정 + ## 2024-08-03 - v24.8.0 diff --git a/Taskfile.yml b/Taskfile.yml index 03a4e8a..834d82e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -25,7 +25,7 @@ tasks: update: cmds: - - "{{.PYTHON}} -m uv pip install -U ultralytics mediapipe ruff pre-commit black devtools pytest" + - "{{.PYTHON}} -m uv pip install -U ultralytics mediapipe ruff pre-commit black devtools pytest hypothesis" update-torch: cmds: diff --git a/aaaaaa/helper.py b/aaaaaa/helper.py index c9c50c9..ae19003 100644 --- a/aaaaaa/helper.py +++ b/aaaaaa/helper.py @@ -5,6 +5,8 @@ from copy import copy from typing import TYPE_CHECKING, Any, Union import torch +from PIL import Image +from typing_extensions import Protocol from modules import safe from modules.shared import opts @@ -57,3 +59,7 @@ def preserve_prompts(p: PT): def copy_extra_params(extra_params: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in extra_params.items() if not callable(v)} + + +class PPImage(Protocol): + image: Image.Image diff --git a/aaaaaa/traceback.py b/aaaaaa/traceback.py index 3f7e44a..4119a9b 100644 --- a/aaaaaa/traceback.py +++ b/aaaaaa/traceback.py @@ -133,7 +133,7 @@ def get_table(title: str, data: dict[str, Any]) -> Table: table.add_column("Value") for key, value in data.items(): if not isinstance(value, str): - value = repr(value) + value = repr(value) # noqa: PLW2901 table.add_row(key, value) return table diff --git a/aaaaaa/ui.py b/aaaaaa/ui.py index e4efb54..4b9b484 100644 --- a/aaaaaa/ui.py +++ b/aaaaaa/ui.py @@ -219,6 +219,7 @@ def one_ui_group(n: int, is_img2img: bool, webui_info: WebuiInfo): with gr.Group(): with gr.Row(elem_id=eid("ad_toprow_prompt")): w.ad_prompt = gr.Textbox( + value="", label="ad_prompt" + suffix(n), show_label=False, lines=3, @@ -230,6 +231,7 @@ def one_ui_group(n: int, is_img2img: bool, webui_info: WebuiInfo): with gr.Row(elem_id=eid("ad_toprow_negative_prompt")): w.ad_negative_prompt = gr.Textbox( + value="", label="ad_negative_prompt" + suffix(n), show_label=False, lines=2, @@ -369,7 +371,7 @@ def mask_preprocessing(w: Widgets, n: int, is_img2img: bool): ) -def inpainting(w: Widgets, n: int, is_img2img: bool, webui_info: WebuiInfo): +def inpainting(w: Widgets, n: int, is_img2img: bool, webui_info: WebuiInfo): # noqa: PLR0915 eid = partial(elem_id, n=n, is_img2img=is_img2img) with gr.Group(): diff --git a/adetailer/__version__.py b/adetailer/__version__.py index 45adada..ca0cfd9 100644 --- a/adetailer/__version__.py +++ b/adetailer/__version__.py @@ -1 +1 @@ -__version__ = "24.8.0" +__version__ = "24.9.0" diff --git a/adetailer/args.py b/adetailer/args.py index ebfd2c0..4efcdd7 100644 --- a/adetailer/args.py +++ b/adetailer/args.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections import UserList from dataclasses import dataclass +from enum import Enum from functools import cached_property, partial from typing import Any, Literal, NamedTuple, Optional @@ -262,6 +263,7 @@ BBOX_SORTBY = [ "Position (center to edge)", "Area (large to small)", ] + MASK_MERGE_INVERT = ["None", "Merge", "Merge and Invert"] _script_default = ( @@ -276,3 +278,16 @@ SCRIPT_DEFAULT = ",".join(sorted(_script_default)) _builtin_script = ("soft_inpainting", "hypertile_script") BUILTIN_SCRIPT = ",".join(sorted(_builtin_script)) + + +class InpaintBBoxMatchMode(Enum): + OFF = "Off" + STRICT = "Strict (SDXL only)" + FREE = "Free" + + +INPAINT_BBOX_MATCH_MODES = [ + InpaintBBoxMatchMode.OFF.value, + InpaintBBoxMatchMode.STRICT.value, + InpaintBBoxMatchMode.FREE.value, +] diff --git a/adetailer/common.py b/adetailer/common.py index f9e42fc..0a4fb7a 100644 --- a/adetailer/common.py +++ b/adetailer/common.py @@ -163,7 +163,7 @@ def create_bbox_from_mask( """ bboxes = [] for mask in masks: - mask = mask.resize(shape) + mask = mask.resize(shape) # noqa: PLW2901 bbox = mask.getbbox() if bbox is not None: bboxes.append(list(bbox)) diff --git a/adetailer/opts.py b/adetailer/opts.py new file mode 100644 index 0000000..a355eac --- /dev/null +++ b/adetailer/opts.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import ClassVar, TypeVar + +import numpy as np + +T = TypeVar("T", int, float) + + +def dynamic_denoise_strength( + denoise_power: float, + denoise_strength: float, + bbox: Sequence[T], + image_size: tuple[int, int], +) -> float: + if len(bbox) != 4: + msg = f"bbox length must be 4, got {len(bbox)}" + raise ValueError(msg) + + if np.isclose(denoise_power, 0.0) or len(bbox) != 4: + return denoise_strength + + width, height = image_size + + image_pixels = width * height + bbox_pixels = (bbox[2] - bbox[0]) * (bbox[3] - bbox[1]) + + normalized_area = bbox_pixels / image_pixels + denoise_modifier = (1.0 - normalized_area) ** denoise_power + + return denoise_strength * denoise_modifier + + +class _OptimalCropSize: + sdxl_res: ClassVar[list[tuple[int, int]]] = [ + (1024, 1024), + (1152, 896), + (896, 1152), + (1216, 832), + (832, 1216), + (1344, 768), + (768, 1344), + (1536, 640), + (640, 1536), + ] + + def sdxl( + self, inpaint_width: int, inpaint_height: int, bbox: Sequence[T] + ) -> tuple[int, int]: + if len(bbox) != 4: + msg = f"bbox length must be 4, got {len(bbox)}" + raise ValueError(msg) + + bbox_width = bbox[2] - bbox[0] + bbox_height = bbox[3] - bbox[1] + bbox_aspect_ratio = bbox_width / bbox_height + + resolutions = [ + res + for res in self.sdxl_res + if (res[0] >= bbox_width and res[1] >= bbox_height) + and (res[0] >= inpaint_width or res[1] >= inpaint_height) + ] + + if not resolutions: + return inpaint_width, inpaint_height + + return min( + resolutions, + key=lambda res: abs((res[0] / res[1]) - bbox_aspect_ratio), + ) + + def free( + self, inpaint_width: int, inpaint_height: int, bbox: Sequence[T] + ) -> tuple[int, int]: + if len(bbox) != 4: + msg = f"bbox length must be 4, got {len(bbox)}" + raise ValueError(msg) + + bbox_width = bbox[2] - bbox[0] + bbox_height = bbox[3] - bbox[1] + bbox_aspect_ratio = bbox_width / bbox_height + + scale_size = max(inpaint_width, inpaint_height) + + if bbox_aspect_ratio > 1: + optimal_width = scale_size + optimal_height = scale_size / bbox_aspect_ratio + else: + optimal_width = scale_size * bbox_aspect_ratio + optimal_height = scale_size + + # Round up to the nearest multiple of 8 to make the dimensions friendly for upscaling/diffusion. + optimal_width = ((optimal_width + 8 - 1) // 8) * 8 + optimal_height = ((optimal_height + 8 - 1) // 8) * 8 + + return int(optimal_width), int(optimal_height) + + +optimal_crop_size = _OptimalCropSize() diff --git a/controlnet_ext/controlnet_ext.py b/controlnet_ext/controlnet_ext.py index bcf7130..e1f0724 100644 --- a/controlnet_ext/controlnet_ext.py +++ b/controlnet_ext/controlnet_ext.py @@ -49,7 +49,7 @@ class ControlNetExt: models = self.external_cn.get_models() self.cn_models.extend(m for m in models if cn_model_regex.search(m)) - def update_scripts_args( + def update_scripts_args( # noqa: PLR0913 self, p, model: str, @@ -78,6 +78,7 @@ class ControlNetExt: guidance_start=guidance_start, guidance_end=guidance_end, pixel_perfect=True, + enabled=True, ) ] diff --git a/controlnet_ext/controlnet_ext_forge.py b/controlnet_ext/controlnet_ext_forge.py index ef59e32..5fba119 100644 --- a/controlnet_ext/controlnet_ext_forge.py +++ b/controlnet_ext/controlnet_ext_forge.py @@ -45,7 +45,7 @@ class ControlNetExt: def init_controlnet(self): self.cn_available = True - def update_scripts_args( + def update_scripts_args( # noqa: PLR0913 self, p, model: str, diff --git a/pyproject.toml b/pyproject.toml index 87f609f..f1be122 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,10 @@ dynamic = ["version"] [project.urls] repository = "https://github.com/Bing-su/adetailer" +[project.optional-dependencies] +dev = ["ruff", "pre-commit", "devtools"] +test = ["pytest", "hypothesis"] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -40,6 +44,7 @@ known_first_party = ["launch", "modules"] [tool.ruff] target-version = "py39" +extend-exclude = ["modules"] [tool.ruff.lint] select = [ @@ -56,6 +61,7 @@ select = [ "N", "PD", "PERF", + "PL", "PIE", "PT", "PTH", @@ -67,7 +73,7 @@ select = [ "UP", "W", ] -ignore = ["B905", "E501"] +ignore = ["B905", "E501", "PLR2004", "PLW0603"] unfixable = ["F401"] [tool.ruff.lint.isort] diff --git a/scripts/!adetailer.py b/scripts/!adetailer.py index b655e6e..52baf15 100644 --- a/scripts/!adetailer.py +++ b/scripts/!adetailer.py @@ -4,10 +4,10 @@ import platform import re import sys import traceback +from collections.abc import Sequence from copy import copy from functools import partial from pathlib import Path -from textwrap import dedent from typing import TYPE_CHECKING, Any, NamedTuple, cast import gradio as gr @@ -17,6 +17,7 @@ from rich import print import modules from aaaaaa.conditional import create_binary_mask, schedulers from aaaaaa.helper import ( + PPImage, change_torch_load, copy_extra_params, pause_total_tqdm, @@ -42,8 +43,10 @@ from adetailer import ( from adetailer.args import ( BBOX_SORTBY, BUILTIN_SCRIPT, + INPAINT_BBOX_MATCH_MODES, SCRIPT_DEFAULT, ADetailerArgs, + InpaintBBoxMatchMode, SkipImg2ImgOrig, ) from adetailer.common import PredictOutput, ensure_pil_image, safe_mkdir @@ -55,6 +58,7 @@ from adetailer.mask import ( mask_preprocess, sort_bboxes, ) +from adetailer.opts import dynamic_denoise_strength, optimal_crop_size from controlnet_ext import ( CNHijackRestore, ControlNetExt, @@ -172,25 +176,23 @@ class AfterDetailerScript(scripts.Script): guidance_end=args.ad_controlnet_guidance_end, ) - def is_ad_enabled(self, *args_) -> bool: - arg_list = [arg for arg in args_ if isinstance(arg, dict)] - if not args_ or not arg_list: - message = f""" - [-] ADetailer: Invalid arguments passed to ADetailer. - input: {args_!r} - ADetailer disabled. - """ - print(dedent(message), file=sys.stderr) + def is_ad_enabled(self, *args) -> bool: + arg_list = [arg for arg in args if isinstance(arg, dict)] + if not arg_list: return False - ad_enabled = args_[0] if isinstance(args_[0], bool) else True - pydantic_args = [] + ad_enabled = args[0] if isinstance(args[0], bool) else True + + not_none = False for arg in arg_list: try: - pydantic_args.append(ADetailerArgs(**arg)) + adarg = ADetailerArgs(**arg) except ValueError: # noqa: PERF203 continue - not_none = not all(arg.need_skip() for arg in pydantic_args) + else: + if not adarg.need_skip(): + not_none = True + break return ad_enabled and not_none def set_skip_img2img(self, p, *args_) -> None: @@ -227,9 +229,6 @@ class AfterDetailerScript(scripts.Script): p.height = 128 def get_args(self, p, *args_) -> list[ADetailerArgs]: - """ - `args_` is at least 1 in length by `is_ad_enabled` immediately above - """ args = [arg for arg in args_ if isinstance(arg, dict)] if not args: @@ -239,21 +238,21 @@ class AfterDetailerScript(scripts.Script): if hasattr(p, "_ad_xyz"): args[0] = {**args[0], **p._ad_xyz} - all_inputs = [] + all_inputs: list[ADetailerArgs] = [] for n, arg_dict in enumerate(args, 1): try: inp = ADetailerArgs(**arg_dict) - except ValueError as e: - msg = f"[-] ADetailer: ValidationError when validating {ordinal(n)} arguments" - if hasattr(e, "add_note"): - e.add_note(msg) - else: - print(msg, file=sys.stderr) - raise + except ValueError: + msg = f"[-] ADetailer: ValidationError when validating {ordinal(n)} arguments:" + print(msg, arg_dict, file=sys.stderr) + continue all_inputs.append(inp) + if not all_inputs: + msg = "[-] ADetailer: No valid arguments found." + raise ValueError(msg) return all_inputs def extra_params(self, arg_list: list[ADetailerArgs]) -> dict: @@ -565,9 +564,14 @@ class AfterDetailerScript(scripts.Script): seed, _ = self.get_seed(p) if opts.data.get(condition, False): + ad_save_images_dir: str = opts.data.get("ad_save_images_dir", "") + + if not ad_save_images_dir.strip(): + ad_save_images_dir = p.outpath_samples + images.save_image( image=image, - path=p.outpath_samples, + path=ad_save_images_dir, basename="", seed=seed, prompt=save_prompt, @@ -633,7 +637,7 @@ class AfterDetailerScript(scripts.Script): ) @staticmethod - def get_i2i_init_image(p, pp): + def get_i2i_init_image(p, pp: PPImage): if is_skip_img2img(p): return p.init_images[0] return pp.image @@ -654,6 +658,7 @@ class AfterDetailerScript(scripts.Script): @staticmethod def get_image_mask(p) -> Image.Image: mask = p.image_mask + mask = ensure_pil_image(mask, "L") if getattr(p, "inpainting_mask_invert", False): mask = ImageChops.invert(mask) mask = create_binary_mask(mask) @@ -668,6 +673,93 @@ class AfterDetailerScript(scripts.Script): width, height = p.width, p.height return images.resize_image(p.resize_mode, mask, width, height) + @staticmethod + def get_dynamic_denoise_strength( + denoise_strength: float, bbox: Sequence[Any], image_size: tuple[int, int] + ): + denoise_power = opts.data.get("ad_dynamic_denoise_power", 0) + if denoise_power == 0: + return denoise_strength + + modified_strength = dynamic_denoise_strength( + denoise_power=denoise_power, + denoise_strength=denoise_strength, + bbox=bbox, + image_size=image_size, + ) + + print( + f"[-] ADetailer: dynamic denoising -- {denoise_strength:.2f} -> {modified_strength:.2f}" + ) + + return modified_strength + + @staticmethod + def get_optimal_crop_image_size( + inpaint_width: int, inpaint_height: int, bbox: Sequence[Any] + ) -> tuple[int, int]: + calculate_optimal_crop = opts.data.get( + "ad_match_inpaint_bbox_size", InpaintBBoxMatchMode.OFF.value + ) + + optimal_resolution: tuple[int, int] | None = None + + # Off + if calculate_optimal_crop == InpaintBBoxMatchMode.OFF.value: + return (inpaint_width, inpaint_height) + + # Strict (SDXL only) + if calculate_optimal_crop == InpaintBBoxMatchMode.STRICT.value: + if not shared.sd_model.is_sdxl: + msg = "[-] ADetailer: strict inpaint bounding box size matching is only available for SDXL. Use Free mode instead." + print(msg) + return (inpaint_width, inpaint_height) + + optimal_resolution = optimal_crop_size.sdxl( + inpaint_width, inpaint_height, bbox + ) + + # Free + elif calculate_optimal_crop == InpaintBBoxMatchMode.FREE.value: + optimal_resolution = optimal_crop_size.free( + inpaint_width, inpaint_height, bbox + ) + + if optimal_resolution is None: + msg = "[-] ADetailer: unsupported inpaint bounding box match mode. Original inpainting dimensions will be used." + print(msg) + return (inpaint_width, inpaint_height) + + # Only use optimal dimensions if they're different enough to current inpaint dimensions. + if ( + abs(optimal_resolution[0] - inpaint_width) > inpaint_width * 0.1 + or abs(optimal_resolution[1] - inpaint_height) > inpaint_height * 0.1 + ): + print( + f"[-] ADetailer: inpaint dimensions optimized -- {inpaint_width}x{inpaint_height} -> {optimal_resolution[0]}x{optimal_resolution[1]}" + ) + + return optimal_resolution + + def fix_p2( # noqa: PLR0913 + self, p, p2, pp: PPImage, args: ADetailerArgs, pred: PredictOutput, j: int + ): + seed, subseed = self.get_seed(p) + p2.seed = self.get_each_tab_seed(seed, j) + p2.subseed = self.get_each_tab_seed(subseed, j) + p2.denoising_strength = self.get_dynamic_denoise_strength( + p2.denoising_strength, pred.bboxes[j], pp.image.size + ) + + p2.cached_c = [None, None] + p2.cached_uc = [None, None] + + # Don't override user-defined dimensions. + if not args.ad_use_inpaint_width_height: + p2.width, p2.height = self.get_optimal_crop_image_size( + p2.width, p2.height, pred.bboxes[j] + ) + @rich_traceback def process(self, p, *args_): if getattr(p, "_ad_disabled", False): @@ -703,7 +795,7 @@ class AfterDetailerScript(scripts.Script): p.extra_generation_params.update(extra_params) def _postprocess_image_inner( - self, p, pp, args: ADetailerArgs, *, n: int = 0 + self, p, pp: PPImage, args: ADetailerArgs, *, n: int = 0 ) -> bool: """ Returns @@ -718,23 +810,23 @@ class AfterDetailerScript(scripts.Script): i = get_i(p) i2i = self.get_i2i_p(p, args, pp.image) - seed, subseed = self.get_seed(p) ad_prompts, ad_negatives = self.get_prompt(p, args) is_mediapipe = args.is_mediapipe() - kwargs = {} if is_mediapipe: - predictor = mediapipe_predict - ad_model = args.ad_model - else: - predictor = ultralytics_predict - ad_model = self.get_ad_model(args.ad_model) - kwargs["device"] = self.ultralytics_device - kwargs["classes"] = args.ad_model_classes + pred = mediapipe_predict(args.ad_model, pp.image, args.ad_confidence) - with change_torch_load(): - pred = predictor(ad_model, pp.image, args.ad_confidence, **kwargs) + else: + with change_torch_load(): + ad_model = self.get_ad_model(args.ad_model) + pred = ultralytics_predict( + ad_model, + image=pp.image, + confidence=args.ad_confidence, + device=self.ultralytics_device, + classes=args.ad_model_classes, + ) if pred.preview is None: print( @@ -768,11 +860,8 @@ class AfterDetailerScript(scripts.Script): if re.match(r"^\s*\[SKIP\]\s*$", p2.prompt): continue - p2.seed = self.get_each_tab_seed(seed, j) - p2.subseed = self.get_each_tab_seed(subseed, j) + self.fix_p2(p, p2, pp, args, pred, j) - p2.cached_c = [None, None] - p2.cached_uc = [None, None] try: processed = process_images(p2) except NansException as e: @@ -782,6 +871,10 @@ class AfterDetailerScript(scripts.Script): finally: p2.close() + if not processed.images: + processed = None + break + self.compare_prompt(p.extra_generation_params, processed, n=n) p2 = copy(i2i) p2.init_images = [processed.images[0]] @@ -793,7 +886,7 @@ class AfterDetailerScript(scripts.Script): return False @rich_traceback - def postprocess_image(self, p, pp, *args_): + def postprocess_image(self, p, pp: PPImage, *args_): if getattr(p, "_ad_disabled", False) or not self.is_ad_enabled(*args_): return @@ -864,6 +957,16 @@ def on_ui_settings(): .needs_reload_ui(), ) + shared.opts.add_option( + "ad_save_images_dir", + shared.OptionInfo( + default="", + label="Output directory for adetailer images", + component=gr.Textbox, + section=section, + ), + ) + shared.opts.add_option( "ad_save_previews", shared.OptionInfo(False, "Save mask previews", section=section), @@ -915,6 +1018,32 @@ def on_ui_settings(): ), ) + shared.opts.add_option( + "ad_dynamic_denoise_power", + shared.OptionInfo( + default=0, + label="Power scaling for dynamic denoise strength based on bounding box size", + component=gr.Slider, + component_args={"minimum": -10, "maximum": 10, "step": 0.01}, + section=section, + ).info( + "Smaller areas get higher denoising, larger areas less. Maximum denoise strength is set by 'Inpaint denoising strength'. 0 = disabled; 1 = linear; 2-4 = recommended" + ), + ) + + shared.opts.add_option( + "ad_match_inpaint_bbox_size", + shared.OptionInfo( + default=InpaintBBoxMatchMode.OFF.value, # Off + component=gr.Radio, + component_args={"choices": INPAINT_BBOX_MATCH_MODES}, + label="Try to match inpainting size to bounding box size, if 'Use separate width/height' is not set", + section=section, + ).info( + "Strict is for SDXL only, and matches exactly to trained SDXL resolutions. Free works with any model, but will use potentially unsupported dimensions." + ), + ) + # xyz_grid diff --git a/tests/test_opts.py b/tests/test_opts.py new file mode 100644 index 0000000..fd37b25 --- /dev/null +++ b/tests/test_opts.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import numpy as np +import pytest +from hypothesis import assume, given +from hypothesis import strategies as st + +from adetailer.opts import dynamic_denoise_strength, optimal_crop_size + + +@pytest.mark.parametrize( + ("denoise_power", "denoise_strength", "bbox", "image_size", "expected_result"), + [ + (0.001, 0.5, [0, 0, 100, 100], (200, 200), 0.4998561796520339), + (1.5, 0.3, [0, 0, 100, 100], (200, 200), 0.1948557158514987), + (-0.001, 0.7, [0, 0, 100, 100], (1000, 1000), 0.7000070352704507), + (-0.5, 0.5, [0, 0, 100, 100], (1000, 1000), 0.502518907629606), + ], +) +def test_dynamic_denoise_strength( + denoise_power: float, + denoise_strength: float, + bbox: list[int], + image_size: tuple[int, int], + expected_result: float, +): + result = dynamic_denoise_strength(denoise_power, denoise_strength, bbox, image_size) + assert np.isclose(result, expected_result) + + +@given(denoise_strength=st.floats(allow_nan=False)) +def test_dynamic_denoise_strength_no_bbox(denoise_strength: float): + with pytest.raises(ValueError, match="bbox length must be 4, got 0"): + dynamic_denoise_strength(0.5, denoise_strength, [], (1000, 1000)) + + +@given(denoise_strength=st.floats(allow_nan=False)) +def test_dynamic_denoise_strength_zero_power(denoise_strength: float): + result = dynamic_denoise_strength( + 0.0, denoise_strength, [0, 0, 100, 100], (1000, 1000) + ) + assert np.isclose(result, denoise_strength) + + +@given( + inpaint_width=st.integers(1), + inpaint_height=st.integers(1), + bbox=st.tuples( + st.integers(0, 500), + st.integers(0, 500), + st.integers(501, 1000), + st.integers(501, 1000), + ), +) +def test_optimal_crop_size_sdxl( + inpaint_width: int, inpaint_height: int, bbox: tuple[int, int, int, int] +): + bbox_width = bbox[2] - bbox[0] + bbox_height = bbox[3] - bbox[1] + assume(bbox_width > 0 and bbox_height > 0) + + result = optimal_crop_size.sdxl(inpaint_width, inpaint_height, bbox) + assert (result in optimal_crop_size.sdxl_res) or result == ( + inpaint_width, + inpaint_height, + ) + + if result != (inpaint_width, inpaint_height): + assert result[0] >= bbox_width + assert result[1] >= bbox_height + assert result[0] >= inpaint_width or result[1] >= inpaint_height + + +@given( + inpaint_width=st.integers(1), + inpaint_height=st.integers(1), + bbox=st.tuples( + st.integers(0, 500), + st.integers(0, 500), + st.integers(501, 1000), + st.integers(501, 1000), + ), +) +def test_optimal_crop_size_free( + inpaint_width: int, inpaint_height: int, bbox: tuple[int, int, int, int] +): + bbox_width = bbox[2] - bbox[0] + bbox_height = bbox[3] - bbox[1] + assume(bbox_width > 0 and bbox_height > 0) + + result = optimal_crop_size.free(inpaint_width, inpaint_height, bbox) + assert result[0] % 8 == 0 + assert result[1] % 8 == 0