diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2f2d929..7379c1b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - fix upscaler init error should not block server - improve torch nvidia arch detection - add torch amd arch detection + - fix prompt weighted lists and internal wildcards ## Update for 2026-04-01 diff --git a/modules/styles.py b/modules/styles.py index 9b024a056..02a154e2b 100644 --- a/modules/styles.py +++ b/modules/styles.py @@ -46,64 +46,48 @@ def apply_styles_to_prompt(prompt, styles): def select_from_weighted_list(inner: str) -> str: - if not inner: + def _split_weight(p: str): + """Split 'name:weight' where the colon separator must not be inside brackets. Returns (name, wstr) or None.""" + depth = 0 + last_colon = -1 + for i, c in enumerate(p): + if c in '<([{': + depth += 1 + elif c in '>)]}': + if depth > 0: + depth -= 1 + elif c == ':' and depth == 0: + last_colon = i + if last_colon < 0: + return None + return p[:last_colon].strip(), p[last_colon + 1:].strip() + + if not inner or len(inner.strip()) == 0: return '' parts = [p.strip() for p in inner.split('|') if p.strip()] weighted: dict[str, float] = {} - unweighted = [] for p in parts: - is_list = (p.startswith('(') and p.endswith(')')) or \ - (p.startswith('[') and p.endswith(']')) or \ - (p.startswith('{') and p.endswith('}')) or \ - (p.startswith('<') and p.endswith('>')) - if (':' in p) and not is_list: - name, wstr = p.split(':', 1) - name = name.strip() + split = _split_weight(p) + if split is not None: + name, wstr = split try: - w = float(wstr.strip()) + w = float(wstr) except Exception: w = 0.0 w = max(0.0, w) weighted[name] = weighted.get(name, 0.0) + w else: - unweighted.append(p) + weighted[p] = 1.0 W = sum(weighted.values()) - U = len(unweighted) - - if U == 0: # only weighted options - keys = list(weighted.keys()) - if not keys: - return '' - if W == 0.0: - return '' - if abs(W - 1.0) > 1e-12: - weighted = {k: v / W for k, v in weighted.items()} - else: # mix of weighted and unweighted - if W > 1.0: # weighted probabilities consume whole mass -> normalize them, unweighted get 0 - for name in unweighted: - weighted[name] = weighted.get(name, 0.0) + 1.0 - total_before = sum(weighted.values()) - if total_before > 0.0: - weighted = {k: v / total_before for k, v in weighted.items()} - else: - remaining = 1.0 - W - per = remaining / U if U > 0 else 0.0 - for name in unweighted: - weighted[name] = weighted.get(name, 0.0) + per - - items = list(weighted.items()) - if not items: - return '' - - total = sum(v for _, v in items) - if total <= 0.0: - return items[0][0] - - names, weights = zip(*items, strict=False) - return random.choices(names, weights=weights, k=1)[0] + if len(weighted) == 0 or W <= 0.0: + return inner + weighted = {k: v / W for k, v in weighted.items()} # normalize to sum=1 + names, weights = zip(*weighted.items(), strict=False) + choice = random.choices(names, weights=weights, k=1)[0] + return choice def apply_curly_braces_to_prompt(prompt, seed=-1): diff --git a/test/test-weighted-lists.py b/test/test-weighted-lists.py index 38ceb0e41..5497836da 100644 --- a/test/test-weighted-lists.py +++ b/test/test-weighted-lists.py @@ -4,10 +4,11 @@ import sys import os from collections import Counter + script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) os.chdir(script_dir) -# --- test defition ------------------------------- + # library fn = r'./modules/styles.py' # tested function @@ -15,9 +16,9 @@ funcname = 'select_from_weighted_list' # random needed ns = {'Dict': dict, 'random': __import__('random')} # number of samples to test -tries = 2000 +tries = 10000 # allowed deviation in percentage points -tolerance_pct = 5 +tolerance_pct = 2.0 # tests tests = [ # - empty @@ -25,24 +26,33 @@ tests = [ # - no weights [ "red|blonde|black", { 'black': 33, 'red': 33, 'blonde': 33 } ], # - full weights <= 1 - [ "red:0.1|blonde:0.9", { 'blonde': 90, 'red': 10 } ], + [ "red:0.1|blonde:0.9", { 'red': 10, 'blonde': 90 } ], # - weights > 1 to test normalization - [ "red:1|blonde:2|black:5", { 'blonde': 25, 'red': 12.5, 'black': 62.5 } ], + [ "red:1|blonde:2|black:5", { 'red': 12.5, 'blonde': 25, 'black': 62.5 } ], # - disabling 0 weights to force one result [ "red:0|blonde|black:0", { 'blonde': 100 } ], # - weights <= 1 with distribution of the leftover - [ "red:0.5|blonde|black:0.3|brown", { 'red': 50, 'black': 30, 'brown': 10, 'blonde': 10 } ], + [ "red:0.5|blonde|black:0.3|brown", { 'red': 0.5, 'blonde': 1.0, 'black': 0.3, 'brown': 1.0 } ], # - weights > 1, unweightes should get default of 1 [ "red:2|blonde|black", { 'red': 50, 'blonde': 25, 'black': 25 } ], # - ignore content of () - [ "red:0.5|(blonde:1.3)", { 'red': 50, '(blonde:1.3)': 50 } ], + [ "red:0.5|(blonde:1.3)", { 'red': 50, '(blonde:1.3)': 100 } ], + # - ignore content of () + [ "red:0.5|(blonde:1.3):0.5", { 'red': 50, '(blonde:1.3)': 50 } ], # - ignore content of [] - [ "red:0.5|[stuff:1.3]", { '[stuff:1.3]': 50, 'red': 50 } ], + [ "red:0.5|[stuff:1.3]", { 'red': 50, '[stuff:1.3]': 100 } ], # - ignore content of <> - [ "red:0.5|", { '': 50, 'red': 50 } ] + [ "red:0.5|", { 'red': 50, '': 100 } ], + # - simple list, 1 entry with lora with weights + [ "red||black", { 'black': 33, 'red': 33, '': 33 } ], + # - simple list, 1 entry with loraand comma + [ "red|blonde |black", { 'black': 33, 'red': 33, 'blonde ': 33 } ], + # - simple list, 1 entry with lora and comma + [ "red|blonde, |black", { 'black': 33, 'red': 33, 'blonde, ': 33 } ], + # - simple list, 1 entry with lora and comma + [ "red|blonde, |black:2", { 'black': 50, 'red': 25, 'blonde, ': 25 } ], ] -# ------------------------------------------------- with open(fn, 'r', encoding='utf-8') as f: src = f.read() @@ -57,7 +67,7 @@ with open(fn, 'r', encoding='utf-8') as f: end = min(end_candidates) if end_candidates else len(src) func_src = src[start:end] - exec(func_src, ns) + exec(func_src, ns) # pylint: disable=exec-used func = ns.get(funcname) if func is None: print('Failed to extract function') @@ -65,16 +75,12 @@ with open(fn, 'r', encoding='utf-8') as f: print('Running' , tries, 'isolated quick tests for ' + funcname + ':\n') - """Print test summary.""" - print("\n" + "=" * 70) - print("TEST SUMMARY") - print("=" * 70) - for t in tests: - print('INPUT:', t) + print('Input:', t[0]) samples = [func(t[0]) for _ in range(tries)] c = Counter(samples) - print("SAMPLES: ", dict(c)) + print(" Expected:", dict(t[1])) + print(" Samples:", dict(c)) # validation expected_pct = t[1] @@ -85,29 +91,32 @@ with open(fn, 'r', encoding='utf-8') as f: if missing or unexpected: if missing: - print("MISSING: ", sorted(missing)) + print(" Missing: ", sorted(missing)) if unexpected: - print("UNEXPECTED: ", sorted(unexpected)) - print("RESULT: FAILED (keys)") - print('') + print(" Unexpected: ", sorted(unexpected)) + print(" Result: FAIL keys") continue failures = [] + W = sum(expected_pct.values()) + deviations = {} for k, pct in expected_pct.items(): - expected_count = tries * (pct / 100.0) + pct = pct / W # normalize + expected_count = tries * pct actual_count = c.get(k, 0) - actual_pct = (actual_count / tries) * 100.0 - if abs(actual_pct - pct) > tolerance_pct: + actual_pct = actual_count / tries + deviation_pct = abs(actual_pct - pct) + deviations[k] = deviation_pct + if deviation_pct > tolerance_pct / 100.0: failures.append( f"{k}: expected {pct:.1f}%, got {actual_pct:.1f}% " f"({actual_count}/{tries})" ) + print(" Deviations:", {k: f"{v*100:.2f}%" for k, v in deviations.items()}) if failures: - print("OUT OF RANGE: ") for line in failures: - print(" - " + line) - print("RESULT: FAILED (distribution)") + print(" " + line) + print(" Result: FAIL distribution") else: - print("RESULT: PASSED") - print('') + print(" Result: PASS")