Merge pull request #5035 from kirtasshh/claude/sdnext-detailer-class-prompts-sunhax

Detailer: per-class prompts via [CLASS=name] tags
This commit is contained in:
Vladimir Mandic
2026-08-18 17:36:00 +02:00
committed by GitHub
3 changed files with 91 additions and 20 deletions
+1 -1
View File
@@ -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, parse_prompt_lines
from .detailer import Detailer
+39 -19
View File
@@ -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():
@@ -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,22 +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)
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 +242,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)
@@ -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:
+51
View File
@@ -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,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
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: