Files
AISeDam e3779864e7 v0.5.60: RPKSampler use_base=False fixes, LowVram patch leak fix, comment cleanup
Bug Fixes:
- Fix RPKSampler use_base=False: null BASE anchor to prevent COL bleeding
- Fix RPKSampler use_base=False: correct BASE skip, lora_offset, exclusive area
- Fix RPKSampler use_base=True: BASE text encoding unaffected by null BASE logic
- Fix all samplers/detailers: patches={} instead of patches.clear()
- Fix all samplers/detailers: unpatch_model() before sampling
- Fix RPRegionalDetailerZImage: pre-build LoRA models before inpaint loop
- Fix RPRegionalDetailerZImage: fresh model.clone() base per run

Changed:
- core/lora_manager.py: lora_offset parameter added
- core/prompt_parser.py: subprompts_raw col-only
- RPKSampler: common pre-merged into DIV texts once
2026-04-30 14:43:18 +09:00

325 lines
12 KiB
Python

"""
Regional Prompter - LoRA Manager (P2)
ComfyUI port based on AddNet/RP latent.py caching approach.
Original design (A1111):
- _reload_loras_for_col(): LoRA switching per division
- cache key: str "rp_col_{idx}_{loras}" (type identifies cache state)
- cache hit: isinstance(key, str) and key == addnet_key
ComfyUI port:
- Cannot replace LoRA weights directly (ModelPatcher based)
- Pre-build ModelPatcher clone per division
- Call separate apply_model per division in model_function_wrapper
- cache: {addnet_key_str: (model_patcher_clone, clip_clone)}
"""
from __future__ import annotations
import os
from typing import Dict, Optional, Tuple
_COMFY_AVAILABLE = False
try:
import comfy.sd
import comfy.utils
import folder_paths
_COMFY_AVAILABLE = True
except ImportError:
pass
# ─────────────────────────────────────────────────────────
# Build cache key (str type: port of A1111 addnet_key approach)
# ─────────────────────────────────────────────────────────
def _make_addnet_key(loras: Dict[str, float]) -> str:
"""
LoRA set identifier (independent of division index).
A1111: "rp_col_{loras_sorted}"
Same LoRA set → same key → cache reuse
"""
return "rp_col_" + ",".join(f"{k}:{v:.4f}" for k, v in sorted(loras.items()))
def _make_col_idx_key(col_idx: int, loras: Dict[str, float]) -> str:
"""
Division unique identifier (includes and_idx).
A1111: "rp_col_{and_idx}_{loras_sorted}"
Used for full-hit check.
"""
return f"rp_col_{col_idx}_" + ",".join(f"{k}:{v:.4f}" for k, v in sorted(loras.items()))
# Backward-compat alias
def _make_cache_key(col_idx: int, loras: Dict[str, float]) -> str:
return _make_col_idx_key(col_idx, loras)
def _find_lora_path(lora_name: str) -> Optional[str]:
if not _COMFY_AVAILABLE:
return None
try:
# Normalize path separator (Windows \ → /)
lora_name_norm = lora_name.replace("\\", "/")
# Remove unnecessary prefix like models/loras/
for prefix in ("models/loras/", "models\\loras\\", "loras/", "loras\\"):
pfx = prefix.replace("\\", "/")
if lora_name_norm.startswith(pfx):
lora_name_norm = lora_name_norm[len(pfx):]
break
lora_stem_norm = os.path.splitext(lora_name_norm)[0] # remove extension
lora_basename = os.path.basename(lora_stem_norm) # filename only
lora_stem_lower = lora_stem_norm.lower()
lora_base_lower = lora_basename.lower()
# Get loras folder path list
try:
lora_dirs = folder_paths.get_folder_paths("loras")
except Exception:
lora_dirs = []
# 1. folder_paths.get_filename_list method (ComfyUI newer versions with recursive support)
try:
candidates = folder_paths.get_filename_list("loras")
for fname in candidates:
fname_norm = fname.replace("\\", "/")
fstem_norm = os.path.splitext(fname_norm)[0]
# exact match including path
if fstem_norm == lora_stem_norm or fstem_norm.lower() == lora_stem_lower:
full = folder_paths.get_full_path("loras", fname)
if full:
print(f"[RP LoRA] '{lora_name}' → '{fname}'")
return full
# filename-only match
fbase = os.path.basename(fstem_norm)
if fbase == lora_basename or fbase.lower() == lora_base_lower:
full = folder_paths.get_full_path("loras", fname)
if full:
print(f"[RP LoRA] '{lora_name}' → '{fname}' (basename)")
return full
except Exception:
pass
# 2. Direct recursive scan of loras folder
# → covers cases where get_filename_list does not support subdirs
lora_exts = {".safetensors", ".pt", ".ckpt", ".bin"}
for lora_dir in lora_dirs:
if not os.path.isdir(lora_dir):
continue
for root, _dirs, files in os.walk(lora_dir):
for fname in files:
if os.path.splitext(fname)[1].lower() not in lora_exts:
continue
full_path = os.path.join(root, fname)
# relative path from lora_dir (/ separator)
rel_path = os.path.relpath(full_path, lora_dir).replace("\\", "/")
rel_stem = os.path.splitext(rel_path)[0]
file_stem = os.path.splitext(fname)[0]
# exact match including path
if rel_stem == lora_stem_norm or rel_stem.lower() == lora_stem_lower:
print(f"[RP LoRA] '{lora_name}' → '{rel_path}' (recursive)")
return full_path
# filename-only match
if file_stem == lora_basename or file_stem.lower() == lora_base_lower:
print(f"[RP LoRA] '{lora_name}' → '{rel_path}' (recursive basename)")
return full_path
except Exception as e:
print(f"[RP LoRA] folder_paths error: {e}")
print(f"[RP LoRA] '{lora_name}' file not found")
return None
def _apply_loras(base_model, base_clip, loras: Dict[str, float]):
"""
ComfyUI official method: comfy.sd.load_lora_for_models
(same as nodes.py LoraLoader)
"""
model = base_model.clone() if hasattr(base_model, "clone") else base_model
clip = base_clip.clone() if (
base_clip is not None and hasattr(base_clip, "clone")
) else base_clip
for lora_name, weight in loras.items():
path = _find_lora_path(lora_name)
if path is None:
continue
try:
lora_data = comfy.utils.load_torch_file(path, safe_load=True)
model, clip = comfy.sd.load_lora_for_models(
model, clip, lora_data,
strength_model=float(weight),
strength_clip=float(weight),
)
print(f"[RP LoRA] '{lora_name}' weight={weight:.3f} applied")
except Exception as e:
print(f"[RP LoRA] '{lora_name}' apply failed: {e}")
return model, clip
class LoRADivisionManager:
"""
ComfyUI port of AddNet/_reload_loras_for_col caching approach.
Cache structure:
_cache: {addnet_key(str): (ModelPatcher, CLIPModel)}
- addnet_key is str type → identified by isinstance(key, str)
- Same LoRA set reuses same ModelPatcher across divisions
_loaded_col_idx: str|None
- Last activated col_idx_key
- For full-hit check (maps to A1111 _loaded_col_idx)
_loaded_cache_key: str|None
- Currently active addnet_key
- None forces reload (already_loaded=False)
"""
def __init__(self):
self.col_lora_map: Dict[int, Dict[str, float]] = {}
self.division_count: int = 0
self._base_model = None
self._base_clip = None
# Cache state variables (A1111 style)
self._cache: Dict[str, Tuple] = {} # addnet_key → (model, clip)
self._col_key_map: Dict[int, str] = {} # col_idx → col_idx_key
self._loaded_col_idx: Optional[str] = None # last col_idx_key
self._loaded_cache_key: Optional[str] = None # last addnet_key (str|None)
# Runtime state
self.u_count: int = 0
self.step: int = 0
def setup(self, col_lora_map, division_count, base_model, base_clip,
div_label_fn=None, lora_offset=0):
self.col_lora_map = col_lora_map
self.division_count = division_count
self._base_model = base_model
self._base_clip = base_clip
self._div_label_fn = div_label_fn # col_idx → "DIV[r,c]" or "BASE"
self._lora_offset = lora_offset # col_lora_map key = col_idx + lora_offset
self.u_count = 0
self.step = 0
self._cache = {}
self._col_key_map = {}
self._loaded_col_idx = None
self._loaded_cache_key = None
def _div_label(self, col_idx: int) -> str:
"""Convert col_idx to DIV[r,c] or BASE label."""
if self._div_label_fn is not None:
return self._div_label_fn(col_idx)
return f"DIV[0,{col_idx}]"
def prebuild_cache(self):
"""
Pre-build ModelPatcher per division before sampling.
Performs A1111 _reload_loras_for_col step 3 (cache miss → load)
in advance during prebuild phase.
Cache key: addnet_key (str) → identifiable as RP cache
Same LoRA set reuses clone without rebuilding.
"""
if not _COMFY_AVAILABLE:
print("[RP LoRA] ComfyUI not available → skip cache")
return
print("[RP LoRA] === Division LoRA cache build ===")
for col_idx in range(self.division_count):
loras = self.col_lora_map.get(col_idx + self._lora_offset, {})
addnet_key = _make_addnet_key(loras)
col_idx_key = _make_col_idx_key(col_idx, loras)
self._col_key_map[col_idx] = col_idx_key
label = self._div_label(col_idx)
if addnet_key in self._cache:
print(f"[RP LoRA] {label} same LoRA set reused: {addnet_key}")
continue
if not loras:
self._cache[addnet_key] = (self._base_model, self._base_clip)
print(f"[RP LoRA] {label} no LoRA")
else:
print(f"[RP LoRA] {label} build: {loras}")
pm, pc = _apply_loras(self._base_model, self._base_clip, loras)
self._cache[addnet_key] = (pm, pc)
print("[RP LoRA] === cache complete ===")
def get_model_for_division(self, col_idx: int) -> Tuple:
"""
Cache decision logic of _reload_loras_for_col() (A1111 port).
Step 1: col_idx_key full hit → return (model, clip)
Step 2: same LoRA set, different division → update _loaded_col_idx and return
Step 3: cache miss → build and return
Returns: (model_patcher, clip_model)
"""
loras = self.col_lora_map.get(col_idx + self._lora_offset, {})
addnet_key = _make_addnet_key(loras)
col_idx_key = self._col_key_map.get(col_idx)
if col_idx_key is None:
col_idx_key = _make_col_idx_key(col_idx, loras)
cached_key = self._loaded_cache_key # str|None
# Determine already_loaded (A1111 style)
# str type check: handles mix of tuple(regular) and str(rp_col) cache entries
already_loaded = (
isinstance(cached_key, str) and cached_key == addnet_key
)
if already_loaded:
if self._loaded_col_idx == col_idx_key:
# Step 1: full hit
pass
else:
# Step 2: same LoRA set, different division → update _loaded_col_idx only
self._loaded_col_idx = col_idx_key
else:
# Step 3: cache miss → build (should already be built in prebuild)
if addnet_key not in self._cache:
if not loras:
self._cache[addnet_key] = (self._base_model, self._base_clip)
elif _COMFY_AVAILABLE:
pm, pc = _apply_loras(self._base_model, self._base_clip, loras)
self._cache[addnet_key] = (pm, pc)
self._loaded_cache_key = addnet_key
self._loaded_col_idx = col_idx_key
return self._cache.get(addnet_key, (self._base_model, self._base_clip))
def reset_step(self, step: int):
self.step = step
self.u_count = 0
def u_start(self) -> Optional[Tuple]:
"""
Calculate division index on each UNet forward → call get_model_for_division.
uncond (last slot) → returns None.
"""
if self.division_count == 0:
return None
n_div = self.division_count + 1 # divisions + uncond
raw_idx = self.u_count % n_div
div_idx = raw_idx
self.u_count += 1
if div_idx >= self.division_count:
return None # uncond → use base model
return self.get_model_for_division(div_idx)
# Backward compat
def _get_cached(self, col_idx: int) -> Optional[Tuple]:
return self.get_model_for_division(col_idx)