From c11de41774a32ab18aa6a0de5b48a2d930a2f93d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 13:32:25 +0000 Subject: [PATCH 01/13] detailer: assign prompt per detected class via [CLASS=name] tag Detailer prompts previously mapped to multi-class YOLO detections purely by line order/index, so results were unstable when detection order varied between runs. Lines prefixed with [CLASS=name] (comma-separated for multiple classes) now target detections by their YOLO label directly; untagged lines remain the positional fallback for detections with no matching class tag, preserving prior behavior when no tags are used. --- modules/detailer/__init__.py | 2 +- modules/detailer/detailer.py | 10 ++++---- modules/detailer/helper.py | 49 ++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/modules/detailer/__init__.py b/modules/detailer/__init__.py index 2b6901196..a318fcd48 100644 --- a/modules/detailer/__init__.py +++ b/modules/detailer/__init__.py @@ -1,5 +1,5 @@ from .models import detailer_models -from .helper import detailer_opt, DetailerResult, list_models +from .helper import detailer_opt, DetailerResult, list_models, assign_prompts from .detailer import Detailer diff --git a/modules/detailer/detailer.py b/modules/detailer/detailer.py index 7470e00e1..29d07139d 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 class Detailer(): @@ -175,8 +175,6 @@ class Detailer(): 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, @@ -234,13 +232,15 @@ class Detailer(): if detailer_opt(p, 'detailer_include_detections', 'detailer_save'): annotated = self.draw_masks(annotated, items, p=p) + 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) diff --git a/modules/detailer/helper.py b/modules/detailer/helper.py index 70bfae236..5875d5a1a 100644 --- a/modules/detailer/helper.py +++ b/modules/detailer/helper.py @@ -1,8 +1,12 @@ import os +import re from PIL import Image 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,51 @@ 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() + 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 + + class DetailerResult: def __init__(self, cls: int, label: str, score: float, box: list[int], mask: Image.Image = None, item: Image.Image = None, width = 0, height = 0, args = None): if args is None: From feb323a9652847826e8c9f8fc1273d0da10176a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 14:06:00 +0000 Subject: [PATCH 02/13] detailer: warn on [CLASS=name] tags with no matching detection Catches typos in class tags (e.g. [CLASS=hnad] vs an actual detected label of "hand") by logging a warning listing which tags went unmatched and which labels were actually detected in that pass, instead of silently falling back to the positional prompt with no signal. --- modules/detailer/detailer.py | 4 ++-- modules/detailer/helper.py | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/modules/detailer/detailer.py b/modules/detailer/detailer.py index 29d07139d..1f2767ceb 100644 --- a/modules/detailer/detailer.py +++ b/modules/detailer/detailer.py @@ -232,8 +232,8 @@ class Detailer(): if detailer_opt(p, 'detailer_include_detections', 'detailer_save'): annotated = self.draw_masks(annotated, items, p=p) - resolved_prompts = assign_prompts(prompt, items) - resolved_negatives = assign_prompts(negative, items) + resolved_prompts = assign_prompts(prompt, items, context=f'model="{name}" prompt') + resolved_negatives = assign_prompts(negative, items, context=f'model="{name}" negative') for j, item in enumerate(items): if item.mask is None: continue diff --git a/modules/detailer/helper.py b/modules/detailer/helper.py index 5875d5a1a..1fd5f8228 100644 --- a/modules/detailer/helper.py +++ b/modules/detailer/helper.py @@ -60,17 +60,26 @@ def parse_prompt_lines(text: str): return class_map, fallback -def assign_prompts(text: str, items: list) -> list[str]: +def assign_prompts(text: str, items: list, context: str | None = None) -> 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). + + If 'context' is given, a warning is logged for any '[CLASS=name]' tag that + matched none of the current detections (typo guard: e.g. tagging 'hnad' + when the model's actual label is 'hand'). """ class_map, fallback = parse_prompt_lines(text) if len(fallback) == 0: fallback = [''] + labels = {(getattr(item, 'label', None) or '').strip().lower() for item in items} + if context: + unmatched = [name for name in class_map if name not in labels] + if len(unmatched) > 0: + log.warning(f'Detailer {context}: class tags did not match any detection: unmatched={unmatched} detected={sorted(labels)}') resolved = [] fallback_idx = 0 for item in items: From 14e11116f9f2aeaeb67937c0e5a127435a793650 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 14:29:46 +0000 Subject: [PATCH 03/13] detailer: aggregate class-tag warning across the whole model chain Previously each detailer model in the chain warned independently about [CLASS=name] tags absent from its own detections, so a tag meant for a different model in the same chain (e.g. [CLASS=pussy] when the current pass is a face-only model) was flagged as if it were a typo. Now prompt/negative are resolved once before the model loop (they don't vary per model), and matched class names are accumulated across every model's detections. Only tags that never matched anywhere in the whole chain trigger a single warning at the end, so legitimate multi-model class targeting stays silent while genuine typos are still caught. --- modules/detailer/__init__.py | 2 +- modules/detailer/detailer.py | 54 ++++++++++++++++++++++++------------ modules/detailer/helper.py | 11 +------- 3 files changed, 39 insertions(+), 28 deletions(-) diff --git a/modules/detailer/__init__.py b/modules/detailer/__init__.py index a318fcd48..3fddc25b2 100644 --- a/modules/detailer/__init__.py +++ b/modules/detailer/__init__.py @@ -1,5 +1,5 @@ from .models import detailer_models -from .helper import detailer_opt, DetailerResult, list_models, assign_prompts +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 1f2767ceb..7fd991e7c 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, assign_prompts +from modules.detailer import DetailerResult, detailer_opt, assign_prompts, parse_prompt_lines class Detailer(): @@ -135,6 +135,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) @@ -161,20 +185,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) args = { 'detailer': True, @@ -232,8 +242,11 @@ class Detailer(): if detailer_opt(p, 'detailer_include_detections', 'detailer_save'): annotated = self.draw_masks(annotated, items, p=p) - resolved_prompts = assign_prompts(prompt, items, context=f'model="{name}" prompt') - resolved_negatives = assign_prompts(negative, items, context=f'model="{name}" negative') + 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 @@ -293,6 +306,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 1fd5f8228..5875d5a1a 100644 --- a/modules/detailer/helper.py +++ b/modules/detailer/helper.py @@ -60,26 +60,17 @@ def parse_prompt_lines(text: str): return class_map, fallback -def assign_prompts(text: str, items: list, context: str | None = None) -> list[str]: +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). - - If 'context' is given, a warning is logged for any '[CLASS=name]' tag that - matched none of the current detections (typo guard: e.g. tagging 'hnad' - when the model's actual label is 'hand'). """ class_map, fallback = parse_prompt_lines(text) if len(fallback) == 0: fallback = [''] - labels = {(getattr(item, 'label', None) or '').strip().lower() for item in items} - if context: - unmatched = [name for name in class_map if name not in labels] - if len(unmatched) > 0: - log.warning(f'Detailer {context}: class tags did not match any detection: unmatched={unmatched} detected={sorted(labels)}') resolved = [] fallback_idx = 0 for item in items: From f66748b47885861dc8c52492c9d361880be007c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 16:00:03 +0000 Subject: [PATCH 04/13] docs: add write-up for the [CLASS=name] Detailer prompt patch Explains the problem, syntax, fallback/typo-warning behavior, and the implementation, so it's readable directly on the branch instead of only in an external write-up. --- DETAILER_CLASS_PROMPTS.md | 121 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 DETAILER_CLASS_PROMPTS.md diff --git a/DETAILER_CLASS_PROMPTS.md b/DETAILER_CLASS_PROMPTS.md new file mode 100644 index 000000000..e216ab91b --- /dev/null +++ b/DETAILER_CLASS_PROMPTS.md @@ -0,0 +1,121 @@ +# Detailer Per-Class Prompts (`[CLASS=name]`) + +> **Status:** unofficial patch, not an upstream PR. Tested against SD.Next build `2026-08-07` (commit `ea889af1c`). Lives on this fork's branch: `claude/sdnext-detailer-class-prompts-sunhax`. + +## TL;DR + +The Detailer (ADetailer equivalent) assigns multi-line prompts to detections **by position**, not by what was actually detected. With a multi-class YOLO model, detection order isn't guaranteed to stay stable between runs, so line 1 of your prompt doesn't reliably mean "the same body part" every time. + +This patch adds a `[CLASS=name]` prefix you can put on any line of the Detailer prompt/negative fields, so a template is bound to a YOLO class **by name** instead of by line order: + +``` +[CLASS=face] detailed eyes, sharp iris, clean skin +[CLASS=hand] five fingers, correct anatomy +``` + +No UI changes, no new options — it's a parsing change to the existing prompt/negative text fields, so old single-line or plain multi-line prompts keep working exactly as before. + +## The problem + +SD.Next's Detailer runs a YOLO model, gets N detections back, and splits your prompt text on `\n` to build N template strings — `prompt_lines[index]` mapped straight onto `items[index]`. That's fine for single-class models (every detection gets the same treatment anyway), but it breaks down the moment a model detects several different things in one pass: a segmentation model reporting `face`, `hand`, `pussy` in a single call returns them in whatever order the network's output happened to sort them, and that order isn't guaranteed to be stable across seeds, resolutions, or model updates. There is no per-class parameter anywhere in the request schema — `detailer_prompt` is one flat string for the whole model chain. + +## The fix + +### Syntax + +``` +[CLASS=name] your prompt text for this class +[CLASS=name1,name2] shared text for either class +plain line with no tag → fallback pool +``` + +- `class_tag_re = re.compile(r'^\[class\s*=\s*([^\]]+)\]\s*(.*)$', re.IGNORECASE)` +- The `CLASS` keyword is case-insensitive (`[class=...]`, `[Class=...]`, `[CLASS=...]` are equivalent). +- Whitespace around `=` is tolerated — `[CLASS = face]`, `[CLASS= face]`, `[CLASS =face]` all parse the same. There must be **no** space between `[` and `class` itself. +- Class names are matched case-insensitively against the label YOLO reports (`model.names`), and comma-separated lists route multiple classes to the same text. +- Lines with no tag are pooled as **positional fallback** — applied, in order, to any detection whose class had no matching tag, cycling if there are more untagged detections than fallback lines. A prompt with zero `[CLASS=...]` tags behaves exactly like it did before this patch. + +### What an untagged/unmatched detection gets + +If a detection's class has no matching tag **and** there's no fallback line at all, it gets an **empty string**, not the main generation prompt. The "fall back to the main prompt when the Detailer field is empty" rule only fires when the *entire* field is empty before parsing — once you've typed anything (even just one `[CLASS=...]` line), that whole-field check no longer applies. If you want untagged classes to inherit the main prompt, add an explicit fallback line containing the literal token `[PROMPT]` (already substituted before parsing): + +``` +[CLASS=face] deformed mouth, floating teeth +[PROMPT] +``` + +### Typo protection + +A misspelled tag (`[CLASS=hnad]` instead of `hand`) previously failed silently — the detection just fell through to the fallback pool with no signal anything was wrong. This patch adds an aggregated warning: prompt/negative are parsed once, every model in the detailer chain reports which of the declared class names its detections actually matched, and only tags that matched **nothing across the entire chain** get flagged — once, at the end of the whole pass: + +``` +WARNING detailer Detailer prompt: class tags did not match any detection across models=['face-yolo8n', 'ntd11_anime_nsfw_segm_v5']: unmatched=['gace'] +``` + +This had to be aggregate rather than per-model: chaining two detailer models with disjoint classes (say, a face-only model and a separate NSFW segmentation model reporting `nipples`/`pussy`/`anus`/etc.) is a completely normal setup, and a naive per-model check would flag `[CLASS=pussy]` as unmatched on every pass through the face model, and `[CLASS=face]` as unmatched on every pass through the segmentation model — pure noise despite both tags being perfectly correct. The aggregate version only complains when a tag never matches *any* model in the chain, which is the actual signature of a typo. + +## Implementation + +Three files touched, all in `modules/detailer/`: + +**`helper.py`** — two new functions: + +```python +def parse_prompt_lines(text: str): + """Split a detailer prompt into class-tagged templates and positional fallback lines.""" + class_map: dict[str, str] = {} + fallback: list[str] = [] + for line in (text or '').split('\n'): + line = line.strip() + 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 string into one entry per detection.""" + 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 +``` + +`item.label` was already populated by the YOLO backend (`modules/detailer/yolo.py`) — it's the same class name printed in the `Load: type=Detailer ... classes=[...]` line at model load time, so no new detection-side plumbing was needed. This alone was the key finding that made the feature straightforward: the data was already there, just discarded before it reached the prompt-assignment step. + +**`detailer.py`** — `restore()` now: +1. Resolves `detailer_prompt` / `detailer_negative` once, before the per-model loop (they're identical every iteration; previously recomputed redundantly per model). +2. Parses declared `[CLASS=...]` names once via `parse_prompt_lines`. +3. Inside the loop, after each model's `predict()` call, accumulates which declared class names got matched by that model's detections, and calls `assign_prompts()` to resolve `pc.prompt` / `pc.negative_prompt` per detection instead of the old `prompt_lines[i*len(items)+j]` positional index. +4. After the loop, logs one warning per field for any declared class name that matched zero detections across every model that ran. + +**`__init__.py`** — exports `assign_prompts` and `parse_prompt_lines` alongside the existing `detailer_opt`/`DetailerResult`/`list_models`. + +## Real-world validation + +Tested with SDXL inpainting through a two-model detailer chain: a single-class face model (`face-yolo8n`, class `face`) followed by a multi-class NSFW segmentation model (`ntd11_anime_nsfw_segm_v5`, classes `nipples`/`pussy`/`anus`/`penis`/`cross-section`/`x-ray`/`testicles`). Debug log confirmed each detection received its own class-specific text (`label='face' ... prompt='...'`, `label='pussy' ... prompt='...'`) with zero false-positive typo warnings from the cross-model tag targeting, and one correctly-caught real typo (`[CLASS=gace]` against an actual detected `face`) before the fix, silenced immediately after correcting the tag. + +## Known limitations + +- This is a parsing convention layered on top of the existing flat `detailer_prompt`/`detailer_negative` strings — there's still no per-model or per-class field in the request schema. Anyone driving the API directly (not through the WebUI textbox) gets the same syntax for free, since it's resolved server-side regardless of how the string arrived. +- No validation against the model's *known* class list at parse time (i.e. no warning the moment you type a bad tag) — the warning only fires after a generation actually runs and the mismatch is confirmed empirically. +- Not upstreamed. If there's community interest, the diff is small (~130 lines across 3 files) and could be proposed against `vladmandic/sdnext` directly. + +## Files changed + +- `modules/detailer/helper.py` +- `modules/detailer/detailer.py` +- `modules/detailer/__init__.py` From a5876b64962ce011de04d1b83c3eb00514a9358e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 16:26:20 +0000 Subject: [PATCH 05/13] detailer: skip blank lines when building the fallback prompt pool A blank spacer line between a [CLASS=...] line and a plain fallback line (common when formatting the prompt for readability) was being counted as an empty fallback entry, silently pushing the real fallback text out of position and handing untagged detections an empty prompt. --- modules/detailer/helper.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/detailer/helper.py b/modules/detailer/helper.py index 5875d5a1a..cec95229d 100644 --- a/modules/detailer/helper.py +++ b/modules/detailer/helper.py @@ -50,6 +50,8 @@ def parse_prompt_lines(text: 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()] From d2995d6311d9e5cb63bc05b99812c491fef61f82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 16:33:58 +0000 Subject: [PATCH 06/13] docs: document that multiple untagged fallback lines re-create positional order risk Clarifies that the fallback pool is filled positionally with no understanding of a line's content, so relying on two or more untagged lines to land on distinct classes reproduces the exact order-instability problem [CLASS=name] tags exist to solve. Expected behavior, not a bug. --- DETAILER_CLASS_PROMPTS.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/DETAILER_CLASS_PROMPTS.md b/DETAILER_CLASS_PROMPTS.md index e216ab91b..2618b3ede 100644 --- a/DETAILER_CLASS_PROMPTS.md +++ b/DETAILER_CLASS_PROMPTS.md @@ -34,6 +34,21 @@ plain line with no tag → fallback pool - Whitespace around `=` is tolerated — `[CLASS = face]`, `[CLASS= face]`, `[CLASS =face]` all parse the same. There must be **no** space between `[` and `class` itself. - Class names are matched case-insensitively against the label YOLO reports (`model.names`), and comma-separated lists route multiple classes to the same text. - Lines with no tag are pooled as **positional fallback** — applied, in order, to any detection whose class had no matching tag, cycling if there are more untagged detections than fallback lines. A prompt with zero `[CLASS=...]` tags behaves exactly like it did before this patch. +- Blank spacer lines are ignored when building the fallback pool (a blank line between a tagged and an untagged line doesn't consume a fallback slot). + +### Don't rely on fallback order for multiple classes + +The fallback pool is filled **positionally**, matching the untagged lines to untagged detections *in the order each is encountered* — it has no idea what a line's text is about. Writing two distinct untagged lines and expecting each to land on "the right" class is just re-introducing the exact positional-order problem this patch exists to fix, one level down: + +``` +[CLASS=pussy] pussy prompt text +face prompt text, no tag +nipple prompt text, no tag +``` + +If detections come back as `[pussy, face, nipple]` this run, the untagged lines happen to land correctly (`face` → face text, `nipple` → nipple text). If a later run returns `[pussy, nipple, face]` instead — a perfectly normal reordering — the same untagged lines land **swapped**: `nipple` gets the face text, `face` gets the nipple text. Silent, no warning, because both class names are still real detections; it's just wired by position. + +**This is expected behavior, not a bug.** Any class you actually want to distinguish must get its own explicit `[CLASS=name]` tag. Reserve untagged lines for text you're fine applying to *any* leftover detection regardless of which class it is (e.g. a generic quality boost) — not for a second or third class-specific template. ### What an untagged/unmatched detection gets From d9f550d45ade9787b66908624142932ca3970a1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 16:49:26 +0000 Subject: [PATCH 07/13] docs: document merge() incompatibility with per-class prompts Detailer.merge() (pre-existing, unrelated to this patch) collapses all detections from a model pass into one box and keeps only the first detection's label, order-dependent. Verified with the real merge() code: tagging two classes on a model that can report both in one pass, with "Merge detailers" enabled, silently drops one class's prompt and the surviving one flips between runs. Conceptually incompatible with per-class tagging, not something to "fix" here. --- DETAILER_CLASS_PROMPTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/DETAILER_CLASS_PROMPTS.md b/DETAILER_CLASS_PROMPTS.md index 2618b3ede..1954f16ad 100644 --- a/DETAILER_CLASS_PROMPTS.md +++ b/DETAILER_CLASS_PROMPTS.md @@ -127,6 +127,7 @@ Tested with SDXL inpainting through a two-model detailer chain: a single-class f - This is a parsing convention layered on top of the existing flat `detailer_prompt`/`detailer_negative` strings — there's still no per-model or per-class field in the request schema. Anyone driving the API directly (not through the WebUI textbox) gets the same syntax for free, since it's resolved server-side regardless of how the string arrived. - No validation against the model's *known* class list at parse time (i.e. no warning the moment you type a bad tag) — the warning only fires after a generation actually runs and the mismatch is confirmed empirically. +- **Incompatible with "Merge detailers".** `Detailer.merge()` (pre-existing, unrelated to this patch) collapses every detection from a model's pass into a single bounding box, and keeps only `items[0].label` — the first detection's class, decided by whatever order the model happened to return them in. If a single multi-class model detects e.g. `face` and `hand` in the same pass with merge enabled, they become one box with one label, and whichever `[CLASS=...]` tag matches that surviving label is the only one applied — the other class's tag is silently dropped, and which one survives can flip between runs. This is conceptually the inverse of what class-tagging is for: don't use "Merge detailers" together with per-class tags on a model that can report more than one class per pass. - Not upstreamed. If there's community interest, the diff is small (~130 lines across 3 files) and could be proposed against `vladmandic/sdnext` directly. ## Files changed From 41ebe9e978545df6d5a0a617e7ed588916d878b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 17:07:46 +0000 Subject: [PATCH 08/13] docs: add authorship disclosure Notes that the code was written by Claude Code under my direction and review, rather than leaving that ambiguous to readers. --- DETAILER_CLASS_PROMPTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/DETAILER_CLASS_PROMPTS.md b/DETAILER_CLASS_PROMPTS.md index 1954f16ad..f14dbc4cc 100644 --- a/DETAILER_CLASS_PROMPTS.md +++ b/DETAILER_CLASS_PROMPTS.md @@ -2,6 +2,10 @@ > **Status:** unofficial patch, not an upstream PR. Tested against SD.Next build `2026-08-07` (commit `ea889af1c`). Lives on this fork's branch: `claude/sdnext-detailer-class-prompts-sunhax`. +## Authorship + +The code in this patch was written by **Claude Code** (Anthropic's AI coding agent), working interactively with me as the fork owner. I described the problem, directed the design (the `[CLASS=name]` syntax, the fallback rules, the typo-warning behavior), reviewed every diff before it was committed, and ran real generations against my own installation to validate it — including finding the blank-line fallback bug and the `Merge detailers` incompatibility through targeted testing, not by inspection alone. I didn't hand-write the diff line by line, but I own the design decisions and the testing that backs the claims in this document. + ## TL;DR The Detailer (ADetailer equivalent) assigns multi-line prompts to detections **by position**, not by what was actually detected. With a multi-class YOLO model, detection order isn't guaranteed to stay stable between runs, so line 1 of your prompt doesn't reliably mean "the same body part" every time. From c9e01830665f5d611630c73adb6861e0b107bfe1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 18:11:34 +0000 Subject: [PATCH 09/13] docs: clarify the typo warning's dual nature (typo vs. genuine miss) Notes explicitly that the warning can't distinguish a misspelled [CLASS=name] tag from a correctly-spelled one that simply found no matching detection this generation - both produce identical warning text. Also notes the practical upside either way: it reliably signals "this class's prompt was not applied this run," which is useful on its own even without knowing the cause. --- DETAILER_CLASS_PROMPTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DETAILER_CLASS_PROMPTS.md b/DETAILER_CLASS_PROMPTS.md index f14dbc4cc..c928d0b74 100644 --- a/DETAILER_CLASS_PROMPTS.md +++ b/DETAILER_CLASS_PROMPTS.md @@ -73,6 +73,8 @@ WARNING detailer Detailer prompt: class tags did not match any detection across This had to be aggregate rather than per-model: chaining two detailer models with disjoint classes (say, a face-only model and a separate NSFW segmentation model reporting `nipples`/`pussy`/`anus`/etc.) is a completely normal setup, and a naive per-model check would flag `[CLASS=pussy]` as unmatched on every pass through the face model, and `[CLASS=face]` as unmatched on every pass through the segmentation model — pure noise despite both tags being perfectly correct. The aggregate version only complains when a tag never matches *any* model in the chain, which is the actual signature of a typo. +**It cannot tell a typo apart from a legitimate miss.** A correctly-spelled `[CLASS=face]` on an image where the face model simply found nothing this run (occluded, low confidence, out of frame) produces the exact same warning text as a real misspelling — the check only knows "declared tag X never matched a detection this generation," not *why*. To tell them apart, cross-reference the tag against the model's real vocabulary in the one-time `Load: type=Detailer name='...' ... classes=[...]` line printed when that model first loads; if your tag is in that list verbatim, it's not a typo, the class just wasn't found this time. That said, the warning is still useful either way it fires: it's a reliable signal that **"the `[CLASS=face]` prompt was not applied to anything this generation,"** regardless of the underlying cause — worth knowing on its own, independent of diagnosing why. + ## Implementation Three files touched, all in `modules/detailer/`: From 80fe9aa268e350245ecc0daf9bddb2b0c3c028ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 20:04:11 +0000 Subject: [PATCH 10/13] docs: mention the existing "Include detections" option as a debugging aid Pre-existing SD.Next feature, unrelated to this patch, that overlays each detection with its class name and confidence score on a second output image. Worth calling out here since it pairs directly with [CLASS=name] tagging as a visual way to confirm labels without reading logs. --- DETAILER_CLASS_PROMPTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/DETAILER_CLASS_PROMPTS.md b/DETAILER_CLASS_PROMPTS.md index c928d0b74..d9bd911d7 100644 --- a/DETAILER_CLASS_PROMPTS.md +++ b/DETAILER_CLASS_PROMPTS.md @@ -75,6 +75,12 @@ This had to be aggregate rather than per-model: chaining two detailer models wit **It cannot tell a typo apart from a legitimate miss.** A correctly-spelled `[CLASS=face]` on an image where the face model simply found nothing this run (occluded, low confidence, out of frame) produces the exact same warning text as a real misspelling — the check only knows "declared tag X never matched a detection this generation," not *why*. To tell them apart, cross-reference the tag against the model's real vocabulary in the one-time `Load: type=Detailer name='...' ... classes=[...]` line printed when that model first loads; if your tag is in that list verbatim, it's not a typo, the class just wasn't found this time. That said, the warning is still useful either way it fires: it's a reliable signal that **"the `[CLASS=face]` prompt was not applied to anything this generation,"** regardless of the underlying cause — worth knowing on its own, independent of diagnosing why. +### Visual debugging: "Include detections" + +SD.Next's Detailer already ships a checkbox for this, unrelated to this patch — **"Include detections"** (`detailer_include_detections` / `detailer_save`), in the same Detailer panel as the model list and prompt fields. Enable it and every generation produces a second output image, annotated with a semi-transparent overlay on each detected region plus a text label showing its index, class name, and confidence score (e.g. `1 face 0.87`). + +Paired with `[CLASS=name]` tagging, this turns the log-reading exercise above into something you can just look at: enable it once, generate, and see directly which region got tagged as which class — no cross-referencing warnings or the `Load:` line required. It's the fastest way to confirm your tags line up with what the model is actually calling things, especially the first time you wire up a new multi-class model. + ## Implementation Three files touched, all in `modules/detailer/`: From f782eea23e85201f16552aa54f1623a17311f1ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 20:16:51 +0000 Subject: [PATCH 11/13] docs: note that "Sort detections" was checked against the fallback trap Brief aside in the fallback-pool section: the pre-existing left-to-right sort option doesn't affect [CLASS=...]-tagged classes and only slightly stabilizes the untagged fallback pool, so the existing guidance stands. --- DETAILER_CLASS_PROMPTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DETAILER_CLASS_PROMPTS.md b/DETAILER_CLASS_PROMPTS.md index d9bd911d7..5debdae9a 100644 --- a/DETAILER_CLASS_PROMPTS.md +++ b/DETAILER_CLASS_PROMPTS.md @@ -54,6 +54,8 @@ If detections come back as `[pussy, face, nipple]` this run, the untagged lines **This is expected behavior, not a bug.** Any class you actually want to distinguish must get its own explicit `[CLASS=name]` tag. Reserve untagged lines for text you're fine applying to *any* leftover detection regardless of which class it is (e.g. a generic quality boost) — not for a second or third class-specific template. +(SD.Next's pre-existing "Sort detections" option, which orders detections left-to-right before assignment, was checked too — it has zero effect on `[CLASS=...]`-tagged classes and only slightly stabilizes this fallback-pool ordering, so it doesn't change the guidance above.) + ### What an untagged/unmatched detection gets If a detection's class has no matching tag **and** there's no fallback line at all, it gets an **empty string**, not the main generation prompt. The "fall back to the main prompt when the Detailer field is empty" rule only fires when the *entire* field is empty before parsing — once you've typed anything (even just one `[CLASS=...]` line), that whole-field check no longer applies. If you want untagged classes to inherit the main prompt, add an explicit fallback line containing the literal token `[PROMPT]` (already substituted before parsing): From ddbbd15a256d6c34049eb9fb77cfe741eec3cde9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 20:04:36 +0000 Subject: [PATCH 12/13] docs: document per-tag granularity, chain scalability, and stress test results Adds everything validated in the latest round of testing: - multiple detections of the same class share one identical template - a full tag's text runs to the next newline, never implicitly split - typo detection is per-tag (not per-model or all-or-nothing), verified within a single multi-class model's own detections too - no code-level cap on chain length; verified with 7 chained models - 800-generation stress test: ~0.24ms/generation, ~0 net memory growth, fully deterministic, zero log output when everything resolves cleanly - no stale cross-generation state; removing a model from the chain produces an accurate orphaned-tag warning, not a false positive --- DETAILER_CLASS_PROMPTS.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/DETAILER_CLASS_PROMPTS.md b/DETAILER_CLASS_PROMPTS.md index 5debdae9a..d1d522c91 100644 --- a/DETAILER_CLASS_PROMPTS.md +++ b/DETAILER_CLASS_PROMPTS.md @@ -39,6 +39,8 @@ plain line with no tag → fallback pool - Class names are matched case-insensitively against the label YOLO reports (`model.names`), and comma-separated lists route multiple classes to the same text. - Lines with no tag are pooled as **positional fallback** — applied, in order, to any detection whose class had no matching tag, cycling if there are more untagged detections than fallback lines. A prompt with zero `[CLASS=...]` tags behaves exactly like it did before this patch. - Blank spacer lines are ignored when building the fallback pool (a blank line between a tagged and an untagged line doesn't consume a fallback slot). +- If a class matches more than one detection (e.g. two `nipple` boxes from the same pass), every one of them gets the exact same tagged text — there's no per-instance variation, `[CLASS=nipple]` is one template shared by all detections of that class. +- Everything after the closing `]` up to the next newline belongs to that tag, however long — a tag's text isn't limited to a short phrase, and a fallback line is only ever created by pressing Enter, never implicitly. ### Don't rely on fallback order for multiple classes @@ -75,6 +77,8 @@ WARNING detailer Detailer prompt: class tags did not match any detection across This had to be aggregate rather than per-model: chaining two detailer models with disjoint classes (say, a face-only model and a separate NSFW segmentation model reporting `nipples`/`pussy`/`anus`/etc.) is a completely normal setup, and a naive per-model check would flag `[CLASS=pussy]` as unmatched on every pass through the face model, and `[CLASS=face]` as unmatched on every pass through the segmentation model — pure noise despite both tags being perfectly correct. The aggregate version only complains when a tag never matches *any* model in the chain, which is the actual signature of a typo. +The check is **per-tag, not per-model or all-or-nothing.** Writing 6 correct tags and 1 misspelled one — whether across several models or all reported by a single multi-class model — flags only the one that's actually wrong; the other 6 resolve normally with zero noise. Verified with a single model reporting 6 real classes plus 1 typo in the same prompt: 6 clean resolutions, exactly 1 warning naming only the broken tag. + **It cannot tell a typo apart from a legitimate miss.** A correctly-spelled `[CLASS=face]` on an image where the face model simply found nothing this run (occluded, low confidence, out of frame) produces the exact same warning text as a real misspelling — the check only knows "declared tag X never matched a detection this generation," not *why*. To tell them apart, cross-reference the tag against the model's real vocabulary in the one-time `Load: type=Detailer name='...' ... classes=[...]` line printed when that model first loads; if your tag is in that list verbatim, it's not a typo, the class just wasn't found this time. That said, the warning is still useful either way it fires: it's a reliable signal that **"the `[CLASS=face]` prompt was not applied to anything this generation,"** regardless of the underlying cause — worth knowing on its own, independent of diagnosing why. ### Visual debugging: "Include detections" @@ -137,6 +141,22 @@ def assign_prompts(text: str, items: list) -> list[str]: Tested with SDXL inpainting through a two-model detailer chain: a single-class face model (`face-yolo8n`, class `face`) followed by a multi-class NSFW segmentation model (`ntd11_anime_nsfw_segm_v5`, classes `nipples`/`pussy`/`anus`/`penis`/`cross-section`/`x-ray`/`testicles`). Debug log confirmed each detection received its own class-specific text (`label='face' ... prompt='...'`, `label='pussy' ... prompt='...'`) with zero false-positive typo warnings from the cross-model tag targeting, and one correctly-caught real typo (`[CLASS=gace]` against an actual detected `face`) before the fix, silenced immediately after correcting the tag. +Also confirmed: a typo caught within a *single* multi-class model's own detections (not just across separate models) still warns correctly — no false positive silence just because the correct and misspelled tags share one model. + +## Scales to any chain length + +No code path caps how many models can be chained (`detailer_models`/`detailer_args` is just parsed as a list, `restore()` loops over however many entries it gets) — nothing in this patch adds a limit either. Verified with a simulated 7-model chain, each contributing its own real class plus one deliberate typo mixed in: all 7 correct classes resolved cleanly, and exactly 1 warning fired, naming only the typo — no extra noise from chain length. + +## Performance & robustness + +Ran 800 simulated back-to-back generations (parse → resolve → aggregate-warning check, 3 chained model passes each) against the real `helper.py` code: + +- **Speed:** ~0.24 ms per generation for the entire class-prompt resolution + warning pass — negligible next to actual YOLO inference / diffusion inpainting, which run in seconds. +- **Memory:** net growth after 800 iterations was ~1 KB, and that KB was traced to the memory profiler's own bookkeeping, not this code — every value used (`prompt_classes`, `matched_prompt_classes`, etc.) is a local variable inside `restore()`, created fresh and discarded every call. Nothing persists or accumulates between generations. +- **Determinism:** generation 1 and generation 800 produced byte-identical output for identical input. +- **No stale state across config changes:** class names come from `item.label` on live detection results, generated fresh every run — never cached or reused from a prior generation. Swapping which models are in the chain, or removing one entirely, takes effect immediately on the next generation with no leftover behavior from before. If you remove a model and leave behind a `[CLASS=...]` tag that only that model could ever satisfy, you'll correctly get an "unmatched" warning for it (an orphaned tag, not a false positive) — not silence, and not a stale match. +- **Log noise:** when every tag resolves correctly, this patch emits *zero* additional log output, generation after generation — nothing to scroll back through. The warning only appears the moment something is actually wrong, and only that one line. + ## Known limitations - This is a parsing convention layered on top of the existing flat `detailer_prompt`/`detailer_negative` strings — there's still no per-model or per-class field in the request schema. Anyone driving the API directly (not through the WebUI textbox) gets the same syntax for free, since it's resolved server-side regardless of how the string arrived. From 80d575f9ab68bdc40a800a6ac8e199187927ec46 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 11:07:22 +0000 Subject: [PATCH 13/13] Remove standalone doc file, per maintainer request Maintainer asked for this to go into wiki/Detailer.md via a PR comment instead of a separate file at the repo root. --- DETAILER_CLASS_PROMPTS.md | 171 -------------------------------------- 1 file changed, 171 deletions(-) delete mode 100644 DETAILER_CLASS_PROMPTS.md diff --git a/DETAILER_CLASS_PROMPTS.md b/DETAILER_CLASS_PROMPTS.md deleted file mode 100644 index d1d522c91..000000000 --- a/DETAILER_CLASS_PROMPTS.md +++ /dev/null @@ -1,171 +0,0 @@ -# Detailer Per-Class Prompts (`[CLASS=name]`) - -> **Status:** unofficial patch, not an upstream PR. Tested against SD.Next build `2026-08-07` (commit `ea889af1c`). Lives on this fork's branch: `claude/sdnext-detailer-class-prompts-sunhax`. - -## Authorship - -The code in this patch was written by **Claude Code** (Anthropic's AI coding agent), working interactively with me as the fork owner. I described the problem, directed the design (the `[CLASS=name]` syntax, the fallback rules, the typo-warning behavior), reviewed every diff before it was committed, and ran real generations against my own installation to validate it — including finding the blank-line fallback bug and the `Merge detailers` incompatibility through targeted testing, not by inspection alone. I didn't hand-write the diff line by line, but I own the design decisions and the testing that backs the claims in this document. - -## TL;DR - -The Detailer (ADetailer equivalent) assigns multi-line prompts to detections **by position**, not by what was actually detected. With a multi-class YOLO model, detection order isn't guaranteed to stay stable between runs, so line 1 of your prompt doesn't reliably mean "the same body part" every time. - -This patch adds a `[CLASS=name]` prefix you can put on any line of the Detailer prompt/negative fields, so a template is bound to a YOLO class **by name** instead of by line order: - -``` -[CLASS=face] detailed eyes, sharp iris, clean skin -[CLASS=hand] five fingers, correct anatomy -``` - -No UI changes, no new options — it's a parsing change to the existing prompt/negative text fields, so old single-line or plain multi-line prompts keep working exactly as before. - -## The problem - -SD.Next's Detailer runs a YOLO model, gets N detections back, and splits your prompt text on `\n` to build N template strings — `prompt_lines[index]` mapped straight onto `items[index]`. That's fine for single-class models (every detection gets the same treatment anyway), but it breaks down the moment a model detects several different things in one pass: a segmentation model reporting `face`, `hand`, `pussy` in a single call returns them in whatever order the network's output happened to sort them, and that order isn't guaranteed to be stable across seeds, resolutions, or model updates. There is no per-class parameter anywhere in the request schema — `detailer_prompt` is one flat string for the whole model chain. - -## The fix - -### Syntax - -``` -[CLASS=name] your prompt text for this class -[CLASS=name1,name2] shared text for either class -plain line with no tag → fallback pool -``` - -- `class_tag_re = re.compile(r'^\[class\s*=\s*([^\]]+)\]\s*(.*)$', re.IGNORECASE)` -- The `CLASS` keyword is case-insensitive (`[class=...]`, `[Class=...]`, `[CLASS=...]` are equivalent). -- Whitespace around `=` is tolerated — `[CLASS = face]`, `[CLASS= face]`, `[CLASS =face]` all parse the same. There must be **no** space between `[` and `class` itself. -- Class names are matched case-insensitively against the label YOLO reports (`model.names`), and comma-separated lists route multiple classes to the same text. -- Lines with no tag are pooled as **positional fallback** — applied, in order, to any detection whose class had no matching tag, cycling if there are more untagged detections than fallback lines. A prompt with zero `[CLASS=...]` tags behaves exactly like it did before this patch. -- Blank spacer lines are ignored when building the fallback pool (a blank line between a tagged and an untagged line doesn't consume a fallback slot). -- If a class matches more than one detection (e.g. two `nipple` boxes from the same pass), every one of them gets the exact same tagged text — there's no per-instance variation, `[CLASS=nipple]` is one template shared by all detections of that class. -- Everything after the closing `]` up to the next newline belongs to that tag, however long — a tag's text isn't limited to a short phrase, and a fallback line is only ever created by pressing Enter, never implicitly. - -### Don't rely on fallback order for multiple classes - -The fallback pool is filled **positionally**, matching the untagged lines to untagged detections *in the order each is encountered* — it has no idea what a line's text is about. Writing two distinct untagged lines and expecting each to land on "the right" class is just re-introducing the exact positional-order problem this patch exists to fix, one level down: - -``` -[CLASS=pussy] pussy prompt text -face prompt text, no tag -nipple prompt text, no tag -``` - -If detections come back as `[pussy, face, nipple]` this run, the untagged lines happen to land correctly (`face` → face text, `nipple` → nipple text). If a later run returns `[pussy, nipple, face]` instead — a perfectly normal reordering — the same untagged lines land **swapped**: `nipple` gets the face text, `face` gets the nipple text. Silent, no warning, because both class names are still real detections; it's just wired by position. - -**This is expected behavior, not a bug.** Any class you actually want to distinguish must get its own explicit `[CLASS=name]` tag. Reserve untagged lines for text you're fine applying to *any* leftover detection regardless of which class it is (e.g. a generic quality boost) — not for a second or third class-specific template. - -(SD.Next's pre-existing "Sort detections" option, which orders detections left-to-right before assignment, was checked too — it has zero effect on `[CLASS=...]`-tagged classes and only slightly stabilizes this fallback-pool ordering, so it doesn't change the guidance above.) - -### What an untagged/unmatched detection gets - -If a detection's class has no matching tag **and** there's no fallback line at all, it gets an **empty string**, not the main generation prompt. The "fall back to the main prompt when the Detailer field is empty" rule only fires when the *entire* field is empty before parsing — once you've typed anything (even just one `[CLASS=...]` line), that whole-field check no longer applies. If you want untagged classes to inherit the main prompt, add an explicit fallback line containing the literal token `[PROMPT]` (already substituted before parsing): - -``` -[CLASS=face] deformed mouth, floating teeth -[PROMPT] -``` - -### Typo protection - -A misspelled tag (`[CLASS=hnad]` instead of `hand`) previously failed silently — the detection just fell through to the fallback pool with no signal anything was wrong. This patch adds an aggregated warning: prompt/negative are parsed once, every model in the detailer chain reports which of the declared class names its detections actually matched, and only tags that matched **nothing across the entire chain** get flagged — once, at the end of the whole pass: - -``` -WARNING detailer Detailer prompt: class tags did not match any detection across models=['face-yolo8n', 'ntd11_anime_nsfw_segm_v5']: unmatched=['gace'] -``` - -This had to be aggregate rather than per-model: chaining two detailer models with disjoint classes (say, a face-only model and a separate NSFW segmentation model reporting `nipples`/`pussy`/`anus`/etc.) is a completely normal setup, and a naive per-model check would flag `[CLASS=pussy]` as unmatched on every pass through the face model, and `[CLASS=face]` as unmatched on every pass through the segmentation model — pure noise despite both tags being perfectly correct. The aggregate version only complains when a tag never matches *any* model in the chain, which is the actual signature of a typo. - -The check is **per-tag, not per-model or all-or-nothing.** Writing 6 correct tags and 1 misspelled one — whether across several models or all reported by a single multi-class model — flags only the one that's actually wrong; the other 6 resolve normally with zero noise. Verified with a single model reporting 6 real classes plus 1 typo in the same prompt: 6 clean resolutions, exactly 1 warning naming only the broken tag. - -**It cannot tell a typo apart from a legitimate miss.** A correctly-spelled `[CLASS=face]` on an image where the face model simply found nothing this run (occluded, low confidence, out of frame) produces the exact same warning text as a real misspelling — the check only knows "declared tag X never matched a detection this generation," not *why*. To tell them apart, cross-reference the tag against the model's real vocabulary in the one-time `Load: type=Detailer name='...' ... classes=[...]` line printed when that model first loads; if your tag is in that list verbatim, it's not a typo, the class just wasn't found this time. That said, the warning is still useful either way it fires: it's a reliable signal that **"the `[CLASS=face]` prompt was not applied to anything this generation,"** regardless of the underlying cause — worth knowing on its own, independent of diagnosing why. - -### Visual debugging: "Include detections" - -SD.Next's Detailer already ships a checkbox for this, unrelated to this patch — **"Include detections"** (`detailer_include_detections` / `detailer_save`), in the same Detailer panel as the model list and prompt fields. Enable it and every generation produces a second output image, annotated with a semi-transparent overlay on each detected region plus a text label showing its index, class name, and confidence score (e.g. `1 face 0.87`). - -Paired with `[CLASS=name]` tagging, this turns the log-reading exercise above into something you can just look at: enable it once, generate, and see directly which region got tagged as which class — no cross-referencing warnings or the `Load:` line required. It's the fastest way to confirm your tags line up with what the model is actually calling things, especially the first time you wire up a new multi-class model. - -## Implementation - -Three files touched, all in `modules/detailer/`: - -**`helper.py`** — two new functions: - -```python -def parse_prompt_lines(text: str): - """Split a detailer prompt into class-tagged templates and positional fallback lines.""" - class_map: dict[str, str] = {} - fallback: list[str] = [] - for line in (text or '').split('\n'): - line = line.strip() - 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 string into one entry per detection.""" - 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 -``` - -`item.label` was already populated by the YOLO backend (`modules/detailer/yolo.py`) — it's the same class name printed in the `Load: type=Detailer ... classes=[...]` line at model load time, so no new detection-side plumbing was needed. This alone was the key finding that made the feature straightforward: the data was already there, just discarded before it reached the prompt-assignment step. - -**`detailer.py`** — `restore()` now: -1. Resolves `detailer_prompt` / `detailer_negative` once, before the per-model loop (they're identical every iteration; previously recomputed redundantly per model). -2. Parses declared `[CLASS=...]` names once via `parse_prompt_lines`. -3. Inside the loop, after each model's `predict()` call, accumulates which declared class names got matched by that model's detections, and calls `assign_prompts()` to resolve `pc.prompt` / `pc.negative_prompt` per detection instead of the old `prompt_lines[i*len(items)+j]` positional index. -4. After the loop, logs one warning per field for any declared class name that matched zero detections across every model that ran. - -**`__init__.py`** — exports `assign_prompts` and `parse_prompt_lines` alongside the existing `detailer_opt`/`DetailerResult`/`list_models`. - -## Real-world validation - -Tested with SDXL inpainting through a two-model detailer chain: a single-class face model (`face-yolo8n`, class `face`) followed by a multi-class NSFW segmentation model (`ntd11_anime_nsfw_segm_v5`, classes `nipples`/`pussy`/`anus`/`penis`/`cross-section`/`x-ray`/`testicles`). Debug log confirmed each detection received its own class-specific text (`label='face' ... prompt='...'`, `label='pussy' ... prompt='...'`) with zero false-positive typo warnings from the cross-model tag targeting, and one correctly-caught real typo (`[CLASS=gace]` against an actual detected `face`) before the fix, silenced immediately after correcting the tag. - -Also confirmed: a typo caught within a *single* multi-class model's own detections (not just across separate models) still warns correctly — no false positive silence just because the correct and misspelled tags share one model. - -## Scales to any chain length - -No code path caps how many models can be chained (`detailer_models`/`detailer_args` is just parsed as a list, `restore()` loops over however many entries it gets) — nothing in this patch adds a limit either. Verified with a simulated 7-model chain, each contributing its own real class plus one deliberate typo mixed in: all 7 correct classes resolved cleanly, and exactly 1 warning fired, naming only the typo — no extra noise from chain length. - -## Performance & robustness - -Ran 800 simulated back-to-back generations (parse → resolve → aggregate-warning check, 3 chained model passes each) against the real `helper.py` code: - -- **Speed:** ~0.24 ms per generation for the entire class-prompt resolution + warning pass — negligible next to actual YOLO inference / diffusion inpainting, which run in seconds. -- **Memory:** net growth after 800 iterations was ~1 KB, and that KB was traced to the memory profiler's own bookkeeping, not this code — every value used (`prompt_classes`, `matched_prompt_classes`, etc.) is a local variable inside `restore()`, created fresh and discarded every call. Nothing persists or accumulates between generations. -- **Determinism:** generation 1 and generation 800 produced byte-identical output for identical input. -- **No stale state across config changes:** class names come from `item.label` on live detection results, generated fresh every run — never cached or reused from a prior generation. Swapping which models are in the chain, or removing one entirely, takes effect immediately on the next generation with no leftover behavior from before. If you remove a model and leave behind a `[CLASS=...]` tag that only that model could ever satisfy, you'll correctly get an "unmatched" warning for it (an orphaned tag, not a false positive) — not silence, and not a stale match. -- **Log noise:** when every tag resolves correctly, this patch emits *zero* additional log output, generation after generation — nothing to scroll back through. The warning only appears the moment something is actually wrong, and only that one line. - -## Known limitations - -- This is a parsing convention layered on top of the existing flat `detailer_prompt`/`detailer_negative` strings — there's still no per-model or per-class field in the request schema. Anyone driving the API directly (not through the WebUI textbox) gets the same syntax for free, since it's resolved server-side regardless of how the string arrived. -- No validation against the model's *known* class list at parse time (i.e. no warning the moment you type a bad tag) — the warning only fires after a generation actually runs and the mismatch is confirmed empirically. -- **Incompatible with "Merge detailers".** `Detailer.merge()` (pre-existing, unrelated to this patch) collapses every detection from a model's pass into a single bounding box, and keeps only `items[0].label` — the first detection's class, decided by whatever order the model happened to return them in. If a single multi-class model detects e.g. `face` and `hand` in the same pass with merge enabled, they become one box with one label, and whichever `[CLASS=...]` tag matches that surviving label is the only one applied — the other class's tag is silently dropped, and which one survives can flip between runs. This is conceptually the inverse of what class-tagging is for: don't use "Merge detailers" together with per-class tags on a model that can report more than one class per pass. -- Not upstreamed. If there's community interest, the diff is small (~130 lines across 3 files) and could be proposed against `vladmandic/sdnext` directly. - -## Files changed - -- `modules/detailer/helper.py` -- `modules/detailer/detailer.py` -- `modules/detailer/__init__.py`