| import re
|
| import os
|
| from ..utils.io import load_image_from_assets, safe_path
|
|
|
|
|
|
|
| CUSTOM_TOKENS = {
|
| ("boy", 0): "toddlerboy shota",
|
| ("boy", 1): "child shota",
|
| ("boy", 2): "preteen shota",
|
| ("boy", 3): "preteen boy",
|
| ("boy", 4): "teenage boy",
|
| ("boy", 5): "man",
|
|
|
| ("girl", 0): "toddlergirl loli",
|
| ("girl", 1): "child loli",
|
| ("girl", 2): "preteen loli",
|
| ("girl", 3): "preteen girl",
|
| ("girl", 4): "teenage girl",
|
| ("girl", 5): "woman",
|
| }
|
|
|
|
|
| _ALLOWED = {f"boy{i}.png" for i in range(6)} | {f"girl{i}.png" for i in range(6)}
|
|
|
| _num2_re = re.compile(r"(\d{2,})")
|
| _num1_re = re.compile(r"(?<!\d)(\d)(?!\d)")
|
|
|
|
|
| _boy_re = re.compile(r"\b(boys?|boi|box)\b", re.IGNORECASE)
|
|
|
| def _bucket_from_number(n: int | None) -> int:
|
| if n is None:
|
| return 5
|
| if n <= 5: return 0
|
| if n <= 8: return 1
|
| if n <= 11: return 2
|
| if n <= 14: return 3
|
| if n <= 19: return 4
|
| return 5
|
|
|
| def _parse_gender(text: str) -> str:
|
|
|
| return "boy" if _boy_re.search(text or "") else "girl"
|
|
|
| def _parse_number(text: str) -> int | None:
|
| if not text:
|
| return None
|
| m = _num2_re.search(text)
|
| if m:
|
| try:
|
| return int(m.group(1))
|
| except:
|
| pass
|
| m = _num1_re.search(text)
|
| if m:
|
| try:
|
| return int(m.group(1))
|
| except:
|
| pass
|
| return None
|
|
|
|
|
|
|
|
|
| class Salia_BAM_2:
|
| """
|
| Multi-view output node (Front/Side/Diagonal/Rear) + per-view prefixes/suffixes.
|
|
|
| Outputs:
|
| Frontview_pos, Sideview_pos, Diagonal_pos, Rearview_pos, image,
|
| Frontview_neg, Sideview_neg, Diagonal_neg, Rearview_neg, BAM_out,
|
| age (INT)
|
|
|
| BAM_in:
|
| - If BAM_in == "" (exactly empty), behave normally (use UI inputs).
|
| - If BAM_in is non-empty, parse and overwrite ALL inputs.
|
|
|
| BAM_out:
|
| - BAM format string with all effective inputs used (after overwrite).
|
| - Fields separated with '###', wrapped in 'START### ... ###END###'
|
| - Empty fields are emitted as '0'
|
| - Gender emitted as: 1 == boy, 2 == girl
|
|
|
| Special rules:
|
| - Frontview_pos gets extra prefix:
|
| (masterpiece:0.8),
|
| (symmetrical <eyeColor> eyes:1.2),
|
| (left and right eye masterpiece same:1.5),
|
| - Frontview_neg gets extra suffix:
|
| (asymmetrical face:1.5), (asymmetrical tail-arched eyebrows:1.0),
|
| (heterochromia:1.5), monochrome, sketch, colorless,
|
| (terribly drawn eyes:1.2), watermark, text
|
| - Diagonal_pos gets same as front positive EXCEPT no symmetrical/same-eye stuff
|
| (it still gets (masterpiece:0.8))
|
| - Sideview_pos like diagonal_pos BUT "eyes" -> singular "eye"
|
| - Rearview_pos: no face/eyes/mouth/expression content at all
|
| - POV token per view:
|
| Front: (straight-on:1.15)
|
| Side: (from side POV:1.15)
|
| Diagonal:(POV from three-quarter view:1.15)
|
| Rear: (from back POV:1.15)
|
|
|
| - Diagonal_neg suffix:
|
| monochrome, sketch, colorless, (terribly drawn eyes:1.2), watermark, text
|
| - Sideview_neg suffix:
|
| monochrome, sketch, colorless, (terribly drawn eye:1.2), watermark, text
|
| (and we also singularize any "... eyes" tokens to "... eye" in Sideview_neg)
|
| - Rearview_neg suffix:
|
| (straight-on:1.15), (from side POV:1.15), (POV from three-quarter view:1.15),
|
| visible face, (visible eyes:1.2), monochrome, sketch, colorless, watermark, text
|
|
|
| Placeholder expansion in pos_prefix/pos_suffix/neg_prefix/neg_suffix:
|
| {age}, {gender}/{boygirl}, {CUSTOM_TOKENS}/{identity_age}, {identity}, {eyes}, {eyes_full}
|
| """
|
| CATEGORY = "text/salia"
|
|
|
| POV_FRONT = "(straight-on:1.15)"
|
| POV_SIDE = "(from side POV:1.15)"
|
| POV_DIAGONAL = "(POV from three-quarter view:1.15)"
|
| POV_REAR = "(from back POV:1.15)"
|
|
|
|
|
|
|
| @staticmethod
|
| def _collapse_spaces(s: str) -> str:
|
| return re.sub(r"\s+", " ", (s or "").strip())
|
|
|
| @classmethod
|
| def _split_tags(cls, text: str):
|
| if not text:
|
| return []
|
| s = str(text).replace("\n", ",")
|
| parts = [cls._collapse_spaces(p.strip(" ,")) for p in s.split(",")]
|
| return [p for p in parts if p]
|
|
|
| @staticmethod
|
| def _dedup_preserve(tokens):
|
| seen, out = set(), []
|
| for t in tokens:
|
| t = (t or "").strip()
|
| if not t:
|
| continue
|
| k = t.lower()
|
| if k not in seen:
|
| seen.add(k)
|
| out.append(t)
|
| return out
|
|
|
| @classmethod
|
| def _ensure_suffix_word(cls, token: str, word: str) -> str:
|
| t = cls._collapse_spaces(token)
|
| if not t:
|
| return ""
|
| t = re.sub(
|
| rf"\b({re.escape(word)})\b(?:\s+\1\b)+",
|
| r"\1",
|
| t,
|
| flags=re.IGNORECASE
|
| )
|
| if re.search(rf"\b{re.escape(word)}\b", t, re.IGNORECASE):
|
| return t
|
| return f"{t} {word}"
|
|
|
|
|
|
|
| _BAM_DELIM = "###"
|
| _BAM_START = "START"
|
| _BAM_END = "END"
|
|
|
| _BAM_FIELDS = [
|
| "gender",
|
| "age",
|
| "identity",
|
| "eyes",
|
| "hair",
|
| "equip_info",
|
|
|
| "aesthetic_tag1", "aesthetic_tag2", "aesthetic_tag3", "aesthetic_tag4", "aesthetic_tag5",
|
| "skin_tag1", "skin_tag2", "skin_tag3", "skin_tag4", "skin_tag5",
|
| "expression_tag1", "expression_tag2", "expression_tag3", "expression_tag4", "expression_tag5",
|
|
|
| "headwear_tag",
|
| "pos_prefix",
|
| "pos_suffix",
|
| "neg_prefix",
|
| "neg_suffix",
|
| "EXTRA_NEG",
|
| ]
|
|
|
| @classmethod
|
| def _bam_clean_field(cls, v: str) -> str:
|
| """
|
| Normalize for BAM output: single-line, trimmed. Empty -> "0".
|
| """
|
| s = "" if v is None else str(v)
|
| s = s.replace("\r\n", "\n").replace("\r", "\n").replace("\n", " ")
|
| s = s.strip()
|
| return "0" if s == "" else s
|
|
|
| @classmethod
|
| def _bam_gender_to_code(cls, gender_text: str) -> str:
|
| """
|
| BAM output code: 1=boy, 2=girl.
|
| Uses existing _parse_gender() logic (boy matches; else girl).
|
| """
|
| g = _parse_gender(str(gender_text or ""))
|
| return "1" if g == "boy" else "2"
|
|
|
| @classmethod
|
| def _bam_build_out(cls, fields: dict) -> str:
|
| """
|
| Build BAM string from effective inputs.
|
| """
|
| gender_code = cls._bam_gender_to_code(fields.get("gender", ""))
|
|
|
| ordered_values = [
|
| gender_code,
|
| fields.get("age", ""),
|
| fields.get("identity", ""),
|
| fields.get("eyes", ""),
|
| fields.get("hair", ""),
|
| fields.get("equip_info", ""),
|
|
|
| fields.get("aesthetic_tag1", ""), fields.get("aesthetic_tag2", ""), fields.get("aesthetic_tag3", ""),
|
| fields.get("aesthetic_tag4", ""), fields.get("aesthetic_tag5", ""),
|
|
|
| fields.get("skin_tag1", ""), fields.get("skin_tag2", ""), fields.get("skin_tag3", ""),
|
| fields.get("skin_tag4", ""), fields.get("skin_tag5", ""),
|
|
|
| fields.get("expression_tag1", ""), fields.get("expression_tag2", ""), fields.get("expression_tag3", ""),
|
| fields.get("expression_tag4", ""), fields.get("expression_tag5", ""),
|
|
|
| fields.get("headwear_tag", ""),
|
| fields.get("pos_prefix", ""),
|
| fields.get("pos_suffix", ""),
|
| fields.get("neg_prefix", ""),
|
| fields.get("neg_suffix", ""),
|
| fields.get("EXTRA_NEG", ""),
|
| ]
|
|
|
| ordered_values = [cls._bam_clean_field(v) for v in ordered_values]
|
| return f"{cls._BAM_START}{cls._BAM_DELIM}" + cls._BAM_DELIM.join(ordered_values) + f"{cls._BAM_DELIM}{cls._BAM_END}{cls._BAM_DELIM}"
|
|
|
| @classmethod
|
| def _bam_parse_in(cls, bam_text: str) -> dict:
|
| """
|
| Parse BAM_in string and return dict of fields (strings).
|
|
|
| Rules:
|
| - Optional START/END wrapper supported
|
| - Token "0" means empty string
|
| - Gender token accepts:
|
| "1" => boy
|
| "2" => girl
|
| also accepts "boy"/"girl" text (and your existing boy synonyms via _boy_re)
|
| - If fewer fields than expected: pads with empty
|
| - If more fields than expected: truncates extras
|
| """
|
| raw = "" if bam_text is None else str(bam_text)
|
|
|
| toks = raw.split(cls._BAM_DELIM)
|
| toks = [t.strip() for t in toks]
|
|
|
|
|
| while toks and toks[0] == "":
|
| toks.pop(0)
|
| while toks and toks[-1] == "":
|
| toks.pop()
|
|
|
|
|
| start_idx = None
|
| for i, t in enumerate(toks):
|
| if t.upper() == cls._BAM_START:
|
| start_idx = i
|
| break
|
|
|
| end_idx = None
|
| for i in range(len(toks) - 1, -1, -1):
|
| if toks[i].upper() == cls._BAM_END:
|
| end_idx = i
|
| break
|
|
|
| if start_idx is not None and end_idx is not None and end_idx > start_idx:
|
| toks = toks[start_idx + 1 : end_idx]
|
| elif start_idx is not None:
|
| toks = toks[start_idx + 1 :]
|
| elif end_idx is not None:
|
| toks = toks[:end_idx]
|
|
|
|
|
| toks = [("" if t == "0" else t) for t in toks]
|
|
|
| expected = len(cls._BAM_FIELDS)
|
| if len(toks) < expected:
|
| toks += [""] * (expected - len(toks))
|
| elif len(toks) > expected:
|
| toks = toks[:expected]
|
|
|
| out = dict(zip(cls._BAM_FIELDS, toks))
|
|
|
|
|
| g = (out.get("gender") or "").strip()
|
| g_low = g.lower()
|
|
|
| if g_low == "1":
|
| out["gender"] = "boy"
|
| elif g_low == "2":
|
| out["gender"] = "girl"
|
| else:
|
| out["gender"] = "boy" if _boy_re.search(g) else "girl"
|
|
|
| return out
|
|
|
|
|
|
|
| _PH_RE = re.compile(r"\{([A-Za-z0-9_]+)\}")
|
|
|
| @classmethod
|
| def _render_placeholders(cls, text: str, mapping: dict) -> str:
|
| if not text:
|
| return ""
|
| mp = {str(k).lower(): "" if v is None else str(v) for k, v in (mapping or {}).items()}
|
|
|
| def repl(m):
|
| key = (m.group(1) or "").strip().lower()
|
| if key in mp:
|
| return mp[key]
|
| return m.group(0)
|
|
|
| return cls._PH_RE.sub(repl, text)
|
|
|
|
|
|
|
| @classmethod
|
| def _find_first_int(cls, s: str):
|
| m = re.search(r"(?<!\d)(\d{1,3})(?!\d)", s or "")
|
| if not m:
|
| return None
|
| try:
|
| v = int(m.group(1))
|
| if 0 <= v <= 120:
|
| return v
|
| except:
|
| pass
|
| return None
|
|
|
| @classmethod
|
| def _parse_gender_neg(cls, inp: str):
|
| s = cls._norm(inp)
|
| male_hits = ["boy", "1boy", "man", "male", "guy"]
|
| if any(t in s for t in male_hits):
|
| return "male"
|
| return "female"
|
|
|
| @staticmethod
|
| def _norm(s: str) -> str:
|
| return (s or "").lower().strip()
|
|
|
|
|
| @classmethod
|
| def _parse_age_group(cls, inp: str):
|
| age = cls._find_first_int(inp)
|
| if age is not None:
|
| if age <= 12:
|
| return "child"
|
| elif 13 <= age <= 19:
|
| return "teen"
|
| elif 20 <= age <= 49:
|
| return "adult"
|
| else:
|
| return "elderly"
|
| return None
|
|
|
| @classmethod
|
| def _parse_age_group(cls, inp: str):
|
| age = cls._find_first_int(inp)
|
| if age is not None:
|
| if age >= 50:
|
| return "elderly"
|
| if age >= 18:
|
| return "adult"
|
| return "adult"
|
|
|
| @staticmethod
|
| def _negatives_for_gender(subject_gender: str):
|
| return ["female"] if subject_gender == "male" else ["male"]
|
|
|
| @staticmethod
|
| def _negatives_for_agegroup(age_group: str, subject_gender: str):
|
| if age_group == "elderly":
|
| return ["adult", "teen", "loli", "shota"]
|
| if age_group == "adult":
|
| return ["elderly", "teen", "loli", "shota"]
|
| if age_group == "teen":
|
| return ["elderly", "adult", "loli", "shota"]
|
| if age_group == "child":
|
| if subject_gender == "male":
|
| return ["elderly", "adult", "teen", "loli"]
|
| if subject_gender == "female":
|
| return ["elderly", "adult", "teen", "shota"]
|
| return ["elderly", "adult", "teen"]
|
| return []
|
|
|
|
|
|
|
| _EYE_COLORS = ["brown", "blue", "grey", "red", "green", "teal"]
|
| _EYE_ALIASES = {"gray": "grey"}
|
| _EYE_WORD_RE = re.compile(r"\beyes?\b", re.IGNORECASE)
|
|
|
| @classmethod
|
| def _eye_norm(cls, token: str) -> str:
|
| t = (token or "").lower().strip()
|
| return cls._EYE_ALIASES.get(t, t)
|
|
|
| @classmethod
|
| def _eye_base(cls, s: str):
|
| pattern = r"\b(" + "|".join(sorted(set(cls._EYE_COLORS + list(cls._EYE_ALIASES.keys())), key=len, reverse=True)) + r")\b"
|
| m = re.search(pattern, s or "", re.IGNORECASE)
|
| if not m:
|
| return None
|
| base = cls._eye_norm(m.group(1))
|
| return base if base in cls._EYE_COLORS else None
|
|
|
| @classmethod
|
| def _eye_others(cls, base: str):
|
| if base is None:
|
| return []
|
| return [f"{c} eyes" for c in cls._EYE_COLORS if c != base]
|
|
|
| @classmethod
|
| def _format_eyes_tokens(cls, eyes_text: str):
|
| parts = cls._split_tags(eyes_text)
|
| out = []
|
| for p in parts:
|
| p = cls._collapse_spaces(p)
|
| p = re.sub(r"\beye\b$", "eyes", p, flags=re.IGNORECASE)
|
|
|
| if cls._EYE_WORD_RE.search(p):
|
| base = cls._eye_base(p)
|
| out.append(f"{base} eyes" if base is not None else p)
|
| else:
|
| base = cls._eye_base(p)
|
| if base is not None and p.strip().lower() in (base, *cls._EYE_ALIASES.keys()):
|
| out.append(f"{base} eyes")
|
| else:
|
| out.append(f"{p} eyes")
|
|
|
| out = cls._dedup_preserve(out)
|
| return out, cls._eye_base(eyes_text)
|
|
|
| @classmethod
|
| def _eyes_placeholder_value(cls, eyes_in: str, eye_base: str | None) -> str:
|
| """
|
| Used for {eyes} placeholder AND for Frontview symmetrical prefix.
|
| Guarantees it is NOT "blue eyes" (so we don't produce "eyes eyes").
|
| """
|
| if eye_base:
|
| return eye_base
|
| raw = cls._collapse_spaces(eyes_in)
|
| raw2 = re.sub(r"\beyes?\b", "", raw, flags=re.IGNORECASE)
|
| raw2 = cls._collapse_spaces(raw2)
|
| return raw2 if raw2 else raw
|
|
|
| @staticmethod
|
| def _eyes_to_singular(tokens):
|
| """
|
| Convert "blue eyes" -> "blue eye" etc.
|
| """
|
| out = []
|
| for t in tokens or []:
|
| if not t:
|
| continue
|
| out.append(re.sub(r"\beyes\b", "eye", t, flags=re.IGNORECASE))
|
| return out
|
|
|
|
|
|
|
| _HAIR_COLORS = [
|
| "black", "brown", "blonde", "auburn", "ginger",
|
| "blue", "green", "pink", "purple",
|
| "white", "gray", "silver",
|
| "cyan", "teal", "magenta", "violet",
|
| ]
|
| _HAIR_ALIASES = {
|
| "blond": "blonde",
|
| "grey": "gray",
|
| "red": "auburn",
|
| "yellow": "blonde",
|
| "orange": "ginger",
|
| }
|
| _HAIR_CONF2 = {
|
| "black": ["purple hair", "blue hair"],
|
| "brown": ["auburn hair", "black hair"],
|
| "blonde": ["ginger hair", "white hair"],
|
| "auburn": ["ginger hair", "brown hair"],
|
| "ginger": ["auburn hair", "blonde hair"],
|
| "pink": ["purple hair", "red hair"],
|
| "purple": ["pink hair", "blue hair"],
|
| "blue": ["purple hair", "teal hair"],
|
| "green": ["teal hair", "blue hair"],
|
| "white": ["gray hair", "silver hair"],
|
| "gray": ["white hair", "silver hair"],
|
| "silver": ["white hair", "gray hair"],
|
| "cyan": ["blue hair", "teal hair"],
|
| "teal": ["cyan hair", "green hair"],
|
| "magenta": ["pink hair", "purple hair"],
|
| "violet": ["purple hair", "pink hair"],
|
| }
|
| _HAIR_NORM = {**{c: c for c in _HAIR_COLORS}, **_HAIR_ALIASES}
|
| _HAIR_TOKENS = tuple(sorted(set(_HAIR_COLORS + list(_HAIR_ALIASES.keys())), key=len, reverse=True))
|
| _HAIR_TOKEN_RE = re.compile(r"\b(?:" + "|".join(map(re.escape, _HAIR_TOKENS)) + r")\b", re.IGNORECASE | re.ASCII)
|
| _HAIR_WORD_RE = re.compile(r"\bhair\b", re.IGNORECASE)
|
| _HAIR_LENGTH_RE = re.compile(r"\b(very\s+long|long|medium|short|bald)\b", re.IGNORECASE)
|
|
|
| @classmethod
|
| def _hair_length_in_token(cls, token: str):
|
| if not token:
|
| return None
|
| m = cls._HAIR_LENGTH_RE.search(token)
|
| if not m:
|
| return None
|
| v = cls._collapse_spaces(m.group(1).lower())
|
| return "very long" if v == "very long" else v
|
|
|
| @classmethod
|
| def _canonicalize_hair_color_in_token(cls, token: str) -> str:
|
| t = cls._collapse_spaces(token)
|
| if not t:
|
| return ""
|
| low = t.lower()
|
|
|
| m = re.fullmatch(
|
| r"(" + "|".join(map(re.escape, cls._HAIR_TOKENS)) + r")(?:\s+hair)?",
|
| low
|
| )
|
| if m:
|
| canon = cls._HAIR_NORM.get(m.group(1).lower(), m.group(1).lower())
|
| return f"{canon} hair"
|
| return t
|
|
|
| @classmethod
|
| def _normalize_hair_token(cls, token: str) -> str:
|
| t = cls._canonicalize_hair_color_in_token(token)
|
| if not t:
|
| return ""
|
|
|
| t = re.sub(r"\b(hair)\b(?:\s+\1\b)+", r"\1", t, flags=re.IGNORECASE)
|
|
|
| if cls._HAIR_WORD_RE.search(t):
|
| return t
|
|
|
| if cls._hair_length_in_token(t) == "bald":
|
| return "bald head"
|
|
|
| if cls._HAIR_TOKEN_RE.search(t):
|
| return f"{t} hair"
|
|
|
| if cls._hair_length_in_token(t) in {"short", "medium", "long", "very long"}:
|
| return f"{t} hair"
|
|
|
| if " " not in t:
|
| return f"{t} hair"
|
|
|
| return t
|
|
|
| @classmethod
|
| def _format_hair_tokens(cls, hair_text: str):
|
| parts = cls._split_tags(hair_text)
|
| if not parts:
|
| return [], None, None
|
|
|
|
|
| color_idx, base_color = None, None
|
| for i, p in enumerate(parts):
|
| m = cls._HAIR_TOKEN_RE.search(p)
|
| if m:
|
| color_idx = i
|
| base_color = cls._HAIR_NORM.get(m.group(0).lower())
|
| break
|
|
|
|
|
| length_idx, hair_len = None, None
|
| for i, p in enumerate(parts):
|
| if i == color_idx:
|
| continue
|
| l = cls._hair_length_in_token(p)
|
| if l:
|
| length_idx = i
|
| hair_len = l
|
| break
|
|
|
| ordered = []
|
| if color_idx is not None:
|
| ordered.append(parts[color_idx])
|
| if length_idx is not None:
|
| ordered.append(parts[length_idx])
|
| for i, p in enumerate(parts):
|
| if i == color_idx or i == length_idx:
|
| continue
|
| ordered.append(p)
|
|
|
| norm = [cls._normalize_hair_token(t) for t in ordered]
|
| norm = [t for t in norm if t]
|
| norm = cls._dedup_preserve(norm)
|
| return norm, base_color, hair_len
|
|
|
| @classmethod
|
| def _hair_other_colors(cls, base: str):
|
| if not base:
|
| return []
|
| canon_colors = list(cls._HAIR_CONF2.keys())
|
| return [f"{c} hair" for c in canon_colors if c != base]
|
|
|
| @classmethod
|
| def _hair_conf2(cls, text: str):
|
| if not text:
|
| return []
|
| m = cls._HAIR_TOKEN_RE.search(text)
|
| if not m:
|
| return []
|
| base = cls._HAIR_NORM.get(m.group(0).lower())
|
| return (cls._HAIR_CONF2.get(base, [])[:2]) if base else []
|
|
|
|
|
|
|
| @classmethod
|
| def _rear_pos_filter(cls, tokens):
|
| """
|
| Remove anything mentioning eyes/face/mouth/expression for Rearview_pos.
|
| (Also removes "eye..." substrings like "eyeshadow".)
|
| """
|
| out = []
|
| for t in tokens or []:
|
| low = (t or "").lower()
|
| if not low.strip():
|
| continue
|
| if "eye" in low:
|
| continue
|
| if re.search(r"\b(face|mouth|expression)\b", low, re.IGNORECASE):
|
| continue
|
| out.append(t)
|
| return out
|
|
|
|
|
|
|
| @classmethod
|
| def INPUT_TYPES(cls):
|
| return {
|
| "required": {
|
|
|
| "BAM_in": ("STRING", {"multiline": True, "default": ""}),
|
|
|
| "gender": ("STRING", {"default": "{{gender}} (enter 'girl' or 'boy')"}),
|
| "age": ("STRING", {"default": "{{age}} (e.g., 33)"}),
|
| "identity": ("STRING", {"default": "{{identity}} (e.g., rogue)"}),
|
|
|
| "eyes": ("STRING", {"default": "{{eyes}} (e.g., blue OR 'blue eyes')"}),
|
|
|
| "hair": ("STRING", {
|
| "multiline": True,
|
| "default": "{{hair}} (e.g., brown, long, messy OR 'brown hair, long hair, messy hair')"
|
| }),
|
|
|
| "equip_info": ("STRING", {
|
| "multiline": True,
|
| "default": "{{EQUIP}}"
|
| }),
|
|
|
| "aesthetic_tag1": ("STRING", {"default": ""}),
|
| "aesthetic_tag2": ("STRING", {"default": ""}),
|
| "aesthetic_tag3": ("STRING", {"default": ""}),
|
| "aesthetic_tag4": ("STRING", {"default": ""}),
|
| "aesthetic_tag5": ("STRING", {"default": ""}),
|
|
|
| "skin_tag1": ("STRING", {"default": ""}),
|
| "skin_tag2": ("STRING", {"default": ""}),
|
| "skin_tag3": ("STRING", {"default": ""}),
|
| "skin_tag4": ("STRING", {"default": ""}),
|
| "skin_tag5": ("STRING", {"default": ""}),
|
|
|
| "expression_tag1": ("STRING", {"default": ""}),
|
| "expression_tag2": ("STRING", {"default": ""}),
|
| "expression_tag3": ("STRING", {"default": ""}),
|
| "expression_tag4": ("STRING", {"default": ""}),
|
| "expression_tag5": ("STRING", {"default": ""}),
|
|
|
| "headwear_tag": ("STRING", {"default": ""}),
|
|
|
| "pos_prefix": ("STRING", {"multiline": True, "default": ""}),
|
| "pos_suffix": ("STRING", {"multiline": True, "default": ""}),
|
| "neg_prefix": ("STRING", {"multiline": True, "default": ""}),
|
| "neg_suffix": ("STRING", {"multiline": True, "default": ""}),
|
|
|
| "EXTRA_NEG": ("STRING", {"multiline": True, "default": ""}),
|
| }
|
| }
|
|
|
| RETURN_TYPES = (
|
| "STRING", "STRING", "STRING", "STRING", "IMAGE",
|
| "STRING", "STRING", "STRING", "STRING",
|
| "STRING",
|
| "INT",
|
| )
|
| RETURN_NAMES = (
|
| "Frontview_pos", "Sideview_pos", "Diagonal_pos", "Rearview_pos", "image",
|
| "Frontview_neg", "Sideview_neg", "Diagonal_neg", "Rearview_neg",
|
| "BAM_out",
|
| "age",
|
| )
|
| FUNCTION = "run"
|
|
|
|
|
|
|
| def run(
|
| self,
|
| BAM_in,
|
|
|
| gender,
|
| age,
|
| identity,
|
| eyes,
|
| hair,
|
| equip_info,
|
|
|
| aesthetic_tag1, aesthetic_tag2, aesthetic_tag3, aesthetic_tag4, aesthetic_tag5,
|
| skin_tag1, skin_tag2, skin_tag3, skin_tag4, skin_tag5,
|
| expression_tag1, expression_tag2, expression_tag3, expression_tag4, expression_tag5,
|
|
|
| headwear_tag,
|
|
|
| pos_prefix, pos_suffix,
|
| neg_prefix, neg_suffix,
|
|
|
| EXTRA_NEG,
|
| ):
|
|
|
| bam_override_in = str(BAM_in or "")
|
| if bam_override_in != "":
|
| parsed = self._bam_parse_in(bam_override_in)
|
|
|
| gender = parsed["gender"]
|
| age = parsed["age"]
|
| identity = parsed["identity"]
|
| eyes = parsed["eyes"]
|
| hair = parsed["hair"]
|
| equip_info = parsed["equip_info"]
|
|
|
| aesthetic_tag1 = parsed["aesthetic_tag1"]
|
| aesthetic_tag2 = parsed["aesthetic_tag2"]
|
| aesthetic_tag3 = parsed["aesthetic_tag3"]
|
| aesthetic_tag4 = parsed["aesthetic_tag4"]
|
| aesthetic_tag5 = parsed["aesthetic_tag5"]
|
|
|
| skin_tag1 = parsed["skin_tag1"]
|
| skin_tag2 = parsed["skin_tag2"]
|
| skin_tag3 = parsed["skin_tag3"]
|
| skin_tag4 = parsed["skin_tag4"]
|
| skin_tag5 = parsed["skin_tag5"]
|
|
|
| expression_tag1 = parsed["expression_tag1"]
|
| expression_tag2 = parsed["expression_tag2"]
|
| expression_tag3 = parsed["expression_tag3"]
|
| expression_tag4 = parsed["expression_tag4"]
|
| expression_tag5 = parsed["expression_tag5"]
|
|
|
| headwear_tag = parsed["headwear_tag"]
|
|
|
| pos_prefix = parsed["pos_prefix"]
|
| pos_suffix = parsed["pos_suffix"]
|
| neg_prefix = parsed["neg_prefix"]
|
| neg_suffix = parsed["neg_suffix"]
|
|
|
| EXTRA_NEG = parsed["EXTRA_NEG"]
|
|
|
|
|
| gender_in = str(gender or "")
|
| age_in = str(age or "")
|
| identity_in = str(identity or "")
|
|
|
| eyes_in = str(eyes or "")
|
| hair_in = str(hair or "")
|
| equip_in = str(equip_info or "")
|
|
|
| headwear_in = str(headwear_tag or "")
|
|
|
| pos_prefix_in = str(pos_prefix or "")
|
| pos_suffix_in = str(pos_suffix or "")
|
| neg_prefix_in = str(neg_prefix or "")
|
| neg_suffix_in = str(neg_suffix or "")
|
| extra_neg_in = str(EXTRA_NEG or "")
|
|
|
|
|
| BAM_out = self._bam_build_out({
|
| "gender": gender_in,
|
| "age": age_in,
|
| "identity": identity_in,
|
| "eyes": eyes_in,
|
| "hair": hair_in,
|
| "equip_info": equip_in,
|
|
|
| "aesthetic_tag1": str(aesthetic_tag1 or ""),
|
| "aesthetic_tag2": str(aesthetic_tag2 or ""),
|
| "aesthetic_tag3": str(aesthetic_tag3 or ""),
|
| "aesthetic_tag4": str(aesthetic_tag4 or ""),
|
| "aesthetic_tag5": str(aesthetic_tag5 or ""),
|
|
|
| "skin_tag1": str(skin_tag1 or ""),
|
| "skin_tag2": str(skin_tag2 or ""),
|
| "skin_tag3": str(skin_tag3 or ""),
|
| "skin_tag4": str(skin_tag4 or ""),
|
| "skin_tag5": str(skin_tag5 or ""),
|
|
|
| "expression_tag1": str(expression_tag1 or ""),
|
| "expression_tag2": str(expression_tag2 or ""),
|
| "expression_tag3": str(expression_tag3 or ""),
|
| "expression_tag4": str(expression_tag4 or ""),
|
| "expression_tag5": str(expression_tag5 or ""),
|
|
|
| "headwear_tag": headwear_in,
|
| "pos_prefix": pos_prefix_in,
|
| "pos_suffix": pos_suffix_in,
|
| "neg_prefix": neg_prefix_in,
|
| "neg_suffix": neg_suffix_in,
|
| "EXTRA_NEG": extra_neg_in,
|
| })
|
|
|
|
|
| gender_norm = _parse_gender(gender_in)
|
| n = _parse_number(age_in)
|
| age_out_int = 0 if n is None else int(n)
|
| bucket = _bucket_from_number(n)
|
| fname = f"{gender_norm}{bucket}.png"
|
|
|
| if fname not in _ALLOWED:
|
| raise FileNotFoundError(f"Unexpected filename: {fname}")
|
|
|
| path = safe_path(fname)
|
| if not os.path.isfile(path):
|
| raise FileNotFoundError(f"Required asset not found: {fname} (place it in assets/images/)")
|
| img, _ = load_image_from_assets(fname)
|
|
|
| identity_age_token = CUSTOM_TOKENS.get((gender_norm, bucket), fname)
|
| one_girl_boy = "1" + gender_norm
|
|
|
|
|
| eyes_tokens, eye_base = self._format_eyes_tokens(eyes_in)
|
| hair_tokens, hair_base, hair_len = self._format_hair_tokens(hair_in)
|
|
|
|
|
| age_for_text = self._find_first_int(age_in)
|
| if age_for_text is None:
|
| age_for_text = self._collapse_spaces(age_in)
|
|
|
| eyes_ph = self._eyes_placeholder_value(eyes_in, eye_base)
|
|
|
| ph = {
|
| "age": age_for_text,
|
| "gender": gender_norm,
|
| "boygirl": gender_norm,
|
|
|
| "custom_tokens": identity_age_token,
|
| "custom_token": identity_age_token,
|
| "identity_age": identity_age_token,
|
|
|
| "identity": identity_in,
|
|
|
|
|
| "eyes": eyes_ph,
|
|
|
|
|
| "eyes_full": (eyes_tokens[0] if eyes_tokens else self._collapse_spaces(eyes_in)),
|
| }
|
|
|
| pos_prefix_in = self._render_placeholders(pos_prefix_in, ph)
|
| pos_suffix_in = self._render_placeholders(pos_suffix_in, ph)
|
| neg_prefix_in = self._render_placeholders(neg_prefix_in, ph)
|
| neg_suffix_in = self._render_placeholders(neg_suffix_in, ph)
|
|
|
|
|
| headwear_tokens = self._split_tags(headwear_in)
|
| has_headwear = bool(headwear_tokens)
|
|
|
| pos_head_tokens = []
|
| neg_head_tokens = []
|
|
|
| if not has_headwear:
|
| if hair_len == "bald":
|
| pos_head_tokens.append("bald head fully visible")
|
| else:
|
| pos_head_tokens.append("uncovered head")
|
| if hair_len in {"short", "medium", "long", "very long"}:
|
| pos_head_tokens.append(f"{hair_len} hair fully visible")
|
| else:
|
| pos_head_tokens.append("hair fully visible")
|
| neg_head_tokens += ["headwear", "hat"]
|
| else:
|
| pos_head_tokens.append(headwear_tokens[0])
|
| neg_head_tokens += ["uncovered head"]
|
|
|
|
|
| aesthetic_tokens = self._split_tags(",".join([aesthetic_tag1, aesthetic_tag2, aesthetic_tag3, aesthetic_tag4, aesthetic_tag5]))
|
| skin_tokens = self._split_tags(",".join([skin_tag1, skin_tag2, skin_tag3, skin_tag4, skin_tag5]))
|
| expr_tokens = self._split_tags(",".join([expression_tag1, expression_tag2, expression_tag3, expression_tag4, expression_tag5]))
|
|
|
| aesthetic_tokens = [self._ensure_suffix_word(t, "aesthetic") for t in aesthetic_tokens if t]
|
| skin_tokens = [self._ensure_suffix_word(t, "skin") for t in skin_tokens if t]
|
| expr_tokens = [self._ensure_suffix_word(t, "expression") for t in expr_tokens if t]
|
|
|
| equip_tokens = self._split_tags(equip_in)
|
|
|
|
|
|
|
| FRONT_POS_EXTRA = [
|
| "(masterpiece:0.8)",
|
| f"(symmetrical {eyes_ph} eyes:1.2)",
|
| "(left and right eye masterpiece same:1.5)",
|
| ]
|
|
|
| DIAG_SIDE_POS_EXTRA = [
|
| "(masterpiece:0.8)",
|
| ]
|
|
|
| FRONT_NEG_SUFFIX = [
|
| "(asymmetrical face:1.5)",
|
| "(asymmetrical tail-arched eyebrows:1.0)",
|
| "(heterochromia:1.5)",
|
| "monochrome",
|
| "sketch",
|
| "colorless",
|
| "(terribly drawn eyes:1.2)",
|
| "watermark",
|
| "text",
|
| ]
|
| DIAGONAL_NEG_SUFFIX = [
|
| "monochrome",
|
| "sketch",
|
| "colorless",
|
| "(terribly drawn eyes:1.2)",
|
| "watermark",
|
| "text",
|
| ]
|
| SIDE_NEG_SUFFIX = [
|
| "monochrome",
|
| "sketch",
|
| "colorless",
|
| "(terribly drawn eye:1.2)",
|
| "watermark",
|
| "text",
|
| ]
|
| REAR_NEG_SUFFIX = [
|
| "(straight-on:1.15)",
|
| "(from side POV:1.15)",
|
| "(POV from three-quarter view:1.15)",
|
| "visible face",
|
| "(visible eyes:1.2)",
|
| "monochrome",
|
| "sketch",
|
| "colorless",
|
| "watermark",
|
| "text",
|
| ]
|
|
|
|
|
| base_pos_prefix_tokens = self._split_tags(pos_prefix_in)
|
| base_pos_suffix_tokens = self._split_tags(pos_suffix_in)
|
|
|
|
|
| front_pos_tokens = []
|
| front_pos_tokens += base_pos_prefix_tokens
|
| front_pos_tokens += FRONT_POS_EXTRA
|
| front_pos_tokens += [one_girl_boy, self.POV_FRONT]
|
| front_pos_tokens += eyes_tokens
|
| front_pos_tokens += hair_tokens
|
| front_pos_tokens += pos_head_tokens
|
| front_pos_tokens += equip_tokens
|
| front_pos_tokens += [identity_age_token, identity_in]
|
| front_pos_tokens += aesthetic_tokens
|
| front_pos_tokens += skin_tokens
|
| front_pos_tokens += expr_tokens
|
| front_pos_tokens += ["against grey background"]
|
| front_pos_tokens += base_pos_suffix_tokens
|
| front_pos_tokens = [self._collapse_spaces(t) for t in front_pos_tokens if self._collapse_spaces(t)]
|
| Frontview_pos = ", ".join(front_pos_tokens)
|
|
|
|
|
| diag_pos_tokens = []
|
| diag_pos_tokens += base_pos_prefix_tokens
|
| diag_pos_tokens += DIAG_SIDE_POS_EXTRA
|
| diag_pos_tokens += [one_girl_boy, self.POV_DIAGONAL]
|
| diag_pos_tokens += eyes_tokens
|
| diag_pos_tokens += hair_tokens
|
| diag_pos_tokens += pos_head_tokens
|
| diag_pos_tokens += equip_tokens
|
| diag_pos_tokens += [identity_age_token, identity_in]
|
| diag_pos_tokens += aesthetic_tokens
|
| diag_pos_tokens += skin_tokens
|
| diag_pos_tokens += expr_tokens
|
| diag_pos_tokens += ["against grey background"]
|
| diag_pos_tokens += base_pos_suffix_tokens
|
| diag_pos_tokens = [self._collapse_spaces(t) for t in diag_pos_tokens if self._collapse_spaces(t)]
|
| Diagonal_pos = ", ".join(diag_pos_tokens)
|
|
|
|
|
| side_eyes_tokens = self._eyes_to_singular(eyes_tokens)
|
| side_pos_tokens = []
|
| side_pos_tokens += base_pos_prefix_tokens
|
| side_pos_tokens += DIAG_SIDE_POS_EXTRA
|
| side_pos_tokens += [one_girl_boy, self.POV_SIDE]
|
| side_pos_tokens += side_eyes_tokens
|
| side_pos_tokens += hair_tokens
|
| side_pos_tokens += pos_head_tokens
|
| side_pos_tokens += equip_tokens
|
| side_pos_tokens += [identity_age_token, identity_in]
|
| side_pos_tokens += aesthetic_tokens
|
| side_pos_tokens += skin_tokens
|
| side_pos_tokens += expr_tokens
|
| side_pos_tokens += ["against grey background"]
|
| side_pos_tokens += base_pos_suffix_tokens
|
| side_pos_tokens = [self._collapse_spaces(t) for t in side_pos_tokens if self._collapse_spaces(t)]
|
| Sideview_pos = ", ".join(side_pos_tokens)
|
|
|
|
|
| rear_pos_tokens = []
|
| rear_pos_tokens += base_pos_prefix_tokens
|
| rear_pos_tokens += [one_girl_boy, self.POV_REAR]
|
|
|
| rear_pos_tokens += hair_tokens
|
| rear_pos_tokens += pos_head_tokens
|
| rear_pos_tokens += equip_tokens
|
| rear_pos_tokens += [identity_age_token, identity_in]
|
| rear_pos_tokens += aesthetic_tokens
|
| rear_pos_tokens += skin_tokens
|
|
|
| rear_pos_tokens += ["against grey background"]
|
| rear_pos_tokens += base_pos_suffix_tokens
|
| rear_pos_tokens = [self._collapse_spaces(t) for t in rear_pos_tokens if self._collapse_spaces(t)]
|
| rear_pos_tokens = self._rear_pos_filter(rear_pos_tokens)
|
| Rearview_pos = ", ".join(rear_pos_tokens)
|
|
|
|
|
| def build_base_neg_tokens():
|
| neg_tokens = []
|
| neg_tokens += self._split_tags(neg_prefix_in)
|
|
|
| parse_seed = f"{gender_in} {age_in}"
|
| subj_gender = self._parse_gender_neg(parse_seed)
|
| age_group = self._parse_age_group(parse_seed)
|
| neg_tokens += self._negatives_for_gender(subj_gender)
|
| neg_tokens += self._negatives_for_agegroup(age_group, subj_gender)
|
|
|
| neg_tokens += self._eye_others(eye_base)
|
| neg_tokens += self._hair_other_colors(hair_base)
|
| neg_tokens += self._hair_conf2(hair_in)
|
|
|
| neg_tokens += neg_head_tokens
|
|
|
| neg_tokens += self._split_tags(extra_neg_in)
|
| neg_tokens += self._split_tags(neg_suffix_in)
|
|
|
| neg_tokens = [self._collapse_spaces(t) for t in neg_tokens if self._collapse_spaces(t)]
|
| return neg_tokens
|
|
|
| base_neg = build_base_neg_tokens()
|
|
|
|
|
| front_neg_tokens = list(base_neg) + FRONT_NEG_SUFFIX
|
| front_neg_tokens = self._dedup_preserve(front_neg_tokens)
|
| Frontview_neg = ", ".join(front_neg_tokens)
|
|
|
|
|
| diag_neg_tokens = list(base_neg) + DIAGONAL_NEG_SUFFIX
|
| diag_neg_tokens = self._dedup_preserve(diag_neg_tokens)
|
| Diagonal_neg = ", ".join(diag_neg_tokens)
|
|
|
|
|
| side_neg_tokens = list(base_neg) + SIDE_NEG_SUFFIX
|
| side_neg_tokens = [re.sub(r"\beyes\b", "eye", t, flags=re.IGNORECASE) for t in side_neg_tokens]
|
| side_neg_tokens = self._dedup_preserve(side_neg_tokens)
|
| Sideview_neg = ", ".join(side_neg_tokens)
|
|
|
|
|
| rear_neg_tokens = list(base_neg) + REAR_NEG_SUFFIX
|
| rear_neg_tokens = self._dedup_preserve(rear_neg_tokens)
|
| Rearview_neg = ", ".join(rear_neg_tokens)
|
|
|
| return (
|
| Frontview_pos,
|
| Sideview_pos,
|
| Diagonal_pos,
|
| Rearview_pos,
|
| img,
|
| Frontview_neg,
|
| Sideview_neg,
|
| Diagonal_neg,
|
| Rearview_neg,
|
| BAM_out,
|
| age_out_int,
|
| )
|
|
|
|
|
| NODE_CLASS_MAPPINGS = {"Salia_BAM_2": Salia_BAM_2}
|
| NODE_DISPLAY_NAME_MAPPINGS = {"Salia_BAM_2": "Salia_BAM_2"}
|
|
|