diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 643758b4c..f6a80e082 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,4 +1,4 @@ -name: lint +name: Lint Project on: - push @@ -15,13 +15,13 @@ jobs: steps: - name: checkout-code - uses: actions/checkout@main + uses: actions/checkout@v4 - name: install-uv run: curl -LsSf https://astral.sh/uv/install.sh | sh - name: setup-python - uses: actions/setup-python@main + uses: actions/setup-python@v5 with: python-version: 3.12.3 @@ -29,7 +29,7 @@ jobs: run: uv pip install ruff pylint pre-commit --system - name: setup-node - uses: actions/setup-node@main + uses: actions/setup-node@v4 with: node-version: 24 @@ -44,7 +44,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - name: cache-pnpm-store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} @@ -55,7 +55,7 @@ jobs: run: pnpm install --frozen-lockfile --unsafe-perm - name: pre-commit - uses: pre-commit-ci/lite-action@v1.1.0 + uses: pre-commit-ci/lite-action@v1.2.0 if: always() with: msg: apply code formatting and linting auto-fixes diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index cf8ba5e51..3450610d8 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,4 +1,4 @@ -name: github-pages +name: Build GitHub Pages on: push: diff --git a/.github/workflows/sponsors.yaml b/.github/workflows/sponsors.yaml index 68a6e9a49..e1db0bd47 100644 --- a/.github/workflows/sponsors.yaml +++ b/.github/workflows/sponsors.yaml @@ -1,4 +1,4 @@ -name: readme-sponsors +name: Edit Readme Sponsors on: workflow_dispatch: diff --git a/modules/detailer/__init__.py b/modules/detailer/__init__.py index f0bf2e64d..3fddc25b2 100644 --- a/modules/detailer/__init__.py +++ b/modules/detailer/__init__.py @@ -1,5 +1,5 @@ from .models import detailer_models -from .helper import DetailerResult, detailer_opt, list_models, get_mask +from .helper import detailer_opt, DetailerResult, list_models, assign_prompts, parse_prompt_lines from .detailer import Detailer diff --git a/modules/detailer/detailer.py b/modules/detailer/detailer.py index e480232af..752e9fbdc 100644 --- a/modules/detailer/detailer.py +++ b/modules/detailer/detailer.py @@ -5,7 +5,7 @@ import gradio as gr from PIL import Image, ImageDraw from modules.logger import log from modules import shared, processing, devices, processing_class, ui_common, ui_components, ui_symbols, images, extra_networks, sd_models -from modules.detailer import DetailerResult, detailer_opt +from modules.detailer import DetailerResult, detailer_opt, assign_prompts, parse_prompt_lines class Detailer(): @@ -195,6 +195,30 @@ class Detailer(): annotated = Image.fromarray(np_image) image = None + # detailer_prompt/negative are the same for every model in the chain, so resolve them once + orig_prompt: str = orig_p.get('all_prompts', [''])[0] + orig_negative: str = orig_p.get('all_negative_prompts', [''])[0] + prompt: str = orig_p.get('detailer_prompt', '') + negative: str = orig_p.get('detailer_negative', '') + if prompt is None or len(prompt) == 0: + prompt = orig_prompt + else: + prompt = prompt.replace('[PROMPT]', orig_prompt) + prompt = prompt.replace('[prompt]', orig_prompt) + if len(negative) == 0: + negative = orig_negative + else: + negative = negative.replace('[PROMPT]', orig_negative) + negative = negative.replace('[prompt]', orig_negative) + + # track which '[CLASS=name]' tags get matched by any model in the chain, to warn on genuine typos only + prompt_classes, _ = parse_prompt_lines(prompt) + negative_classes, _ = parse_prompt_lines(negative) + prompt_classes = set(prompt_classes.keys()) + negative_classes = set(negative_classes.keys()) + matched_prompt_classes = set() + matched_negative_classes = set() + for i, model_val in enumerate(models): if ':' in model_val: model_name, model_args = model_val.split(':', 1) @@ -222,22 +246,6 @@ class Detailer(): items = self.merge(items) shared.opts.data['mask_apply_overlay'] = True - orig_prompt: str = orig_p.get('all_prompts', [''])[0] - orig_negative: str = orig_p.get('all_negative_prompts', [''])[0] - prompt: str = orig_p.get('detailer_prompt', '') - negative: str = orig_p.get('detailer_negative', '') - if prompt is None or len(prompt) == 0: - prompt = orig_prompt - else: - prompt = prompt.replace('[PROMPT]', orig_prompt) - prompt = prompt.replace('[prompt]', orig_prompt) - if len(negative) == 0: - negative = orig_negative - else: - negative = negative.replace('[PROMPT]', orig_negative) - negative = negative.replace('[prompt]', orig_negative) - prompt_lines = 99 * [p.strip() for p in prompt.split('\n')] - negative_lines = 99 * [n.strip() for n in negative.split('\n')] args = { 'detailer': True, @@ -295,13 +303,18 @@ class Detailer(): if detailer_opt(p, 'detailer_include_detections', 'detailer_save'): annotated = self.draw_masks(annotated, items, p=p) + labels_this_pass = {(item.label or '').strip().lower() for item in items} + matched_prompt_classes |= (prompt_classes & labels_this_pass) + matched_negative_classes |= (negative_classes & labels_this_pass) + resolved_prompts = assign_prompts(prompt, items) + resolved_negatives = assign_prompts(negative, items) for j, item in enumerate(items): if item.mask is None: continue pc.keep_prompts = True shared.sd_model.fail_on_switch_error = True - pc.prompt = prompt_lines[i*len(items)+j] - pc.negative_prompt = negative_lines[i*len(items)+j] + pc.prompt = resolved_prompts[j] + pc.negative_prompt = resolved_negatives[j] pc.prompts = [pc.prompt] pc.negative_prompts = [pc.negative_prompt] pc.prompts, pc.network_data = extra_networks.parse_prompts(pc.prompts, pc.network_data) @@ -354,6 +367,13 @@ class Detailer(): p.image_mask = blend([np.array(m) for m in mask_all]) p.image_mask = Image.fromarray(p.image_mask) + unmatched_prompt = prompt_classes - matched_prompt_classes + if len(unmatched_prompt) > 0: + log.warning(f'Detailer prompt: class tags did not match any detection across models={models}: unmatched={sorted(unmatched_prompt)}') + unmatched_negative = negative_classes - matched_negative_classes + if len(unmatched_negative) > 0: + log.warning(f'Detailer negative: class tags did not match any detection across models={models}: unmatched={sorted(unmatched_negative)}') + if image is not None: np_images.append(np.array(image)) if detailer_opt(p, 'detailer_include_detections', 'detailer_save') and annotated is not None: diff --git a/modules/detailer/helper.py b/modules/detailer/helper.py index fa6af9f86..2f721c112 100644 --- a/modules/detailer/helper.py +++ b/modules/detailer/helper.py @@ -1,8 +1,12 @@ import os +import re from PIL import Image, ImageDraw from modules.logger import log +class_tag_re = re.compile(r'^\[class\s*=\s*([^\]]+)\]\s*(.*)$', re.IGNORECASE) + + def list_models(self): from modules.detailer import detailer_models from modules import shared @@ -34,6 +38,53 @@ def detailer_opt(p, attr, opts_attr=None): return getattr(shared.opts, opts_attr or attr, None) +def parse_prompt_lines(text: str): + """Split a detailer prompt into class-tagged templates and positional fallback lines. + + A line starting with '[CLASS=name]' or '[CLASS=name1,name2]' assigns its text to every + detection whose label matches one of the given class names (case-insensitive). All + other non-empty lines are kept, in order, as the legacy positional fallback used for + detections that don't match any class tag. + """ + class_map: dict[str, str] = {} + fallback: list[str] = [] + for line in (text or '').split('\n'): + line = line.strip() + if len(line) == 0: + continue # blank spacer lines don't count as a fallback entry + m = class_tag_re.match(line) + if m: + names = [n.strip().lower() for n in m.group(1).split(',') if n.strip()] + for name in names: + class_map[name] = m.group(2).strip() + else: + fallback.append(line) + return class_map, fallback + + +def assign_prompts(text: str, items: list) -> list[str]: + """Resolve a detailer prompt/negative-prompt string into one entry per detection. + + Detections whose YOLO label matches a '[CLASS=name]' tag get that tag's text. + Remaining detections fall back to the untagged lines, applied positionally in + detection order and cycling if there are more detections than fallback lines + (matching prior behavior when no class tags are used). + """ + class_map, fallback = parse_prompt_lines(text) + if len(fallback) == 0: + fallback = [''] + resolved = [] + fallback_idx = 0 + for item in items: + label = (getattr(item, 'label', None) or '').strip().lower() + if label in class_map: + resolved.append(class_map[label]) + else: + resolved.append(fallback[fallback_idx % len(fallback)]) + fallback_idx += 1 + return resolved + + def get_mask(box: list[int], image: Image.Image, include_mask: bool = True) -> tuple[Image.Image | None, Image.Image]: cropped = image.crop(box) if not include_mask: