mirror of
https://github.com/vladmandic/automatic
synced 2026-09-18 16:54:33 +02:00
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: github-pages
|
||||
name: Build GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: readme-sponsors
|
||||
name: Edit Readme Sponsors
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user