improve randomness

This commit is contained in:
RegulusAlpha
2025-08-24 15:16:45 -04:00
parent d62afd86ac
commit 41aa91f338
3 changed files with 115 additions and 31 deletions
+52 -1
View File
@@ -3,7 +3,7 @@
A minimal **dynamic prompting + mirrored wildcards** node for ComfyUI.
## Features
## Overview
- `{a|b|{c|d}}` nested choice expansion (deterministic by seed).
- `__name__` loads `wildcards/name.txt`.
- `__name-mir__` strictly loads `wildcards/name-mir.txt`.
@@ -25,6 +25,7 @@ A minimal **dynamic prompting + mirrored wildcards** node for ComfyUI.
- negative: negative prompt
- Seed: this is used to determine how the node will select tags
- allow neg from mir: automatically adds -mir tags to negative prompt without explicitly needing to add it to the negative prompt
- variety: int between 0-10; creates extra randomness 'lanes' [Variety](#-variety-extra-randomness-lanes)
<br>
Wire the outputs into your usual `CLIP Text Encode` node <br>
You can preview what the output is with the "preview any" node in utils
@@ -109,6 +110,28 @@ Behavior:
- With the checkbox enabled, the extension auto-injects `__hats-mir__` into the **negative** and expands it there as the comma‑separated “other” options.
- If the seed picks **`red hat`** for positive, the negative gets **`blue hat, green hat`** automatically.
## 🎲 Variety (extra randomness lanes)
**What it is:** An integer knob that creates alternate, reproducible randomness *lanes* without changing your seed or prompt.
**Deterministic:** With the same `(seed, variety, prompt, files)`, you get the same positive & negative outputs every time.
**Independence from fixed text:** Only decision points advance randomness—each `{…}` block and each `__wildcard__` / `__*-mir__`. Fixed words/commas/spaces do **not** affect picks.
**How it works (under the hood):**
- Choices use `choice#0, choice#1, …`
- Wildcards use `wild#0, wild#1, …` (also salted with the token name)
- We hash `(seed, counter[, token], variety)` to pick an index.
**When to use:** Keep `seed` fixed and sweep `variety = 0,1,2…` to explore different, reproducible variants from the same prompt.
**Example**
```text
Prompt: __color__, {A|B|{C|D}}, __pose-mir__
(seed=123, variety=0) → picks one set of branches
(seed=123, variety=1) → different set of branches (still reproducible)
## ⚙️ Usage
- Write prompts as usual with `{}` and `__wildcards__`.
@@ -127,4 +150,32 @@ Negative: lowres, bad anatomy, __hats-mir__
- If `negative` is blank and `auto_neg_from_mir` is ON, the node scans the positive for `__tokens__`
and auto-builds a mirrored negative when possible.
## Changelog
### 2025-08-24 — Counter-Based Randomness & “Variety” lanes
**Why**: fixed tokens could cause prompt to converge.
**What changed**
- **Decision-only RNG**: Random choices now depend **only** on decision points (each `{…}` block and each `__wildcard__` / `__*-mir__` call), not on surrounding fixed text.
- Internally we hash `(seed, decision_counter[, token], variety)` where counters advance as we hit choices/wildcards left→right.
- Adding/removing fixed words, commas, or spacing no longer shifts picks.
- **Deterministic lanes via `variety`**: New integer input that adds an extra salt.
- Same `(seed, variety, prompt, files)` ⇒ same outputs.
- Sweep `variety = 0,1,2…` to get reproducible alternates without touching seed or prompt.
- **Deep mirroring (strict)**: `__foo-mir__` contributes mirrored exclusions to Negative even when **nested** inside other wildcards, and **only** reads `foo-mir.txt` (never falls back).
- **Empty-branch support in `*-mir.txt`**: `{option|}` is honored.
- If Positive picks `option`, Negative adds nothing for that token; if Positive picks the empty branch, Negative adds the non-empty options.
- **Single-line `*-mir.txt` with nested braces**: `{1|2|{3|4}}` is flattened to leaves so mirroring can compute “all-except-chosen” correctly.
**Behavior notes**
- Fixed text no longer influences randomness. Only the **number/order** of decision points matters.
- Changing decision points (e.g., adding a `{…}` or another `__wildcard__`) will change the sequence of draws—as expected.
- Deep mirror uses the same counters as Positive, ensuring the Negative always gets the correct complement.
**Example**
```text
Prompt: __color__, {A|B|{C|D}}, __pose-mir__
Same seed, same variety → same picks (pos & neg)
Change only fixed words → picks stay identical
Bump variety (e.g., 0→1) → different, reproducible picks
License: GPLv3 (same as the original)
+27 -23
View File
@@ -4,7 +4,6 @@ from typing import List
from .dynprompt.expander import (
PromptExpander,
WILDCARD_TOKEN_RE,
_stable_pick_index, # reuse same salt/index math as the expander
)
# -------------------- Small helpers --------------------
@@ -28,26 +27,26 @@ def _merge_csv(*parts: str) -> str:
out.append(chunk)
return ", ".join(out)
def _collect_mirrors_deep(text: str, expander: PromptExpander) -> str:
def _collect_mirrors_deep(text: str, base_expander: PromptExpander) -> str:
"""
Deep mirroring pass:
- Simulate the expander's left-to-right wildcard pass on the POSITIVE prompt.
- For tokens ending with '-mir', compute the chosen index and collect
"all except chosen" into a bag. (STRICT lookup: '<name>-mir.txt' only.)
- For non-mir tokens, just replace with the chosen option (no collection).
Notes:
* We collapse braces BEFORE scanning for wildcards to match the expander's order.
* Empty branches in -mir files are supported:
- If chosen == '' (empty), we add all NON-empty others to Negative.
- If chosen is non-empty, we add all other NON-empty options.
* Newly introduced nested wildcards (from chosen options) are handled
by continuing the while-loop until no __token__ remains.
Deep mirroring pass with counter-based RNG:
- Use a fresh expander (same seed/wildcard_dir/variety) so counters start at 0.
- Collapse braces first (consumes choice counter).
- Expand wildcards left-to-right; for any token ending with '-mir', add
"all except chosen" (ignore empties) to the bag.
- Replace tokens with their chosen values to permit nested discovery.
"""
# Step 1: match expander's first phase (choices first)
s = expander.collapse_choices(text or "")
mirror = PromptExpander(
seed=base_expander.seed,
wildcard_dir=base_expander.wildcard_dir,
variety=getattr(base_expander, "variety", 0),
)
# choices first (consumes mirror's choice counter)
s = mirror.collapse_choices(text or "")
bag: List[str] = []
# Step 2: left-to-right wildcard pass (mirrors expander._expand_wildcards_recursively)
# wildcard pass (consumes mirror's wild counter in the same sequence as Positive)
while True:
m = WILDCARD_TOKEN_RE.search(s)
if not m:
@@ -56,16 +55,15 @@ def _collect_mirrors_deep(text: str, expander: PromptExpander) -> str:
token = m.group(1)
start, end = m.span()
opts = expander.read_options_for_token(token) # STRICT; may be empty
opts = mirror.read_options_for_token(token)
if not opts:
# Drop unknown token to mirror main expander behavior
# drop unknown to mirror main expander behavior
s = s[:start] + s[end:]
continue
idx = _stable_pick_index(expander.seed, f"wild:{token}:{s}:{start}-{end}", len(opts))
idx = mirror._pick_wild_index(token, len(opts))
chosen = opts[idx] if 0 <= idx < len(opts) else ""
# If this is a strict '-mir' token, accumulate "all except chosen" (ignore empties)
if token.endswith("-mir"):
others = [o for i, o in enumerate(opts) if i != idx and o.strip()]
if others:
@@ -87,6 +85,10 @@ class DynPromptExpand:
appends deep-mirrored exclusions from ANY __*-mir__ encountered in the
Positive (including those nested inside wildcard files). Strict '-mir':
only '<name>-mir.txt' is consulted; no fallback to '<name>.txt'.
Counter-based RNG means fixed tokens don't influence randomness; only
decision points (braces/wildcards) advance the counters. Optional 'variety'
input gives multiple reproducible randomness lanes per seed.
"""
@classmethod
@@ -96,6 +98,8 @@ class DynPromptExpand:
"text": ("STRING", {"multiline": True, "default": ""}),
"negative": ("STRING", {"multiline": True, "default": ""}),
"seed": ("INT", {"default": 0, "min": 0, "max": 2**31 - 1}),
"variety": ("INT", {"default": 0, "min": 0, "max": 100,
"tooltip": "Extra randomness lane. 0 = off; same seed+variety reproduce."}),
"wildcard_dir": ("STRING", {
"default": "",
"tooltip": "Folder with wildcard .txt files (supports subfolders like clothes/tops.txt)"
@@ -112,7 +116,7 @@ class DynPromptExpand:
FUNCTION = "expand"
CATEGORY = "prompt"
def expand(self, text: str, negative: str, seed: int, wildcard_dir: str, auto_neg_from_mir: bool):
def expand(self, text: str, negative: str, seed: int, variety: int, wildcard_dir: str, auto_neg_from_mir: bool):
# Resolve wildcard dir (absolute, ~ supported). Fallback to ./wildcards next to this file.
wd = (wildcard_dir or "").strip()
if wd:
@@ -120,7 +124,7 @@ class DynPromptExpand:
if not wd:
wd = os.path.abspath(os.path.join(os.path.dirname(__file__), "wildcards"))
expander = PromptExpander(seed=seed, wildcard_dir=wd)
expander = PromptExpander(seed=seed, wildcard_dir=wd, variety=variety)
# A) Positive prompt: full expansion (choices -> wildcards -> choices)
pos_final = expander.expand_prompt(text or "", phase="pos")
+36 -7
View File
@@ -115,7 +115,7 @@ def _stable_pick_index(seed: int, salt: str, n: int) -> int:
class PromptExpander:
"""
Expands deterministically by seed:
Expands deterministically by seed and counter-based salts:
1) {a|b|{c|d}|} --> brace choices (empty branch allowed)
2) __token__ --> wildcards from <wildcard_dir>/token.txt
@@ -127,16 +127,45 @@ class PromptExpander:
- phase="pos": pick-one for wildcards (normal)
- phase="neg": join all except chosen (used for mirroring)
(empty options are ignored when joining, so they don't add commas)
Counter-based randomness:
- Fixed text has no effect on picks.
- Choices use an incrementing choice counter (choice#0, choice#1, ...).
- Wildcards use an incrementing wild counter (wild#0, wild#1, ...), salted with token.
- Optional 'variety' integer allows multiple deterministic lanes per seed.
"""
def __init__(self, seed: int, wildcard_dir: Optional[str] = None):
def __init__(self, seed: int, wildcard_dir: Optional[str] = None, variety: int = 0):
self.seed = int(seed) if seed is not None else 0
self.wildcard_dir = wildcard_dir or ""
self.variety = int(variety) if variety else 0
self._salt_extra = f":v{self.variety}" if self.variety else ""
# runtime counters (reset per expand)
self._choice_counter = 0
self._wild_counter = 0
# -------------------- Counter RNG helpers --------------------
def _reset_counters(self):
self._choice_counter = 0
self._wild_counter = 0
def _pick_choice_index(self, n: int) -> int:
idx = _stable_pick_index(self.seed, f"choice#{self._choice_counter}{self._salt_extra}", n)
self._choice_counter += 1
return idx
def _pick_wild_index(self, token: str, n: int) -> int:
idx = _stable_pick_index(self.seed, f"wild#{self._wild_counter}:{token}{self._salt_extra}", n)
self._wild_counter += 1
return idx
# -------------------- Public helpers --------------------
def collapse_choices(self, text: str) -> str:
"""Deterministically collapse all {a|b|{c|d}|} blocks. No wildcard expansion."""
# Ensure counters match the main expand order if used standalone
self._reset_counters()
return self._expand_choices_recursively(text or "")
def read_options_for_token(self, token: str) -> List[str]:
@@ -154,7 +183,6 @@ class PromptExpander:
- For non-mir tokens: split by TOP-LEVEL '|' into options
(empty branches kept as '').
"""
# Resolve and read file raw (without any brace splitting)
path = self._normalize_token_to_path(token)
if not path:
return []
@@ -182,7 +210,6 @@ class PromptExpander:
return _dedup_preserve(_flatten_brace_expr(line))
else:
# Non-mir: choose among top-level alts (empties allowed)
# e.g., "{red|blue| |green}" -> ["red","blue","","green"]
m = re.fullmatch(r"\{(.*)\}", line)
if m:
return _split_top_level_alts(m.group(1))
@@ -195,7 +222,7 @@ class PromptExpander:
# are collapsed later by the brace pass).
return raw
# -------------------- IO & parsing ----------------------
# -------------------- IO & path safety ----------------------
def _normalize_token_to_path(self, token: str) -> Optional[str]:
if not self.wildcard_dir:
@@ -221,7 +248,7 @@ class PromptExpander:
if not options:
# Leave unchanged on malformed {}
return s
idx = _stable_pick_index(self.seed, f"choice:{s}:{start}-{end}", len(options))
idx = self._pick_choice_index(len(options))
chosen = options[idx] # may be '' (empty branch)
return s[:start] + chosen + s[end:]
@@ -246,7 +273,7 @@ class PromptExpander:
# Missing file => drop token silently
return s[:start] + "" + s[end:]
idx = _stable_pick_index(self.seed, f"wild:{token}:{s}:{start}-{end}", len(options))
idx = self._pick_wild_index(token, len(options))
chosen = options[idx] if 0 <= idx < len(options) else ""
if phase == "pos":
@@ -269,6 +296,8 @@ class PromptExpander:
def expand_prompt(self, text: str, phase: str = "pos") -> str:
if not text:
return ""
# Reset counters so fixed text doesn't affect picks; reproducible per call
self._reset_counters()
out = text
# 1) Collapse braces first (enables optional segments via empty branches)
out = self._expand_choices_recursively(out)