Fix Z-Image image-gen + LoRA training; add separate LoRA training base model

- images.py: stop reusing cached prompt embeds for Z-Image. Its encode_prompt
  returns per-sample lists the pipeline consumes during a run, so cache-HIT
  reuse corrupted the batch (AssertionError: len(size) == bsz in unpatchify).
  Z-Image now always encodes natively; cache key also bound to id(pipeline).
- loras.py: fix wrong import (multi_model_manager lives in codai.models.manager,
  not codai.api.state) used for VRAM unload + base-model path resolution; add an
  up-front architecture guard so transformer/DiT models (Z-Image/Flux/SD3) fail
  with a clear message instead of crashing in the CLIP tokenizer.
- New train_base_model on LoraTrainRequest: train LoRAs against a separate
  UNet-based SD1.x/SDXL model while generation keeps using the DiT image model.
- gen_township_fighters.py: thread --lora-train-base-model / web field through
  train_lora, _train_profile_loras, stage_loras/stage_env_loras, all run + per-
  card call sites; CONFIG_FIELDS + live default_args apply on /start, /save-config.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 2c736da4
...@@ -737,11 +737,23 @@ async def _generate_with_diffusers(pipeline, request, global_args, http_request= ...@@ -737,11 +737,23 @@ async def _generate_with_diffusers(pipeline, request, global_args, http_request=
# Try to encode the prompt once and reuse the embeddings. # Try to encode the prompt once and reuse the embeddings.
# Falls back to passing the plain text prompt if encoding fails. # Falls back to passing the plain text prompt if encoding fails.
# ------------------------------------------------------------------ # ------------------------------------------------------------------
model_id = getattr(pipeline, 'model_name_or_path', None) or str(type(pipeline).__name__) # Bind the cache entry to this specific *loaded* pipeline instance, so a
# reload (the model-swapping workflow does this constantly) is a guaranteed
# cache miss instead of feeding stale GPU tensors into a fresh pipeline.
_pipe_cls = str(type(pipeline).__name__)
_model_name = getattr(pipeline, 'model_name_or_path', None) or _pipe_cls
model_id = f"{_model_name}#{id(pipeline)}"
neg_prompt = getattr(request, 'negative_prompt', None) or "" neg_prompt = getattr(request, 'negative_prompt', None) or ""
do_cfg = cfg_scale > 1.0 do_cfg = cfg_scale > 1.0
cached_embeds = _embed_cache.get(request.prompt, neg_prompt, model_id) # Some pipelines (Z-Image) return prompt embeddings as *per-sample lists*
# rather than stacked tensors, and the pipeline consumes/extends those
# lists during a run. Reusing them on a later request corrupts the batch
# dimension (`assert len(size) == bsz` in the Z-Image transformer's
# unpatchify). These are not safe to cache, so encode fresh every time.
_embed_cacheable = "ZImage" not in _pipe_cls
cached_embeds = _embed_cache.get(request.prompt, neg_prompt, model_id) if _embed_cacheable else None
embed_kwargs = {} embed_kwargs = {}
cache_hit = False cache_hit = False
...@@ -749,6 +761,11 @@ async def _generate_with_diffusers(pipeline, request, global_args, http_request= ...@@ -749,6 +761,11 @@ async def _generate_with_diffusers(pipeline, request, global_args, http_request=
embed_kwargs = cached_embeds embed_kwargs = cached_embeds
cache_hit = True cache_hit = True
print(f"Prompt embed cache HIT for model '{model_id}'") print(f"Prompt embed cache HIT for model '{model_id}'")
elif not _embed_cacheable:
# Z-Image et al.: don't pre-encode at all. Leaving embed_kwargs empty
# makes the call below pass the raw prompt, so the pipeline runs its own
# native encode + batching (the only path that survives reuse here).
pass
else: else:
# Try to encode and cache # Try to encode and cache
try: try:
...@@ -781,7 +798,7 @@ async def _generate_with_diffusers(pipeline, request, global_args, http_request= ...@@ -781,7 +798,7 @@ async def _generate_with_diffusers(pipeline, request, global_args, http_request=
'pooled_prompt_embeds': enc[2], 'pooled_prompt_embeds': enc[2],
'negative_pooled_prompt_embeds': enc[3], 'negative_pooled_prompt_embeds': enc[3],
} }
if embed_kwargs: if embed_kwargs and _embed_cacheable:
_embed_cache.put(request.prompt, neg_prompt, model_id, embed_kwargs) _embed_cache.put(request.prompt, neg_prompt, model_id, embed_kwargs)
print(f"Prompt embed cache STORE for model '{model_id}'") print(f"Prompt embed cache STORE for model '{model_id}'")
except Exception as e: except Exception as e:
......
...@@ -131,6 +131,10 @@ def _require_api_auth(request: Request) -> None: ...@@ -131,6 +131,10 @@ def _require_api_auth(request: Request) -> None:
class LoraTrainRequest(BaseModel): class LoraTrainRequest(BaseModel):
name: str # output LoRA name (folder) name: str # output LoRA name (folder)
base_model: str # image model key (models.json) or HF id / path base_model: str # image model key (models.json) or HF id / path
# Optional separate UNet-based SD1.x/SDXL model to train the LoRA against,
# when `base_model` (the generation model) is a transformer/DiT (Z-Image,
# Flux, SD3) this trainer can't target. Falls back to base_model if unset.
train_base_model: Optional[str] = None
character: Optional[str] = None # saved character profile to pull images from character: Optional[str] = None # saved character profile to pull images from
environment: Optional[str] = None # OR saved environment profile to pull images from environment: Optional[str] = None # OR saved environment profile to pull images from
images: Optional[List[str]] = None # OR explicit base64/data-uri images images: Optional[List[str]] = None # OR explicit base64/data-uri images
...@@ -148,7 +152,7 @@ class LoraTrainRequest(BaseModel): ...@@ -148,7 +152,7 @@ class LoraTrainRequest(BaseModel):
def _resolve_base_model_path(base_model: str) -> str: def _resolve_base_model_path(base_model: str) -> str:
"""Resolve an image model key (or path/HF id) to a diffusers model directory.""" """Resolve an image model key (or path/HF id) to a diffusers model directory."""
try: try:
from codai.api.state import multi_model_manager from codai.models.manager import multi_model_manager
for key in (f"image:{base_model}", base_model): for key in (f"image:{base_model}", base_model):
cfg = multi_model_manager.config.get(key) cfg = multi_model_manager.config.get(key)
if cfg: if cfg:
...@@ -222,7 +226,9 @@ def _train_lora_sync(req: LoraTrainRequest) -> dict: ...@@ -222,7 +226,9 @@ def _train_lora_sync(req: LoraTrainRequest) -> dict:
from transformers import CLIPTextModel, CLIPTokenizer from transformers import CLIPTextModel, CLIPTokenizer
name = req.name name = req.name
base_path = _resolve_base_model_path(req.base_model) # Train against a dedicated UNet model when provided (the generation model
# may be a DiT this trainer can't target); otherwise use base_model.
base_path = _resolve_base_model_path(req.train_base_model or req.base_model)
steps = max(50, min(5000, int(req.steps or 800))) steps = max(50, min(5000, int(req.steps or 800)))
rank = max(2, min(128, int(req.rank or 16))) rank = max(2, min(128, int(req.rank or 16)))
resolution = int(req.resolution or 512) resolution = int(req.resolution or 512)
...@@ -247,7 +253,7 @@ def _train_lora_sync(req: LoraTrainRequest) -> dict: ...@@ -247,7 +253,7 @@ def _train_lora_sync(req: LoraTrainRequest) -> dict:
# Free VRAM: evict every resident model so training has the whole GPU. # Free VRAM: evict every resident model so training has the whole GPU.
try: try:
from codai.api.state import multi_model_manager from codai.models.manager import multi_model_manager
multi_model_manager.unload_all_models() multi_model_manager.unload_all_models()
except Exception as e: except Exception as e:
print(f" [lora] could not unload models before training: {e}") print(f" [lora] could not unload models before training: {e}")
...@@ -257,6 +263,22 @@ def _train_lora_sync(req: LoraTrainRequest) -> dict: ...@@ -257,6 +263,22 @@ def _train_lora_sync(req: LoraTrainRequest) -> dict:
_set_progress(status="preparing", message=f"loading base model: {base_path}") _set_progress(status="preparing", message=f"loading base model: {base_path}")
# This DreamBooth-LoRA trainer only supports UNet-based SD1.x / SDXL
# pipelines. Transformer/DiT models (Z-Image, Flux, SD3, …) have a
# `transformer/` subfolder and no `unet/`; loading their non-CLIP tokenizer
# as a CLIPTokenizer crashes deep inside transformers. Detect that up front
# and fail with an actionable message instead.
import os as _os
_has_unet = _os.path.isdir(_os.path.join(base_path, "unet"))
_has_transformer = _os.path.isdir(_os.path.join(base_path, "transformer"))
if _has_transformer and not _has_unet:
raise ValueError(
f"LoRA training base model '{base_path}' is a transformer/DiT "
f"architecture (has 'transformer/', no 'unet/'). This trainer only "
f"supports UNet-based SD1.x/SDXL models. Configure an SD1.x or SDXL "
f"image model as the LoRA training base."
)
# Detect SDXL by attempting to load a second tokenizer. # Detect SDXL by attempting to load a second tokenizer.
is_sdxl = False is_sdxl = False
try: try:
...@@ -537,6 +559,7 @@ def _write_meta(name, req, base_path, n_images, arch, instance_prompt): ...@@ -537,6 +559,7 @@ def _write_meta(name, req, base_path, n_images, arch, instance_prompt):
meta = { meta = {
"name": name, "name": name,
"base_model": req.base_model, "base_model": req.base_model,
"train_base_model": req.train_base_model or req.base_model,
"base_path": base_path, "base_path": base_path,
"arch": arch, "arch": arch,
"instance_prompt": instance_prompt, "instance_prompt": instance_prompt,
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment