Files
UndefinedUo 0ad8da9034 feat: multi LoRA stack nodes with Gram-Schmidt orthogonal stacking
Three ComfyUI nodes: Multi LoRA Stack (MODEL+CLIP), Model Only, and an
Ortho variant that orthogonalizes LoRA down-projection row spaces at load
time to reduce interference between stacked LoRAs. Includes strength_1-10
override inputs and dynamic JS UI for all nodes.
2026-07-01 19:34:21 -07:00

570 lines
21 KiB
Python

import torch
from nodes import LoraLoader, LoraLoaderModelOnly
class MultiLoRAStack:
"""A powerful node to load multiple LoRAs with individual controls."""
def __init__(self):
# Use a single instance for caching benefits
self.lora_loader = LoraLoader()
@classmethod
def INPUT_TYPES(cls):
_strength_inputs = {
f"strength_{i}": ("FLOAT", {
"default": 1.0, "min": -2.0, "max": 2.0, "step": 0.01, "forceInput": True,
}) for i in range(1, 11)
}
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"lora_stack": ("STRING", {
"multiline": False,
"default": "[]",
"forceInput": False
}),
},
"optional": _strength_inputs,
}
RETURN_TYPES = ("MODEL", "CLIP")
RETURN_NAMES = ("MODEL", "CLIP")
FUNCTION = "load_loras"
CATEGORY = "loaders"
def load_loras(self, model, clip, lora_stack="[]", **kwargs):
"""Loads multiple LoRAs based on the configuration."""
import json
# Debug output
print(f"\n=== MultiLoRAStack Debug ===")
print(f"Received lora_stack: {lora_stack}")
# Parse lora_stack if it's a string
lora_configs = []
if isinstance(lora_stack, str):
try:
lora_configs = json.loads(lora_stack)
print(f"✅ Parsed {len(lora_configs)} LoRA configs from JSON")
for i, config in enumerate(lora_configs):
print(f" Config {i}: {config}")
except Exception as e:
print(f"❌ Failed to parse JSON: {e}")
return (model, clip)
else:
lora_configs = lora_stack if isinstance(lora_stack, list) else []
applied_count = 0
current_model = model
current_clip = clip
# Process each LoRA configuration
for i, config in enumerate(lora_configs):
print(f"\n--- Processing LoRA {i+1} ---")
if not isinstance(config, dict):
print(f"❌ Skipping invalid config at index {i}: {config}")
continue
# Check if this LoRA should be applied
if not config.get('on', False):
print(f"⏸️ Skipping disabled LoRA: {config.get('lora', 'Unknown')}")
continue
lora_name = config.get('lora')
if not lora_name or lora_name == "None":
print(f"❌ Skipping empty/None LoRA")
continue
strength_model = float(config.get('strength', 1.0))
# strength_N input overrides the widget value (1-indexed slot position)
override = kwargs.get(f"strength_{i + 1}")
if override is not None:
strength_model = float(override)
strength_clip = float(config.get('strengthTwo', strength_model))
# Skip if both strengths are 0
if strength_model == 0 and strength_clip == 0:
print(f"⏸️ Skipping zero-strength LoRA: {lora_name}")
continue
print(f"🔄 Attempting to load LoRA:")
print(f" Name: {lora_name}")
print(f" Model Strength: {strength_model}")
print(f" Clip Strength: {strength_clip}")
# Apply the LoRA using ComfyUI's standard loader
try:
# Apply the LoRA to current model and clip
current_model, current_clip = self.lora_loader.load_lora(
current_model, current_clip, lora_name, strength_model, strength_clip
)
applied_count += 1
print(f"✅ Successfully applied LoRA: {lora_name}")
except Exception as e:
print(f"❌ Failed to load LoRA '{lora_name}': {str(e)}")
print(f" Error type: {type(e).__name__}")
import traceback
print(f" Full traceback: {traceback.format_exc()}")
print(f"\n=== Summary ===")
print(f"Applied {applied_count} out of {len(lora_configs)} LoRAs")
print(f"===================\n")
return (current_model, current_clip)
class MultiLoRAStackModelOnly:
"""A powerful node to load multiple LoRAs with individual controls (Model Only)."""
def __init__(self):
# Use a single instance for caching benefits
self.lora_loader = LoraLoaderModelOnly()
@classmethod
def INPUT_TYPES(cls):
_strength_inputs = {
f"strength_{i}": ("FLOAT", {
"default": 1.0, "min": -2.0, "max": 2.0, "step": 0.01, "forceInput": True,
}) for i in range(1, 11)
}
return {
"required": {
"model": ("MODEL",),
"lora_stack": ("STRING", {
"multiline": False,
"default": "[]",
"forceInput": False
}),
},
"optional": _strength_inputs,
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("MODEL",)
FUNCTION = "load_loras"
CATEGORY = "loaders"
def load_loras(self, model, lora_stack="[]", **kwargs):
"""Loads multiple LoRAs based on the configuration (Model Only)."""
import json
# Debug output
print(f"\n=== MultiLoRAStackModelOnly Debug ===")
print(f"Received lora_stack: {lora_stack}")
# Parse lora_stack if it's a string
lora_configs = []
if isinstance(lora_stack, str):
try:
lora_configs = json.loads(lora_stack)
print(f"✅ Parsed {len(lora_configs)} LoRA configs from JSON")
for i, config in enumerate(lora_configs):
print(f" Config {i}: {config}")
except Exception as e:
print(f"❌ Failed to parse JSON: {e}")
return (model,)
else:
lora_configs = lora_stack if isinstance(lora_stack, list) else []
applied_count = 0
current_model = model
# Process each LoRA configuration
for i, config in enumerate(lora_configs):
print(f"\n--- Processing LoRA {i+1} ---")
if not isinstance(config, dict):
print(f"❌ Skipping invalid config at index {i}: {config}")
continue
# Check if this LoRA should be applied
if not config.get('on', False):
print(f"⏸️ Skipping disabled LoRA: {config.get('lora', 'Unknown')}")
continue
lora_name = config.get('lora')
if not lora_name or lora_name == "None":
print(f"❌ Skipping empty/None LoRA")
continue
strength_model = float(config.get('strength', 1.0))
# strength_N input overrides the widget value (1-indexed slot position)
override = kwargs.get(f"strength_{i + 1}")
if override is not None:
strength_model = float(override)
# Skip if strength is 0
if strength_model == 0:
print(f"⏸️ Skipping zero-strength LoRA: {lora_name}")
continue
print(f"🔄 Attempting to load LoRA (Model Only):")
print(f" Name: {lora_name}")
print(f" Model Strength: {strength_model}")
# Apply the LoRA using ComfyUI's model-only loader
try:
# FIXED: Use the correct method name
current_model, = self.lora_loader.load_lora_model_only(
current_model, lora_name, strength_model
)
applied_count += 1
print(f"✅ Successfully applied LoRA (Model Only): {lora_name}")
except Exception as e:
print(f"❌ Failed to load LoRA '{lora_name}': {str(e)}")
print(f" Error type: {type(e).__name__}")
import traceback
print(f" Full traceback: {traceback.format_exc()}")
print(f"\n=== Summary ===")
print(f"Applied {applied_count} out of {len(lora_configs)} LoRAs (Model Only)")
print(f"===================\n")
return (current_model,)
# ---------------------------------------------------------------------------
# Orthogonal stacking helpers
# ---------------------------------------------------------------------------
def _extract_up_down(patch):
"""Return (lora_up, lora_down) from a ComfyUI patch, or (None, None).
Handles:
LoRAAdapter object — comfy.weight_adapter.lora, weights attr holds tensors
("lora", (up, down, alpha, dora)) — tagged string format (modern)
(up, down, ...) — tensor-first format (old)
(alpha_float, (up, down, ...)) — float-prefixed format (some versions)
"""
# comfy.weight_adapter.lora.LoRAAdapter — newer ComfyUI
if hasattr(patch, "weights"):
w = patch.weights
if isinstance(w, (tuple, list)) and len(w) >= 2:
return w[0], w[1]
return None, None
if isinstance(patch, (list, tuple)) and len(patch) >= 2:
first = patch[0]
if isinstance(first, str):
# ("lora", (up, down, alpha, dora))
v = patch[1]
if isinstance(v, (tuple, list)) and len(v) >= 2:
return v[0], v[1]
elif isinstance(first, torch.Tensor):
# (up, down, ...)
return patch[0], patch[1]
elif isinstance(first, (int, float)):
rest = patch[1]
if isinstance(rest, str) and len(patch) >= 3:
inner = patch[2]
if isinstance(inner, (tuple, list)) and len(inner) >= 2:
return inner[0], inner[1]
elif isinstance(rest, (tuple, list)) and len(rest) >= 2:
if isinstance(rest[0], torch.Tensor):
return rest[0], rest[1]
elif isinstance(rest[0], str) and len(rest) >= 2:
inner = rest[1]
if isinstance(inner, (tuple, list)) and len(inner) >= 2:
return inner[0], inner[1]
return None, None
def _replace_down(patch, new_down):
"""Return a copy of patch with lora_down replaced by new_down."""
import copy
# comfy.weight_adapter.lora.LoRAAdapter
if hasattr(patch, "weights"):
new_patch = copy.copy(patch)
w = patch.weights
new_patch.weights = (w[0], new_down) + tuple(w[2:])
return new_patch
if not isinstance(patch, (list, tuple)) or len(patch) < 2:
return patch
first = patch[0]
if isinstance(first, str):
v = list(patch[1])
v[1] = new_down
return (patch[0], tuple(v))
elif isinstance(first, torch.Tensor):
v = list(patch)
v[1] = new_down
return tuple(v)
elif isinstance(first, (int, float)):
rest = patch[1]
if isinstance(rest, str) and len(patch) >= 3:
inner = list(patch[2])
inner[1] = new_down
return (patch[0], patch[1], tuple(inner))
elif isinstance(rest, (tuple, list)) and len(rest) >= 2:
if isinstance(rest[0], torch.Tensor):
inner = list(rest)
inner[1] = new_down
return (patch[0], tuple(inner))
elif isinstance(rest[0], str) and len(rest) >= 2:
inner = list(rest[1])
inner[1] = new_down
return (patch[0], (rest[0], tuple(inner)))
return patch
def _gram_schmidt_ortho_downs(downs_list):
"""Gram-Schmidt orthogonalization of down projection matrices in input space.
Each successive down matrix has its rows projected onto the orthogonal
complement of the row spaces already committed by earlier LoRAs, so that
LoRA_i reads from input directions that LoRA_1..i-1 do not respond to.
downs_list: list of (rank, in_dim) float tensors
Returns: list of orthogonalized tensors, same device/dtype as inputs
"""
result = []
Q = None # (k, in_dim) — accumulated orthonormal row basis
for down in downs_list:
orig_dtype = down.dtype
orig_device = down.device
d = down.float().to(orig_device)
if d.dim() != 2:
result.append(down)
continue
if Q is not None and Q.shape[0] > 0:
# Project rows of d out of the committed subspace
proj = (d @ Q.T) @ Q # (r, in_dim)
d_ortho = d - proj
else:
d_ortho = d.clone()
result.append(d_ortho.to(orig_dtype))
# Extend Q with the row space of d_ortho
fro = d_ortho.norm(p="fro")
if fro.item() < 1e-10:
continue
try:
_, S, Vh = torch.linalg.svd(d_ortho, full_matrices=False)
# Vh: (r, in_dim) — rows are right singular vectors
tol = max(S[0].item() * 1e-6, 1e-10) if S.numel() > 0 else 1e-10
keep = S > tol
if not keep.any():
continue
Q_new = Vh[keep] # (k', in_dim) orthonormal rows
if Q is None:
Q = Q_new
else:
# Concatenate and re-orthogonalize
combined = torch.cat([Q, Q_new], dim=0).T # (in_dim, k+k')
Q_upd, R = torch.linalg.qr(combined, mode="reduced")
r_diag = R.diagonal().abs()
if r_diag.numel() > 0 and r_diag.max().item() > 0:
keep_q = r_diag > r_diag.max().item() * 1e-6
Q = Q_upd[:, keep_q].T # (k_new, in_dim)
else:
Q = Q_upd.T
except Exception as exc:
print(f"[MultiLoRAOrtho] SVD/QR failed during basis update: {exc}")
return result
class MultiLoRAStackModelOnlyOrtho:
"""Multi LoRA Stack with Gram-Schmidt input-space orthogonalization.
Reduces interference between stacked LoRAs by projecting each LoRA's
down matrix onto the orthogonal complement of the row spaces already
claimed by earlier LoRAs. Ports the OrthoLoRA principle from anima_lora
(Cayley-rotated SVD basis, networks/lora_modules/ortho.py) to
inference-time stacking without requiring retraining.
merge_method:
sequential — identical to the plain MultiLoRAStackModelOnly node.
ortho_gs — Gram-Schmidt projection in input space before applying.
"""
def __init__(self):
self.lora_loader = LoraLoaderModelOnly()
@classmethod
def INPUT_TYPES(cls):
_strength_inputs = {
f"strength_{i}": ("FLOAT", {
"default": 1.0, "min": -2.0, "max": 2.0, "step": 0.01, "forceInput": True,
}) for i in range(1, 11)
}
return {
"required": {
"model": ("MODEL",),
"lora_stack": ("STRING", {
"multiline": False,
"default": "[]",
"forceInput": False,
}),
"merge_method": (["ortho_gs", "sequential"], {
"default": "ortho_gs",
}),
},
"optional": _strength_inputs,
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("MODEL",)
FUNCTION = "load_loras"
CATEGORY = "loaders"
def load_loras(self, model, lora_stack="[]", merge_method="ortho_gs", **kwargs):
import json
print(f"\n=== MultiLoRAStackModelOnlyOrtho [{merge_method}] ===")
lora_configs = []
if isinstance(lora_stack, str):
try:
lora_configs = json.loads(lora_stack)
except Exception as e:
print(f"❌ JSON parse error: {e}")
return (model,)
else:
lora_configs = lora_stack if isinstance(lora_stack, list) else []
enabled = []
for i, cfg in enumerate(lora_configs):
if not isinstance(cfg, dict) or not cfg.get("on", False):
continue
name = cfg.get("lora")
if not name or name == "None":
continue
strength = float(cfg.get("strength", 1.0))
# strength_N input overrides the widget value (1-indexed slot position)
override = kwargs.get(f"strength_{i + 1}")
if override is not None:
strength = float(override)
if strength == 0.0:
continue
enabled.append((name, strength))
if not enabled:
print("No enabled LoRAs.")
return (model,)
if merge_method == "sequential" or len(enabled) == 1:
return self._sequential(model, enabled)
return self._ortho_gs(model, enabled)
# ------------------------------------------------------------------
def _sequential(self, model, lora_list):
current = model
for name, strength in lora_list:
print(f" sequential: {name} @ {strength}")
current, = self.lora_loader.load_lora_model_only(current, name, strength)
return (current,)
# ------------------------------------------------------------------
def _ortho_gs(self, model, lora_list):
import comfy.utils
import comfy.lora
import folder_paths
# Build model key map
key_map = {}
try:
key_map = comfy.lora.model_lora_keys_unet(model.model, key_map)
except Exception as e:
print(f"[MultiLoRAOrtho] model_lora_keys_unet failed ({e}); falling back to sequential")
return self._sequential(model, lora_list)
# Load all LoRAs into per-key patch dicts
all_patches = [] # list of ({model_key: patch_tuple}, strength, name)
for name, strength in lora_list:
path = folder_paths.get_full_path("loras", name)
if not path:
print(f"[MultiLoRAOrtho] ❌ LoRA not found: {name}")
continue
try:
lora_sd = comfy.utils.load_torch_file(path, safe_load=True)
patches = comfy.lora.load_lora(lora_sd, key_map)
all_patches.append((patches, strength, name))
print(f" loaded: {name} @ {strength} ({len(patches)} keys)")
except Exception as e:
print(f"[MultiLoRAOrtho] ❌ Failed to load {name}: {e}")
if not all_patches:
return (model,)
# Group by model key
key_to_entries = {} # key -> [(patch_tuple, strength, name)]
for patches, strength, name in all_patches:
for key, patch in patches.items():
key_to_entries.setdefault(key, []).append((patch, strength, name))
new_model = model.clone()
ortho_count = 0
sequential_count = 0
for key, entries in key_to_entries.items():
if len(entries) == 1:
patch, strength, _ = entries[0]
new_model.add_patches({key: patch}, strength)
sequential_count += 1
continue
# Extract (up, down) from each entry
components = []
fallback = False
for patch, strength, name in entries:
up, down = _extract_up_down(patch)
if up is None or down is None or down.dim() != 2:
# Non-LoRA or conv patch — skip orthogonalization
fallback = True
break
components.append((up, down, patch, strength))
if fallback:
for patch, strength, _ in entries:
new_model.add_patches({key: patch}, strength)
sequential_count += len(entries)
continue
# Gram-Schmidt on the down matrices
downs = [c[1] for c in components]
ortho_downs = _gram_schmidt_ortho_downs(downs)
for (up, down, patch, strength), ortho_down in zip(components, ortho_downs):
new_patch = _replace_down(patch, ortho_down.to(down.dtype))
new_model.add_patches({key: new_patch}, strength)
ortho_count += 1
print(f" ortho keys: {ortho_count}, pass-through: {sequential_count}")
print("===================\n")
return (new_model,)
# Register the nodes
NODE_CLASS_MAPPINGS = {
"MultiLoRAStack": MultiLoRAStack,
"MultiLoRAStackModelOnly": MultiLoRAStackModelOnly,
"MultiLoRAStackModelOnlyOrtho": MultiLoRAStackModelOnlyOrtho,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MultiLoRAStack": "Multi LoRA Stack",
"MultiLoRAStackModelOnly": "Multi LoRA Stack (Model Only)",
"MultiLoRAStackModelOnlyOrtho": "Multi LoRA Stack Ortho (Model Only)",
}