Files
bradsec-ComfyUI_RandomPerson/nodes.py
T

501 lines
22 KiB
Python

"""
Random Person Description Node for ComfyUI
==========================================
Produces a clean, consistently structured person description, e.g.:
34 year old Brazilian female, heart face, warm beige complexion,
hazel eyes, button nose, bow-shaped lips, auburn long hair worn
loose waves, hourglass build
Each category also has its own individual output pin.
Per-category MODE:
"random" - pick any entry from the (sex-appropriate) full list
"allow_list" - comma-separated label subset (blank = all)
"fixed" - use the exact label selected in the dropdown
"off" - omit this category entirely
Age uses numeric age_min / age_max (0 = no bound) or age_fixed.
Dual-API: the shared core (generate_person) is wrapped by a V1 class
(NODE_CLASS_MAPPINGS, authoritative on the 0.25.0 if/elif loader) and an
import-guarded V3 class (comfy_entrypoint) for builds whose loader prefers
the comfy_api schema. node_id "RandomPersonNode" is fixed in both so the
js/ sex-aware widget extension keeps matching.
"""
import json
import os
import random
from functools import lru_cache
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
MODES = ["random", "allow_list", "fixed", "off"]
SEX_MODES = ["random", "male", "female"]
AGE_MODES = ["random", "fixed", "off"]
# -- Data helpers --------------------------------------------------------------
@lru_cache(maxsize=None)
def load(subdir, filename):
# Parsed once per process. Callers must treat the result as read-only.
# Matches the documented "edit data, restart ComfyUI" workflow.
with open(os.path.join(DATA_DIR, subdir, filename), encoding="utf-8") as f:
return json.load(f)
def load_shared(fn): return load("shared", fn)
def load_sex(sex, fn): return load(sex, fn)
def _labels(data):
"""Extract label list from a JSON data list."""
return [item["label"] if isinstance(item, dict) else item for item in data]
def _union(*lists):
"""Merge multiple label lists, deduplicated and sorted."""
seen, out = set(), []
for lst in lists:
for x in lst:
if x not in seen:
seen.add(x)
out.append(x)
return sorted(out)
# Build dropdown option lists once at import time.
_NAT_LABELS = ["(none)"] + _labels(load_shared("nationality.json"))
_COMP_LABELS = ["(none)"] + _labels(load_shared("complexion.json"))
_EYES_LABELS = ["(none)"] + _labels(load_shared("eyes.json"))
# Sex-gated lists: union of male + female so the dropdown is valid for any sex.
_HAIR_COLOR_LABELS = ["(none)"] + _union(_labels(load_sex("male", "hair_color.json")),
_labels(load_sex("female", "hair_color.json")))
_HAIR_STYLE_LABELS = ["(none)"] + _union(_labels(load_sex("male", "hair_style.json")),
_labels(load_sex("female", "hair_style.json")))
_HAIR_LENGTH_LABELS = ["(none)"] + _union(_labels(load_sex("male", "hair_length.json")),
_labels(load_sex("female", "hair_length.json")))
_BODY_TYPE_LABELS = ["(none)"] + _union(_labels(load_sex("male", "body_type.json")),
_labels(load_sex("female", "body_type.json")))
_FACE_SHAPE_LABELS = ["(none)"] + _union(_labels(load_sex("male", "face_shape.json")),
_labels(load_sex("female", "face_shape.json")))
_NOSE_SHAPE_LABELS = ["(none)"] + _union(_labels(load_sex("male", "nose_shape.json")),
_labels(load_sex("female", "nose_shape.json")))
_MOUTH_SHAPE_LABELS = ["(none)"] + _union(_labels(load_sex("male", "mouth_shape.json")),
_labels(load_sex("female", "mouth_shape.json")))
_FACE_FEATURE_LABELS = ["(none)"] + _union(_labels(load_sex("male", "face_feature.json")),
_labels(load_sex("female", "face_feature.json")))
_FACIAL_HAIR_LABELS = ["(none)"] + _union(_labels(load_sex("male", "facial_hair.json")),
_labels(load_sex("female", "facial_hair.json")))
_MAKEUP_LABELS = ["(none)"] + _union(_labels(load_sex("male", "makeup.json")),
_labels(load_sex("female", "makeup.json")))
# Shared (sex-agnostic) lists.
_SKIN_TEXTURE_LABELS = ["(none)"] + _labels(load_shared("skin_texture.json"))
_ACCESSORIES_LABELS = ["(none)"] + _labels(load_shared("accessories.json"))
_EXPRESSION_LABELS = ["(none)"] + _labels(load_shared("expression.json"))
# Per-category spec drives BOTH API wrappers so they cannot drift.
# (key, fixed_label_list, allow_list_placeholder)
CATEGORY_SPECS = [
("nationality", _NAT_LABELS, "e.g. American, Japanese, Italian"),
("complexion", _COMP_LABELS, "e.g. fair, olive, deep brown"),
("skin_texture", _SKIN_TEXTURE_LABELS, "e.g. smooth, freckled, wrinkled"),
("eyes", _EYES_LABELS, "e.g. deep brown, ice blue, hazel"),
("face_shape", _FACE_SHAPE_LABELS, "e.g. oval, square, heart"),
("nose_shape", _NOSE_SHAPE_LABELS, "e.g. straight nose, broad nose, snub nose"),
("mouth_shape", _MOUTH_SHAPE_LABELS, "e.g. full lips, thin lips, wide mouth"),
("face_feature", _FACE_FEATURE_LABELS, "e.g. high cheekbones, dimples, freckles"),
("hair_color", _HAIR_COLOR_LABELS, "e.g. jet black, auburn, platinum blonde"),
("hair_style", _HAIR_STYLE_LABELS, "e.g. bun, braids, pixie cut"),
("hair_length", _HAIR_LENGTH_LABELS, "e.g. short, medium, long"),
("facial_hair", _FACIAL_HAIR_LABELS, "e.g. full beard, stubble, goatee"),
("body_type", _BODY_TYPE_LABELS, "e.g. athletic, slim, curvy, stocky"),
("expression", _EXPRESSION_LABELS, "e.g. soft smile, serious, confident"),
("accessories", _ACCESSORIES_LABELS, "e.g. glasses, hoop earrings, nose ring"),
("makeup", _MAKEUP_LABELS, "e.g. natural makeup, bold lipstick, smokey eye"),
]
# Categories whose data lives under data/<sex>/ rather than data/shared/.
SEX_GATED = {"hair_color", "hair_style", "hair_length", "body_type", "face_shape",
"nose_shape", "mouth_shape", "face_feature", "facial_hair", "makeup"}
# Optional "flair" categories default to mode "off" so the base description stays
# clean and realistic; the user opts them in per category. Core identity
# categories (nationality, complexion, eyes, face/nose/mouth shape, hair, body)
# default to "random".
DEFAULT_OFF = {"skin_texture", "face_feature", "facial_hair",
"expression", "accessories", "makeup"}
def _mode_default(key):
return "off" if key in DEFAULT_OFF else "random"
# Tooltips (shared by both APIs).
_SEED_TIP = "Seed for reproducibility. With randomize off, the same seed reproduces the same person."
_RAND_TIP = "On: pick a new random person every run, ignoring the seed widget. Off: use the seed."
_SEX_TIP = "random, male, or female. male/female filter the sex-specific dropdowns in the UI."
_MODE_TIP = "How this attribute is chosen: random (any), allow_list (subset you list), fixed (the dropdown value), off (omit it)."
_ALLOW_TIP = "Comma-separated values used when mode=allow_list. Unknown values are added as literal descriptors."
_FIXED_TIP = "Exact value used when mode=fixed. (none) falls back to random."
_AGEMIN_TIP = "Minimum age. 0 = no lower bound (defaults to 21). Cannot go below 21."
_AGEMAX_TIP = "Maximum age. 0 = no upper bound (defaults to 90). Minimum is always 21."
_AGEFIX_TIP = "Exact age when mode=fixed. 0 = ignored. Values below 21 are clamped to 21."
_EXTRA_TIP = "Free text appended to the description as-is, comma-separated. Clothing, accessories, expression, etc."
RETURN_NAMES = ("description", "sex", "age", "nationality", "complexion",
"face", "hair", "facial_hair", "body_type",
"expression", "accessories", "makeup", "seed")
# -- Selection logic -----------------------------------------------------------
def item_matches(item, token):
"""
Fuzzy multi-field match (case-insensitive).
Priority: exact (2) > token-in-field or field-in-token (1) > no match (0).
"""
if isinstance(item, dict):
fields = [item.get("label", ""), item.get("descriptor", ""),
item.get("from", ""), item.get("description", "")]
else:
fields = [str(item)]
fields = [f.lower() for f in fields if f]
for field in fields:
if token == field: return 2
for field in fields:
if token in field: return 1 # "america" in "american"
if field in token: return 1 # "french" in "french canadian"
return 0
def _best_match(full_list, token):
"""Return the best-matching item for token, or None."""
scored = [(item_matches(item, token), item) for item in full_list]
scored = [(s, item) for s, item in scored if s > 0]
if not scored:
return None
best_score = max(s for s, _ in scored)
candidates = [item for s, item in scored if s == best_score]
# Among equal-score candidates, prefer shortest label (most specific match).
if isinstance(candidates[0], dict):
candidates.sort(key=lambda x: len(x.get("label", "")))
return candidates[0]
def resolve(mode, full_list, allow_str="", fixed_val=""):
"""Pick one item from full_list according to mode. Returns None if off."""
if mode == "off":
return None
if mode == "fixed" and fixed_val.strip() and fixed_val.strip() != "(none)":
token = fixed_val.strip().lower()
match = _best_match(full_list, token)
if match is not None:
return match
print(f"[RandomPersonNode] WARNING: fixed '{fixed_val}' not found - using random.")
if mode == "allow_list" and allow_str.strip():
tokens = [s.strip() for s in allow_str.split(",") if s.strip()]
matched_items, custom_strings = [], []
for t in tokens:
tl = t.lower()
hits = [item for item in full_list if item_matches(item, tl) > 0]
if hits:
matched_items.extend(hits)
else:
custom_strings.append(t) # not in JSON - treat as literal custom value
seen, deduped = set(), []
for item in matched_items:
key = id(item)
if key not in seen:
seen.add(key)
deduped.append(item)
pool = deduped + [{"label": s, "description": s} for s in custom_strings]
if pool:
return random.choice(pool)
print(f"[RandomPersonNode] WARNING: allow_list '{allow_str}' matched nothing - using random.")
return random.choice(full_list)
def resolve_age(mode, age_min, age_max, age_fixed_val, all_ages):
if mode == "off":
return None
if mode == "fixed" and age_fixed_val > 0:
age_fixed_val = max(age_fixed_val, 21)
exact = [a for a in all_ages if a["age"] == age_fixed_val]
if exact:
return exact[0]
print(f"[RandomPersonNode] WARNING: age {age_fixed_val} not in data - using random.")
lo = max(age_min, 21) if age_min > 0 else 21
hi = min(age_max, 90) if age_max > 0 else 90
if lo > hi:
lo, hi = hi, lo
pool = [a for a in all_ages if lo <= a["age"] <= hi]
if not pool:
print(f"[RandomPersonNode] WARNING: no ages in {lo}-{hi} - using full list.")
pool = all_ages
return random.choice(pool)
def d(item, key="description"):
"""Extract a string from a dict item, or the item itself."""
return item.get(key, "") if isinstance(item, dict) else str(item)
def nat_text(item):
"""Nationality display text. Falls back when a custom allow_list value has
no 'descriptor' key (custom items carry only label/description)."""
if not item:
return ""
if isinstance(item, dict):
return item.get("descriptor") or item.get("description") or item.get("label") or ""
return str(item)
# -- Description builder -------------------------------------------------------
def build_description(sex, nat, age, skin_texture,
face_shape, complexion, eyes, nose_shape, mouth_shape, face_feature,
hair_color, hair_style, hair_length, facial_hair,
body, expression, accessories, makeup, extra_attributes=""):
"""Produce a single comma-separated descriptor string."""
parts = []
def add(item, fmt="{}"):
"""Append a category's description if it resolves to non-empty text."""
s = d(item).strip() if item else ""
if s:
parts.append(fmt.format(s))
# Opener: "34 year old Brazilian female"
age_str = f"{age['age']} year old" if age else None
nat_str = nat_text(nat) or None
sex_str = sex if sex else None
opener_parts = [p for p in [age_str, nat_str, sex_str] if p]
if opener_parts:
parts.append(" ".join(opener_parts))
add(face_shape, "{} face")
add(complexion, "{} complexion")
add(skin_texture)
if eyes:
eyes_str = d(eyes)
parts.append(eyes_str if "eyes" in eyes_str.lower() else f"{eyes_str} eyes")
add(nose_shape)
add(mouth_shape)
add(face_feature)
# Hair: "auburn long hair worn loose waves"
hair_color_str = d(hair_color) if hair_color else None
hair_length_str = d(hair_length) if hair_length else None
hair_style_str = d(hair_style) if hair_style else None
if hair_color_str or hair_length_str or hair_style_str:
pre_hair = " ".join(p for p in [hair_color_str, hair_length_str] if p)
if pre_hair and hair_style_str:
parts.append(f"{pre_hair} hair worn {hair_style_str}")
elif pre_hair:
parts.append(f"{pre_hair} hair")
elif hair_style_str:
parts.append(f"hair worn {hair_style_str}")
add(facial_hair)
if body:
build_str = d(body)
parts.append(build_str if build_str.lower().endswith("build") else f"{build_str} build")
add(expression)
add(accessories)
add(makeup)
if extra_attributes and extra_attributes.strip():
for attr in extra_attributes.split(","):
attr = attr.strip().strip(".")
if attr:
parts.append(attr)
return ", ".join(parts)
# -- Shared core (called by both API wrappers) ---------------------------------
def generate_person(seed, randomize, sex, category_args,
age_mode, age_min, age_max, age_fixed, extra_attributes):
"""
category_args: dict keyed by category prefix with (mode, allow_list, fixed)
tuples for each entry in CATEGORY_SPECS. Returns the output tuple matching
RETURN_NAMES.
"""
if randomize:
seed = random.randint(0, 0xFFFFFFFF)
random.seed(seed)
resolved_sex = sex if sex in ("male", "female") else random.choice(["male", "female"])
def load_list(key):
sub = resolved_sex if key in SEX_GATED else "shared"
return load(sub, f"{key}.json")
p = {}
for key, _, _ in CATEGORY_SPECS:
mode, allow, fixed = category_args[key]
p[key] = resolve(mode, load_list(key), allow, fixed)
age = resolve_age(age_mode, age_min, age_max, age_fixed, load_shared("age.json"))
description = build_description(
resolved_sex, p["nationality"], age, p["skin_texture"],
p["face_shape"], p["complexion"], p["eyes"], p["nose_shape"], p["mouth_shape"], p["face_feature"],
p["hair_color"], p["hair_style"], p["hair_length"], p["facial_hair"],
p["body_type"], p["expression"], p["accessories"], p["makeup"],
extra_attributes)
def dd(key, sub_key="description"):
item = p[key]
return d(item, sub_key) if item else ""
out_age = str(age["age"]) if age else ""
out_nat = nat_text(p["nationality"])
out_comp = ", ".join(filter(None, [dd("complexion"), dd("skin_texture")]))
out_hair = " ".join(filter(None, [dd("hair_color"), dd("hair_length"), dd("hair_style")]))
def _eyes_str():
s = dd("eyes")
return (s if "eyes" in s.lower() else f"{s} eyes") if s else ""
out_face = ", ".join(filter(None, [
f"{dd('face_shape')} face" if dd("face_shape") else "",
_eyes_str(),
dd("nose_shape"),
dd("mouth_shape"),
dd("face_feature"),
]))
return (description, resolved_sex, out_age, out_nat, out_comp,
out_face, out_hair, dd("facial_hair"), dd("body_type"),
dd("expression"), dd("accessories"), dd("makeup"), seed)
def _collect_category_args(kwargs):
"""Pull the (mode, allow_list, fixed) triple for each category from kwargs."""
return {
key: (kwargs[f"{key}_mode"], kwargs[f"{key}_allow_list"], kwargs[f"{key}_fixed"])
for key, _, _ in CATEGORY_SPECS
}
# -- V1 node (legacy dict API) -------------------------------------------------
class RandomPersonNode:
CATEGORY = "utils"
FUNCTION = "generate"
OUTPUT_NODE = False
DESCRIPTION = "Generate a randomised, structured physical person description for image prompts."
RETURN_TYPES = ("STRING",) * (len(RETURN_NAMES) - 1) + ("INT",)
RETURN_NAMES = RETURN_NAMES
@classmethod
def INPUT_TYPES(cls):
required = {
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFF, "tooltip": _SEED_TIP}),
"randomize": ("BOOLEAN", {"default": True, "tooltip": _RAND_TIP}),
"sex": (SEX_MODES, {"default": "random", "tooltip": _SEX_TIP}),
}
for key, fixed_labels, placeholder in CATEGORY_SPECS:
required[f"{key}_mode"] = (MODES, {"default": _mode_default(key), "tooltip": _MODE_TIP})
required[f"{key}_allow_list"] = ("STRING", {"default": "", "multiline": False,
"placeholder": placeholder, "tooltip": _ALLOW_TIP})
required[f"{key}_fixed"] = (fixed_labels, {"default": "(none)", "tooltip": _FIXED_TIP})
required["age_mode"] = (AGE_MODES, {"default": "random"})
required["age_min"] = ("INT", {"default": 0, "min": 0, "max": 90, "tooltip": _AGEMIN_TIP})
required["age_max"] = ("INT", {"default": 0, "min": 0, "max": 90, "tooltip": _AGEMAX_TIP})
required["age_fixed"] = ("INT", {"default": 0, "min": 0, "max": 90, "tooltip": _AGEFIX_TIP})
required["extra_attributes"] = ("STRING", {"default": "", "multiline": True,
"placeholder": "e.g. wearing glasses, tattoo on left arm",
"tooltip": _EXTRA_TIP})
return {"required": required}
def generate(self, seed, randomize, sex, age_mode, age_min, age_max,
age_fixed, extra_attributes, **kwargs):
return generate_person(seed, randomize, sex, _collect_category_args(kwargs),
age_mode, age_min, age_max, age_fixed, extra_attributes)
@classmethod
def IS_CHANGED(cls, randomize, seed, **kwargs):
# randomize=True -> always re-run; otherwise re-run only when seed changes.
return float("nan") if randomize else seed
NODE_CLASS_MAPPINGS = {"RandomPersonNode": RandomPersonNode}
NODE_DISPLAY_NAME_MAPPINGS = {"RandomPersonNode": "Random Person Description"}
# -- V3 node (import-guarded comfy_api schema) ---------------------------------
try:
from comfy_api.v0_0_2 import io, ComfyExtension
class RandomPersonNodeV3(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
inputs = [
io.Int.Input("seed", default=0, min=0, max=0xFFFFFFFF, tooltip=_SEED_TIP),
io.Boolean.Input("randomize", default=True, tooltip=_RAND_TIP),
io.Combo.Input("sex", options=SEX_MODES, default="random", tooltip=_SEX_TIP),
]
for key, fixed_labels, placeholder in CATEGORY_SPECS:
inputs += [
io.Combo.Input(f"{key}_mode", options=MODES, default=_mode_default(key), tooltip=_MODE_TIP),
io.String.Input(f"{key}_allow_list", default="", multiline=False,
placeholder=placeholder, tooltip=_ALLOW_TIP),
io.Combo.Input(f"{key}_fixed", options=fixed_labels, default="(none)", tooltip=_FIXED_TIP),
]
inputs += [
io.Combo.Input("age_mode", options=AGE_MODES, default="random"),
io.Int.Input("age_min", default=0, min=0, max=90, tooltip=_AGEMIN_TIP),
io.Int.Input("age_max", default=0, min=0, max=90, tooltip=_AGEMAX_TIP),
io.Int.Input("age_fixed", default=0, min=0, max=90, tooltip=_AGEFIX_TIP),
io.String.Input("extra_attributes", default="", multiline=True,
placeholder="e.g. wearing glasses, tattoo on left arm", tooltip=_EXTRA_TIP),
]
outputs = [io.String.Output(id=n, display_name=n) for n in RETURN_NAMES[:-1]]
outputs.append(io.Int.Output(id="seed", display_name="seed", tooltip=_SEED_TIP))
return io.Schema(
node_id="RandomPersonNode",
display_name="Random Person Description",
category="utils",
description=RandomPersonNode.DESCRIPTION,
inputs=inputs,
outputs=outputs,
)
@classmethod
def fingerprint_inputs(cls, randomize, seed, **kwargs):
return float("nan") if randomize else seed
@classmethod
def execute(cls, seed, randomize, sex, age_mode, age_min, age_max,
age_fixed, extra_attributes, **kwargs) -> io.NodeOutput:
result = generate_person(seed, randomize, sex, _collect_category_args(kwargs),
age_mode, age_min, age_max, age_fixed, extra_attributes)
return io.NodeOutput(*result)
class RandomPersonExtension(ComfyExtension):
async def get_node_list(self):
return [RandomPersonNodeV3]
async def comfy_entrypoint() -> "RandomPersonExtension":
return RandomPersonExtension()
except ImportError:
# Older ComfyUI without comfy_api: V1 mappings above are the only path.
pass