alfredplpl commited on
Commit
32e1ffb
·
verified ·
1 Parent(s): 6c802e8

Upload convert_flux2_klein_bfl_to_diffusers.py

Browse files
convert_flux2_klein_bfl_to_diffusers.py ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # convert_flux2_klein_bfl_to_diffusers.py
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import torch
11
+ from safetensors import safe_open
12
+ from safetensors.torch import save_file
13
+
14
+
15
+ FLUX2_KLEIN_BASE_4B_CONFIG = {
16
+ "_class_name": "Flux2Transformer2DModel",
17
+ "_diffusers_version": "0.37.0.dev0",
18
+ "attention_head_dim": 128,
19
+ "axes_dims_rope": [32, 32, 32, 32],
20
+ "eps": 1e-6,
21
+ "guidance_embeds": False,
22
+ "in_channels": 128,
23
+ "joint_attention_dim": 7680,
24
+ "mlp_ratio": 3.0,
25
+ "num_attention_heads": 24,
26
+ "num_layers": 5,
27
+ "num_single_layers": 20,
28
+ "out_channels": None,
29
+ "patch_size": 1,
30
+ "rope_theta": 2000,
31
+ "timestep_guidance_channels": 256,
32
+ }
33
+
34
+
35
+ PREFIXES_TO_STRIP = (
36
+ "model.diffusion_model.",
37
+ "diffusion_model.",
38
+ "model.",
39
+ )
40
+
41
+
42
+ def normalize_source_key(key: str) -> str:
43
+ for prefix in PREFIXES_TO_STRIP:
44
+ if key.startswith(prefix):
45
+ return key[len(prefix):]
46
+ return key
47
+
48
+
49
+ def make_normalized_key_map(reader) -> dict[str, str]:
50
+ mapping = {}
51
+ for real_key in reader.keys():
52
+ key = normalize_source_key(real_key)
53
+ if key in mapping:
54
+ raise ValueError(
55
+ f"Duplicate normalized key: {key}\n"
56
+ f" {mapping[key]}\n"
57
+ f" {real_key}"
58
+ )
59
+ mapping[key] = real_key
60
+ return mapping
61
+
62
+
63
+ def infer_max_index(keys: set[str], prefix: str) -> int | None:
64
+ pattern = re.compile(rf"^{re.escape(prefix)}\.(\d+)\.")
65
+ indices = []
66
+ for key in keys:
67
+ m = pattern.match(key)
68
+ if m:
69
+ indices.append(int(m.group(1)))
70
+ return max(indices) if indices else None
71
+
72
+
73
+ def add_tensor(dst: dict[str, torch.Tensor], key: str, tensor: torch.Tensor) -> None:
74
+ if key in dst:
75
+ raise ValueError(f"Destination key already exists: {key}")
76
+ dst[key] = tensor.contiguous()
77
+
78
+
79
+ def split_qkv_weight(w: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
80
+ if w.shape[0] % 3 != 0:
81
+ raise ValueError(f"QKV tensor first dim is not divisible by 3: {tuple(w.shape)}")
82
+ q, k, v = torch.chunk(w, 3, dim=0)
83
+ return q.contiguous(), k.contiguous(), v.contiguous()
84
+
85
+
86
+ def swap_final_adaln_scale_shift(t: torch.Tensor) -> torch.Tensor:
87
+ """
88
+ BFL FLUX LastLayer:
89
+ shift, scale = adaLN(...).chunk(2)
90
+
91
+ diffusers AdaLayerNormContinuous:
92
+ scale, shift = linear(...).chunk(2)
93
+
94
+ Therefore:
95
+ source [shift, scale] -> diffusers [scale, shift]
96
+ """
97
+ if t.shape[0] % 2 != 0:
98
+ raise ValueError(f"final AdaLN tensor first dim is not divisible by 2: {tuple(t.shape)}")
99
+ shift, scale = torch.chunk(t, 2, dim=0)
100
+ return torch.cat([scale, shift], dim=0).contiguous()
101
+
102
+
103
+ def convert(
104
+ input_file: Path,
105
+ *,
106
+ config: dict,
107
+ include_guidance: bool = False,
108
+ strict_unused: bool = False,
109
+ ) -> tuple[dict[str, torch.Tensor], list[str]]:
110
+ converted: dict[str, torch.Tensor] = {}
111
+
112
+ with safe_open(str(input_file), framework="pt", device="cpu") as f:
113
+ real_keys = make_normalized_key_map(f)
114
+ source_keys = set(real_keys.keys())
115
+ used: set[str] = set()
116
+
117
+ def has(key: str) -> bool:
118
+ return key in real_keys
119
+
120
+ def get(key: str, *, required: bool = True) -> torch.Tensor | None:
121
+ real_key = real_keys.get(key)
122
+ if real_key is None:
123
+ if required:
124
+ raise KeyError(f"Missing source tensor: {key}")
125
+ return None
126
+ used.add(key)
127
+ return f.get_tensor(real_key)
128
+
129
+ def copy_weight(src_base: str, dst_base: str, *, required: bool = True) -> None:
130
+ w = get(f"{src_base}.weight", required=required)
131
+ if w is not None:
132
+ add_tensor(converted, f"{dst_base}.weight", w)
133
+
134
+ # klein base 4B の transformer は基本 bias なし。
135
+ # ただし派生 checkpoint 対応として存在すれば写す。
136
+ b = get(f"{src_base}.bias", required=False)
137
+ if b is not None:
138
+ add_tensor(converted, f"{dst_base}.bias", b)
139
+
140
+ def copy_scale_as_weight(src_base: str, dst_base: str) -> None:
141
+ s = get(f"{src_base}.scale")
142
+ add_tensor(converted, f"{dst_base}.weight", s)
143
+
144
+ def copy_qkv(src_base: str, dst_q: str, dst_k: str, dst_v: str) -> None:
145
+ w = get(f"{src_base}.weight")
146
+ q, k, v = split_qkv_weight(w)
147
+ add_tensor(converted, f"{dst_q}.weight", q)
148
+ add_tensor(converted, f"{dst_k}.weight", k)
149
+ add_tensor(converted, f"{dst_v}.weight", v)
150
+
151
+ # 念のため bias あり派生にも対応。
152
+ b = get(f"{src_base}.bias", required=False)
153
+ if b is not None:
154
+ qb, kb, vb = split_qkv_weight(b)
155
+ add_tensor(converted, f"{dst_q}.bias", qb)
156
+ add_tensor(converted, f"{dst_k}.bias", kb)
157
+ add_tensor(converted, f"{dst_v}.bias", vb)
158
+
159
+ def copy_final_adaln(src_base: str, dst_base: str) -> None:
160
+ w = get(f"{src_base}.weight")
161
+ add_tensor(converted, f"{dst_base}.weight", swap_final_adaln_scale_shift(w))
162
+
163
+ b = get(f"{src_base}.bias", required=False)
164
+ if b is not None:
165
+ add_tensor(converted, f"{dst_base}.bias", swap_final_adaln_scale_shift(b))
166
+
167
+ expected_double = int(config["num_layers"])
168
+ expected_single = int(config["num_single_layers"])
169
+
170
+ max_double = infer_max_index(source_keys, "double_blocks")
171
+ max_single = infer_max_index(source_keys, "single_blocks")
172
+
173
+ if max_double is None or max_double + 1 != expected_double:
174
+ raise ValueError(
175
+ f"double_blocks count mismatch: source max index={max_double}, "
176
+ f"config expects {expected_double} blocks"
177
+ )
178
+
179
+ if max_single is None or max_single + 1 != expected_single:
180
+ raise ValueError(
181
+ f"single_blocks count mismatch: source max index={max_single}, "
182
+ f"config expects {expected_single} blocks"
183
+ )
184
+
185
+ # ---------------------------------------------------------------------
186
+ # Top-level embeddings
187
+ # ---------------------------------------------------------------------
188
+ copy_weight("img_in", "x_embedder")
189
+ copy_weight("txt_in", "context_embedder")
190
+
191
+ copy_weight(
192
+ "time_in.in_layer",
193
+ "time_guidance_embed.timestep_embedder.linear_1",
194
+ )
195
+ copy_weight(
196
+ "time_in.out_layer",
197
+ "time_guidance_embed.timestep_embedder.linear_2",
198
+ )
199
+
200
+ # FLUX.2 klein base 4B は guidance_embeds=False。
201
+ # 蒸留版・派生で guidance_in がある場合だけ使う。
202
+ if include_guidance:
203
+ copy_weight(
204
+ "guidance_in.in_layer",
205
+ "time_guidance_embed.guidance_embedder.linear_1",
206
+ )
207
+ copy_weight(
208
+ "guidance_in.out_layer",
209
+ "time_guidance_embed.guidance_embedder.linear_2",
210
+ )
211
+
212
+ # ---------------------------------------------------------------------
213
+ # Modulation
214
+ # ---------------------------------------------------------------------
215
+ copy_weight(
216
+ "double_stream_modulation_img.lin",
217
+ "double_stream_modulation_img.linear",
218
+ )
219
+ copy_weight(
220
+ "double_stream_modulation_txt.lin",
221
+ "double_stream_modulation_txt.linear",
222
+ )
223
+ copy_weight(
224
+ "single_stream_modulation.lin",
225
+ "single_stream_modulation.linear",
226
+ )
227
+
228
+ # ---------------------------------------------------------------------
229
+ # Double-stream blocks
230
+ # ---------------------------------------------------------------------
231
+ for i in range(expected_double):
232
+ src = f"double_blocks.{i}"
233
+ dst = f"transformer_blocks.{i}"
234
+
235
+ copy_qkv(
236
+ f"{src}.img_attn.qkv",
237
+ f"{dst}.attn.to_q",
238
+ f"{dst}.attn.to_k",
239
+ f"{dst}.attn.to_v",
240
+ )
241
+
242
+ copy_qkv(
243
+ f"{src}.txt_attn.qkv",
244
+ f"{dst}.attn.add_q_proj",
245
+ f"{dst}.attn.add_k_proj",
246
+ f"{dst}.attn.add_v_proj",
247
+ )
248
+
249
+ copy_scale_as_weight(
250
+ f"{src}.img_attn.norm.query_norm",
251
+ f"{dst}.attn.norm_q",
252
+ )
253
+ copy_scale_as_weight(
254
+ f"{src}.img_attn.norm.key_norm",
255
+ f"{dst}.attn.norm_k",
256
+ )
257
+ copy_scale_as_weight(
258
+ f"{src}.txt_attn.norm.query_norm",
259
+ f"{dst}.attn.norm_added_q",
260
+ )
261
+ copy_scale_as_weight(
262
+ f"{src}.txt_attn.norm.key_norm",
263
+ f"{dst}.attn.norm_added_k",
264
+ )
265
+
266
+ copy_weight(
267
+ f"{src}.img_attn.proj",
268
+ f"{dst}.attn.to_out.0",
269
+ )
270
+ copy_weight(
271
+ f"{src}.txt_attn.proj",
272
+ f"{dst}.attn.to_add_out",
273
+ )
274
+
275
+ copy_weight(
276
+ f"{src}.img_mlp.0",
277
+ f"{dst}.ff.linear_in",
278
+ )
279
+ copy_weight(
280
+ f"{src}.img_mlp.2",
281
+ f"{dst}.ff.linear_out",
282
+ )
283
+ copy_weight(
284
+ f"{src}.txt_mlp.0",
285
+ f"{dst}.ff_context.linear_in",
286
+ )
287
+ copy_weight(
288
+ f"{src}.txt_mlp.2",
289
+ f"{dst}.ff_context.linear_out",
290
+ )
291
+
292
+ # ---------------------------------------------------------------------
293
+ # Single-stream blocks
294
+ # ---------------------------------------------------------------------
295
+ for i in range(expected_single):
296
+ src = f"single_blocks.{i}"
297
+ dst = f"single_transformer_blocks.{i}"
298
+
299
+ # BFL 側 linear1 は [Q, K, V, MLP-in] の fused projection。
300
+ # diffusers 側も to_qkv_mlp_proj として同じ fused projection を持つ。
301
+ copy_weight(
302
+ f"{src}.linear1",
303
+ f"{dst}.attn.to_qkv_mlp_proj",
304
+ )
305
+
306
+ # BFL 側 linear2 は [attention-out, MLP-out] の fused output。
307
+ copy_weight(
308
+ f"{src}.linear2",
309
+ f"{dst}.attn.to_out",
310
+ )
311
+
312
+ copy_scale_as_weight(
313
+ f"{src}.norm.query_norm",
314
+ f"{dst}.attn.norm_q",
315
+ )
316
+ copy_scale_as_weight(
317
+ f"{src}.norm.key_norm",
318
+ f"{dst}.attn.norm_k",
319
+ )
320
+
321
+ # ---------------------------------------------------------------------
322
+ # Final layer
323
+ # ---------------------------------------------------------------------
324
+ copy_final_adaln(
325
+ "final_layer.adaLN_modulation.1",
326
+ "norm_out.linear",
327
+ )
328
+ copy_weight(
329
+ "final_layer.linear",
330
+ "proj_out",
331
+ )
332
+
333
+ unused = sorted(source_keys - used)
334
+
335
+ meaningful_unused = [
336
+ k for k in unused
337
+ if not k.startswith("_")
338
+ and "optimizer" not in k.lower()
339
+ and "ema" not in k.lower()
340
+ ]
341
+
342
+ if meaningful_unused:
343
+ print(f"[warn] unused source tensors: {len(meaningful_unused)}", file=sys.stderr)
344
+ for k in meaningful_unused[:100]:
345
+ print(f" UNUSED {k}", file=sys.stderr)
346
+ if strict_unused:
347
+ raise SystemExit(1)
348
+
349
+ return converted, meaningful_unused
350
+
351
+
352
+ def validate_against_diffusers(converted: dict[str, torch.Tensor], config: dict) -> None:
353
+ try:
354
+ from accelerate import init_empty_weights
355
+ from diffusers import Flux2Transformer2DModel
356
+ except Exception as e:
357
+ print(
358
+ "[warn] diffusers validation skipped. "
359
+ f"Could not import Flux2Transformer2DModel: {e}",
360
+ file=sys.stderr,
361
+ )
362
+ return
363
+
364
+ kwargs = {k: v for k, v in config.items() if not k.startswith("_")}
365
+
366
+ with init_empty_weights():
367
+ model = Flux2Transformer2DModel(**kwargs)
368
+
369
+ expected_state = model.state_dict()
370
+ expected_keys = set(expected_state.keys())
371
+ got_keys = set(converted.keys())
372
+
373
+ missing = sorted(expected_keys - got_keys)
374
+ unexpected = sorted(got_keys - expected_keys)
375
+
376
+ shape_mismatches = []
377
+ for key in sorted(expected_keys & got_keys):
378
+ expected_shape = tuple(expected_state[key].shape)
379
+ got_shape = tuple(converted[key].shape)
380
+ if expected_shape != got_shape:
381
+ shape_mismatches.append((key, expected_shape, got_shape))
382
+
383
+ if missing or unexpected or shape_mismatches:
384
+ print("[error] diffusers key validation failed", file=sys.stderr)
385
+
386
+ if missing:
387
+ print(f"[error] missing keys: {len(missing)}", file=sys.stderr)
388
+ for k in missing[:100]:
389
+ print(f" MISSING {k}", file=sys.stderr)
390
+
391
+ if unexpected:
392
+ print(f"[error] unexpected keys: {len(unexpected)}", file=sys.stderr)
393
+ for k in unexpected[:100]:
394
+ print(f" UNEXPECTED {k}", file=sys.stderr)
395
+
396
+ if shape_mismatches:
397
+ print(f"[error] shape mismatches: {len(shape_mismatches)}", file=sys.stderr)
398
+ for k, exp, got in shape_mismatches[:100]:
399
+ print(f" SHAPE {k}: expected={exp}, got={got}", file=sys.stderr)
400
+
401
+ raise SystemExit(1)
402
+
403
+ print(f"[info] diffusers validation passed: {len(got_keys)} tensors")
404
+
405
+
406
+ def main() -> None:
407
+ parser = argparse.ArgumentParser(
408
+ description="Convert FLUX.2 klein base 4B BFL single-file transformer weights to diffusers transformer format."
409
+ )
410
+ parser.add_argument(
411
+ "--input",
412
+ required=True,
413
+ type=Path,
414
+ help="Path to flux-2-klein-base-4b.safetensors",
415
+ )
416
+ parser.add_argument(
417
+ "--output",
418
+ required=True,
419
+ type=Path,
420
+ help="Output diffusers transformer directory",
421
+ )
422
+ parser.add_argument(
423
+ "--include-guidance",
424
+ action="store_true",
425
+ help="Use only for variants with guidance_in.* and guidance_embeds=True.",
426
+ )
427
+ parser.add_argument(
428
+ "--skip-validation",
429
+ action="store_true",
430
+ help="Skip validation against diffusers Flux2Transformer2DModel state_dict.",
431
+ )
432
+ parser.add_argument(
433
+ "--strict-unused",
434
+ action="store_true",
435
+ help="Fail if unused source tensors remain.",
436
+ )
437
+ args = parser.parse_args()
438
+
439
+ if not args.input.exists():
440
+ raise FileNotFoundError(args.input)
441
+
442
+ config = dict(FLUX2_KLEIN_BASE_4B_CONFIG)
443
+ config["guidance_embeds"] = bool(args.include_guidance)
444
+
445
+ print(f"[info] input : {args.input}")
446
+ print(f"[info] output: {args.output}")
447
+
448
+ converted, _ = convert(
449
+ args.input,
450
+ config=config,
451
+ include_guidance=args.include_guidance,
452
+ strict_unused=args.strict_unused,
453
+ )
454
+
455
+ if not args.skip_validation:
456
+ validate_against_diffusers(converted, config)
457
+
458
+ args.output.mkdir(parents=True, exist_ok=True)
459
+
460
+ config_path = args.output / "config.json"
461
+ weight_path = args.output / "diffusion_pytorch_model.safetensors"
462
+
463
+ with open(config_path, "w", encoding="utf-8") as f:
464
+ json.dump(config, f, ensure_ascii=False, indent=2)
465
+ f.write("\n")
466
+
467
+ save_file(
468
+ converted,
469
+ str(weight_path),
470
+ metadata={"format": "pt"},
471
+ )
472
+
473
+ print("[done]")
474
+ print(f" config : {config_path}")
475
+ print(f" weights: {weight_path}")
476
+
477
+
478
+ if __name__ == "__main__":
479
+ main()