ajh-code commited on
Commit
d30a2b6
·
verified ·
1 Parent(s): d9f3070

Add vendor/mage_flow/pipeline.py

Browse files
Files changed (1) hide show
  1. vendor/mage_flow/pipeline.py +762 -0
vendor/mage_flow/pipeline.py ADDED
@@ -0,0 +1,762 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MageFlow text-to-image + image-edit inference pipeline.
2
+
3
+ Self-contained MageFlow t2i / edit inference: load a HuggingFace diffusers-style
4
+ repo (model_index.json + transformer/ vae/ scheduler/), then generate or edit
5
+ images. No training/eval deps.
6
+
7
+ Both ``generate_images`` and ``generate_edits`` support PACKED multi-resolution
8
+ inference: several samples (each at its own resolution) are concatenated into a
9
+ single varlen sequence and processed in one transformer forward per denoise
10
+ step. Per-sample ``cu_seqlens`` (inside the flash-attn varlen kernel) isolate
11
+ samples, exactly mirroring training-time packing. These packed functions are the
12
+ sole implementation — the single-image case is just a pack of size 1, exposed
13
+ via the ``MageFlowPipeline.generate`` / ``.edit`` convenience methods.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import random
21
+
22
+ import torch
23
+ from einops import rearrange
24
+ from PIL import Image
25
+
26
+ from diffusers import FlowMatchEulerDiscreteScheduler
27
+
28
+ from .models.mage_flow import MageFlowModel, ModelConfig
29
+ from .models.utils import PROMPT_TEMPLATE, get_noise, unpack
30
+ from .models.modules.mage_text import make_refusal_image
31
+ from .models.modules.mage_latent import encode_noise, resolve_gs_key
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Scheduler — diffusers FlowMatchEulerDiscreteScheduler
36
+ # ---------------------------------------------------------------------------
37
+ def build_scheduler(num_steps: int, device=None, shift: float = 6.0):
38
+ """Construct a diffusers ``FlowMatchEulerDiscreteScheduler`` whose sigma
39
+ schedule reproduces our default preset exactly.
40
+
41
+ The base sigmas ``linspace(1, 1/num_steps, num_steps)`` fed to
42
+ ``set_timesteps`` are run through the scheduler's built-in static shift
43
+ ``shift·s/(1+(shift-1)·s)`` and a terminal 0 is appended — the static-shift
44
+ schedule (the only supported schedule).
45
+ """
46
+ scheduler = FlowMatchEulerDiscreteScheduler(
47
+ num_train_timesteps=1000, shift=shift, use_dynamic_shifting=False)
48
+ base_sigmas = torch.linspace(1.0, 1.0 / num_steps, num_steps).tolist()
49
+ scheduler.set_timesteps(sigmas=base_sigmas, device=device)
50
+ return scheduler
51
+
52
+
53
+ def _get_scheduler(model, steps, device, static_shift):
54
+ scheduler = getattr(model, "scheduler", None)
55
+ if scheduler is None:
56
+ return build_scheduler(steps, device=device,
57
+ shift=(static_shift if static_shift is not None else 6.0))
58
+ if static_shift is not None:
59
+ scheduler.set_shift(static_shift)
60
+ scheduler.set_timesteps(sigmas=torch.linspace(1.0, 1.0 / steps, steps).tolist(), device=device)
61
+ return scheduler
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Small helpers
66
+ # ---------------------------------------------------------------------------
67
+ def _template_info(name: str | None) -> dict:
68
+ name = name or "mage-flow"
69
+ if name not in PROMPT_TEMPLATE:
70
+ raise ValueError(f"Unknown prompt template: {name}")
71
+ return PROMPT_TEMPLATE[name]
72
+
73
+
74
+ def _as_list(val, default, n):
75
+ """Broadcast a scalar/None to a length-n list, or validate a given list."""
76
+ if val is None:
77
+ return [default] * n
78
+ if isinstance(val, (list, tuple)):
79
+ if len(val) != n:
80
+ raise ValueError(f"expected {n} values, got {len(val)}")
81
+ return list(val)
82
+ return [val] * n
83
+
84
+
85
+ def _lens_to_cu(lens, device):
86
+ """Sequence lengths -> cumulative cu_seqlens [0, l0, l0+l1, ...] (int32)."""
87
+ t = torch.tensor(lens, device=device, dtype=torch.int32)
88
+ return torch.cat([torch.zeros(1, dtype=torch.int32, device=device),
89
+ torch.cumsum(t, dim=0, dtype=torch.int32)])
90
+
91
+
92
+ def _make_divisible_by_16(size: int) -> int:
93
+ return max(16, 16 * (size // 16))
94
+
95
+
96
+ def _compute_aspect_ratio_size(pil_img: Image.Image, max_size: int):
97
+ """Longest side = ``max_size``, short side from aspect ratio, both /16."""
98
+ w, h = pil_img.size
99
+ if h >= w:
100
+ new_h, new_w = max_size, int(round(w * max_size / h))
101
+ else:
102
+ new_w, new_h = max_size, int(round(h * max_size / w))
103
+ return _make_divisible_by_16(new_h), _make_divisible_by_16(new_w)
104
+
105
+
106
+ def _edit_target_size(pil_img: Image.Image, max_size, height, width):
107
+ """Output (H, W) for an edit sample, derived from its PRIMARY reference.
108
+
109
+ Precedence: explicit ``height`` AND ``width`` (custom size) > ``max_size``
110
+ (longest side, short side by aspect ratio) > the source image's own size.
111
+ All rounded down to a multiple of 16.
112
+ """
113
+ if height and width:
114
+ return _make_divisible_by_16(height), _make_divisible_by_16(width)
115
+ if max_size:
116
+ return _compute_aspect_ratio_size(pil_img, max_size)
117
+ # Nothing specified: keep the source resolution (its own longest side).
118
+ return _compute_aspect_ratio_size(pil_img, max(pil_img.size))
119
+
120
+
121
+ def _decode_one(model, tokens, height, width, dev):
122
+ """Unpack one sample's image tokens [1, H*W, C] and VAE-decode to a PIL image."""
123
+ with torch.autocast(device_type=dev.type, dtype=torch.bfloat16):
124
+ out = model.vae.decode(unpack(tokens.float(), height, width))
125
+ out = rearrange(out.clamp(-1, 1), "b c h w -> b h w c")
126
+ out = (127.5 * (out + 1.0)).cpu().byte().numpy()
127
+ return Image.fromarray(out[0])
128
+
129
+
130
+ def _build_pack_ctx(img_ids, img_cu, img_shapes, img_lens, txt, txt_cu, txt_mask, vec,
131
+ neg_txt, neg_cu, neg_mask, neg_vec, cfg, renormalization, batch_cfg, device):
132
+ """Precompute the static per-step transformer inputs for a packed batch.
133
+
134
+ When a negative branch is present and ``batch_cfg`` is True, the conditional
135
+ and unconditional passes are fused into ONE varlen forward: the image tokens
136
+ are duplicated (cond copy + uncond copy) and the positive/negative texts are
137
+ concatenated, so cond sample i and uncond sample i become two independent
138
+ varlen segments processed in a single kernel launch. flash_attn_varlen_func
139
+ keeps every segment isolated via cu_seqlens, so this is numerically identical
140
+ to two separate forwards — just one launch instead of two.
141
+ """
142
+ na = len(img_lens)
143
+ ctx = {
144
+ "na": na, "cfg": cfg, "renorm": renormalization, "batch_cfg": batch_cfg,
145
+ "has_neg": neg_txt is not None,
146
+ "img_ids": img_ids, "img_cu": img_cu, "img_shapes": img_shapes,
147
+ "img_max": int(max(img_lens)),
148
+ "txt": txt, "txt_ids": torch.zeros(1, txt.shape[1], 3, device=device),
149
+ "txt_cu": txt_cu, "txt_mask": txt_mask, "vec": vec,
150
+ "txt_max": int((txt_cu[1:] - txt_cu[:-1]).max().item()),
151
+ }
152
+ if neg_txt is None:
153
+ return ctx
154
+ ctx.update({
155
+ "neg_txt": neg_txt, "neg_ids": torch.zeros(1, neg_txt.shape[1], 3, device=device),
156
+ "neg_cu": neg_cu, "neg_mask": neg_mask, "neg_vec": neg_vec,
157
+ "neg_max": int((neg_cu[1:] - neg_cu[:-1]).max().item()),
158
+ })
159
+ if batch_cfg:
160
+ # Duplicate image segments (cond then uncond) and concat pos+neg text.
161
+ d_txt = torch.cat([txt, neg_txt], dim=1)
162
+ pos_lens = (txt_cu[1:] - txt_cu[:-1]).tolist()
163
+ neg_lens = (neg_cu[1:] - neg_cu[:-1]).tolist()
164
+ ctx.update({
165
+ "d_img_ids": torch.cat([img_ids, img_ids], dim=1),
166
+ "d_img_cu": _lens_to_cu(list(img_lens) + list(img_lens), device),
167
+ "d_img_shapes": [img_shapes[0] + img_shapes[0]],
168
+ "d_txt": d_txt,
169
+ "d_txt_ids": torch.zeros(1, d_txt.shape[1], 3, device=device),
170
+ "d_txt_cu": _lens_to_cu(pos_lens + neg_lens, device),
171
+ "d_txt_mask": torch.ones(1, d_txt.shape[1], device=device),
172
+ "d_vec": torch.cat([vec, neg_vec], dim=0),
173
+ "d_txt_max": int(max(pos_lens + neg_lens)),
174
+ })
175
+ return ctx
176
+
177
+
178
+ def _velocity(transformer, img, ctx, sigma):
179
+ """CFG-combined image-token velocity for a packed batch at noise level ``sigma``.
180
+
181
+ Returns [1, sum_img_len, C] in the conditional sample order. When
182
+ ``batch_cfg`` is set the cond+uncond passes share a single fused varlen
183
+ forward; otherwise they are two forwards.
184
+ """
185
+ dev = img.device
186
+ na = ctx["na"]
187
+
188
+ def _fwd(x, n, img_ids, img_cu, img_max, img_shapes, txt, txt_ids, txt_cu, txt_mask, txt_max, vec):
189
+ t_vec = torch.full((n,), sigma, dtype=x.dtype, device=dev)
190
+ return transformer(img=x, txt=txt, timesteps=t_vec, img_shapes=img_shapes,
191
+ img_cu_seqlens=img_cu, txt_cu_seqlens=txt_cu)
192
+
193
+ if not ctx["has_neg"]:
194
+ return _fwd(img, na, ctx["img_ids"], ctx["img_cu"], ctx["img_max"], ctx["img_shapes"],
195
+ ctx["txt"], ctx["txt_ids"], ctx["txt_cu"], ctx["txt_mask"], ctx["txt_max"], ctx["vec"])
196
+
197
+ if ctx["batch_cfg"]:
198
+ n_img = img.shape[1]
199
+ out = _fwd(torch.cat([img, img], dim=1), 2 * na,
200
+ ctx["d_img_ids"], ctx["d_img_cu"], ctx["img_max"], ctx["d_img_shapes"],
201
+ ctx["d_txt"], ctx["d_txt_ids"], ctx["d_txt_cu"], ctx["d_txt_mask"], ctx["d_txt_max"], ctx["d_vec"])
202
+ cond, unc = out[:, :n_img, :], out[:, n_img:, :]
203
+ else:
204
+ cond = _fwd(img, na, ctx["img_ids"], ctx["img_cu"], ctx["img_max"], ctx["img_shapes"],
205
+ ctx["txt"], ctx["txt_ids"], ctx["txt_cu"], ctx["txt_mask"], ctx["txt_max"], ctx["vec"])
206
+ unc = _fwd(img, na, ctx["img_ids"], ctx["img_cu"], ctx["img_max"], ctx["img_shapes"],
207
+ ctx["neg_txt"], ctx["neg_ids"], ctx["neg_cu"], ctx["neg_mask"], ctx["neg_max"], ctx["neg_vec"])
208
+
209
+ cfg = ctx["cfg"]
210
+ if ctx["renorm"]:
211
+ # CFG renormalization: rescale the guided velocity per token back to the
212
+ # conditional velocity's norm (reduces oversaturation at high cfg).
213
+ comb = unc + cfg * (cond - unc)
214
+ return comb * (torch.norm(cond, dim=-1, keepdim=True) /
215
+ (torch.norm(comb, dim=-1, keepdim=True) + 1e-6))
216
+ return unc + cfg * (cond - unc)
217
+
218
+
219
+ def _encode_texts_packed(model, prompts, template, drop_idx, device):
220
+ """Encode a LIST of templated text-only prompts in ONE packed varlen forward
221
+ (``TextEncoder.forward`` — varlen cu_seqlens isolates each prompt,
222
+ verified zero cross-contamination). Returns (txt_flat [ΣLi, D], vec [N, D],
223
+ per-prompt token lengths list)."""
224
+ tokenizer = model.txt_enc.tokenizer
225
+ max_len = model.txt_enc.tokenizer_max_length + drop_idx
226
+ ids_list = [
227
+ tokenizer(template.format(p), max_length=max_len, truncation=True,
228
+ return_tensors="pt").input_ids.squeeze(0)
229
+ for p in prompts
230
+ ]
231
+ input_ids = torch.cat(ids_list).to(device)
232
+ cu_seqlens = _lens_to_cu([int(t.numel()) for t in ids_list], device)
233
+ res = model.txt_enc(
234
+ input_ids, cu_seqlens, drop_idx_override=drop_idx)
235
+ return res["txt"], res["vec"], res["txt_seq_lens"].tolist()
236
+
237
+
238
+ def _slice_packed(txt_flat, vec, lens, start, count, device):
239
+ """Format a contiguous ``count``-prompt slice (starting at prompt ``start``) of a
240
+ packed text encode into the (txt [1, ΣL, D], cu_seqlens, ones-mask, vec [count, D])
241
+ tuple that ``_build_pack_ctx`` consumes."""
242
+ seg_lens = lens[start:start + count]
243
+ tok_start = sum(lens[:start])
244
+ tok_end = tok_start + sum(seg_lens)
245
+ txt = txt_flat[tok_start:tok_end].reshape(1, -1, txt_flat.shape[-1]).to(device)
246
+ return (txt, _lens_to_cu(seg_lens, device),
247
+ torch.ones(1, txt.shape[1], device=device), vec[start:start + count].to(device))
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Text-to-image (packed, multi-resolution)
252
+ # ---------------------------------------------------------------------------
253
+ @torch.no_grad()
254
+ def generate_images(model, prompts, neg_prompts=None, seeds=None, steps=30, cfg=5.0,
255
+ heights=None, widths=None, device="cuda",
256
+ prompt_template="mage-flow", static_shift=None,
257
+ gs_key=None,
258
+ renormalization=False, batch_cfg=True):
259
+ """Generate one image per prompt. Prompts may request DIFFERENT resolutions;
260
+ all are packed into a single varlen forward per denoise step — samples are
261
+ kept isolated by ``flash_attn_varlen_func`` via per-sample ``cu_seqlens`` (no
262
+ cross-sample attention), mirroring training-time packing. When ``cfg > 1`` and
263
+ ``batch_cfg`` is set, the positive and negative passes are fused into that
264
+ same varlen forward. Returns a list of PIL images aligned with ``prompts``.
265
+ """
266
+ if isinstance(prompts, str):
267
+ prompts = [prompts]
268
+ n = len(prompts)
269
+ neg_prompts = _as_list(neg_prompts, " ", n)
270
+ seeds = _as_list(seeds, 42, n)
271
+ heights = _as_list(heights, 1024, n)
272
+ widths = _as_list(widths, 1024, n)
273
+ info = _template_info(prompt_template)
274
+ template = info.get("template", "{}")
275
+ drop_idx = int(info.get("start_idx", 0))
276
+ dev = torch.device(device)
277
+
278
+ # Content-policy gate per sample (MANDATORY — runs on the same text-encoder
279
+ # weights as conditioning, no opt-out). Violating prompts get a refusal
280
+ # placeholder and are dropped from the pack.
281
+ results = [None] * n
282
+ active = []
283
+ for i in range(n):
284
+ if seeds[i] == -1:
285
+ seeds[i] = random.randint(0, 2**32 - 1)
286
+ verdict = model.txt_enc.screen_text(prompts[i])
287
+ if verdict.violates:
288
+ h_, w_ = _make_divisible_by_16(heights[i]), _make_divisible_by_16(widths[i])
289
+ print(verdict.banner())
290
+ results[i] = make_refusal_image(verdict, height=h_, width=w_)
291
+ continue
292
+ active.append(i)
293
+ if not active:
294
+ return results
295
+
296
+ gs_key_int = resolve_gs_key(gs_key)
297
+ # Per-sample noise tokens + position ids + shapes (MageVAE: flatten, no packing).
298
+ ch = model.vae.latent_channels
299
+ img_list, ids_list, lens, shapes, hw = [], [], [], [], []
300
+ for i in active:
301
+ h_, w_ = _make_divisible_by_16(heights[i]), _make_divisible_by_16(widths[i])
302
+ torch.manual_seed(seeds[i])
303
+ x = get_noise(num_samples=1, channel=ch, height=h_, width=w_,
304
+ device=dev, dtype=torch.bfloat16, seed=seeds[i])
305
+ # Distribution-preserving watermark in the initial noise (same shape,
306
+ # still ~N(0,1)); detect by inverting the flow ODE back to noise.
307
+ x = encode_noise(tuple(x.shape[1:]), key=gs_key_int,
308
+ seed=seeds[i], device=dev, dtype=torch.bfloat16)
309
+ _, _, gh, gw = x.shape
310
+ img_list.append(rearrange(x, "b c h w -> b (h w) c")[0])
311
+ ids = torch.zeros(gh, gw, 3, device=dev)
312
+ ids[..., 1] = ids[..., 1] + torch.arange(gh, device=dev)[:, None]
313
+ ids[..., 2] = ids[..., 2] + torch.arange(gw, device=dev)[None, :]
314
+ ids_list.append(rearrange(ids, "h w c -> (h w) c"))
315
+ lens.append(gh * gw); shapes.append((1, gh, gw)); hw.append((h_, w_))
316
+ img = torch.cat(img_list, 0).unsqueeze(0)
317
+ img_ids = torch.cat(ids_list, 0).unsqueeze(0)
318
+ img_cu = _lens_to_cu(lens, dev)
319
+ img_shapes = [shapes]
320
+
321
+ # Packed text: positive prompts AND (for CFG) negative prompts are encoded
322
+ # TOGETHER in ONE varlen forward, then split back — cu_seqlens keeps every
323
+ # prompt isolated (verified zero cross-contamination).
324
+ pos_prompts = [prompts[i] for i in active]
325
+ na = len(active)
326
+ use_neg = cfg > 1.0 and any(neg_prompts[i] for i in active)
327
+ if use_neg:
328
+ neg_list = [neg_prompts[i] or " " for i in active]
329
+ txt_flat, vec_all, lens_t = _encode_texts_packed(
330
+ model, pos_prompts + neg_list, template, drop_idx, dev)
331
+ txt, txt_cu, txt_mask, vec = _slice_packed(txt_flat, vec_all, lens_t, 0, na, dev)
332
+ neg_txt, neg_cu, neg_mask, neg_vec = _slice_packed(txt_flat, vec_all, lens_t, na, na, dev)
333
+ else:
334
+ txt_flat, vec_all, lens_t = _encode_texts_packed(model, pos_prompts, template, drop_idx, dev)
335
+ txt, txt_cu, txt_mask, vec = _slice_packed(txt_flat, vec_all, lens_t, 0, na, dev)
336
+ neg_txt = neg_cu = neg_mask = neg_vec = None
337
+
338
+ ctx = _build_pack_ctx(img_ids, img_cu, img_shapes, lens, txt, txt_cu, txt_mask, vec,
339
+ neg_txt, neg_cu, neg_mask, neg_vec, cfg, renormalization, batch_cfg, dev)
340
+ scheduler = _get_scheduler(model, steps, device, static_shift)
341
+ for si, t in enumerate(scheduler.timesteps):
342
+ pred = _velocity(model.transformer, img, ctx, scheduler.sigmas[si].item())
343
+ img = scheduler.step(pred, t, img, return_dict=False)[0]
344
+
345
+ off = 0
346
+ for k, i in enumerate(active):
347
+ L = lens[k]
348
+ h_, w_ = hw[k]
349
+ results[i] = _decode_one(model, img[:, off:off + L, :], h_, w_, dev)
350
+ off += L
351
+ return results
352
+
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # Image edit (packed, multi-resolution)
356
+ # ---------------------------------------------------------------------------
357
+ def _preprocess_ref_image(pil_img: Image.Image, height: int, width: int, device) -> torch.Tensor:
358
+ """Resize an RGB reference image to (height, width) and normalize to [-1, 1]."""
359
+ from torchvision.transforms import functional as TF
360
+ img = pil_img.convert("RGB")
361
+ img = TF.resize(img, [height, width], interpolation=TF.InterpolationMode.BICUBIC)
362
+ t = TF.to_tensor(img) # [3, H, W] in [0, 1]
363
+ t = TF.normalize(t, [0.5, 0.5, 0.5], [0.5, 0.5, 0.5]) # -> [-1, 1]
364
+ return t.to(device)
365
+
366
+
367
+ def _resize_long_edge(image: Image.Image, max_long_edge: int | None) -> Image.Image:
368
+ """Cap the VL conditioning image's long edge, preserving aspect ratio.
369
+
370
+ Matches training's data.processor._resize_long_edge (BICUBIC). Without this,
371
+ inference feeds a full-resolution image to the Qwen-VL processor whose
372
+ default max_pixels is far larger than 384**2 — a train/test mismatch.
373
+ """
374
+ if max_long_edge is None or max_long_edge <= 0:
375
+ return image
376
+ w, h = image.size
377
+ long_edge = max(w, h)
378
+ if long_edge <= max_long_edge:
379
+ return image
380
+ scale = max_long_edge / long_edge
381
+ new_w = max(1, int(round(w * scale)))
382
+ new_h = max(1, int(round(h * scale)))
383
+ return image.resize((new_w, new_h), Image.BICUBIC)
384
+
385
+
386
+ # Fixed image placeholder used at edit training time (one per reference image).
387
+ _EDIT_IMAGE_PLACEHOLDER = "<|vision_start|><|image_pad|><|vision_end|>"
388
+
389
+
390
+ def _edit_prompt_body(instruction, num_refs):
391
+ """Training-time multi-reference prompt body: ``Image 1: <ph>Image 2: <ph>…{instruction}``."""
392
+ prefix = "".join(f"Image {j}: {_EDIT_IMAGE_PLACEHOLDER}" for j in range(1, num_refs + 1))
393
+ return prefix + instruction
394
+
395
+
396
+ def _encode_edits_packed(model, ref_pils_per_sample, instructions, template, drop_idx, device):
397
+ """Encode ALL image-conditioned edit instructions in ONE packed multimodal
398
+ varlen forward (pixel_values/image_grid_thw concatenated across samples,
399
+ cu_seqlens isolates each). Returns (txt_flat [ΣLi, D], vec [N, D], per-sample lens)."""
400
+ processor = model.txt_enc.processor
401
+ ids_list, pv_list, thw_list = [], [], []
402
+ for ref_pils, instr in zip(ref_pils_per_sample, instructions, strict=False):
403
+ formatted = template.format(_edit_prompt_body(instr, len(ref_pils)))
404
+ vl = processor(text=[formatted], images=list(ref_pils), padding=True, return_tensors="pt")
405
+ vl = {k: (v.to(device) if hasattr(v, "to") else v) for k, v in vl.items()}
406
+ ids_list.append(vl["input_ids"].squeeze(0))
407
+ if vl.get("pixel_values") is not None:
408
+ pv_list.append(vl["pixel_values"]); thw_list.append(vl["image_grid_thw"])
409
+ input_ids = torch.cat(ids_list).to(device)
410
+ cu = _lens_to_cu([int(t.numel()) for t in ids_list], device)
411
+ inputs = {"input_ids": input_ids, "cu_seqlens": cu}
412
+ if pv_list:
413
+ inputs["pixel_values"] = torch.cat(pv_list, dim=0)
414
+ inputs["image_grid_thw"] = torch.cat(thw_list, dim=0)
415
+ res = model.txt_enc(
416
+ input_ids, cu, inputs=inputs, drop_idx_override=drop_idx)
417
+ return res["txt"], res["vec"], res["txt_seq_lens"].tolist()
418
+
419
+
420
+ @torch.no_grad()
421
+ def generate_edits(model, prompts, ref_images, neg_prompts=None, seeds=None, steps=30, cfg=5.0,
422
+ max_size=None, heights=None, widths=None, device="cuda",
423
+ prompt_template="mage-flow-edit", static_shift=None,
424
+ gs_key=None,
425
+ vl_cond_long_edge=384,
426
+ renormalization=False, batch_cfg=True):
427
+ """Edit reference image(s) per prompt. Each ``ref_images[i]`` may be a single
428
+ image/path OR a list of source images (multi-image edit, like training —
429
+ trained with up to 3, but more are accepted) — all produce ONE edited output. Each sample's
430
+ ``[target, ref_1, …, ref_N]`` latent tokens are sequence-concatenated, and
431
+ all samples are packed into one varlen forward per denoise step.
432
+
433
+ Output resolution (derived from the first/primary reference of each sample):
434
+ if both ``heights[i]`` and ``widths[i]`` are given, use them; else if
435
+ ``max_size`` is given, the longest side is ``max_size`` and the short side
436
+ follows the reference's aspect ratio; otherwise the output keeps the source
437
+ image's own resolution. All references are VAE-encoded at that target size.
438
+ Returns a list of PIL images.
439
+ """
440
+ if isinstance(prompts, str):
441
+ prompts = [prompts]
442
+ ref_images = [ref_images]
443
+ n = len(prompts)
444
+ neg_prompts = _as_list(neg_prompts, " ", n)
445
+ seeds = _as_list(seeds, 42, n)
446
+ heights = _as_list(heights, None, n)
447
+ widths = _as_list(widths, None, n)
448
+ info = _template_info(prompt_template)
449
+ template = info.get("template", "{}")
450
+ drop_idx = int(info.get("start_idx", 0))
451
+ dev = torch.device(device)
452
+
453
+ # Normalize each sample's references to a list of 1..3 PIL images.
454
+ def _load_pil(r):
455
+ if isinstance(r, str):
456
+ r = Image.open(r)
457
+ return r.convert("RGB")
458
+
459
+ pils_per_sample = []
460
+ for r in ref_images:
461
+ refs = list(r) if isinstance(r, (list, tuple)) else [r]
462
+ if not refs:
463
+ raise ValueError("each edit sample needs at least one reference image")
464
+ pils_per_sample.append([_load_pil(x) for x in refs])
465
+
466
+ # Per-sample output resolution (from the first/primary reference) + content gate.
467
+ results = [None] * n
468
+ res_hw = [None] * n
469
+ active = []
470
+ for i in range(n):
471
+ res_hw[i] = _edit_target_size(pils_per_sample[i][0], max_size, heights[i], widths[i])
472
+ if seeds[i] == -1:
473
+ seeds[i] = random.randint(0, 2**32 - 1)
474
+ # Multimodal gate (MANDATORY): inspect the source image(s) AND the
475
+ # instruction, so NSFW / copyrighted-character / real-public-figure
476
+ # source photos are blocked even under an innocuous instruction.
477
+ verdict = model.txt_enc.screen_edit(prompts[i], pils_per_sample[i])
478
+ if verdict.violates:
479
+ h_, w_ = res_hw[i]
480
+ print(verdict.banner())
481
+ results[i] = make_refusal_image(verdict, height=h_, width=w_)
482
+ continue
483
+ active.append(i)
484
+ if not active:
485
+ return results
486
+
487
+ gs_key_int = resolve_gs_key(gs_key)
488
+
489
+ # Per sample: reference latent tokens (clean) + target noise tokens, plus the
490
+ # combined [target, ref_1, …, ref_N] position ids and shapes. ``target_idx``
491
+ # records where each sample's target tokens land in the packed sequence so we
492
+ # can slice the velocity and step only the target portion.
493
+ ch = model.vae.latent_channels
494
+ targets, refs, ids_list, shape_seq, samp_lens, tgt_lens, hw = [], [], [], [], [], [], []
495
+ target_idx_parts = []
496
+ off = 0
497
+ for i in active:
498
+ h_, w_ = res_hw[i]
499
+ torch.manual_seed(seeds[i]) # MageVAE.encode samples the posterior (global RNG)
500
+ # All references resized to the target resolution and VAE-encoded together.
501
+ ref_tensors = [_preprocess_ref_image(p, h_, w_, dev) for p in pils_per_sample[i]]
502
+ ref_tok, ref_shapes, ref_ids = model.compute_vae_encodings(ref_tensors, with_ids=True)
503
+ ref_tok = ref_tok.to(torch.bfloat16) # [1, N*Lr, C]
504
+ x = get_noise(num_samples=1, channel=ch, height=h_, width=w_,
505
+ device=dev, dtype=torch.bfloat16, seed=seeds[i])
506
+ x = encode_noise(tuple(x.shape[1:]), key=gs_key_int,
507
+ seed=seeds[i], device=dev, dtype=torch.bfloat16)
508
+ _, _, gh, gw = x.shape
509
+ tgt = rearrange(x, "b c h w -> b (h w) c") # [1, Lt, C]
510
+ tgt_ids = torch.zeros(gh, gw, 3, device=dev)
511
+ tgt_ids[..., 1] = tgt_ids[..., 1] + torch.arange(gh, device=dev)[:, None]
512
+ tgt_ids[..., 2] = tgt_ids[..., 2] + torch.arange(gw, device=dev)[None, :]
513
+ tgt_ids = rearrange(tgt_ids, "h w c -> (h w) c").unsqueeze(0)
514
+ lt, lr = tgt.shape[1], ref_tok.shape[1]
515
+ targets.append(tgt); refs.append(ref_tok)
516
+ ids_list.append(torch.cat([tgt_ids, ref_ids.to(dev)], dim=1)[0]) # [Lt + N*Lr, 3]
517
+ shape_seq.append((1, gh, gw)) # target frame idx 0
518
+ shape_seq.extend(s[0] for s in ref_shapes) # ref_j frame idx j
519
+ samp_lens.append(lt + lr); tgt_lens.append(lt); hw.append((h_, w_))
520
+ target_idx_parts.append(torch.arange(off, off + lt, device=dev))
521
+ off += lt + lr
522
+ img_ids = torch.cat(ids_list, 0).unsqueeze(0)
523
+ img_cu = _lens_to_cu(samp_lens, dev)
524
+ img_shapes = [shape_seq]
525
+ target_idx = torch.cat(target_idx_parts)
526
+
527
+ # Packed edit text — positive AND (for CFG) negative are encoded TOGETHER in
528
+ # ONE packed multimodal forward, then split. Both branches share the same
529
+ # reference images; cu_seqlens isolates every sequence (zero cross-contamination).
530
+ # The VL conditioning image's long edge is capped (default 384) to match
531
+ # training preprocessing — the VAE path above keeps the full target resolution.
532
+ na = len(active)
533
+ edit_refs = [[_resize_long_edge(p, vl_cond_long_edge) for p in pils_per_sample[i]]
534
+ for i in active]
535
+ if cfg > 1.0:
536
+ pos_instr = [prompts[i] for i in active]
537
+ neg_instr = [neg_prompts[i] or " " for i in active]
538
+ txt_flat, vec_all, lens_t = _encode_edits_packed(
539
+ model, edit_refs + edit_refs, pos_instr + neg_instr, template, drop_idx, dev)
540
+ txt, txt_cu, txt_mask, vec = _slice_packed(txt_flat, vec_all, lens_t, 0, na, dev)
541
+ neg_txt, neg_cu, neg_mask, neg_vec = _slice_packed(txt_flat, vec_all, lens_t, na, na, dev)
542
+ else:
543
+ txt_flat, vec_all, lens_t = _encode_edits_packed(
544
+ model, edit_refs, [prompts[i] for i in active], template, drop_idx, dev)
545
+ txt, txt_cu, txt_mask, vec = _slice_packed(txt_flat, vec_all, lens_t, 0, na, dev)
546
+ neg_txt = neg_cu = neg_mask = neg_vec = None
547
+
548
+ ctx = _build_pack_ctx(img_ids, img_cu, img_shapes, samp_lens, txt, txt_cu, txt_mask, vec,
549
+ neg_txt, neg_cu, neg_mask, neg_vec, cfg, renormalization, batch_cfg, dev)
550
+ scheduler = _get_scheduler(model, steps, device, static_shift)
551
+ for si, t in enumerate(scheduler.timesteps):
552
+ parts = []
553
+ for k in range(na):
554
+ parts.append(targets[k]); parts.append(refs[k])
555
+ img = torch.cat(parts, dim=1) # [1, sum(Lt+Lr), C], ref clean
556
+ vel = _velocity(model.transformer, img, ctx, scheduler.sigmas[si].item())
557
+ pred_t = vel[:, target_idx, :] # [1, sum Lt, C] — target tokens only
558
+ tgt_packed = torch.cat(targets, dim=1) # [1, sum Lt, C]
559
+ stepped = scheduler.step(pred_t, t, tgt_packed, return_dict=False)[0]
560
+ o = 0
561
+ new_targets = []
562
+ for k in range(na):
563
+ lt = tgt_lens[k]
564
+ new_targets.append(stepped[:, o:o + lt, :]); o += lt
565
+ targets = new_targets
566
+
567
+ for k, i in enumerate(active):
568
+ h_, w_ = hw[k]
569
+ results[i] = _decode_one(model, targets[k], h_, w_, dev)
570
+ return results
571
+
572
+
573
+ # ---------------------------------------------------------------------------
574
+ # Flow-ODE inversion (Gaussian-Shading watermark detection)
575
+ # ---------------------------------------------------------------------------
576
+ @torch.no_grad()
577
+ def invert_to_noise(model, z0, height, width, steps=30, device="cuda",
578
+ prompt_template="mage-flow", static_shift=None, prompt=""):
579
+ """Reverse the flow ODE from a clean latent ``z0`` back to the initial noise.
580
+
581
+ This is the detection primitive for the Gaussian-Shading watermark: VAE-encode
582
+ the image to ``z0`` (posterior MEAN — deterministic), run this to recover the
583
+ initial noise, then read the signs via ``mage_latent.decode_bits``.
584
+
585
+ Inversion uses an empty prompt at cfg=1 (the standard Tree-Ring /
586
+ Gaussian-Shading setup). Reverse Euler recovers ``x_i`` from ``x_{i+1}`` with
587
+ the velocity evaluated at the point in hand; the sign-only watermark tolerates
588
+ the resulting approximation error (see the module's redundancy).
589
+
590
+ Args:
591
+ z0: clean latent ``[1, C, gh, gw]`` (e.g. the mean of ``model.vae.encode``).
592
+ Returns:
593
+ recovered initial-noise latent ``[1, C, gh, gw]`` (float32).
594
+ """
595
+ dev = torch.device(device)
596
+ info = _template_info(prompt_template)
597
+ template = info.get("template", "{}")
598
+ drop_idx = int(info.get("start_idx", 0))
599
+
600
+ z0 = z0.to(dev)
601
+ _, ch, gh, gw = z0.shape
602
+ img = rearrange(z0, "b c h w -> b (h w) c").to(torch.bfloat16) # [1, gh*gw, C]
603
+
604
+ ids = torch.zeros(gh, gw, 3, device=dev)
605
+ ids[..., 1] = ids[..., 1] + torch.arange(gh, device=dev)[:, None]
606
+ ids[..., 2] = ids[..., 2] + torch.arange(gw, device=dev)[None, :]
607
+ img_ids = rearrange(ids, "h w c -> (h w) c").unsqueeze(0)
608
+ lens = [gh * gw]
609
+ img_cu = _lens_to_cu(lens, dev)
610
+ img_shapes = [[(1, gh, gw)]]
611
+
612
+ # Empty-prompt conditioning, no negative branch, cfg=1 (single forward).
613
+ txt_flat, vec_all, lens_t = _encode_texts_packed(model, [prompt], template, drop_idx, dev)
614
+ txt, txt_cu, txt_mask, vec = _slice_packed(txt_flat, vec_all, lens_t, 0, 1, dev)
615
+ ctx = _build_pack_ctx(img_ids, img_cu, img_shapes, lens, txt, txt_cu, txt_mask, vec,
616
+ None, None, None, None, 1.0, False, False, dev)
617
+
618
+ scheduler = _get_scheduler(model, steps, device, static_shift)
619
+ sigmas = scheduler.sigmas
620
+ n = len(scheduler.timesteps)
621
+ # Forward step si: x_{si+1} = x_si + (s_{si+1}-s_si)·v(x_si, s_si).
622
+ # Reverse it from clean (x_n, sigma 0) up to noise (x_0), using x_{si+1} as the
623
+ # proxy for x_si at the forward eval sigma s_si.
624
+ for si in range(n - 1, -1, -1):
625
+ s_cur = sigmas[si].item()
626
+ s_next = sigmas[si + 1].item()
627
+ vel = _velocity(model.transformer, img, ctx, s_cur)
628
+ img = img - (s_next - s_cur) * vel
629
+ return unpack(img.float(), height, width) # [1, C, gh, gw]
630
+
631
+
632
+ # ---------------------------------------------------------------------------
633
+ # High-level pipeline wrapper
634
+ # ---------------------------------------------------------------------------
635
+ class MageFlowPipeline:
636
+ """``MageFlowPipeline.from_pretrained(repo).generate(...) / .edit(...)``.
637
+
638
+ ``generate`` / ``edit`` are packed multi-resolution calls: they take a list
639
+ of prompts (a single string is accepted and treated as a pack of size 1) and
640
+ return a list of PIL images. Per-sample ``heights``/``widths``/``seeds`` are
641
+ lists. Every prompt is screened by the text encoder's mandatory content
642
+ gate (no opt-out); banned prompts come back as refusal placeholders
643
+ interleaved with the real images. Real outputs always carry a Gaussian-Shading
644
+ watermark in the initial noise (no toggle), using the configured secret key.
645
+ """
646
+
647
+ def __init__(self, model, device="cuda"):
648
+ self.model = model
649
+ self.device = device
650
+
651
+ @classmethod
652
+ def from_pretrained(cls, repo_dir: str, device: str = "cuda"):
653
+ """Load a Mage-Flow diffusers-style repo (``model_index.json`` +
654
+ ``transformer/`` ``vae/`` ``scheduler/`` ``text_encoder/``).
655
+
656
+ ``repo_dir`` may be a local directory OR a Hugging Face Hub repo id
657
+ (e.g. ``"microsoft/Mage-Flow-4B"``), which is downloaded and cached
658
+ automatically on first use.
659
+ """
660
+ return cls(load_from_repo(repo_dir, device), device)
661
+
662
+ def generate(self, prompts, **kw) -> list[Image.Image]:
663
+ """Packed multi-resolution t2i. ``prompts`` is a list (or a single
664
+ string); pass per-sample ``heights``/``widths``/``seeds`` as lists."""
665
+ kw.setdefault("device", self.device)
666
+ return generate_images(self.model, prompts, **kw)
667
+
668
+ def edit(self, prompts, ref_images, **kw) -> list[Image.Image]:
669
+ """Packed multi-resolution edit. ``prompts`` is a list (or a single
670
+ string); each ``ref_images[i]`` is one reference or a list of references."""
671
+ kw.setdefault("device", self.device)
672
+ return generate_edits(self.model, prompts, ref_images, **kw)
673
+
674
+ def invert_to_noise(self, z0, height, width, **kw):
675
+ """Recover the initial noise from a clean latent (Gaussian-Shading detect)."""
676
+ kw.setdefault("device", self.device)
677
+ return invert_to_noise(self.model, z0, height, width, **kw)
678
+
679
+
680
+ def _safe_subpath(root: str, *parts: str) -> str:
681
+ """Join ``parts`` under ``root`` and confirm the result stays inside ``root``.
682
+
683
+ ``root`` is normalized up front; the joined path is normalized **lexically**
684
+ (``os.path.normpath`` — symlinks are *not* followed, so a Hugging Face cache
685
+ whose weight files are symlinks into the shared blob store still loads) and
686
+ rejected if it escapes ``root``. This guards the user-supplied model path
687
+ against path traversal (CWE-22 / CodeQL ``py/path-injection``).
688
+ """
689
+ root = os.path.realpath(root)
690
+ full = os.path.normpath(os.path.join(root, *parts))
691
+ if full != root and not full.startswith(root + os.sep):
692
+ raise ValueError(
693
+ f"Resolved path {os.path.join(*parts)!r} escapes repo directory {root!r}"
694
+ )
695
+ return full
696
+
697
+
698
+ def _resolve_repo_dir(repo_dir: str) -> str:
699
+ """Return a local directory for ``repo_dir``.
700
+
701
+ If ``repo_dir`` is an existing local path it is returned as a normalized
702
+ absolute path; otherwise it is treated as a Hugging Face Hub repo id (e.g.
703
+ ``microsoft/Mage-Flow``) and downloaded/cached via
704
+ ``huggingface_hub.snapshot_download``.
705
+ """
706
+ candidate = os.path.realpath(repo_dir)
707
+ if os.path.isdir(candidate):
708
+ return candidate
709
+ from huggingface_hub import snapshot_download
710
+ return snapshot_download(repo_id=repo_dir)
711
+
712
+
713
+ def load_from_repo(repo_dir: str, device: str = "cuda") -> MageFlowModel:
714
+ """Load a Mage-Flow diffusers-style repo (model_index.json + transformer/
715
+ vae/ scheduler/). Transformer weights come from the bf16 safetensors;
716
+ VAE + text encoder are built from the sources recorded in model_index.json.
717
+
718
+ ``repo_dir`` may be a local directory OR a Hugging Face Hub repo id (e.g.
719
+ ``microsoft/Mage-Flow-4B``), which is downloaded/cached automatically.
720
+ """
721
+ from safetensors.torch import load_file
722
+ repo_dir = _resolve_repo_dir(repo_dir)
723
+ mi = json.load(open(_safe_subpath(repo_dir, "model_index.json")))
724
+ tcfg = json.load(open(_safe_subpath(repo_dir, "transformer", "config.json")))
725
+ # Keys stripped from the checkpoint config before it becomes model_structure.
726
+ # ``schedule_mode`` is a legacy field still present in some config.json files;
727
+ # Keys of the checkpoint config that are NOT MageFlowParams constructor args
728
+ # (legacy/unused fields). Everything else becomes model_structure. The DiT only
729
+ # reads: in_channels, out_channels, context_in_dim, hidden_size, num_heads,
730
+ # depth, axes_dim, checkpoint, patch_size.
731
+ _meta = {"_class_name", "txt_max_length", "max_sequence_length", "param_dtype",
732
+ "packing", "schedule_mode", "static_shift", "use_time_shift",
733
+ "rope_type", "apply_text_rotary_emb",
734
+ "mlp_ratio", "depth_single_blocks", "theta", "qkv_bias", "guidance_embed",
735
+ "vec_in_dim", "vec_type", "time_type", "double_block_type"}
736
+ structure = {k: v for k, v in tcfg.items() if k not in _meta}
737
+
738
+ def _resolve(p):
739
+ return p if os.path.isabs(p) else _safe_subpath(repo_dir, p)
740
+
741
+ cfg = ModelConfig(
742
+ vae_path=_resolve(mi.get("_vae_source")),
743
+ txt_enc_path=_resolve(mi.get("_text_encoder_path")),
744
+ model_structure=structure,
745
+ txt_max_length=tcfg.get("txt_max_length", 2048),
746
+ packing=tcfg.get("packing", True),
747
+ static_shift=tcfg.get("static_shift", 6.0),
748
+ )
749
+ model = MageFlowModel(cfg)
750
+ sd = load_file(_safe_subpath(repo_dir, "transformer", "diffusion_pytorch_model.safetensors"),
751
+ device="cpu")
752
+ model.transformer.load_state_dict(sd, strict=False, assign=True)
753
+ model.to(device)
754
+ model.transformer.to(torch.bfloat16)
755
+ model.txt_enc.to(torch.bfloat16)
756
+ if model.vae is not None:
757
+ model.vae.to(torch.bfloat16)
758
+ model.eval()
759
+ # Diffusers FlowMatchEulerDiscreteScheduler (scheduler/scheduler_config.json).
760
+ model.scheduler = FlowMatchEulerDiscreteScheduler.from_pretrained(
761
+ _safe_subpath(repo_dir, "scheduler"))
762
+ return model