M3st3rJ4k3l commited on
Commit
3f14c5d
Β·
1 Parent(s): d67b4a2

Added new functionalities

Browse files

Added controlnet-style depthmap and pose generator. Added bulk processing and multi-generations. Added canvas control. Added multiple QoL improvements

Files changed (8) hide show
  1. app.py +881 -710
  2. config.py +28 -0
  3. control_tools.py +269 -0
  4. image_utils.py +318 -0
  5. lora_registry.py +285 -0
  6. requirements.txt +4 -3
  7. ui_theme.py +64 -0
  8. upscale.py +136 -0
app.py CHANGED
@@ -1,20 +1,57 @@
1
  import os
2
  import gc
 
 
 
 
 
 
 
 
3
  import gradio as gr
4
  import numpy as np
5
  import spaces
6
  import torch
7
- import random
8
- import uuid
9
- from PIL import Image, ImageOps
10
- from typing import Iterable
11
- import time
12
- import threading
13
  from logging_utils import log_inference
14
 
15
- # ── requirements.txt should include: ─────────────────────────────────────────
16
- # spandrel
17
- # ─────────────────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  MODEL_VARIANT = os.environ.get("MODEL_VARIANT", "9B")
20
  if MODEL_VARIANT == "9B-KV":
@@ -23,11 +60,11 @@ if MODEL_VARIANT == "9B-KV":
23
  else:
24
  from diffusers import Flux2KleinPipeline as _PipeClass
25
  _MODEL_REPO = "black-forest-labs/FLUX.2-klein-9B"
26
- MODEL_VARIANT = "9B" # normalise any typos back to default
27
- from huggingface_hub import hf_hub_download
28
 
29
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
30
 
 
31
  from gradio.themes import Soft
32
  from gradio.themes.utils import colors, fonts, sizes
33
 
@@ -38,21 +75,13 @@ colors.orange_red = colors.Color(
38
  )
39
 
40
  class OrangeRedTheme(Soft):
41
- def __init__(
42
- self, *, primary_hue: colors.Color | str = colors.gray,
43
- secondary_hue: colors.Color | str = colors.orange_red,
44
- neutral_hue: colors.Color | str = colors.slate, text_size: sizes.Size | str = sizes.text_lg,
45
- font: fonts.Font | str | Iterable[fonts.Font | str] = (
46
- fonts.GoogleFont("Outfit"), "Arial", "sans-serif",
47
- ),
48
- font_mono: fonts.Font | str | Iterable[fonts.Font | str] = (
49
- fonts.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace",
50
- ),
51
- ):
52
- super().__init__(
53
- primary_hue=primary_hue, secondary_hue=secondary_hue, neutral_hue=neutral_hue,
54
- text_size=text_size, font=font, font_mono=font_mono,
55
- )
56
  super().set(
57
  background_fill_primary="*primary_50",
58
  background_fill_primary_dark="*primary_900",
@@ -64,8 +93,7 @@ class OrangeRedTheme(Soft):
64
  button_primary_background_fill_hover="linear-gradient(90deg, *secondary_600, *secondary_700)",
65
  button_primary_background_fill_dark="linear-gradient(90deg, *secondary_600, *secondary_700)",
66
  button_primary_background_fill_hover_dark="linear-gradient(90deg, *secondary_500, *secondary_600)",
67
- slider_color="*secondary_500",
68
- slider_color_dark="*secondary_600",
69
  block_title_text_weight="600", block_border_width="3px",
70
  block_shadow="*shadow_drop_lg", button_primary_shadow="*shadow_drop_lg",
71
  button_large_padding="11px", color_accent_soft="*primary_100",
@@ -75,40 +103,20 @@ class OrangeRedTheme(Soft):
75
  orange_red_theme = OrangeRedTheme()
76
  MAX_SEED = np.iinfo(np.int32).max
77
 
78
- # ── Upscaler model registry ───────────────────────────────────────────────────
79
- # Add new entries here to extend the dropdown β€” spandrel handles any ESRGAN-
80
- # family .pth file automatically. scale= is the integer output multiplier.
81
  UPSCALE_MODELS = {
82
- "None": {
83
- "scale": None, "file": None, "url": None,
84
- },
85
- "2Γ— β€” RealESRGAN (balanced)": {
86
- "scale": 2,
87
- "file": "RealESRGAN_x2plus.pth",
88
- "url": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth",
89
- },
90
- "4Γ— β€” RealESRGAN (balanced)": {
91
- "scale": 4,
92
- "file": "RealESRGAN_x4plus.pth",
93
- "url": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth",
94
- },
95
- "4Γ— β€” UltraSharp (crisp)": {
96
- "scale": 4,
97
- "file": "4x-UltraSharpV2.pth",
98
- "url": "https://huggingface.co/Kim2091/UltraSharpV2/resolve/main/4x-UltraSharpV2.pth",
99
- },
100
- "4Γ— β€” Remacri (natural)": {
101
- "scale": 4,
102
- "file": "4x_foolhardy_Remacri.pth",
103
- "url": "https://huggingface.co/FacehugmanIII/4x_foolhardy_Remacri/resolve/main/4x_foolhardy_Remacri.pth",
104
- },
105
- "4Γ— β€” Nomos2 HQ DAT2 (Photography)": {
106
- "scale": 4,
107
- "file": "4xNomos2_hq_dat2.pth",
108
- "url": "https://github.com/Phhofm/models/releases/download/4xNomos2_hq_dat2/4xNomos2_hq_dat2.pth",
109
- },
110
  }
111
- # ─────────────────────────────────────────────────────────────────────────────
112
 
113
  FACE_SWAP_PROMPT = """head_swap: start with Picture 1 as the base image, keeping its lighting, environment, and background. Remove the head from Picture 1 completely and replace it with the head from Picture 2.
114
  FROM PICTURE 1 (strictly preserve):
@@ -122,384 +130,186 @@ FROM PICTURE 2 (strictly preserve identity):
122
  - Skin: texture, tone, complexion
123
  The replaced head must seamlessly match Picture 1's lighting and expression while maintaining the complete identity from Picture 2. High quality, photorealistic, sharp details, 4k."""
124
 
125
- # MAX number of simultaneous LoRA weight sliders to pre-render in the UI
126
  MAX_LORA_SLOTS = 6
127
 
128
  LORA_STYLES = [
129
- {
130
- "image": "https://huggingface.co/spaces/prithivMLmods/FLUX.2-Klein-LoRA-Studio/resolve/main/examples/image.webp",
131
- "title": "None",
132
- "adapter_name": None,
133
- "repo": None,
134
- "weights": None,
135
- "default_prompt": None,
136
- "default_weight": 1.0,
137
- },
138
- {
139
- "title": "Klein-Delight-Style",
140
- "adapter_name": "klein-delight",
141
- "repo": "linoyts/Flux2-Klein-Delight-LoRA",
142
- "weights": "pytorch_lora_weights.safetensors",
143
- "default_prompt": "Relight the image to remove all existing lighting conditions and replace them with neutral, uniform illumination. Apply soft, evenly distributed lighting with no directional shadows, no harsh highlights, and no dramatic contrast. Maintain the original identity of all subjects exactlyβ€”preserve facial structure, skin tone, proportions, expressions, hair, clothing, and textures. Do not alter pose, camera angle, background geometry, or image composition. Lighting should appear balanced, and studio-neutral, similar to diffuse overcast or a soft lightbox setup. Ensure consistent exposure across the entire image with realistic depth and subtle shading only where necessary for form.",
144
- "default_weight": 1.0,
145
- },
146
- {
147
- "title": "Klein-Consistency",
148
- "adapter_name": "klein-consistency",
149
- "repo": "dx8152/Flux2-Klein-9B-Consistency",
150
- "weights": "Klein-consistency.safetensors",
151
- "default_prompt": None,
152
- "default_weight": 0.3,
153
- },
154
- {
155
- "title": "Best-Face-Swap",
156
- "adapter_name": "face-swap",
157
- "repo": "Alissonerdx/BFS-Best-Face-Swap",
158
- "weights": "bfs_head_v1_flux-klein_9b_step3750_rank64.safetensors",
159
- "default_prompt": FACE_SWAP_PROMPT,
160
- "default_weight": 1.0,
161
- },
162
- {
163
- "title": "NSFW v2",
164
- "adapter_name": "nsfw-v2",
165
- "repo": "diroverflo/FLux_Klein_9B_NSFW",
166
- "weights": "Flux Klein - NSFW v2.safetensors",
167
- "default_prompt": None,
168
- "default_weight": 1.0,
169
- },
170
- {
171
- "title": "Ultimate Upscaler Klein-9b",
172
- "adapter_name": "Ultimate Upscaler",
173
- "repo": "loras",
174
- "weights": "Flux2-Klein-Image-RestoreV1.safetensors",
175
- "default_prompt": "restore the image quality, remove any compression artefacts, remove any haze and soft edges, enrich the original with new intricate detail in all textures and surfaces creating a professional photorealistic photograph with natural lighting and skin texture.",
176
- "default_weight": 1.0,
177
- },
178
- {
179
- "title": "High Resolution",
180
- "adapter_name": "High Resolution",
181
- "repo": "loras",
182
- "weights": "HighResolution9B.safetensors",
183
- "default_prompt": "High Resolution",
184
- "default_weight": 1.0,
185
- },
186
- {
187
- "title": "Female Asshole",
188
- "adapter_name": "Female Asshole",
189
- "repo": "loras",
190
- "weights": "femaleasshole-f2-klein-9b.safetensors",
191
- "default_prompt": None,
192
- "default_weight": 1.0,
193
- },
194
- {
195
- "title": "Realistic Nudes",
196
- "adapter_name": "Realistic Nudes",
197
- "repo": "loras",
198
- "weights": "realistic_nudes_klein_v3.safetensors",
199
- "default_prompt": None,
200
- "default_weight": 1.0,
201
- },
202
- {
203
- "title": "Perky Pointy Puffy Breasts",
204
- "adapter_name": "Perky Pointy Puffy Breasts",
205
- "repo": "loras",
206
- "weights": "PerkyPointyPuffy_v1.1_small_pointy_breasts_large_puffy_nipples.safetensors",
207
- "default_prompt": "Small pointy breasts with large puffy nipples",
208
- "default_weight": 1.0,
209
- },
210
- {
211
- "title": "Flat Chested",
212
- "adapter_name": "Flat Chested",
213
- "repo": "loras",
214
- "weights": "Flux2-Klein-9b-FlatChested-v1.safetensors",
215
- "default_prompt": "flat chested",
216
- "default_weight": 1.5,
217
- },
218
- {
219
- "title": "Controllight",
220
- "adapter_name": "Controllight",
221
- "repo": "ControlLight/ControlLight",
222
- "weights": "controllight.safetensors",
223
- "default_prompt": None,
224
- "default_weight": 1.0,
225
- },
226
- {
227
- "title": "RefControl - Depth",
228
- "adapter_name": "RefConDep",
229
- "repo": "thedeoxen/refcontrol-FLUX.2-klein-9B-reference-depth-lora",
230
- "weights": "flux2_klein_9b_refcontrol_depth.safetensors",
231
- "default_prompt": "refcontrol",
232
- "default_weight": 1.0,
233
- },
234
- {
235
- "title": "RefControl - Pose",
236
- "adapter_name": "RefConPos",
237
- "repo": "thedeoxen/refcontrol-FLUX.2-klein-9B-reference-pose-lora",
238
- "weights": "refcontrol_v2_poses.safetensors",
239
- "default_prompt": "apply pose from image 1 with reference from image 2",
240
- "default_weight": 1.0,
241
- },
242
  ]
243
 
244
- # LOADED_ADAPTERS is the only piece of LoRA state that's legitimately global:
245
- # it just tracks which adapter names have been loaded onto the shared `pipe`
246
- # at least once, so we don't re-download/re-attach weights on every call.
247
- # It is NOT used to decide what a user can select β€” that comes from each
248
- # session's own `dynamic_loras_state` (a gr.State dict), so one user's custom
249
- # HuggingFace LoRA never shows up in another user's checklist.
250
  LOADED_ADAPTERS = set()
251
 
252
 
253
  def get_all_styles(dynamic_loras):
254
- all_styles = list(LORA_STYLES)
255
- for dynamic_lora in (dynamic_loras or {}).values():
256
- all_styles.append(dynamic_lora)
257
- return all_styles
258
 
259
  def get_selectable_styles(dynamic_loras):
260
- """Return all styles except 'None' for the checkbox selector."""
261
  return [s for s in get_all_styles(dynamic_loras) if s["adapter_name"] is not None]
262
 
263
  def get_style_by_title(title, dynamic_loras):
264
- for style in get_all_styles(dynamic_loras):
265
- if style["title"] == title:
266
- return style
267
  return None
268
 
269
- def get_style_by_adapter_name(adapter_name, dynamic_loras):
270
- for style in get_all_styles(dynamic_loras):
271
- if style["adapter_name"] == adapter_name:
272
- return style
273
- return None
274
 
275
  print(f"Loading FLUX.2 Klein {MODEL_VARIANT} from {_MODEL_REPO}...")
276
  pipe = _PipeClass.from_pretrained(_MODEL_REPO, torch_dtype=torch.bfloat16).to(device)
277
  print(f"Model loaded successfully: FLUX.2 Klein {MODEL_VARIANT}")
278
 
279
- def compute_base_dimensions(image):
280
- """Scale image so longest side fits within 1024, aligned to 16px grid."""
281
- if image is None:
282
- return 1024, 1024
283
- original_width, original_height = image.size
284
- scale = min(1024 / original_width, 1024 / original_height)
285
- new_width = (int(original_width * scale) // 16) * 16
286
- new_height = (int(original_height * scale) // 16) * 16
287
- return new_width, new_height
288
-
289
- # Keep old name as alias so nothing inside infer breaks if called directly
290
- update_dimensions_on_upload = compute_base_dimensions
291
-
292
- def fix_orientation(img):
293
- """Correct EXIF orientation in-place (returns a new image).
294
- Browsers/OS viewers respect the EXIF orientation tag, but PIL does not
295
- apply it by default β€” so an image can *look* correctly oriented
296
- everywhere you view it while the actual pixel data fed to the model is
297
- rotated 90/180/270Β°. This normalises the pixels to match what you see."""
298
- if img is None:
299
- return None
300
- return ImageOps.exif_transpose(img)
301
 
302
- def on_base_image_change(img):
303
- """Update the size/dimension preview when the base image changes."""
304
- if img is None:
305
- return "*No base image uploaded yet*"
306
- try:
307
- pil_img = img if isinstance(img, Image.Image) else Image.open(img)
308
- orig_w, orig_h = pil_img.size
309
- base_w, base_h = compute_base_dimensions(pil_img)
310
- return f"Input: **{orig_w} Γ— {orig_h}** px β†’ Output (pre-upscale): **{base_w} Γ— {base_h}** px"
311
- except Exception as e:
312
- return f"*Could not read dimensions: {e}*"
313
-
314
- def on_reference_change(images):
315
- """Update the reference image count info when the gallery changes."""
316
- if not images:
317
- return "πŸ“· No reference images"
318
- count = len(images)
319
- return f"πŸ“· {count} reference image{'s' if count != 1 else ''} uploaded"
320
-
321
- def process_images(base_image, reference_images):
322
- """Build the ordered list of PIL images sent to the pipeline:
323
- [base_image] + reference_images. EXIF orientation is corrected on every
324
- image, regardless of whether it came in via direct upload or the editor."""
325
- pil_images = []
326
- if base_image is not None:
327
- try:
328
- img = base_image if isinstance(base_image, Image.Image) else Image.open(base_image)
329
- pil_images.append(fix_orientation(img).convert("RGB"))
330
- except Exception as e:
331
- print(f"Skipping invalid base image: {e}")
332
- for item in (reference_images or []):
333
- try:
334
- path_or_img = item[0] if isinstance(item, (tuple, list)) else item
335
- if isinstance(path_or_img, Image.Image):
336
- img = path_or_img
337
- elif isinstance(path_or_img, str):
338
- img = Image.open(path_or_img)
339
- else:
340
- img = Image.open(path_or_img.name)
341
- pil_images.append(fix_orientation(img).convert("RGB"))
342
- except Exception as e:
343
- print(f"Skipping invalid reference image: {e}")
344
- return pil_images
345
-
346
- def fix_editor_orientation(editor_value):
347
- """Runs on upload into the Image Editor β€” corrects EXIF orientation on
348
- the background layer before the user starts cropping, so what they see
349
- in the crop box matches the actual pixel data."""
350
- if not editor_value or editor_value.get("background") is None:
351
- return editor_value
352
- bg = editor_value["background"]
353
- if isinstance(bg, Image.Image):
354
- editor_value["background"] = fix_orientation(bg)
355
- return editor_value
356
-
357
- def _editor_composite(editor_value):
358
- if not editor_value or editor_value.get("composite") is None:
359
- raise gr.Error("Upload and crop an image in the editor first.")
360
- composite = editor_value["composite"]
361
- if isinstance(composite, np.ndarray):
362
- composite = Image.fromarray(composite)
363
- return composite.convert("RGB")
364
-
365
- def send_editor_to_base(editor_value):
366
- composite = _editor_composite(editor_value)
367
- gr.Info("Sent to Base Image")
368
- return composite
369
-
370
- def send_editor_to_reference(editor_value, current_gallery):
371
- composite = _editor_composite(editor_value)
372
- current = list(current_gallery or [])
373
- current.append(composite)
374
- gr.Info("Added to Reference Images")
375
- return current
376
-
377
- # ── LoRA selection β†’ update weight sliders + LoRA prompt display ──────────────
378
 
379
  def update_weight_sliders(selected_titles, dynamic_loras):
380
- """
381
- Given a list of selected LoRA titles, return updated visibility/label/value
382
- for each of the MAX_LORA_SLOTS pre-rendered sliders, plus an update for the
383
- LoRA prompt display box (visible when any selected LoRA has a default prompt).
384
-
385
- The user's own prompt is never touched here β€” LoRA prompts are shown
386
- separately and appended at inference time.
387
- """
388
- selected_styles = [get_style_by_title(t, dynamic_loras) for t in (selected_titles or []) if get_style_by_title(t, dynamic_loras)]
389
- slider_updates = []
390
  for i in range(MAX_LORA_SLOTS):
391
- if i < len(selected_styles):
392
- style = selected_styles[i]
393
- slider_updates.append(gr.update(
394
- visible=True,
395
- label=f"{style['title']} β€” weight",
396
- value=style.get("default_weight", 1.0),
397
- ))
398
  else:
399
- slider_updates.append(gr.update(visible=False, value=1.0))
 
 
 
 
400
 
401
- # Collect default prompts from ALL selected LoRAs (not just one)
402
- lora_prompts = [s.get("default_prompt") for s in selected_styles if s.get("default_prompt")]
403
- if lora_prompts:
404
- combined = "\n\n".join(lora_prompts)
405
- lora_prompt_update = gr.update(value=combined, visible=True)
406
- else:
407
- lora_prompt_update = gr.update(value="", visible=False)
408
 
409
- return slider_updates + [lora_prompt_update]
 
 
 
410
 
411
- # ── Inference ─────────────────────────────────────────────────────────────────
412
 
413
- def _upscale_tiled(model_fn, img_t: torch.Tensor, tile: int = 512, overlap: int = 32) -> torch.Tensor:
414
- """
415
- Run the upscaler model patch-by-patch to cap peak VRAM usage.
416
- Overlapping tiles are averaged so there are no seam artifacts.
417
- Output is assembled on CPU so only one tile lives on GPU at a time.
418
- """
419
- _, c, h, w = img_t.shape
420
 
421
- # Probe output scale with a tiny crop rather than hard-coding it
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  with torch.no_grad():
423
  probe = model_fn(img_t[:, :, :min(4, h), :min(4, w)])
424
  scale = probe.shape[-1] // min(4, w)
425
- del probe
426
- torch.cuda.empty_cache()
427
-
428
  out_h, out_w = h * scale, w * scale
429
  canvas = torch.zeros(1, c, out_h, out_w, dtype=torch.float32)
430
  weights = torch.zeros(1, 1, out_h, out_w, dtype=torch.float32)
431
-
432
  step = max(tile - overlap, 1)
433
- # Ensure the last tile always reaches the edge
434
- ys = list(range(0, max(h - tile, 0), step)) + [max(h - tile, 0)]
435
- xs = list(range(0, max(w - tile, 0), step)) + [max(w - tile, 0)]
436
- ys = sorted(set(ys))
437
- xs = sorted(set(xs))
438
-
439
  for y0 in ys:
440
  for x0 in xs:
441
- y1 = min(y0 + tile, h)
442
- x1 = min(x0 + tile, w)
443
- patch = img_t[:, :, y0:y1, x0:x1]
444
  with torch.no_grad():
445
- out = model_fn(patch).cpu().float()
446
  oy0, ox0 = y0 * scale, x0 * scale
447
  oy1, ox1 = y1 * scale, x1 * scale
448
  canvas[:, :, oy0:oy1, ox0:ox1] += out
449
  weights[:, :, oy0:oy1, ox0:ox1] += 1.0
450
-
451
  return (canvas / weights.clamp(min=1)).clamp(0, 1)
452
 
453
 
454
- def apply_realesrgan(image: Image.Image, model_key: str) -> Image.Image:
455
- """
456
- Upscale a PIL image using the model selected in UPSCALE_MODELS.
457
- Weights are downloaded once to /tmp/realesrgan_weights/ and reused.
458
- """
459
  cfg = UPSCALE_MODELS[model_key]
460
-
461
  try:
462
  from spandrel import ImageModelDescriptor, ModelLoader
463
  except ImportError:
464
  raise gr.Error("spandrel is not installed. Add 'spandrel' to requirements.txt.")
465
-
466
  import urllib.request
467
-
468
  cache_dir = "/tmp/realesrgan_weights"
469
  os.makedirs(cache_dir, exist_ok=True)
470
  cache_path = os.path.join(cache_dir, cfg["file"])
471
-
472
  if not os.path.exists(cache_path):
473
  print(f"Downloading {cfg['file']}…")
474
  urllib.request.urlretrieve(cfg["url"], cache_path)
475
-
476
  model = ModelLoader().load_from_file(cache_path)
477
  if not isinstance(model, ImageModelDescriptor):
478
  raise gr.Error(f"Loaded model is not a single-image descriptor: {type(model)}")
479
-
480
  sr_model = model.model.to(device).eval()
481
-
482
  img_np = np.array(image).astype(np.float32) / 255.0
483
  img_t = torch.from_numpy(img_np).permute(2, 0, 1).unsqueeze(0).to(device)
484
-
485
  out_t = _upscale_tiled(sr_model, img_t, tile=512, overlap=32)
486
-
487
- # Free upscaler weights immediately β€” FLUX pipeline stays resident
488
  del sr_model, img_t
489
- gc.collect()
490
- torch.cuda.empty_cache()
491
-
492
  out_np = out_t.squeeze(0).permute(1, 2, 0).numpy()
493
  return Image.fromarray((out_np * 255).astype(np.uint8))
494
 
495
 
496
- # ── Logging helper β€” fires a daemon thread so logging never blocks the user ──
497
 
498
  def _spawn_log(pil_images, result_image, prompt, seed, steps, guidance_scale,
499
  width, height, duration, success, error="",
500
  lora_titles=None, lora_weights=None, upscale_factor="None",
501
  lora_prompt_text=""):
502
-
503
  if os.environ.get("ENABLE_LOGGING", "No").strip().lower() != "yes":
504
  return
505
  threading.Thread(
@@ -512,186 +322,283 @@ def _spawn_log(pil_images, result_image, prompt, seed, steps, guidance_scale,
512
  ).start()
513
 
514
 
515
- def infer(
516
- base_image,
517
- reference_images,
518
- prompt,
519
- lora_prompt_text,
520
- custom_prompt_text,
521
- selected_titles,
522
- seed,
523
- randomize_seed,
524
- guidance_scale,
525
- steps,
526
- upscale_factor,
527
- dynamic_loras,
528
- *slider_values,
529
- progress=gr.Progress(track_tqdm=True)
530
- ):
531
- """CPU-side wrapper β€” handles pre/post processing and logging.
532
- The actual GPU work is delegated to _infer_gpu so that the
533
- _spawn_log daemon thread survives after the @spaces.GPU subprocess exits."""
534
- gc.collect()
535
- torch.cuda.empty_cache()
536
-
537
- # Guard against stale MCP schema sending the old output_scale float value
538
- if not isinstance(upscale_factor, str) or upscale_factor not in UPSCALE_MODELS:
539
- upscale_factor = "None"
540
-
541
- if base_image is None:
542
- raise gr.Error("Please upload a base image.")
543
-
544
- pil_images = process_images(base_image, reference_images)
545
- if not pil_images:
546
- raise gr.Error("Could not process uploaded images.")
547
-
548
- selected_titles = selected_titles or []
549
-
550
- if randomize_seed:
551
- seed = random.randint(0, MAX_SEED)
552
-
553
- # Collect LoRA metadata for logging before entering GPU
554
- active_styles = [get_style_by_title(t, dynamic_loras) for t in selected_titles
555
- if get_style_by_title(t, dynamic_loras) and get_style_by_title(t, dynamic_loras)["adapter_name"] is not None]
556
- log_lora_titles = [s["title"] for s in active_styles]
557
- log_lora_weights = [float(w) for w in slider_values[:len(active_styles)]]
558
- log_lora_prompt = (lora_prompt_text or "").strip()
559
-
560
- t0 = time.perf_counter()
561
- try:
562
- image, seed, width, height = _infer_gpu(
563
- pil_images, prompt, lora_prompt_text, custom_prompt_text, selected_titles,
564
- seed, guidance_scale, steps, upscale_factor, dynamic_loras,
565
- *slider_values, progress=progress
566
- )
567
- duration = time.perf_counter() - t0
568
- # _spawn_log runs on CPU main process so the daemon thread survives
569
- _spawn_log(pil_images, image, prompt, seed, steps, guidance_scale,
570
- width, height, duration, True,
571
- lora_titles=log_lora_titles, lora_weights=log_lora_weights,
572
- upscale_factor=upscale_factor, lora_prompt_text=log_lora_prompt)
573
- return image, str(seed)
574
- except Exception as e:
575
- duration = time.perf_counter() - t0
576
- _spawn_log(pil_images, None, prompt, seed, steps, guidance_scale,
577
- 0, 0, duration, False, str(e),
578
- lora_titles=log_lora_titles, lora_weights=log_lora_weights,
579
- upscale_factor=upscale_factor, lora_prompt_text=log_lora_prompt)
580
- raise
581
-
582
 
583
  @spaces.GPU
584
  def _infer_gpu(
585
- pil_images,
586
- prompt,
587
- lora_prompt_text,
588
- custom_prompt_text,
589
- selected_titles,
590
- seed,
591
- guidance_scale,
592
- steps,
593
- upscale_factor,
594
- dynamic_loras,
595
- *slider_values,
596
- progress=gr.Progress(track_tqdm=True)
597
  ):
598
- """GPU-side inference β€” all LoRA loading, pipe() calls, and upscaling happen here."""
599
-
600
- # Face-swap validation
601
  if "Best-Face-Swap" in selected_titles:
602
  if len(pil_images) < 2:
603
- raise gr.Error("Face Swap requires 2 images: a Base image and one Reference image (used as the face source).")
604
  if len(pil_images) > 2:
605
  gr.Warning("Face Swap uses only the Base image and the first Reference image.")
606
  pil_images = pil_images[:2]
607
 
608
- # Build active adapter list (skip None)
609
- active_styles = [get_style_by_title(t, dynamic_loras) for t in selected_titles if get_style_by_title(t, dynamic_loras) and get_style_by_title(t, dynamic_loras)["adapter_name"] is not None]
 
610
  weights = list(slider_values[:len(active_styles)])
611
 
612
  if not active_styles:
613
- print("No LoRA selected β€” running base model.")
614
  pipe.disable_lora()
615
  else:
616
- for i, style in enumerate(active_styles):
617
- adapter_name = style["adapter_name"]
618
- if adapter_name not in LOADED_ADAPTERS:
619
- print(f"Loading adapter: {style['title']}")
620
  try:
621
- pipe.load_lora_weights(
622
- style["repo"],
623
- weight_name=style["weights"],
624
- adapter_name=adapter_name,
625
- )
626
- LOADED_ADAPTERS.add(adapter_name)
627
  except Exception as e:
628
  raise gr.Error(f"Failed to load {style['title']}: {e}")
 
 
629
 
630
- adapter_names = [s["adapter_name"] for s in active_styles]
631
- adapter_weights = [float(w) for w in weights]
632
- print(f"Activating adapters: {list(zip(adapter_names, adapter_weights))}")
633
- pipe.set_adapters(adapter_names, adapter_weights=adapter_weights)
634
-
635
-
636
- # Combine user prompt with LoRA default prompts and any selected custom prompts
637
- user_prompt = (prompt or "").strip()
638
- lora_extra = (lora_prompt_text or "").strip()
639
- custom_extra = (custom_prompt_text or "").strip()
640
- parts = [p for p in [user_prompt, lora_extra, custom_extra] if p]
641
- full_prompt = "\n".join(parts)
642
 
643
- # Always generate at base 1024-capped resolution
644
- width, height = compute_base_dimensions(pil_images[0])
645
- print(f"Generating at: {width} Γ— {height} px")
646
 
647
- processed_images = [img.resize((width, height), Image.LANCZOS).convert("RGB") for img in pil_images]
648
- image_input = processed_images if len(processed_images) > 1 else processed_images[0]
649
 
650
  try:
651
- # Flux2KleinKVPipeline is a distilled model and does not accept
652
- # guidance_scale β€” only pass it for the non-KV 9B variant.
653
- call_kwargs = dict(
654
- image=image_input,
655
- prompt=full_prompt,
656
- width=width,
657
- height=height,
658
- num_inference_steps=steps,
659
- generator=torch.Generator(device=device).manual_seed(seed),
660
- )
661
  if MODEL_VARIANT != "9B-KV":
662
- call_kwargs["guidance_scale"] = guidance_scale
663
- image = pipe(**call_kwargs).images[0]
664
  except Exception as e:
665
  raise gr.Error(f"Inference failed: {e}")
666
 
667
- # ── Post-generation upscaling ─────────────────────────────────────────────
668
  if upscale_factor and upscale_factor != "None":
669
- scale_int = UPSCALE_MODELS[upscale_factor]["scale"]
670
- print(f"Upscaling {scale_int}Γ— with {upscale_factor}…")
671
- # Flush FLUX pipeline residuals before loading upscaler weights
672
- gc.collect()
673
- torch.cuda.synchronize()
674
- torch.cuda.empty_cache()
675
  try:
676
  image = apply_realesrgan(image, upscale_factor)
677
- print(f"Upscaled to: {image.width} Γ— {image.height} px")
678
  except Exception as e:
679
- gr.Warning(f"Upscaling failed, returning 1024px result: {e}")
680
- # ──────────────────────────────────────���──────────────────────────────────
681
 
682
- gc.collect()
683
- torch.cuda.empty_cache()
684
  return image, seed, width, height
685
 
686
- # ── Dynamic LoRA loader ───────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
687
 
688
  def add_custom_lora(repo_id, weight_name, adapter_name, dynamic_loras_state):
689
- """
690
- Adds a custom LoRA to *this session's* state only β€” other users never see
691
- it in their selector. The adapter_name attached to the shared pipe is
692
- still made globally unique (via a short random suffix) since the pipe
693
- object itself is one shared resource across all sessions.
694
- """
695
  dynamic_loras = dict(dynamic_loras_state or {})
696
  if not repo_id or not repo_id.strip():
697
  return "Please enter a valid HuggingFace repo ID.", gr.update(), dynamic_loras
@@ -709,74 +616,51 @@ def add_custom_lora(repo_id, weight_name, adapter_name, dynamic_loras_state):
709
  if not actual_weight:
710
  available = [f.filename for f in info.siblings if f.filename.endswith(('.safetensors', '.bin'))]
711
  return f"No weight found. Available: {', '.join(available) or 'None'}", gr.update(), dynamic_loras
712
-
713
  base_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in requested_name) if requested_name else "custom"
714
-
715
  static_names = {s["adapter_name"] for s in LORA_STYLES if s["adapter_name"]}
716
- final_adapter_name = f"{base_name}_{uuid.uuid4().hex[:6]}"
717
- while final_adapter_name in static_names or final_adapter_name in LOADED_ADAPTERS:
718
- final_adapter_name = f"{base_name}_{uuid.uuid4().hex[:6]}"
719
-
720
- custom_style = {
721
  "image": "https://huggingface.co/spaces/prithivMLmods/FLUX.2-Klein-LoRA-Studio/resolve/main/examples/image.webp",
722
- "title": f"Custom: {base_name}",
723
- "adapter_name": final_adapter_name,
724
- "repo": repo_id,
725
- "weights": actual_weight,
726
- "default_prompt": None,
727
- "default_weight": 1.0,
728
  }
729
- dynamic_loras[final_adapter_name] = custom_style
730
  new_choices = [s["title"] for s in get_selectable_styles(dynamic_loras)]
731
  return f"βœ… Added: {base_name} from {repo_id}", gr.update(choices=new_choices), dynamic_loras
732
  except Exception as e:
733
  return f"❌ Failed: {str(e)}", gr.update(), dynamic_loras
734
 
735
- # ── Custom Prompt manager ─────────────────────────────────────────────────────
 
736
 
737
  def add_custom_prompt(name, text, prompts_state, counter_state):
738
- """
739
- All state is passed in and returned β€” never touches module-level globals.
740
- Each user session gets its own isolated copy via gr.State.
741
- """
742
- prompts = dict(prompts_state) # never mutate the state object directly
743
- counter = int(counter_state)
744
  text = text.strip() if text else ""
745
  name = name.strip() if name else ""
746
  if not text:
747
  return "Please enter some prompt text.", prompts, counter, gr.update(), gr.update(), gr.update()
748
  if not name:
749
- counter += 1
750
- name = f"Prompt {counter}"
751
  if name in prompts:
752
- return f"⚠️ '{name}' already exists β€” choose a different name.", prompts, counter, gr.update(), gr.update(), gr.update()
753
  prompts[name] = text
754
  choices = list(prompts.keys())
755
- return (
756
- f"βœ… Saved: '{name}'",
757
- prompts, # updated prompts state
758
- counter, # updated counter state
759
- gr.update(choices=choices), # custom_prompt_selector
760
- gr.update(choices=choices), # delete_prompt_name dropdown
761
- gr.update(value="", interactive=True), # clear the name field
762
- )
763
 
764
  def delete_custom_prompt(name, currently_selected, prompts_state):
765
  prompts = dict(prompts_state)
 
766
  if name and name in prompts:
767
  del prompts[name]
768
- msg = f"πŸ—‘οΈ Deleted: '{name}'"
769
- else:
770
- msg = "Nothing to delete."
771
  choices = list(prompts.keys())
772
- # Drop deleted name from active selection if it was ticked
773
- new_selection = [n for n in (currently_selected or []) if n in prompts]
774
- return (
775
- msg,
776
- prompts, # updated prompts state
777
- gr.update(choices=choices, value=new_selection), # custom_prompt_selector
778
- gr.update(choices=choices, value=None), # delete_prompt_name
779
- )
780
 
781
  def update_custom_prompt_display(selected_names, prompts_state):
782
  if not selected_names:
@@ -787,7 +671,7 @@ def update_custom_prompt_display(selected_names, prompts_state):
787
  return gr.update(value="", visible=False)
788
 
789
 
790
- # ── UI ────────────────────────────────────────────────────────────────────────
791
 
792
  css = """
793
  #col-container { margin: 0 auto; max-width: 980px; }
@@ -796,11 +680,12 @@ css = """
796
  .lora-weight-row { background: var(--block-background-fill); border-radius: 8px; padding: 4px 12px; margin-bottom: 4px; }
797
  """
798
 
799
- with gr.Blocks(css=css, theme=orange_red_theme) as demo:
800
- # Per-session state β€” each user gets their own isolated copy, never shared across sessions
801
  custom_prompts_state = gr.State({})
802
  custom_prompt_counter_state = gr.State(0)
803
- dynamic_loras_state = gr.State({}) # this session's custom HF LoRAs β€” never shared with other users
 
804
 
805
  with gr.Column(elem_id="col-container"):
806
  _logging_on = os.environ.get("ENABLE_LOGGING", "No").strip().lower() == "yes"
@@ -809,206 +694,286 @@ with gr.Blocks(css=css, theme=orange_red_theme) as demo:
809
  gr.Markdown("# **FLUX.2-Klein-LoRA-Studio**", elem_id="main-title")
810
  gr.Markdown(
811
  f"Apply one or more [LoRA](https://huggingface.co/models?other=base_model:adapter:black-forest-labs/FLUX.2-klein-9B) "
812
- f"adapters to your images using "
813
- f"[FLUX.2-Klein-{MODEL_VARIANT}]({_MODEL_REPO}).\n\n"
814
  f"**Model:** `{MODEL_VARIANT}` Β· **Logging:** {_logging_badge}"
815
  )
816
- gr.Markdown(
817
- "*Results may vary when combining multiple LoRAs. "
818
- "Uploaded images and metadata may be temporarily stored to help optimise the space β€” "
819
- "stored data is automatically deleted. "
820
- "Always comply with HF and model Terms of Service.*"
821
- )
822
 
823
  with gr.Tabs() as main_tabs:
824
- # ── Generate tab ────────────────────────────────────────────────────
825
  with gr.Tab("🎨 Generate", id="tab_generate"):
826
  with gr.Row(equal_height=False):
827
- # ── Left column ──────────────────────────────────────────────
828
  with gr.Column(scale=1):
829
  base_image = gr.Image(
830
- label="Base Image",
831
- type="pil",
832
- sources=["upload", "clipboard"],
833
- height=260,
834
- elem_id="base_image",
835
  )
836
  size_info = gr.Markdown("*No image uploaded yet*")
837
 
838
  reference_images = gr.Gallery(
839
- label="Reference Image(s) β€” optional",
840
- type="filepath",
841
- columns=2, rows=1, height=140,
842
- allow_preview=True,
843
  elem_id="reference_gallery",
844
  )
845
  reference_info = gr.Markdown("πŸ“· No reference images")
846
- gr.Markdown(
847
- "*For Face Swap, the first Reference image is used as the face source.*"
848
- )
849
-
850
- prompt = gr.Text(
851
- label="Prompt",
852
- max_lines=3,
853
- placeholder="Describe the edit, or leave blank for style-only LoRAs",
854
- )
855
-
856
- # Read-only display for LoRA default prompts β€” shown when any
857
- # selected LoRA has a default prompt. Combined with user prompt
858
- # at inference time; never overwrites what the user typed.
859
- lora_prompt_display = gr.Textbox(
860
- label="LoRA Default Prompts (auto-appended)",
861
- info="These prompts come from your selected LoRAs and are appended to your prompt above during generation.",
862
- interactive=False,
863
- visible=False,
864
- lines=3,
865
- )
866
-
867
- # Read-only display for active custom prompts β€” shown when any
868
- # saved custom prompt is ticked in the Custom Prompts accordion.
869
- custom_prompt_display = gr.Textbox(
870
- label="Custom Prompts (auto-appended)",
871
- info="These are your selected saved prompts, appended alongside LoRA prompts during generation.",
872
- interactive=False,
873
- visible=False,
874
- lines=3,
875
- )
876
-
877
  run_button = gr.Button("β–Ά Generate", variant="primary", size="lg")
878
 
879
  with gr.Accordion("βš™οΈ Advanced Settings", open=False):
880
  seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0)
881
  randomize_seed = gr.Checkbox(label="Randomize seed", value=True)
882
- guidance_scale = gr.Slider(label="Guidance Scale", minimum=0.0, maximum=10.0, step=0.1, value=1.0, visible=MODEL_VARIANT != "9B-KV")
 
883
  steps = gr.Slider(label="Steps", minimum=1, maximum=50, value=4, step=1)
884
- upscale_factor = gr.Dropdown(
885
- label="Upscale model",
886
- info="Applied after generation. UltraSharp / Remacri are sharper than RealESRGAN for clean photo inputs.",
887
- choices=list(UPSCALE_MODELS.keys()),
888
- value="None",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
889
  )
 
 
 
 
890
 
891
- # ── Right column ─────────────────────────────────────────────
892
  with gr.Column(scale=1):
893
- output_image = gr.Image(label="Output", interactive=False, format="png", height=320)
894
- used_seed = gr.Textbox(
895
- label="🌱 Seed used",
896
- info="Re-enter this seed in Advanced Settings to reproduce the result exactly.",
897
- interactive=False,
898
- visible=True,
899
- max_lines=1,
 
 
 
 
 
 
 
 
 
 
 
900
  )
901
 
902
- # ── LoRA selector ─────────────────────────────────────────────────
903
  gr.Markdown("### 🎨 Select LoRA(s)")
904
  lora_selector = gr.CheckboxGroup(
905
  choices=[s["title"] for s in get_selectable_styles({})],
906
- value=[],
907
- label="Active LoRAs β€” tick one or more",
908
  )
909
-
910
- # ── Weight sliders (pre-rendered, shown/hidden dynamically) ───────
911
  gr.Markdown("#### Weights for selected LoRAs")
912
  weight_sliders = []
913
  with gr.Group():
914
  for i in range(MAX_LORA_SLOTS):
915
  with gr.Row(elem_classes="lora-weight-row"):
916
- s = gr.Slider(
917
  minimum=0.0, maximum=2.0, step=0.05, value=1.0,
918
- label=f"LoRA slot {i+1}",
919
- visible=False,
920
- interactive=True,
921
- )
922
- weight_sliders.append(s)
923
 
924
- # ── Dynamic HuggingFace loader ─────────────────────────────────────
925
  with gr.Accordion("βž• Load Custom LoRA from HuggingFace", open=False):
926
- gr.Markdown("Add any FLUX.2-Klein-compatible LoRA from [HuggingFace Hub](https://huggingface.co/models?other=base_model:adapter:black-forest-labs/FLUX.2-klein-9B). "
927
- "*Added LoRAs are only visible in your own session β€” other visitors won't see them.*")
928
  with gr.Row():
929
  lora_repo_id = gr.Textbox(label="Repo ID", placeholder="username/repo-name")
930
  with gr.Row():
931
- lora_weight_name = gr.Textbox(label="Weight filename (optional)", placeholder="pytorch_lora_weights.safetensors")
 
932
  lora_adapter_name = gr.Textbox(label="Adapter name (optional)", placeholder="my-lora")
933
  with gr.Row():
934
  add_lora_btn = gr.Button("Add LoRA", variant="primary")
935
  lora_status = gr.Textbox(label="Status", interactive=False)
936
 
937
- # ── Custom Prompt library ──────────────────────────────────────────
938
  with gr.Accordion("πŸ“ Custom Prompts", open=False):
939
- gr.Markdown(
940
- "Save reusable prompt snippets for this session. "
941
- "Tick any saved prompt below and it will be appended to your generation alongside any LoRA prompts. "
942
- "*Prompts are stored in memory β€” they reset when the Space restarts or the worker recycles.*"
943
- )
944
-
945
  custom_prompt_selector = gr.CheckboxGroup(
946
- choices=[],
947
- value=[],
948
  label="Saved prompts β€” tick to append to generation",
949
  )
950
-
951
  with gr.Row():
952
- prompt_name_input = gr.Textbox(
953
- label="Name",
954
- placeholder="e.g. Skin detail enhancer",
955
- scale=1,
956
- )
957
  with gr.Row():
958
- prompt_text_input = gr.Textbox(
959
- label="Prompt text",
960
- placeholder="Enter the prompt snippet you want to save and reuse…",
961
- lines=4,
962
- )
963
  with gr.Row():
964
  add_prompt_btn = gr.Button("πŸ’Ύ Save Prompt", variant="primary")
965
  prompt_status = gr.Textbox(label="Status", interactive=False, scale=2)
966
  with gr.Row():
967
- delete_prompt_name = gr.Dropdown(
968
- label="Delete a saved prompt",
969
- choices=[],
970
- value=None,
971
- interactive=True,
972
- scale=2,
973
- )
974
  delete_prompt_btn = gr.Button("πŸ—‘οΈ Delete", variant="secondary", scale=1)
975
 
976
- gr.Markdown("*Experimental space β€” results may vary when combining multiple LoRAs.*")
977
-
978
- # ── Crop / Fix Image tab ─────���───────────────────────────────────────
979
  with gr.Tab("βœ‚οΈ Crop / Fix Image", id="tab_editor"):
980
  gr.Markdown(
981
- "Upload an image to crop it, then send the result to the Base Image or add it as a "
982
- "Reference image on the Generate tab. EXIF orientation (the most common cause of "
983
- "images coming out sideways) is corrected automatically as soon as you upload here."
984
  )
985
  editor = gr.ImageEditor(
986
- label="Editor",
987
- type="pil",
988
- transforms=["crop"],
989
- brush=False,
990
- eraser=False,
991
- layers=False,
 
 
992
  sources=["upload", "clipboard"],
993
  height=420,
994
  )
 
 
 
 
 
 
995
  with gr.Row():
996
  send_to_base_btn = gr.Button("β†’ Send to Base Image", variant="primary")
997
- send_to_ref_btn = gr.Button("β†’ Add to Reference Images")
998
 
999
- # ── Event wiring ──────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1000
 
1001
- base_image.change(
1002
- fn=on_base_image_change,
1003
- inputs=[base_image],
1004
- outputs=[size_info],
1005
- )
 
 
 
1006
 
1007
- reference_images.change(
1008
- fn=on_reference_change,
1009
- inputs=[reference_images],
1010
- outputs=[reference_info],
1011
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1012
 
1013
  lora_selector.change(
1014
  fn=update_weight_sliders,
@@ -1025,53 +990,259 @@ with gr.Blocks(css=css, theme=orange_red_theme) as demo:
1025
  add_prompt_btn.click(
1026
  fn=add_custom_prompt,
1027
  inputs=[prompt_name_input, prompt_text_input, custom_prompts_state, custom_prompt_counter_state],
1028
- outputs=[prompt_status, custom_prompts_state, custom_prompt_counter_state, custom_prompt_selector, delete_prompt_name, prompt_name_input],
 
1029
  )
1030
-
1031
  delete_prompt_btn.click(
1032
  fn=delete_custom_prompt,
1033
  inputs=[delete_prompt_name, custom_prompt_selector, custom_prompts_state],
1034
  outputs=[prompt_status, custom_prompts_state, custom_prompt_selector, delete_prompt_name],
1035
  )
1036
-
1037
  custom_prompt_selector.change(
1038
  fn=update_custom_prompt_display,
1039
  inputs=[custom_prompt_selector, custom_prompts_state],
1040
  outputs=[custom_prompt_display],
1041
  )
1042
 
1043
- run_button.click(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1044
  fn=infer,
1045
- inputs=[base_image, reference_images, prompt, lora_prompt_display, custom_prompt_display, lora_selector, seed, randomize_seed, guidance_scale, steps, upscale_factor, dynamic_loras_state] + weight_sliders,
1046
- outputs=[output_image, used_seed],
 
 
 
 
1047
  )
1048
 
1049
- # ── Image Editor tab wiring ─────────────────────────────────────────────
1050
- editor.upload(
1051
- fn=fix_editor_orientation,
1052
- inputs=[editor],
1053
- outputs=[editor],
1054
- )
1055
 
1056
- send_to_base_btn.click(
1057
- fn=send_editor_to_base,
1058
- inputs=[editor],
 
 
 
 
 
 
 
 
 
 
1059
  outputs=[base_image],
1060
- ).then(
1061
- fn=on_base_image_change, inputs=[base_image], outputs=[size_info],
1062
- ).then(
1063
- fn=lambda: gr.Tabs(selected="tab_generate"), outputs=[main_tabs],
1064
- )
1065
 
1066
- send_to_ref_btn.click(
1067
- fn=send_editor_to_reference,
1068
- inputs=[editor, reference_images],
1069
  outputs=[reference_images],
1070
- ).then(
1071
- fn=on_reference_change, inputs=[reference_images], outputs=[reference_info],
1072
- ).then(
1073
- fn=lambda: gr.Tabs(selected="tab_generate"), outputs=[main_tabs],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1074
  )
1075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1076
  if __name__ == "__main__":
1077
- demo.queue().launch(css=css, theme=orange_red_theme, mcp_server=True, ssr_mode=False, show_error=True)
 
 
 
1
  import os
2
  import gc
3
+ import csv
4
+ import time
5
+ import random
6
+ import uuid
7
+ import zipfile
8
+ import threading
9
+ from typing import Iterable
10
+
11
  import gradio as gr
12
  import numpy as np
13
  import spaces
14
  import torch
15
+ from PIL import Image
16
+
 
 
 
 
17
  from logging_utils import log_inference
18
 
19
+ from image_utils import (
20
+ fix_orientation,
21
+ compute_base_dimensions,
22
+ compute_canvas_dimensions,
23
+ fit_to_canvas,
24
+ on_base_image_change,
25
+ on_reference_change,
26
+ process_images,
27
+ reencode_upload,
28
+ send_editor_to_base,
29
+ send_editor_to_reference,
30
+ load_heic_to_editor,
31
+ send_output_to_base,
32
+ send_output_to_reference,
33
+ save_with_metadata,
34
+ build_pnginfo,
35
+ push_pil_to_base,
36
+ push_pil_to_reference,
37
+ )
38
+
39
+ from control_tools import (
40
+ generate_depthmap,
41
+ detect_pose,
42
+ render_pose_skeleton,
43
+ render_pose_overlay,
44
+ move_joint,
45
+ hide_joint,
46
+ clear_all_joints,
47
+ default_pose_template,
48
+ person_choices,
49
+ parse_person_idx,
50
+ joint_name_to_index,
51
+ OPENPOSE_KEYPOINT_NAMES,
52
+ )
53
+
54
+ # requirements.txt: spandrel, pillow-heif
55
 
56
  MODEL_VARIANT = os.environ.get("MODEL_VARIANT", "9B")
57
  if MODEL_VARIANT == "9B-KV":
 
60
  else:
61
  from diffusers import Flux2KleinPipeline as _PipeClass
62
  _MODEL_REPO = "black-forest-labs/FLUX.2-klein-9B"
63
+ MODEL_VARIANT = "9B"
 
64
 
65
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
66
 
67
+ # ── Theme ────────────────────────────────────────────────────────────────────
68
  from gradio.themes import Soft
69
  from gradio.themes.utils import colors, fonts, sizes
70
 
 
75
  )
76
 
77
  class OrangeRedTheme(Soft):
78
+ def __init__(self, *, primary_hue=colors.gray, secondary_hue=colors.orange_red,
79
+ neutral_hue=colors.slate, text_size=sizes.text_lg,
80
+ font=(fonts.GoogleFont("Outfit"), "Arial", "sans-serif"),
81
+ font_mono=(fonts.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace")):
82
+ super().__init__(primary_hue=primary_hue, secondary_hue=secondary_hue,
83
+ neutral_hue=neutral_hue, text_size=text_size,
84
+ font=font, font_mono=font_mono)
 
 
 
 
 
 
 
 
85
  super().set(
86
  background_fill_primary="*primary_50",
87
  background_fill_primary_dark="*primary_900",
 
93
  button_primary_background_fill_hover="linear-gradient(90deg, *secondary_600, *secondary_700)",
94
  button_primary_background_fill_dark="linear-gradient(90deg, *secondary_600, *secondary_700)",
95
  button_primary_background_fill_hover_dark="linear-gradient(90deg, *secondary_500, *secondary_600)",
96
+ slider_color="*secondary_500", slider_color_dark="*secondary_600",
 
97
  block_title_text_weight="600", block_border_width="3px",
98
  block_shadow="*shadow_drop_lg", button_primary_shadow="*shadow_drop_lg",
99
  button_large_padding="11px", color_accent_soft="*primary_100",
 
103
  orange_red_theme = OrangeRedTheme()
104
  MAX_SEED = np.iinfo(np.int32).max
105
 
106
+ # ── Upscaler models, FACE_SWAP_PROMPT, LORA_STYLES β€” UNCHANGED from previous step
 
 
107
  UPSCALE_MODELS = {
108
+ "None": {"scale": None, "file": None, "url": None},
109
+ "2Γ— β€” RealESRGAN (balanced)": {"scale": 2, "file": "RealESRGAN_x2plus.pth",
110
+ "url": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth"},
111
+ "4Γ— β€” RealESRGAN (balanced)": {"scale": 4, "file": "RealESRGAN_x4plus.pth",
112
+ "url": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth"},
113
+ "4Γ— β€” UltraSharp (crisp)": {"scale": 4, "file": "4x-UltraSharpV2.pth",
114
+ "url": "https://huggingface.co/Kim2091/UltraSharpV2/resolve/main/4x-UltraSharpV2.pth"},
115
+ "4Γ— β€” Remacri (natural)": {"scale": 4, "file": "4x_foolhardy_Remacri.pth",
116
+ "url": "https://huggingface.co/FacehugmanIII/4x_foolhardy_Remacri/resolve/main/4x_foolhardy_Remacri.pth"},
117
+ "4Γ— β€” Nomos2 HQ DAT2 (Photography)": {"scale": 4, "file": "4xNomos2_hq_dat2.pth",
118
+ "url": "https://github.com/Phhofm/models/releases/download/4xNomos2_hq_dat2/4xNomos2_hq_dat2.pth"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  }
 
120
 
121
  FACE_SWAP_PROMPT = """head_swap: start with Picture 1 as the base image, keeping its lighting, environment, and background. Remove the head from Picture 1 completely and replace it with the head from Picture 2.
122
  FROM PICTURE 1 (strictly preserve):
 
130
  - Skin: texture, tone, complexion
131
  The replaced head must seamlessly match Picture 1's lighting and expression while maintaining the complete identity from Picture 2. High quality, photorealistic, sharp details, 4k."""
132
 
 
133
  MAX_LORA_SLOTS = 6
134
 
135
  LORA_STYLES = [
136
+ {"image": "https://huggingface.co/spaces/prithivMLmods/FLUX.2-Klein-LoRA-Studio/resolve/main/examples/image.webp",
137
+ "title": "None", "adapter_name": None, "repo": None, "weights": None,
138
+ "default_prompt": None, "default_weight": 1.0},
139
+ {"title": "Klein-Delight-Style", "adapter_name": "klein-delight",
140
+ "repo": "linoyts/Flux2-Klein-Delight-LoRA", "weights": "pytorch_lora_weights.safetensors",
141
+ "default_prompt": "Relight the image to remove all existing lighting conditions and replace them with neutral, uniform illumination. Apply soft, evenly distributed lighting with no directional shadows, no harsh highlights, and no dramatic contrast. Maintain the original identity of all subjects exactlyβ€”preserve facial structure, skin tone, proportions, expressions, hair, clothing, and textures. Do not alter pose, camera angle, background geometry, or image composition. Lighting should appear balanced, and studio-neutral, similar to diffuse overcast or a soft lightbox setup. Ensure consistent exposure across the entire image with realistic depth and subtle shading only where necessary for form.",
142
+ "default_weight": 1.0},
143
+ {"title": "Klein-Consistency", "adapter_name": "klein-consistency",
144
+ "repo": "dx8152/Flux2-Klein-9B-Consistency", "weights": "Klein-consistency.safetensors",
145
+ "default_prompt": None, "default_weight": 0.3},
146
+ {"title": "Best-Face-Swap", "adapter_name": "face-swap",
147
+ "repo": "Alissonerdx/BFS-Best-Face-Swap",
148
+ "weights": "bfs_head_v1_flux-klein_9b_step3750_rank64.safetensors",
149
+ "default_prompt": FACE_SWAP_PROMPT, "default_weight": 1.0},
150
+ {"title": "NSFW v2", "adapter_name": "nsfw-v2",
151
+ "repo": "diroverflo/FLux_Klein_9B_NSFW", "weights": "Flux Klein - NSFW v2.safetensors",
152
+ "default_prompt": None, "default_weight": 1.0},
153
+ {"title": "Ultimate Upscaler Klein-9b", "adapter_name": "Ultimate Upscaler",
154
+ "repo": "loras", "weights": "Flux2-Klein-Image-RestoreV1.safetensors",
155
+ "default_prompt": "restore the image quality, remove any compression artefacts, remove any haze and soft edges, enrich the original with new intricate detail in all textures and surfaces creating a professional photorealistic photograph with natural lighting and skin texture.",
156
+ "default_weight": 1.0},
157
+ {"title": "High Resolution", "adapter_name": "High Resolution",
158
+ "repo": "loras", "weights": "HighResolution9B.safetensors",
159
+ "default_prompt": "High Resolution", "default_weight": 1.0},
160
+ {"title": "Female Asshole", "adapter_name": "Female Asshole",
161
+ "repo": "loras", "weights": "femaleasshole-f2-klein-9b.safetensors",
162
+ "default_prompt": None, "default_weight": 1.0},
163
+ {"title": "Realistic Nudes", "adapter_name": "Realistic Nudes",
164
+ "repo": "loras", "weights": "realistic_nudes_klein_v3.safetensors",
165
+ "default_prompt": None, "default_weight": 1.0},
166
+ {"title": "Perky Pointy Puffy Breasts", "adapter_name": "Perky Pointy Puffy Breasts",
167
+ "repo": "loras", "weights": "PerkyPointyPuffy_v1.1_small_pointy_breasts_large_puffy_nipples.safetensors",
168
+ "default_prompt": "Small pointy breasts with large puffy nipples", "default_weight": 1.0},
169
+ {"title": "Flat Chested", "adapter_name": "Flat Chested",
170
+ "repo": "loras", "weights": "Flux2-Klein-9b-FlatChested-v1.safetensors",
171
+ "default_prompt": "flat chested", "default_weight": 1.5},
172
+ {"title": "Controllight", "adapter_name": "Controllight",
173
+ "repo": "ControlLight/ControlLight", "weights": "controllight.safetensors",
174
+ "default_prompt": None, "default_weight": 1.0},
175
+ {"title": "RefControl - Depth", "adapter_name": "RefConDep",
176
+ "repo": "thedeoxen/refcontrol-FLUX.2-klein-9B-reference-depth-lora",
177
+ "weights": "flux2_klein_9b_refcontrol_depth.safetensors",
178
+ "default_prompt": "refcontrol", "default_weight": 1.0},
179
+ {"title": "RefControl - Pose", "adapter_name": "RefConPos",
180
+ "repo": "thedeoxen/refcontrol-FLUX.2-klein-9B-reference-pose-lora",
181
+ "weights": "refcontrol_v2_poses.safetensors",
182
+ "default_prompt": "apply pose from image 1 with reference from image 2",
183
+ "default_weight": 1.0},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  ]
185
 
 
 
 
 
 
 
186
  LOADED_ADAPTERS = set()
187
 
188
 
189
  def get_all_styles(dynamic_loras):
190
+ return list(LORA_STYLES) + list((dynamic_loras or {}).values())
 
 
 
191
 
192
  def get_selectable_styles(dynamic_loras):
 
193
  return [s for s in get_all_styles(dynamic_loras) if s["adapter_name"] is not None]
194
 
195
  def get_style_by_title(title, dynamic_loras):
196
+ for s in get_all_styles(dynamic_loras):
197
+ if s["title"] == title:
198
+ return s
199
  return None
200
 
 
 
 
 
 
201
 
202
  print(f"Loading FLUX.2 Klein {MODEL_VARIANT} from {_MODEL_REPO}...")
203
  pipe = _PipeClass.from_pretrained(_MODEL_REPO, torch_dtype=torch.bfloat16).to(device)
204
  print(f"Model loaded successfully: FLUX.2 Klein {MODEL_VARIANT}")
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
 
207
+ # ── UI helper callbacks ──────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
 
209
  def update_weight_sliders(selected_titles, dynamic_loras):
210
+ selected = [get_style_by_title(t, dynamic_loras) for t in (selected_titles or [])
211
+ if get_style_by_title(t, dynamic_loras)]
212
+ updates = []
 
 
 
 
 
 
 
213
  for i in range(MAX_LORA_SLOTS):
214
+ if i < len(selected):
215
+ s = selected[i]
216
+ updates.append(gr.update(visible=True,
217
+ label=f"{s['title']} β€” weight",
218
+ value=s.get("default_weight", 1.0)))
 
 
219
  else:
220
+ updates.append(gr.update(visible=False, value=1.0))
221
+ prompts = [s.get("default_prompt") for s in selected if s.get("default_prompt")]
222
+ if prompts:
223
+ return updates + [gr.update(value="\n\n".join(prompts), visible=True)]
224
+ return updates + [gr.update(value="", visible=False)]
225
 
 
 
 
 
 
 
 
226
 
227
+ def on_canvas_mode_change(mode):
228
+ """Custom W/H sliders only relevant when mode == Custom."""
229
+ is_custom = (mode == "Custom")
230
+ return gr.update(visible=is_custom), gr.update(visible=is_custom)
231
 
 
232
 
233
+ def on_fit_mode_change(fit_mode):
234
+ """Pad colour swatch only relevant for Pad (color)."""
235
+ return gr.update(visible=(fit_mode == "Pad (color)"))
 
 
 
 
236
 
237
+
238
+ def on_batch_vary_change(vary_mode):
239
+ """Sweep range only relevant for the LoRA sweep mode."""
240
+ is_sweep = (vary_mode == "Sweep first LoRA weight")
241
+ return gr.update(visible=is_sweep), gr.update(visible=is_sweep)
242
+
243
+
244
+ def on_gallery_select(evt: gr.SelectData, gallery_value):
245
+ """Remember which gallery item is selected so Send→* uses it."""
246
+ if evt is None or gallery_value is None or evt.index is None:
247
+ return None
248
+ try:
249
+ item = gallery_value[evt.index]
250
+ except (IndexError, TypeError):
251
+ return None
252
+ return item[0] if isinstance(item, (list, tuple)) else item
253
+
254
+
255
+ # ── Upscaler (tiled) β€” unchanged ─────────────────────────────────────────────
256
+
257
+ def _upscale_tiled(model_fn, img_t, tile=512, overlap=32):
258
+ _, c, h, w = img_t.shape
259
  with torch.no_grad():
260
  probe = model_fn(img_t[:, :, :min(4, h), :min(4, w)])
261
  scale = probe.shape[-1] // min(4, w)
262
+ del probe; torch.cuda.empty_cache()
 
 
263
  out_h, out_w = h * scale, w * scale
264
  canvas = torch.zeros(1, c, out_h, out_w, dtype=torch.float32)
265
  weights = torch.zeros(1, 1, out_h, out_w, dtype=torch.float32)
 
266
  step = max(tile - overlap, 1)
267
+ ys = sorted(set(list(range(0, max(h - tile, 0), step)) + [max(h - tile, 0)]))
268
+ xs = sorted(set(list(range(0, max(w - tile, 0), step)) + [max(w - tile, 0)]))
 
 
 
 
269
  for y0 in ys:
270
  for x0 in xs:
271
+ y1 = min(y0 + tile, h); x1 = min(x0 + tile, w)
 
 
272
  with torch.no_grad():
273
+ out = model_fn(img_t[:, :, y0:y1, x0:x1]).cpu().float()
274
  oy0, ox0 = y0 * scale, x0 * scale
275
  oy1, ox1 = y1 * scale, x1 * scale
276
  canvas[:, :, oy0:oy1, ox0:ox1] += out
277
  weights[:, :, oy0:oy1, ox0:ox1] += 1.0
 
278
  return (canvas / weights.clamp(min=1)).clamp(0, 1)
279
 
280
 
281
+ def apply_realesrgan(image, model_key):
 
 
 
 
282
  cfg = UPSCALE_MODELS[model_key]
 
283
  try:
284
  from spandrel import ImageModelDescriptor, ModelLoader
285
  except ImportError:
286
  raise gr.Error("spandrel is not installed. Add 'spandrel' to requirements.txt.")
 
287
  import urllib.request
 
288
  cache_dir = "/tmp/realesrgan_weights"
289
  os.makedirs(cache_dir, exist_ok=True)
290
  cache_path = os.path.join(cache_dir, cfg["file"])
 
291
  if not os.path.exists(cache_path):
292
  print(f"Downloading {cfg['file']}…")
293
  urllib.request.urlretrieve(cfg["url"], cache_path)
 
294
  model = ModelLoader().load_from_file(cache_path)
295
  if not isinstance(model, ImageModelDescriptor):
296
  raise gr.Error(f"Loaded model is not a single-image descriptor: {type(model)}")
 
297
  sr_model = model.model.to(device).eval()
 
298
  img_np = np.array(image).astype(np.float32) / 255.0
299
  img_t = torch.from_numpy(img_np).permute(2, 0, 1).unsqueeze(0).to(device)
 
300
  out_t = _upscale_tiled(sr_model, img_t, tile=512, overlap=32)
 
 
301
  del sr_model, img_t
302
+ gc.collect(); torch.cuda.empty_cache()
 
 
303
  out_np = out_t.squeeze(0).permute(1, 2, 0).numpy()
304
  return Image.fromarray((out_np * 255).astype(np.uint8))
305
 
306
 
307
+ # ── Logging ──────────────────────────────────────────────────────────────────
308
 
309
  def _spawn_log(pil_images, result_image, prompt, seed, steps, guidance_scale,
310
  width, height, duration, success, error="",
311
  lora_titles=None, lora_weights=None, upscale_factor="None",
312
  lora_prompt_text=""):
 
313
  if os.environ.get("ENABLE_LOGGING", "No").strip().lower() != "yes":
314
  return
315
  threading.Thread(
 
322
  ).start()
323
 
324
 
325
+ # ── GPU step (shared by single, batch, and bulk) ─────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
 
327
  @spaces.GPU
328
  def _infer_gpu(
329
+ pil_images, prompt, lora_prompt_text, custom_prompt_text, selected_titles,
330
+ seed, guidance_scale, steps, upscale_factor,
331
+ canvas_mode, custom_width, custom_height,
332
+ canvas_fit_mode, pad_color, dynamic_loras,
333
+ *slider_values, progress=gr.Progress(track_tqdm=True),
 
 
 
 
 
 
 
334
  ):
 
 
 
335
  if "Best-Face-Swap" in selected_titles:
336
  if len(pil_images) < 2:
337
+ raise gr.Error("Face Swap requires 2 images: a Base image and one Reference image.")
338
  if len(pil_images) > 2:
339
  gr.Warning("Face Swap uses only the Base image and the first Reference image.")
340
  pil_images = pil_images[:2]
341
 
342
+ active_styles = [get_style_by_title(t, dynamic_loras) for t in selected_titles
343
+ if get_style_by_title(t, dynamic_loras)
344
+ and get_style_by_title(t, dynamic_loras)["adapter_name"] is not None]
345
  weights = list(slider_values[:len(active_styles)])
346
 
347
  if not active_styles:
 
348
  pipe.disable_lora()
349
  else:
350
+ for style in active_styles:
351
+ an = style["adapter_name"]
352
+ if an not in LOADED_ADAPTERS:
 
353
  try:
354
+ pipe.load_lora_weights(style["repo"], weight_name=style["weights"], adapter_name=an)
355
+ LOADED_ADAPTERS.add(an)
 
 
 
 
356
  except Exception as e:
357
  raise gr.Error(f"Failed to load {style['title']}: {e}")
358
+ pipe.set_adapters([s["adapter_name"] for s in active_styles],
359
+ adapter_weights=[float(w) for w in weights])
360
 
361
+ full_prompt = "\n".join(p for p in [
362
+ (prompt or "").strip(),
363
+ (lora_prompt_text or "").strip(),
364
+ (custom_prompt_text or "").strip(),
365
+ ] if p)
 
 
 
 
 
 
 
366
 
367
+ width, height = compute_canvas_dimensions(pil_images[0], canvas_mode, custom_width, custom_height)
368
+ print(f"Generating at: {width}Γ—{height} (canvas={canvas_mode}, fit={canvas_fit_mode})")
 
369
 
370
+ processed = [fit_to_canvas(img, width, height, canvas_fit_mode, pad_color) for img in pil_images]
371
+ image_input = processed if len(processed) > 1 else processed[0]
372
 
373
  try:
374
+ kwargs = dict(image=image_input, prompt=full_prompt,
375
+ width=width, height=height,
376
+ num_inference_steps=steps,
377
+ generator=torch.Generator(device=device).manual_seed(seed))
 
 
 
 
 
 
378
  if MODEL_VARIANT != "9B-KV":
379
+ kwargs["guidance_scale"] = guidance_scale
380
+ image = pipe(**kwargs).images[0]
381
  except Exception as e:
382
  raise gr.Error(f"Inference failed: {e}")
383
 
 
384
  if upscale_factor and upscale_factor != "None":
385
+ gc.collect(); torch.cuda.synchronize(); torch.cuda.empty_cache()
 
 
 
 
 
386
  try:
387
  image = apply_realesrgan(image, upscale_factor)
 
388
  except Exception as e:
389
+ gr.Warning(f"Upscaling failed, returning {width}Γ—{height} result: {e}")
 
390
 
391
+ gc.collect(); torch.cuda.empty_cache()
 
392
  return image, seed, width, height
393
 
394
+
395
+ def _meta_for(prompt, seed, steps, guidance_scale, width, height, upscale_factor,
396
+ canvas_mode, canvas_fit_mode, lora_titles, lora_weights,
397
+ lora_prompt, custom_prompt, extra=None):
398
+ meta = {
399
+ "prompt": (prompt or "").strip(),
400
+ "seed": seed, "steps": steps,
401
+ "guidance_scale": guidance_scale if MODEL_VARIANT != "9B-KV" else None,
402
+ "width": width, "height": height,
403
+ "model": f"FLUX.2-Klein-{MODEL_VARIANT}",
404
+ "upscale_factor": upscale_factor,
405
+ "canvas_mode": canvas_mode, "canvas_fit_mode": canvas_fit_mode,
406
+ "loras": list(zip(lora_titles or [], lora_weights or [])),
407
+ "lora_prompt": (lora_prompt or "").strip(),
408
+ "custom_prompt": (custom_prompt or "").strip(),
409
+ }
410
+ if extra:
411
+ meta.update(extra)
412
+ return meta
413
+
414
+
415
+ # ── Single / batch infer (generator β†’ streams into gr.Gallery) ───────────────
416
+
417
+ def infer(
418
+ base_image, reference_images, prompt, lora_prompt_text, custom_prompt_text,
419
+ selected_titles, seed, randomize_seed, guidance_scale, steps, upscale_factor,
420
+ canvas_mode, custom_width, custom_height, canvas_fit_mode, pad_color,
421
+ batch_count, batch_vary, sweep_min, sweep_max,
422
+ dynamic_loras, *slider_values, progress=gr.Progress(track_tqdm=True),
423
+ ):
424
+ """Generator. Streams a list of PNG paths into the output gallery, one
425
+ result at a time. Each iteration is its own @spaces.GPU call, so a
426
+ ZeroGPU quota wall mid-batch only loses the in-progress item β€” earlier
427
+ PNGs are already saved to disk and surfaced in the gallery."""
428
+ gc.collect(); torch.cuda.empty_cache()
429
+ if not isinstance(upscale_factor, str) or upscale_factor not in UPSCALE_MODELS:
430
+ upscale_factor = "None"
431
+ if base_image is None:
432
+ raise gr.Error("Please upload a base image.")
433
+ pil_images = process_images(base_image, reference_images)
434
+ if not pil_images:
435
+ raise gr.Error("Could not process uploaded images.")
436
+
437
+ selected_titles = selected_titles or []
438
+ batch_count = max(1, int(batch_count))
439
+
440
+ # Pre-plan per-iteration seed and weight overrides β€” keeps the loop simple
441
+ # and lets us put the LoRA-sweep values into PNG metadata cleanly.
442
+ base_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
443
+ seeds, weight_overrides = [], []
444
+ for i in range(batch_count):
445
+ if batch_vary == "Sequential seed (+1 each)":
446
+ seeds.append((base_seed + i) % (MAX_SEED + 1)); weight_overrides.append(None)
447
+ elif batch_vary == "Sweep first LoRA weight":
448
+ seeds.append(base_seed)
449
+ t = i / max(batch_count - 1, 1)
450
+ weight_overrides.append((0, float(sweep_min) + t * (float(sweep_max) - float(sweep_min))))
451
+ else: # "Random seed each run" (default)
452
+ seeds.append(random.randint(0, MAX_SEED)); weight_overrides.append(None)
453
+
454
+ active_styles = [get_style_by_title(t, dynamic_loras) for t in selected_titles
455
+ if get_style_by_title(t, dynamic_loras)
456
+ and get_style_by_title(t, dynamic_loras)["adapter_name"] is not None]
457
+ log_titles = [s["title"] for s in active_styles]
458
+
459
+ results = []
460
+ last_seed_text = ""
461
+ for i in range(batch_count):
462
+ sliders = list(slider_values)
463
+ if weight_overrides[i] is not None:
464
+ slot, val = weight_overrides[i]
465
+ if slot < len(sliders):
466
+ sliders[slot] = val
467
+
468
+ cur_seed = seeds[i]
469
+ log_weights = [float(w) for w in sliders[:len(active_styles)]]
470
+ t0 = time.perf_counter()
471
+ try:
472
+ image, used_seed, w, h = _infer_gpu(
473
+ pil_images, prompt, lora_prompt_text, custom_prompt_text, selected_titles,
474
+ cur_seed, guidance_scale, steps, upscale_factor,
475
+ canvas_mode, custom_width, custom_height,
476
+ canvas_fit_mode, pad_color, dynamic_loras,
477
+ *sliders, progress=progress,
478
+ )
479
+ meta = _meta_for(prompt, used_seed, steps, guidance_scale, w, h, upscale_factor,
480
+ canvas_mode, canvas_fit_mode, log_titles, log_weights,
481
+ lora_prompt_text, custom_prompt_text,
482
+ extra={"batch_index": i, "batch_total": batch_count,
483
+ "batch_vary": batch_vary})
484
+ results.append(save_with_metadata(image, meta))
485
+ last_seed_text = str(used_seed)
486
+ _spawn_log(pil_images, image, prompt, used_seed, steps, guidance_scale,
487
+ w, h, time.perf_counter() - t0, True,
488
+ lora_titles=log_titles, lora_weights=log_weights,
489
+ upscale_factor=upscale_factor, lora_prompt_text=lora_prompt_text or "")
490
+ yield results, last_seed_text
491
+ except Exception as e:
492
+ _spawn_log(pil_images, None, prompt, cur_seed, steps, guidance_scale,
493
+ 0, 0, time.perf_counter() - t0, False, str(e),
494
+ lora_titles=log_titles, lora_weights=log_weights,
495
+ upscale_factor=upscale_factor, lora_prompt_text=lora_prompt_text or "")
496
+ yield results, f"Batch {i+1}/{batch_count} failed: {e}"
497
+ # keep going β€” earlier results are preserved in the gallery
498
+
499
+
500
+ # ── Bulk processing (one input image per iteration) ─────────────────────────
501
+
502
+ def _new_bulk_workdir() -> str:
503
+ sid = uuid.uuid4().hex[:8]
504
+ path = f"/tmp/bulk_{sid}"
505
+ os.makedirs(path, exist_ok=True)
506
+ return path
507
+
508
+
509
+ def bulk_infer(
510
+ input_files,
511
+ prompt, lora_prompt_text, custom_prompt_text, selected_titles,
512
+ seed, randomize_seed, guidance_scale, steps, upscale_factor,
513
+ canvas_mode, custom_width, custom_height, canvas_fit_mode, pad_color,
514
+ dynamic_loras, *slider_values, progress=gr.Progress(),
515
+ ):
516
+ """Process each uploaded image as its own GPU call, streaming results
517
+ into the bulk output gallery as soon as they complete. Outputs and a CSV
518
+ manifest are written to /tmp/bulk_<sid>/ with stable filenames, so a
519
+ ZeroGPU quota wall mid-run still leaves earlier outputs grabbable from
520
+ the gallery and from disk."""
521
+ if not input_files:
522
+ raise gr.Error("Upload at least one image first.")
523
+
524
+ work_dir = _new_bulk_workdir()
525
+ manifest_path = os.path.join(work_dir, "manifest.csv")
526
+ with open(manifest_path, "w", newline="") as f:
527
+ csv.writer(f).writerow([
528
+ "idx", "input_filename", "output_path", "seed",
529
+ "width", "height", "success", "error", "duration_sec",
530
+ ])
531
+
532
+ active_styles = [get_style_by_title(t, dynamic_loras) for t in (selected_titles or [])
533
+ if get_style_by_title(t, dynamic_loras)
534
+ and get_style_by_title(t, dynamic_loras)["adapter_name"] is not None]
535
+ log_titles = [s["title"] for s in active_styles]
536
+ log_weights = [float(w) for w in slider_values[:len(active_styles)]]
537
+
538
+ results, succeeded, failed = [], 0, 0
539
+ total = len(input_files)
540
+
541
+ for i, path in enumerate(input_files):
542
+ progress(i / total, desc=f"Image {i+1}/{total}")
543
+ fname = os.path.basename(path) if isinstance(path, str) else f"input_{i}"
544
+ t0 = time.perf_counter()
545
+ try:
546
+ img = fix_orientation(Image.open(path)).convert("RGB")
547
+ cur_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
548
+
549
+ image, used_seed, w, h = _infer_gpu(
550
+ [img], prompt, lora_prompt_text, custom_prompt_text, selected_titles or [],
551
+ cur_seed, guidance_scale, steps, upscale_factor,
552
+ canvas_mode, custom_width, custom_height,
553
+ canvas_fit_mode, pad_color, dynamic_loras,
554
+ *slider_values, progress=progress,
555
+ )
556
+
557
+ # Stable, predictable filename inside the session work_dir so the
558
+ # user can also find outputs on disk if the UI drops.
559
+ stem = os.path.splitext(fname)[0]
560
+ out_path = os.path.join(work_dir, f"{i:03d}_{stem}.png")
561
+ meta = _meta_for(prompt, used_seed, steps, guidance_scale, w, h, upscale_factor,
562
+ canvas_mode, canvas_fit_mode, log_titles, log_weights,
563
+ lora_prompt_text, custom_prompt_text,
564
+ extra={"bulk_input": fname,
565
+ "bulk_index": i, "bulk_total": total})
566
+ image.save(out_path, format="PNG", pnginfo=build_pnginfo(meta))
567
+ results.append(out_path)
568
+ succeeded += 1
569
+ duration = time.perf_counter() - t0
570
+
571
+ with open(manifest_path, "a", newline="") as f:
572
+ csv.writer(f).writerow([i, fname, out_path, used_seed, w, h, True, "", f"{duration:.2f}"])
573
+
574
+ _spawn_log([img], image, prompt, used_seed, steps, guidance_scale,
575
+ w, h, duration, True,
576
+ lora_titles=log_titles, lora_weights=log_weights,
577
+ upscale_factor=upscale_factor, lora_prompt_text=lora_prompt_text or "")
578
+
579
+ status = f"βœ… {succeeded}/{total} done ({failed} failed)"
580
+ yield results, status, gr.update() # zip not ready yet
581
+ except Exception as e:
582
+ failed += 1
583
+ duration = time.perf_counter() - t0
584
+ with open(manifest_path, "a", newline="") as f:
585
+ csv.writer(f).writerow([i, fname, "", "", "", "", False, str(e)[:300], f"{duration:.2f}"])
586
+ yield results, f"⚠️ Image {i+1} failed: {e} | {succeeded} ok, {failed} failed", gr.update()
587
+ continue
588
+
589
+ # Final pass: bundle a zip. ZIP_STORED because PNGs are already compressed.
590
+ zip_path = os.path.join(work_dir, "outputs.zip")
591
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_STORED) as zf:
592
+ for p in results:
593
+ zf.write(p, arcname=os.path.basename(p))
594
+ zf.write(manifest_path, arcname="manifest.csv")
595
+
596
+ yield results, f"πŸŽ‰ Done β€” {succeeded}/{total} succeeded, {failed} failed", zip_path
597
+
598
+
599
+ # ── Dynamic LoRA loader (unchanged from previous step) ───────────────────────
600
 
601
  def add_custom_lora(repo_id, weight_name, adapter_name, dynamic_loras_state):
 
 
 
 
 
 
602
  dynamic_loras = dict(dynamic_loras_state or {})
603
  if not repo_id or not repo_id.strip():
604
  return "Please enter a valid HuggingFace repo ID.", gr.update(), dynamic_loras
 
616
  if not actual_weight:
617
  available = [f.filename for f in info.siblings if f.filename.endswith(('.safetensors', '.bin'))]
618
  return f"No weight found. Available: {', '.join(available) or 'None'}", gr.update(), dynamic_loras
 
619
  base_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in requested_name) if requested_name else "custom"
 
620
  static_names = {s["adapter_name"] for s in LORA_STYLES if s["adapter_name"]}
621
+ final = f"{base_name}_{uuid.uuid4().hex[:6]}"
622
+ while final in static_names or final in LOADED_ADAPTERS:
623
+ final = f"{base_name}_{uuid.uuid4().hex[:6]}"
624
+ dynamic_loras[final] = {
 
625
  "image": "https://huggingface.co/spaces/prithivMLmods/FLUX.2-Klein-LoRA-Studio/resolve/main/examples/image.webp",
626
+ "title": f"Custom: {base_name}", "adapter_name": final,
627
+ "repo": repo_id, "weights": actual_weight,
628
+ "default_prompt": None, "default_weight": 1.0,
 
 
 
629
  }
 
630
  new_choices = [s["title"] for s in get_selectable_styles(dynamic_loras)]
631
  return f"βœ… Added: {base_name} from {repo_id}", gr.update(choices=new_choices), dynamic_loras
632
  except Exception as e:
633
  return f"❌ Failed: {str(e)}", gr.update(), dynamic_loras
634
 
635
+
636
+ # ── Custom prompt manager (unchanged) ────────────────────────────────────────
637
 
638
  def add_custom_prompt(name, text, prompts_state, counter_state):
639
+ prompts = dict(prompts_state); counter = int(counter_state)
 
 
 
 
 
640
  text = text.strip() if text else ""
641
  name = name.strip() if name else ""
642
  if not text:
643
  return "Please enter some prompt text.", prompts, counter, gr.update(), gr.update(), gr.update()
644
  if not name:
645
+ counter += 1; name = f"Prompt {counter}"
 
646
  if name in prompts:
647
+ return f"⚠️ '{name}' already exists.", prompts, counter, gr.update(), gr.update(), gr.update()
648
  prompts[name] = text
649
  choices = list(prompts.keys())
650
+ return (f"βœ… Saved: '{name}'", prompts, counter,
651
+ gr.update(choices=choices), gr.update(choices=choices),
652
+ gr.update(value="", interactive=True))
 
 
 
 
 
653
 
654
  def delete_custom_prompt(name, currently_selected, prompts_state):
655
  prompts = dict(prompts_state)
656
+ msg = f"πŸ—‘οΈ Deleted: '{name}'" if name and name in prompts else "Nothing to delete."
657
  if name and name in prompts:
658
  del prompts[name]
 
 
 
659
  choices = list(prompts.keys())
660
+ new_sel = [n for n in (currently_selected or []) if n in prompts]
661
+ return (msg, prompts,
662
+ gr.update(choices=choices, value=new_sel),
663
+ gr.update(choices=choices, value=None))
 
 
 
 
664
 
665
  def update_custom_prompt_display(selected_names, prompts_state):
666
  if not selected_names:
 
671
  return gr.update(value="", visible=False)
672
 
673
 
674
+ # ── UI ───────────────────────────────────────────────────────────────────────
675
 
676
  css = """
677
  #col-container { margin: 0 auto; max-width: 980px; }
 
680
  .lora-weight-row { background: var(--block-background-fill); border-radius: 8px; padding: 4px 12px; margin-bottom: 4px; }
681
  """
682
 
683
+ # Gradio 6.0: theme/css go on launch(), not Blocks()
684
+ with gr.Blocks() as demo:
685
  custom_prompts_state = gr.State({})
686
  custom_prompt_counter_state = gr.State(0)
687
+ dynamic_loras_state = gr.State({})
688
+ selected_output_state = gr.State(None) # currently-selected gallery item path
689
 
690
  with gr.Column(elem_id="col-container"):
691
  _logging_on = os.environ.get("ENABLE_LOGGING", "No").strip().lower() == "yes"
 
694
  gr.Markdown("# **FLUX.2-Klein-LoRA-Studio**", elem_id="main-title")
695
  gr.Markdown(
696
  f"Apply one or more [LoRA](https://huggingface.co/models?other=base_model:adapter:black-forest-labs/FLUX.2-klein-9B) "
697
+ f"adapters using [FLUX.2-Klein-{MODEL_VARIANT}]({_MODEL_REPO}). "
 
698
  f"**Model:** `{MODEL_VARIANT}` Β· **Logging:** {_logging_badge}"
699
  )
 
 
 
 
 
 
700
 
701
  with gr.Tabs() as main_tabs:
702
+ # ── Generate tab ─────────────────────────────────────────────────
703
  with gr.Tab("🎨 Generate", id="tab_generate"):
704
  with gr.Row(equal_height=False):
 
705
  with gr.Column(scale=1):
706
  base_image = gr.Image(
707
+ label="Base Image", type="pil",
708
+ sources=["upload", "clipboard"], height=260, elem_id="base_image",
 
 
 
709
  )
710
  size_info = gr.Markdown("*No image uploaded yet*")
711
 
712
  reference_images = gr.Gallery(
713
+ label="Reference Image(s) β€” optional", type="filepath",
714
+ columns=2, rows=1, height=140, allow_preview=True,
 
 
715
  elem_id="reference_gallery",
716
  )
717
  reference_info = gr.Markdown("πŸ“· No reference images")
718
+ gr.Markdown("*For Face Swap, the first Reference image is used as the face source.*")
719
+
720
+ prompt = gr.Text(label="Prompt", max_lines=3,
721
+ placeholder="Describe the edit, or leave blank for style-only LoRAs")
722
+ lora_prompt_display = gr.Textbox(label="LoRA Default Prompts (auto-appended)",
723
+ interactive=False, visible=False, lines=3)
724
+ custom_prompt_display = gr.Textbox(label="Custom Prompts (auto-appended)",
725
+ interactive=False, visible=False, lines=3)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
726
  run_button = gr.Button("β–Ά Generate", variant="primary", size="lg")
727
 
728
  with gr.Accordion("βš™οΈ Advanced Settings", open=False):
729
  seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0)
730
  randomize_seed = gr.Checkbox(label="Randomize seed", value=True)
731
+ guidance_scale = gr.Slider(label="Guidance Scale", minimum=0.0, maximum=10.0,
732
+ step=0.1, value=1.0, visible=MODEL_VARIANT != "9B-KV")
733
  steps = gr.Slider(label="Steps", minimum=1, maximum=50, value=4, step=1)
734
+ upscale_factor = gr.Dropdown(label="Upscale model",
735
+ choices=list(UPSCALE_MODELS.keys()), value="None")
736
+ gr.Markdown("#### πŸ–ΌοΈ Output canvas size")
737
+ canvas_mode = gr.Radio(
738
+ choices=["Auto (from base image)", "Custom"],
739
+ value="Auto (from base image)", label="Canvas mode",
740
+ info=("Auto matches the base image's aspect ratio (longest side 1024). "
741
+ "Use Custom when base and references have very different proportions."),
742
+ )
743
+ custom_width = gr.Slider(label="Width", minimum=512, maximum=2048,
744
+ step=16, value=1024, visible=False)
745
+ custom_height = gr.Slider(label="Height", minimum=512, maximum=2048,
746
+ step=16, value=1024, visible=False)
747
+ canvas_fit_mode = gr.Radio(
748
+ choices=["Stretch", "Pad (color)", "Pad (blur)", "Crop (cover)"],
749
+ value="Stretch", label="Canvas fit mode",
750
+ info=("How input images are placed into the canvas. "
751
+ "Stretch = current default (can squish). "
752
+ "Pad keeps aspect; Crop fills by trimming edges."),
753
+ )
754
+ pad_color = gr.ColorPicker(label="Pad colour",
755
+ value="#000000", visible=False)
756
+
757
+ gr.Markdown("#### πŸ” Batch")
758
+ batch_count = gr.Slider(label="Number of runs", minimum=1, maximum=12,
759
+ step=1, value=1)
760
+ batch_vary = gr.Radio(
761
+ choices=["Random seed each run",
762
+ "Sequential seed (+1 each)",
763
+ "Sweep first LoRA weight"],
764
+ value="Random seed each run", label="Variation strategy",
765
+ info=("Sweep linearly varies the weight of whichever LoRA is in "
766
+ "slot 1 (first ticked) across the runs."),
767
  )
768
+ sweep_min = gr.Slider(label="Sweep min weight", minimum=0.0, maximum=2.0,
769
+ step=0.05, value=0.4, visible=False)
770
+ sweep_max = gr.Slider(label="Sweep max weight", minimum=0.0, maximum=2.0,
771
+ step=0.05, value=1.4, visible=False)
772
 
773
+ # ── Right column ─────────────────────────────────────────
774
  with gr.Column(scale=1):
775
+ # Gallery so batch runs stream in as they finish; type=
776
+ # filepath so PNG metadata round-trips to Send→* buttons
777
+ # and to the user's downloads.
778
+ output_gallery = gr.Gallery(
779
+ label="Output", type="filepath", columns=2, rows=2,
780
+ height=420, allow_preview=True, preview=True,
781
+ object_fit="contain", show_label=True,
782
+ )
783
+ used_seed = gr.Textbox(label="🌱 Seed used (last run)",
784
+ interactive=False, max_lines=1)
785
+ with gr.Row():
786
+ send_out_to_base_btn = gr.Button("↩ Send β†’ Base", size="sm")
787
+ send_out_to_ref_btn = gr.Button("↩ Send β†’ Reference", size="sm")
788
+ gr.Markdown(
789
+ "*Click a gallery thumbnail before Send→ to pick that specific "
790
+ "image; otherwise the latest is used. PNGs contain seed, prompt, "
791
+ "LoRAs and settings as `parameters` tEXt chunks (sd-webui / "
792
+ "Civitai / ComfyUI readable).*"
793
  )
794
 
795
+ # LoRA selector + weight sliders
796
  gr.Markdown("### 🎨 Select LoRA(s)")
797
  lora_selector = gr.CheckboxGroup(
798
  choices=[s["title"] for s in get_selectable_styles({})],
799
+ value=[], label="Active LoRAs β€” tick one or more",
 
800
  )
 
 
801
  gr.Markdown("#### Weights for selected LoRAs")
802
  weight_sliders = []
803
  with gr.Group():
804
  for i in range(MAX_LORA_SLOTS):
805
  with gr.Row(elem_classes="lora-weight-row"):
806
+ weight_sliders.append(gr.Slider(
807
  minimum=0.0, maximum=2.0, step=0.05, value=1.0,
808
+ label=f"LoRA slot {i+1}", visible=False, interactive=True,
809
+ ))
 
 
 
810
 
 
811
  with gr.Accordion("βž• Load Custom LoRA from HuggingFace", open=False):
812
+ gr.Markdown("Add any FLUX.2-Klein-compatible LoRA. *Added LoRAs are only visible in your own session.*")
 
813
  with gr.Row():
814
  lora_repo_id = gr.Textbox(label="Repo ID", placeholder="username/repo-name")
815
  with gr.Row():
816
+ lora_weight_name = gr.Textbox(label="Weight filename (optional)",
817
+ placeholder="pytorch_lora_weights.safetensors")
818
  lora_adapter_name = gr.Textbox(label="Adapter name (optional)", placeholder="my-lora")
819
  with gr.Row():
820
  add_lora_btn = gr.Button("Add LoRA", variant="primary")
821
  lora_status = gr.Textbox(label="Status", interactive=False)
822
 
 
823
  with gr.Accordion("πŸ“ Custom Prompts", open=False):
824
+ gr.Markdown("Save reusable prompt snippets for this session.")
 
 
 
 
 
825
  custom_prompt_selector = gr.CheckboxGroup(
826
+ choices=[], value=[],
 
827
  label="Saved prompts β€” tick to append to generation",
828
  )
 
829
  with gr.Row():
830
+ prompt_name_input = gr.Textbox(label="Name",
831
+ placeholder="e.g. Skin detail enhancer", scale=1)
 
 
 
832
  with gr.Row():
833
+ prompt_text_input = gr.Textbox(label="Prompt text", lines=4,
834
+ placeholder="Enter the prompt snippet you want to save…")
 
 
 
835
  with gr.Row():
836
  add_prompt_btn = gr.Button("πŸ’Ύ Save Prompt", variant="primary")
837
  prompt_status = gr.Textbox(label="Status", interactive=False, scale=2)
838
  with gr.Row():
839
+ delete_prompt_name = gr.Dropdown(label="Delete a saved prompt",
840
+ choices=[], value=None, interactive=True, scale=2)
 
 
 
 
 
841
  delete_prompt_btn = gr.Button("πŸ—‘οΈ Delete", variant="secondary", scale=1)
842
 
843
+ # ── Crop / Fix Image tab ─────────────────────────────────────────
 
 
844
  with gr.Tab("βœ‚οΈ Crop / Fix Image", id="tab_editor"):
845
  gr.Markdown(
846
+ "Upload an image to crop / paint on it, then send the result to the Base "
847
+ "Image or add it as a Reference. EXIF orientation is corrected on export."
 
848
  )
849
  editor = gr.ImageEditor(
850
+ label="Editor", type="pil",
851
+ transforms=("crop",),
852
+ brush=gr.Brush(default_size=12,
853
+ colors=["#FF4500", "#FFFFFF", "#000000",
854
+ "#FF0000", "#00FF00", "#0000FF"],
855
+ color_mode="fixed"),
856
+ eraser=gr.Eraser(default_size=20),
857
+ layers=True,
858
  sources=["upload", "clipboard"],
859
  height=420,
860
  )
861
+ with gr.Row():
862
+ heic_uploader = gr.File(
863
+ label="πŸ“Έ Load HEIC / HEIF (iPhone photos)",
864
+ file_types=[".heic", ".heif", ".HEIC", ".HEIF"],
865
+ file_count="single", type="filepath",
866
+ )
867
  with gr.Row():
868
  send_to_base_btn = gr.Button("β†’ Send to Base Image", variant="primary")
869
+ send_to_ref_btn = gr.Button("β†’ Add to Reference Images")
870
 
871
+ # ── Bulk processing tab ──────────────────────────────────────────
872
+ with gr.Tab("πŸ“¦ Bulk Process", id="tab_bulk"):
873
+ gr.Markdown(
874
+ "Upload many images and process them with the **same settings as the "
875
+ "Generate tab** (prompt, LoRAs, weights, canvas, upscaler, etc.). "
876
+ "Outputs stream in one-by-one β€” each image is its own GPU call, so a "
877
+ "ZeroGPU quota wall mid-run only loses the in-progress item. "
878
+ "Earlier outputs stay in the gallery and on disk under `/tmp/bulk_<id>/`."
879
+ )
880
+ bulk_files = gr.File(
881
+ label="Input images",
882
+ file_count="multiple", type="filepath",
883
+ file_types=["image", ".heic", ".heif"],
884
+ )
885
+ with gr.Row():
886
+ bulk_run_btn = gr.Button("β–Ά Start bulk run", variant="primary")
887
+ bulk_stop_btn = gr.Button("⏹ Stop", variant="stop")
888
+ bulk_status = gr.Markdown("*Ready.*")
889
+ bulk_gallery = gr.Gallery(
890
+ label="Bulk outputs", type="filepath",
891
+ columns=4, rows=2, height=480, allow_preview=True, object_fit="contain",
892
+ )
893
+ bulk_zip = gr.File(label="πŸ“₯ Download all (zip + manifest.csv)",
894
+ interactive=False)
895
 
896
+ # ── Depth / Pose tab ─────────────────────────────────────────────
897
+ with gr.Tab("🦴 Depth / Pose", id="tab_control"):
898
+ gr.Markdown(
899
+ "Generate ControlNet-style **depthmaps** and editable **OpenPose** "
900
+ "skeletons. The result feeds well into the **RefControl – Depth** / "
901
+ "**RefControl – Pose** LoRAs on the Generate tab when sent as a "
902
+ "Reference image."
903
+ )
904
 
905
+ # Per-tab state β€” kept here, not in module globals, so each
906
+ # session edits its own pose without crosstalk.
907
+ pose_source_state = gr.State(None) # PIL of last source image
908
+ pose_keypoints_state = gr.State([]) # list[list[dict]]
909
+
910
+ with gr.Row():
911
+ with gr.Column(scale=1):
912
+ ctrl_source = gr.Image(
913
+ label="Source image", type="pil",
914
+ sources=["upload", "clipboard"], height=320,
915
+ )
916
+ with gr.Row():
917
+ detect_depth_btn = gr.Button("🌐 Generate depthmap", variant="primary")
918
+ detect_pose_btn = gr.Button("🦴 Detect pose", variant="primary")
919
+ insert_blank_btn = gr.Button("βž• Insert blank skeleton template")
920
+
921
+ with gr.Column(scale=1):
922
+ depth_output = gr.Image(label="Depthmap", type="pil",
923
+ interactive=False, height=320, format="png")
924
+ with gr.Row():
925
+ send_depth_ref_btn = gr.Button("β†’ Send depth to Reference",
926
+ variant="primary")
927
+ send_depth_base_btn = gr.Button("β†’ Send depth to Base")
928
+
929
+ gr.Markdown("### ✏️ Pose editor")
930
+ gr.Markdown(
931
+ "Pick a person and a joint, then **click anywhere on the editor preview** "
932
+ "to move that joint. Hidden joints can be re-added the same way β€” select "
933
+ "them and click. Use the buttons below for delete / clear / re-detect."
934
+ )
935
+
936
+ with gr.Row():
937
+ with gr.Column(scale=1):
938
+ # interactive=False so users can't accidentally upload a new image
939
+ # into this slot. .select still fires for click coordinates.
940
+ pose_overlay = gr.Image(
941
+ label="Editor β€” click to place active joint",
942
+ type="pil", interactive=False, height=420, format="png",
943
+ )
944
+ with gr.Column(scale=1):
945
+ pose_clean = gr.Image(
946
+ label="Skeleton (sent to Reference / Base)",
947
+ type="pil", interactive=False, height=420, format="png",
948
+ )
949
+
950
+ with gr.Row():
951
+ active_person_dd = gr.Dropdown(
952
+ label="Active person", choices=[], value=None, interactive=True,
953
+ )
954
+ active_joint_dd = gr.Dropdown(
955
+ label="Active joint",
956
+ choices=list(OPENPOSE_KEYPOINT_NAMES),
957
+ value=None, interactive=True,
958
+ )
959
+
960
+ with gr.Row():
961
+ delete_joint_btn = gr.Button("πŸ—‘οΈ Hide active joint")
962
+ reset_pose_btn = gr.Button("πŸ”„ Re-detect from source")
963
+ clear_pose_btn = gr.Button("🧹 Clear all joints")
964
+
965
+ with gr.Row():
966
+ send_pose_ref_btn = gr.Button("β†’ Send pose to Reference",
967
+ variant="primary")
968
+ send_pose_base_btn = gr.Button("β†’ Send pose to Base")
969
+
970
+ # ── Event wiring ─────────────────────────────────────────────────────────
971
+
972
+ # HEIC preview fix on the main tab
973
+ base_image.upload(fn=reencode_upload, inputs=[base_image], outputs=[base_image])
974
+
975
+ base_image.change(fn=on_base_image_change, inputs=[base_image], outputs=[size_info])
976
+ reference_images.change(fn=on_reference_change, inputs=[reference_images], outputs=[reference_info])
977
 
978
  lora_selector.change(
979
  fn=update_weight_sliders,
 
990
  add_prompt_btn.click(
991
  fn=add_custom_prompt,
992
  inputs=[prompt_name_input, prompt_text_input, custom_prompts_state, custom_prompt_counter_state],
993
+ outputs=[prompt_status, custom_prompts_state, custom_prompt_counter_state,
994
+ custom_prompt_selector, delete_prompt_name, prompt_name_input],
995
  )
 
996
  delete_prompt_btn.click(
997
  fn=delete_custom_prompt,
998
  inputs=[delete_prompt_name, custom_prompt_selector, custom_prompts_state],
999
  outputs=[prompt_status, custom_prompts_state, custom_prompt_selector, delete_prompt_name],
1000
  )
 
1001
  custom_prompt_selector.change(
1002
  fn=update_custom_prompt_display,
1003
  inputs=[custom_prompt_selector, custom_prompts_state],
1004
  outputs=[custom_prompt_display],
1005
  )
1006
 
1007
+ # Canvas / fit / batch UI toggles
1008
+ canvas_mode.change(fn=on_canvas_mode_change, inputs=[canvas_mode],
1009
+ outputs=[custom_width, custom_height])
1010
+ canvas_fit_mode.change(fn=on_fit_mode_change, inputs=[canvas_fit_mode],
1011
+ outputs=[pad_color])
1012
+ batch_vary.change(fn=on_batch_vary_change, inputs=[batch_vary],
1013
+ outputs=[sweep_min, sweep_max])
1014
+
1015
+ # Track which gallery item the user clicked, so Send→Base/Ref can use it
1016
+ output_gallery.select(fn=on_gallery_select, inputs=[output_gallery],
1017
+ outputs=[selected_output_state])
1018
+
1019
+ # The Generate-tab generator. .click() returns an event we keep so the
1020
+ # bulk Stop button can cancel mid-stream too if desired.
1021
+ run_event = run_button.click(
1022
  fn=infer,
1023
+ inputs=[base_image, reference_images, prompt, lora_prompt_display, custom_prompt_display,
1024
+ lora_selector, seed, randomize_seed, guidance_scale, steps, upscale_factor,
1025
+ canvas_mode, custom_width, custom_height, canvas_fit_mode, pad_color,
1026
+ batch_count, batch_vary, sweep_min, sweep_max,
1027
+ dynamic_loras_state] + weight_sliders,
1028
+ outputs=[output_gallery, used_seed],
1029
  )
1030
 
1031
+ # ── Editor tab wiring ────────────────────────────────────────────────────
1032
+ # NOTE: do NOT add editor.upload(outputs=[editor]) β€” remounts the editor.
1033
+
1034
+ heic_uploader.upload(fn=load_heic_to_editor, inputs=[heic_uploader], outputs=[editor])
 
 
1035
 
1036
+ send_to_base_btn.click(fn=send_editor_to_base, inputs=[editor], outputs=[base_image]) \
1037
+ .then(fn=on_base_image_change, inputs=[base_image], outputs=[size_info]) \
1038
+ .then(fn=lambda: gr.Tabs(selected="tab_generate"), outputs=[main_tabs])
1039
+
1040
+ send_to_ref_btn.click(fn=send_editor_to_reference,
1041
+ inputs=[editor, reference_images], outputs=[reference_images]) \
1042
+ .then(fn=on_reference_change, inputs=[reference_images], outputs=[reference_info]) \
1043
+ .then(fn=lambda: gr.Tabs(selected="tab_generate"), outputs=[main_tabs])
1044
+
1045
+ # Send-output buttons use the selected gallery item (or latest fallback)
1046
+ send_out_to_base_btn.click(
1047
+ fn=send_output_to_base,
1048
+ inputs=[selected_output_state, output_gallery],
1049
  outputs=[base_image],
1050
+ ).then(fn=on_base_image_change, inputs=[base_image], outputs=[size_info])
 
 
 
 
1051
 
1052
+ send_out_to_ref_btn.click(
1053
+ fn=send_output_to_reference,
1054
+ inputs=[selected_output_state, output_gallery, reference_images],
1055
  outputs=[reference_images],
1056
+ ).then(fn=on_reference_change, inputs=[reference_images], outputs=[reference_info])
1057
+
1058
+ # ── Bulk tab wiring ──────────────────────────────────────────────────────
1059
+ # Reuses Generate-tab settings as inputs verbatim β€” single source of truth,
1060
+ # no two-way state sync to keep in step.
1061
+ bulk_event = bulk_run_btn.click(
1062
+ fn=bulk_infer,
1063
+ inputs=[bulk_files,
1064
+ prompt, lora_prompt_display, custom_prompt_display, lora_selector,
1065
+ seed, randomize_seed, guidance_scale, steps, upscale_factor,
1066
+ canvas_mode, custom_width, custom_height, canvas_fit_mode, pad_color,
1067
+ dynamic_loras_state] + weight_sliders,
1068
+ outputs=[bulk_gallery, bulk_status, bulk_zip],
1069
+ )
1070
+
1071
+ # Stop cancels both running generators; the in-flight GPU call still
1072
+ # finishes (ZeroGPU can't be killed mid-step), but no further iterations
1073
+ # start. Any already-saved outputs remain in the gallery and on disk.
1074
+ bulk_stop_btn.click(fn=lambda: gr.Info("Stop requested β€” finishing current image."),
1075
+ cancels=[bulk_event, run_event])
1076
+
1077
+ # ── Depth / Pose tab wiring ──────────────────────────────────────────────
1078
+
1079
+ # Cache the last successfully-loaded source so re-renders after edits
1080
+ # don't need the user to keep the upload widget populated.
1081
+ ctrl_source.change(
1082
+ fn=lambda img: img, inputs=[ctrl_source], outputs=[pose_source_state],
1083
+ )
1084
+
1085
+ # Depth generation
1086
+ detect_depth_btn.click(
1087
+ fn=generate_depthmap, inputs=[ctrl_source], outputs=[depth_output],
1088
  )
1089
 
1090
+ # Pose detection β†’ fills state, dropdowns, both preview images.
1091
+ def _on_detect_pose(source):
1092
+ if source is None:
1093
+ raise gr.Error("Upload a source image first.")
1094
+ poses, w, h = detect_pose(source)
1095
+ if not poses:
1096
+ gr.Warning("No people detected β€” try 'Insert blank skeleton template' "
1097
+ "or a different image.")
1098
+ return ([], gr.update(choices=[], value=None),
1099
+ gr.update(value=None), None, None)
1100
+ return (
1101
+ poses,
1102
+ gr.update(choices=person_choices(poses), value="Person 1"),
1103
+ gr.update(value=OPENPOSE_KEYPOINT_NAMES[0]),
1104
+ render_pose_overlay(source, poses, 0, 0),
1105
+ render_pose_skeleton(poses, w, h),
1106
+ )
1107
+
1108
+ detect_pose_btn.click(
1109
+ fn=_on_detect_pose, inputs=[ctrl_source],
1110
+ outputs=[pose_keypoints_state, active_person_dd, active_joint_dd,
1111
+ pose_overlay, pose_clean],
1112
+ )
1113
+ reset_pose_btn.click( # same handler β€” re-runs detection
1114
+ fn=_on_detect_pose, inputs=[ctrl_source],
1115
+ outputs=[pose_keypoints_state, active_person_dd, active_joint_dd,
1116
+ pose_overlay, pose_clean],
1117
+ )
1118
+
1119
+ # Insert a default standing-figure template centred in the source canvas.
1120
+ def _on_insert_blank(source):
1121
+ if source is None:
1122
+ raise gr.Error("Upload a source image first.")
1123
+ w, h = source.size
1124
+ poses = [default_pose_template(w, h)]
1125
+ return (
1126
+ poses,
1127
+ gr.update(choices=["Person 1"], value="Person 1"),
1128
+ gr.update(value=OPENPOSE_KEYPOINT_NAMES[0]),
1129
+ render_pose_overlay(source, poses, 0, 0),
1130
+ render_pose_skeleton(poses, w, h),
1131
+ )
1132
+
1133
+ insert_blank_btn.click(
1134
+ fn=_on_insert_blank, inputs=[ctrl_source],
1135
+ outputs=[pose_keypoints_state, active_person_dd, active_joint_dd,
1136
+ pose_overlay, pose_clean],
1137
+ )
1138
+
1139
+ # ── Click-to-edit: the heart of the pose editor ──
1140
+ # gr.SelectData on a gr.Image gives .index = (x, y) in image pixels, even
1141
+ # when interactive=False, which is exactly what we need.
1142
+ def _on_overlay_click(evt: gr.SelectData, poses, source, person_label, joint_name):
1143
+ if not poses or source is None or evt is None or evt.index is None:
1144
+ return gr.update(), gr.update(), gr.update()
1145
+ person_idx = parse_person_idx(person_label)
1146
+ joint_idx = joint_name_to_index(joint_name)
1147
+ if person_idx is None or joint_idx < 0:
1148
+ return gr.update(), gr.update(), gr.update()
1149
+ x, y = evt.index
1150
+ w, h = source.size
1151
+ new_poses = move_joint(poses, person_idx, joint_idx, x, y, w, h)
1152
+ return (
1153
+ new_poses,
1154
+ render_pose_overlay(source, new_poses, person_idx, joint_idx),
1155
+ render_pose_skeleton(new_poses, w, h),
1156
+ )
1157
+
1158
+ pose_overlay.select(
1159
+ fn=_on_overlay_click,
1160
+ inputs=[pose_keypoints_state, pose_source_state,
1161
+ active_person_dd, active_joint_dd],
1162
+ outputs=[pose_keypoints_state, pose_overlay, pose_clean],
1163
+ )
1164
+
1165
+ # Changing the active joint or person just re-renders the overlay so the
1166
+ # highlight ring follows β€” keypoints are not mutated.
1167
+ def _on_active_change(poses, source, person_label, joint_name):
1168
+ if not poses or source is None:
1169
+ return gr.update()
1170
+ person_idx = parse_person_idx(person_label) or 0
1171
+ joint_idx = max(joint_name_to_index(joint_name), 0)
1172
+ return render_pose_overlay(source, poses, person_idx, joint_idx)
1173
+
1174
+ active_person_dd.change(
1175
+ fn=_on_active_change,
1176
+ inputs=[pose_keypoints_state, pose_source_state,
1177
+ active_person_dd, active_joint_dd],
1178
+ outputs=[pose_overlay],
1179
+ )
1180
+ active_joint_dd.change(
1181
+ fn=_on_active_change,
1182
+ inputs=[pose_keypoints_state, pose_source_state,
1183
+ active_person_dd, active_joint_dd],
1184
+ outputs=[pose_overlay],
1185
+ )
1186
+
1187
+ # Hide / clear
1188
+ def _on_hide_active(poses, source, person_label, joint_name):
1189
+ person_idx = parse_person_idx(person_label)
1190
+ joint_idx = joint_name_to_index(joint_name)
1191
+ new_poses = hide_joint(poses, person_idx, joint_idx)
1192
+ if source is None:
1193
+ return new_poses, gr.update(), gr.update()
1194
+ w, h = source.size
1195
+ return (new_poses,
1196
+ render_pose_overlay(source, new_poses, person_idx, joint_idx),
1197
+ render_pose_skeleton(new_poses, w, h))
1198
+
1199
+ delete_joint_btn.click(
1200
+ fn=_on_hide_active,
1201
+ inputs=[pose_keypoints_state, pose_source_state,
1202
+ active_person_dd, active_joint_dd],
1203
+ outputs=[pose_keypoints_state, pose_overlay, pose_clean],
1204
+ )
1205
+
1206
+ def _on_clear_all(poses, source):
1207
+ new_poses = clear_all_joints(poses)
1208
+ if source is None:
1209
+ return new_poses, gr.update(), gr.update()
1210
+ w, h = source.size
1211
+ return (new_poses,
1212
+ render_pose_overlay(source, new_poses, None, None),
1213
+ render_pose_skeleton(new_poses, w, h))
1214
+
1215
+ clear_pose_btn.click(
1216
+ fn=_on_clear_all,
1217
+ inputs=[pose_keypoints_state, pose_source_state],
1218
+ outputs=[pose_keypoints_state, pose_overlay, pose_clean],
1219
+ )
1220
+
1221
+ # Send β†’ main tab. We reuse the existing base_image / reference_images
1222
+ # components so users land back on the Generate tab fully wired up.
1223
+ send_depth_ref_btn.click(
1224
+ fn=push_pil_to_reference, inputs=[depth_output, reference_images],
1225
+ outputs=[reference_images],
1226
+ ).then(fn=on_reference_change, inputs=[reference_images], outputs=[reference_info]
1227
+ ).then(fn=lambda: gr.Tabs(selected="tab_generate"), outputs=[main_tabs])
1228
+
1229
+ send_depth_base_btn.click(
1230
+ fn=push_pil_to_base, inputs=[depth_output], outputs=[base_image],
1231
+ ).then(fn=on_base_image_change, inputs=[base_image], outputs=[size_info]
1232
+ ).then(fn=lambda: gr.Tabs(selected="tab_generate"), outputs=[main_tabs])
1233
+
1234
+ send_pose_ref_btn.click(
1235
+ fn=push_pil_to_reference, inputs=[pose_clean, reference_images],
1236
+ outputs=[reference_images],
1237
+ ).then(fn=on_reference_change, inputs=[reference_images], outputs=[reference_info]
1238
+ ).then(fn=lambda: gr.Tabs(selected="tab_generate"), outputs=[main_tabs])
1239
+
1240
+ send_pose_base_btn.click(
1241
+ fn=push_pil_to_base, inputs=[pose_clean], outputs=[base_image],
1242
+ ).then(fn=on_base_image_change, inputs=[base_image], outputs=[size_info]
1243
+ ).then(fn=lambda: gr.Tabs(selected="tab_generate"), outputs=[main_tabs])
1244
+
1245
  if __name__ == "__main__":
1246
+ # Gradio 6.0: theme and css go here, not on Blocks()
1247
+ demo.queue().launch(css=css, theme=orange_red_theme,
1248
+ mcp_server=True, ssr_mode=False, show_error=True)
config.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Environment-derived configuration and global constants.
2
+
3
+ Importing this module has no side effects beyond reading env vars and computing
4
+ a handful of constants. It must not import torch, diffusers, or gradio so it can
5
+ be imported anywhere without dragging in heavy dependencies.
6
+ """
7
+ import os
8
+ import numpy as np
9
+
10
+ # ── Model variant selection ──────────────────────────────────────────────────
11
+ MODEL_VARIANT = os.environ.get("MODEL_VARIANT", "9B")
12
+ if MODEL_VARIANT == "9B-KV":
13
+ _MODEL_REPO = "black-forest-labs/FLUX.2-klein-9b-kv"
14
+ else:
15
+ MODEL_VARIANT = "9B" # normalise any typos back to default
16
+ _MODEL_REPO = "black-forest-labs/FLUX.2-klein-9B"
17
+
18
+ MODEL_REPO = _MODEL_REPO
19
+
20
+ # ── Misc constants ───────────────────────────────────────────────────────────
21
+ MAX_SEED = int(np.iinfo(np.int32).max)
22
+
23
+ # Maximum number of simultaneous LoRA weight sliders to pre-render in the UI.
24
+ MAX_LORA_SLOTS = 6
25
+
26
+ # Logging toggle β€” read once, exposed as a plain bool so other modules don't
27
+ # repeat the env-string-parsing dance.
28
+ ENABLE_LOGGING = os.environ.get("ENABLE_LOGGING", "No").strip().lower() == "yes"
control_tools.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Depth + pose estimation helpers for the Depth/Pose tab.
2
+
3
+ Two heavy models are lazy-loaded on first use, both inside @spaces.GPU
4
+ functions so ZeroGPU schedules them as normal short-lived workloads:
5
+ - Depth-Anything-V2-Small via transformers' depth-estimation pipeline
6
+ - OpenposeDetector (lllyasviel/Annotators) via controlnet_aux
7
+
8
+ Pose keypoints round-trip through a plain JSON-friendly list-of-dicts so they
9
+ serialise cleanly into gr.State β€” that's what makes click-to-edit feasible.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+ from PIL import Image, ImageDraw, ImageEnhance
15
+ import gradio as gr
16
+ import torch
17
+ import spaces
18
+
19
+
20
+ # ── Standard OpenPose body-18 layout (COCO-18 ordering) ─────────────────────
21
+ OPENPOSE_KEYPOINT_NAMES = [
22
+ "nose", "neck",
23
+ "right_shoulder", "right_elbow", "right_wrist",
24
+ "left_shoulder", "left_elbow", "left_wrist",
25
+ "right_hip", "right_knee", "right_ankle",
26
+ "left_hip", "left_knee", "left_ankle",
27
+ "right_eye", "left_eye", "right_ear", "left_ear",
28
+ ]
29
+
30
+ # Bones as 0-indexed (a, b) pairs into OPENPOSE_KEYPOINT_NAMES. Mirrors the
31
+ # OpenPose `limbSeq` constant so ControlNet/RefControl-Pose readers see the
32
+ # exact skeleton topology they expect.
33
+ SKELETON_EDGES = [
34
+ (1, 2), (1, 5), (2, 3), (3, 4), (5, 6), (6, 7),
35
+ (1, 8), (8, 9), (9, 10), (1, 11), (11, 12), (12, 13),
36
+ (1, 0), (0, 14), (14, 16), (0, 15), (15, 17),
37
+ ]
38
+
39
+ # OpenPose-canonical 18-joint colour table (used for both bones and dots).
40
+ JOINT_COLORS = [
41
+ (255, 0, 0), (255, 85, 0), (255, 170, 0), (255, 255, 0), (170, 255, 0),
42
+ (85, 255, 0), (0, 255, 0), (0, 255, 85), (0, 255, 170), (0, 255, 255),
43
+ (0, 170, 255), (0, 85, 255), (0, 0, 255), (85, 0, 255), (170, 0, 255),
44
+ (255, 0, 255), (255, 0, 170), (255, 0, 85),
45
+ ]
46
+
47
+
48
+ # ── Lazy model loaders ───────────────────────────────────────────────────────
49
+
50
+ _depth_pipeline = None
51
+ _pose_detector = None
52
+
53
+
54
+ def _load_depth_pipeline():
55
+ """Loaded inside the first @spaces.GPU call, then cached for the rest of
56
+ the worker's lifetime. Avoids paying the cold-start cost for users who
57
+ only ever use depth or only ever use pose."""
58
+ global _depth_pipeline
59
+ if _depth_pipeline is None:
60
+ from transformers import pipeline
61
+ _depth_pipeline = pipeline(
62
+ "depth-estimation",
63
+ model="depth-anything/Depth-Anything-V2-Small-hf",
64
+ device=0 if torch.cuda.is_available() else -1,
65
+ )
66
+ return _depth_pipeline
67
+
68
+
69
+ def _load_pose_detector():
70
+ global _pose_detector
71
+ if _pose_detector is None:
72
+ try:
73
+ from controlnet_aux import OpenposeDetector
74
+ except ImportError:
75
+ raise gr.Error(
76
+ "controlnet_aux is not installed. Add `controlnet_aux` to requirements.txt."
77
+ )
78
+ _pose_detector = OpenposeDetector.from_pretrained("lllyasviel/Annotators")
79
+ if torch.cuda.is_available():
80
+ _pose_detector = _pose_detector.to("cuda")
81
+ return _pose_detector
82
+
83
+
84
+ # ── Depth ────────────────────────────────────────────────────────────────────
85
+
86
+ @spaces.GPU
87
+ def generate_depthmap(image: Image.Image) -> Image.Image:
88
+ if image is None:
89
+ raise gr.Error("Upload a source image first.")
90
+ pipe = _load_depth_pipeline()
91
+ depth = pipe(image.convert("RGB"))["depth"] # PIL L
92
+ return depth.convert("RGB")
93
+
94
+
95
+ # ── Pose detection β†’ editable keypoint list ─────────────────────────────────
96
+
97
+ @spaces.GPU
98
+ def detect_pose(image: Image.Image) -> tuple[list[list[dict]], int, int]:
99
+ """Run pose detection and return:
100
+ - poses : list of dicts, one per detected person. Each pose is a list of
101
+ 18 entries {"name", "x", "y", "visible"} β€” in *pixel* coords
102
+ (not normalised), so the editor can pass click positions in
103
+ directly without a coordinate transform.
104
+ - w, h : source image dimensions.
105
+ """
106
+ if image is None:
107
+ raise gr.Error("Upload a source image first.")
108
+ img = image.convert("RGB")
109
+ w, h = img.size
110
+ detector = _load_pose_detector()
111
+ try:
112
+ poses_raw = detector.detect_poses(np.array(img))
113
+ except Exception as e:
114
+ raise gr.Error(f"Pose detection failed: {e}")
115
+
116
+ out = []
117
+ for pose in poses_raw:
118
+ body_kps = pose.body.keypoints if pose.body else [None] * 18
119
+ person = []
120
+ for i in range(18):
121
+ kp = body_kps[i] if i < len(body_kps) else None
122
+ name = OPENPOSE_KEYPOINT_NAMES[i]
123
+ # controlnet_aux returns normalised (x, y) ∈ [0, 1] with a score.
124
+ # Drop low-confidence detections so the editor starts clean.
125
+ if kp is None or (getattr(kp, "score", 1.0) or 0) < 0.3:
126
+ person.append({"name": name, "x": 0.0, "y": 0.0, "visible": False})
127
+ else:
128
+ person.append({
129
+ "name": name,
130
+ "x": float(kp.x) * w,
131
+ "y": float(kp.y) * h,
132
+ "visible": True,
133
+ })
134
+ out.append(person)
135
+ return out, w, h
136
+
137
+
138
+ # ── Rendering ───────────────────────────────────────────────────────────────
139
+
140
+ def render_pose_skeleton(poses: list[list[dict]], width: int, height: int) -> Image.Image:
141
+ """Skeleton on a black canvas β€” this is what ControlNet/RefControl-Pose wants."""
142
+ canvas = Image.new("RGB", (width, height), (0, 0, 0))
143
+ draw = ImageDraw.Draw(canvas)
144
+ for pose in poses:
145
+ for a, b in SKELETON_EDGES:
146
+ ka, kb = pose[a], pose[b]
147
+ if not (ka["visible"] and kb["visible"]):
148
+ continue
149
+ draw.line([ka["x"], ka["y"], kb["x"], kb["y"]],
150
+ fill=JOINT_COLORS[a], width=4)
151
+ for i, kp in enumerate(pose):
152
+ if not kp["visible"]:
153
+ continue
154
+ r = 4
155
+ draw.ellipse([kp["x"] - r, kp["y"] - r, kp["x"] + r, kp["y"] + r],
156
+ fill=JOINT_COLORS[i])
157
+ return canvas
158
+
159
+
160
+ def render_pose_overlay(
161
+ source: Image.Image,
162
+ poses: list[list[dict]],
163
+ active_person: int | None = None,
164
+ active_joint: int | None = None,
165
+ ) -> Image.Image | None:
166
+ """Skeleton on top of a dimmed source β€” the editing view. The active joint
167
+ gets a white-ringed highlight so the user can see what their next click
168
+ will move."""
169
+ if source is None:
170
+ return None
171
+ w, h = source.size
172
+ base = ImageEnhance.Brightness(source.convert("RGB")).enhance(0.45)
173
+ skel = render_pose_skeleton(poses, w, h)
174
+ np_base, np_skel = np.array(base), np.array(skel)
175
+ mask = np_skel.any(axis=2)
176
+ np_base[mask] = np_skel[mask]
177
+ out = Image.fromarray(np_base)
178
+
179
+ if (active_person is not None and 0 <= active_person < len(poses)
180
+ and active_joint is not None and 0 <= active_joint < 18):
181
+ kp = poses[active_person][active_joint]
182
+ if kp["visible"]:
183
+ d = ImageDraw.Draw(out)
184
+ x, y = kp["x"], kp["y"]
185
+ # Double ring so it stays visible on any background colour
186
+ for r, col in [(10, (255, 255, 255)), (7, (0, 0, 0))]:
187
+ d.ellipse([x - r, y - r, x + r, y + r], outline=col, width=2)
188
+ return out
189
+
190
+
191
+ # ── Edit operations ─────────────────────────────────────────────────────────
192
+
193
+ def move_joint(poses, person_idx, joint_idx, x, y, width, height):
194
+ """Move (and make visible) a joint. Clamped to image bounds so clicks just
195
+ outside the canvas don't fly off."""
196
+ if (not poses or person_idx is None or joint_idx is None
197
+ or not (0 <= person_idx < len(poses) and 0 <= joint_idx < 18)):
198
+ return poses
199
+ x = float(max(0, min(x, width - 1)))
200
+ y = float(max(0, min(y, height - 1)))
201
+ new = [list(p) for p in poses]
202
+ new[person_idx][joint_idx] = {
203
+ **new[person_idx][joint_idx], "x": x, "y": y, "visible": True,
204
+ }
205
+ return new
206
+
207
+
208
+ def hide_joint(poses, person_idx, joint_idx):
209
+ if (not poses or person_idx is None or joint_idx is None
210
+ or not (0 <= person_idx < len(poses) and 0 <= joint_idx < 18)):
211
+ return poses
212
+ new = [list(p) for p in poses]
213
+ new[person_idx][joint_idx] = {**new[person_idx][joint_idx], "visible": False}
214
+ return new
215
+
216
+
217
+ def clear_all_joints(poses):
218
+ return [[{**kp, "visible": False} for kp in pose] for pose in (poses or [])]
219
+
220
+
221
+ def default_pose_template(width: int, height: int) -> list[dict]:
222
+ """Standing-figure skeleton centred in the canvas β€” handy when detection
223
+ misses everyone, or when you want to draw a pose from scratch."""
224
+ cx = width / 2
225
+ s = min(width, height) * 0.40
226
+ top = height / 2 - s
227
+ def y(f): return top + f * (2 * s)
228
+ return [
229
+ {"name": "nose", "x": cx, "y": y(0.05), "visible": True},
230
+ {"name": "neck", "x": cx, "y": y(0.15), "visible": True},
231
+ {"name": "right_shoulder", "x": cx + s * 0.18, "y": y(0.18), "visible": True},
232
+ {"name": "right_elbow", "x": cx + s * 0.25, "y": y(0.35), "visible": True},
233
+ {"name": "right_wrist", "x": cx + s * 0.30, "y": y(0.50), "visible": True},
234
+ {"name": "left_shoulder", "x": cx - s * 0.18, "y": y(0.18), "visible": True},
235
+ {"name": "left_elbow", "x": cx - s * 0.25, "y": y(0.35), "visible": True},
236
+ {"name": "left_wrist", "x": cx - s * 0.30, "y": y(0.50), "visible": True},
237
+ {"name": "right_hip", "x": cx + s * 0.12, "y": y(0.55), "visible": True},
238
+ {"name": "right_knee", "x": cx + s * 0.13, "y": y(0.75), "visible": True},
239
+ {"name": "right_ankle", "x": cx + s * 0.14, "y": y(0.95), "visible": True},
240
+ {"name": "left_hip", "x": cx - s * 0.12, "y": y(0.55), "visible": True},
241
+ {"name": "left_knee", "x": cx - s * 0.13, "y": y(0.75), "visible": True},
242
+ {"name": "left_ankle", "x": cx - s * 0.14, "y": y(0.95), "visible": True},
243
+ {"name": "right_eye", "x": cx + s * 0.025, "y": y(0.03), "visible": True},
244
+ {"name": "left_eye", "x": cx - s * 0.025, "y": y(0.03), "visible": True},
245
+ {"name": "right_ear", "x": cx + s * 0.05, "y": y(0.05), "visible": True},
246
+ {"name": "left_ear", "x": cx - s * 0.05, "y": y(0.05), "visible": True},
247
+ ]
248
+
249
+
250
+ # ── Dropdown helpers ─────────────────────────────────────────────────────────
251
+
252
+ def person_choices(poses):
253
+ return [f"Person {i+1}" for i in range(len(poses or []))]
254
+
255
+ def parse_person_idx(label: str | None) -> int | None:
256
+ if not label:
257
+ return None
258
+ try:
259
+ return int(label.replace("Person ", "")) - 1
260
+ except ValueError:
261
+ return None
262
+
263
+ def joint_name_to_index(name: str | None) -> int:
264
+ if not name:
265
+ return -1
266
+ try:
267
+ return OPENPOSE_KEYPOINT_NAMES.index(name)
268
+ except ValueError:
269
+ return -1
image_utils.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure image helpers β€” no torch, no diffusers, no gradio state.
2
+
3
+ Owns: EXIF handling, dimension snapping, canvas fitting, editor-composite
4
+ extraction, HEIC decoding, PNG metadata embedding.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import tempfile
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ from PIL import Image, ImageOps, ImageFilter
14
+ from PIL.PngImagePlugin import PngInfo
15
+ import gradio as gr
16
+
17
+ # ── HEIC / HEIF support ──────────────────────────────────────────────────────
18
+ try:
19
+ from pillow_heif import register_heif_opener
20
+ register_heif_opener()
21
+ except ImportError:
22
+ print("pillow-heif not installed β€” HEIC/HEIF uploads will not work. "
23
+ "Add `pillow-heif` to requirements.txt.")
24
+
25
+
26
+ # ── EXIF / dimension helpers ─────────────────────────────────────────────────
27
+
28
+ def fix_orientation(img: Image.Image | None) -> Image.Image | None:
29
+ if img is None:
30
+ return None
31
+ return ImageOps.exif_transpose(img)
32
+
33
+
34
+ def _snap16(v: float) -> int:
35
+ """Snap to a multiple of 16 β€” required by FLUX's VAE."""
36
+ return max(16, (int(v) // 16) * 16)
37
+
38
+
39
+ def compute_base_dimensions(image: Image.Image | None) -> tuple[int, int]:
40
+ if image is None:
41
+ return 1024, 1024
42
+ w, h = image.size
43
+ scale = min(1024 / w, 1024 / h)
44
+ return _snap16(w * scale), _snap16(h * scale)
45
+
46
+
47
+ update_dimensions_on_upload = compute_base_dimensions
48
+
49
+
50
+ def compute_canvas_dimensions(
51
+ base_image: Image.Image | None,
52
+ canvas_mode: str,
53
+ custom_width: int,
54
+ custom_height: int,
55
+ ) -> tuple[int, int]:
56
+ if canvas_mode == "Custom":
57
+ return _snap16(custom_width), _snap16(custom_height)
58
+ return compute_base_dimensions(base_image)
59
+
60
+
61
+ # ── Canvas fitting ──────────────────────────────────────────────────────────
62
+
63
+ def fit_to_canvas(
64
+ img: Image.Image,
65
+ width: int,
66
+ height: int,
67
+ mode: str = "Stretch",
68
+ pad_color: str = "#000000",
69
+ ) -> Image.Image:
70
+ """Return `img` resized to exactly widthΓ—height using the given strategy.
71
+
72
+ Modes:
73
+ - "Stretch" : resize ignoring aspect (current default, may distort)
74
+ - "Pad (color)" : scale to fit, pad with `pad_color`
75
+ - "Pad (blur)" : scale to fit, pad with a blurred cover of the image
76
+ - "Crop (cover)" : scale to cover, center-crop to canvas
77
+ """
78
+ img = img.convert("RGB")
79
+
80
+ if mode == "Stretch":
81
+ return img.resize((width, height), Image.LANCZOS)
82
+
83
+ iw, ih = img.size
84
+
85
+ if mode == "Pad (color)":
86
+ scale = min(width / iw, height / ih)
87
+ nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale))
88
+ resized = img.resize((nw, nh), Image.LANCZOS)
89
+ canvas = Image.new("RGB", (width, height), pad_color)
90
+ canvas.paste(resized, ((width - nw) // 2, (height - nh) // 2))
91
+ return canvas
92
+
93
+ if mode == "Pad (blur)":
94
+ # Foreground: scale-to-fit
95
+ scale = min(width / iw, height / ih)
96
+ nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale))
97
+ fg = img.resize((nw, nh), Image.LANCZOS)
98
+ # Background: scale-to-cover, center-crop, then blur heavily
99
+ cscale = max(width / iw, height / ih)
100
+ cw, ch = max(1, int(iw * cscale)), max(1, int(ih * cscale))
101
+ bg = img.resize((cw, ch), Image.LANCZOS)
102
+ bg = bg.crop(((cw - width) // 2, (ch - height) // 2,
103
+ (cw - width) // 2 + width, (ch - height) // 2 + height))
104
+ bg = bg.filter(ImageFilter.GaussianBlur(radius=32))
105
+ bg.paste(fg, ((width - nw) // 2, (height - nh) // 2))
106
+ return bg
107
+
108
+ if mode == "Crop (cover)":
109
+ cscale = max(width / iw, height / ih)
110
+ nw, nh = max(1, int(iw * cscale)), max(1, int(ih * cscale))
111
+ resized = img.resize((nw, nh), Image.LANCZOS)
112
+ left = (nw - width) // 2
113
+ top = (nh - height) // 2
114
+ return resized.crop((left, top, left + width, top + height))
115
+
116
+ # Unknown mode β†’ fall back to stretch rather than erroring during inference
117
+ print(f"[fit_to_canvas] unknown mode {mode!r} β€” falling back to Stretch.")
118
+ return img.resize((width, height), Image.LANCZOS)
119
+
120
+
121
+ # ── UI label updates ────────────────────────────────────────────────────────
122
+
123
+ def on_base_image_change(img) -> str:
124
+ if img is None:
125
+ return "*No base image uploaded yet*"
126
+ try:
127
+ pil_img = img if isinstance(img, Image.Image) else Image.open(img)
128
+ ow, oh = pil_img.size
129
+ bw, bh = compute_base_dimensions(pil_img)
130
+ return (
131
+ f"Input: **{ow} Γ— {oh}** px β†’ "
132
+ f"Auto canvas (pre-upscale): **{bw} Γ— {bh}** px"
133
+ )
134
+ except Exception as e:
135
+ return f"*Could not read dimensions: {e}*"
136
+
137
+
138
+ def on_reference_change(images) -> str:
139
+ if not images:
140
+ return "πŸ“· No reference images"
141
+ count = len(images)
142
+ return f"πŸ“· {count} reference image{'s' if count != 1 else ''} uploaded"
143
+
144
+
145
+ # ── Upload round-trip (fixes HEIC preview in main tab) ──────────────────────
146
+
147
+ def reencode_upload(img):
148
+ if img is None:
149
+ return None
150
+ if not isinstance(img, Image.Image):
151
+ try:
152
+ img = Image.open(img)
153
+ except Exception:
154
+ return img
155
+ return fix_orientation(img).convert("RGB")
156
+
157
+
158
+ # ── Inference input assembly ────────────────────────────────────────────────
159
+
160
+ def process_images(base_image, reference_images) -> list[Image.Image]:
161
+ pil_images: list[Image.Image] = []
162
+ if base_image is not None:
163
+ try:
164
+ img = base_image if isinstance(base_image, Image.Image) else Image.open(base_image)
165
+ pil_images.append(fix_orientation(img).convert("RGB"))
166
+ except Exception as e:
167
+ print(f"Skipping invalid base image: {e}")
168
+ for item in (reference_images or []):
169
+ try:
170
+ path_or_img = item[0] if isinstance(item, (tuple, list)) else item
171
+ if isinstance(path_or_img, Image.Image):
172
+ img = path_or_img
173
+ elif isinstance(path_or_img, str):
174
+ img = Image.open(path_or_img)
175
+ else:
176
+ img = Image.open(path_or_img.name)
177
+ pil_images.append(fix_orientation(img).convert("RGB"))
178
+ except Exception as e:
179
+ print(f"Skipping invalid reference image: {e}")
180
+ return pil_images
181
+
182
+
183
+ # ── ImageEditor helpers ─────────────────────────────────────────────────────
184
+
185
+ def _editor_composite(editor_value) -> Image.Image:
186
+ if not editor_value or editor_value.get("composite") is None:
187
+ raise gr.Error("Upload and crop an image in the editor first.")
188
+ composite = editor_value["composite"]
189
+ if isinstance(composite, np.ndarray):
190
+ composite = Image.fromarray(composite)
191
+ return composite.convert("RGB")
192
+
193
+
194
+ def send_editor_to_base(editor_value) -> Image.Image:
195
+ composite = fix_orientation(_editor_composite(editor_value))
196
+ gr.Info("Sent to Base Image")
197
+ return composite
198
+
199
+
200
+ def send_editor_to_reference(editor_value, current_gallery) -> list:
201
+ composite = fix_orientation(_editor_composite(editor_value))
202
+ current = list(current_gallery or [])
203
+ current.append(composite)
204
+ gr.Info("Added to Reference Images")
205
+ return current
206
+
207
+
208
+ def load_heic_to_editor(path):
209
+ if not path:
210
+ return gr.update()
211
+ try:
212
+ img = fix_orientation(Image.open(path)).convert("RGB")
213
+ except Exception as e:
214
+ raise gr.Error(f"Could not decode HEIC/HEIF: {e}")
215
+ gr.Info("HEIC loaded into editor.")
216
+ return img
217
+
218
+
219
+ # ── Send output β†’ base / reference (gallery-aware) ──────────────────────────
220
+
221
+ def _resolve_gallery_path(selected_path, gallery_value):
222
+ """Pick the path the Send-to-* buttons should use.
223
+
224
+ Prefers the user's currently-selected gallery item; falls back to the most
225
+ recent (last) item so single-image runs and "didn't click anything" cases
226
+ both behave intuitively.
227
+ """
228
+ if selected_path:
229
+ return selected_path
230
+ if not gallery_value:
231
+ return None
232
+ item = gallery_value[-1]
233
+ return item[0] if isinstance(item, (list, tuple)) else item
234
+
235
+
236
+ def send_output_to_base(selected_path, gallery_value):
237
+ path = _resolve_gallery_path(selected_path, gallery_value)
238
+ if not path:
239
+ raise gr.Error("Nothing to send β€” generate an image first.")
240
+ img = Image.open(path).convert("RGB")
241
+ gr.Info("Output sent to Base Image.")
242
+ return img
243
+
244
+
245
+ def send_output_to_reference(selected_path, gallery_value, current_gallery):
246
+ path = _resolve_gallery_path(selected_path, gallery_value)
247
+ if not path:
248
+ raise gr.Error("Nothing to send β€” generate an image first.")
249
+ img = Image.open(path).convert("RGB")
250
+ current = list(current_gallery or [])
251
+ current.append(img)
252
+ gr.Info("Output added to Reference Images.")
253
+ return current
254
+
255
+
256
+ # ── PNG metadata embedding ──────────────────────────────────────────────────
257
+
258
+ def _format_parameters_string(meta: dict[str, Any]) -> str:
259
+ prompt = meta.get("prompt", "") or ""
260
+ fields = [
261
+ ("Seed", meta.get("seed")),
262
+ ("Steps", meta.get("steps")),
263
+ ("CFG scale", meta.get("guidance_scale")),
264
+ ("Size", f"{meta.get('width')}x{meta.get('height')}"),
265
+ ("Model", meta.get("model")),
266
+ ("Upscaler", meta.get("upscale_factor")),
267
+ ("Canvas mode", meta.get("canvas_mode")),
268
+ ("Fit mode", meta.get("canvas_fit_mode")),
269
+ ]
270
+ loras = meta.get("loras") or []
271
+ if loras:
272
+ fields.append(("LoRAs", ", ".join(f"{n}:{w:.2f}" for n, w in loras)))
273
+ kv = ", ".join(f"{k}: {v}" for k, v in fields if v not in (None, "", "None"))
274
+ return f"{prompt}\n{kv}".strip()
275
+
276
+
277
+ def build_pnginfo(meta: dict[str, Any]) -> PngInfo:
278
+ """Public so bulk processing can reuse it for in-place saves."""
279
+ info = PngInfo()
280
+ info.add_text("parameters", _format_parameters_string(meta))
281
+ for k in ("prompt", "seed", "steps", "guidance_scale", "width", "height",
282
+ "model", "upscale_factor", "canvas_mode", "canvas_fit_mode",
283
+ "lora_prompt", "custom_prompt"):
284
+ info.add_text(k, str(meta.get(k, "")))
285
+ info.add_text("loras", json.dumps(meta.get("loras") or []))
286
+ return info
287
+
288
+
289
+ def save_with_metadata(image: Image.Image, meta: dict[str, Any],
290
+ path: str | None = None) -> str:
291
+ """Save `image` as PNG with embedded generation metadata.
292
+
293
+ If `path` is given, write there (used by bulk-process to keep predictable
294
+ filenames inside its work directory). Otherwise allocate a temp PNG.
295
+ """
296
+ if path is None:
297
+ tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False, prefix="flux2_klein_")
298
+ tmp.close()
299
+ path = tmp.name
300
+ image.save(path, format="PNG", pnginfo=build_pnginfo(meta))
301
+ return path
302
+
303
+ # ── Send arbitrary PIL β†’ base / reference (used by the Depth/Pose tab) ──────
304
+
305
+ def push_pil_to_base(img):
306
+ if img is None:
307
+ raise gr.Error("Nothing to send β€” generate it first.")
308
+ gr.Info("Sent to Base Image.")
309
+ return img
310
+
311
+
312
+ def push_pil_to_reference(img, current_gallery):
313
+ if img is None:
314
+ raise gr.Error("Nothing to send β€” generate it first.")
315
+ current = list(current_gallery or [])
316
+ current.append(img)
317
+ gr.Info("Added to Reference Images.")
318
+ return current
lora_registry.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LoRA catalog + per-session custom LoRA loader.
2
+
3
+ Two layers of state:
4
+
5
+ * LORA_STYLES (module-level constant) β€” the curated catalog, identical for
6
+ every visitor.
7
+ * dynamic_loras (gr.State, per session) β€” a session-private dict of any
8
+ custom HuggingFace LoRAs the user has added via the UI. Other sessions
9
+ never see them.
10
+
11
+ Adapter loading itself is tracked in LOADED_ADAPTERS, which is a *legitimate*
12
+ module-level set: it mirrors what's been attached to the shared pipe object
13
+ in generation.py so we don't re-download/re-attach the same weights twice.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import uuid
18
+
19
+ import gradio as gr
20
+
21
+ from config import MAX_LORA_SLOTS
22
+
23
+
24
+ FACE_SWAP_PROMPT = """head_swap: start with Picture 1 as the base image, keeping its lighting, environment, and background. Remove the head from Picture 1 completely and replace it with the head from Picture 2.
25
+ FROM PICTURE 1 (strictly preserve):
26
+ - Scene: lighting conditions, shadows, highlights, color temperature, environment, background
27
+ - Head positioning: exact rotation angle, tilt, direction the head is facing
28
+ - Expression: facial expression, micro-expressions, eye gaze direction, mouth position, emotion
29
+ FROM PICTURE 2 (strictly preserve identity):
30
+ - Facial structure: face shape, bone structure, jawline, chin
31
+ - All facial features: eye color, eye shape, nose structure, lip shape and fullness, eyebrows
32
+ - Hair: color, style, texture, hairline
33
+ - Skin: texture, tone, complexion
34
+ The replaced head must seamlessly match Picture 1's lighting and expression while maintaining the complete identity from Picture 2. High quality, photorealistic, sharp details, 4k."""
35
+
36
+ # MAX number of simultaneous LoRA weight sliders to pre-render in the UI
37
+ MAX_LORA_SLOTS = 6
38
+
39
+ LORA_STYLES = [
40
+ {
41
+ "image": "https://huggingface.co/spaces/prithivMLmods/FLUX.2-Klein-LoRA-Studio/resolve/main/examples/image.webp",
42
+ "title": "None",
43
+ "adapter_name": None,
44
+ "repo": None,
45
+ "weights": None,
46
+ "default_prompt": None,
47
+ "default_weight": 1.0,
48
+ },
49
+ {
50
+ "title": "Klein-Delight-Style",
51
+ "adapter_name": "klein-delight",
52
+ "repo": "linoyts/Flux2-Klein-Delight-LoRA",
53
+ "weights": "pytorch_lora_weights.safetensors",
54
+ "default_prompt": "Relight the image to remove all existing lighting conditions and replace them with neutral, uniform illumination. Apply soft, evenly distributed lighting with no directional shadows, no harsh highlights, and no dramatic contrast. Maintain the original identity of all subjects exactlyβ€”preserve facial structure, skin tone, proportions, expressions, hair, clothing, and textures. Do not alter pose, camera angle, background geometry, or image composition. Lighting should appear balanced, and studio-neutral, similar to diffuse overcast or a soft lightbox setup. Ensure consistent exposure across the entire image with realistic depth and subtle shading only where necessary for form.",
55
+ "default_weight": 1.0,
56
+ },
57
+ {
58
+ "title": "Klein-Consistency",
59
+ "adapter_name": "klein-consistency",
60
+ "repo": "dx8152/Flux2-Klein-9B-Consistency",
61
+ "weights": "Klein-consistency.safetensors",
62
+ "default_prompt": None,
63
+ "default_weight": 0.3,
64
+ },
65
+ {
66
+ "title": "Best-Face-Swap",
67
+ "adapter_name": "face-swap",
68
+ "repo": "Alissonerdx/BFS-Best-Face-Swap",
69
+ "weights": "bfs_head_v1_flux-klein_9b_step3750_rank64.safetensors",
70
+ "default_prompt": FACE_SWAP_PROMPT,
71
+ "default_weight": 1.0,
72
+ },
73
+ {
74
+ "title": "NSFW v2",
75
+ "adapter_name": "nsfw-v2",
76
+ "repo": "diroverflo/FLux_Klein_9B_NSFW",
77
+ "weights": "Flux Klein - NSFW v2.safetensors",
78
+ "default_prompt": None,
79
+ "default_weight": 1.0,
80
+ },
81
+ {
82
+ "title": "Ultimate Upscaler Klein-9b",
83
+ "adapter_name": "Ultimate Upscaler",
84
+ "repo": "loras",
85
+ "weights": "Flux2-Klein-Image-RestoreV1.safetensors",
86
+ "default_prompt": "restore the image quality, remove any compression artefacts, remove any haze and soft edges, enrich the original with new intricate detail in all textures and surfaces creating a professional photorealistic photograph with natural lighting and skin texture.",
87
+ "default_weight": 1.0,
88
+ },
89
+ {
90
+ "title": "High Resolution",
91
+ "adapter_name": "High Resolution",
92
+ "repo": "loras",
93
+ "weights": "HighResolution9B.safetensors",
94
+ "default_prompt": "High Resolution",
95
+ "default_weight": 1.0,
96
+ },
97
+ {
98
+ "title": "Female Asshole",
99
+ "adapter_name": "Female Asshole",
100
+ "repo": "loras",
101
+ "weights": "femaleasshole-f2-klein-9b.safetensors",
102
+ "default_prompt": None,
103
+ "default_weight": 1.0,
104
+ },
105
+ {
106
+ "title": "Realistic Nudes",
107
+ "adapter_name": "Realistic Nudes",
108
+ "repo": "loras",
109
+ "weights": "realistic_nudes_klein_v3.safetensors",
110
+ "default_prompt": None,
111
+ "default_weight": 1.0,
112
+ },
113
+ {
114
+ "title": "Perky Pointy Puffy Breasts",
115
+ "adapter_name": "Perky Pointy Puffy Breasts",
116
+ "repo": "loras",
117
+ "weights": "PerkyPointyPuffy_v1.1_small_pointy_breasts_large_puffy_nipples.safetensors",
118
+ "default_prompt": "Small pointy breasts with large puffy nipples",
119
+ "default_weight": 1.0,
120
+ },
121
+ {
122
+ "title": "Flat Chested",
123
+ "adapter_name": "Flat Chested",
124
+ "repo": "loras",
125
+ "weights": "Flux2-Klein-9b-FlatChested-v1.safetensors",
126
+ "default_prompt": "flat chested",
127
+ "default_weight": 1.5,
128
+ },
129
+ {
130
+ "title": "Controllight",
131
+ "adapter_name": "Controllight",
132
+ "repo": "ControlLight/ControlLight",
133
+ "weights": "controllight.safetensors",
134
+ "default_prompt": None,
135
+ "default_weight": 1.0,
136
+ },
137
+ {
138
+ "title": "RefControl - Depth",
139
+ "adapter_name": "RefConDep",
140
+ "repo": "thedeoxen/refcontrol-FLUX.2-klein-9B-reference-depth-lora",
141
+ "weights": "flux2_klein_9b_refcontrol_depth.safetensors",
142
+ "default_prompt": "refcontrol",
143
+ "default_weight": 1.0,
144
+ },
145
+ {
146
+ "title": "RefControl - Pose",
147
+ "adapter_name": "RefConPos",
148
+ "repo": "thedeoxen/refcontrol-FLUX.2-klein-9B-reference-pose-lora",
149
+ "weights": "refcontrol_v2_poses.safetensors",
150
+ "default_prompt": "apply pose from image 1 with reference from image 2",
151
+ "default_weight": 1.0,
152
+ },
153
+ ]
154
+
155
+
156
+
157
+ # LOADED_ADAPTERS is the only piece of LoRA state that's legitimately global:
158
+ # it just tracks which adapter names have been loaded onto the shared `pipe`
159
+ # at least once, so we don't re-download/re-attach weights on every call.
160
+ # It is NOT used to decide what a user can select β€” that comes from each
161
+ # session's own `dynamic_loras_state` (a gr.State dict), so one user's custom
162
+ # HuggingFace LoRA never shows up in another user's checklist.
163
+ LOADED_ADAPTERS: set[str] = set()
164
+
165
+
166
+ # ── Catalog lookup helpers ────────────────────────────────────────────────────
167
+
168
+ def get_all_styles(dynamic_loras):
169
+ all_styles = list(LORA_STYLES)
170
+ for dynamic_lora in (dynamic_loras or {}).values():
171
+ all_styles.append(dynamic_lora)
172
+ return all_styles
173
+
174
+
175
+ def get_selectable_styles(dynamic_loras):
176
+ """Return all styles except 'None' for the checkbox selector."""
177
+ return [s for s in get_all_styles(dynamic_loras) if s["adapter_name"] is not None]
178
+
179
+
180
+ def get_style_by_title(title, dynamic_loras):
181
+ for style in get_all_styles(dynamic_loras):
182
+ if style["title"] == title:
183
+ return style
184
+ return None
185
+
186
+
187
+ def get_style_by_adapter_name(adapter_name, dynamic_loras):
188
+ for style in get_all_styles(dynamic_loras):
189
+ if style["adapter_name"] == adapter_name:
190
+ return style
191
+ return None
192
+
193
+
194
+ # ── LoRA selection β†’ update weight sliders + LoRA prompt display ──────────────
195
+
196
+ def update_weight_sliders(selected_titles, dynamic_loras):
197
+ """Given a list of selected LoRA titles, return updated visibility/label/value
198
+ for each of the MAX_LORA_SLOTS pre-rendered sliders, plus an update for the
199
+ LoRA prompt display box (visible when any selected LoRA has a default prompt).
200
+
201
+ The user's own prompt is never touched here β€” LoRA prompts are shown
202
+ separately and appended at inference time.
203
+ """
204
+ selected_styles = [
205
+ get_style_by_title(t, dynamic_loras)
206
+ for t in (selected_titles or [])
207
+ if get_style_by_title(t, dynamic_loras)
208
+ ]
209
+ slider_updates = []
210
+ for i in range(MAX_LORA_SLOTS):
211
+ if i < len(selected_styles):
212
+ style = selected_styles[i]
213
+ slider_updates.append(gr.update(
214
+ visible=True,
215
+ label=f"{style['title']} β€” weight",
216
+ value=style.get("default_weight", 1.0),
217
+ ))
218
+ else:
219
+ slider_updates.append(gr.update(visible=False, value=1.0))
220
+
221
+ # Collect default prompts from ALL selected LoRAs (not just one)
222
+ lora_prompts = [s.get("default_prompt") for s in selected_styles if s.get("default_prompt")]
223
+ if lora_prompts:
224
+ combined = "\n\n".join(lora_prompts)
225
+ lora_prompt_update = gr.update(value=combined, visible=True)
226
+ else:
227
+ lora_prompt_update = gr.update(value="", visible=False)
228
+
229
+ return slider_updates + [lora_prompt_update]
230
+
231
+
232
+ # ── Dynamic HF LoRA loader (per-session) ─────────────────────────────────────
233
+
234
+ def add_custom_lora(repo_id, weight_name, adapter_name, dynamic_loras_state):
235
+ """Adds a custom LoRA to *this session's* state only β€” other users never see
236
+ it in their selector. The adapter_name attached to the shared pipe is
237
+ still made globally unique (via a short random suffix) since the pipe
238
+ object itself is one shared resource across all sessions.
239
+ """
240
+ dynamic_loras = dict(dynamic_loras_state or {})
241
+ if not repo_id or not repo_id.strip():
242
+ return "Please enter a valid HuggingFace repo ID.", gr.update(), dynamic_loras
243
+ repo_id = repo_id.strip()
244
+ requested_name = adapter_name.strip() if adapter_name and adapter_name.strip() else None
245
+ try:
246
+ from huggingface_hub import model_info
247
+ info = model_info(repo_id)
248
+ actual_weight = weight_name.strip() if weight_name and weight_name.strip() else None
249
+ if not actual_weight:
250
+ for name in ["pytorch_lora_weights.safetensors", "lora.safetensors", "adapter_model.safetensors"]:
251
+ if any(f.filename == name for f in info.siblings):
252
+ actual_weight = name
253
+ break
254
+ if not actual_weight:
255
+ available = [f.filename for f in info.siblings if f.filename.endswith(('.safetensors', '.bin'))]
256
+ return (
257
+ f"No weight found. Available: {', '.join(available) or 'None'}",
258
+ gr.update(),
259
+ dynamic_loras,
260
+ )
261
+
262
+ base_name = (
263
+ "".join(c if c.isalnum() or c in "-_" else "_" for c in requested_name)
264
+ if requested_name else "custom"
265
+ )
266
+
267
+ static_names = {s["adapter_name"] for s in LORA_STYLES if s["adapter_name"]}
268
+ final_adapter_name = f"{base_name}_{uuid.uuid4().hex[:6]}"
269
+ while final_adapter_name in static_names or final_adapter_name in LOADED_ADAPTERS:
270
+ final_adapter_name = f"{base_name}_{uuid.uuid4().hex[:6]}"
271
+
272
+ custom_style = {
273
+ "image": "https://huggingface.co/spaces/prithivMLmods/FLUX.2-Klein-LoRA-Studio/resolve/main/examples/image.webp",
274
+ "title": f"Custom: {base_name}",
275
+ "adapter_name": final_adapter_name,
276
+ "repo": repo_id,
277
+ "weights": actual_weight,
278
+ "default_prompt": None,
279
+ "default_weight": 1.0,
280
+ }
281
+ dynamic_loras[final_adapter_name] = custom_style
282
+ new_choices = [s["title"] for s in get_selectable_styles(dynamic_loras)]
283
+ return f"βœ… Added: {base_name} from {repo_id}", gr.update(choices=new_choices), dynamic_loras
284
+ except Exception as e:
285
+ return f"❌ Failed: {str(e)}", gr.update(), dynamic_loras
requirements.txt CHANGED
@@ -1,12 +1,11 @@
1
  git+https://github.com/huggingface/diffusers.git
2
- transformers==4.57.3
3
  huggingface_hub
4
  sentencepiece
5
  torch==2.8.0
6
  bitsandbytes
7
  torchvision
8
  accelerate
9
- kernels # @vllm-flash-attn3
10
  torchao
11
  spaces
12
  hf_xet
@@ -15,4 +14,6 @@ numpy
15
  peft
16
  av
17
  spandrel
18
- pyarrow
 
 
 
1
  git+https://github.com/huggingface/diffusers.git
 
2
  huggingface_hub
3
  sentencepiece
4
  torch==2.8.0
5
  bitsandbytes
6
  torchvision
7
  accelerate
8
+ kernels<0.10
9
  torchao
10
  spaces
11
  hf_xet
 
14
  peft
15
  av
16
  spandrel
17
+ pillow-heif
18
+ pyarrow
19
+ controlnet-aux
ui_theme.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom Gradio theme β€” extracted verbatim from the original app.py."""
2
+ from typing import Iterable
3
+
4
+ from gradio.themes import Soft
5
+ from gradio.themes.utils import colors, fonts, sizes
6
+
7
+
8
+ # Register a custom colour palette on the gradio colours module so it can be
9
+ # referenced by name elsewhere (matching the original code).
10
+ colors.orange_red = colors.Color(
11
+ name="orange_red",
12
+ c50="#FFF0E5", c100="#FFE0CC", c200="#FFC299", c300="#FFA366",
13
+ c400="#FF8533", c500="#FF4500", c600="#E63E00", c700="#CC3700",
14
+ c800="#B33000", c900="#992900", c950="#802200",
15
+ )
16
+
17
+
18
+ class OrangeRedTheme(Soft):
19
+ def __init__(
20
+ self,
21
+ *,
22
+ primary_hue: colors.Color | str = colors.gray,
23
+ secondary_hue: colors.Color | str = colors.orange_red,
24
+ neutral_hue: colors.Color | str = colors.slate,
25
+ text_size: sizes.Size | str = sizes.text_lg,
26
+ font: fonts.Font | str | Iterable[fonts.Font | str] = (
27
+ fonts.GoogleFont("Outfit"), "Arial", "sans-serif",
28
+ ),
29
+ font_mono: fonts.Font | str | Iterable[fonts.Font | str] = (
30
+ fonts.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace",
31
+ ),
32
+ ):
33
+ super().__init__(
34
+ primary_hue=primary_hue,
35
+ secondary_hue=secondary_hue,
36
+ neutral_hue=neutral_hue,
37
+ text_size=text_size,
38
+ font=font,
39
+ font_mono=font_mono,
40
+ )
41
+ super().set(
42
+ background_fill_primary="*primary_50",
43
+ background_fill_primary_dark="*primary_900",
44
+ body_background_fill="linear-gradient(135deg, *primary_200, *primary_100)",
45
+ body_background_fill_dark="linear-gradient(135deg, *primary_900, *primary_800)",
46
+ button_primary_text_color="white",
47
+ button_primary_text_color_hover="white",
48
+ button_primary_background_fill="linear-gradient(90deg, *secondary_500, *secondary_600)",
49
+ button_primary_background_fill_hover="linear-gradient(90deg, *secondary_600, *secondary_700)",
50
+ button_primary_background_fill_dark="linear-gradient(90deg, *secondary_600, *secondary_700)",
51
+ button_primary_background_fill_hover_dark="linear-gradient(90deg, *secondary_500, *secondary_600)",
52
+ slider_color="*secondary_500",
53
+ slider_color_dark="*secondary_600",
54
+ block_title_text_weight="600",
55
+ block_border_width="3px",
56
+ block_shadow="*shadow_drop_lg",
57
+ button_primary_shadow="*shadow_drop_lg",
58
+ button_large_padding="11px",
59
+ color_accent_soft="*primary_100",
60
+ block_label_background_fill="*primary_200",
61
+ )
62
+
63
+
64
+ orange_red_theme = OrangeRedTheme()
upscale.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ESRGAN-family upscaler registry + tiled inference.
2
+
3
+ Spandrel handles any ESRGAN-family .pth file β€” to add a model, just append an
4
+ entry to UPSCALE_MODELS with its download URL and integer scale factor.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import gc
9
+ import os
10
+ import urllib.request
11
+
12
+ import numpy as np
13
+ import torch
14
+ import gradio as gr
15
+ from PIL import Image
16
+
17
+
18
+ # ── Upscaler model registry ───────────────────────────────────────────────────
19
+ # Add new entries here to extend the dropdown β€” spandrel handles any ESRGAN-
20
+ # family .pth file automatically. scale= is the integer output multiplier.
21
+ UPSCALE_MODELS = {
22
+ "None": {
23
+ "scale": None, "file": None, "url": None,
24
+ },
25
+ "2Γ— β€” RealESRGAN (balanced)": {
26
+ "scale": 2,
27
+ "file": "RealESRGAN_x2plus.pth",
28
+ "url": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth",
29
+ },
30
+ "4Γ— β€” RealESRGAN (balanced)": {
31
+ "scale": 4,
32
+ "file": "RealESRGAN_x4plus.pth",
33
+ "url": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth",
34
+ },
35
+ "4Γ— β€” UltraSharp (crisp)": {
36
+ "scale": 4,
37
+ "file": "4x-UltraSharpV2.pth",
38
+ "url": "https://huggingface.co/Kim2091/UltraSharpV2/resolve/main/4x-UltraSharpV2.pth",
39
+ },
40
+ "4Γ— β€” Remacri (natural)": {
41
+ "scale": 4,
42
+ "file": "4x_foolhardy_Remacri.pth",
43
+ "url": "https://huggingface.co/FacehugmanIII/4x_foolhardy_Remacri/resolve/main/4x_foolhardy_Remacri.pth",
44
+ },
45
+ "4Γ— β€” Nomos2 HQ DAT2 (Photography)": {
46
+ "scale": 4,
47
+ "file": "4xNomos2_hq_dat2.pth",
48
+ "url": "https://github.com/Phhofm/models/releases/download/4xNomos2_hq_dat2/4xNomos2_hq_dat2.pth",
49
+ },
50
+ }
51
+
52
+
53
+ def _upscale_tiled(
54
+ model_fn,
55
+ img_t: torch.Tensor,
56
+ tile: int = 512,
57
+ overlap: int = 32,
58
+ ) -> torch.Tensor:
59
+ """Run the upscaler model patch-by-patch to cap peak VRAM usage.
60
+
61
+ Overlapping tiles are averaged so there are no seam artifacts. Output is
62
+ assembled on CPU so only one tile lives on GPU at a time.
63
+ """
64
+ _, c, h, w = img_t.shape
65
+
66
+ # Probe output scale with a tiny crop rather than hard-coding it
67
+ with torch.no_grad():
68
+ probe = model_fn(img_t[:, :, :min(4, h), :min(4, w)])
69
+ scale = probe.shape[-1] // min(4, w)
70
+ del probe
71
+ torch.cuda.empty_cache()
72
+
73
+ out_h, out_w = h * scale, w * scale
74
+ canvas = torch.zeros(1, c, out_h, out_w, dtype=torch.float32)
75
+ weights = torch.zeros(1, 1, out_h, out_w, dtype=torch.float32)
76
+
77
+ step = max(tile - overlap, 1)
78
+ # Ensure the last tile always reaches the edge
79
+ ys = list(range(0, max(h - tile, 0), step)) + [max(h - tile, 0)]
80
+ xs = list(range(0, max(w - tile, 0), step)) + [max(w - tile, 0)]
81
+ ys = sorted(set(ys))
82
+ xs = sorted(set(xs))
83
+
84
+ for y0 in ys:
85
+ for x0 in xs:
86
+ y1 = min(y0 + tile, h)
87
+ x1 = min(x0 + tile, w)
88
+ patch = img_t[:, :, y0:y1, x0:x1]
89
+ with torch.no_grad():
90
+ out = model_fn(patch).cpu().float()
91
+ oy0, ox0 = y0 * scale, x0 * scale
92
+ oy1, ox1 = y1 * scale, x1 * scale
93
+ canvas[:, :, oy0:oy1, ox0:ox1] += out
94
+ weights[:, :, oy0:oy1, ox0:ox1] += 1.0
95
+
96
+ return (canvas / weights.clamp(min=1)).clamp(0, 1)
97
+
98
+
99
+ def apply_realesrgan(image: Image.Image, model_key: str, device: torch.device) -> Image.Image:
100
+ """Upscale a PIL image using the model selected in UPSCALE_MODELS.
101
+
102
+ Weights are downloaded once to /tmp/realesrgan_weights/ and reused.
103
+ """
104
+ cfg = UPSCALE_MODELS[model_key]
105
+
106
+ try:
107
+ from spandrel import ImageModelDescriptor, ModelLoader
108
+ except ImportError:
109
+ raise gr.Error("spandrel is not installed. Add 'spandrel' to requirements.txt.")
110
+
111
+ cache_dir = "/tmp/realesrgan_weights"
112
+ os.makedirs(cache_dir, exist_ok=True)
113
+ cache_path = os.path.join(cache_dir, cfg["file"])
114
+
115
+ if not os.path.exists(cache_path):
116
+ print(f"Downloading {cfg['file']}…")
117
+ urllib.request.urlretrieve(cfg["url"], cache_path)
118
+
119
+ model = ModelLoader().load_from_file(cache_path)
120
+ if not isinstance(model, ImageModelDescriptor):
121
+ raise gr.Error(f"Loaded model is not a single-image descriptor: {type(model)}")
122
+
123
+ sr_model = model.model.to(device).eval()
124
+
125
+ img_np = np.array(image).astype(np.float32) / 255.0
126
+ img_t = torch.from_numpy(img_np).permute(2, 0, 1).unsqueeze(0).to(device)
127
+
128
+ out_t = _upscale_tiled(sr_model, img_t, tile=512, overlap=32)
129
+
130
+ # Free upscaler weights immediately β€” FLUX pipeline stays resident
131
+ del sr_model, img_t
132
+ gc.collect()
133
+ torch.cuda.empty_cache()
134
+
135
+ out_np = out_t.squeeze(0).permute(1, 2, 0).numpy()
136
+ return Image.fromarray((out_np * 255).astype(np.uint8))