Vlad Iliescu commited on
Commit
a30449a
·
1 Parent(s): fb27a76

add initial lora loading

Browse files
Files changed (3) hide show
  1. app.py +66 -6
  2. lora_utils.py +270 -0
  3. tests/test_lora_utils.py +53 -0
app.py CHANGED
@@ -19,6 +19,7 @@ import torch
19
  from huggingface_hub import hf_hub_download
20
 
21
  from diffusers import Ideogram4Pipeline
 
22
 
23
  # Runtime shim (keeps the bundled diffusers pristine): cu130-era bitsandbytes returns Params4bit.shape as a
24
  # plain tuple, but diffusers' check_quantized_param_shape calls .numel() on it. math.prod handles both, so
@@ -43,6 +44,7 @@ LM_HEAD_REPO = "multimodalart/qwen3-vl-8b-instruct-lm-head"
43
  AOTI_REPO = "multimodalart/i4-block-aoti"
44
  AOTI_BLOCK_FILE = "Ideogram4TransformerBlock/package.pt2"
45
  MAX_SEED = 2**31 - 1
 
46
 
47
  # Prompt upsampling: Ideogram's hosted magic-prompt (default) with the local Qwen graft as fallback.
48
  IDEOGRAM_MAGIC_PROMPT_URL = "https://api.ideogram.ai/v1/ideogram-v4/magic-prompt"
@@ -143,16 +145,48 @@ def _per_step(width, height):
143
  return max(0.2, _PS_A + _PS_B * ((int(width) // 16) * (int(height) // 16)))
144
 
145
 
146
- def _gpu_duration(final_prompt, mode, width, height, seed, do_local, progress=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  steps = MODES.get(mode, MODES["Default · 20 steps"])["num_inference_steps"]
148
  budget = steps * _per_step(width, height) + DIFFUSION_OVERHEAD_S
149
  if do_local:
150
  budget += LOCAL_UPSAMPLE_S
151
- return max(60, int(math.ceil(budget * DURATION_MARGIN)))
 
 
152
 
153
 
154
  @spaces.GPU(duration=_gpu_duration, size="xlarge")
155
- def _gpu_generate(final_prompt, mode, width, height, seed, do_local, progress=gr.Progress(track_tqdm=True)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  # Overlap the AOTI block-patch with the (transformer-idle) local upsample, if any.
157
  aoti_thread = Thread(target=_apply_aoti, daemon=True)
158
  aoti_thread.start()
@@ -180,6 +214,15 @@ def _gpu_generate(final_prompt, mode, width, height, seed, do_local, progress=gr
180
  caption = json.loads(final_prompt)
181
  except Exception:
182
  caption = {"prompt": final_prompt}
 
 
 
 
 
 
 
 
 
183
  return image, int(seed), caption
184
 
185
 
@@ -192,8 +235,12 @@ def generate(
192
  seed=0,
193
  randomize_seed=False,
194
  raw_json_prompt="",
 
 
195
  progress=gr.Progress(track_tqdm=True),
196
  ):
 
 
197
  if randomize_seed or seed < 0:
198
  seed = random.randint(0, MAX_SEED)
199
 
@@ -203,7 +250,7 @@ def generate(
203
  final_prompt = json.dumps(json.loads(raw_json_prompt), ensure_ascii=False, separators=(",", ":"))
204
  except Exception as e:
205
  raise gr.Error(f"Raw JSON prompt is not valid JSON: {e}")
206
- return _gpu_generate(final_prompt, mode, width, height, seed, False)
207
 
208
  # Remote upsample is a network call -> run it here, OFF the GPU. Fall back to local (on-GPU) on failure.
209
  final_prompt, do_local = prompt, True
@@ -218,7 +265,7 @@ def generate(
218
  print(f"[upsample] remote failed, falling back to local: {e!r}", flush=True)
219
  gr.Warning("Ideogram API unavailable — using the local Qwen upsampler.")
220
 
221
- return _gpu_generate(final_prompt, mode, width, height, seed, do_local)
222
 
223
 
224
  @spaces.GPU(size="xlarge")
@@ -267,6 +314,19 @@ with gr.Blocks(theme=gr.themes.Citrus(), title="Ideogram 4", css=CSS) as demo:
267
  placeholder='{"prompt":"a ginger cat wearing a tiny wizard hat reading a spellbook"}',
268
  buttons=["copy"],
269
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  with gr.Row():
271
  width = gr.Slider(512, 2048, value=1024, step=64, label="Width")
272
  height = gr.Slider(512, 2048, value=1024, step=64, label="Height")
@@ -292,7 +352,7 @@ with gr.Blocks(theme=gr.themes.Citrus(), title="Ideogram 4", css=CSS) as demo:
292
 
293
  run.click(
294
  generate,
295
- inputs=[prompt, mode, upsampler, width, height, seed, randomize, raw_json_prompt],
296
  outputs=[out_image, seed, out_caption],
297
  )
298
 
 
19
  from huggingface_hub import hf_hub_download
20
 
21
  from diffusers import Ideogram4Pipeline
22
+ from lora_utils import ensure_loras_loaded
23
 
24
  # Runtime shim (keeps the bundled diffusers pristine): cu130-era bitsandbytes returns Params4bit.shape as a
25
  # plain tuple, but diffusers' check_quantized_param_shape calls .numel() on it. math.prod handles both, so
 
44
  AOTI_REPO = "multimodalart/i4-block-aoti"
45
  AOTI_BLOCK_FILE = "Ideogram4TransformerBlock/package.pt2"
46
  MAX_SEED = 2**31 - 1
47
+ LAST_LORAS = {}
48
 
49
  # Prompt upsampling: Ideogram's hosted magic-prompt (default) with the local Qwen graft as fallback.
50
  IDEOGRAM_MAGIC_PROMPT_URL = "https://api.ideogram.ai/v1/ideogram-v4/magic-prompt"
 
145
  return max(0.2, _PS_A + _PS_B * ((int(width) // 16) * (int(height) // 16)))
146
 
147
 
148
+ def _gpu_duration(
149
+ final_prompt,
150
+ mode,
151
+ width,
152
+ height,
153
+ seed,
154
+ do_local,
155
+ is_raw_json=False,
156
+ lora_spec="",
157
+ lora_scale=1.0,
158
+ progress=None,
159
+ ):
160
+ lora_requested = bool(lora_spec and str(lora_spec).strip())
161
+ if is_raw_json:
162
+ return 80 if lora_requested else 20
163
  steps = MODES.get(mode, MODES["Default · 20 steps"])["num_inference_steps"]
164
  budget = steps * _per_step(width, height) + DIFFUSION_OVERHEAD_S
165
  if do_local:
166
  budget += LOCAL_UPSAMPLE_S
167
+ if lora_requested:
168
+ budget += 10
169
+ return max(20, int(math.ceil(budget * DURATION_MARGIN)))
170
 
171
 
172
  @spaces.GPU(duration=_gpu_duration, size="xlarge")
173
+ def _gpu_generate(
174
+ final_prompt,
175
+ mode,
176
+ width,
177
+ height,
178
+ seed,
179
+ do_local,
180
+ is_raw_json=False,
181
+ lora_spec="",
182
+ lora_scale=1.0,
183
+ progress=gr.Progress(track_tqdm=True),
184
+ ):
185
+ try:
186
+ active_loras = ensure_loras_loaded(pipe, lora_spec, float(lora_scale), LAST_LORAS)
187
+ except Exception as e:
188
+ raise gr.Error(f"Failed to load LoRA adapters: {e}") from e
189
+
190
  # Overlap the AOTI block-patch with the (transformer-idle) local upsample, if any.
191
  aoti_thread = Thread(target=_apply_aoti, daemon=True)
192
  aoti_thread.start()
 
214
  caption = json.loads(final_prompt)
215
  except Exception:
216
  caption = {"prompt": final_prompt}
217
+ if active_loras:
218
+ caption["loras"] = [
219
+ {
220
+ "repo": entry["repo_id"],
221
+ "weight_name": entry["weight_name"],
222
+ "scale": entry["scale"],
223
+ }
224
+ for entry in active_loras
225
+ ]
226
  return image, int(seed), caption
227
 
228
 
 
235
  seed=0,
236
  randomize_seed=False,
237
  raw_json_prompt="",
238
+ lora_spec="",
239
+ lora_scale=1.0,
240
  progress=gr.Progress(track_tqdm=True),
241
  ):
242
+ if isinstance(lora_scale, str):
243
+ lora_scale = float(lora_scale)
244
  if randomize_seed or seed < 0:
245
  seed = random.randint(0, MAX_SEED)
246
 
 
250
  final_prompt = json.dumps(json.loads(raw_json_prompt), ensure_ascii=False, separators=(",", ":"))
251
  except Exception as e:
252
  raise gr.Error(f"Raw JSON prompt is not valid JSON: {e}")
253
+ return _gpu_generate(final_prompt, mode, width, height, seed, False, True, lora_spec, lora_scale)
254
 
255
  # Remote upsample is a network call -> run it here, OFF the GPU. Fall back to local (on-GPU) on failure.
256
  final_prompt, do_local = prompt, True
 
265
  print(f"[upsample] remote failed, falling back to local: {e!r}", flush=True)
266
  gr.Warning("Ideogram API unavailable — using the local Qwen upsampler.")
267
 
268
+ return _gpu_generate(final_prompt, mode, width, height, seed, do_local, False, lora_spec, lora_scale)
269
 
270
 
271
  @spaces.GPU(size="xlarge")
 
314
  placeholder='{"prompt":"a ginger cat wearing a tiny wizard hat reading a spellbook"}',
315
  buttons=["copy"],
316
  )
317
+ lora_spec = gr.Textbox(
318
+ label="LoRAs (one per line)",
319
+ info="Format: username/repo:weights.safetensors@0.8 or a direct Hugging Face .safetensors URL. Leave empty for no LoRA.",
320
+ lines=4,
321
+ placeholder="vladi/real-loras:ema4_flux2_klein_9b_000002000.safetensors\nvladi/loras:klein9b/klein_snofs_v1_4.safetensors@0.8",
322
+ )
323
+ lora_scale = gr.Slider(
324
+ 0.0,
325
+ 2.0,
326
+ value=1.0,
327
+ step=0.05,
328
+ label="LoRA scale multiplier",
329
+ )
330
  with gr.Row():
331
  width = gr.Slider(512, 2048, value=1024, step=64, label="Width")
332
  height = gr.Slider(512, 2048, value=1024, step=64, label="Height")
 
352
 
353
  run.click(
354
  generate,
355
+ inputs=[prompt, mode, upsampler, width, height, seed, randomize, raw_json_prompt, lora_spec, lora_scale],
356
  outputs=[out_image, seed, out_caption],
357
  )
358
 
lora_utils.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import os
3
+ from urllib.parse import urlparse
4
+
5
+
6
+ ADAPTER_NAME_PREFIX = "custom"
7
+ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("hf")
8
+
9
+
10
+ def _parse_hf_lora_url(url: str):
11
+ parsed = urlparse(url)
12
+ if "huggingface.co" not in parsed.netloc:
13
+ return None, None
14
+
15
+ path_parts = [part for part in parsed.path.split("/") if part]
16
+ if len(path_parts) < 2:
17
+ return None, None
18
+
19
+ repo_id = f"{path_parts[0]}/{path_parts[1]}"
20
+ weight_parts = path_parts[2:]
21
+ if len(weight_parts) >= 2 and weight_parts[0] in {"blob", "resolve"}:
22
+ weight_parts = weight_parts[2:]
23
+ weight_name = "/".join(weight_parts) if weight_parts else None
24
+ if not weight_name or not weight_name.endswith(".safetensors"):
25
+ return repo_id, None
26
+ return repo_id, weight_name
27
+
28
+
29
+ def _split_lora_spec(spec: str):
30
+ if not spec:
31
+ return None, None
32
+
33
+ spec = spec.strip()
34
+ if not spec:
35
+ return None, None
36
+
37
+ if spec.startswith("http://") or spec.startswith("https://"):
38
+ return _parse_hf_lora_url(spec)
39
+ if ":" in spec:
40
+ repo_id, weight_name = spec.split(":", 1)
41
+ return repo_id.strip(), weight_name.strip()
42
+ return spec, None
43
+
44
+
45
+ def _split_adapter_line_scale(line: str):
46
+ if "@" not in line:
47
+ return line, 1.0
48
+
49
+ spec_candidate, scale_candidate = line.rsplit("@", 1)
50
+ try:
51
+ inline_scale = float(scale_candidate.strip())
52
+ except ValueError:
53
+ return line, 1.0
54
+ return spec_candidate.strip(), inline_scale
55
+
56
+
57
+ def parse_adapter_specs(spec_text: str, global_scale: float):
58
+ if not spec_text or not spec_text.strip():
59
+ return []
60
+
61
+ requested_entries = []
62
+ seen_keys = set()
63
+
64
+ for line_number, raw_line in enumerate(spec_text.splitlines(), start=1):
65
+ line = raw_line.strip()
66
+ if not line:
67
+ continue
68
+
69
+ spec, inline_scale = _split_adapter_line_scale(line)
70
+ repo_id, weight_name = _split_lora_spec(spec)
71
+ if not repo_id or not weight_name:
72
+ raise ValueError(
73
+ "Please provide LoRA entries as "
74
+ "'user/repo:weights.safetensors' or direct .safetensors URLs. "
75
+ f"Invalid line {line_number}: {raw_line!r}"
76
+ )
77
+
78
+ adapter_key = (repo_id, weight_name)
79
+ if adapter_key in seen_keys:
80
+ raise ValueError(
81
+ f"Duplicate LoRA entry for '{repo_id}:{weight_name}' on line {line_number}."
82
+ )
83
+ seen_keys.add(adapter_key)
84
+
85
+ requested_entries.append(
86
+ {
87
+ "key": adapter_key,
88
+ "repo_id": repo_id,
89
+ "weight_name": weight_name,
90
+ "adapter_name": adapter_runtime_name(adapter_key),
91
+ "inline_scale": inline_scale,
92
+ "global_scale": global_scale,
93
+ "scale": inline_scale * global_scale,
94
+ }
95
+ )
96
+
97
+ return requested_entries
98
+
99
+
100
+ def adapter_runtime_name(adapter_key):
101
+ digest = hashlib.sha1(f"{adapter_key[0]}:{adapter_key[1]}".encode("utf-8")).hexdigest()[:12]
102
+ return f"{ADAPTER_NAME_PREFIX}_{digest}"
103
+
104
+
105
+ def _iter_adapter_hosts(pipe):
106
+ seen = set()
107
+ for host in (
108
+ pipe,
109
+ getattr(pipe, "transformer", None),
110
+ getattr(pipe, "unconditional_transformer", None),
111
+ ):
112
+ if host is None or id(host) in seen:
113
+ continue
114
+ seen.add(id(host))
115
+ yield host
116
+
117
+
118
+ def _flatten_adapter_names(adapter_mapping):
119
+ if isinstance(adapter_mapping, dict):
120
+ names = set()
121
+ for adapters in adapter_mapping.values():
122
+ if isinstance(adapters, str):
123
+ names.add(adapters)
124
+ else:
125
+ names.update(adapters)
126
+ return names
127
+ if isinstance(adapter_mapping, str):
128
+ return {adapter_mapping}
129
+ if adapter_mapping is None:
130
+ return set()
131
+ return set(adapter_mapping)
132
+
133
+
134
+ def _sorted_lora_entries(entries):
135
+ return sorted(entries, key=lambda entry: entry["adapter_name"])
136
+
137
+
138
+ def safe_unload_lora_adapters(pipe):
139
+ deleted = False
140
+ for host in _iter_adapter_hosts(pipe):
141
+ if not hasattr(host, "delete_adapters"):
142
+ continue
143
+ try:
144
+ adapter_names = sorted(_flatten_adapter_names(host.get_list_adapters()))
145
+ except Exception:
146
+ adapter_names = []
147
+ for adapter_name in adapter_names:
148
+ try:
149
+ host.delete_adapters(adapter_name)
150
+ deleted = True
151
+ except Exception:
152
+ pass
153
+ if deleted:
154
+ return
155
+
156
+ if hasattr(pipe, "unload_lora_weights"):
157
+ try:
158
+ pipe.unload_lora_weights()
159
+ return
160
+ except Exception:
161
+ pass
162
+
163
+ for host in _iter_adapter_hosts(pipe):
164
+ if hasattr(host, "set_adapters"):
165
+ try:
166
+ host.set_adapters([])
167
+ except Exception:
168
+ pass
169
+ if hasattr(host, "disable_adapters"):
170
+ try:
171
+ host.disable_adapters()
172
+ except Exception:
173
+ pass
174
+ if hasattr(host, "disable_lora"):
175
+ try:
176
+ host.disable_lora()
177
+ except Exception:
178
+ pass
179
+
180
+
181
+ def _set_adapters_on_host(host, adapter_names, adapter_weights):
182
+ if not hasattr(host, "set_adapters"):
183
+ return False
184
+
185
+ if not adapter_names:
186
+ try:
187
+ host.set_adapters([])
188
+ return True
189
+ except Exception:
190
+ return False
191
+
192
+ for kwargs in (
193
+ {"adapter_weights": adapter_weights},
194
+ {"weights": adapter_weights},
195
+ ):
196
+ try:
197
+ host.set_adapters(adapter_names, **kwargs)
198
+ return True
199
+ except TypeError:
200
+ continue
201
+ except Exception:
202
+ return False
203
+ return False
204
+
205
+
206
+ def apply_lora_adapters(pipe, lora_entries):
207
+ if not lora_entries:
208
+ safe_unload_lora_adapters(pipe)
209
+ return
210
+
211
+ sorted_entries = _sorted_lora_entries(lora_entries)
212
+ adapter_names = [entry["adapter_name"] for entry in sorted_entries]
213
+ adapter_weights = [entry["scale"] for entry in sorted_entries]
214
+
215
+ for host in _iter_adapter_hosts(pipe):
216
+ if _set_adapters_on_host(host, adapter_names, adapter_weights):
217
+ return
218
+
219
+ if len(adapter_names) == 1 and hasattr(pipe, "set_lora_scale"):
220
+ pipe.set_lora_scale(adapter_weights[0])
221
+ return
222
+
223
+ raise ValueError("This runtime does not support activating multiple LoRA adapters.")
224
+
225
+
226
+ def load_lora_adapter(pipe, entry, token=HF_TOKEN):
227
+ load_kwargs = {
228
+ "weight_name": entry["weight_name"],
229
+ "adapter_name": entry["adapter_name"],
230
+ }
231
+ if token:
232
+ load_kwargs["token"] = token
233
+
234
+ try:
235
+ pipe.load_lora_weights(entry["repo_id"], **load_kwargs)
236
+ except TypeError:
237
+ if not token:
238
+ raise
239
+ load_kwargs.pop("token", None)
240
+ pipe.load_lora_weights(entry["repo_id"], **load_kwargs)
241
+
242
+
243
+ def ensure_loras_loaded(pipe, spec_text: str, global_scale: float, active_by_key: dict, token=HF_TOKEN):
244
+ desired_entries = parse_adapter_specs(spec_text, global_scale)
245
+ desired_by_key = {entry["key"]: entry for entry in desired_entries}
246
+
247
+ if not desired_entries:
248
+ if active_by_key:
249
+ safe_unload_lora_adapters(pipe)
250
+ active_by_key.clear()
251
+ return []
252
+
253
+ if set(active_by_key.keys()) != set(desired_by_key.keys()):
254
+ try:
255
+ safe_unload_lora_adapters(pipe)
256
+ loaded_entries = []
257
+ for entry in _sorted_lora_entries(desired_entries):
258
+ load_lora_adapter(pipe, entry, token=token)
259
+ loaded_entries.append(entry)
260
+ apply_lora_adapters(pipe, loaded_entries)
261
+ except Exception:
262
+ safe_unload_lora_adapters(pipe)
263
+ active_by_key.clear()
264
+ raise
265
+ else:
266
+ apply_lora_adapters(pipe, desired_entries)
267
+
268
+ active_by_key.clear()
269
+ active_by_key.update(desired_by_key)
270
+ return desired_entries
tests/test_lora_utils.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from lora_utils import parse_adapter_specs
4
+
5
+
6
+ class ParseAdapterSpecsTest(unittest.TestCase):
7
+ def test_blank_spec_returns_no_entries(self):
8
+ self.assertEqual(parse_adapter_specs("\n \n", 1.0), [])
9
+
10
+ def test_parses_repo_weight_and_default_scale(self):
11
+ entries = parse_adapter_specs("vladi/real-loras:ema4_flux2_klein_9b_000002000.safetensors", 1.0)
12
+
13
+ self.assertEqual(entries[0]["repo_id"], "vladi/real-loras")
14
+ self.assertEqual(entries[0]["weight_name"], "ema4_flux2_klein_9b_000002000.safetensors")
15
+ self.assertEqual(entries[0]["scale"], 1.0)
16
+
17
+ def test_parses_multiple_entries_and_inline_scale(self):
18
+ entries = parse_adapter_specs(
19
+ "vladi/real-loras:ema4_flux2_klein_9b_000002000.safetensors\n"
20
+ "vladi/loras:klein9b/klein_snofs_v1_4.safetensors@0.8",
21
+ 0.5,
22
+ )
23
+
24
+ self.assertEqual(len(entries), 2)
25
+ self.assertEqual(entries[1]["repo_id"], "vladi/loras")
26
+ self.assertEqual(entries[1]["weight_name"], "klein9b/klein_snofs_v1_4.safetensors")
27
+ self.assertEqual(entries[1]["scale"], 0.4)
28
+
29
+ def test_parses_huggingface_url_with_subfolder(self):
30
+ entries = parse_adapter_specs(
31
+ "https://huggingface.co/vladi/loras/blob/dev/klein9b/klein_snofs_v1_4.safetensors@0.8",
32
+ 1.0,
33
+ )
34
+
35
+ self.assertEqual(entries[0]["repo_id"], "vladi/loras")
36
+ self.assertEqual(entries[0]["weight_name"], "klein9b/klein_snofs_v1_4.safetensors")
37
+ self.assertEqual(entries[0]["scale"], 0.8)
38
+
39
+ def test_rejects_missing_weight_name(self):
40
+ with self.assertRaises(ValueError):
41
+ parse_adapter_specs("vladi/real-loras", 1.0)
42
+
43
+ def test_rejects_duplicate_entries(self):
44
+ with self.assertRaises(ValueError):
45
+ parse_adapter_specs(
46
+ "vladi/real-loras:ema4_flux2_klein_9b_000002000.safetensors\n"
47
+ "vladi/real-loras:ema4_flux2_klein_9b_000002000.safetensors@0.8",
48
+ 1.0,
49
+ )
50
+
51
+
52
+ if __name__ == "__main__":
53
+ unittest.main()