Merge branch 'dev'

This commit is contained in:
Dowon
2024-09-02 23:14:20 +09:00
20 changed files with 455 additions and 81 deletions

View File

@@ -26,8 +26,6 @@ body:
label: Steps to reproduce label: Steps to reproduce
description: | description: |
Description of how we can reproduce this issue. Description of how we can reproduce this issue.
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
@@ -38,7 +36,7 @@ body:
attributes: attributes:
label: Console logs, from start to end. label: Console logs, from start to end.
description: | description: |
The full console log of your terminal. The FULL console log of your terminal.
placeholder: | placeholder: |
Python ... Python ...
Version: ... Version: ...

View File

@@ -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

View File

@@ -7,7 +7,7 @@ on:
jobs: jobs:
test: test:
name: test name: test
runs-on: macos-14 runs-on: macos-latest
strategy: strategy:
matrix: matrix:
python-version: python-version:
@@ -27,7 +27,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
uv pip install --system . pytest uv pip install --system ".[test]"
- name: Run tests - name: Run tests
run: pytest -v run: pytest -v

34
.github/workflows/test.yml vendored Normal file
View File

@@ -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

1
.gitignore vendored
View File

@@ -195,3 +195,4 @@ pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode # End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode
*.ipynb *.ipynb
node_modules node_modules
modules

View File

@@ -1,6 +1,8 @@
ci: ci:
autoupdate_branch: "dev" autoupdate_branch: "dev"
exclude: ^modules/
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0 rev: v4.6.0
@@ -22,7 +24,7 @@ repos:
- id: prettier - id: prettier
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.6 rev: v0.5.7
hooks: hooks:
- id: ruff - id: ruff
args: [--fix, --exit-non-zero-on-fix] args: [--fix, --exit-non-zero-on-fix]

View File

@@ -1,5 +1,14 @@
# Changelog # 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 ## 2024-08-03
- v24.8.0 - v24.8.0

View File

@@ -25,7 +25,7 @@ tasks:
update: update:
cmds: 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: update-torch:
cmds: cmds:

View File

@@ -5,6 +5,8 @@ from copy import copy
from typing import TYPE_CHECKING, Any, Union from typing import TYPE_CHECKING, Any, Union
import torch import torch
from PIL import Image
from typing_extensions import Protocol
from modules import safe from modules import safe
from modules.shared import opts 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]: 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)} return {k: v for k, v in extra_params.items() if not callable(v)}
class PPImage(Protocol):
image: Image.Image

View File

@@ -133,7 +133,7 @@ def get_table(title: str, data: dict[str, Any]) -> Table:
table.add_column("Value") table.add_column("Value")
for key, value in data.items(): for key, value in data.items():
if not isinstance(value, str): if not isinstance(value, str):
value = repr(value) value = repr(value) # noqa: PLW2901
table.add_row(key, value) table.add_row(key, value)
return table return table

View File

@@ -219,6 +219,7 @@ def one_ui_group(n: int, is_img2img: bool, webui_info: WebuiInfo):
with gr.Group(): with gr.Group():
with gr.Row(elem_id=eid("ad_toprow_prompt")): with gr.Row(elem_id=eid("ad_toprow_prompt")):
w.ad_prompt = gr.Textbox( w.ad_prompt = gr.Textbox(
value="",
label="ad_prompt" + suffix(n), label="ad_prompt" + suffix(n),
show_label=False, show_label=False,
lines=3, 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")): with gr.Row(elem_id=eid("ad_toprow_negative_prompt")):
w.ad_negative_prompt = gr.Textbox( w.ad_negative_prompt = gr.Textbox(
value="",
label="ad_negative_prompt" + suffix(n), label="ad_negative_prompt" + suffix(n),
show_label=False, show_label=False,
lines=2, 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) eid = partial(elem_id, n=n, is_img2img=is_img2img)
with gr.Group(): with gr.Group():

View File

@@ -1 +1 @@
__version__ = "24.8.0" __version__ = "24.9.0"

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from collections import UserList from collections import UserList
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum
from functools import cached_property, partial from functools import cached_property, partial
from typing import Any, Literal, NamedTuple, Optional from typing import Any, Literal, NamedTuple, Optional
@@ -262,6 +263,7 @@ BBOX_SORTBY = [
"Position (center to edge)", "Position (center to edge)",
"Area (large to small)", "Area (large to small)",
] ]
MASK_MERGE_INVERT = ["None", "Merge", "Merge and Invert"] MASK_MERGE_INVERT = ["None", "Merge", "Merge and Invert"]
_script_default = ( _script_default = (
@@ -276,3 +278,16 @@ SCRIPT_DEFAULT = ",".join(sorted(_script_default))
_builtin_script = ("soft_inpainting", "hypertile_script") _builtin_script = ("soft_inpainting", "hypertile_script")
BUILTIN_SCRIPT = ",".join(sorted(_builtin_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,
]

View File

@@ -163,7 +163,7 @@ def create_bbox_from_mask(
""" """
bboxes = [] bboxes = []
for mask in masks: for mask in masks:
mask = mask.resize(shape) mask = mask.resize(shape) # noqa: PLW2901
bbox = mask.getbbox() bbox = mask.getbbox()
if bbox is not None: if bbox is not None:
bboxes.append(list(bbox)) bboxes.append(list(bbox))

101
adetailer/opts.py Normal file
View File

@@ -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()

View File

@@ -49,7 +49,7 @@ class ControlNetExt:
models = self.external_cn.get_models() models = self.external_cn.get_models()
self.cn_models.extend(m for m in models if cn_model_regex.search(m)) 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, self,
p, p,
model: str, model: str,
@@ -78,6 +78,7 @@ class ControlNetExt:
guidance_start=guidance_start, guidance_start=guidance_start,
guidance_end=guidance_end, guidance_end=guidance_end,
pixel_perfect=True, pixel_perfect=True,
enabled=True,
) )
] ]

View File

@@ -45,7 +45,7 @@ class ControlNetExt:
def init_controlnet(self): def init_controlnet(self):
self.cn_available = True self.cn_available = True
def update_scripts_args( def update_scripts_args( # noqa: PLR0913
self, self,
p, p,
model: str, model: str,

View File

@@ -27,6 +27,10 @@ dynamic = ["version"]
[project.urls] [project.urls]
repository = "https://github.com/Bing-su/adetailer" repository = "https://github.com/Bing-su/adetailer"
[project.optional-dependencies]
dev = ["ruff", "pre-commit", "devtools"]
test = ["pytest", "hypothesis"]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
@@ -40,6 +44,7 @@ known_first_party = ["launch", "modules"]
[tool.ruff] [tool.ruff]
target-version = "py39" target-version = "py39"
extend-exclude = ["modules"]
[tool.ruff.lint] [tool.ruff.lint]
select = [ select = [
@@ -56,6 +61,7 @@ select = [
"N", "N",
"PD", "PD",
"PERF", "PERF",
"PL",
"PIE", "PIE",
"PT", "PT",
"PTH", "PTH",
@@ -67,7 +73,7 @@ select = [
"UP", "UP",
"W", "W",
] ]
ignore = ["B905", "E501"] ignore = ["B905", "E501", "PLR2004", "PLW0603"]
unfixable = ["F401"] unfixable = ["F401"]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]

View File

@@ -4,10 +4,10 @@ import platform
import re import re
import sys import sys
import traceback import traceback
from collections.abc import Sequence
from copy import copy from copy import copy
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from textwrap import dedent
from typing import TYPE_CHECKING, Any, NamedTuple, cast from typing import TYPE_CHECKING, Any, NamedTuple, cast
import gradio as gr import gradio as gr
@@ -17,6 +17,7 @@ from rich import print
import modules import modules
from aaaaaa.conditional import create_binary_mask, schedulers from aaaaaa.conditional import create_binary_mask, schedulers
from aaaaaa.helper import ( from aaaaaa.helper import (
PPImage,
change_torch_load, change_torch_load,
copy_extra_params, copy_extra_params,
pause_total_tqdm, pause_total_tqdm,
@@ -42,8 +43,10 @@ from adetailer import (
from adetailer.args import ( from adetailer.args import (
BBOX_SORTBY, BBOX_SORTBY,
BUILTIN_SCRIPT, BUILTIN_SCRIPT,
INPAINT_BBOX_MATCH_MODES,
SCRIPT_DEFAULT, SCRIPT_DEFAULT,
ADetailerArgs, ADetailerArgs,
InpaintBBoxMatchMode,
SkipImg2ImgOrig, SkipImg2ImgOrig,
) )
from adetailer.common import PredictOutput, ensure_pil_image, safe_mkdir from adetailer.common import PredictOutput, ensure_pil_image, safe_mkdir
@@ -55,6 +58,7 @@ from adetailer.mask import (
mask_preprocess, mask_preprocess,
sort_bboxes, sort_bboxes,
) )
from adetailer.opts import dynamic_denoise_strength, optimal_crop_size
from controlnet_ext import ( from controlnet_ext import (
CNHijackRestore, CNHijackRestore,
ControlNetExt, ControlNetExt,
@@ -172,25 +176,23 @@ class AfterDetailerScript(scripts.Script):
guidance_end=args.ad_controlnet_guidance_end, guidance_end=args.ad_controlnet_guidance_end,
) )
def is_ad_enabled(self, *args_) -> bool: def is_ad_enabled(self, *args) -> bool:
arg_list = [arg for arg in args_ if isinstance(arg, dict)] arg_list = [arg for arg in args if isinstance(arg, dict)]
if not args_ or not arg_list: if not arg_list:
message = f"""
[-] ADetailer: Invalid arguments passed to ADetailer.
input: {args_!r}
ADetailer disabled.
"""
print(dedent(message), file=sys.stderr)
return False return False
ad_enabled = args_[0] if isinstance(args_[0], bool) else True ad_enabled = args[0] if isinstance(args[0], bool) else True
pydantic_args = []
not_none = False
for arg in arg_list: for arg in arg_list:
try: try:
pydantic_args.append(ADetailerArgs(**arg)) adarg = ADetailerArgs(**arg)
except ValueError: # noqa: PERF203 except ValueError: # noqa: PERF203
continue 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 return ad_enabled and not_none
def set_skip_img2img(self, p, *args_) -> None: def set_skip_img2img(self, p, *args_) -> None:
@@ -227,9 +229,6 @@ class AfterDetailerScript(scripts.Script):
p.height = 128 p.height = 128
def get_args(self, p, *args_) -> list[ADetailerArgs]: 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)] args = [arg for arg in args_ if isinstance(arg, dict)]
if not args: if not args:
@@ -239,21 +238,21 @@ class AfterDetailerScript(scripts.Script):
if hasattr(p, "_ad_xyz"): if hasattr(p, "_ad_xyz"):
args[0] = {**args[0], **p._ad_xyz} args[0] = {**args[0], **p._ad_xyz}
all_inputs = [] all_inputs: list[ADetailerArgs] = []
for n, arg_dict in enumerate(args, 1): for n, arg_dict in enumerate(args, 1):
try: try:
inp = ADetailerArgs(**arg_dict) inp = ADetailerArgs(**arg_dict)
except ValueError as e: except ValueError:
msg = f"[-] ADetailer: ValidationError when validating {ordinal(n)} arguments" msg = f"[-] ADetailer: ValidationError when validating {ordinal(n)} arguments:"
if hasattr(e, "add_note"): print(msg, arg_dict, file=sys.stderr)
e.add_note(msg) continue
else:
print(msg, file=sys.stderr)
raise
all_inputs.append(inp) all_inputs.append(inp)
if not all_inputs:
msg = "[-] ADetailer: No valid arguments found."
raise ValueError(msg)
return all_inputs return all_inputs
def extra_params(self, arg_list: list[ADetailerArgs]) -> dict: def extra_params(self, arg_list: list[ADetailerArgs]) -> dict:
@@ -565,9 +564,14 @@ class AfterDetailerScript(scripts.Script):
seed, _ = self.get_seed(p) seed, _ = self.get_seed(p)
if opts.data.get(condition, False): 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( images.save_image(
image=image, image=image,
path=p.outpath_samples, path=ad_save_images_dir,
basename="", basename="",
seed=seed, seed=seed,
prompt=save_prompt, prompt=save_prompt,
@@ -633,7 +637,7 @@ class AfterDetailerScript(scripts.Script):
) )
@staticmethod @staticmethod
def get_i2i_init_image(p, pp): def get_i2i_init_image(p, pp: PPImage):
if is_skip_img2img(p): if is_skip_img2img(p):
return p.init_images[0] return p.init_images[0]
return pp.image return pp.image
@@ -654,6 +658,7 @@ class AfterDetailerScript(scripts.Script):
@staticmethod @staticmethod
def get_image_mask(p) -> Image.Image: def get_image_mask(p) -> Image.Image:
mask = p.image_mask mask = p.image_mask
mask = ensure_pil_image(mask, "L")
if getattr(p, "inpainting_mask_invert", False): if getattr(p, "inpainting_mask_invert", False):
mask = ImageChops.invert(mask) mask = ImageChops.invert(mask)
mask = create_binary_mask(mask) mask = create_binary_mask(mask)
@@ -668,6 +673,93 @@ class AfterDetailerScript(scripts.Script):
width, height = p.width, p.height width, height = p.width, p.height
return images.resize_image(p.resize_mode, mask, width, 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 @rich_traceback
def process(self, p, *args_): def process(self, p, *args_):
if getattr(p, "_ad_disabled", False): if getattr(p, "_ad_disabled", False):
@@ -703,7 +795,7 @@ class AfterDetailerScript(scripts.Script):
p.extra_generation_params.update(extra_params) p.extra_generation_params.update(extra_params)
def _postprocess_image_inner( def _postprocess_image_inner(
self, p, pp, args: ADetailerArgs, *, n: int = 0 self, p, pp: PPImage, args: ADetailerArgs, *, n: int = 0
) -> bool: ) -> bool:
""" """
Returns Returns
@@ -718,23 +810,23 @@ class AfterDetailerScript(scripts.Script):
i = get_i(p) i = get_i(p)
i2i = self.get_i2i_p(p, args, pp.image) i2i = self.get_i2i_p(p, args, pp.image)
seed, subseed = self.get_seed(p)
ad_prompts, ad_negatives = self.get_prompt(p, args) ad_prompts, ad_negatives = self.get_prompt(p, args)
is_mediapipe = args.is_mediapipe() is_mediapipe = args.is_mediapipe()
kwargs = {}
if is_mediapipe: if is_mediapipe:
predictor = mediapipe_predict pred = mediapipe_predict(args.ad_model, pp.image, args.ad_confidence)
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
with change_torch_load(): else:
pred = predictor(ad_model, pp.image, args.ad_confidence, **kwargs) 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: if pred.preview is None:
print( print(
@@ -768,11 +860,8 @@ class AfterDetailerScript(scripts.Script):
if re.match(r"^\s*\[SKIP\]\s*$", p2.prompt): if re.match(r"^\s*\[SKIP\]\s*$", p2.prompt):
continue continue
p2.seed = self.get_each_tab_seed(seed, j) self.fix_p2(p, p2, pp, args, pred, j)
p2.subseed = self.get_each_tab_seed(subseed, j)
p2.cached_c = [None, None]
p2.cached_uc = [None, None]
try: try:
processed = process_images(p2) processed = process_images(p2)
except NansException as e: except NansException as e:
@@ -782,6 +871,10 @@ class AfterDetailerScript(scripts.Script):
finally: finally:
p2.close() p2.close()
if not processed.images:
processed = None
break
self.compare_prompt(p.extra_generation_params, processed, n=n) self.compare_prompt(p.extra_generation_params, processed, n=n)
p2 = copy(i2i) p2 = copy(i2i)
p2.init_images = [processed.images[0]] p2.init_images = [processed.images[0]]
@@ -793,7 +886,7 @@ class AfterDetailerScript(scripts.Script):
return False return False
@rich_traceback @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_): if getattr(p, "_ad_disabled", False) or not self.is_ad_enabled(*args_):
return return
@@ -864,6 +957,16 @@ def on_ui_settings():
.needs_reload_ui(), .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( shared.opts.add_option(
"ad_save_previews", "ad_save_previews",
shared.OptionInfo(False, "Save mask previews", section=section), 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 # xyz_grid

93
tests/test_opts.py Normal file
View File

@@ -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