Spaces:
Runtime error
Runtime error
Commit Β·
3f14c5d
1
Parent(s): d67b4a2
Added new functionalities
Browse filesAdded controlnet-style depthmap and pose generator. Added bulk processing and multi-generations. Added canvas control. Added multiple QoL improvements
- app.py +881 -710
- config.py +28 -0
- control_tools.py +269 -0
- image_utils.py +318 -0
- lora_registry.py +285 -0
- requirements.txt +4 -3
- ui_theme.py +64 -0
- 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
|
| 8 |
-
|
| 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 |
-
|
| 16 |
-
|
| 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"
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 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
|
| 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 |
-
|
| 84 |
-
|
| 85 |
-
"
|
| 86 |
-
"
|
| 87 |
-
|
| 88 |
-
"url": "https://
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
"
|
| 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 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
{
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
{
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 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 |
-
|
| 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
|
| 265 |
-
if
|
| 266 |
-
return
|
| 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 |
-
|
| 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 |
-
|
| 382 |
-
|
| 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(
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
value=style.get("default_weight", 1.0),
|
| 397 |
-
))
|
| 398 |
else:
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 410 |
|
| 411 |
-
# ββ Inference βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 412 |
|
| 413 |
-
def
|
| 414 |
-
"""
|
| 415 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 434 |
-
|
| 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(
|
| 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
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 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
|
| 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 |
-
|
| 609 |
-
|
|
|
|
| 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
|
| 617 |
-
|
| 618 |
-
if
|
| 619 |
-
print(f"Loading adapter: {style['title']}")
|
| 620 |
try:
|
| 621 |
-
pipe.load_lora_weights(
|
| 622 |
-
|
| 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 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 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 |
-
|
| 644 |
-
|
| 645 |
-
print(f"Generating at: {width} Γ {height} px")
|
| 646 |
|
| 647 |
-
|
| 648 |
-
image_input =
|
| 649 |
|
| 650 |
try:
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 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 |
-
|
| 663 |
-
image = pipe(**
|
| 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 |
-
|
| 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
|
| 680 |
-
# ββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββ
|
| 681 |
|
| 682 |
-
gc.collect()
|
| 683 |
-
torch.cuda.empty_cache()
|
| 684 |
return image, seed, width, height
|
| 685 |
|
| 686 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 717 |
-
while
|
| 718 |
-
|
| 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 |
-
"
|
| 724 |
-
"
|
| 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 |
-
|
|
|
|
| 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
|
| 753 |
prompts[name] = text
|
| 754 |
choices = list(prompts.keys())
|
| 755 |
-
return (
|
| 756 |
-
|
| 757 |
-
|
| 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 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 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 |
-
|
| 800 |
-
|
| 801 |
custom_prompts_state = gr.State({})
|
| 802 |
custom_prompt_counter_state = gr.State(0)
|
| 803 |
-
dynamic_loras_state = gr.State({})
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 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,
|
|
|
|
| 883 |
steps = gr.Slider(label="Steps", minimum=1, maximum=50, value=4, step=1)
|
| 884 |
-
upscale_factor = gr.Dropdown(
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 889 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 890 |
|
| 891 |
-
# ββ Right column βββββββββββββββββββββββββββββββββββββββββ
|
| 892 |
with gr.Column(scale=1):
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 900 |
)
|
| 901 |
|
| 902 |
-
#
|
| 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 |
-
|
| 917 |
minimum=0.0, maximum=2.0, step=0.05, value=1.0,
|
| 918 |
-
label=f"LoRA slot {i+1}",
|
| 919 |
-
|
| 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
|
| 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)",
|
|
|
|
| 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 |
-
|
| 954 |
-
placeholder="e.g. Skin detail enhancer",
|
| 955 |
-
scale=1,
|
| 956 |
-
)
|
| 957 |
with gr.Row():
|
| 958 |
-
prompt_text_input = gr.Textbox(
|
| 959 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 982 |
-
"
|
| 983 |
-
"images coming out sideways) is corrected automatically as soon as you upload here."
|
| 984 |
)
|
| 985 |
editor = gr.ImageEditor(
|
| 986 |
-
label="Editor",
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
|
|
|
|
|
|
| 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
|
| 998 |
|
| 999 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1000 |
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
|
|
|
|
|
|
|
|
|
| 1006 |
|
| 1007 |
-
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
|
| 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,
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1044 |
fn=infer,
|
| 1045 |
-
inputs=[base_image, reference_images, prompt, lora_prompt_display, custom_prompt_display,
|
| 1046 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1047 |
)
|
| 1048 |
|
| 1049 |
-
# ββ
|
| 1050 |
-
editor.upload(
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
outputs=[editor],
|
| 1054 |
-
)
|
| 1055 |
|
| 1056 |
-
send_to_base_btn.click(
|
| 1057 |
-
fn=
|
| 1058 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1067 |
-
fn=
|
| 1068 |
-
inputs=[
|
| 1069 |
outputs=[reference_images],
|
| 1070 |
-
).then(
|
| 1071 |
-
|
| 1072 |
-
|
| 1073 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1074 |
)
|
| 1075 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1076 |
if __name__ == "__main__":
|
| 1077 |
-
|
|
|
|
|
|
|
|
|
| 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
|
| 10 |
torchao
|
| 11 |
spaces
|
| 12 |
hf_xet
|
|
@@ -15,4 +14,6 @@ numpy
|
|
| 15 |
peft
|
| 16 |
av
|
| 17 |
spandrel
|
| 18 |
-
|
|
|
|
|
|
|
|
|
| 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))
|