Files
olivv-cs-ComfyUI-FunPack/modules/training/lora/train.py
T
Claude 45b60ac874 Training captions are plain text boxes; the set has its own prefix and postfix
Tags from the tagger are added after a comma and the caption is never reworded.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MpnF7GwNGMbngU11wjym7P
2026-10-17 18:38:03 +00:00

393 lines
18 KiB
Python

"""The training run: the set's media become latents, its captions become conditioning, ComfyUI's own trainer does the learning.
Blocking, and run on ComfyUI's worker. Everything that needs torch or comfy is imported inside the
functions, so the rest of the module (sets, tags, the editor's routes) loads without them.
"""
from __future__ import annotations
import os
from pathlib import Path
from ..._core import log
from . import captions, jobs, media_io, store
POSITIVE = ("own", "liked")
NEGATIVE = ("disliked", "bad_composition", "bad_image")
USES = {"normal": "liked and your own media", "avoid": "disliked results, to train an 'avoid' LoRA", "all": "everything in the set"}
ALGORITHMS = ("LoRA", "LoHa", "LoKr", "OFT")
class Stopped(Exception):
"""The person pressed Stop."""
def pick(doc: dict, use: str) -> list[dict]:
"""The items a run trains on. 'normal' leaves out what was disliked, 'avoid' keeps only that, 'all' keeps everything."""
keep = {"normal": POSITIVE, "avoid": NEGATIVE, "all": POSITIVE + NEGATIVE}.get(use)
if keep is None:
raise ValueError(f"use is one of {sorted(USES)}, not {use!r}")
return [i for i in doc["items"] if i["label"] in keep]
def roots() -> dict:
import folder_paths
return {"output": folder_paths.get_output_directory(), "temp": folder_paths.get_temp_directory(),
"input": folder_paths.get_input_directory()}
def bin_path(media_id: str):
from ..._core import media
try:
return media.path_for(media_id)
except Exception: # noqa: BLE001 -- a bin entry that is gone is a missing file, said by the caller
return None
def plan_items(set_name: str, use: str):
"""(items with their files, problems). Nothing here touches torch."""
doc = store.get(set_name)
if doc is None:
return [], [f"there is no training set called {set_name!r}"]
problems, found = [], []
chosen = pick(doc, use)
if not chosen:
problems.append(f"the set {set_name!r} has nothing to train on for 'use: {use}' ({USES[use]})")
for item in chosen:
path = store.resolve(item["src"], roots(), bin_path)
if path is None:
problems.append(f"{item['name'] or item['id']} is no longer there")
continue
found.append((item, path))
return found, problems
def _encode_text(clip, text: str):
tokens = clip.tokenize(text)
return clip.encode_from_tokens_scheduled(tokens)
def _encode_pixels(vae, pixels):
"""Pixels [N,H,W,3] -> latent tensor, in the VAE's own layout."""
return vae.encode(pixels[:, :, :, :3])
def encode_item(item, path, size, cfg, vae, adapt, audio_vae):
"""One item's latents. For video models a clip stays a clip (cfg['frames'] frames); for picture models a clip gives one frame."""
import torch
width, height = size
if adapt and adapt.get("video"):
frames = adapt["frames"](cfg["frames"])
if item["media_kind"] == "video":
pixels, rate = media_io.clip_frames(path, frames, cfg["fps"] or adapt.get("fps", 24), cfg["start"], width, height)
else:
from PIL import Image, ImageOps
with Image.open(path) as img:
still = media_io.cover(ImageOps.exif_transpose(img).convert("RGB"), width, height)
pixels, rate = media_io.to_tensor([still] * frames), float(adapt.get("fps", 24))
video = _encode_pixels(vae, pixels)
if video.ndim == 4: # a 2D VAE gave a stack of frames: [T,C,h,w] -> [1,C,T,h,w]
video = video.permute(1, 0, 2, 3)[None]
sound = None
if audio_vae is not None and adapt.get("silence"):
sound = _sound_latent(path, item["media_kind"], frames / rate, audio_vae, adapt)
return video, sound
img = media_io_picture(path, item["media_kind"], width, height)
return _encode_pixels(vae, media_io.to_tensor([img])), None
def media_io_picture(path, kind, width, height):
from . import frames
return media_io.cover(frames.picture(path), width, height)
def _sound_latent(path, kind, seconds, audio_vae, adapt):
import comfy.audio
wave = media_io.read_audio(path, seconds) if kind == "video" else None
if wave is None:
return adapt["silence"](audio_vae, seconds)
waveform, rate = wave
target = int(getattr(audio_vae, "audio_sample_rate", 44100))
if rate != target:
waveform = comfy.audio.resample(waveform, rate, target)
return audio_vae.encode(waveform.movedim(1, -1))
def run(model, clip, vae, audio_vae, set_name: str, cfg: dict, prompt_id: str = "") -> str:
"""Train and save. Returns the saved LoRA's file name inside the loras folder. Raises with a reason a person can act on."""
import torch
import comfy.model_management as mm
import comfy_extras.nodes_train as nt
import folder_paths
import safetensors.torch
from ..._core import registry
run_id = jobs.begin(set_name, cfg["name"], cfg["steps"], cfg, prompt_id) # first, so a refusal below is shown in the window and not only in ComfyUI's log
try:
if model is None:
model = load_model(cfg.get("model_name"), cfg.get("weight_dtype", "default"), cfg.get("compute_dtype", "default"))
adapt = registry.current().ask("training_adapt", model)
items, problems = plan_items(set_name, cfg["use"])
if problems and not items:
raise ValueError("; ".join(problems))
if problems:
log.warning("FunPack LoRA training", f"left out of the run: {'; '.join(problems[:5])}{' ...' if len(problems) > 5 else ''}")
if adapt is None and getattr(model, "cached_patcher_init", None) is None:
raise ValueError("this model was loaded in a way the trainer cannot copy; load it with the FunPack Diffusion Model Loader from a plain file")
batch = cfg["batch_size"]
if adapt and batch > adapt.get("batch_limit", batch):
log.warning("FunPack LoRA training", f"{adapt['name']} trains one sample at a time; batch size {batch} is lowered to {adapt['batch_limit']}")
batch = adapt["batch_limit"]
jobs.say(f"Reading {len(items)} items")
step = (adapt or {}).get("step") or 64
sizes = []
for item, path in items:
try:
sizes.append(media_io.size_of(path, item["media_kind"]))
except Exception as exc: # noqa: BLE001
raise ValueError(f"{item['name'] or item['id']} cannot be read: {exc}") from exc
single = bool(adapt) or not cfg["aspect_buckets"]
buckets = media_io.buckets_for([w / h for w, h in sizes], cfg["resolution"], step, single)
texts = [captions.with_parts(item["caption"], cfg["trigger"], cfg.get("postfix", "")) for item, _ in items]
if adapt and adapt.get("video"):
cfg = _fit_frames(cfg, adapt, items)
videos, sounds, conds = [], [], []
with torch.inference_mode():
for number, ((item, path), size, text) in enumerate(zip(items, buckets, texts), 1):
if jobs.stop_requested():
raise Stopped()
jobs.say(f"Preparing {number} of {len(items)}")
latent, sound = encode_item(item, path, size, cfg, vae, adapt, audio_vae)
videos.append(latent.clone())
sounds.append(None if sound is None else sound.clone())
conds.append(_encode_text(clip, text))
videos, sounds, conds = _normal(videos), _normal(sounds), _normal(conds) # made in inference mode: copies a backward pass can use
if adapt and adapt.get("pack") and audio_vae is not None and all(s is not None for s in sounds):
latents = adapt["pack"](videos, sounds)
payload = [latents]
elif adapt and adapt.get("pack"):
raise ValueError("this model trains picture and sound together and needs its audio VAE: add the audio VAE loader to the pipeline")
elif len({tuple(v.shape) for v in videos}) == 1:
payload = [{"samples": torch.cat(videos, 0)}]
else:
payload = [{"samples": v} for v in videos]
_sizes(payload, conds)
_card("before freeing", mm, torch)
del vae, clip, audio_vae # their work is done; the trainer copies the model next and needs the room
_free_the_card(mm, torch)
_card("after freeing", mm, torch)
_train_on_a_plain_copy(model)
losses = _loss_hook(nt, cfg["steps"] * cfg["grad_accumulation"])
jobs.say("Training", "training")
shim = adapt["shims"]() if adapt and adapt.get("shims") else _nothing()
with shim, losses, _watch(nt, mm, torch):
out = nt.TrainLoraNode.execute(
model=[model], latents=payload, positive=conds, batch_size=[batch], steps=[cfg["steps"]],
grad_accumulation_steps=[cfg["grad_accumulation"]], learning_rate=[cfg["learning_rate"]], rank=[cfg["rank"]],
optimizer=[cfg["optimizer"]], loss_function=[cfg["loss"]], seed=[cfg["seed"]], training_dtype=[cfg["training_dtype"]],
lora_dtype=[cfg["lora_dtype"]], quantized_backward=[False], algorithm=[cfg["algorithm"]],
gradient_checkpointing=[cfg["gradient_checkpointing"]], checkpoint_depth=[(adapt or {}).get("checkpoint_depth", 1)], offloading=[cfg["offloading"]],
existing_lora=["[None]"], bucket_mode=[False], bypass_mode=[cfg["bypass"]])
lora = (out.result if hasattr(out, "result") else out)[0]
file = _save(lora, cfg["name"], folder_paths, safetensors.torch, set_name, cfg)
jobs.finish("done", lora=file)
return file
except Stopped:
jobs.finish("stopped")
raise
except mm.InterruptProcessingException:
jobs.finish("stopped")
raise
except Exception as exc: # noqa: BLE001 -- recorded for the window, then raised for ComfyUI to show
jobs.finish("error", error=f"{type(exc).__name__}: {exc}")
raise
class _nothing:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
_WEIGHT = {"fp8_e4m3fn": "float8_e4m3fn", "fp8_e5m2": "float8_e5m2", "fp16": "float16", "bf16": "bfloat16", "fp32": "float32"}
def load_model(name, weight_dtype="default", compute_dtype="default"):
"""The model file, read once and never moved to the card here. The trainer's own copy shares it, so one model is in memory."""
import torch
import comfy.sd
import folder_paths
if not name:
raise ValueError("no model to train on: the pipeline's model loader names no file")
if str(name).lower().endswith(".gguf"):
raise ValueError("a GGUF model cannot be trained: pick a .safetensors file in the model loader")
path = folder_paths.get_full_path_or_raise("diffusion_models", name)
options = {}
if weight_dtype in _WEIGHT:
options["dtype"] = getattr(torch, _WEIGHT[weight_dtype])
with torch.inference_mode(False): # ComfyUI runs nodes in inference mode; weights made there cannot take part in a backward pass
model = comfy.sd.load_diffusion_model(path, model_options=options, disable_dynamic=True)
if compute_dtype in _WEIGHT and hasattr(model, "set_model_compute_dtype"):
model.set_model_compute_dtype(getattr(torch, _WEIGHT[compute_dtype]))
model.cached_patcher_init = (lambda *args, **kwargs: model, ()) # the trainer's "fresh copy" is this model: no second read of the file
return model
def _normal(value):
"""Tensors made in inference mode, copied so autograd may save them; lists, tuples and dicts are walked."""
import torch
if isinstance(value, torch.Tensor):
with torch.inference_mode(False):
return value.clone() if value.is_inference() else value
if isinstance(value, list):
return [_normal(v) for v in value]
if isinstance(value, tuple):
return tuple(_normal(v) for v in value)
if isinstance(value, dict):
return {k: _normal(v) for k, v in value.items()}
return value
def _fit_frames(cfg, adapt, items):
"""Every clip is read for the same number of frames, so the shortest clip sets the limit: fewer than asked is used, and said."""
rate = cfg["fps"] or adapt.get("fps", 24)
shortest = min((media_io.frames_at(path, rate) for item, path in items if item["media_kind"] == "video"), default=None)
wanted = adapt["frames"](cfg["frames"])
if shortest is None or shortest >= wanted:
return cfg
fit = adapt["fit"](shortest) if adapt.get("fit") else shortest
if fit < 1:
raise ValueError(f"the shortest clip has only {shortest} frames at {rate:g} fps: too short to train on")
log.warning("FunPack LoRA training", f"the shortest clip gives {shortest} frames at {rate:g} fps, so every clip is read for {fit} frames and not {wanted}")
return {**cfg, "frames": fit}
def _sizes(payload, conds):
"""One Log line: the shape of the first item's latent and of its text, to tell a run that is too big from one that is not."""
def shape(x):
tensors = getattr(x, "tensors", None)
if tensors is not None:
return " + ".join(str(list(t.shape)) for t in tensors)
return str(list(x.shape)) if hasattr(x, "shape") else type(x).__name__
try:
first = payload[0]["samples"] if isinstance(payload[0], dict) else payload[0]
text = conds[0][0] if isinstance(conds[0], (list, tuple)) else conds[0]
log.info("FunPack LoRA training", f"{len(conds)} text(s); first latent {shape(first)}, first text {shape(text)}")
except Exception as exc: # noqa: BLE001 -- a report must never stop a run
log.info("FunPack LoRA training", f"sizes: not readable ({type(exc).__name__})")
def _card(when, mm, torch):
"""One line in the Log: what the card holds and which models ComfyUI still has loaded."""
try:
device = mm.get_torch_device()
gib = 1024 ** 3
loaded = ", ".join(f"{type(getattr(m.model, 'model', m.model)).__name__} {m.model_loaded_memory() / gib:.1f} GiB" for m in mm.current_loaded_models) or "none"
log.info("FunPack LoRA training", f"card {when}: tensors {torch.cuda.memory_allocated(device) / gib:.1f} GiB, free {mm.get_free_memory(device) / gib:.1f} GiB, loaded models: {loaded}")
except Exception as exc: # noqa: BLE001 -- a report must never stop a run
log.info("FunPack LoRA training", f"card {when}: not readable ({type(exc).__name__})")
class _watch:
"""While open, a Log line after each stage of the trainer's setup, so an out-of-memory says which stage filled the card."""
STAGES = ("_prepare_latents_and_count", "_setup_lora_adapters", "_setup_lora_adapters_bypass", "_create_optimizer")
def __init__(self, nt, mm, torch):
self.nt, self.mm, self.torch, self.put = nt, mm, torch, []
def __enter__(self):
def wrap(owner, name, label):
original = getattr(owner, name, None)
if original is None:
return
def watched(*args, **kwargs):
out = original(*args, **kwargs)
_card(f"after {label}", self.mm, self.torch)
return out
setattr(owner, name, watched)
self.put.append((owner, name, original))
for name in self.STAGES:
wrap(self.nt, name, name.strip("_").replace("_", " "))
wrap(self.mm, "load_models_gpu", "loading the model")
return self
def __exit__(self, *exc):
for owner, name, original in reversed(self.put):
setattr(owner, name, original)
return False
def _free_the_card(mm, torch):
import gc
gc.collect()
mm.unload_all_models()
gc.collect()
mm.soft_empty_cache()
if torch.cuda.is_available():
torch.cuda.empty_cache()
def _train_on_a_plain_copy(model):
"""The trainer copies the model with clone(force_deepcopy=True). On a dynamic-VRAM model that copy is dynamic too, and the
trainer then loads it in full beside the original: two models on the card. Ask for a plain copy, loaded once."""
is_dynamic = getattr(model, "is_dynamic", None)
if not (callable(is_dynamic) and is_dynamic()):
return
clone = model.clone
def plain(*args, **kwargs):
return clone(*args, **{**kwargs, "disable_dynamic": True})
model.clone = plain
class _loss_hook:
"""While open, every step's loss goes to the run record, and Stop (or ComfyUI's interrupt) ends the loop at the next step."""
def __init__(self, nt, total):
self.nt, self.total = nt, total
def __enter__(self):
import comfy.model_management as mm
nt = self.nt
self.original = nt.TrainSampler
def sampler(*args, **kwargs):
inner = kwargs.get("loss_callback")
def callback(loss):
if inner:
inner(loss)
jobs.step(float(loss))
mm.throw_exception_if_processing_interrupted()
if jobs.stop_requested():
raise Stopped()
kwargs["loss_callback"] = callback
return self.original(*args, **kwargs)
nt.TrainSampler = sampler
return self
def __exit__(self, *exc):
self.nt.TrainSampler = self.original
return False
def _save(lora, name, folder_paths, safetensors_torch, set_name, cfg) -> str:
"""Into the first loras folder, as <name>.safetensors, or <name>_2 and so on rather than over an earlier one."""
folder = Path(folder_paths.get_folder_paths("loras")[0])
folder.mkdir(parents=True, exist_ok=True)
stem = "".join(c for c in name if c.isalnum() or c in "-_ .") .strip() or "funpack_lora"
n, path = 1, folder / f"{stem}.safetensors"
while path.exists():
n += 1
path = folder / f"{stem}_{n}.safetensors"
meta = {"software": "ComfyUI-FunPack", "training_set": set_name, "algorithm": cfg["algorithm"], "rank": str(cfg["rank"]),
"steps": str(cfg["steps"]), "trigger": cfg["trigger"]}
safetensors_torch.save_file(lora, str(path), metadata=meta)
return path.name