"""Krea 2 LoRA Trainer — HF Space (HF Jobs backend). Sign in with Hugging Face, pick what you're training (a style, or an object/character), upload a handful of images, let the built-in AI caption them, and submit a DreamBooth-LoRA job to HF Jobs. The job trains on **Krea 2 RAW**, (optionally) validates on **Krea 2 Turbo**, and pushes the LoRA to your Hub — all under your account. The Space runs on `cpu-basic`; the GPU work runs on HF Jobs. Three tokens, three jobs: * the **user's** OAuth token — dataset + the pushed LoRA (their account / billing); * `KREA_TOKEN` secret — downloads the gated Krea 2 weights inside the job only; * `CAPTION_HF_TOKEN` secret — calls the Inference API for AI captioning on this Space only. """ from __future__ import annotations import os import gradio as gr import caption import jobs MAX_IMAGES = 40 MAX_LOG = 60_000 IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tiff", ".tif") LR_SCHEDULERS = ["constant", "cosine", "linear", "constant_with_warmup", "polynomial"] OPTIMIZERS = ["adamW", "prodigy"] QUANT_CHOICES = [ ("None — bf16 (best quality, most VRAM)", "none"), ("FP8 (faster compute, needs GPU ≥ 8.9)", "fp8"), ("4-bit NF4 / QLoRA (lowest VRAM)", "4bit"), ] CONCEPT_CHOICES = [ ("🎨 Style", "style"), ("🧸 Object or character", "object"), ("⚙️ Custom", "custom"), ] AGENT_URL = "https://huggingface.co/spaces/multimodalart/krea2-lora-trainer/raw/main/agent.md" CSS = """ #main_title{text-align:center} #main_title h1{font-size:2.25rem;margin-bottom:0} #main_title h3{margin-top:.25em;font-size:1.25em} #main_title p{margin-top:.25em;font-size:1.05em;opacity:.85} .accordion{color:var(--body-text-color)} .login_logout{width:100% !important} #login{width:100% !important;margin:0 auto} #signin_banner{text-align:center} .agent_hint{text-align:center;opacity:.7;font-size:.9em;margin:.4em 0 -.2em} .agent_curl{max-width:380px;margin:0 auto} #main_ui.locked{pointer-events:none;opacity:.55;filter:grayscale(.3)} /* captioning panel */ #captioning_area{padding:0 4px 4px 16px} #captioning_area h2{margin:0 0 8px} .trigger_row{gap:8px;align-items:stretch} .suggest_btn button{width:100%;height:100%;border:var(--button-border-width) solid var(--button-secondary-border-color);background:var(--button-secondary-background-fill)} .cap_item{border:1px solid var(--border-color-primary);border-radius:10px;padding:10px;margin-bottom:12px;gap:8px} .cap_img{flex:0 0 auto !important;max-width:180px} .cap_img .icon-button-wrapper,.cap_img .source-selection,.cap_img .image-frame .controls{display:none !important} .cap_img img,.cap_img .image-frame{object-fit:cover;border-radius:8px;max-height:180px} .cap_row{align-items:stretch !important;gap:10px} .cap_one_btn button{height:100%;min-width:46px;padding-left:0;padding-right:0} """ THEME = gr.themes.Monochrome( text_size=gr.themes.Size(lg="18px", md="15px", sm="13px", xl="22px", xs="12px", xxl="24px", xxs="9px"), font=[gr.themes.GoogleFont("Source Sans Pro"), "ui-sans-serif", "system-ui", "sans-serif"], ) FLAVOR_GUIDE = """**Which GPU?** You're billed per-minute of actual runtime. Krea 2 is a 12B DiT. | Flavor | VRAM | Best for | |---|---|---| | `rtx-pro-6000` | 96 GB | **recommended (default)** — newest Blackwell card, bf16 with room to spare | | `a100-large` | 80 GB | proven A100 — bf16 with offload + cached latents | First run is slow to start: it downloads the gated Krea 2 RAW + Turbo weights before training. """ TRIGGER_HELP = { "style": ( "Your **trigger** is a short descriptive phrase appended to every caption — e.g. " "*heavy impasto style*, *monochrome ink wash style*. Click **✨ Suggest** to get one " "from your images." ), "object": ( "Your **trigger** is a rare, unique token for the subject — e.g. *b3@rcup*, *sks dog*. " "Click **✨ Suggest** to get one from your images." ), "custom": ( "Advanced: caption however you like. The **trigger** is whatever token or phrase you " "choose; **✨ Add captions** writes a literal description and appends your trigger, " "and blank captions fall back to it." ), } TRIGGER_PLACEHOLDER = {"style": "heavy impasto style", "object": "b3@rcup", "custom": "TOK"} CAPTION_TIP = ( "
ℹ️ How captioning works\n\n" "- For a style, captions describe only the content (subjects, layout, setting) " "and end with your style trigger — the model then learns the look, not the subjects.\n" "- For an object / character, captions describe the scene and tag the subject with your " "trigger token.\n" "- ✨ Auto-caption fills these for you; edit anything you like. Blank captions fall " "back to the trigger.\n" "- Already have captions? Upload a .txt next to each image with the same name " "(e.g. cat.jpg + cat.txt) and they're filled in automatically.\n" "
" ) def _paths(files) -> list[str]: return [p if isinstance(p, str) else getattr(p, "name", p) for p in (files or [])] def _split_uploads(files): """Split a mixed upload into image paths and a {basename: caption} map from .txt sidecars. A .txt file whose name matches an image (e.g. cat.jpg + cat.txt) prefills that caption. """ images, captions = [], {} for p in _paths(files): ext = os.path.splitext(p)[1].lower() base = os.path.splitext(os.path.basename(p))[0] if ext == ".txt": try: with open(p, encoding="utf-8") as f: captions[base] = f.read().strip() except Exception: # noqa: BLE001 pass elif ext in IMAGE_EXTS: images.append(p) return images, captions def _signin_state(profile: gr.OAuthProfile | None): # The form stays visible but locked (pointer-events:none) until sign-in: OAuth is a full-page # redirect, so anything typed before logging in would be wiped on the way back. if profile is None: return ("", gr.update(elem_classes=["locked"]), gr.update()) return ( "", gr.update(elem_classes=[]), gr.update(info=f"Becomes your model repo `{profile.username}/` (+ a dataset repo). " "Spaces and symbols are turned into dashes automatically."), ) def on_concept_change(concept: str): # picking a concept reveals the upload area (lora-ease "part by part" reveal) return ( gr.update(visible=True), gr.update(placeholder=TRIGGER_PLACEHOLDER.get(concept, "")), TRIGGER_HELP.get(concept, ""), ) def load_captioning(files, trigger): """Reveal the captioning + training UI once images are uploaded; one row per image. If a matching `.txt` sidecar was uploaded, its content prefills that image's caption. """ paths, txt_caps = _split_uploads(files) n = len(paths) if n > MAX_IMAGES: raise gr.Error(f"For now, up to {MAX_IMAGES} images are supported (got {n}).") updates = [gr.update(visible=n > 0), gr.update(visible=n > 0)] # captioning_area, post_upload for i in range(MAX_IMAGES): visible = i < n if visible: base = os.path.splitext(os.path.basename(paths[i]))[0] cap_val = txt_caps.get(base) or (trigger or "") else: cap_val = None updates.append(gr.update(visible=visible)) # row updates.append(gr.update(value=paths[i] if visible else None, visible=visible)) # image updates.append(gr.update(value=cap_val, visible=visible)) # caption return updates def ai_suggest_trigger(files, concept_type): paths, _ = _split_uploads(files) if not paths: raise gr.Error("Upload images first.") return caption.suggest_trigger(paths, concept_type) def ai_caption_all(files, concept_type, trigger): paths, _ = _split_uploads(files) if not paths: raise gr.Error("Upload images first.") trigger = (trigger or "").strip() if not trigger: trigger = caption.suggest_trigger(paths, concept_type) # suggest one and reuse it everywhere caps = [caption.caption_one(p, concept_type, trigger) for p in paths] cap_updates = [gr.update(value=caps[i]) if i < len(caps) else gr.update() for i in range(MAX_IMAGES)] return [gr.update(value=trigger), *cap_updates] def make_caption_one(idx): """Build a handler that captions only image `idx` (seeding the trigger if it's empty).""" def _caption_one(files, concept_type, trigger): paths, _ = _split_uploads(files) if idx >= len(paths): return gr.update(), gr.update() trigger = (trigger or "").strip() if not trigger: trigger = caption.suggest_trigger(paths, concept_type) return gr.update(value=trigger), gr.update(value=caption.caption_one(paths[idx], concept_type, trigger)) return _caption_one def gather_dataset(files, *captions): paths, _ = _split_uploads(files) return [[img, (captions[i] if i < len(captions) else "")] for i, img in enumerate(paths)] def start_training( dataset_rows, concept_type, lora_name, trigger, rank, lora_alpha, max_train_steps, learning_rate, lr_scheduler, resolution, repeats, train_batch_size, gradient_accumulation_steps, seed, optimizer, use_8bit_adam, cache_latents, gradient_checkpointing, offload, quantization, lora_layers, make_gallery, num_gallery_images, custom_eval_prompts, flavor, timeout, profile: gr.OAuthProfile | None = None, oauth_token: gr.OAuthToken | None = None, ): if oauth_token is None or profile is None: return "❌ Please **sign in with Hugging Face** first (top-right).", "", "" if not dataset_rows: return "❌ Upload at least one image.", "", "" if not (lora_name or "").strip(): return "❌ Give your LoRA a name.", "", "" image_paths = [r[0] for r in dataset_rows] captions = [r[1] for r in dataset_rows] params = { "concept_type": concept_type, "lora_name": lora_name, "instance_prompt": trigger, "rank": rank, "lora_alpha": lora_alpha, "max_train_steps": max_train_steps, "learning_rate": learning_rate, "lr_scheduler": lr_scheduler, "resolution": resolution, "repeats": repeats, "train_batch_size": train_batch_size, "gradient_accumulation_steps": gradient_accumulation_steps, "seed": seed, "optimizer": optimizer, "use_8bit_adam": bool(use_8bit_adam), "cache_latents": bool(cache_latents), "gradient_checkpointing": bool(gradient_checkpointing), "offload": bool(offload), "quantization": quantization, "lora_layers": lora_layers, "make_gallery": bool(make_gallery), "num_gallery_images": num_gallery_images, "custom_eval_prompts": custom_eval_prompts, "hf_token": oauth_token.token, } try: res = jobs.submit(params, image_paths, captions, flavor=flavor, timeout=timeout) except Exception as e: # noqa: BLE001 return f"❌ Submission failed: {e}", "", "" status = f"✅ Job submitted on **{flavor}**, running as **{profile.username}**." link = ( f"**Job:** [{res['job_id']}]({res['url']}) \n" f"**Dataset:** `{res['dataset_repo']}` \n" f"**LoRA will be pushed to:** `{res['hub_model_id']}` \n\n" f"Track progress in the **Monitor** tab (job id is prefilled)." ) return status, link, res["job_id"] def cost_estimate(steps, num_gallery, make_gallery, flavor): minutes, dollars = jobs.estimate_cost(steps, num_gallery, make_gallery, flavor) sps = jobs.SEC_PER_STEP.get(flavor, 2.35) return ( f"💸 **Estimated cost:** ~${dollars:.2f} · ~{minutes:.0f} min on `{flavor}` " f"(~{sps:.1f}s/step, compiled). _Rough estimate — the first run also downloads the " f"gated Krea 2 weights (one-time ~2–3 min)._" ) def refresh(job_id, oauth_token: gr.OAuthToken | None = None): if not (job_id or "").strip(): return "Enter a job id.", "" token = oauth_token.token if oauth_token else "" st = jobs.job_status(job_id.strip(), token) logs = jobs.job_logs(job_id.strip(), token) return f"**Status:** `{st}`", logs[-MAX_LOG:] if len(logs) > MAX_LOG else logs with gr.Blocks(title="Krea 2 LoRA Trainer") as demo: gr.Markdown( "# 🎨 Krea 2 LoRA Trainer\n" "### Train a high-quality Krea 2 LoRA from your own images\n" "Trains on **Krea 2 RAW**, optionally validates on **Turbo**, runs on **HF Jobs**, " "pushed to your Hub. You only pay for the job's GPU minutes.", elem_id="main_title", ) with gr.Row(): gr.Column(scale=2, min_width=0) with gr.Column(scale=0, min_width=380): gr.LoginButton("Sign in with Hugging Face to use the UI", elem_id="login", elem_classes=["login_logout"]) gr.Markdown("…or paste this to your coding agent:", elem_classes=["agent_hint"]) gr.Code(f"curl {AGENT_URL}", language="shell", interactive=False, show_label=False, elem_classes=["agent_curl"]) gr.Column(scale=2, min_width=0) banner = gr.Markdown(elem_id="signin_banner") with gr.Column(elem_id="main_ui", elem_classes=["locked"]) as main_ui, gr.Tabs(): with gr.Tab("Train"): lora_name = gr.Textbox( label="The name of your LoRA", placeholder="e.g. my-impasto-style", info="Has to be unique — used for your output model repo (you/) and dataset repo.", ) concept_type = gr.Radio( CONCEPT_CHOICES, value=None, label="What are you training?", info="Drives how images are captioned and what kind of trigger is suggested.", ) with gr.Row(visible=False, equal_height=False) as image_upload: with gr.Column(scale=1): images = gr.File( label="Upload your images (4–30 ideal)", file_count="multiple", file_types=["image", ".txt"], interactive=True, height=320, ) gr.Markdown( "_Already have captions? Drop a `.txt` next to each image with the same " "name (e.g. `cat.png` + `cat.txt`) and they'll be filled in automatically. " "Otherwise, you'll be able to caption your images here →_", elem_id="upload_hint", ) with gr.Column(scale=3, visible=False, elem_id="captioning_area") as captioning_area: gr.Markdown("## Trigger & captioning") with gr.Row(elem_classes=["trigger_row"], equal_height=True): trigger = gr.Textbox( label="Trigger", placeholder=TRIGGER_PLACEHOLDER["style"], scale=4, interactive=True, ) suggest_btn = gr.Button("✨ Suggest", scale=1, min_width=120, variant="secondary", elem_classes=["suggest_btn"]) trigger_help = gr.Markdown(TRIGGER_HELP["style"]) autocaption_btn = gr.Button("✨ Add captions with Gemma", variant="primary") gr.Markdown(CAPTION_TIP) caption_rows, caption_imgs, caption_txts = [], [], [] for i in range(MAX_IMAGES): with gr.Column(visible=False, elem_classes=["cap_item"]) as row: img = gr.Image( height=180, interactive=False, show_label=False, container=False, elem_classes=["cap_img"], ) with gr.Row(elem_classes=["cap_row"], equal_height=True): cap = gr.Textbox(label=f"Caption {i + 1}", scale=14, interactive=True) cap_btn = gr.Button("✨", scale=0, min_width=46, variant="secondary", elem_classes=["cap_one_btn"]) cap_btn.click(make_caption_one(i), inputs=[images, concept_type, trigger], outputs=[trigger, cap]) caption_rows.append(row) caption_imgs.append(img) caption_txts.append(cap) with gr.Column(visible=False) as post_upload: with gr.Accordion("Advanced options", open=False, elem_classes=["accordion"]): with gr.Row(): rank = gr.Number(label="LoRA rank", value=32, precision=0, info="Authors recommend 32; raise it for long runs / high-frequency styles.") lora_alpha = gr.Number(label="LoRA alpha", value=32, precision=0, info="Keep equal to rank (scale 1.0).") with gr.Row(): max_train_steps = gr.Number(label="Training steps", value=1000, precision=0) learning_rate = gr.Number(label="Learning rate", value=3e-4, info="3e-4 ~ 7e-4 with constant works well; go higher with cosine.") with gr.Row(): lr_scheduler = gr.Dropdown(LR_SCHEDULERS, value="constant", label="LR scheduler") resolution = gr.Number(label="Resolution", value=1024, precision=0) with gr.Row(): repeats = gr.Number(label="Dataset repeats", value=1, precision=0) seed = gr.Number(label="Seed", value=0, precision=0) lora_layers = gr.Textbox( label="Target layers (optional)", placeholder="wq,wk,wv,wo,gate", info="Blank = the authors' recommended full set. For long runs, narrow to the " "attention layers (wq,wk,wv,wo,gate) and raise the rank so prompt adherence holds.", ) timeout = gr.Textbox( label="Job timeout", value="6h", info="Max job runtime before it's stopped. The first run also downloads the " "gated Krea 2 weights, so leave headroom.", ) with gr.Accordion("Memory / performance", open=False): with gr.Row(): quantization = gr.Dropdown(QUANT_CHOICES, value="none", label="Quantization") optimizer = gr.Dropdown(OPTIMIZERS, value="adamW", label="Optimizer") with gr.Row(): use_8bit_adam = gr.Checkbox(label="8-bit Adam", value=True) cache_latents = gr.Checkbox(label="Cache latents", value=True) with gr.Row(): gradient_checkpointing = gr.Checkbox(label="Gradient checkpointing", value=True) offload = gr.Checkbox(label="CPU offload (VAE + text encoder)", value=False) with gr.Row(): train_batch_size = gr.Number(label="Batch size", value=1, precision=0) gradient_accumulation_steps = gr.Number(label="Grad accumulation", value=1, precision=0) with gr.Accordion("Preview gallery & README (on Turbo)", open=True, elem_classes=["accordion"]): make_gallery = gr.Checkbox( label="Generate a preview gallery + rich README", value=True, info="After training, render sample images on Krea 2 Turbo with your LoRA " "and push a model-card README where each image is captioned by its prompt.", ) num_gallery_images = gr.Number(label="Number of samples", value=3, precision=0) custom_eval_prompts = gr.Textbox( label="Custom showcase prompts (optional)", lines=4, placeholder="One prompt per line — use where the trigger should go.\n" "Leave blank to auto-generate diverse prompts with the LLM.", info="If blank, the LLM writes diverse showcase prompts from your concept + trigger.", ) gr.Markdown("### Output & submit") flavor = gr.Dropdown(jobs.FLAVORS, value=jobs.DEFAULT_FLAVOR, label="GPU flavor") with gr.Accordion("GPU guide", open=False): gr.Markdown(FLAVOR_GUIDE) cost_md = gr.Markdown(cost_estimate(1000, 3, True, jobs.DEFAULT_FLAVOR)) submit_btn = gr.Button("🚀 Submit training job", variant="primary", size="lg") status = gr.Markdown() joblink = gr.Markdown() with gr.Tab("Monitor"): with gr.Row(): job_id = gr.Textbox(label="Job id", scale=3) refresh_btn = gr.Button("🔄 Refresh", scale=1) mon_status = gr.Markdown() mon_logs = gr.Textbox(label="Job logs", lines=22, autoscroll=True, max_lines=22) dataset_state = gr.State([]) caption_outputs = [captioning_area, post_upload] for r, im, c in zip(caption_rows, caption_imgs, caption_txts): caption_outputs += [r, im, c] demo.load(_signin_state, inputs=None, outputs=[banner, main_ui, lora_name]) concept_type.change( on_concept_change, inputs=[concept_type], outputs=[image_upload, trigger, trigger_help], ) images.change(load_captioning, inputs=[images, trigger], outputs=caption_outputs) suggest_btn.click(ai_suggest_trigger, inputs=[images, concept_type], outputs=[trigger]) autocaption_btn.click( ai_caption_all, inputs=[images, concept_type, trigger], outputs=[trigger, *caption_txts], ) submit_btn.click( gather_dataset, inputs=[images, *caption_txts], outputs=dataset_state, ).then( start_training, inputs=[dataset_state, concept_type, lora_name, trigger, rank, lora_alpha, max_train_steps, learning_rate, lr_scheduler, resolution, repeats, train_batch_size, gradient_accumulation_steps, seed, optimizer, use_8bit_adam, cache_latents, gradient_checkpointing, offload, quantization, lora_layers, make_gallery, num_gallery_images, custom_eval_prompts, flavor, timeout], outputs=[status, joblink, job_id], ) cost_inputs = [max_train_steps, num_gallery_images, make_gallery, flavor] for comp in cost_inputs: comp.change(cost_estimate, inputs=cost_inputs, outputs=cost_md) refresh_btn.click(refresh, inputs=[job_id], outputs=[mon_status, mon_logs]) if __name__ == "__main__": demo.queue(default_concurrency_limit=4).launch(theme=THEME, css=CSS)