Files
doggeddalle 4ef4de0a5d Robust adaln, pdd, keymap, modality fixes
Multiple improvements and hardening across the Power LoRA stack:

- adaln: stronger caching (file-version keys), device-safe grid/basis computation, normalized tensor hashing for basis cache, more robust table-to-table rebasing with residual checks and fallback, better detection of adaLN weight name variants.
- apply: validate numeric strengths, reject unsupported passengers, handle loading errors, detect shape mismatches, track rejected/skipped/quantized merges, improved PDD handoff and reporting.
- pdd: introduce movable PddBank/PddState, register banks as additional models, thread-local PDD context, strength-scheduled blending and safer interval clamping.
- keymap/detect/gain/modality/nodes/branch: add support for varied LoRA key spellings, more validation, skip incomplete adapters, and small bugfixes.
- server: surface partial calibration note.
- pyproject/README/.gitignore: packaging, docs, and ignore cleanup; add Python >=3.10 requirement and publisher repo fix.
- web UI: fix balance button behavior, safer auto-balance error handling and factor sanitization.
- tests: add unit tests for adaln, modality and pdd.

Overall: better robustness to varied LoRA formats, mixed devices, malformed inputs, and improved user-facing error/reporting.
2026-08-28 02:22:53 +10:00

131 lines
4.9 KiB
Python

"""Key normalisation for MiniMax H3 LoRAs.
Every H3 LoRA in the wild is one of a handful of naming conventions. Observed
across the local collection:
* ai-toolkit / diffusers ``diffusion_model.blocks.0.attn.qkv_proj.lora_A.weight``
* same, no prefix ``blocks.0.attn.qkv_proj.lora_A.weight``
* kohya / musubi ``lora_unet_blocks_0_attn_qkv_proj.lora_down.weight`` + ``.alpha``
* lycoris ``lycoris_blocks_0_attn_qkv_proj.*``
* peft ``base_model.model.blocks.0...`` / ``transformer.blocks.0...``
``comfy.lora.model_lora_keys_unet`` already covers the ``diffusion_model.``-
prefixed and ``lora_unet_`` forms, but not the bare form, and it gives us no
handle for rewriting adaLN tensors before they are matched. So we normalise
everything ourselves onto the canonical ``diffusion_model.<path>`` module key,
resolving underscore forms through the model's own state dict rather than
guessing where the word boundaries are.
"""
from __future__ import annotations
import re
# suffixes comfy.lora.load_lora understands, longest first so that
# ``.lora_down.weight`` is not truncated to ``.weight``
_LORA_SUFFIXES = (
".lora_linear_layer.down.weight", ".lora_linear_layer.up.weight",
"_lora.down.weight", "_lora.up.weight",
".lora.down.weight", ".lora.up.weight",
".lora_down.weight", ".lora_up.weight",
".lora_A.weight", ".lora_B.weight",
".lora_A.default.weight", ".lora_B.default.weight",
".lora_A", ".lora_B",
".lora_mid.weight", ".dora_scale",
".diff_b", ".diff", ".set_weight", ".alpha",
".hada_w1_a", ".hada_w1_b", ".hada_w2_a", ".hada_w2_b", ".hada_t1", ".hada_t2",
".lokr_w1", ".lokr_w2", ".lokr_w1_a", ".lokr_w1_b", ".lokr_w2_a", ".lokr_w2_b",
".lokr_t2", ".reshape_weight", ".oft_blocks", ".boft_blocks", ".rescale",
".w_norm", ".b_norm",
)
_STRIP_PREFIXES = (
"diffusion_model.", "transformer.", "base_model.model.", "pipe.dit.",
"model.diffusion_model.", "unet.",
)
_UNDERSCORE_PREFIXES = ("lora_unet_", "lycoris_", "lora_transformer_")
def split_suffix(key: str):
"""``blocks.0.mlp.fc1.lora_A.weight`` -> ``('blocks.0.mlp.fc1', '.lora_A.weight')``."""
for suffix in sorted(_LORA_SUFFIXES, key=len, reverse=True):
if key.endswith(suffix):
return key[: -len(suffix)], suffix
return None, None
def build_module_index(model_sd_keys) -> dict:
"""Map every spelling of a DiT module path to its canonical form.
Canonical form is the state-dict key minus ``.weight``, e.g.
``diffusion_model.blocks.0.attn.qkv_proj``. Both the dotted path and the
underscored path (which is what kohya trainers emit) resolve to it, and the
underscore mapping is derived from the real keys so no heuristic is needed
to decide whether ``qkv_proj`` is one token or two.
"""
index: dict[str, str] = {}
for k in model_sd_keys:
if not k.startswith("diffusion_model.") or not k.endswith(".weight"):
continue
canonical = k[: -len(".weight")]
bare = canonical[len("diffusion_model."):]
index[canonical] = canonical
index[bare] = canonical
index[bare.replace(".", "_")] = canonical
return index
def _strip_prefixes(path: str) -> str:
changed = True
while changed:
changed = False
for pre in _STRIP_PREFIXES:
if path.startswith(pre):
path = path[len(pre):]
changed = True
break
for pre in _UNDERSCORE_PREFIXES:
if path.startswith(pre):
path = path[len(pre):]
break
return path
def canonical_module(key_body: str, index: dict):
"""Resolve one LoRA key body to a canonical module path, or None."""
body = _strip_prefixes(key_body)
hit = index.get(body)
if hit is not None:
return hit
# kohya writes ``lora_unet__blocks_0_...`` (double underscore) and some
# trainers keep a dotted tail after the underscored head
squashed = body.replace(".", "_")
hit = index.get(squashed) or index.get(re.sub(r"_{2,}", "_", squashed))
if hit is not None:
return hit
return None
def normalize(lora_sd: dict, index: dict):
"""Rewrite a LoRA state dict onto canonical module keys.
Returns ``(normalized_sd, unmatched_keys)``. Non-LoRA passenger tensors
(``adaln_t_table`` and full ``adaln_proj.linear.weight`` replacements found
in "complete pruned" LoRA files) are passed through untouched so the caller
can deal with them.
"""
out: dict = {}
unmatched: list[str] = []
for key, value in lora_sd.items():
body, suffix = split_suffix(key)
if body is None:
out[key] = value # passenger tensor, handled upstream
continue
module = canonical_module(body, index)
if module is None:
unmatched.append(key)
continue
out[module + suffix] = value
return out, unmatched