Whyx-PROmpTea / src /variation_engine.py
ArtShumov's picture
fix: pose tag naming + swap DWPose detector to YOLOv8m-pose for anime
4fb75d4
Raw
History Blame Contribute Delete
71.4 kB
import random
import hashlib
import json as _json
import os as _os
from copy import deepcopy
from src.prompt_parser import ParsedPrompt
from src.tag_warehouse import TagWarehouse, MAX_RATING_MAP
from src.model_formatter import format_prompt, _is_score_tag
from src.tag_format import to_internal_tag, normalize_tag
from src.synonym_filter import apply_synonym_filter, has_synonym_conflict
from src.prompt_rewriter import get_tag_categories
from src.presets import PRESETS, get_preset_bundle_tags
from src.dedup_engine import smart_dedup
from src.synonym_data import _find_synonym_group, get_synonym_groups
from src.tag_searcher import has_human_subject, filter_subject_conflicts, get_cooccurrence_tags
from src.semantic_coherence import (
semantic_pick_tags, split_core_decorative, detect_intent,
compute_theme_budget, _THEME_GROUPS, SCORING,
)
CREATIVITY_SETTINGS = {
"very_low": {
"tags_per_category": (1, 1),
"replacement_rate": 0.1,
"shuffle_general": False,
"concept_cross": False,
"diversity_threshold": 0.5,
"wildcard_categories": 0,
"substitution_chance": 0.0,
},
"low": {
"tags_per_category": (1, 2),
"replacement_rate": 0.2,
"shuffle_general": False,
"concept_cross": False,
"diversity_threshold": 0.4,
"wildcard_categories": 0,
"substitution_chance": 0.05,
},
"medium": {
"tags_per_category": (2, 4),
"replacement_rate": 0.3,
"shuffle_general": False,
"concept_cross": False,
"diversity_threshold": 0.3,
"wildcard_categories": 0,
"substitution_chance": 0.1,
},
"high": {
"tags_per_category": (3, 6),
"replacement_rate": 0.4,
"shuffle_general": True,
"concept_cross": True,
"diversity_threshold": 0.25,
"wildcard_categories": 1,
"substitution_chance": 0.15,
},
"very_high": {
"tags_per_category": (4, 8),
"replacement_rate": 0.5,
"shuffle_general": True,
"concept_cross": True,
"diversity_threshold": 0.2,
"wildcard_categories": 2,
"substitution_chance": 0.2,
},
"extreme": {
"tags_per_category": (5, 10),
"replacement_rate": 0.6,
"shuffle_general": True,
"concept_cross": True,
"diversity_threshold": 0.15,
"wildcard_categories": 3,
"substitution_chance": 0.3,
},
}
CONFLICT_GROUPS = [
{"from above", "from below", "from side", "from behind", "birds-eye view", "worms-eye view"},
{"smile", "laughing", "serious", "angry", "sad", "crying", "surprised", "expressionless"},
{"standing", "sitting", "lying", "kneeling", "running", "jumping", "dancing", "crouching"},
{"simple background", "detailed background", "complex background", "gradient background"},
{"day", "night", "twilight", "sunset", "sunrise"},
{"portrait", "full body", "upper body", "lower body", "cowboy shot", "wide shot"},
{"soft lighting", "hard lighting", "harsh lighting"},
{"warm colors", "cool colors", "monochrome", "vibrant colors", "pastel colors", "dark colors", "neon palette"},
{"looking at viewer", "looking away", "looking up", "looking down", "looking back"},
{"sunlight", "moonlight"},
{"cloudy sky", "starry sky", "clear sky"},
{"misty", "foggy"},
{"indoors", "outdoors"},
{"innocent", "seductive smile"},
{"rainy", "snowy"},
{"close-up", "wide shot", "extreme close-up"},
{"chibi", "photorealistic"},
{"bloom", "soft focus"},
{"underwater", "space", "cityscape"},
{"hoodie", "poncho", "t-shirt", "crop top", "sweater", "jacket", "coat", "blazer", "vest", "cardigan", "blouse", "shirt", "tank top", "cape", "cloak", "robe", "dress", "kimono", "yukata", "qipao", "overalls", "suit", "armor"},
{"jeans", "shorts", "skirt", "mini skirt", "leggings", "sweatpants", "trousers", "hot pants", "long skirt", "pleated skirt"},
{"shoes", "boots", "sandals", "sneakers", "heels", "loafers", "flip-flops", "socks", "thighhighs", "kneehighs"},
{"hat", "cap", "beanie", "crown", "hood", "veil", "witch hat", "tara", "hairband", "headband", "maid headdress", "cat ears", "fox ears", "animal ears"},
{"confident", "shy", "timid", "bashful", "embarrassed", "proud", "self-assured"},
]
_CONFLICT_INDEX = None
_CONFLICT_ONLY_INDEX = None
def _build_conflict_index() -> dict[str, set]:
global _CONFLICT_INDEX
global _CONFLICT_ONLY_INDEX
index: dict[str, set] = {}
only: dict[str, set] = {}
for group in CONFLICT_GROUPS:
for member in group:
ml = member.lower().strip()
index[ml] = group
only[ml] = group
for group in get_synonym_groups():
for member in group:
ml = member.lower().strip()
if ml in index:
index[ml] = index[ml] | group
else:
index[ml] = group
_CONFLICT_INDEX = index
_CONFLICT_ONLY_INDEX = only
return _CONFLICT_INDEX
def _find_conflict_group(tag: str) -> set | None:
global _CONFLICT_INDEX
if _CONFLICT_INDEX is None:
_build_conflict_index()
return _CONFLICT_INDEX.get(tag.lower().strip())
def _find_conflict_only_group(tag: str) -> set | None:
"""Conflict group WITHOUT synonym members.
Used for negative-prompt inversion: a synonym of a desired tag shares its
meaning, so it must NOT be negated (that would fight the positive prompt).
"""
global _CONFLICT_ONLY_INDEX
if _CONFLICT_ONLY_INDEX is None:
_build_conflict_index()
return _CONFLICT_ONLY_INDEX.get(tag.lower().strip())
def _find_conflicts(tag: str, existing: list[str]) -> set[str]:
group = _find_conflict_group(tag)
if group is None:
return set()
existing_lower = {e.lower().strip() for e in existing}
conflicting = group & existing_lower
return {e for e in existing if e.lower().strip() in conflicting}
def _resolve_and_replace(tag: str, existing: list[str], warehouse=None, protected=None) -> tuple[list[str], bool]:
tl = tag.lower().strip()
protected = protected or set()
existing_lower = {e.lower().strip() for e in existing}
conflicting = _find_conflicts(tag, existing)
# Exclude the tag itself (case-insensitive dup) from conflict resolution.
conflicting = {e for e in conflicting if e.lower().strip() != tl}
if warehouse:
for e in existing:
if warehouse.has_conflict(tag, [e]):
conflicting.add(e)
# Never remove a protected (user/core) tag. If the only conflict is protected,
# drop the incoming tag instead so user intent is always preserved.
for e in conflicting:
if e.lower().strip() in protected:
return existing, False
already = tl in existing_lower
if not conflicting and already:
return existing, False
out = [e for e in existing if e not in conflicting]
if not already:
out.append(tag)
return out, not already
def _substitute_at(result: list[str], i: int, best: str, warehouse=None, protected=None) -> list[str]:
"""Replace result[i] with best, preserving conflict removal from _resolve_and_replace.
_resolve_and_replace returns the full list with conflicting tags removed; the
substitution caller must use that list (not just overwrite position i) or the
removed conflict would silently reappear at its original position.
"""
other = [t for j, t in enumerate(result) if j != i]
out, ok = _resolve_and_replace(best, other, warehouse, protected)
if not ok:
return result
new_result = []
for j, t in enumerate(result):
if j == i:
new_result.append(best)
elif t in out:
new_result.append(t)
return new_result
def _extract_artists_from_general(variant, known_artists: dict[str, str]) -> None:
"""Move tags that match known artist names into ``variant.artists``.
Operates in place on ``variant.general_tags``. ``known_artists`` MUST be
built once per generation call (it is independent of the variation loop).
"""
skip = {
(variant.subject or "").lower().strip(),
(variant.character or "").lower().strip(),
(variant.series or "").lower().strip(),
}
skip.discard("")
remaining = []
for tag in variant.general_tags:
tl = tag.lower().strip()
if tl in skip:
continue
matched = known_artists.get(tl)
if matched and matched not in variant.artists:
variant.artists.append(matched)
else:
remaining.append(tag)
variant.general_tags = remaining
def _min_diversity_index(tags1: list[str], tags2: list[str]) -> float:
if not tags1 or not tags2:
return 1.0
s1 = {t.lower().strip() for t in tags1}
s2 = {t.lower().strip() for t in tags2}
diff = (s1 | s2) - (s1 & s2)
return len(diff) / max(len(s1 | s2), 1)
# --- Smart balancing helpers (booru best practices) ---
# Restrictive metadata tags stripped from positive prompts
# Auto-stripped "noise / restrictive / metadata" tags (mirrors Booru Prompt
# Gallery's "removes metadata / restrictive tags" behavior + our extensions).
RESTRICTIVE_TAGS = frozenset({
"white background", "simple background", "black background",
"transparent background", "grey background", "gray background",
"censor", "censored", "mosaic censorship", "censorship",
"web address", "patreon logo", "patreon username",
"commentary request", "translated", "english text",
# --- extended metadata / artifact strip (C) ---
"watermark", "artist name", "signature", "edited", "edit",
"cropped", "crop", "duplicate", "disapproved", "resized",
"compressed", "real life", "photograph", "photo", "raw image",
"source image", "screencap", "screenshot", "scanned",
"recycled", "upscaled", "downscaled", "downsampling",
"high resolution", "low resolution", "4k", "8k", "12k",
"deformed", "bad art", "worst quality", "low quality",
})
# Canonical alias map (Booru Tag Gallery "Alias Resolver", G). Maps common
# non-canonical spellings to the booru-standard tag. Applied during cleaning.
# NOTE: Danbooru canon is `blonde hair` (NOT `yellow hair`) — that is a common
# heuristic mistake; aliases below only canonicalize well-known, unambiguous
# spellings, and never overwrite an official booru tag with a non-booru one.
_BOORU_ALIASES = {
"blond hair": "blonde hair",
"blonde hair": "blonde hair",
"blonde_hair": "blonde hair",
"blond": "blonde hair",
"grey hair": "gray hair",
"grey eyes": "gray eyes",
"grey background": "gray background",
"grey skin": "gray skin",
"platinum hair": "white hair",
"silver hair": "white hair",
"violet hair": "purple hair",
"lavender hair": "purple hair",
"scarlet hair": "red hair",
"crimson hair": "red hair",
"azure hair": "blue hair",
"light blue hair": "aqua hair",
"cyan hair": "aqua hair",
"turquoise hair": "aqua hair",
"magenta hair": "pink hair",
"rose hair": "pink hair",
"pink-colored": "pink",
"coloured skin": "colored skin",
"colored eyes": "colored pupils",
"heterochromia eyes": "heterochromia",
"mismatched eyes": "heterochromia",
"two-tone hair": "multicolored hair",
"two toned hair": "multicolored hair",
"gradient hair": "multicolored hair",
"ombre hair": "multicolored hair",
"multicolor eyes": "heterochromia",
"expressionless face": "expressionless",
"looking back": "looking at viewer", # canonical spike
"looking_at_viewer": "looking at viewer", # pose tagger outputs underscore
"looking_away": "looking away", # pose tagger outputs underscore
"looking_up": "looking up",
"looking_down": "looking down",
"looking_back": "looking at viewer",
"closed_eyes": "eyes closed",
"arms_up": "arms up",
"arms_behind_head": "arms behind head",
"arms_at_sides": "arms at sides",
"arms_crossed": "arms crossed",
"arms_behind_back": "arms behind back",
"arms_around_neck": "arms around neck",
"hands_on_hips": "hands on hips",
"hand_on_hip": "hand on hip",
"hand_on_own_face": "hand on own face",
"hand_on_own_chest": "hand on own chest",
"hand_on_own_stomach": "hand on own stomach",
"hand_in_pocket": "hand in pocket",
"hands_in_pockets": "hands in pockets",
"covering_face": "covering face",
"head_tilt": "head tilt",
"one_arm_up": "one arm up",
"one_leg_up": "one leg up",
"standing_on_one_leg": "standing on one leg",
"on_all_fours": "on all fours",
"kneeling_on_one_knee": "kneeling on one knee",
"crossed_legs": "crossed legs",
"legs_apart": "legs apart",
"close up": "close-up",
"extreme close up": "extreme close-up",
"upper body": "upper body",
"lower body": "lower body",
"full body": "full body",
"cowboy shot": "cowboy shot",
"dutch angle": "dutch angle",
"birds eye view": "bird's-eye view",
"birds-eye view": "bird's-eye view",
"worms eye view": "worm's-eye view",
"worms-eye view": "worm's-eye view",
"white background": "white background",
"simple background": "simple background",
"gradient background": "gradient background",
"detailed background": "detailed background",
"blurry background": "blurry background",
"blurred background": "blurry background",
"starry sky": "starry sky",
"cloudy sky": "cloudy sky",
"clear sky": "clear sky",
"night sky": "night sky",
"starry sky": "starry sky", # canonical
"sunny": "sunny",
"rainy": "rainy",
"snowy": "snowy",
"foggy": "foggy",
"misty": "foggy",
"overcast": "cloudy sky",
"golden hour": "golden hour",
"blue hour": "dusk",
"twilight": "dusk",
"dawn": "sunrise",
"golden lighting": "golden hour",
"dramatic lighting": "dramatic lighting",
"cinematic lighting": "cinematic lighting",
"volumetric lighting": "volumetric lighting",
"soft lighting": "soft lighting",
"hard lighting": "hard lighting",
"harsh lighting": "hard lighting",
"studio lighting": "studio lighting",
"natural lighting": "natural lighting",
"neon lighting": "neon lighting",
"glowing": "glowing",
"lens flare": "lens flare",
"bloom": "bloom",
"depth of field": "depth of field",
"bokeh": "bokeh",
"chromatic aberration": "chromatic aberration",
"film grain": "film grain",
"vignette": "vignette",
"motion blur": "motion blur",
"sharp focus": "sharp focus",
"soft focus": "soft focus",
}
# Token ceiling for one variation (Anima/Illustrious sweet spot ~75).
MAX_TOKENS_DEFAULT = 75
FX_CATEGORIES = ["effects", "special_fx", "lighting", "atmosphere"]
# Negative-quality / source-metadata tags that must never land in a positive prompt.
_NEG_QUALITY_BAD = frozenset({
"worst quality", "bad quality", "low quality", "worst aesthetic",
"worst score", "low score", "average score",
"displeasing", "very displeasing", "bad aesthetic", "normal quality",
})
def _is_negative_quality(tag: str) -> bool:
tl = tag.lower().strip()
if tl in _NEG_QUALITY_BAD:
return True
if "displeasing" in tl:
return True
if tl.startswith("worst") or tl.startswith("low ") or tl.startswith("bad "):
return True
if tl.startswith("score_"):
try:
num = int(tl.split("_", 1)[1])
except (ValueError, IndexError):
return False
return num < 7
if tl.startswith("source_"):
return True
return False
# Categories whose tags describe the subject's fixed design (identity) rather
# than flexible scene/ambiance. Used by Full Rewrite to keep the concept while
# rebuilding everything else.
_DESIGN_CATS = {
"quality", "expression", "hair", "body", "pose", "clothing",
"background", "lighting", "effects", "atmosphere", "colors",
"composition", "style",
}
# Semantic role priority for final tag ordering (lower = earlier in prompt).
# SD gives earlier tokens more attention, so identity/composition must lead
# and ambiance/effects must trail; unknown categories stay in a stable tail.
_TAG_ORDER_PRIORITY = {
"quality": 0, "year_meta": 0,
"composition": 1, "framing": 1,
"pose": 2, "expression": 2,
"body": 3, "hair": 3, "eyes": 3, "colors": 3, "face": 3, "makeup": 3,
"accessory": 4, "clothing": 4,
"background": 5, "architecture": 5, "season": 5, "atmosphere": 5, "weather": 5,
"lighting": 6, "color_grading": 6,
"effects": 7, "special_fx": 7,
"style": 8, "bloom": 8,
"object": 9, "food": 9, "weapon": 9, "vehicle": 9,
"demon": 10, "angelic": 10, "magic": 10,
"animal": 11, "furry": 11,
"nsfw": 12,
"horror": 13, "fantasy": 13, "cyberpunk": 13, "gothic": 13,
"steampunk": 13, "noir": 13, "retro": 13, "kawaii": 13,
"watercolor": 13, "space": 13, "underwater": 13, "warrior": 13,
"magical_girl": 13,
}
def _smart_order_tags(tags: list[str]) -> list[str]:
"""Stable-sort tags by semantic role so identity/composition lead the prompt.
Stable sort preserves the existing order inside each role group (e.g. the
order the user wrote their hair/eye tags). Tags with no known category
(subject names like ``1girl``, character tags) rank at the front so user
intent keeps attention; genuinely unknown additions fall to the tail.
"""
def _rank(t: str) -> int:
cats = get_tag_categories(t)
if not cats:
return 1
return min(_TAG_ORDER_PRIORITY.get(c, 50) for c in cats)
return sorted(tags, key=_rank)
def _protected_set(parsed: ParsedPrompt, design_only: bool = False) -> set[str]:
"""Tags that must never be removed or overwritten by conflict resolution / budgeting.
design_only=False (Standard Varry): protect the user's subject, character,
series, artists, quality tags AND every explicit general tag they wrote, so
variations only ADD variety and never erase the user's intent.
design_only=True (Full Rewrite): protect subject/character/series + only the
design-category general tags, letting the rest of the prompt be rebuilt.
"""
p: set[str] = set()
if parsed.subject:
p.add(parsed.subject.lower().strip())
if parsed.character:
for c in parsed.character.split(","):
c = c.strip()
if c:
p.add(c.lower())
if parsed.series:
p.add(parsed.series.lower().strip())
for a in (parsed.artists or []):
p.add(a.lower().strip())
for q in (parsed.quality_tags or []):
p.add(q.lower().strip())
for t in (parsed.general_tags or []):
tl = t.lower().strip()
if design_only:
cats = get_tag_categories(t)
if any(c in _DESIGN_CATS for c in cats):
p.add(tl)
else:
p.add(tl)
return p
def _head(tag: str) -> str:
"""Last whitespace-separated token of a tag (its head noun), lower-cased."""
parts = tag.split()
return parts[-1].lower() if parts else tag.lower()
import re as _re
_JUNK_RE = _re.compile(r"(//|cid=|[<>]|[\"'`])")
def _clean_general_tags(tags: list[str]) -> list[str]:
"""Strip crawler/UI artifacts and normalize spaced score tags.
e.g. '0.4//cid=12>' -> dropped, 'score 9' -> 'score_9'. Keeps the user's
meaningful tags intact so downstream generation stays clean.
"""
out = []
for t in tags:
tl = t.strip()
if not tl:
continue
m = _re.fullmatch(r"score\s+(\d+)", tl, _re.I)
if m:
out.append(f"score_{m.group(1)}")
continue
if _re.fullmatch(r"\d+(\.\d+)?", tl):
continue
if _JUNK_RE.search(tl):
continue
# Normalize booru underscores to the spaced canonical form (except
# score_N) so category / co-occurrence / conflict lookups match and so
# user tags and injected tags share one consistent surface form. This
# is what prevents mixed "looking_at_viewer, magical girl pose" output.
tl = to_internal_tag(tl)
# Alias Resolver (G): canonicalize common non-standard spellings.
alias = _BOORU_ALIASES.get(tl.lower())
if alias:
tl = alias
out.append(tl)
return out
def _is_known_tag(tag: str) -> bool:
"""True if the tag is a recognized pool/category tag (vs a user-specific name)."""
return bool(get_tag_categories(tag))
def _normalize_exclude(exclude_tags: list[str] | None) -> set[str]:
if not exclude_tags:
return set()
return {t.lower().strip() for t in exclude_tags if t and t.strip()}
def _apply_strip_flags(
parsed: "ParsedPrompt",
warehouse: "TagWarehouse",
strip_quality: bool = False,
strip_artist: bool = False,
strip_lora: bool = False,
strip_meta: bool = False,
) -> "ParsedPrompt":
"""Apply the B (cleanup toggles) flags to a parsed prompt in place.
- strip_quality: drop quality tokens from general + quality_tags
- strip_artist: drop artist entries (so they are not protected either)
- strip_lora: drop LoRA/embed trigger tokens (':', '<', '>')
- strip_meta: drop meta tokens
"""
if strip_quality:
parsed.quality_tags = []
parsed.general_tags = [
t for t in parsed.general_tags
if not ("quality" in t.lower() or "masterpiece" in t.lower() or _is_score_tag(t))
]
if strip_artist:
known_artists = {a["tag"].lower().strip() for a in warehouse.get_all_artists()}
parsed.artists = []
parsed.general_tags = [
t for t in parsed.general_tags if t.lower().strip() not in known_artists
]
if strip_lora:
def _is_lora(t: str) -> bool:
tl = t.lower()
if ":" in tl or "<" in tl or ">" in tl:
return True
if "lora" in tl or "loha" in tl or "lycoris" in tl or "embedding" in tl:
return True
return False
parsed.general_tags = [t for t in parsed.general_tags if not _is_lora(t)]
if strip_meta:
parsed.meta_tags = []
return parsed
def _apply_blacklist(tags: list[str], exclude: set[str]) -> list[str]:
if not exclude:
return tags
return [t for t in tags if t.lower().strip() not in exclude]
def _ensure_min_tags(
tags: list[str],
min_tags: int,
warehouse: "TagWarehouse",
protected: set[str],
user_heads: set[str],
exclude: set[str],
max_rating: str,
rng: "random.Random",
parsed: "ParsedPrompt | None" = None,
intent: str | None = None,
) -> list[str]:
"""Top up a tag list to at least `min_tags` (D).
Candidates are drawn from the tag pools and selected through the semantic
picker (intent/co-occurrence/harmony), so the fill tags are contextually
relevant instead of being random pool noise.
"""
if min_tags <= 0:
return tags
out = list(tags)
used = {t.lower().strip() for t in out} | exclude
candidates: list[str] = []
cats = [c for c in warehouse.pools if c not in ("nsfw", "furry")]
rng.shuffle(cats)
for cat in cats:
if len(candidates) >= min_tags * 4:
break
pool = warehouse.get_pool(cat)
if pool is None:
continue
for tag in pool.get_all_tags(max_rating=max_rating):
tl = tag.lower().strip()
if tl in used:
continue
if _is_negative_quality(tag):
continue
if has_synonym_conflict(tag, out + candidates):
continue
if _head(tag) in user_heads and tl not in protected:
continue
candidates.append(tag)
used.add(tl)
if len(candidates) >= min_tags * 4:
break
if not candidates:
return out
need = min_tags - len(out)
if need <= 0:
return out
attempts = 0
while len(out) < min_tags and candidates and attempts < min_tags * 6:
attempts += 1
if parsed is None:
picked = rng.sample(candidates, min(need, len(candidates)))
else:
picked = semantic_pick_tags(candidates, need, parsed, rng, set(), list(out), intent=intent)
added_any = False
for tag in picked:
if len(out) >= min_tags:
break
new_out, was_added = _resolve_and_replace(tag, out, warehouse, protected)
if was_added:
out = new_out
added_any = True
if not added_any:
# All current picks were rejected (conflicts) — drop them and retry.
candidates = [c for c in candidates if c not in picked]
need = min_tags - len(out)
return out
def _remove_intra_conflicts(tags: list[str], warehouse: TagWarehouse, protected: set[str]) -> list[str]:
"""Final safety pass: ensure no two tags in the result conflict.
Protected (user/core) tags win; a later non-protected conflicting tag is
dropped, and a later protected tag removes an earlier non-protected conflict.
When BOTH tags are protected (the user's own explicit choices) we keep both
rather than silently dropping one of the user's tags. Generation paths that
append directly (tandems, artist signatures, fx) may bypass per-add conflict
resolution, so this guarantees a clean prompt.
"""
from src.semantic_coherence import _scene_conflict
kept: list[str] = []
for t in tags:
tl = t.lower().strip()
drop = False
for k in list(kept):
if warehouse.has_conflict(t, [k]) or _scene_conflict(tl, k.lower().strip()):
kp = k.lower().strip()
if tl in protected and kp in protected:
continue # both are user intent: keep both
if tl in protected and kp not in protected:
kept.remove(k)
else:
drop = True
break
if not drop:
kept.append(t)
return kept
def _estimate_tokens(tags: list[str]) -> int:
"""CLIP-style estimate: booru tag ≈ words×0.75 + 1 separator token."""
n = 0
for t in tags:
words = len(t.replace("_", " ").split())
n += max(1, int(words * 0.75)) + 1
return n
def _category_token_budget(
resolved_categories: list[str],
settings: dict,
warehouse: TagWarehouse,
max_rating: str,
max_tokens: int,
) -> dict[str, int]:
"""Allocate per-category tag counts so the estimated token cost fits max_tokens.
Categories with longer tags (more tokens) get fewer slots; the allocation is
proportional to the category's base share so no single category can blow the
budget before _balance_variation has to pop tags from the tail.
"""
budgets: dict[str, int] = {}
if not resolved_categories or max_tokens <= 0:
return budgets
min_t, max_t = settings["tags_per_category"]
base = (min_t + max_t) / 2.0
weights: dict[str, float] = {}
for cat in resolved_categories:
pool = warehouse.get_pool(cat)
if pool is None:
continue
sample = pool.get_all_tags(max_rating=max_rating)[:200]
if not sample:
continue
avg_cost = sum(_estimate_tokens([t]) for t in sample) / len(sample)
weights[cat] = max(avg_cost, 1.0)
if not weights:
return budgets
total_weight = sum(base * w for w in weights.values())
if total_weight <= 0:
return budgets
scale = max_tokens / total_weight
for cat, w in weights.items():
budgets[cat] = max(min_t, min(max_t, int(base * scale)))
return budgets
def _tag_theme(tag: str) -> str:
cats = set(get_tag_categories(tag))
for theme, cset in _THEME_GROUPS.items():
if cats & cset:
return theme
return "misc"
def _balance_variation(
tags: list[str],
protected_lower: set[str],
theme_budget: dict[str, int] | None,
max_tokens: int,
) -> list[str]:
"""Trim decorative tags so no theme dominates and the token budget is respected.
Protected (subject/character/series/artist/quality) tags are always kept."""
protected = [t for t in tags if t.lower().strip() in protected_lower]
decorative = [t for t in tags if t.lower().strip() not in protected_lower]
if theme_budget:
theme_of: dict[str, str] = {}
counts: dict[str, int] = {}
for t in decorative:
th = _tag_theme(t)
theme_of[t.lower().strip()] = th
counts[th] = counts.get(th, 0) + 1
for th, budget in theme_budget.items():
over = counts.get(th, 0) - budget
if over <= 0:
continue
removed = 0
kept = []
for t in reversed(decorative):
if removed < over and theme_of.get(t.lower().strip()) == th:
removed += 1
continue
kept.append(t)
decorative = list(reversed(kept))
if max_tokens and _estimate_tokens(protected + decorative) > max_tokens:
while decorative and _estimate_tokens(protected + decorative) > max_tokens:
decorative.pop()
return protected + decorative
def _apply_fx_layer(
new_general: list[str],
parsed: ParsedPrompt,
warehouse: TagWarehouse,
rng: random.Random,
fx_count: int,
protected_lower: set[str],
used: set[str],
max_rating: str,
) -> list[str]:
"""Add an FX tag layer (effects/special_fx/lighting/atmosphere), co-occurrence
matched to the prompt where possible. Protected tags are never displaced."""
if fx_count <= 0:
return new_general
fx_cats = [c for c in FX_CATEGORIES if warehouse.get_pool(c)]
if not fx_cats:
return new_general
related: set[str] = set()
if parsed.general_tags:
for g in parsed.general_tags:
for r in get_cooccurrence_tags(g, limit=10):
rt = (r.get("tag") or "").lower().strip()
if rt:
related.add(rt)
added = 0
attempts = 0
while added < fx_count and attempts < fx_count * 8 and fx_cats:
cat = rng.choice(fx_cats)
pool = warehouse.get_pool(cat)
cands = pool.get_all_tags(max_rating=max_rating)
if not cands:
attempts += 1
continue
preferred = [c for c in cands if c.lower().strip() in related]
pool_choice = preferred if preferred and rng.random() < 0.7 else cands
tag = rng.choice(pool_choice)
tl = tag.lower().strip()
if tl in used or tl in protected_lower or _is_negative_quality(tag):
attempts += 1
continue
new_general, ok = _resolve_and_replace(tag, new_general, warehouse, protected_lower)
if ok:
added += 1
used.add(tl)
attempts += 1
return new_general
def _score_tag_context(
tag: str,
parsed: ParsedPrompt,
) -> float:
"""Word-boundary context match between tag and user intent fields."""
score = 0.0
tag_words = set(tag.lower().replace("_", " ").split())
context_sources: list[str] = []
if parsed.subject:
context_sources.append(parsed.subject.lower().strip())
if parsed.character:
context_sources.append(parsed.character.lower().strip())
if parsed.series:
context_sources.append(parsed.series.lower().strip())
tag_cats = get_tag_categories(tag)
for ctx in context_sources:
ctx_words = set(ctx.replace("_", " ").split())
if ctx_words & tag_words:
score += 2.0
ctx_cats = get_tag_categories(ctx)
if tag_cats and ctx_cats and (set(tag_cats) & set(ctx_cats)):
score += 1.0
return min(score, SCORING["context_score_cap"])
def _cooccurrence_bonus(tag: str, context_tags: list[str]) -> float:
"""Sum of co-occurrence weights between tag and already-selected tags."""
if not context_tags:
return 0.0
related = get_cooccurrence_tags(tag, limit=20)
if not related:
return 0.0
related_map = {}
for r in related:
rt = (r.get("tag") or "").lower().strip()
if rt:
related_map[rt] = r.get("weight", 1.0)
bonus = 0.0
for ctx_tag in context_tags:
cl = ctx_tag.lower().strip()
if cl in related_map:
bonus += float(related_map[cl])
return min(bonus, 5.0)
def _pick_tags_weighted(
candidates: list[str],
count: int,
parsed: ParsedPrompt,
rng: random.Random,
used_globals: set[str],
selected_tags: list[str] | None = None,
intent: str | None = None,
) -> list[str]:
"""Unified tag picker: delegates to semantic_pick_tags for context-aware selection."""
return semantic_pick_tags(candidates, count, parsed, rng, used_globals, selected_tags, intent=intent)
def _smart_substitution(
tags: list[str],
chance: float,
rng: random.Random,
parsed: ParsedPrompt | None = None,
warehouse: TagWarehouse | None = None,
resolved_categories: list[str] | None = None,
core_tags: set[str] | None = None,
protected: set[str] | None = None,
) -> list[str]:
"""Context-aware substitution with cross-category fallback and scoring."""
result = list(tags)
result_lower = {t.lower().strip() for t in result}
# Pre-compute category pools (tag + categories) once
pool_cache: dict[str, list[tuple[str, list[str]]]] = {}
if warehouse:
for cat in (resolved_categories or []):
pool = warehouse.get_pool(cat)
if pool is None:
continue
entries = [(pt, pt.lower().strip(), get_tag_categories(pt))
for pt in pool.get_all_tags(max_rating="explicit")]
pool_cache[cat] = entries
for i, tag in enumerate(result):
# Protect core + user-intent tags from substitution
if core_tags and tag.lower().strip() in core_tags:
continue
if protected and tag.lower().strip() in protected:
continue
if rng.random() >= chance:
continue
tl = tag.lower().strip()
candidates = []
# 1. Synonym alternatives from same group
group = _find_synonym_group(tag)
substituted = False
if group:
for t in group:
alt = t.lower().strip()
if alt != tl and alt not in result_lower:
candidates.append(t)
if len(candidates) >= 5:
rng.shuffle(candidates)
best = max(candidates, key=lambda c: (
_score_tag_context(c, parsed) if parsed else 0.0
))
result = _substitute_at(result, i, best, warehouse, protected)
result_lower = {t.lower().strip() for t in result}
substituted = True
if substituted:
continue
# 2. Cross-category: scan only categories the tag belongs to (not all resolved)
if pool_cache:
tag_cats = get_tag_categories(tag)
if tag_cats:
for cat in pool_cache:
if cat not in tag_cats:
continue
for pt, ptl, pt_cats in pool_cache[cat][:30]:
if ptl == tl or ptl in result_lower:
continue
if not (set(tag_cats) & set(pt_cats)):
continue
candidates.append(pt)
if not candidates:
continue
# 3. Score and pick best
other_tags = [t for j, t in enumerate(result) if j != i]
best = None
best_score = float("-inf")
for cand in candidates:
base = _score_tag_context(cand, parsed) if parsed else 0.0
cooc = _cooccurrence_bonus(cand, other_tags)
penalty = 0.0
cand_syn = _find_synonym_group(cand)
if cand_syn:
for ot in other_tags:
if ot.lower().strip() in cand_syn:
penalty = 3.0
break
syn_bonus = 1.0 if group and cand in group else 0.0
total = base + cooc + syn_bonus - penalty + rng.uniform(0, 0.3)
if total > best_score:
best_score = total
best = cand
if best is None:
continue
result = _substitute_at(result, i, best, warehouse, protected)
result_lower = {t.lower().strip() for t in result}
return result
def _pick_random_tandem(rng: random.Random, warehouse: TagWarehouse) -> list[str]:
tandems = warehouse.get_all_tandems()
if not tandems:
return []
tandem = rng.choice(tandems)
artists = tandem.get("artists", [])
tags = list(artists)
for aname in artists:
sig = warehouse.get_artist_signature_tags(aname)
tags.extend(sig)
return tags
def _pick_style_tandem(rng: random.Random, warehouse: TagWarehouse, artist_style: str) -> list[str]:
"""Pick a tandem matching the given art style; fall back to random."""
tandems = warehouse.get_all_tandems()
if not tandems:
return []
if artist_style:
style_artists = warehouse.get_artists_by_style(artist_style)
if style_artists:
style_names = {a["tag"].lower().strip() for a in style_artists}
matching = [t for t in tandems if any(a.lower().strip() in style_names for a in t.get("artists", []))]
if matching:
tandem = rng.choice(matching)
artists = tandem.get("artists", [])
tags = list(artists)
for aname in artists:
sig = warehouse.get_artist_signature_tags(aname)
tags.extend(sig)
return tags
return _pick_random_tandem(rng, warehouse)
def _compute_tag_weights(
warehouse: TagWarehouse,
selected_artists: list[str] | None,
) -> dict[str, float]:
all_a = warehouse.get_all_artists()
max_pop = max((a.get("popularity", 0) for a in all_a), default=100)
weights = {}
for a in all_a:
pop = a.get("popularity", 0)
name = a["tag"].lower().strip()
w = 1.0 + (pop / max_pop) * 0.4
weights[name] = round(min(max(w, 1.0), 1.4), 2)
if selected_artists:
for name in selected_artists:
w = weights.get(name.lower().strip(), 1.2)
weights[name.lower().strip()] = max(w, 1.2)
return weights
def _pick_wildcard_categories(
available: list[str],
count: int,
parsed: ParsedPrompt,
rng: random.Random,
) -> list[str]:
"""Pick wildcard categories contextually based on user prompt tags.
Noise is low enough (0.2) that category alignment still dominates but the
choice is not fully deterministic across variations.
"""
if not available or count <= 0:
return []
cat_scores: dict[str, float] = {}
for tag in parsed.general_tags:
for cat in get_tag_categories(tag):
cat_scores[cat] = cat_scores.get(cat, 0) + 2.0
if parsed.subject:
for cat in get_tag_categories(parsed.subject):
cat_scores[cat] = cat_scores.get(cat, 0) + 1.0
scored = [(cat, cat_scores.get(cat, 0.0) + rng.uniform(0, SCORING["noise_max"])) for cat in available]
scored.sort(key=lambda x: -x[1])
return [c for c, _ in scored[:count]]
def _adjust_settings_by_prompt_length(
settings: dict,
parsed: ParsedPrompt,
total_avail_pools: int,
) -> dict:
"""Adjust creativity settings based on prompt length."""
adjusted = dict(settings)
tag_count = len(parsed.general_tags or [])
if tag_count <= 2:
min_t, max_t = adjusted["tags_per_category"]
adjusted["tags_per_category"] = (min_t + 1, max(max_t + 1, min_t + 2))
adjusted["wildcard_categories"] = min(adjusted["wildcard_categories"] + 1, total_avail_pools)
adjusted["replacement_rate"] = min(adjusted["replacement_rate"] + 0.1, 0.8)
elif tag_count >= 12:
min_t, max_t = adjusted["tags_per_category"]
adjusted["tags_per_category"] = (max(1, min_t - 1), max(1, max_t - 1))
adjusted["replacement_rate"] = max(0.0, adjusted["replacement_rate"] - 0.1)
return adjusted
def generate_variations(
parsed: ParsedPrompt,
selected_categories: list[str],
num_variations: int = 5,
creativity: str = "medium",
model: str = "anima",
rating: str = "pg",
warehouse: TagWarehouse = None,
artist_style: str = "",
selected_artists: list[str] | None = None,
use_tandems: bool = False,
selected_tandem: dict | None = None,
weight_mode: str = "off",
mode: str = "standard",
web_enrich: bool = False,
selected_presets: list[str] | None = None,
fx_count: int = 0,
seed: int | None = None,
exclude_tags: list[str] | None = None,
strip_quality: bool = False,
strip_artist: bool = False,
strip_lora: bool = False,
strip_meta: bool = False,
min_tags: int = 0,
output_format: str = "prompt",
) -> list[str]:
if warehouse is None:
warehouse = TagWarehouse()
exclude = _normalize_exclude(exclude_tags)
parsed.general_tags = _clean_general_tags(parsed.general_tags)
parsed.general_tags = _apply_blacklist(parsed.general_tags, exclude)
parsed = _apply_strip_flags(
parsed, warehouse, strip_quality, strip_artist, strip_lora, strip_meta
)
if mode == "rewrite":
return full_rewrite(
parsed=parsed,
warehouse=warehouse,
model=model,
rating=rating,
creativity=creativity,
num_variations=num_variations,
seed=seed,
selected_categories=selected_categories,
selected_presets=selected_presets,
fx_count=fx_count,
weight_mode=weight_mode,
artist_style=artist_style,
selected_artists=selected_artists,
use_tandems=use_tandems,
selected_tandem=selected_tandem,
web_enrich=web_enrich,
exclude_tags=exclude_tags,
strip_quality=strip_quality,
strip_artist=strip_artist,
strip_lora=strip_lora,
strip_meta=strip_meta,
min_tags=min_tags,
output_format=output_format,
)
settings = CREATIVITY_SETTINGS.get(creativity, CREATIVITY_SETTINGS["medium"])
settings = _adjust_settings_by_prompt_length(settings, parsed, len(warehouse.pools))
max_rating = MAX_RATING_MAP.get(rating, "sfw")
results = []
all_new_tags_per_variation: list[list[str]] = []
base_general = list(parsed.general_tags)
if selected_categories:
parsed = apply_synonym_filter(parsed, selected_categories, warehouse, model, rating)
base_general = list(parsed.general_tags)
protected_lower = _protected_set(parsed)
# Preset bundle tags are user-intended additions: protect them from the
# balancing/dedup passes so an applied preset is never silently dropped.
for _p in (selected_presets or []):
for _t in get_preset_bundle_tags(_p):
protected_lower.add(_t.lower().strip())
# Head nouns the user already specified (e.g. "hair" from "blue hair"); we
# avoid stacking a second same-head tag like "purple hair" on top of it.
user_heads = {_head(t) for t in base_general if len(t.split()) >= 2}
# Cross-category injection: add related categories via rewrite_map
resolved_categories = list(selected_categories)
AUTO_EXCLUDE = {"furry", "nsfw"}
if settings["concept_cross"] and parsed.general_tags:
for tag in parsed.general_tags:
tag_cats = get_tag_categories(tag)
for cat in tag_cats:
if cat not in resolved_categories and warehouse.get_pool(cat) is not None:
resolved_categories.append(cat)
resolved_categories = [
c for c in resolved_categories
if c in selected_categories or c not in AUTO_EXCLUDE
]
# Wildcard categories: add context-relevant extra categories
if settings["wildcard_categories"] > 0:
all_avail = [c for c in warehouse.pools if c not in resolved_categories and c not in AUTO_EXCLUDE]
wild_rng = random.Random(seed) if seed is not None else random.Random()
extra = _pick_wildcard_categories(all_avail, settings["wildcard_categories"], parsed, wild_rng)
resolved_categories.extend(extra)
_skip_animal = has_human_subject(base_general) and "animal" not in selected_categories
# Artist lookup map is loop-invariant — build once, reuse per variation.
known_artists_map = {a["tag"].lower().strip(): a["tag"] for a in warehouse.get_all_artists()}
core_tags, decorative_tags = split_core_decorative(base_general)
core_set = {t.lower().strip() for t in core_tags}
# Theme budget for adaptive decorative tag allocation (intent is computed ONCE
# per generation call and threaded through the per-category picker).
intent = detect_intent(parsed)
theme_budget = compute_theme_budget(intent, resolved_categories, settings["tags_per_category"])
cat_to_theme: dict[str, str] = {}
for theme, cats in _THEME_GROUPS.items():
for cat in cats:
cat_to_theme[cat] = theme
# Per-category token budget (loop-invariant): keeps the whole variation under
# MAX_TOKENS_DEFAULT before tags are even picked, instead of trimming at the end.
cat_token_budget = _category_token_budget(
resolved_categories, settings, warehouse, max_rating, MAX_TOKENS_DEFAULT
)
base_seed = seed
for var_idx in range(num_variations):
var_seed = (base_seed + var_idx * 7919) if base_seed is not None else random.randint(0, 2**31 - 1) + var_idx * 7919
rng = random.Random(var_seed)
variant = deepcopy(parsed)
new_general = list(base_general)
new_added = []
if use_tandems and not selected_tandem and not selected_artists:
tandem_tags = _pick_style_tandem(rng, warehouse, artist_style)
existing_lower = {t.lower().strip() for t in new_general}
for t in tandem_tags:
if t.lower().strip() not in existing_lower:
new_general.append(t)
new_added.append(t)
existing_lower.add(t.lower().strip())
if use_tandems and selected_tandem and not selected_artists:
tandem_artists = selected_tandem.get("artists", [])
existing_lower = {t.lower().strip() for t in new_general}
for aname in tandem_artists:
if aname.lower().strip() not in existing_lower:
new_general.append(aname)
new_added.append(aname)
existing_lower.add(aname.lower().strip())
sig = warehouse.get_artist_signature_tags(aname)
for st in sig:
if warehouse.tag_exceeds_rating(st, max_rating):
continue
if st.lower().strip() not in existing_lower:
new_general.append(st)
new_added.append(st)
existing_lower.add(st.lower().strip())
if artist_style and not selected_artists and not use_tandems and not selected_tandem:
style_artists = warehouse.get_artists_by_style(artist_style)
if style_artists:
pool = rng.sample(style_artists, min(3, len(style_artists)))
for a in pool:
new_general.append(a["tag"])
new_added.append(a["tag"])
if selected_artists:
for aname in selected_artists:
if aname not in new_general:
new_general.append(aname)
new_added.append(aname)
sig_tags = warehouse.get_artist_signature_tags(aname)
existing_lower = {t.lower().strip() for t in new_general}
for st in sig_tags:
if warehouse.tag_exceeds_rating(st, max_rating):
continue
if st.lower().strip() not in existing_lower:
new_general.append(st)
new_added.append(st)
existing_lower.add(st.lower().strip())
# Replacement rate: remove some existing user tags proportionally
# Quality tags are weighted lower to preserve them; core tags are protected
if settings["replacement_rate"] > 0 and base_general:
# Never remove the user's protected intent (subject design / explicit
# category tags) or core tags; only replace flexible user tags so that
# variations stay true to what the user actually asked for.
user_tags = [
t for t in new_general
if t in base_general
and t.lower().strip() not in core_set
and t.lower().strip() not in protected_lower
]
if user_tags:
n_replace = max(1, int(len(user_tags) * settings["replacement_rate"]))
quality_keywords = {"score", "masterpiece", "quality", "aesthetic", "detailed"}
weights = []
for t in user_tags:
tl = t.lower().strip()
is_quality = any(kw in tl for kw in quality_keywords)
weights.append(0.2 if is_quality else 1.0)
to_remove = rng.choices(user_tags, weights=weights, k=min(n_replace, len(user_tags)))
to_remove = list(dict.fromkeys(to_remove))
for t in to_remove:
if t in new_general:
new_general.remove(t)
used_globals = {t.lower().strip() for t in new_general}
# Track per-variation theme usage
var_theme_usage: dict[str, int] = {}
for cat in resolved_categories:
if cat == "animal" and _skip_animal:
continue
pool = warehouse.get_pool(cat)
if pool is None:
continue
min_t, max_t = settings["tags_per_category"]
theme = cat_to_theme.get(cat, "misc")
theme_max = theme_budget.get(theme, max_t * 2)
used_this_theme = var_theme_usage.get(theme, 0)
# Reduce count if theme budget is exceeded (and never exceed the
# category's pre-allocated token budget).
local_max = max(min_t, min(max_t, theme_max - used_this_theme))
local_max = min(local_max, cat_token_budget.get(cat, local_max))
count = rng.randint(min_t, local_max) if local_max >= min_t else min_t
candidates = pool.get_all_tags(max_rating=max_rating)
if not candidates:
continue
picked = _pick_tags_weighted(candidates, count, parsed, rng, used_globals, new_general, intent=intent)
for tag in picked:
if _is_negative_quality(tag):
continue
if has_synonym_conflict(tag, new_general):
continue
# Don't stack a second same-head tag on a tag the user already gave.
if _head(tag) in user_heads and tag.lower().strip() not in protected_lower:
continue
new_general, was_added = _resolve_and_replace(tag, new_general, warehouse, protected_lower)
if was_added:
new_added.append(tag)
used_globals.add(tag.lower().strip())
var_theme_usage[theme] = used_this_theme + 1
used_this_theme += 1
# Smart substitution pass: context-aware replacement with cross-category fallback
if settings["substitution_chance"] > 0:
new_general = _smart_substitution(new_general, settings["substitution_chance"], rng,
parsed, warehouse, resolved_categories, core_set, protected_lower)
# Preset overlay (protected additions) + FX tag layer (Standard Varry).
for pname in (selected_presets or []):
for tag in get_preset_bundle_tags(pname):
new_general, _ = _resolve_and_replace(tag, new_general, warehouse, protected_lower)
new_general = _apply_fx_layer(new_general, parsed, warehouse, rng, fx_count, protected_lower, used_globals, max_rating)
# Web enrichment: co-occurrence + Danbooru tags keyed on the user's
# subject/character (falls back to local co-occurrence data offline).
if web_enrich:
from src.tag_searcher import enrich_prompt_tags
enrich = enrich_prompt_tags(variant, warehouse, max_tags=5, user_tags=parsed.general_tags)
for t in enrich:
new_general, _ = _resolve_and_replace(t, new_general, warehouse, protected_lower)
variant.general_tags = new_general
for prev_tags in all_new_tags_per_variation:
diversity = _min_diversity_index(new_added, prev_tags)
if diversity < settings["diversity_threshold"] and num_variations > 1:
extra_seed = rng.randint(0, 2**31 - 1)
re_rng = random.Random(extra_seed)
extra_candidates = []
for cat in resolved_categories:
pool = warehouse.get_pool(cat)
if pool is None:
continue
for tag in pool.get_all_tags(max_rating=max_rating):
if _is_negative_quality(tag):
continue
if _head(tag) in user_heads and tag.lower().strip() not in protected_lower:
continue
if tag.lower().strip() in used_globals:
continue
extra_candidates.append(tag)
if extra_candidates:
picked = semantic_pick_tags(
extra_candidates, 2, parsed, re_rng,
set(), variant.general_tags, intent=intent,
)
for tag in picked:
variant.general_tags, _ = _resolve_and_replace(tag, variant.general_tags, warehouse, protected_lower)
break
all_new_tags_per_variation.append(new_added)
variant.general_tags = filter_subject_conflicts(variant.general_tags, base_general)
variant.general_tags = smart_dedup(variant.general_tags, model=model)
variant.general_tags = [t for t in variant.general_tags if not _is_negative_quality(t)]
variant.general_tags = _remove_intra_conflicts(variant.general_tags, warehouse, protected_lower)
# Move artist tags into variant.artists (loop-invariant lookup map).
_extract_artists_from_general(variant, known_artists_map)
# Smart balancing: cap per-theme dominance + token budget, strip metadata noise.
variant.general_tags = _balance_variation(variant.general_tags, protected_lower, theme_budget, MAX_TOKENS_DEFAULT)
variant.general_tags = [t for t in variant.general_tags if t.lower().strip() not in RESTRICTIVE_TAGS]
variant.general_tags = _apply_blacklist(variant.general_tags, exclude)
if min_tags > 0:
variant.general_tags = _ensure_min_tags(
variant.general_tags, min_tags, warehouse, protected_lower, user_heads, exclude, max_rating, rng,
parsed=parsed, intent=intent,
)
# Principled ordering: identity/composition lead, ambiance/effects trail
# (SD attends to earlier tokens more, so random shuffle is harmful).
variant.general_tags = _smart_order_tags(variant.general_tags)
tag_weights = None
if weight_mode != "off":
tag_weights = _compute_tag_weights(warehouse, selected_artists)
result = format_prompt(
variant, model=model, rating=rating,
quality_enabled=("quality" in selected_categories and not strip_quality),
weight_mode=weight_mode, tag_weights=tag_weights,
output_format=output_format,
)
results.append(result)
return results
def full_rewrite(
parsed: ParsedPrompt,
warehouse: TagWarehouse,
model: str = "anima",
rating: str = "pg",
creativity: str = "medium",
num_variations: int = 5,
seed: int | None = None,
selected_categories: list[str] | None = None,
selected_presets: list[str] | None = None,
fx_count: int = 0,
weight_mode: str = "off",
artist_style: str = "",
selected_artists: list[str] | None = None,
use_tandems: bool = False,
selected_tandem: dict | None = None,
web_enrich: bool = False,
exclude_tags: list[str] | None = None,
strip_quality: bool = False,
strip_artist: bool = False,
strip_lora: bool = False,
strip_meta: bool = False,
min_tags: int = 0,
output_format: str = "prompt",
) -> list[str]:
"""Full Rewrite mode: keep subject + character (the original concept), rebuild
every other tag from scratch so each variation is a genuinely different but
on-theme prompt. A per-variation 'angle' preset shifts mood/style/setting."""
if warehouse is None:
warehouse = TagWarehouse()
exclude = _normalize_exclude(exclude_tags)
parsed.general_tags = _clean_general_tags(parsed.general_tags)
parsed.general_tags = _apply_blacklist(parsed.general_tags, exclude)
parsed = _apply_strip_flags(
parsed, warehouse, strip_quality, strip_artist, strip_lora, strip_meta
)
selected_presets = selected_presets or []
selected_categories = selected_categories or []
settings = CREATIVITY_SETTINGS.get(creativity, CREATIVITY_SETTINGS["medium"])
settings = _adjust_settings_by_prompt_length(settings, parsed, len(warehouse.pools))
max_rating = MAX_RATING_MAP.get(rating, "sfw")
original_intent = detect_intent(parsed)
protected = _protected_set(parsed, design_only=True)
# Preserve user-specific names (characters/series/artists not in our pools)
# so Full Rewrite keeps the identity instead of discarding it.
named = {
t.lower().strip()
for t in parsed.general_tags
if t.lower().strip() not in protected and not _is_known_tag(t)
}
protected |= named
# Head nouns the user already specified (e.g. "hair" from "blue hair"); we
# avoid stacking a second same-head tag like "neon hair" on top of it.
user_heads = {_head(t) for t in parsed.general_tags if len(t.split()) >= 2}
all_preset_keys = list(PRESETS.keys())
results: list[str] = []
cats = [c for c in selected_categories if c not in ("nsfw", "furry") and warehouse.get_pool(c)]
if not cats:
cats = [c for c in warehouse.pools if c not in ("nsfw", "furry")]
cat_to_theme: dict[str, str] = {}
for theme, cs in _THEME_GROUPS.items():
for c in cs:
cat_to_theme[c] = theme
cat_token_budget = _category_token_budget(
cats, settings, warehouse, max_rating, MAX_TOKENS_DEFAULT
)
base_seed = seed
for var_idx in range(num_variations):
var_seed = (base_seed + var_idx * 7919) if base_seed is not None else random.randint(0, 2 ** 31 - 1) + var_idx * 7919
rng = random.Random(var_seed)
work = deepcopy(parsed)
# Keep the user's protected design tags (hair/eyes/clothing/etc.); rebuild
# everything else from scratch so the concept is preserved but fresh.
preserved = [t for t in parsed.general_tags if t.lower().strip() in protected]
work.general_tags = list(preserved)
preserved_cats = set()
for t in preserved:
preserved_cats.update(get_tag_categories(t))
angle = rng.choice(all_preset_keys)
presets_this = list(selected_presets) + [angle]
new_general: list[str] = list(preserved)
used: set[str] = {t.lower().strip() for t in preserved}
theme_budget = compute_theme_budget(original_intent, cats, settings["tags_per_category"])
for cat in cats:
if cat in preserved_cats:
continue
pool = warehouse.get_pool(cat)
if pool is None:
continue
min_t, max_t = settings["tags_per_category"]
theme = cat_to_theme.get(cat, "misc")
theme_max = theme_budget.get(theme, max_t * 2)
count = rng.randint(min_t, max(min_t, min(max_t, theme_max)))
count = min(count, cat_token_budget.get(cat, count))
cands = pool.get_all_tags(max_rating=max_rating)
if not cands:
continue
picked = _pick_tags_weighted(cands, count, work, rng, used, new_general, intent=original_intent)
for tag in picked:
if _is_negative_quality(tag):
continue
if has_synonym_conflict(tag, new_general):
continue
if _head(tag) in user_heads and tag.lower().strip() not in protected:
continue
new_general, _ = _resolve_and_replace(tag, new_general, warehouse, protected)
used.add(tag.lower().strip())
for p in presets_this:
for tag in get_preset_bundle_tags(p):
new_general, _ = _resolve_and_replace(tag, new_general, warehouse, protected)
new_general = _apply_fx_layer(new_general, parsed, warehouse, rng, fx_count, protected, used, max_rating)
# Web enrichment: co-occurrence + Danbooru tags keyed on the user's
# subject/character (falls back to local co-occurrence data offline).
if web_enrich:
from src.tag_searcher import enrich_prompt_tags
enrich = enrich_prompt_tags(work, warehouse, max_tags=5, user_tags=parsed.general_tags)
for t in enrich:
new_general, _ = _resolve_and_replace(t, new_general, warehouse, protected)
new_general = _balance_variation(new_general, protected, theme_budget, MAX_TOKENS_DEFAULT)
new_general = [t for t in new_general if t.lower().strip() not in RESTRICTIVE_TAGS]
new_general = [t for t in new_general if not _is_negative_quality(t)]
new_general = _apply_blacklist(new_general, exclude)
new_general = _remove_intra_conflicts(new_general, warehouse, protected)
if min_tags > 0:
new_general = _ensure_min_tags(
new_general, min_tags, warehouse, protected, user_heads, exclude, max_rating, rng,
parsed=work, intent=original_intent,
)
# Principled ordering: identity/composition lead, ambiance/effects trail.
new_general = _smart_order_tags(new_general)
work.general_tags = new_general
quality_on = "quality" in selected_categories and not strip_quality
tag_weights = None
if weight_mode != "off":
tag_weights = _compute_tag_weights(warehouse, selected_artists)
result = format_prompt(
work, model=model, rating=rating,
quality_enabled=quality_on, weight_mode=weight_mode, tag_weights=tag_weights,
output_format=output_format,
)
results.append(result)
return results
_NEG_TEMPLATES_PATH = _os.path.join(
_os.path.dirname(_os.path.dirname(__file__)), "data", "negative_templates.json"
)
def _load_negative_templates() -> dict:
try:
with open(_NEG_TEMPLATES_PATH, "r", encoding="utf-8") as f:
return _json.load(f)
except (FileNotFoundError, _json.JSONDecodeError):
return {}
_NEG = _load_negative_templates()
NEGATIVE_PROMPTS: list[str] = _NEG.get("templates", [])
_RATING_SAFETY_ADDONS: dict[str, str] = _NEG.get("rating_addons", {})
_SFWMODEL_NEGATIVE_ADDON: str = _NEG.get("sfwmodel_addon", "")
_ILLUSTRIOUS_NEGATIVE_ADDON: str = _NEG.get("illustrious_addon", "")
_ANIMAL_NEGATIVE_ADDON: str = _NEG.get("animal_addon", "")
_HUMAN_NEGATIVE_ADDON: str = _NEG.get("human_addon", "")
# Intent → tags that fight the detected scene type. These get negated so the
# negative prompt opposes what will be generated (portrait → not a wide shot,
# environment → not a close-up, etc.). Values are validated against the tag
# pools so no phantom tags leak into the output.
_INTENT_NEGATIVE_MAP = {
"portrait": ["wide shot", "full body", "dutch angle"],
"action": ["lying", "sitting"],
"environment": ["close-up", "extreme close-up", "face focus"],
"horror": ["cheerful", "bright colors", "pastel colors", "innocent"],
"romantic": ["dark atmosphere", "horror"],
"fantasy": ["photorealistic", "realistic"],
}
def _get_inverted_conflicts(
tags: list[str],
max_count: int = 3,
rng: random.Random | None = None,
) -> list[str]:
"""Generate negative tags by inverting user tags' conflict/synonym groups."""
if not tags:
return []
seen = set()
conflicts: list[str] = []
for tag in tags:
tl = tag.lower().strip()
if tl in seen:
continue
seen.add(tl)
group = _find_conflict_only_group(tag)
if group and len(group) > 1:
for gt in group:
gtl = gt.lower().strip()
if gtl != tl and gtl not in seen:
conflicts.append(gt)
seen.add(gtl)
if len(conflicts) >= max_count * 3:
break
if len(conflicts) >= max_count * 3:
break
if rng and len(conflicts) > max_count:
return rng.sample(conflicts, max_count)
return conflicts[:max_count]
def generate_negative_prompt(
parsed: ParsedPrompt,
selected_categories: list[str],
num_variations: int = 5,
rating: str = "pg",
warehouse: TagWarehouse = None,
model: str = "anima",
positive_tags: list[str] | None = None,
extra_negative: list[str] | None = None,
output_format: str = "prompt",
) -> list[str]:
results: list[str] = []
max_rating = MAX_RATING_MAP.get(rating, "sfw")
has_human = bool(parsed.subject and parsed.subject not in ("no_humans", "no humans"))
is_animal = "animal" in selected_categories
# Intent inversion source: tags that fight the detected scene type are
# negated so the negative prompt opposes what will be generated.
intent = detect_intent(parsed)
conflict_neg_intent: list[str] = list(_INTENT_NEGATIVE_MAP.get(intent, []))
base_pool = list(NEGATIVE_PROMPTS)
# Build a conflict-inversion source from the user prompt AND a sample of
# the selected category pools, so the negative prompt opposes what will be
# generated (not just the literal user tags).
conflict_source: list[str] = list(parsed.general_tags or [])
if warehouse is not None and selected_categories:
for cat in selected_categories:
pool = warehouse.get_pool(cat)
if pool is None:
continue
for t in pool.get_all_tags(max_rating=max_rating)[:5]:
if t.lower().strip() not in {c.lower().strip() for c in conflict_source}:
conflict_source.append(t)
positive_lower = set()
for pt in (positive_tags or []):
for tok in str(pt).split(","):
tl = tok.strip().lower()
if tl:
if tl.startswith("(") and tl.endswith(")") and ":" in tl:
tl = tl[1:-1].rsplit(":", 1)[0].strip()
positive_lower.add(tl)
for var_idx in range(num_variations):
seed_bytes = f"neg:{var_idx}:{rating}:{model}:{','.join(sorted(selected_categories))}".encode()
seed = int.from_bytes(hashlib.sha256(seed_bytes).digest()[:4], "big") & 0x7FFFFFFF
rng = random.Random(seed)
primary_idx = rng.randint(0, len(base_pool) - 1)
primary = base_pool[primary_idx]
secondary_idx = rng.randint(0, len(base_pool) - 1)
while secondary_idx == primary_idx and len(base_pool) > 1:
secondary_idx = rng.randint(0, len(base_pool) - 1)
secondary = base_pool[secondary_idx]
parts = primary.split(", ")
secondary_parts = secondary.split(", ")
extra = rng.sample(secondary_parts, min(3, len(secondary_parts)))
for e in extra:
if e not in parts:
parts.append(e)
# Rating-specific safety addons
safety_pool = _RATING_SAFETY_ADDONS.get(max_rating, _RATING_SAFETY_ADDONS.get("sfw", ""))
if safety_pool:
safety_tags = safety_pool.split(", ")
parts.extend(rng.sample(safety_tags, min(2, len(safety_tags))))
if model == "anima" and rng.random() < 0.4:
swf_addons = _SFWMODEL_NEGATIVE_ADDON.split(", ")
parts.extend(rng.sample(swf_addons, min(2, len(swf_addons))))
if model == "illustrious" and rng.random() < 0.3:
ill_addons = _ILLUSTRIOUS_NEGATIVE_ADDON.split(", ")
parts.extend(rng.sample(ill_addons, min(2, len(ill_addons))))
if has_human and not is_animal:
human_addons = _HUMAN_NEGATIVE_ADDON.split(", ")
parts.extend(rng.sample(human_addons, min(3, len(human_addons))))
if is_animal:
animal_addons = _ANIMAL_NEGATIVE_ADDON.split(", ")
for a in animal_addons:
if a in parts:
parts.remove(a)
# Conflict inversion: add 1-2 opposing tags per variation
if conflict_source:
conflict_neg = _get_inverted_conflicts(conflict_source, max_count=2, rng=rng)
parts.extend(conflict_neg)
# Intent inversion: negate tags that fight the detected scene type.
if conflict_neg_intent and rng.random() < 0.6:
neg_pool = [t for t in conflict_neg_intent if t.lower() not in positive_lower]
if neg_pool:
parts.extend(rng.sample(neg_pool, min(2, len(neg_pool))))
seen = set()
deduped = []
for p in parts:
pl = p.strip().lower()
if not pl:
continue
if pl in seen:
continue
# Never negate a tag that is actually present in the positive prompt.
if pl in positive_lower:
continue
seen.add(pl)
deduped.append(p.strip())
# Mirror user blacklist into the negative prompt when requested (A).
if extra_negative:
for en in extra_negative:
enl = en.strip().lower()
if enl and enl not in positive_lower and enl not in seen:
seen.add(enl)
deduped.append(en.strip())
deduped = [normalize_tag(p, output_format) for p in deduped]
results.append(", ".join(deduped))
return results