diff --git a/.github/workflows/lint.yml b/.github/workflows/lgtm.yml similarity index 95% rename from .github/workflows/lint.yml rename to .github/workflows/lgtm.yml index ffa7ba6..08c0fad 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lgtm.yml @@ -8,6 +8,7 @@ on: jobs: lint: permissions: + issues: write pull-requests: write runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index ce19e6c..aefb818 100644 --- a/.gitignore +++ b/.gitignore @@ -194,3 +194,4 @@ pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode *.ipynb +node_modules diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 20cd974..765a14f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,13 +16,13 @@ repos: - id: end-of-file-fixer - id: mixed-line-ending - - repo: https://github.com/pre-commit/mirrors-prettier - rev: "v4.0.0-alpha.8" + - repo: https://github.com/rbubley/mirrors-prettier + rev: v3.2.5 hooks: - id: prettier - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.7 + rev: v0.4.4 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/CHANGELOG.md b/CHANGELOG.md index 8481da1..1093ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 2024-05-19 + +- v24.5.0 +- 개별 탭 활성화/비활성화 체크박스 추가 +- ad_extra_model_dir 옵션에 |로 구분된 여러 디렉토리를 추가할 수 있게 함 (PR #596) +- `hypertile` 빌트인 확장이 지원되도록 함 +- 항상 cond 캐시를 비움 +- 설치 스크립트에 uv를 사용함 +- mediapipe 최소 버전을 올려 protobuf 버전 4를 사용하게 함 + ## 2024-04-17 - v24.4.2 diff --git a/Taskfile.yml b/Taskfile.yml index 355de2f..03a4e8a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -10,6 +10,7 @@ tasks: cmds: - echo "$PYTHON" - echo "$WEBUI" + - echo "$UV_PYTHON" silent: true launch: @@ -24,8 +25,8 @@ tasks: update: cmds: - - "{{.PYTHON}} -m 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" update-torch: cmds: - - "{{.PYTHON}} -m pip install -U torch torchvision torchaudio -f https://download.pytorch.org/whl/torch_stable.html" + - "{{.PYTHON}} -m uv pip install -U torch torchvision torchaudio -f https://download.pytorch.org/whl/torch_stable.html" diff --git a/aaaaaa/p_method.py b/aaaaaa/p_method.py index 9a87e7c..a9dfd14 100644 --- a/aaaaaa/p_method.py +++ b/aaaaaa/p_method.py @@ -28,3 +28,7 @@ def get_i(p) -> int: bs = p.batch_size i = p.batch_index return it * bs + i + + +def is_skip_img2img(p) -> bool: + return getattr(p, "_ad_skip_img2img", False) diff --git a/adetailer/traceback.py b/aaaaaa/traceback.py similarity index 87% rename from adetailer/traceback.py rename to aaaaaa/traceback.py index 74d1848..f4f653d 100644 --- a/adetailer/traceback.py +++ b/aaaaaa/traceback.py @@ -12,6 +12,7 @@ from rich.traceback import Traceback from adetailer.__version__ import __version__ +from adetailer.args import ADetailerArgs def processing(*args: Any) -> dict[str, Any]: @@ -66,23 +67,30 @@ def sd_models() -> dict[str, str]: def ad_args(*args: Any) -> dict[str, Any]: - ad_args = [ - arg - for arg in args - if isinstance(arg, dict) and arg.get("ad_model", "None") != "None" - ] + ad_args = [] + for arg in args: + if not isinstance(arg, dict): + continue + + try: + a = ADetailerArgs(**arg) + except ValueError: + continue + + if not a.need_skip(): + ad_args.append(a) + if not ad_args: return {} arg0 = ad_args[0] - is_api = arg0.get("is_api", True) return { "version": __version__, - "ad_model": arg0["ad_model"], - "ad_prompt": arg0.get("ad_prompt", ""), - "ad_negative_prompt": arg0.get("ad_negative_prompt", ""), - "ad_controlnet_model": arg0.get("ad_controlnet_model", "None"), - "is_api": type(is_api) is not tuple, + "ad_model": arg0.ad_model, + "ad_prompt": arg0.ad_prompt, + "ad_negative_prompt": arg0.ad_negative_prompt, + "ad_controlnet_model": arg0.ad_controlnet_model, + "is_api": arg0.is_api, } diff --git a/adetailer/ui.py b/aaaaaa/ui.py similarity index 96% rename from adetailer/ui.py rename to aaaaaa/ui.py index f17c52c..7bc923d 100644 --- a/adetailer/ui.py +++ b/aaaaaa/ui.py @@ -162,7 +162,7 @@ def adui( states.append(state) infotext_fields.extend(infofields) - # components: [bool, dict, dict, ...] + # components: [bool, bool, dict, dict, ...] components = [ad_enable, ad_skip_img2img, *states] return components, infotext_fields @@ -171,26 +171,35 @@ def one_ui_group(n: int, is_img2img: bool, webui_info: WebuiInfo): w = Widgets() eid = partial(elem_id, n=n, is_img2img=is_img2img) + model_choices = ( + [*webui_info.ad_model_list, "None"] + if n == 0 + else ["None", *webui_info.ad_model_list] + ) + with gr.Group(): - with gr.Row(): - model_choices = ( - [*webui_info.ad_model_list, "None"] - if n == 0 - else ["None", *webui_info.ad_model_list] + with gr.Row(variant="compact"): + w.ad_tap_enable = gr.Checkbox( + label=f"Enable this tap ({ordinal(n + 1)})", + value=True, + visible=True, + elem_id=eid("ad_tap_enable"), ) + with gr.Row(): w.ad_model = gr.Dropdown( - label="ADetailer model" + suffix(n), + label="ADetailer detector" + suffix(n), choices=model_choices, value=model_choices[0], visible=True, type="value", elem_id=eid("ad_model"), + info="Select a model to use for detection.", ) with gr.Row(): w.ad_model_classes = gr.Textbox( - label="ADetailer model classes" + suffix(n), + label="ADetailer detector classes" + suffix(n), value="", visible=False, elem_id=eid("ad_classes"), @@ -354,6 +363,7 @@ def mask_preprocessing(w: Widgets, n: int, is_img2img: bool): choices=MASK_MERGE_INVERT, value="None", elem_id=eid("ad_mask_merge_invert"), + info="None: do nothing, Merge: merge masks, Merge and Invert: merge all masks and invert", ) diff --git a/adetailer/__version__.py b/adetailer/__version__.py index 5068bd4..7ad6f1b 100644 --- a/adetailer/__version__.py +++ b/adetailer/__version__.py @@ -1 +1 @@ -__version__ = "24.4.2" +__version__ = "24.5.0" diff --git a/adetailer/args.py b/adetailer/args.py index a54ac6c..ba0808c 100644 --- a/adetailer/args.py +++ b/adetailer/args.py @@ -55,6 +55,7 @@ def names(self) -> tuple[str, ...]: class ADetailerArgs(BaseModel, extra=Extra.forbid): ad_model: str = "None" ad_model_classes: str = "" + ad_tap_enable: bool = True ad_prompt: str = "" ad_negative_prompt: str = "" ad_confidence: confloat(ge=0.0, le=1.0) = 0.3 @@ -119,7 +120,7 @@ def ppop( p.pop(k, None) def extra_params(self, suffix: str = "") -> dict[str, Any]: - if self.ad_model == "None": + if self.need_skip(): return {} p = {name: getattr(self, attr) for attr, name in ALL_ARGS} @@ -128,6 +129,7 @@ def extra_params(self, suffix: str = "") -> dict[str, Any]: ppop("ADetailer model classes") ppop("ADetailer prompt") ppop("ADetailer negative prompt") + p.pop("ADetailer tap enable", None) # always pop ppop("ADetailer mask only top k largest", cond=0) ppop("ADetailer mask min ratio", cond=0.0) ppop("ADetailer mask max ratio", cond=1.0) @@ -200,10 +202,17 @@ def extra_params(self, suffix: str = "") -> dict[str, Any]: return p + def is_mediapipe(self) -> bool: + return self.ad_model.lower().startswith("mediapipe") + + def need_skip(self) -> bool: + return self.ad_model == "None" or self.ad_tap_enable is False + _all_args = [ ("ad_model", "ADetailer model"), ("ad_model_classes", "ADetailer model classes"), + ("ad_tap_enable", "ADetailer tap enable"), ("ad_prompt", "ADetailer prompt"), ("ad_negative_prompt", "ADetailer negative prompt"), ("ad_confidence", "ADetailer confidence"), @@ -262,6 +271,8 @@ def extra_params(self, suffix: str = "") -> dict[str, Any]: "wildcards", "lora_block_weight", "negpip", - "soft_inpainting", ) SCRIPT_DEFAULT = ",".join(sorted(_script_default)) + +_builtin_script = ("soft_inpainting", "hypertile_script") +BUILTIN_SCRIPT = ",".join(sorted(_builtin_script)) diff --git a/install.py b/install.py index 10efe7b..7a6d4d0 100644 --- a/install.py +++ b/install.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import os import subprocess import sys from importlib.metadata import version # python >= 3.8 @@ -38,19 +39,29 @@ def is_installed( def run_pip(*args): - subprocess.run([sys.executable, "-m", "pip", "install", *args]) + subprocess.run([sys.executable, "-m", "pip", "install", *args], check=False) + + +def run_uv_pip(*args): + subprocess.run([sys.executable, "-m", "uv", "pip", "install", *args], check=False) def install(): deps = [ # requirements - ("ultralytics", "8.1.29", None), - ("mediapipe", "0.10.9", None), + ("ultralytics", "8.2.0", None), + ("mediapipe", "0.10.13", None), ("rich", "13.0.0", None), # mediapipe - ("protobuf", "3.20", "3.9999"), + ("protobuf", "4.25.3", "4.9999"), ] + if not is_installed("uv", "0.1.44", None): + run_pip("uv>=0.1.44") + + os.environ["UV_PYTHON"] = sys.executable + + pkgs = [] for pkg, low, high in deps: if not is_installed(pkg, low, high): if low and high: @@ -61,8 +72,9 @@ def install(): cmd = f"{pkg}<={high}" else: cmd = pkg + pkgs.append(cmd) - run_pip("-U", cmd) + run_uv_pip(*pkgs) try: diff --git a/pyproject.toml b/pyproject.toml index 66edde1..3c55fed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,11 +2,11 @@ name = "adetailer" description = "An object detection and auto-mask extension for stable diffusion webui." authors = [{ name = "dowon", email = "ks2515@naver.com" }] -requires-python = ">=3.8,<3.13" +requires-python = ">=3.8" readme = "README.md" license = { text = "AGPL-3.0" } dependencies = [ - "ultralytics>=8.1", + "ultralytics>=8.2", "mediapipe>=0.10", "pydantic<3", "rich>=13", diff --git a/scripts/!adetailer.py b/scripts/!adetailer.py index b3fb1c6..c5a5f16 100644 --- a/scripts/!adetailer.py +++ b/scripts/!adetailer.py @@ -26,9 +26,12 @@ get_i, is_img2img_inpaint, is_inpaint_only_masked, + is_skip_img2img, need_call_postprocess, need_call_process, ) +from aaaaaa.traceback import rich_traceback +from aaaaaa.ui import WebuiInfo, adui, ordinal, suffix from adetailer import ( AFTER_DETAILER, __version__, @@ -36,7 +39,13 @@ mediapipe_predict, ultralytics_predict, ) -from adetailer.args import BBOX_SORTBY, SCRIPT_DEFAULT, ADetailerArgs, SkipImg2ImgOrig +from adetailer.args import ( + BBOX_SORTBY, + BUILTIN_SCRIPT, + SCRIPT_DEFAULT, + ADetailerArgs, + SkipImg2ImgOrig, +) from adetailer.common import PredictOutput, ensure_pil_image, safe_mkdir from adetailer.mask import ( filter_by_ratio, @@ -46,8 +55,6 @@ mask_preprocess, sort_bboxes, ) -from adetailer.traceback import rich_traceback -from adetailer.ui import WebuiInfo, adui, ordinal, suffix from controlnet_ext import ( CNHijackRestore, ControlNetExt, @@ -76,10 +83,10 @@ adetailer_dir = Path(paths.models_path, "adetailer") safe_mkdir(adetailer_dir) -extra_models_dir = shared.opts.data.get("ad_extra_models_dir", "") +extra_models_dirs = shared.opts.data.get("ad_extra_models_dir", "") model_mapping = get_models( adetailer_dir, - extra_models_dir, + *extra_models_dirs.split("|"), huggingface=not no_huggingface, ) @@ -180,7 +187,13 @@ def is_ad_enabled(self, *args_) -> bool: return False ad_enabled = args_[0] if isinstance(args_[0], bool) else True - not_none = any(arg.get("ad_model", "None") != "None" for arg in arg_list) + pydantic_args = [] + for arg in arg_list: + try: + pydantic_args.append(ADetailerArgs(**arg)) + except ValueError: # noqa: PERF203 + continue + not_none = not all(arg.need_skip() for arg in pydantic_args) return ad_enabled and not_none def set_skip_img2img(self, p, *args_) -> None: @@ -441,10 +454,11 @@ def script_filter(self, p, args: ADetailerArgs): if not ad_only_seleted_scripts: return script_runner, script_args - ad_script_names = opts.data.get("ad_script_names", SCRIPT_DEFAULT) + ad_script_names_string: str = opts.data.get("ad_script_names", SCRIPT_DEFAULT) + ad_script_names = ad_script_names_string.split(",") + BUILTIN_SCRIPT.split(",") script_names_set = { name - for script_name in ad_script_names.split(",") + for script_name in ad_script_names for name in (script_name, script_name.strip()) } @@ -625,7 +639,7 @@ def compare_prompt(p, extra_params: dict[str, Any], processed, n: int = 0): @staticmethod def get_i2i_init_image(p, pp): - if getattr(p, "_ad_skip_img2img", False): + if is_skip_img2img(p): return p.init_images[0] return pp.image @@ -649,7 +663,7 @@ def get_image_mask(p) -> Image.Image: mask = ImageChops.invert(mask) mask = create_binary_mask(mask) - if getattr(p, "_ad_skip_img2img", False): + if is_skip_img2img(p): if hasattr(p, "init_images") and p.init_images: width, height = p.init_images[0].size else: @@ -712,7 +726,7 @@ def _postprocess_image_inner( seed, subseed = self.get_seed(p) ad_prompts, ad_negatives = self.get_prompt(p, args) - is_mediapipe = args.ad_model.lower().startswith("mediapipe") + is_mediapipe = args.is_mediapipe() kwargs = {} if is_mediapipe: @@ -762,6 +776,8 @@ def _postprocess_image_inner( p2.seed = self.get_each_tap_seed(seed, j) p2.subseed = self.get_each_tap_seed(subseed, j) + p2.cached_c = [None, None] + p2.cached_uc = [None, None] try: processed = process_images(p2) except NansException as e: @@ -800,11 +816,11 @@ def postprocess_image(self, p, pp, *args_): is_processed = False with CNHijackRestore(), pause_total_tqdm(), cn_allow_script_control(): for n, args in enumerate(arg_list): - if args.ad_model == "None": + if args.need_skip(): continue is_processed |= self._postprocess_image_inner(p, pp, args, n=n) - if is_processed and not getattr(p, "_ad_skip_img2img", False): + if is_processed and not is_skip_img2img(p): self.save_image( p, init_image, condition="ad_save_images_before", suffix="-ad-before" ) @@ -849,10 +865,12 @@ def on_ui_settings(): "ad_extra_models_dir", shared.OptionInfo( default="", - label="Extra path to scan adetailer models", + label="Extra paths to scan adetailer models seperated by vertical bars(|)", component=gr.Textbox, section=section, - ), + ) + .info("eg. path\\to\\models|C:\\path\\to\\models|another/path/to/models") + .needs_reload_ui(), ) shared.opts.add_option( diff --git a/tests/test_args.py b/tests/test_args.py index 89b0c2b..19db115 100644 --- a/tests/test_args.py +++ b/tests/test_args.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + from adetailer.args import ALL_ARGS, ADetailerArgs @@ -12,3 +14,35 @@ def test_all_args() -> None: if attr == "is_api": continue assert attr in ALL_ARGS.attrs, attr + + +@pytest.mark.parametrize( + ("ad_model", "expect"), + [("mediapipe_face_full", True), ("face_yolov8n.pt", False)], +) +def test_is_mediapipe(ad_model: str, expect: bool) -> None: + args = ADetailerArgs(ad_model=ad_model) + assert args.is_mediapipe() is expect + + +@pytest.mark.parametrize( + ("ad_model", "expect"), + [("mediapipe_face_full", False), ("face_yolov8n.pt", False), ("None", True)], +) +def test_need_skip(ad_model: str, expect: bool) -> None: + args = ADetailerArgs(ad_model=ad_model) + assert args.need_skip() is expect + + +@pytest.mark.parametrize( + ("ad_model", "ad_tap_enable", "expect"), + [ + ("face_yolov8n.pt", False, True), + ("mediapipe_face_full", False, True), + ("None", True, True), + ("ace_yolov8s.pt", True, False), + ], +) +def test_need_skip_tap_enable(ad_model: str, ad_tap_enable: bool, expect: bool) -> None: + args = ADetailerArgs(ad_model=ad_model, ad_tap_enable=ad_tap_enable) + assert args.need_skip() is expect