From 36902252db8953ed6c86b3d59ff98430bcaa16ba Mon Sep 17 00:00:00 2001 From: Dowon Date: Fri, 8 Mar 2024 21:39:36 +0900 Subject: [PATCH 01/19] chore: ultralytics min version -> 8.1.21 yolo world v2 support --- install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.py b/install.py index c7de57d..1c21ad3 100644 --- a/install.py +++ b/install.py @@ -44,7 +44,7 @@ def run_pip(*args): def install(): deps = [ # requirements - ("ultralytics", "8.1.18", None), + ("ultralytics", "8.1.21", None), ("mediapipe", "0.10.9", None), ("rich", "13.0.0", None), # mediapipe From 5685858e4be1fbf65889c98859002f3b6ffcbc18 Mon Sep 17 00:00:00 2001 From: Dowon Date: Fri, 8 Mar 2024 21:45:41 +0900 Subject: [PATCH 02/19] chore: update pre-commit, add rules --- .pre-commit-config.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea055e6..d48af0d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,6 +2,10 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: + - id: check-added-large-files + args: [--maxkb=100] + - id: check-merge-conflict + - id: check-case-conflict - id: check-ast - id: trailing-whitespace args: [--markdown-linebreak-ext=md] @@ -9,7 +13,7 @@ repos: - id: mixed-line-ending - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.2.2 + rev: v0.3.1 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 4551aeea961a06158e6558a579efeb605f70b5b0 Mon Sep 17 00:00:00 2001 From: Dowon Date: Fri, 8 Mar 2024 21:47:04 +0900 Subject: [PATCH 03/19] ci: remove 'if' --- .github/workflows/lint.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 405efe5..3279467 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,7 +8,6 @@ on: jobs: lint: runs-on: ubuntu-latest - if: github.repository == 'Bing-su/adetailer' || github.repository == '' steps: - uses: actions/checkout@v4 From 78ca13ebfa9e68dc73a1f203088ace603b86de90 Mon Sep 17 00:00:00 2001 From: Dowon Date: Fri, 8 Mar 2024 22:04:19 +0900 Subject: [PATCH 04/19] chore: update pyproject.toml --- adetailer/__version__.py | 2 +- pyproject.toml | 26 +++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/adetailer/__version__.py b/adetailer/__version__.py index 98cbf02..de3c281 100644 --- a/adetailer/__version__.py +++ b/adetailer/__version__.py @@ -1 +1 @@ -__version__ = "24.3.0" +__version__ = "24.3.1-dev.0" diff --git a/pyproject.toml b/pyproject.toml index 90ca5dd..410827b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,17 +2,41 @@ 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.12" +requires-python = ">=3.8,<3.13" readme = "README.md" license = { text = "AGPL-3.0" } +dependencies = [ + "ultralytics>=8.1", + "mediapipe>=10", + "pydantic<2", + "rich>=13", + "huggingface_hub", +] +keywords = [ + "stable-diffusion", + "stable-diffusion-webui", + "adetailer", + "ultralytics", +] +dynamic = ["version"] [project.urls] repository = "https://github.com/Bing-su/adetailer" +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "adetailer/__version__.py" + [tool.isort] profile = "black" known_first_party = ["launch", "modules"] +[tool.ruff] +target-version = "py38" + [tool.ruff.lint] select = [ "A", From 4d804552dffd8142cfaadfcea5b2e45e5734d76c Mon Sep 17 00:00:00 2001 From: Dowon Date: Fri, 8 Mar 2024 22:24:28 +0900 Subject: [PATCH 05/19] fix: ad_model typing --- adetailer/__version__.py | 2 +- adetailer/common.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/adetailer/__version__.py b/adetailer/__version__.py index de3c281..a4ea88b 100644 --- a/adetailer/__version__.py +++ b/adetailer/__version__.py @@ -1 +1 @@ -__version__ = "24.3.1-dev.0" +__version__ = "24.3.1-dev.1" diff --git a/adetailer/common.py b/adetailer/common.py index 317d9c6..cf3ad3f 100644 --- a/adetailer/common.py +++ b/adetailer/common.py @@ -44,7 +44,7 @@ def scan_model_dir(path_: str | Path) -> list[Path]: def get_models( model_dir: str | Path, extra_dir: str | Path = "", huggingface: bool = True -) -> OrderedDict[str, str | None]: +) -> OrderedDict[str, str]: model_paths = [*scan_model_dir(model_dir), *scan_model_dir(extra_dir)] models = OrderedDict() @@ -63,10 +63,10 @@ def get_models( ) models.update( { - "mediapipe_face_full": None, - "mediapipe_face_short": None, - "mediapipe_face_mesh": None, - "mediapipe_face_mesh_eyes_only": None, + "mediapipe_face_full": "mediapipe_face_full", + "mediapipe_face_short": "mediapipe_face_short", + "mediapipe_face_mesh": "mediapipe_face_mesh", + "mediapipe_face_mesh_eyes_only": "mediapipe_face_mesh_eyes_only", } ) From 827f8bb655a017621c0a4aca744f83fb1469e754 Mon Sep 17 00:00:00 2001 From: Dowon Date: Fri, 8 Mar 2024 22:45:23 +0900 Subject: [PATCH 06/19] fix: img2img inpainting: check intersection --- adetailer/mask.py | 18 ++++++++++++++---- scripts/!adetailer.py | 18 +++++++++++++++--- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/adetailer/mask.py b/adetailer/mask.py index d2f3680..c76d2a8 100644 --- a/adetailer/mask.py +++ b/adetailer/mask.py @@ -83,12 +83,22 @@ def offset(img: Image.Image, x: int = 0, y: int = 0) -> Image.Image: return ImageChops.offset(img, x, -y) -def is_all_black(img: Image.Image) -> bool: - arr = np.array(img) - return cv2.countNonZero(arr) == 0 +def is_all_black(img: Image.Image | np.ndarray) -> bool: + if isinstance(img, Image.Image): + img = np.array(img) + return cv2.countNonZero(img) == 0 + + +def has_intersection(im1: Image.Image, im2: Image.Image) -> bool: + if im1.mode != "L" or im2.mode != "L": + msg = "Both images must be grayscale" + raise ValueError(msg) + arr1 = np.array(im1) + arr2 = np.array(im2) + return not is_all_black(cv2.bitwise_and(arr1, arr2)) -def bbox_area(bbox: list[float]): +def bbox_area(bbox: list[float]) -> float: return (bbox[2] - bbox[0]) * (bbox[3] - bbox[1]) diff --git a/scripts/!adetailer.py b/scripts/!adetailer.py index 8fa8352..8174509 100644 --- a/scripts/!adetailer.py +++ b/scripts/!adetailer.py @@ -31,6 +31,8 @@ from adetailer.mask import ( filter_by_ratio, filter_k_largest, + has_intersection, + is_all_black, mask_preprocess, sort_bboxes, ) @@ -637,16 +639,24 @@ def get_each_tap_seed(seed: int, i: int): @staticmethod def is_img2img_inpaint(p) -> bool: - return hasattr(p, "image_mask") and bool(p.image_mask) + return hasattr(p, "image_mask") and p.image_mask is not None + + @staticmethod + def inpaint_mask_filter( + img2img_mask: Image.Image, ad_mask: list[Image.Image] + ) -> list[Image.Image]: + return [mask for mask in ad_mask if has_intersection(img2img_mask, mask)] @rich_traceback def process(self, p, *args_): if getattr(p, "_ad_disabled", False): return - if self.is_img2img_inpaint(p): + if self.is_img2img_inpaint(p) and is_all_black(p.image_mask): p._ad_disabled = True - msg = "[-] ADetailer: img2img inpainting detected. adetailer disabled." + msg = ( + "[-] ADetailer: img2img inpainting with no mask -- adetailer disabled." + ) print(msg) return @@ -701,6 +711,8 @@ def _postprocess_image_inner( pred = predictor(ad_model, pp.image, args.ad_confidence, **kwargs) masks = self.pred_preprocessing(pred, args) + if self.is_img2img_inpaint(p): + masks = self.inpaint_mask_filter(p.image_mask, masks) shared.state.assign_current_image(pred.preview) if not masks: From ce4ea25f437084694e9a990cf5603afa989afbe4 Mon Sep 17 00:00:00 2001 From: Dowon Date: Tue, 12 Mar 2024 01:04:31 +0900 Subject: [PATCH 07/19] fix: move inpaint mask filter func --- scripts/!adetailer.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/!adetailer.py b/scripts/!adetailer.py index 8174509..ff761a2 100644 --- a/scripts/!adetailer.py +++ b/scripts/!adetailer.py @@ -567,19 +567,22 @@ def sort_bboxes(self, pred: PredictOutput) -> PredictOutput: sortby_idx = BBOX_SORTBY.index(sortby) return sort_bboxes(pred, sortby_idx) - def pred_preprocessing(self, pred: PredictOutput, args: ADetailerArgs): + def pred_preprocessing(self, p, pred: PredictOutput, args: ADetailerArgs): pred = filter_by_ratio( pred, low=args.ad_mask_min_ratio, high=args.ad_mask_max_ratio ) pred = filter_k_largest(pred, k=args.ad_mask_k_largest) pred = self.sort_bboxes(pred) - return mask_preprocess( + masks = mask_preprocess( pred.masks, kernel=args.ad_dilate_erode, x_offset=args.ad_x_offset, y_offset=args.ad_y_offset, merge_invert=args.ad_mask_merge_invert, ) + if self.is_img2img_inpaint(p): + masks = self.inpaint_mask_filter(p.image_mask, masks) + return masks @staticmethod def ensure_rgb_image(image: Any): @@ -710,9 +713,7 @@ def _postprocess_image_inner( with change_torch_load(): pred = predictor(ad_model, pp.image, args.ad_confidence, **kwargs) - masks = self.pred_preprocessing(pred, args) - if self.is_img2img_inpaint(p): - masks = self.inpaint_mask_filter(p.image_mask, masks) + masks = self.pred_preprocessing(p, pred, args) shared.state.assign_current_image(pred.preview) if not masks: From a9feea2662092c3d0fda284aa1757d0d00ae6f6b Mon Sep 17 00:00:00 2001 From: Dowon Date: Tue, 12 Mar 2024 01:15:03 +0900 Subject: [PATCH 08/19] fix: replace CNHijackRestore with nullcontext for forge --- controlnet_ext/__init__.py | 9 +++++++++ scripts/!adetailer.py | 6 ++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/controlnet_ext/__init__.py b/controlnet_ext/__init__.py index 06cc3a1..1f03bed 100644 --- a/controlnet_ext/__init__.py +++ b/controlnet_ext/__init__.py @@ -13,8 +13,17 @@ get_cn_models, ) +from .restore import cn_allow_script_control + +if controlnet_type == "forge": + from contextlib import nullcontext as CNHijackRestore # noqa: N812 +else: + from .restore import CNHijackRestore + __all__ = [ "ControlNetExt", + "CNHijackRestore", + "cn_allow_script_control", "controlnet_exists", "controlnet_type", "get_cn_models", diff --git a/scripts/!adetailer.py b/scripts/!adetailer.py index ff761a2..534eae0 100644 --- a/scripts/!adetailer.py +++ b/scripts/!adetailer.py @@ -39,15 +39,13 @@ from adetailer.traceback import rich_traceback from adetailer.ui import WebuiInfo, adui, ordinal, suffix from controlnet_ext import ( + CNHijackRestore, ControlNetExt, + cn_allow_script_control, controlnet_exists, controlnet_type, get_cn_models, ) -from controlnet_ext.restore import ( - CNHijackRestore, - cn_allow_script_control, -) from modules import images, paths, safe, script_callbacks, scripts, shared from modules.devices import NansException from modules.processing import ( From f6a7b95585fe0ef5647bbad9e18665b111ad2c3c Mon Sep 17 00:00:00 2001 From: Dowon Date: Sat, 16 Mar 2024 14:50:00 +0900 Subject: [PATCH 09/19] fix: ensure pil image, mask has intersection --- adetailer/common.py | 11 ++++++++++- adetailer/mask.py | 12 +++++------- scripts/!adetailer.py | 15 +++------------ 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/adetailer/common.py b/adetailer/common.py index cf3ad3f..9e1d1b9 100644 --- a/adetailer/common.py +++ b/adetailer/common.py @@ -3,11 +3,12 @@ from collections import OrderedDict from dataclasses import dataclass, field from pathlib import Path -from typing import Optional +from typing import Any, Optional from huggingface_hub import hf_hub_download from PIL import Image, ImageDraw from rich import print +from torchvision.transforms.functional import to_pil_image REPO_ID = "Bingsu/adetailer" _download_failed = False @@ -133,3 +134,11 @@ def create_bbox_from_mask( if bbox is not None: bboxes.append(list(bbox)) return bboxes + + +def ensure_pil_image(image: Any, mode: str = "RGB") -> Image.Image: + if not isinstance(image, Image.Image): + image = to_pil_image(image) + if image.mode != mode: + image = image.convert(mode) + return image diff --git a/adetailer/mask.py b/adetailer/mask.py index c76d2a8..5514828 100644 --- a/adetailer/mask.py +++ b/adetailer/mask.py @@ -3,13 +3,14 @@ from enum import IntEnum from functools import partial, reduce from math import dist +from typing import Any import cv2 import numpy as np from PIL import Image, ImageChops from adetailer.args import MASK_MERGE_INVERT -from adetailer.common import PredictOutput +from adetailer.common import PredictOutput, ensure_pil_image class SortBy(IntEnum): @@ -89,12 +90,9 @@ def is_all_black(img: Image.Image | np.ndarray) -> bool: return cv2.countNonZero(img) == 0 -def has_intersection(im1: Image.Image, im2: Image.Image) -> bool: - if im1.mode != "L" or im2.mode != "L": - msg = "Both images must be grayscale" - raise ValueError(msg) - arr1 = np.array(im1) - arr2 = np.array(im2) +def has_intersection(im1: Any, im2: Any) -> bool: + arr1 = np.array(ensure_pil_image(im1, "L")) + arr2 = np.array(ensure_pil_image(im2, "L")) return not is_all_black(cv2.bitwise_and(arr1, arr2)) diff --git a/scripts/!adetailer.py b/scripts/!adetailer.py index 534eae0..18f6382 100644 --- a/scripts/!adetailer.py +++ b/scripts/!adetailer.py @@ -16,7 +16,6 @@ import torch from PIL import Image from rich import print -from torchvision.transforms.functional import to_pil_image import modules from adetailer import ( @@ -27,7 +26,7 @@ ultralytics_predict, ) from adetailer.args import ALL_ARGS, BBOX_SORTBY, ADetailerArgs, SkipImg2ImgOrig -from adetailer.common import PredictOutput +from adetailer.common import PredictOutput, ensure_pil_image from adetailer.mask import ( filter_by_ratio, filter_k_largest, @@ -582,14 +581,6 @@ def pred_preprocessing(self, p, pred: PredictOutput, args: ADetailerArgs): masks = self.inpaint_mask_filter(p.image_mask, masks) return masks - @staticmethod - def ensure_rgb_image(image: Any): - if not isinstance(image, Image.Image): - image = to_pil_image(image) - if image.mode != "RGB": - image = image.convert("RGB") - return image - @staticmethod def i2i_prompts_replace( i2i, prompts: list[str], negative_prompts: list[str], j: int @@ -737,7 +728,7 @@ def _postprocess_image_inner( p2 = copy(i2i) for j in range(steps): p2.image_mask = masks[j] - p2.init_images[0] = self.ensure_rgb_image(p2.init_images[0]) + p2.init_images[0] = ensure_pil_image(p2.init_images[0], "RGB") self.i2i_prompts_replace(p2, ad_prompts, ad_negatives, j) if re.match(r"^\s*\[SKIP\]\s*$", p2.prompt): @@ -771,7 +762,7 @@ def postprocess_image(self, p, pp, *args_): return pp.image = self.get_i2i_init_image(p, pp) - pp.image = self.ensure_rgb_image(pp.image) + pp.image = ensure_pil_image(pp.image, "RGB") init_image = copy(pp.image) arg_list = self.get_args(p, *args_) params_txt_content = Path(paths.data_path, "params.txt").read_text("utf-8") From 06b3c17ee6f4e36563bfb333978ccb27b8e80d30 Mon Sep 17 00:00:00 2001 From: Dowon Date: Sat, 16 Mar 2024 15:19:21 +0900 Subject: [PATCH 10/19] test: common.py tests --- tests/test_common.py | 69 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_common.py diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 0000000..c162990 --- /dev/null +++ b/tests/test_common.py @@ -0,0 +1,69 @@ +import numpy as np +from PIL import Image, ImageDraw + +from adetailer.common import create_bbox_from_mask, create_mask_from_bbox + + +def test_create_mask_from_bbox(): + img = Image.new("L", (10, 10), color="black") + bbox = [[1.0, 1.0, 2.0, 2.0], [7.0, 7.0, 8.0, 8.0]] + masks = create_mask_from_bbox(bbox, img.size) + expect1 = np.array( + [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 255, 255, 0, 0, 0, 0, 0, 0, 0], + [0, 255, 255, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ], + dtype=np.uint8, + ) + expect2 = np.array( + [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 255, 255, 0], + [0, 0, 0, 0, 0, 0, 0, 255, 255, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ], + dtype=np.uint8, + ) + assert len(masks) == len(bbox) + arr1 = np.array(masks[0]) + arr2 = np.array(masks[1]) + assert arr1.shape == expect1.shape + assert arr2.shape == expect2.shape + assert arr1.shape == (10, 10) + assert arr1.dtype == expect1.dtype + assert arr2.dtype == expect2.dtype + assert np.array_equal(arr1, expect1) + assert np.array_equal(arr2, expect2) + + # The function correctly receives a list of masks and the shape of the image. + + +def test_create_bbox_from_mask(): + mask = Image.new("L", (10, 10), color="black") + draw = ImageDraw.Draw(mask) + draw.rectangle((2, 2, 5, 5), fill="white") + + result = create_bbox_from_mask([mask], (10, 10)) + + assert isinstance(result, list) + assert len(result) == 1 + assert all(isinstance(bbox, list) for bbox in result) + assert all(len(bbox) == 4 for bbox in result) + assert result[0] == [2, 2, 6, 6] + + result = create_bbox_from_mask([mask], (256, 256)) + assert result[0] == [38, 38, 166, 166] From ea29140cdf1020409393f4b4a92d4e1ea73a7280 Mon Sep 17 00:00:00 2001 From: Dowon Date: Sat, 16 Mar 2024 21:23:41 +0900 Subject: [PATCH 11/19] feat: update yolo world to v2 --- adetailer/common.py | 4 ++-- tests/test_ultralytics.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/adetailer/common.py b/adetailer/common.py index 9e1d1b9..5fc6b53 100644 --- a/adetailer/common.py +++ b/adetailer/common.py @@ -57,8 +57,8 @@ def get_models( "hand_yolov8n.pt": hf_download("hand_yolov8n.pt"), "person_yolov8n-seg.pt": hf_download("person_yolov8n-seg.pt"), "person_yolov8s-seg.pt": hf_download("person_yolov8s-seg.pt"), - "yolov8x-world.pt": hf_download( - "yolov8x-world.pt", repo_id="Bingsu/yolo-world-mirror" + "yolov8x-worldv2.pt": hf_download( + "yolov8x-worldv2.pt", repo_id="Bingsu/yolo-world-mirror" ), } ) diff --git a/tests/test_ultralytics.py b/tests/test_ultralytics.py index fe40097..1a75d7a 100644 --- a/tests/test_ultralytics.py +++ b/tests/test_ultralytics.py @@ -26,7 +26,7 @@ def test_ultralytics_hf_models(sample_image: Image.Image, model_name: str): def test_yolo_world_default(sample_image: Image.Image): - model_path = hf_hub_download("Bingsu/yolo-world-mirror", "yolov8x-world.pt") + model_path = hf_hub_download("Bingsu/yolo-world-mirror", "yolov8x-worldv2.pt") result = ultralytics_predict(model_path, sample_image) assert result.preview is not None @@ -43,6 +43,6 @@ def test_yolo_world_default(sample_image: Image.Image): ], ) def test_yolo_world(sample_image2: Image.Image, klass: str): - model_path = hf_hub_download("Bingsu/yolo-world-mirror", "yolov8x-world.pt") + model_path = hf_hub_download("Bingsu/yolo-world-mirror", "yolov8x-worldv2.pt") result = ultralytics_predict(model_path, sample_image2, classes=klass) assert result.preview is not None From 0e031e16cc22f4000193a36e09d91033e223193a Mon Sep 17 00:00:00 2001 From: Dowon Date: Sat, 16 Mar 2024 21:55:19 +0900 Subject: [PATCH 12/19] test: add some mask test --- tests/test_mask.py | 154 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 tests/test_mask.py diff --git a/tests/test_mask.py b/tests/test_mask.py new file mode 100644 index 0000000..e5ae03e --- /dev/null +++ b/tests/test_mask.py @@ -0,0 +1,154 @@ +import numpy as np +from PIL import Image, ImageDraw + +from adetailer.mask import dilate_erode, has_intersection, is_all_black, offset + + +def test_dilate_positive_value(): + img = Image.new("L", (10, 10), color="black") + draw = ImageDraw.Draw(img) + draw.rectangle((3, 3, 5, 5), fill="white") + value = 3 + + result = dilate_erode(img, value) + + assert isinstance(result, Image.Image) + assert result.size == (10, 10) + + expect = np.array( + [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 255, 255, 255, 255, 255, 0, 0, 0], + [0, 0, 255, 255, 255, 255, 255, 0, 0, 0], + [0, 0, 255, 255, 255, 255, 255, 0, 0, 0], + [0, 0, 255, 255, 255, 255, 255, 0, 0, 0], + [0, 0, 255, 255, 255, 255, 255, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ], + dtype=np.uint8, + ) + assert np.array_equal(np.array(result), expect) + + +def test_offset(): + img = Image.new("L", (10, 10), color="black") + draw = ImageDraw.Draw(img) + draw.rectangle((4, 4, 5, 5), fill="white") + + result = offset(img, x=1, y=2) + + assert isinstance(result, Image.Image) + assert result.size == (10, 10) + + expect = np.array( + [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 255, 255, 0, 0, 0], + [0, 0, 0, 0, 0, 255, 255, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ], + dtype=np.uint8, + ) + assert np.array_equal(np.array(result), expect) + + +def test_is_all_black_1(): + img = Image.new("L", (10, 10), color="black") + assert is_all_black(img) + + draw = ImageDraw.Draw(img) + draw.rectangle((4, 4, 5, 5), fill="white") + assert not is_all_black(img) + + +def test_is_all_black_2(): + img = np.zeros((10, 10), dtype=np.uint8) + assert is_all_black(img) + + img[4:6, 4:6] = 255 + assert not is_all_black(img) + + +def test_has_intersection_1(): + arr1 = np.array( + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ] + ) + arr2 = arr1.copy() + assert not has_intersection(arr1, arr2) + + +def test_has_intersection_2(): + arr1 = np.array( + [ + [0, 0, 0, 0], + [0, 255, 255, 0], + [0, 255, 255, 0], + [0, 0, 0, 0], + ] + ) + arr2 = np.array( + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 255, 255], + [0, 0, 255, 255], + ] + ) + assert has_intersection(arr1, arr2) + + arr3 = np.array( + [ + [255, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 255], + [0, 0, 255, 255], + ] + ) + assert not has_intersection(arr1, arr3) + + +def test_has_intersection_3(): + img1 = Image.new("L", (10, 10), color="black") + draw1 = ImageDraw.Draw(img1) + draw1.rectangle((3, 3, 5, 5), fill="white") + img2 = Image.new("L", (10, 10), color="black") + draw2 = ImageDraw.Draw(img2) + draw2.rectangle((6, 6, 8, 8), fill="white") + assert not has_intersection(img1, img2) + + img3 = Image.new("L", (10, 10), color="black") + draw3 = ImageDraw.Draw(img3) + draw3.rectangle((2, 2, 8, 8), fill="white") + assert has_intersection(img1, img3) + + +def test_has_intersection_4(): + img1 = Image.new("RGB", (10, 10), color="black") + draw1 = ImageDraw.Draw(img1) + draw1.rectangle((3, 3, 5, 5), fill="white") + img2 = Image.new("RGBA", (10, 10), color="black") + draw2 = ImageDraw.Draw(img2) + draw2.rectangle((2, 2, 8, 8), fill="white") + assert has_intersection(img1, img2) + + +def test_has_intersection_5(): + img1 = Image.new("RGB", (10, 10), color="black") + draw1 = ImageDraw.Draw(img1) + draw1.rectangle((4, 4, 5, 5), fill="white") + img2 = np.full((10, 10, 4), 255, dtype=np.uint8) + assert has_intersection(img1, img2) From 2255341fb88e98040a49300a378a7e19ade1e00e Mon Sep 17 00:00:00 2001 From: Dowon Date: Sat, 16 Mar 2024 22:45:53 +0900 Subject: [PATCH 13/19] fix: filter only if not full res inpaint --- scripts/!adetailer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/!adetailer.py b/scripts/!adetailer.py index 18f6382..9e07391 100644 --- a/scripts/!adetailer.py +++ b/scripts/!adetailer.py @@ -577,7 +577,7 @@ def pred_preprocessing(self, p, pred: PredictOutput, args: ADetailerArgs): y_offset=args.ad_y_offset, merge_invert=args.ad_mask_merge_invert, ) - if self.is_img2img_inpaint(p): + if self.is_img2img_inpaint(p) and not self.is_inpaint_only_masked(p): masks = self.inpaint_mask_filter(p.image_mask, masks) return masks @@ -633,6 +633,10 @@ def get_each_tap_seed(seed: int, i: int): def is_img2img_inpaint(p) -> bool: return hasattr(p, "image_mask") and p.image_mask is not None + @staticmethod + def is_inpaint_only_masked(p) -> bool: + return hasattr(p, "inpaint_full_res") and p.inpaint_full_res + @staticmethod def inpaint_mask_filter( img2img_mask: Image.Image, ad_mask: list[Image.Image] From c3e2884b805fe593c43b4420033590aa9a358a68 Mon Sep 17 00:00:00 2001 From: Dowon Date: Sat, 16 Mar 2024 22:52:32 +0900 Subject: [PATCH 14/19] fix: prepare pydantic v2 --- adetailer/args.py | 32 ++++++++++++++++++++++---------- pyproject.toml | 2 +- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/adetailer/args.py b/adetailer/args.py index bda899d..5dd4577 100644 --- a/adetailer/args.py +++ b/adetailer/args.py @@ -5,16 +5,28 @@ from functools import cached_property, partial from typing import Any, Literal, NamedTuple, Optional -from pydantic import ( - BaseModel, - Extra, - NonNegativeFloat, - NonNegativeInt, - PositiveInt, - confloat, - conint, - validator, -) +try: + from pydantic.v1 import ( + BaseModel, + Extra, + NonNegativeFloat, + NonNegativeInt, + PositiveInt, + confloat, + conint, + validator, + ) +except ImportError: + from pydantic import ( + BaseModel, + Extra, + NonNegativeFloat, + NonNegativeInt, + PositiveInt, + confloat, + conint, + validator, + ) @dataclass diff --git a/pyproject.toml b/pyproject.toml index 410827b..9b0822f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ license = { text = "AGPL-3.0" } dependencies = [ "ultralytics>=8.1", "mediapipe>=10", - "pydantic<2", + "pydantic<3", "rich>=13", "huggingface_hub", ] From 4ec348a462c88e6b461f168d475dafb11dd2b30f Mon Sep 17 00:00:00 2001 From: Dowon Date: Sat, 16 Mar 2024 23:02:58 +0900 Subject: [PATCH 15/19] fix: revert CNHijackRestore --- controlnet_ext/__init__.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/controlnet_ext/__init__.py b/controlnet_ext/__init__.py index 1f03bed..6da28d4 100644 --- a/controlnet_ext/__init__.py +++ b/controlnet_ext/__init__.py @@ -13,12 +13,7 @@ get_cn_models, ) -from .restore import cn_allow_script_control - -if controlnet_type == "forge": - from contextlib import nullcontext as CNHijackRestore # noqa: N812 -else: - from .restore import CNHijackRestore +from .restore import CNHijackRestore, cn_allow_script_control __all__ = [ "ControlNetExt", From 62d000e382ebade17f67684a420d6034847e387f Mon Sep 17 00:00:00 2001 From: Dowon Date: Sat, 16 Mar 2024 23:21:07 +0900 Subject: [PATCH 16/19] fix: invert mask image as inpainting mask invert --- scripts/!adetailer.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/!adetailer.py b/scripts/!adetailer.py index 9e07391..e89d694 100644 --- a/scripts/!adetailer.py +++ b/scripts/!adetailer.py @@ -14,7 +14,7 @@ import gradio as gr import torch -from PIL import Image +from PIL import Image, ImageChops from rich import print import modules @@ -578,7 +578,9 @@ def pred_preprocessing(self, p, pred: PredictOutput, args: ADetailerArgs): merge_invert=args.ad_mask_merge_invert, ) if self.is_img2img_inpaint(p) and not self.is_inpaint_only_masked(p): - masks = self.inpaint_mask_filter(p.image_mask, masks) + invert = p.inpainting_mask_invert + image_mask = ensure_pil_image(p.image_mask, mode="L") + masks = self.inpaint_mask_filter(image_mask, masks, invert) return masks @staticmethod @@ -639,8 +641,10 @@ def is_inpaint_only_masked(p) -> bool: @staticmethod def inpaint_mask_filter( - img2img_mask: Image.Image, ad_mask: list[Image.Image] + img2img_mask: Image.Image, ad_mask: list[Image.Image], invert: int = 0 ) -> list[Image.Image]: + if invert: + img2img_mask = ImageChops.invert(img2img_mask) return [mask for mask in ad_mask if has_intersection(img2img_mask, mask)] @rich_traceback From 06de40673c12a3bafbef9a17aa0af76e98ae09c9 Mon Sep 17 00:00:00 2001 From: Dowon Date: Sat, 16 Mar 2024 23:24:38 +0900 Subject: [PATCH 17/19] chore: update ultralytics version -> 8.1.29 --- install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.py b/install.py index 1c21ad3..10efe7b 100644 --- a/install.py +++ b/install.py @@ -44,7 +44,7 @@ def run_pip(*args): def install(): deps = [ # requirements - ("ultralytics", "8.1.21", None), + ("ultralytics", "8.1.29", None), ("mediapipe", "0.10.9", None), ("rich", "13.0.0", None), # mediapipe From f11c182997e2bcf6b239117930789dcd2a31353a Mon Sep 17 00:00:00 2001 From: Dowon Date: Sat, 16 Mar 2024 23:25:28 +0900 Subject: [PATCH 18/19] docs: update readme --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 06ce487..1eb67ff 100644 --- a/README.md +++ b/README.md @@ -102,3 +102,11 @@ ADetailer works in three simple steps. 1. Create an image. 2. Detect object with a detection model and create a mask image. 3. Inpaint using the image from 1 and the mask from 2. + +## Development + +ADetailer is developed and tested using the stable-diffusion 1.5 model, for the [AUTOMATIC1111/stable-diffusion-webui](https://github.com/AUTOMATIC1111/stable-diffusion-webui) repository only. + +## License + +ADetailer is a derivative work that uses two AGPL-licensed works (stable-diffusion-webui, ultralytics) and is therefore distributed under the AGPL license. From bbd774e197ff467ec14f1d3cfce435298111569d Mon Sep 17 00:00:00 2001 From: Dowon Date: Sat, 16 Mar 2024 23:25:50 +0900 Subject: [PATCH 19/19] chore: v24.3.1 --- CHANGELOG.md | 6 ++++++ adetailer/__version__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb36ab2..da709bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2024-03-16 + +- YOLO World v2, YOLO9 지원가능한 버전으로 ultralytics 업데이트 +- inpaint full res인 경우 인페인트 모드에서 동작하게 변경 +- inpaint full res가 아닌 경우, 사용자가 입력한 마스크와 교차점이 있는 마스크만 선택하여 사용함 + ## 2024-03-01 - v24.3.0 diff --git a/adetailer/__version__.py b/adetailer/__version__.py index a4ea88b..c83195c 100644 --- a/adetailer/__version__.py +++ b/adetailer/__version__.py @@ -1 +1 @@ -__version__ = "24.3.1-dev.1" +__version__ = "24.3.1"